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
@@ -9,6 +9,7 @@ const path_1 = __importDefault(require("path"));
9
9
  const child_process_1 = require("child_process");
10
10
  const checks_1 = require("../../../src/commands/preflight/checks");
11
11
  const preflight_1 = require("../../../src/commands/preflight");
12
+ const run_1 = require("../../../src/commands/sensors/run");
12
13
  // Only `execSync` (used by `resolveOnPath` to check for `gh`/`glab`) is mocked — `git
13
14
  // remote get-url origin` runs for real via `execFileSync` against real tmpdir git repos,
14
15
  // same as every other check in this file exercises the real filesystem.
@@ -16,7 +17,12 @@ jest.mock('child_process', () => ({
16
17
  ...jest.requireActual('child_process'),
17
18
  execSync: jest.fn(),
18
19
  }));
20
+ jest.mock('../../../src/commands/sensors/run', () => ({
21
+ ...jest.requireActual('../../../src/commands/sensors/run'),
22
+ runSensors: jest.fn(),
23
+ }));
19
24
  const mockExecSync = child_process_1.execSync;
25
+ const mockRunSensors = run_1.runSensors;
20
26
  /** Turn a tmpdir into a real git repo with (optionally) an `origin` remote. */
21
27
  function gitRepo(dir, remoteUrl) {
22
28
  (0, child_process_1.execFileSync)('git', ['init'], { cwd: dir, stdio: 'pipe' });
@@ -47,6 +53,50 @@ const make = (o) => { const d = project(o); dirs.push(d); return d; };
47
53
  afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
48
54
  const check = (r, id) => r.checks.find(c => c.id === id);
49
55
  describe('preflight', () => {
56
+ afterEach(() => mockRunSensors.mockReset());
57
+ it('keeps default preflight static and does not dispatch sensors', async () => {
58
+ const dir = make({
59
+ manifest: { pack: 'generic', sensors: { security: { enabled: false } } },
60
+ });
61
+ await (0, checks_1.preflight)(dir);
62
+ expect(mockRunSensors).not.toHaveBeenCalled();
63
+ });
64
+ it('rejects an array passed as public preflight options before filesystem work', async () => {
65
+ await expect((0, checks_1.preflight)(process.cwd(), []))
66
+ .rejects.toThrow('preflight options must contain an optional boolean verifySensors');
67
+ });
68
+ it('requires an empirical sensor pass when verification is requested', async () => {
69
+ const dir = make({
70
+ manifest: { pack: 'generic', sensors: { security: { enabled: false } } },
71
+ });
72
+ mockRunSensors.mockResolvedValue({
73
+ overall: 'not_certified',
74
+ sensors: [{
75
+ name: 'lint', status: 'inconclusive', errors: [], skipReason: 'timeout after 30000ms',
76
+ execution: { timeoutMs: 30000, timeoutSource: 'project', elapsedMs: 30012, requestedScope: 'full', effectiveScope: 'full' },
77
+ }],
78
+ });
79
+ const report = await (0, checks_1.preflight)(dir, { verifySensors: true });
80
+ expect(mockRunSensors).toHaveBeenCalledWith({ cwd: dir, all: true });
81
+ expect(report.status).toBe('degraded');
82
+ expect(check(report, 'sensors-execution')).toMatchObject({
83
+ ok: false,
84
+ detail: expect.stringMatching(/lint.*30000ms.*elapsed.*timeout/i),
85
+ });
86
+ });
87
+ it('does not invent a sensor or timeout when empirical verification has no executed sensors', async () => {
88
+ const dir = make();
89
+ mockRunSensors.mockResolvedValue({ overall: 'not_certified', sensors: [] });
90
+ const report = await (0, checks_1.preflight)(dir, { verifySensors: true });
91
+ const execution = check(report, 'sensors-execution');
92
+ expect(execution).toMatchObject({
93
+ ok: false,
94
+ detail: 'sensor verdict was not_certified; no sensor established an empirical pass',
95
+ });
96
+ expect(execution.detail).not.toMatch(/timeout|elapsed|named sensor/i);
97
+ expect(execution.remedy).toContain('awm sensors init');
98
+ expect(execution.remedy).not.toContain('named sensor');
99
+ });
50
100
  it('reports not_configured when no sensor manifest exists', async () => {
51
101
  // The team-rollout case: a developer clones the repo and never runs
52
102
  // `awm sensors init`. Today nothing notices until an unattended run is already
@@ -7,6 +7,7 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const baseline_1 = require("../../../src/commands/sensors/baseline");
10
+ const result_1 = require("../../../src/commands/sensors/result");
10
11
  const err = (over = {}) => ({
11
12
  file: 'lib/a.ts', rule: 'TS2345', message: 'Argument of type X', ...over,
12
13
  });
@@ -76,6 +77,19 @@ describe('partition', () => {
76
77
  expect(newErrors).toHaveLength(0);
77
78
  });
78
79
  });
80
+ describe('applyBaseline', () => {
81
+ it('applies the same baseline to structured findings (R2)', () => {
82
+ const finding = { file: 'src/a.ts', line: 1, message: 'x' };
83
+ const accepted = [(0, baseline_1.fingerprint)('lint', finding)];
84
+ expect((0, result_1.applyBaseline)({ name: 'lint', status: 'fail', errors: [finding] }, accepted))
85
+ .toMatchObject({ status: 'pass', baselineCount: 1, newCount: 0 });
86
+ });
87
+ test.each(['inconclusive', 'skipped'])('baseline never changes %s to pass (R2.1)', status => {
88
+ const finding = { file: 'src/a.ts', line: 1, message: 'fixture finding' };
89
+ const accepted = [(0, baseline_1.fingerprint)('lint', finding)];
90
+ expect((0, result_1.applyBaseline)({ name: 'lint', status, errors: [finding], skipReason: 'fixture' }, accepted).status).toBe(status);
91
+ });
92
+ });
79
93
  describe('readBaseline / writeBaseline', () => {
80
94
  let cwd;
81
95
  beforeEach(() => { cwd = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bl-')); });
@@ -41,4 +41,7 @@ describe('applyChangedCmd — Windows quoting', () => {
41
41
  expect((0, changed_1.applyChangedCmd)('eslint {files}', ['report\\']))
42
42
  .toBe(`eslint "report\\\\"`);
43
43
  });
44
+ it('refuses unsafe filenames for legacy shell interpolation', () => {
45
+ expect((0, changed_1.changedScopeError)({ files: ['src/a&b.ts'] })).toMatch(/cmd\.exe metacharacter/);
46
+ });
44
47
  });
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const contract_1 = require("../../../../src/commands/sensors/compatibility/contract");
7
+ const timeout_1 = require("../../../../src/commands/sensors/compatibility/timeout");
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const os_1 = __importDefault(require("os"));
9
10
  const path_1 = __importDefault(require("path"));
@@ -43,6 +44,22 @@ function validPack() {
43
44
  };
44
45
  }
45
46
  describe('sensor pack v2 contract', () => {
47
+ it('exports bounded timeout validation and resolution (R3.1, R3.4)', () => {
48
+ expect((0, timeout_1.positiveTimeout)(1, 'sensor timeout')).toBe(1);
49
+ expect((0, timeout_1.resolveTimeout)({ project: 90_000, pack: 30_000, fast: true })).toEqual({ timeoutMs: 90_000, source: 'project' });
50
+ expect((0, timeout_1.resolveTimeout)({ pack: 30_000, fast: true })).toEqual({ timeoutMs: 30_000, source: 'pack' });
51
+ expect((0, timeout_1.resolveTimeout)({ fast: true })).toEqual({ timeoutMs: 10_000, source: 'fallback' });
52
+ expect((0, timeout_1.resolveTimeout)({ fast: false })).toEqual({ timeoutMs: 120_000, source: 'fallback' });
53
+ });
54
+ test.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1000'])('rejects invalid resolved timeout %p (R3.3)', timeout => {
55
+ expect(() => (0, timeout_1.positiveTimeout)(timeout, 'sensor timeout')).toThrow(/sensor timeout.*positive safe integer/);
56
+ expect(() => (0, timeout_1.resolveTimeout)({ project: timeout, fast: true })).toThrow(/project timeout.*positive safe integer/);
57
+ });
58
+ it('rejects malformed timeout helper inputs loudly (R3.4)', () => {
59
+ expect(() => (0, timeout_1.positiveTimeout)(1000, '')).toThrow('timeout location must be a nonempty string');
60
+ expect(() => (0, timeout_1.resolveTimeout)(null)).toThrow('timeout resolution input is invalid');
61
+ expect(() => (0, timeout_1.resolveTimeout)({ fast: 'true' })).toThrow('timeout resolution input is invalid');
62
+ });
46
63
  it('derives Semgrep compatibility from a contained shared policy reference', () => {
47
64
  const sensorPacks = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-semgrep-policy-'));
48
65
  const packDir = path_1.default.join(sensorPacks, 'python');
@@ -79,6 +96,40 @@ describe('sensor pack v2 contract', () => {
79
96
  it('parses a valid versioned pack', () => {
80
97
  expect((0, contract_1.parseSensorPack)(validPack(), 'pack.json')).toMatchObject({ kind: 'v2', pack: validPack() });
81
98
  });
99
+ it('accepts one standalone files placeholder in changedCommand (R4)', () => {
100
+ const pack = validPack();
101
+ pack.sensors.lint.variants[0].changedCommand = {
102
+ executable: 'eslint', resolution: 'node-modules-bin',
103
+ args: ['--format', 'json', '{files}'],
104
+ fileInput: { placeholder: '{files}', extensions: ['.js', '.ts'] },
105
+ };
106
+ expect((0, contract_1.parseSensorPack)(pack, '/registry/sensor-packs/js-ts/pack.json')).toMatchObject({
107
+ kind: 'v2', pack: { sensors: { lint: { variants: [{ changedCommand: { args: ['--format', 'json', '{files}'] } }] } } },
108
+ });
109
+ });
110
+ test.each([
111
+ { args: ['{files}', '{files}'], fileInput: { placeholder: '{files}', extensions: ['.ts'] } },
112
+ { args: ['prefix-{files}'], fileInput: { placeholder: '{files}', extensions: ['.ts'] } },
113
+ { args: ['{files}'], fileInput: { placeholder: '{files}', extensions: [] } },
114
+ ])('rejects unsafe changedCommand %# (R4)', changedCommand => {
115
+ const pack = validPack();
116
+ pack.sensors.lint.variants[0].changedCommand = {
117
+ executable: 'eslint', resolution: 'node-modules-bin', ...changedCommand,
118
+ };
119
+ expect(() => (0, contract_1.parseSensorPack)(pack, '/registry/sensor-packs/js-ts/pack.json')).toThrow(/changedCommand|fileInput|\{files\}/);
120
+ });
121
+ test.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1000'])('rejects pack sensor timeout %p (R3.3)', timeout => {
122
+ const pack = validPack();
123
+ pack.sensors.lint.timeout = timeout;
124
+ expect(() => (0, contract_1.parseSensorPack)(pack, '/registry/sensor-packs/js-ts/pack.json')).toThrow(/timeout.*positive safe integer/);
125
+ });
126
+ it('accepts an optional positive pack sensor timeout (R3.1)', () => {
127
+ const pack = validPack();
128
+ pack.sensors.lint.timeout = 30_000;
129
+ expect((0, contract_1.parseSensorPack)(pack, '/registry/sensor-packs/js-ts/pack.json')).toMatchObject({
130
+ kind: 'v2', pack: { sensors: { lint: { timeout: 30_000 } } },
131
+ });
132
+ });
82
133
  it('accepts an opt-in hardening asset while variants may require no assets', () => {
83
134
  const pack = {
84
135
  ...validPack(),
@@ -6,6 +6,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const manifest_1 = require("../../../../src/commands/sensors/compatibility/manifest");
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ function validV2Manifest() {
10
+ return {
11
+ schemaVersion: 2,
12
+ pack: 'js-ts',
13
+ sensors: {
14
+ lint: {
15
+ enabled: true, variantId: 'eslint-9',
16
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.', '--format', 'json'] },
17
+ initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-9', toolVersion: '9.0.0', runtimeVersion: '24.0.0', certifiedRange: '>=9 <10', evidence: [] },
18
+ },
19
+ },
20
+ };
21
+ }
9
22
  describe('sensor manifest contract', () => {
10
23
  it('keeps compatibility contracts on an acyclic import boundary', () => {
11
24
  const source = (relative) => fs_1.default.readFileSync(path_1.default.join(__dirname, '../../../../src/commands/sensors', relative), 'utf8');
@@ -21,20 +34,23 @@ describe('sensor manifest contract', () => {
21
34
  } });
22
35
  });
23
36
  it('accepts a v2 selected variant and structured command', () => {
24
- const manifest = {
25
- schemaVersion: 2,
26
- pack: 'js-ts',
27
- sensors: {
28
- lint: {
29
- enabled: true, variantId: 'eslint-9',
30
- command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.', '--format', 'json'] },
31
- initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-9', toolVersion: '9.0.0', runtimeVersion: '24.0.0', certifiedRange: '>=9 <10', evidence: [] },
32
- },
33
- },
34
- };
37
+ const manifest = validV2Manifest();
35
38
  expect((0, manifest_1.parseSensorManifest)(manifest, 'sensors.json')).toMatchObject({ kind: 'v2', pack: manifest });
36
39
  expect(JSON.parse((0, manifest_1.serializeManifestV2)(manifest))).toEqual(manifest);
37
40
  });
41
+ it('accepts and serializes a positive v2 project timeout (R3)', () => {
42
+ const manifest = validV2Manifest();
43
+ manifest.sensors.lint.timeout = 45_000;
44
+ expect((0, manifest_1.parseSensorManifest)(manifest, '/project/.awm/sensors.json')).toMatchObject({
45
+ kind: 'v2', pack: { sensors: { lint: { timeout: 45_000 } } },
46
+ });
47
+ expect(JSON.parse((0, manifest_1.serializeManifestV2)(manifest))).toEqual(manifest);
48
+ });
49
+ test.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, '1000'])('rejects v2 timeout %p before execution (R3.3)', timeout => {
50
+ const manifest = validV2Manifest();
51
+ manifest.sensors.lint.timeout = timeout;
52
+ expect(() => (0, manifest_1.parseSensorManifest)(manifest, '/project/.awm/sensors.json')).toThrow(/timeout.*positive safe integer/);
53
+ });
38
54
  it('persists only an explicit v2 pack selection as applicability provenance', () => {
39
55
  const manifest = {
40
56
  schemaVersion: 2, pack: 'generic', packSelection: 'explicit',
@@ -1,7 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const probe_1 = require("../../../../src/commands/sensors/compatibility/probe");
4
- const fakeExecutor = jest.fn(async () => ({ code: 0, signal: null, timedOut: false, overflowed: false, stdout: 'SECRET_VALUE\nmatched', stderr: '' }));
4
+ const execResult = (overrides = {}) => ({
5
+ code: 0,
6
+ signal: null,
7
+ timedOut: false,
8
+ overflowed: false,
9
+ elapsedMs: 0,
10
+ stdout: 'SECRET_VALUE\nmatched',
11
+ stderr: '',
12
+ ...overrides,
13
+ });
14
+ const fakeExecutor = jest.fn(async () => execResult());
5
15
  const evidence = { cwd: process.cwd(), configFiles: ['eslint.config.js'], scripts: ['lint'] };
6
16
  describe('runCompatibilityProbe', () => {
7
17
  beforeEach(() => fakeExecutor.mockClear());
@@ -11,9 +21,9 @@ describe('runCompatibilityProbe', () => {
11
21
  expect(JSON.stringify(result)).not.toContain('SECRET_VALUE');
12
22
  });
13
23
  it('never treats a timeout or overflow as a match', async () => {
14
- fakeExecutor.mockResolvedValueOnce({ code: null, signal: 'SIGKILL', timedOut: true, overflowed: false, stdout: '', stderr: '' });
24
+ fakeExecutor.mockResolvedValueOnce(execResult({ code: null, signal: 'SIGKILL', timedOut: true, stdout: '' }));
15
25
  await expect((0, probe_1.runCompatibilityProbe)({ kind: 'version' }, evidence, fakeExecutor)).resolves.toMatchObject({ status: 'unverifiable' });
16
- fakeExecutor.mockResolvedValueOnce({ code: 0, signal: null, timedOut: false, overflowed: true, stdout: 'ok', stderr: '' });
26
+ fakeExecutor.mockResolvedValueOnce(execResult({ overflowed: true, stdout: 'ok' }));
17
27
  await expect((0, probe_1.runCompatibilityProbe)({ kind: 'version' }, evidence, fakeExecutor)).resolves.toMatchObject({ status: 'unverifiable' });
18
28
  });
19
29
  it('binds tool probes to the project node_modules executable instead of PATH', async () => {
@@ -6,7 +6,7 @@ exports.spawnFailed = exports.overflowed = exports.timedOut = exports.exited = e
6
6
  * mock the exec boundary rather than `child_process` directly: `runCommand`
7
7
  * never throws, so a mocked run is a value, not an exception.
8
8
  */
9
- const base = { stdout: '', stderr: '', code: null, signal: null, timedOut: false, overflowed: false };
9
+ const base = { stdout: '', stderr: '', code: null, signal: null, timedOut: false, overflowed: false, elapsedMs: 0 };
10
10
  /** Clean run: exit 0. */
11
11
  const ok = (stdout = '') => ({ ...base, stdout, code: 0 });
12
12
  exports.ok = ok;
@@ -7,6 +7,17 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const exec_1 = require("../../../src/commands/sensors/exec");
10
+ const result_1 = require("../../../src/commands/sensors/result");
11
+ const sensor = (overrides = {}) => ({
12
+ name: 'lint',
13
+ command: { kind: 'legacy', value: 'node -e "setTimeout(() => {}, 1000)"' },
14
+ formatter: 'generic',
15
+ timeoutMs: 5_000,
16
+ timeoutSource: 'fallback',
17
+ requestedScope: 'full',
18
+ effectiveScope: 'full',
19
+ ...overrides,
20
+ });
10
21
  const onPosix = process.platform !== 'win32' ? describe : describe.skip;
11
22
  const itPosix = process.platform !== 'win32' ? it : it.skip;
12
23
  /** Poll until `fn()` is true or the budget runs out. Avoids fixed sleeps. */
@@ -20,6 +31,12 @@ async function until(fn, budgetMs = 4000) {
20
31
  return fn();
21
32
  }
22
33
  describe('runCommand — exit codes and output', () => {
34
+ it('records bounded execution evidence on timeout (R3.2,R3.4,R7.1)', async () => {
35
+ const result = await (0, result_1.executePrepared)(sensor({ timeoutMs: 25, timeoutSource: 'project' }));
36
+ expect(result.status).toBe('inconclusive');
37
+ expect(result.execution).toMatchObject({ timeoutMs: 25, timeoutSource: 'project', effectiveScope: 'full' });
38
+ expect(result.execution.elapsedMs).toBeGreaterThanOrEqual(0);
39
+ });
23
40
  it('passes structured metacharacters literally without a shell', async () => {
24
41
  const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-exec-argv-'));
25
42
  const marker = path_1.default.join(dir, 'must-not-exist');
@@ -15,18 +15,56 @@ const prompts_1 = require("@clack/prompts");
15
15
  const coverage_1 = require("../../../src/commands/sensors/coverage");
16
16
  const render_1 = require("../../../src/commands/sensors/coverage/render");
17
17
  const index_1 = require("../../../src/commands/sensors/index");
18
- describe('exitCodeFor sensor run verdict → exit code', () => {
19
- const base = (overall) => ({ sensors: [], overall });
20
- it('pass 0', () => expect((0, index_1.exitCodeFor)(base('pass'))).toBe(0));
21
- it('skipped0', () => expect((0, index_1.exitCodeFor)(base('skipped'))).toBe(0));
22
- it('not_certified → 0 (signal is in overall, not exit code)', () => expect((0, index_1.exitCodeFor)(base('not_certified'))).toBe(0));
23
- it('fail → 1', () => expect((0, index_1.exitCodeFor)(base('fail'))).toBe(1));
18
+ const verdict_1 = require("../../../src/commands/sensors/verdict");
19
+ const processExit = jest.spyOn(process, 'exit').mockImplementation((() => undefined));
20
+ const stdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
21
+ describe('exitCodeForVerdict — sensor run verdict exit code', () => {
22
+ it.each([
23
+ ['pass', 0],
24
+ ['fail', 1],
25
+ ['skipped', 1],
26
+ ['not_certified', 1],
27
+ ])('%s → %i', (overall, code) => {
28
+ expect((0, verdict_1.exitCodeForVerdict)(overall)).toBe(code);
29
+ });
30
+ });
31
+ describe('sensors run Commander wiring', () => {
32
+ const originalExitCode = process.exitCode;
33
+ beforeEach(() => {
34
+ jest.clearAllMocks();
35
+ process.exitCode = undefined;
36
+ });
37
+ afterAll(() => {
38
+ process.exitCode = originalExitCode;
39
+ });
40
+ const programWithSensors = () => {
41
+ const program = new commander_1.Command();
42
+ program.exitOverride();
43
+ (0, index_1.registerSensorsCommand)(program);
44
+ return program;
45
+ };
46
+ it.each([
47
+ ['pass', 0],
48
+ ['fail', 1],
49
+ ['skipped', 1],
50
+ ['not_certified', 1],
51
+ ])('writes full %s JSON before assigning exit code %i without process.exit', async (overall, code) => {
52
+ require('../../../src/commands/sensors/run').runSensors.mockResolvedValue({ sensors: [], overall });
53
+ const calls = [];
54
+ stdoutWrite.mockImplementation(() => {
55
+ calls.push(`stdout:${process.exitCode ?? 0}`);
56
+ return true;
57
+ });
58
+ await programWithSensors().parseAsync(['node', 'awm', 'sensors', 'run']);
59
+ expect(JSON.parse(String(stdoutWrite.mock.calls[0][0]))).toEqual({ sensors: [], overall });
60
+ expect(calls).toEqual(['stdout:0']);
61
+ expect(process.exitCode).toBe(code);
62
+ expect(processExit).not.toHaveBeenCalled();
63
+ });
24
64
  });
25
65
  describe('sensors coverage Commander wiring', () => {
26
66
  const report = { schemaVersion: 1, pack: 'js-ts', registry: 'baseline', overall: 'gaps',
27
67
  static: { status: 'gaps', reason: null, classes: [] } };
28
- const stdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
29
- const processExit = jest.spyOn(process, 'exit').mockImplementation((() => undefined));
30
68
  beforeEach(() => {
31
69
  jest.clearAllMocks();
32
70
  coverage_1.runCoverage.mockReturnValue(report);
@@ -310,7 +310,7 @@ describe('initSensors', () => {
310
310
  const written = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), 'utf8'));
311
311
  expect(explicit.manifest).toMatchObject({ schemaVersion: 2, pack: 'generic', packSelection: 'explicit', sensors: { security: { variantId: 'eslint-10' } } });
312
312
  expect(written.packSelection).toBe('explicit');
313
- await expect((0, status_1.computeSensorStatus)(tmpDir)).resolves.toMatchObject({ overall: 'HEALTHY', checks: { security: { ok: true } } });
313
+ await expect((0, status_1.computeSensorStatus)(tmpDir)).resolves.toMatchObject({ overall: 'DEGRADED', checks: { security: { ok: false } } });
314
314
  }
315
315
  finally {
316
316
  fs_1.default.rmSync(v2Registry, { recursive: true, force: true });
@@ -370,6 +370,35 @@ describe('initSensors', () => {
370
370
  fs_1.default.rmSync(v2Registry, { recursive: true, force: true });
371
371
  }
372
372
  });
373
+ it('preserves only the project timeout override, never prior executable authority (R3, R10)', async () => {
374
+ const v2Registry = makeV2Registry();
375
+ try {
376
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'package.json'), JSON.stringify({ devDependencies: { eslint: '^10.0.0' } }));
377
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, 'node_modules', 'eslint'), { recursive: true });
378
+ fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'node_modules', 'eslint', 'package.json'), JSON.stringify({ version: '10.0.0' }));
379
+ await (0, init_1.initSensors)({ cwd: tmpDir, registryRoot: v2Registry });
380
+ const manifestPath = path_1.default.join(tmpDir, '.awm', 'sensors.json');
381
+ const selected = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf8'));
382
+ selected.sensors.lint.timeout = 45_000;
383
+ selected.sensors.lint.command = {
384
+ executable: 'otherlint', resolution: 'path', args: ['--custom'],
385
+ environment: { ESLINT_USE_FLAT_CONFIG: 'false' },
386
+ };
387
+ selected.sensors.lint.assets = ['prior-owned.config'];
388
+ fs_1.default.writeFileSync(manifestPath, JSON.stringify(selected));
389
+ await (0, init_1.initSensors)({ cwd: tmpDir, registryRoot: v2Registry });
390
+ const written = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf8'));
391
+ expect(written.sensors.lint).toMatchObject({
392
+ timeout: 45_000,
393
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
394
+ assets: ['eslint.config.awm.mjs'],
395
+ });
396
+ expect(written.sensors.lint.command.environment).toBeUndefined();
397
+ }
398
+ finally {
399
+ fs_1.default.rmSync(v2Registry, { recursive: true, force: true });
400
+ }
401
+ });
373
402
  it('rejects a symlinked v2 pack source instead of reading it through init', async () => {
374
403
  const v2Registry = makeV2Registry();
375
404
  try {
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const prepare_1 = require("../../../src/commands/sensors/prepare");
4
+ const fullCommand = { executable: 'manifest-eslint', resolution: 'path', args: ['.'] };
5
+ const changedCommand = {
6
+ executable: 'live-eslint',
7
+ resolution: 'path',
8
+ args: ['--format', 'json', '{files}'],
9
+ fileInput: { placeholder: '{files}', extensions: ['.ts'] },
10
+ };
11
+ function v2Input(overrides = {}) {
12
+ const liveVariant = {
13
+ id: 'eslint-live', priority: 1, certifiedRange: '>=1.0.0', requirements: { tool: 'eslint', toolRange: '>=1.0.0', runtime: 'node', runtimeRange: '>=1.0.0' }, assets: [],
14
+ probe: { kind: 'version' }, command: { executable: 'live-eslint', resolution: 'path', args: ['.'] }, changedCommand, formatter: 'eslint-llm',
15
+ };
16
+ return {
17
+ name: 'lint',
18
+ sensor: {
19
+ enabled: true, fast: false, variantId: 'eslint-live', command: fullCommand,
20
+ initializedCompatibility: { state: 'certified', reason: 'test', variantId: 'eslint-live', toolVersion: '1.0.0', runtimeVersion: '1.0.0', certifiedRange: '>=1.0.0', evidence: [] },
21
+ },
22
+ liveSensor: { applicability: {}, fast: false, timeout: 30_000, variants: [liveVariant] },
23
+ liveState: { state: 'certified', reason: 'test', variantId: 'eslint-live', toolVersion: '1.0.0', runtimeVersion: '1.0.0', certifiedRange: '>=1.0.0', evidence: [] },
24
+ changed: { files: ['src/a.ts'] },
25
+ requestedScope: 'changed',
26
+ projectTimeout: 90_000,
27
+ ...overrides,
28
+ };
29
+ }
30
+ describe('prepareV2Sensor', () => {
31
+ test('v2 uses the live command and project > pack > fallback timeout (R1.1, R3.1)', () => {
32
+ const prepared = (0, prepare_1.prepareV2Sensor)(v2Input());
33
+ expect(prepared.command).toEqual({ kind: 'structured', value: { ...changedCommand, args: ['--format', 'json', 'src/a.ts'] } });
34
+ expect(prepared.timeoutMs).toBe(90_000);
35
+ expect(prepared.timeoutSource).toBe('project');
36
+ expect((0, prepare_1.prepareV2Sensor)(v2Input({ projectTimeout: undefined })).timeoutSource).toBe('pack');
37
+ const fallback = v2Input();
38
+ fallback.liveSensor.timeout = undefined;
39
+ fallback.liveSensor.fast = true;
40
+ fallback.sensor.fast = undefined;
41
+ expect((0, prepare_1.prepareV2Sensor)({ ...fallback, projectTimeout: undefined })).toMatchObject({ timeoutMs: 10_000, timeoutSource: 'fallback' });
42
+ });
43
+ test('expands changed paths as literal argv entries (R4.1, R10.2)', () => {
44
+ const prepared = (0, prepare_1.prepareV2Sensor)(v2Input({ changed: { files: ['src/a b.ts', 'src/$x.ts'] } }));
45
+ expect(prepared.command).toEqual({ kind: 'structured', value: { ...changedCommand, args: ['--format', 'json', 'src/a b.ts', 'src/$x.ts'] } });
46
+ expect(prepared.effectiveScope).toBe('changed');
47
+ });
48
+ test('falls back full with an explicit reason without changedCommand (R4.2)', () => {
49
+ const input = v2Input();
50
+ input.liveSensor.variants[0].changedCommand = undefined;
51
+ const prepared = (0, prepare_1.prepareV2Sensor)(input);
52
+ expect(prepared.command).toEqual({ kind: 'structured', value: input.liveSensor.variants[0].command });
53
+ expect(prepared.effectiveScope).toBe('full');
54
+ expect(prepared.scopeReason).toMatch(/does not support changed scope/);
55
+ });
56
+ test('uses the full command with an explicit reason when the diff cannot resolve', () => {
57
+ const prepared = (0, prepare_1.prepareV2Sensor)(v2Input({ changed: { files: [], error: 'git failed' } }));
58
+ expect(prepared.effectiveScope).toBe('full');
59
+ expect(prepared.scopeReason).toMatch(/could not be resolved: git failed/);
60
+ });
61
+ test('returns zero-file pass plan without a process (R4.4)', () => {
62
+ const prepared = (0, prepare_1.prepareV2Sensor)(v2Input({ changed: { files: ['README.md'] } }));
63
+ expect(prepared).toMatchObject({ effectiveScope: 'changed', files: 0, syntheticStatus: 'pass' });
64
+ expect(prepared.command).toBeUndefined();
65
+ });
66
+ test('rejects an invalid requested scope before preparing a command', () => {
67
+ expect(() => (0, prepare_1.prepareV2Sensor)({ ...v2Input(), requestedScope: 'sideways' }))
68
+ .toThrow('requested scope must be "full" or "changed"');
69
+ });
70
+ });
71
+ describe('prepareLegacySensor', () => {
72
+ const originalPlatform = process.platform;
73
+ afterEach(() => Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }));
74
+ test('falls back to the full command for an unsafe Windows filename', () => {
75
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
76
+ const prepared = (0, prepare_1.prepareLegacySensor)({
77
+ name: 'lint', config: { cmd: 'eslint .', changedCmd: 'eslint {files}' }, requestedScope: 'changed', changed: { files: ['src/a&b.ts'] },
78
+ });
79
+ expect(prepared).toMatchObject({ command: { kind: 'legacy', value: 'eslint .' }, effectiveScope: 'full' });
80
+ expect(prepared.scopeReason).toMatch(/cmd\.exe metacharacter/);
81
+ });
82
+ });
83
+ describe('validateRunOptions', () => {
84
+ test('rejects changed baseline capture before any scope preparation (R4.6)', () => {
85
+ expect(() => (0, prepare_1.validateRunOptions)({ changed: true, ignoreBaseline: true }))
86
+ .toThrow(/refusing to combine --changed with a baseline capture/);
87
+ });
88
+ });
@@ -4,7 +4,7 @@ jest.mock('@clack/prompts', () => ({ log: { success: jest.fn(), info: jest.fn()
4
4
  jest.mock('picocolors', () => ({ green: (s) => s, yellow: (s) => s, red: (s) => s }));
5
5
  jest.mock('../../../src/commands/sensors/run', () => ({ runSensors: jest.fn().mockReturnValue({ sensors: [], overall: 'pass' }) }));
6
6
  jest.mock('../../../src/commands/sensors/init', () => ({ initSensors: jest.fn().mockReturnValue({ detection: { pack: 'js-ts', indicators: [] }, manifest: { sensors: {} }, configured: [] }) }));
7
- jest.mock('../../../src/commands/sensors/status', () => ({ computeSensorStatus: jest.fn().mockReturnValue({ overall: 'HEALTHY', pack: 'js-ts', checks: {} }) }));
7
+ jest.mock('../../../src/commands/sensors/status', () => ({ computeSensorStatus: jest.fn().mockReturnValue({ overall: 'READY', pack: 'js-ts', checks: {} }) }));
8
8
  jest.mock('../../../src/commands/sensors/install', () => ({ installSensorHook: jest.fn().mockReturnValue({ status: 'installed' }) }));
9
9
  const commander_1 = require("commander");
10
10
  const index_1 = require("../../../src/commands/sensors/index");
@@ -21,4 +21,18 @@ describe('registerSensorsCommand', () => {
21
21
  expect(subNames).toContain('install');
22
22
  expect(subNames).toContain('coverage');
23
23
  });
24
+ it('renders READY without claiming sensor execution, HEALTHY, or project certification', async () => {
25
+ const program = new commander_1.Command();
26
+ const output = jest.spyOn(console, 'log').mockImplementation(() => undefined);
27
+ try {
28
+ (0, index_1.registerSensorsCommand)(program);
29
+ await program.parseAsync(['node', 'awm', 'sensors', 'status']);
30
+ const rendered = output.mock.calls.flat().join('\n');
31
+ expect(rendered).toContain('READY');
32
+ expect(rendered).not.toMatch(/HEALTHY|certif/i);
33
+ }
34
+ finally {
35
+ output.mockRestore();
36
+ }
37
+ });
24
38
  });
@@ -84,12 +84,12 @@ describe('runSensors --changed', () => {
84
84
  expect(out.sensors[0].scope).toBeUndefined();
85
85
  expect(out.changedScope).toEqual({ files: 0, error: 'not a git repository' });
86
86
  });
87
- it('skips an opted-in sensor when nothing changed, without touching the others', async () => {
87
+ it('records a clean synthetic pass when an opted-in sensor has no changed files, without touching the others', async () => {
88
88
  dir = project({ lint: LINT, typecheck: TYPECHECK });
89
89
  mockChangedFiles.mockReturnValue({ files: [] });
90
90
  const out = await load().runSensors({ cwd: dir, changed: true });
91
91
  const lint = out.sensors.find((s) => s.name === 'lint');
92
- expect(lint.status).toBe('skipped');
92
+ expect(lint.status).toBe('pass');
93
93
  expect(lint.skipReason).toBe('no changed files in scope');
94
94
  expect(cmds()).toEqual(['tsc --noEmit']);
95
95
  });
@@ -101,13 +101,13 @@ describe('runSensors --changed', () => {
101
101
  await load().runSensors({ cwd: dir, changed: true });
102
102
  expect(cmds()).toEqual([`eslint --format json 'src/a.ts'`]);
103
103
  });
104
- it('skips the sensor when the filter empties the scope, rather than running repo-wide', async () => {
104
+ it('records a clean synthetic pass when the filter empties the scope, rather than running repo-wide', async () => {
105
105
  // A docs-only commit means the lint sensor has nothing to say. Falling back to
106
106
  // the full command here would reintroduce exactly the cost --changed removes.
107
107
  dir = project({ lint: { ...LINT, changedExtensions: ['.ts'] }, typecheck: TYPECHECK });
108
108
  mockChangedFiles.mockReturnValue({ files: ['README.md'] });
109
109
  const out = await load().runSensors({ cwd: dir, changed: true });
110
- expect(out.sensors.find((s) => s.name === 'lint').status).toBe('skipped');
110
+ expect(out.sensors.find((s) => s.name === 'lint').status).toBe('pass');
111
111
  expect(cmds()).toEqual(['tsc --noEmit']);
112
112
  });
113
113
  it('refuses a changedCmd without a {files} placeholder instead of running it repo-wide', async () => {
@@ -7,6 +7,7 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const run_1 = require("../../../src/commands/sensors/run");
10
+ const verdict_1 = require("../../../src/commands/sensors/verdict");
10
11
  function mkTmp() {
11
12
  return fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sensors-'));
12
13
  }
@@ -53,6 +54,17 @@ describe('runSensors', () => {
53
54
  fs_1.default.rmSync(emptyDir, { recursive: true });
54
55
  }
55
56
  });
57
+ it.each([
58
+ ['fast', { fast: 'yes' }, 'run option fast must be a boolean'],
59
+ ['slow', { slow: 1 }, 'run option slow must be a boolean'],
60
+ ['all', { all: null }, 'run option all must be a boolean'],
61
+ ['cwd', { cwd: ' ' }, 'run option cwd must be a nonempty string'],
62
+ ])('rejects invalid public %s options before selecting or dispatching sensors', async (_name, options, message) => {
63
+ const { runSensors } = load();
64
+ await expect(runSensors(options)).rejects.toThrow(message);
65
+ expect(mockRunCommand).not.toHaveBeenCalled();
66
+ expect(mockRunStructuredCommand).not.toHaveBeenCalled();
67
+ });
56
68
  it('runs only fast sensors with --fast flag', async () => {
57
69
  mockRunCommand.mockResolvedValue(ok());
58
70
  const { runSensors } = load();
@@ -61,6 +73,12 @@ describe('runSensors', () => {
61
73
  expect(result.sensors.some((s) => s.name === 'security')).toBe(false);
62
74
  expect(result.overall).toBe('not_certified');
63
75
  });
76
+ it('runs both fast and slow sensors when --fast and --slow are combined', async () => {
77
+ mockRunCommand.mockResolvedValue(ok());
78
+ const { runSensors } = load();
79
+ const result = await runSensors({ fast: true, slow: true, cwd: tmpDir });
80
+ expect(result.sensors.map((sensor) => sensor.name)).toEqual(['typecheck', 'lint', 'security', 'mutation']);
81
+ });
64
82
  it('returns fail when a fast sensor has errors', async () => {
65
83
  mockRunCommand
66
84
  .mockResolvedValueOnce(exited(1, 'src/a.ts(1,1): error TS0001: Bad type.'))
@@ -203,6 +221,17 @@ describe('runSensors', () => {
203
221
  expect(result.overall).toBe('fail');
204
222
  });
205
223
  });
224
+ describe('reduceVerdict', () => {
225
+ it('lets a failing full sensor outrank an empty changed-scope synthetic pass', () => {
226
+ expect((0, verdict_1.reduceVerdict)([
227
+ { name: 'lint', status: 'pass', errors: [], scope: 'changed' },
228
+ { name: 'typecheck', status: 'fail', errors: [{ message: 'broken' }] },
229
+ ])).toBe('fail');
230
+ });
231
+ it('rejects malformed result statuses instead of treating them as skipped', () => {
232
+ expect(() => (0, verdict_1.reduceVerdict)([{ name: 'lint', status: 'bogus', errors: [] }])).toThrow('sensor result status is invalid');
233
+ });
234
+ });
206
235
  describe('runSensors v2 lifecycle contract', () => {
207
236
  let project;
208
237
  let home;