agentic-workflow-manager 3.10.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/preflight/checks.js +27 -0
- package/dist/src/commands/sensors/formatters/mypy.js +30 -0
- package/dist/src/commands/sensors/formatters/ruff.js +45 -0
- package/dist/src/commands/sensors/formatters/shellcheck.js +45 -0
- package/dist/src/commands/sensors/index.js +11 -4
- package/dist/src/commands/sensors/init.js +80 -15
- package/dist/src/commands/sensors/run.js +36 -5
- package/dist/src/commands/sensors/status.js +8 -1
- package/dist/tests/commands/preflight/preflight.test.js +49 -14
- package/dist/tests/commands/sensors/formatters/mypy.test.js +60 -0
- package/dist/tests/commands/sensors/formatters/ruff.test.js +92 -0
- package/dist/tests/commands/sensors/formatters/shellcheck.test.js +65 -0
- package/dist/tests/commands/sensors/init.test.js +159 -4
- package/dist/tests/commands/sensors/run.test.js +91 -0
- package/dist/tests/commands/sensors/status.test.js +29 -0
- package/package.json +1 -1
|
@@ -64,6 +64,19 @@ function checkManifest(cwd, manifest) {
|
|
|
64
64
|
}
|
|
65
65
|
const total = Object.keys(manifest.sensors ?? {}).length;
|
|
66
66
|
const enabled = Object.values(manifest.sensors ?? {}).filter(s => s.enabled !== false).length;
|
|
67
|
+
// total === 0 is NOT an opt-out: a deliberate opt-out lists every known sensor NAME
|
|
68
|
+
// explicitly with `enabled: false` (total > 0, enabled === 0). Zero entries means
|
|
69
|
+
// nothing was ever configured — most commonly because the registry had no pack.json
|
|
70
|
+
// for the detected stack, so `awm sensors init` built an honest, empty manifest
|
|
71
|
+
// rather than inventing defaults. That must not read as "all sensors disabled".
|
|
72
|
+
if (total === 0) {
|
|
73
|
+
return {
|
|
74
|
+
id: 'manifest',
|
|
75
|
+
ok: false,
|
|
76
|
+
detail: `pack '${manifest.pack}' has no sensors — the registry has no pack.json for it`,
|
|
77
|
+
remedy: `registry has no pack for '${manifest.pack}': run \`awm update\` or add a registry that has it`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
67
80
|
return {
|
|
68
81
|
id: 'manifest',
|
|
69
82
|
ok: true,
|
|
@@ -77,6 +90,20 @@ function checkTools(cwd) {
|
|
|
77
90
|
if (status.overall === 'NOT_CONFIGURED') {
|
|
78
91
|
return { id: 'tools', ok: false, detail: 'no manifest to check', remedy: 'run `awm sensors init`' };
|
|
79
92
|
}
|
|
93
|
+
// A manifest with zero sensor entries (honest-degraded — no pack.json reachable in
|
|
94
|
+
// the registry for this stack) makes `Object.entries({}).filter(...)` vacuously
|
|
95
|
+
// empty, which used to read as "0 broken out of 0" — a clean pass for a manifest
|
|
96
|
+
// that checks nothing at all. Mirrors the same zero-sensors signal `checkManifest`
|
|
97
|
+
// already guards against; this defends the invariant independently rather than
|
|
98
|
+
// relying solely on `checkManifest`'s gate to catch this exact manifest shape.
|
|
99
|
+
if (Object.keys(status.checks).length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
id: 'tools',
|
|
102
|
+
ok: false,
|
|
103
|
+
detail: 'no sensors configured to check (0 sensor entries in the manifest)',
|
|
104
|
+
remedy: `registry has no pack for '${status.pack}': run \`awm update\` or add a registry that has it`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
80
107
|
const broken = Object.entries(status.checks).filter(([, c]) => !c.ok);
|
|
81
108
|
if (broken.length > 0) {
|
|
82
109
|
return {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseMypyOutput = parseMypyOutput;
|
|
4
|
+
// mypy's plain-text output (no --output json flag in this pack's defaultCmd), one
|
|
5
|
+
// finding per line: `file:line: error: message [code]`. The `[code]` suffix is
|
|
6
|
+
// separated from the message by two spaces and is OPTIONAL — some mypy error kinds
|
|
7
|
+
// omit it. No column: this pack's defaultCmd has no --show-column-numbers.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately excluded, not matched by this pattern:
|
|
10
|
+
// - `note:` lines (e.g. `reveal_type` output, supplementary context) — not failures.
|
|
11
|
+
// - the trailing summary line (`Found N errors in M files…` / `Success: …`).
|
|
12
|
+
const MYPY_LINE = /^(.+):(\d+): error: (.*?)(?: \[(\S+)\])?$/;
|
|
13
|
+
function parseMypyOutput(raw) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
for (const line of raw.split('\n')) {
|
|
16
|
+
if (!line)
|
|
17
|
+
continue;
|
|
18
|
+
const m = MYPY_LINE.exec(line);
|
|
19
|
+
if (!m)
|
|
20
|
+
continue;
|
|
21
|
+
const [, file, lineStr, msg, code] = m;
|
|
22
|
+
errors.push({
|
|
23
|
+
file,
|
|
24
|
+
line: parseInt(lineStr, 10),
|
|
25
|
+
rule: code,
|
|
26
|
+
message: `SENSOR[typecheck] ${file}:${lineStr} — ${msg} Fix: review the type annotation. Error code: ${code ?? 'n/a'}.`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return errors;
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseRuffOutput = parseRuffOutput;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
function parseRuffOutput(raw) {
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
// Valid JSON syntax does not guarantee the expected shape — `{}`, `null`, `42` all
|
|
17
|
+
// parse successfully but are not arrays, and an array element can itself be `null`
|
|
18
|
+
// or missing the fields this parser reads. Guard both, or a well-formed-but-wrong
|
|
19
|
+
// shape from `ruff --output-format=json` throws instead of degrading to [].
|
|
20
|
+
if (!Array.isArray(parsed))
|
|
21
|
+
return [];
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
const errors = [];
|
|
24
|
+
for (const item of parsed) {
|
|
25
|
+
if (!item || typeof item !== 'object')
|
|
26
|
+
continue;
|
|
27
|
+
const msg = item;
|
|
28
|
+
if (typeof msg.filename !== 'string')
|
|
29
|
+
continue;
|
|
30
|
+
if (!msg.location || typeof msg.location !== 'object'
|
|
31
|
+
|| typeof msg.location.row !== 'number' || typeof msg.location.column !== 'number')
|
|
32
|
+
continue;
|
|
33
|
+
const rel = msg.filename.startsWith(cwd + path_1.default.sep)
|
|
34
|
+
? path_1.default.relative(cwd, msg.filename)
|
|
35
|
+
: msg.filename;
|
|
36
|
+
errors.push({
|
|
37
|
+
file: rel,
|
|
38
|
+
line: msg.location.row,
|
|
39
|
+
column: msg.location.column,
|
|
40
|
+
rule: msg.code ?? 'unknown',
|
|
41
|
+
message: `SENSOR[lint] ${rel}:${msg.location.row} — ${msg.message ?? ''} Fix: check rule ${msg.code ?? 'unknown'}.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseShellcheckOutput = parseShellcheckOutput;
|
|
4
|
+
// shellcheck's own levels, from most to least severe: error, warning, info, style.
|
|
5
|
+
// `info`/`style` are advisory — quoting preferences, portability nits — not genuine
|
|
6
|
+
// problems (mirrors eslint.ts's `severity < 2` filter, which drops eslint's "warn" the
|
|
7
|
+
// same way: findings should be real breakage, not 100% of the tool's advisory noise).
|
|
8
|
+
// Only `error`/`warning` are reported as SensorErrors.
|
|
9
|
+
const FAILING_LEVELS = new Set(['error', 'warning']);
|
|
10
|
+
function parseShellcheckOutput(raw) {
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(raw);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
// Valid JSON syntax does not guarantee the expected shape — `{}`, `null`, `42` all
|
|
19
|
+
// parse successfully but are not arrays, and an array element can itself be `null`
|
|
20
|
+
// or missing the fields this parser reads. Guard both, or a well-formed-but-wrong
|
|
21
|
+
// shape from `shellcheck -f json` throws instead of degrading to [].
|
|
22
|
+
if (!Array.isArray(parsed))
|
|
23
|
+
return [];
|
|
24
|
+
const errors = [];
|
|
25
|
+
for (const item of parsed) {
|
|
26
|
+
if (!item || typeof item !== 'object')
|
|
27
|
+
continue;
|
|
28
|
+
const msg = item;
|
|
29
|
+
if (typeof msg.file !== 'string' || typeof msg.line !== 'number'
|
|
30
|
+
|| typeof msg.column !== 'number' || typeof msg.code !== 'number'
|
|
31
|
+
|| !msg.level)
|
|
32
|
+
continue;
|
|
33
|
+
if (!FAILING_LEVELS.has(msg.level))
|
|
34
|
+
continue;
|
|
35
|
+
const rule = `SC${msg.code}`;
|
|
36
|
+
errors.push({
|
|
37
|
+
file: msg.file,
|
|
38
|
+
line: msg.line,
|
|
39
|
+
column: msg.column,
|
|
40
|
+
rule,
|
|
41
|
+
message: `SENSOR[lint] ${msg.file}:${msg.line} — ${msg.message ?? ''} Fix: see https://www.shellcheck.net/wiki/${rule}.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
|
@@ -46,12 +46,19 @@ function registerSensorsCommand(program) {
|
|
|
46
46
|
.description('detect stack and write .awm/sensors.json (+ copy pack config files)')
|
|
47
47
|
.option('--no-configure', 'skip copying sensor pack config files into the project')
|
|
48
48
|
.option('--registry-root <path>', 'path to AWM registry root')
|
|
49
|
+
.option('--pack <name>', 'skip auto-detection, use this pack explicitly')
|
|
49
50
|
.action((opts) => {
|
|
50
51
|
const registryRoot = opts.registryRoot ?? (0, registries_1.capabilityRoot)('sensor-packs') ?? undefined;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
try {
|
|
53
|
+
const result = (0, init_1.initSensors)({ configure: opts.configure, registryRoot, pack: opts.pack });
|
|
54
|
+
prompts_1.log.success(`Detected: ${result.detection.pack} (${result.detection.indicators.join(', ') || 'fallback'})`);
|
|
55
|
+
prompts_1.log.success('Wrote .awm/sensors.json');
|
|
56
|
+
result.configured.forEach((f) => prompts_1.log.info(` Installed ${f}`));
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
prompts_1.log.error(e instanceof Error ? e.message : String(e));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
55
62
|
});
|
|
56
63
|
sensors
|
|
57
64
|
.command('baseline')
|
|
@@ -11,14 +11,37 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const STACK_DETECTORS = [
|
|
13
13
|
{ pack: 'js-ts', files: ['package.json'] },
|
|
14
|
-
{ pack: 'python', files: ['pyproject.toml', 'setup.py', 'setup.cfg'] },
|
|
14
|
+
{ pack: 'python', files: ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile'] },
|
|
15
15
|
];
|
|
16
|
+
// Shell detection is a glob (`*.sh` in the repo root or in `scripts/`), unlike the
|
|
17
|
+
// exact-filename matches above — so it needs its own scan rather than fitting the
|
|
18
|
+
// STACK_DETECTORS table. Tried last, after js-ts and python both fail: a Python
|
|
19
|
+
// project that also ships a root `deploy.sh` must still detect as `python`, never
|
|
20
|
+
// `shell`. Order of specificity: js-ts > python > shell > generic.
|
|
21
|
+
const SHELL_SCAN_DIRS = ['.', 'scripts'];
|
|
22
|
+
function findShellIndicators(cwd) {
|
|
23
|
+
const found = [];
|
|
24
|
+
for (const dir of SHELL_SCAN_DIRS) {
|
|
25
|
+
const full = path_1.default.join(cwd, dir);
|
|
26
|
+
if (!fs_1.default.existsSync(full) || !fs_1.default.statSync(full).isDirectory())
|
|
27
|
+
continue;
|
|
28
|
+
for (const entry of fs_1.default.readdirSync(full, { withFileTypes: true })) {
|
|
29
|
+
if (entry.isFile() && entry.name.endsWith('.sh')) {
|
|
30
|
+
found.push(dir === '.' ? entry.name : path_1.default.join(dir, entry.name));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
16
36
|
function detectStack(cwd) {
|
|
17
37
|
for (const { pack, files } of STACK_DETECTORS) {
|
|
18
38
|
const found = files.filter(f => fs_1.default.existsSync(path_1.default.join(cwd, f)));
|
|
19
39
|
if (found.length > 0)
|
|
20
40
|
return { pack, indicators: found };
|
|
21
41
|
}
|
|
42
|
+
const shellIndicators = findShellIndicators(cwd);
|
|
43
|
+
if (shellIndicators.length > 0)
|
|
44
|
+
return { pack: 'shell', indicators: shellIndicators };
|
|
22
45
|
return { pack: 'generic', indicators: [] };
|
|
23
46
|
}
|
|
24
47
|
// Candidate source dirs in priority order. `depcheck` analyzes the ones that
|
|
@@ -31,17 +54,6 @@ function detectSourceDirs(cwd) {
|
|
|
31
54
|
});
|
|
32
55
|
return found.length > 0 ? found : ['src'];
|
|
33
56
|
}
|
|
34
|
-
// Fallback defaults for packs that don't yet ship a pack.json in the registry
|
|
35
|
-
// (today: python). js-ts/generic are sourced from
|
|
36
|
-
// registry/sensor-packs/<pack>/pack.json — single source of truth.
|
|
37
|
-
const FALLBACK_DEFAULTS = {
|
|
38
|
-
python: {
|
|
39
|
-
typecheck: { cmd: 'mypy .', fast: true },
|
|
40
|
-
lint: { cmd: 'ruff check . --output-format json', fast: true },
|
|
41
|
-
security: { cmd: 'semgrep --config .semgrep.awm.yml --json .', fast: false },
|
|
42
|
-
mutation: { enabled: false },
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
57
|
/**
|
|
46
58
|
* Read sensor defaults from the pack's pack.json (the single source of truth).
|
|
47
59
|
* Maps `defaultCmd` → `cmd` and substitutes the `{{SOURCE_DIRS}}` placeholder
|
|
@@ -76,21 +88,74 @@ function readPackDefaults(pack, registryRoot, cwd) {
|
|
|
76
88
|
entry.changedCmd = def.changedCmd;
|
|
77
89
|
if (def.changedExtensions)
|
|
78
90
|
entry.changedExtensions = def.changedExtensions;
|
|
91
|
+
// Carries the real tool name (`mypy`, `ruff`, `shellcheck`…) so the runner can
|
|
92
|
+
// dispatch to the right output parser instead of guessing from the sensor name —
|
|
93
|
+
// see `SensorConfig.formatter`.
|
|
94
|
+
if (def.formatter)
|
|
95
|
+
entry.formatter = def.formatter;
|
|
79
96
|
sensors[name] = entry;
|
|
80
97
|
}
|
|
81
98
|
return sensors;
|
|
82
99
|
}
|
|
83
100
|
function buildManifest(pack, existing, registryRoot, cwd = process.cwd()) {
|
|
84
101
|
const fromPack = registryRoot ? readPackDefaults(pack, registryRoot, cwd) : null;
|
|
85
|
-
|
|
102
|
+
// No registry root, or the pack has no pack.json there → `{}` is the honest floor,
|
|
103
|
+
// not a bug to paper over with CLI-hardcoded defaults. `checkManifest` (preflight)
|
|
104
|
+
// and `computeSensorStatus` both surface a zero-sensor manifest as degraded, with a
|
|
105
|
+
// remedy pointing at the registry — never silently inventing sensors here instead.
|
|
106
|
+
const defaults = fromPack ?? {};
|
|
86
107
|
const existingSensors = existing?.sensors ?? {};
|
|
87
|
-
|
|
108
|
+
// Per-FIELD merge, not whole-sensor-object replacement: if `existingSensors.foo`
|
|
109
|
+
// exists at all, a naive `{ ...defaults, ...existingSensors }` would replace
|
|
110
|
+
// `defaults.foo` wholesale, permanently dropping any field that only lives in the
|
|
111
|
+
// (newer) pack default — e.g. a pre-`formatter`-era manifest re-merged against a
|
|
112
|
+
// pack.json that now declares `formatter` would silently lose it forever. Merging
|
|
113
|
+
// field-by-field within each sensor entry lets a user's hand-edited field (e.g. a
|
|
114
|
+
// custom `cmd`) win, while still inheriting any field the existing manifest doesn't
|
|
115
|
+
// specify.
|
|
116
|
+
const sensorNames = new Set([...Object.keys(defaults), ...Object.keys(existingSensors)]);
|
|
117
|
+
const sensors = {};
|
|
118
|
+
for (const name of sensorNames) {
|
|
119
|
+
sensors[name] = { ...defaults[name], ...existingSensors[name] };
|
|
120
|
+
}
|
|
121
|
+
return { pack, sensors };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validate that `pack` exists as a directory under `<registryRoot>/sensor-packs/`.
|
|
125
|
+
* Throws (not a swallow-and-return) so `awm sensors init --pack bogus` actually stops
|
|
126
|
+
* instead of silently writing a manifest for a pack that doesn't exist. Lists every
|
|
127
|
+
* pack directory actually present, sorted, so the user immediately sees valid options.
|
|
128
|
+
*/
|
|
129
|
+
function assertPackExists(pack, registryRoot) {
|
|
130
|
+
const packsDir = path_1.default.join(registryRoot, 'sensor-packs');
|
|
131
|
+
if (!fs_1.default.existsSync(packsDir) || !fs_1.default.statSync(packsDir).isDirectory()) {
|
|
132
|
+
throw new Error('registry has no sensor-packs directory');
|
|
133
|
+
}
|
|
134
|
+
const available = fs_1.default.readdirSync(packsDir, { withFileTypes: true })
|
|
135
|
+
.filter(e => e.isDirectory())
|
|
136
|
+
.map(e => e.name)
|
|
137
|
+
.sort();
|
|
138
|
+
if (!available.includes(pack)) {
|
|
139
|
+
throw new Error(`pack '${pack}' not found in registry (available: ${available.join(', ')})`);
|
|
140
|
+
}
|
|
88
141
|
}
|
|
89
142
|
function initSensors(opts = {}) {
|
|
90
143
|
const cwd = opts.cwd ?? process.cwd();
|
|
91
144
|
const configure = opts.configure ?? true; // configure (copy pack config files) by default
|
|
92
145
|
const manifestPath = path_1.default.join(cwd, '.awm', 'sensors.json');
|
|
93
|
-
|
|
146
|
+
// --pack skips the heuristic entirely. Only validate against the registry when a
|
|
147
|
+
// registryRoot was actually given — same tolerance pattern as readPackDefaults /
|
|
148
|
+
// buildManifest elsewhere in this file for a missing registry: nothing to validate
|
|
149
|
+
// against, so nothing is validated.
|
|
150
|
+
let detection;
|
|
151
|
+
if (opts.pack) {
|
|
152
|
+
if (opts.registryRoot)
|
|
153
|
+
assertPackExists(opts.pack, opts.registryRoot);
|
|
154
|
+
detection = { pack: opts.pack, indicators: ['--pack override'] };
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
detection = detectStack(cwd);
|
|
158
|
+
}
|
|
94
159
|
let existing;
|
|
95
160
|
if (fs_1.default.existsSync(manifestPath)) {
|
|
96
161
|
try {
|
|
@@ -17,6 +17,9 @@ const eslint_1 = require("./formatters/eslint");
|
|
|
17
17
|
const semgrep_1 = require("./formatters/semgrep");
|
|
18
18
|
const generic_1 = require("./formatters/generic");
|
|
19
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");
|
|
20
23
|
const baseline_1 = require("./baseline");
|
|
21
24
|
const changed_1 = require("./changed");
|
|
22
25
|
const init_1 = require("./init");
|
|
@@ -117,7 +120,35 @@ function shouldRun(isFast, opts) {
|
|
|
117
120
|
return true;
|
|
118
121
|
return false;
|
|
119
122
|
}
|
|
120
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Dispatch by the pack's `formatter` field (the real tool behind the sensor slot —
|
|
125
|
+
* `lint` is eslint on js-ts but ruff on python, shellcheck on shell) when present.
|
|
126
|
+
* Manifests written before this field existed carry no `formatter`, so they fall back
|
|
127
|
+
* to the pre-existing name-based dispatch — nothing already installed breaks.
|
|
128
|
+
*/
|
|
129
|
+
function getFormatter(name, formatterField) {
|
|
130
|
+
// A `formatter` field that is PRESENT but unrecognized (a typo in a pack.json, or a
|
|
131
|
+
// future pack declaring a tool this CLI version doesn't know about yet) is a
|
|
132
|
+
// different situation from no field at all. Falling through to name-based dispatch
|
|
133
|
+
// in that case would silently misparse a foreign output shape via the wrong parser
|
|
134
|
+
// (e.g. a `bandit` formatter falling through to `parseSemgrepOutput`, reading
|
|
135
|
+
// bandit's differently-shaped JSON and producing garbage findings). Only the
|
|
136
|
+
// ABSENT case (old manifest, written before this field existed) gets name-based
|
|
137
|
+
// backward-compat dispatch; a present-but-unknown value degrades honestly to the
|
|
138
|
+
// generic raw-wrap formatter instead.
|
|
139
|
+
if (formatterField !== undefined) {
|
|
140
|
+
switch (formatterField) {
|
|
141
|
+
case 'tsc': return tsc_1.parseTscOutput;
|
|
142
|
+
case 'eslint-llm': return eslint_1.parseEslintOutput;
|
|
143
|
+
case 'semgrep': return semgrep_1.parseSemgrepOutput;
|
|
144
|
+
case 'test': return test_1.parseTestOutput;
|
|
145
|
+
case 'mypy': return mypy_1.parseMypyOutput;
|
|
146
|
+
case 'ruff': return ruff_1.parseRuffOutput;
|
|
147
|
+
case 'shellcheck': return shellcheck_1.parseShellcheckOutput;
|
|
148
|
+
case 'generic': return generic_1.parseGenericOutput;
|
|
149
|
+
default: return generic_1.parseGenericOutput;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
121
152
|
if (name === 'typecheck')
|
|
122
153
|
return tsc_1.parseTscOutput;
|
|
123
154
|
if (name === 'lint')
|
|
@@ -131,9 +162,9 @@ function getFormatter(name) {
|
|
|
131
162
|
function isExitCodeSensor(name) {
|
|
132
163
|
return name === 'test';
|
|
133
164
|
}
|
|
134
|
-
async function runSensor(name, cmd, timeout, cwd) {
|
|
165
|
+
async function runSensor(name, cmd, timeout, cwd, formatterField) {
|
|
135
166
|
const res = await (0, exec_1.runCommand)(cmd, { timeout, cwd, maxBuffer: MAX_BUFFER });
|
|
136
|
-
const format = getFormatter(name);
|
|
167
|
+
const format = getFormatter(name, formatterField);
|
|
137
168
|
// The shell itself never started (bad cwd, no shell). Nothing ran.
|
|
138
169
|
if (res.spawnError) {
|
|
139
170
|
return {
|
|
@@ -290,7 +321,7 @@ async function runSensors(opts = {}) {
|
|
|
290
321
|
// in manifest order, so the reported order stays stable.
|
|
291
322
|
const tasks = [];
|
|
292
323
|
const settled = (r) => () => Promise.resolve(r);
|
|
293
|
-
for (const [name, config] of Object.entries(activeManifest.sensors)) {
|
|
324
|
+
for (const [name, config] of Object.entries(activeManifest.sensors ?? {})) {
|
|
294
325
|
const isFast = config.fast ?? false;
|
|
295
326
|
if (!shouldRun(isFast, opts))
|
|
296
327
|
continue;
|
|
@@ -336,7 +367,7 @@ async function runSensors(opts = {}) {
|
|
|
336
367
|
}
|
|
337
368
|
const timeout = config.timeout ?? (isFast ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT);
|
|
338
369
|
tasks.push(async () => {
|
|
339
|
-
const result = await runSensor(name, cmd, timeout, cwd);
|
|
370
|
+
const result = await runSensor(name, cmd, timeout, cwd, config.formatter);
|
|
340
371
|
const scoped = scope ? { ...result, scope } : result;
|
|
341
372
|
return baseline ? applyBaseline(scoped, baseline[name]) : scoped;
|
|
342
373
|
});
|
|
@@ -66,7 +66,7 @@ function computeSensorStatus(cwd = process.cwd()) {
|
|
|
66
66
|
return { overall: 'NOT_CONFIGURED', pack: null, checks: {} };
|
|
67
67
|
}
|
|
68
68
|
const checks = {};
|
|
69
|
-
for (const [name, config] of Object.entries(manifest.sensors)) {
|
|
69
|
+
for (const [name, config] of Object.entries(manifest.sensors ?? {})) {
|
|
70
70
|
if (config.enabled === false) {
|
|
71
71
|
checks[name] = { ok: true, detail: 'disabled' };
|
|
72
72
|
continue;
|
|
@@ -77,6 +77,13 @@ function computeSensorStatus(cwd = process.cwd()) {
|
|
|
77
77
|
}
|
|
78
78
|
checks[name] = checkCmd(config.cmd, cwd);
|
|
79
79
|
}
|
|
80
|
+
// `Object.values({}).every(...)` is vacuously true — a manifest with zero sensor
|
|
81
|
+
// entries (the registry had no pack.json for this stack; see init.ts) must not read
|
|
82
|
+
// as HEALTHY just because there was nothing to fail. Same false-green `checkManifest`
|
|
83
|
+
// guards against in preflight.
|
|
84
|
+
if (Object.keys(manifest.sensors ?? {}).length === 0) {
|
|
85
|
+
return { overall: 'DEGRADED', pack: manifest.pack, checks };
|
|
86
|
+
}
|
|
80
87
|
const allOk = Object.values(checks).every(c => c.ok);
|
|
81
88
|
return { overall: allOk ? 'HEALTHY' : 'DEGRADED', pack: manifest.pack, checks };
|
|
82
89
|
}
|
|
@@ -96,6 +96,36 @@ describe('preflight', () => {
|
|
|
96
96
|
expect(report.status).toBe('ready');
|
|
97
97
|
expect(check(report, 'manifest').detail).toContain('opt-out');
|
|
98
98
|
});
|
|
99
|
+
it('flags a manifest with zero sensor entries as degraded, distinct from a deliberate opt-out', () => {
|
|
100
|
+
// Genuinely different manifest shape from the opt-out test above: no sensor
|
|
101
|
+
// NAMES at all, vs. an opt-out which lists every known sensor explicitly with
|
|
102
|
+
// `enabled: false`. This is the honest-floor case from init.ts — the registry
|
|
103
|
+
// had no pack.json for the detected stack — and must never read as "opted out".
|
|
104
|
+
const noPack = make({
|
|
105
|
+
manifest: { pack: 'python', sensors: {} },
|
|
106
|
+
});
|
|
107
|
+
const report = (0, checks_1.preflight)(noPack);
|
|
108
|
+
expect(report.status).toBe('degraded');
|
|
109
|
+
expect(check(report, 'manifest').ok).toBe(false);
|
|
110
|
+
expect(check(report, 'manifest').detail).not.toContain('opt-out');
|
|
111
|
+
expect(check(report, 'manifest').detail).toContain('python');
|
|
112
|
+
expect(check(report, 'manifest').remedy).toContain('python');
|
|
113
|
+
});
|
|
114
|
+
it('flags the tools check as failing (not "0/0 runnable") for a manifest with zero sensor entries', () => {
|
|
115
|
+
// Regression for Finding 6: `checkTools` independently inspects
|
|
116
|
+
// `status.checks`, which is also `{}` for a zero-sensor manifest —
|
|
117
|
+
// `Object.entries({}).filter(...)` is vacuously `[]`, so before the fix this
|
|
118
|
+
// read as "0 broken out of 0 sensors" -> ok: true, a clean pass for a manifest
|
|
119
|
+
// that checks nothing at all. `checkManifest`'s own `total === 0` gate happens
|
|
120
|
+
// to also catch this exact manifest shape and keeps overall status degraded —
|
|
121
|
+
// but `checkTools` must defend the same invariant on its own.
|
|
122
|
+
const noPack = make({
|
|
123
|
+
manifest: { pack: 'python', sensors: {} },
|
|
124
|
+
});
|
|
125
|
+
const report = (0, checks_1.preflight)(noPack);
|
|
126
|
+
expect(check(report, 'tools').ok).toBe(false);
|
|
127
|
+
expect(report.status).toBe('degraded');
|
|
128
|
+
});
|
|
99
129
|
it('flags a manifest stuck on generic while the tree has a real stack', () => {
|
|
100
130
|
// The gate would run, report green, and have checked almost nothing.
|
|
101
131
|
const dir = make({
|
|
@@ -128,9 +158,14 @@ describe('preflight', () => {
|
|
|
128
158
|
expect((0, preflight_1.exitCodeFor)({ status: 'ready', checks: [] })).toBe(0);
|
|
129
159
|
});
|
|
130
160
|
describe('host check (advisory — never changes the exit code)', () => {
|
|
161
|
+
// Fixtures below use a non-empty, deliberately-opted-out manifest (one sensor
|
|
162
|
+
// entry, `enabled: false`), not `sensors: {}` — the host check is orthogonal to
|
|
163
|
+
// sensor configuration, and an empty sensors object now fails `checkManifest`
|
|
164
|
+
// (see the `total === 0` branch), which would drag `report.status` off 'ready'
|
|
165
|
+
// for reasons unrelated to what these tests exercise.
|
|
131
166
|
beforeEach(() => { mockExecSync.mockReset(); });
|
|
132
167
|
it('reports github + gh available, and does not affect status', () => {
|
|
133
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
168
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
134
169
|
gitRepo(dir, 'git@github.com:kodria/agentic-workflow.git');
|
|
135
170
|
mockExecSync.mockImplementation(((cmd) => {
|
|
136
171
|
if (cmd === 'command -v gh')
|
|
@@ -145,7 +180,7 @@ describe('preflight', () => {
|
|
|
145
180
|
it('is still ok:true (advisory only) when gitlab is detected but glab is not on PATH, and status stays ready', () => {
|
|
146
181
|
// The only thing "wrong" in this fixture is the missing `glab` — proving the
|
|
147
182
|
// advisory contract: it must not drag an otherwise-clean repo to `degraded`.
|
|
148
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
183
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
149
184
|
gitRepo(dir, 'https://gitlab.com/kodria/agentic-workflow.git');
|
|
150
185
|
mockExecSync.mockImplementation((() => {
|
|
151
186
|
throw new Error('not found');
|
|
@@ -157,7 +192,7 @@ describe('preflight', () => {
|
|
|
157
192
|
expect(report.status).toBe('ready');
|
|
158
193
|
});
|
|
159
194
|
it('handles no origin remote gracefully — no throw, ok:true, minimal detail', () => {
|
|
160
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
195
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
161
196
|
// Not a git repo at all — the common case for `execFileSync` failing here.
|
|
162
197
|
const report = (0, checks_1.preflight)(dir);
|
|
163
198
|
expect(check(report, 'host').ok).toBe(true);
|
|
@@ -166,14 +201,14 @@ describe('preflight', () => {
|
|
|
166
201
|
expect(report.status).toBe('ready');
|
|
167
202
|
});
|
|
168
203
|
it('handles a git repo with no origin remote configured gracefully', () => {
|
|
169
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
204
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
170
205
|
gitRepo(dir); // git init, no remote
|
|
171
206
|
const report = (0, checks_1.preflight)(dir);
|
|
172
207
|
expect(check(report, 'host').ok).toBe(true);
|
|
173
208
|
expect(check(report, 'host').detail).toContain('no git remote detected');
|
|
174
209
|
});
|
|
175
210
|
it('does not overclaim support for an unrecognized host', () => {
|
|
176
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
211
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
177
212
|
gitRepo(dir, 'git@bitbucket.org:kodria/agentic-workflow.git');
|
|
178
213
|
const report = (0, checks_1.preflight)(dir);
|
|
179
214
|
expect(check(report, 'host').ok).toBe(true);
|
|
@@ -185,7 +220,7 @@ describe('preflight', () => {
|
|
|
185
220
|
// string, so an org/repo name containing "gitlab" false-positives even though
|
|
186
221
|
// the actual host is unrelated. Hostname must be extracted first and matched
|
|
187
222
|
// in isolation.
|
|
188
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
223
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
189
224
|
gitRepo(dir, 'git@github.enterprise.internal:kodria/gitlab-migration-tool.git');
|
|
190
225
|
const report = (0, checks_1.preflight)(dir);
|
|
191
226
|
expect(check(report, 'host').ok).toBe(true);
|
|
@@ -195,7 +230,7 @@ describe('preflight', () => {
|
|
|
195
230
|
it('does not misclassify a non-GitHub host whose repo NAME contains "github"', () => {
|
|
196
231
|
// Same class of bug on the github side: "something-github-tool" is a repo
|
|
197
232
|
// name, not the host.
|
|
198
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
233
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
199
234
|
gitRepo(dir, 'https://example.com/kodria/something-github-tool.git');
|
|
200
235
|
const report = (0, checks_1.preflight)(dir);
|
|
201
236
|
expect(check(report, 'host').ok).toBe(true);
|
|
@@ -214,7 +249,7 @@ describe('preflight', () => {
|
|
|
214
249
|
// correctly classifies as github (checkHost's own substring matching is a
|
|
215
250
|
// separate, pre-existing design, not part of this fix). The regression this
|
|
216
251
|
// test guards is that it must never again read as gitlab.
|
|
217
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
252
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
218
253
|
gitRepo(dir, 'ssh://gitlab@github.company-internal.com:22/team/repo.git');
|
|
219
254
|
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
220
255
|
const report = (0, checks_1.preflight)(dir);
|
|
@@ -228,7 +263,7 @@ describe('preflight', () => {
|
|
|
228
263
|
// `git remote set-url origin https://x-access-token:$TOKEN@host/...`. If the
|
|
229
264
|
// token or password happens to contain "gitlab", it must not leak into the
|
|
230
265
|
// matched host either.
|
|
231
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
266
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
232
267
|
gitRepo(dir, 'https://user:gitlab@example-host.com/org/repo.git');
|
|
233
268
|
const report = (0, checks_1.preflight)(dir);
|
|
234
269
|
expect(check(report, 'host').ok).toBe(true);
|
|
@@ -241,7 +276,7 @@ describe('preflight', () => {
|
|
|
241
276
|
// not let a bogus "host@evil"-shaped capture slip past the colon check —
|
|
242
277
|
// the host-capture group excludes "@", so this fails to match at all and
|
|
243
278
|
// falls through to "unrecognized" rather than misclassifying as gitlab.
|
|
244
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
279
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
245
280
|
gitRepo(dir, 'user@github.com@gitlab.evil:org/repo.git');
|
|
246
281
|
const report = (0, checks_1.preflight)(dir);
|
|
247
282
|
expect(check(report, 'host').ok).toBe(true);
|
|
@@ -249,28 +284,28 @@ describe('preflight', () => {
|
|
|
249
284
|
expect(report.status).toBe('ready');
|
|
250
285
|
});
|
|
251
286
|
it('still detects github.com over HTTPS', () => {
|
|
252
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
287
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
253
288
|
gitRepo(dir, 'https://github.com/org/repo.git');
|
|
254
289
|
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
255
290
|
const report = (0, checks_1.preflight)(dir);
|
|
256
291
|
expect(check(report, 'host').detail).toContain('github detected');
|
|
257
292
|
});
|
|
258
293
|
it('still detects github.com over SSH shorthand', () => {
|
|
259
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
294
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
260
295
|
gitRepo(dir, 'git@github.com:org/repo.git');
|
|
261
296
|
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
262
297
|
const report = (0, checks_1.preflight)(dir);
|
|
263
298
|
expect(check(report, 'host').detail).toContain('github detected');
|
|
264
299
|
});
|
|
265
300
|
it('still detects gitlab over HTTPS', () => {
|
|
266
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
301
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
267
302
|
gitRepo(dir, 'https://gitlab.example.com/org/repo.git');
|
|
268
303
|
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
269
304
|
const report = (0, checks_1.preflight)(dir);
|
|
270
305
|
expect(check(report, 'host').detail).toContain('gitlab detected');
|
|
271
306
|
});
|
|
272
307
|
it('still detects gitlab over SSH shorthand', () => {
|
|
273
|
-
const dir = make({ manifest: { pack: 'generic', sensors: {} } });
|
|
308
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
274
309
|
gitRepo(dir, 'git@gitlab.example.com:org/repo.git');
|
|
275
310
|
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
276
311
|
const report = (0, checks_1.preflight)(dir);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mypy_1 = require("../../../../src/commands/sensors/formatters/mypy");
|
|
4
|
+
describe('parseMypyOutput', () => {
|
|
5
|
+
it('parses a single mypy error line (real captured output)', () => {
|
|
6
|
+
const raw = 'bad.py:6: error: Incompatible return value type (got "int", expected "str") [return-value]\n'
|
|
7
|
+
+ 'Found 1 error in 1 file (checked 1 source file)';
|
|
8
|
+
const errors = (0, mypy_1.parseMypyOutput)(raw);
|
|
9
|
+
expect(errors).toHaveLength(1);
|
|
10
|
+
expect(errors[0].file).toBe('bad.py');
|
|
11
|
+
expect(errors[0].line).toBe(6);
|
|
12
|
+
expect(errors[0].rule).toBe('return-value');
|
|
13
|
+
expect(errors[0].message).toMatch('SENSOR[typecheck]');
|
|
14
|
+
expect(errors[0].message).toMatch('Fix:');
|
|
15
|
+
expect(errors[0].column).toBeUndefined(); // plain mypy output has no column
|
|
16
|
+
});
|
|
17
|
+
it('parses multiple error lines and ignores the trailing summary', () => {
|
|
18
|
+
const raw = 'multi.py:2: error: Incompatible return value type (got "int", expected "str") [return-value]\n'
|
|
19
|
+
+ 'multi.py:5: error: Incompatible return value type (got "str", expected "int") [return-value]\n'
|
|
20
|
+
+ 'multi.py:7: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]\n'
|
|
21
|
+
+ 'Found 3 errors in 1 file (checked 1 source file)';
|
|
22
|
+
const errors = (0, mypy_1.parseMypyOutput)(raw);
|
|
23
|
+
expect(errors).toHaveLength(3);
|
|
24
|
+
expect(errors.map(e => e.line)).toEqual([2, 5, 7]);
|
|
25
|
+
expect(errors[2].rule).toBe('assignment');
|
|
26
|
+
});
|
|
27
|
+
it('excludes `note:` lines — only `error:` lines are findings', () => {
|
|
28
|
+
// Real captured output: `reveal_type()` prints a note line ahead of the actual
|
|
29
|
+
// error, and an incompatible override reports without a trailing summary change.
|
|
30
|
+
const raw = 'notetest.py:2: note: Revealed type is "builtins.int"\n'
|
|
31
|
+
+ 'notetest.py:10: error: Return type "str" of "foo" incompatible with return type "int" in supertype "A" [override]\n'
|
|
32
|
+
+ 'Found 1 error in 1 file (checked 1 source file)';
|
|
33
|
+
const errors = (0, mypy_1.parseMypyOutput)(raw);
|
|
34
|
+
expect(errors).toHaveLength(1);
|
|
35
|
+
expect(errors[0].line).toBe(10);
|
|
36
|
+
expect(errors[0].rule).toBe('override');
|
|
37
|
+
});
|
|
38
|
+
it('handles an error line with no trailing [code] bracket', () => {
|
|
39
|
+
// Synthetic case: attempted to reproduce a real mypy output line missing the
|
|
40
|
+
// bracketed error code against mypy 1.19.1 (syntax errors, import errors,
|
|
41
|
+
// `--warn-unused-ignores`, `--warn-redundant-casts`, unterminated strings,
|
|
42
|
+
// deep generic instantiation) — every error line this environment's mypy
|
|
43
|
+
// 1.19.1 produced included the `[code]` suffix (error codes have been attached
|
|
44
|
+
// to essentially all builtin error messages since they were introduced in
|
|
45
|
+
// mypy 0.730, per the mypy changelog). No real bracket-less line was found, so
|
|
46
|
+
// this fixture stays synthetic. The regex's optional bracket is kept
|
|
47
|
+
// defensively regardless — a plugin-emitted error, a third-party mypy
|
|
48
|
+
// extension, or an older mypy version could plausibly still omit it, and the
|
|
49
|
+
// parser must not crash or misparse if so.
|
|
50
|
+
const raw = 'foo.py:3: error: some mypy error kinds omit the bracketed code';
|
|
51
|
+
const errors = (0, mypy_1.parseMypyOutput)(raw);
|
|
52
|
+
expect(errors).toHaveLength(1);
|
|
53
|
+
expect(errors[0].rule).toBeUndefined();
|
|
54
|
+
expect(errors[0].message).toContain('n/a');
|
|
55
|
+
});
|
|
56
|
+
it('returns empty array for a clean run (real captured success line)', () => {
|
|
57
|
+
expect((0, mypy_1.parseMypyOutput)('Success: no issues found in 1 source file')).toEqual([]);
|
|
58
|
+
expect((0, mypy_1.parseMypyOutput)('')).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const ruff_1 = require("../../../../src/commands/sensors/formatters/ruff");
|
|
4
|
+
// Real `ruff check . --output-format json` output, captured against a fabricated
|
|
5
|
+
// fixture (unused import + unused local variable).
|
|
6
|
+
const SAMPLE = JSON.stringify([
|
|
7
|
+
{
|
|
8
|
+
cell: null,
|
|
9
|
+
code: 'F401',
|
|
10
|
+
end_location: { column: 10, row: 1 },
|
|
11
|
+
filename: '/home/user/project/bad.py',
|
|
12
|
+
fix: {
|
|
13
|
+
applicability: 'safe',
|
|
14
|
+
edits: [{ content: '', end_location: { column: 1, row: 2 }, location: { column: 1, row: 1 } }],
|
|
15
|
+
message: 'Remove unused import: `os`',
|
|
16
|
+
},
|
|
17
|
+
location: { column: 8, row: 1 },
|
|
18
|
+
message: '`os` imported but unused',
|
|
19
|
+
noqa_row: 1,
|
|
20
|
+
severity: 'error',
|
|
21
|
+
url: 'https://docs.astral.sh/ruff/rules/unused-import',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
cell: null,
|
|
25
|
+
code: 'F841',
|
|
26
|
+
end_location: { column: 6, row: 4 },
|
|
27
|
+
filename: '/home/user/project/bad.py',
|
|
28
|
+
fix: {
|
|
29
|
+
applicability: 'unsafe',
|
|
30
|
+
edits: [{ content: '', end_location: { column: 1, row: 5 }, location: { column: 1, row: 4 } }],
|
|
31
|
+
message: 'Remove assignment to unused variable `x`',
|
|
32
|
+
},
|
|
33
|
+
location: { column: 5, row: 4 },
|
|
34
|
+
message: 'Local variable `x` is assigned to but never used',
|
|
35
|
+
noqa_row: 4,
|
|
36
|
+
severity: 'error',
|
|
37
|
+
url: 'https://docs.astral.sh/ruff/rules/unused-variable',
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
describe('parseRuffOutput', () => {
|
|
41
|
+
let cwdSpy;
|
|
42
|
+
beforeEach(() => { cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue('/home/user/project'); });
|
|
43
|
+
afterEach(() => { cwdSpy.mockRestore(); });
|
|
44
|
+
it('parses ruff JSON output into SensorErrors', () => {
|
|
45
|
+
const errors = (0, ruff_1.parseRuffOutput)(SAMPLE);
|
|
46
|
+
expect(errors).toHaveLength(2);
|
|
47
|
+
expect(errors[0].file).toBe('bad.py'); // relativized against cwd
|
|
48
|
+
expect(errors[0].line).toBe(1);
|
|
49
|
+
expect(errors[0].column).toBe(8);
|
|
50
|
+
expect(errors[0].rule).toBe('F401');
|
|
51
|
+
expect(errors[0].message).toMatch('SENSOR[lint]');
|
|
52
|
+
expect(errors[0].message).toMatch('Fix:');
|
|
53
|
+
expect(errors[1].rule).toBe('F841');
|
|
54
|
+
});
|
|
55
|
+
it('returns empty array for a clean run ([])', () => {
|
|
56
|
+
expect((0, ruff_1.parseRuffOutput)('[]')).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
it('returns empty array for malformed JSON', () => {
|
|
59
|
+
expect((0, ruff_1.parseRuffOutput)('not json')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
// Regression for Finding 2: valid JSON that isn't the expected shape (object, null,
|
|
62
|
+
// number) must not throw when iterated — `JSON.parse` succeeding is not the same as
|
|
63
|
+
// the result being an array.
|
|
64
|
+
it.each([['{}'], ['null'], ['42'], ['"a string"']])('returns empty array for valid-but-non-array JSON: %s', (raw) => {
|
|
65
|
+
expect(() => (0, ruff_1.parseRuffOutput)(raw)).not.toThrow();
|
|
66
|
+
expect((0, ruff_1.parseRuffOutput)(raw)).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
it('skips a null array element instead of crashing', () => {
|
|
69
|
+
expect(() => (0, ruff_1.parseRuffOutput)('[null]')).not.toThrow();
|
|
70
|
+
expect((0, ruff_1.parseRuffOutput)('[null]')).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
it('skips an element with a null/missing location instead of crashing on .row/.column', () => {
|
|
73
|
+
const raw = JSON.stringify([
|
|
74
|
+
{ code: 'F401', filename: '/home/user/project/a.py', location: null, message: 'x' },
|
|
75
|
+
]);
|
|
76
|
+
expect(() => (0, ruff_1.parseRuffOutput)(raw)).not.toThrow();
|
|
77
|
+
expect((0, ruff_1.parseRuffOutput)(raw)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
it('skips a malformed element but still returns valid elements from the same array', () => {
|
|
80
|
+
const raw = JSON.stringify([
|
|
81
|
+
null,
|
|
82
|
+
{ code: 'F401', filename: '/home/user/project/a.py', location: null, message: 'bad' },
|
|
83
|
+
{
|
|
84
|
+
code: 'F841', filename: '/home/user/project/bad.py',
|
|
85
|
+
location: { column: 5, row: 4 }, message: 'Local variable `x` is assigned to but never used',
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
const errors = (0, ruff_1.parseRuffOutput)(raw);
|
|
89
|
+
expect(errors).toHaveLength(1);
|
|
90
|
+
expect(errors[0].rule).toBe('F841');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const shellcheck_1 = require("../../../../src/commands/sensors/formatters/shellcheck");
|
|
4
|
+
describe('parseShellcheckOutput', () => {
|
|
5
|
+
it('parses an error-level finding (real captured output: unbalanced if/then)', () => {
|
|
6
|
+
const raw = JSON.stringify([
|
|
7
|
+
{ file: 'syntaxerr.sh', line: 2, endLine: 2, column: 1, endColumn: 1, level: 'error', code: 1049, message: "Did you forget the 'then' for this 'if'?", fix: null },
|
|
8
|
+
]);
|
|
9
|
+
const errors = (0, shellcheck_1.parseShellcheckOutput)(raw);
|
|
10
|
+
expect(errors).toHaveLength(1);
|
|
11
|
+
expect(errors[0].file).toBe('syntaxerr.sh');
|
|
12
|
+
expect(errors[0].line).toBe(2);
|
|
13
|
+
expect(errors[0].column).toBe(1);
|
|
14
|
+
expect(errors[0].rule).toBe('SC1049'); // shellcheck's own stable SC-prefix convention
|
|
15
|
+
expect(errors[0].message).toMatch('SENSOR[lint]');
|
|
16
|
+
expect(errors[0].message).toMatch('https://www.shellcheck.net/wiki/SC1049');
|
|
17
|
+
});
|
|
18
|
+
it('parses a warning-level finding (real captured output: unused variable)', () => {
|
|
19
|
+
const raw = JSON.stringify([
|
|
20
|
+
{ file: 'bad.sh', line: 7, endLine: 7, column: 1, endColumn: 4, level: 'warning', code: 2034, message: 'FOO appears unused. Verify use (or export if used externally).', fix: null },
|
|
21
|
+
]);
|
|
22
|
+
const errors = (0, shellcheck_1.parseShellcheckOutput)(raw);
|
|
23
|
+
expect(errors).toHaveLength(1);
|
|
24
|
+
expect(errors[0].rule).toBe('SC2034');
|
|
25
|
+
});
|
|
26
|
+
// Deliberate choice, mirroring eslint.ts's `severity < 2` filter (which drops
|
|
27
|
+
// eslint's "warn"): shellcheck's `info`/`style` levels are advisory — quoting
|
|
28
|
+
// preferences, portability nits, not genuine breakage — and are excluded so
|
|
29
|
+
// findings stay real problems rather than 100% of shellcheck's advisory noise.
|
|
30
|
+
// Only `error`/`warning` are reported.
|
|
31
|
+
it('excludes info- and style-level findings (real captured output: SC2086, SC2268)', () => {
|
|
32
|
+
const raw = JSON.stringify([
|
|
33
|
+
{ file: 'bad.sh', line: 4, endLine: 4, column: 6, endColumn: 9, level: 'style', code: 2268, message: 'Avoid x-prefix in comparisons as it no longer serves a purpose.', fix: null },
|
|
34
|
+
{ file: 'bad.sh', line: 4, endLine: 4, column: 7, endColumn: 9, level: 'info', code: 2086, message: 'Double quote to prevent globbing and word splitting.', fix: null },
|
|
35
|
+
]);
|
|
36
|
+
expect((0, shellcheck_1.parseShellcheckOutput)(raw)).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
it('returns empty array for a clean run (real captured output: [])', () => {
|
|
39
|
+
expect((0, shellcheck_1.parseShellcheckOutput)('[]')).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
it('returns empty array for malformed JSON', () => {
|
|
42
|
+
expect((0, shellcheck_1.parseShellcheckOutput)('not json')).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
// Regression for Finding 2: valid JSON that isn't the expected shape (object, null,
|
|
45
|
+
// number) must not throw when iterated — `JSON.parse` succeeding is not the same as
|
|
46
|
+
// the result being an array.
|
|
47
|
+
it.each([['{}'], ['null'], ['42'], ['"a string"']])('returns empty array for valid-but-non-array JSON: %s', (raw) => {
|
|
48
|
+
expect(() => (0, shellcheck_1.parseShellcheckOutput)(raw)).not.toThrow();
|
|
49
|
+
expect((0, shellcheck_1.parseShellcheckOutput)(raw)).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
it('skips a null array element instead of crashing', () => {
|
|
52
|
+
expect(() => (0, shellcheck_1.parseShellcheckOutput)('[null]')).not.toThrow();
|
|
53
|
+
expect((0, shellcheck_1.parseShellcheckOutput)('[null]')).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
it('skips a malformed element but still returns valid elements from the same array', () => {
|
|
56
|
+
const raw = JSON.stringify([
|
|
57
|
+
null,
|
|
58
|
+
{ file: 'bad.sh', line: 7, column: 1, level: 'warning' }, // missing code/message
|
|
59
|
+
{ file: 'bad.sh', line: 7, endLine: 7, column: 1, endColumn: 4, level: 'warning', code: 2034, message: 'FOO appears unused.', fix: null },
|
|
60
|
+
]);
|
|
61
|
+
const errors = (0, shellcheck_1.parseShellcheckOutput)(raw);
|
|
62
|
+
expect(errors).toHaveLength(1);
|
|
63
|
+
expect(errors[0].rule).toBe('SC2034');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -16,14 +16,30 @@ function makeRegistry() {
|
|
|
16
16
|
fs_1.default.writeFileSync(path_1.default.join(packDir, 'pack.json'), JSON.stringify({
|
|
17
17
|
name: 'js-ts',
|
|
18
18
|
sensors: {
|
|
19
|
-
typecheck: { fast: true, defaultCmd: 'npx tsc --noEmit' },
|
|
20
|
-
lint: { fast: true, defaultCmd: 'npx eslint . --config eslint.config.awm.mjs --cache --format json' },
|
|
19
|
+
typecheck: { fast: true, defaultCmd: 'npx tsc --noEmit', formatter: 'tsc' },
|
|
20
|
+
lint: { fast: true, defaultCmd: 'npx eslint . --config eslint.config.awm.mjs --cache --format json', formatter: 'eslint-llm' },
|
|
21
21
|
depcheck: { fast: false, defaultCmd: 'npx depcruise --config .dep-cruiser.awm.js {{SOURCE_DIRS}}' },
|
|
22
22
|
mutation: { fast: false, enabled: false, defaultCmd: 'npx stryker run' },
|
|
23
23
|
},
|
|
24
24
|
}));
|
|
25
25
|
return registryRoot;
|
|
26
26
|
}
|
|
27
|
+
// Mirrors makeRegistry()'s js-ts shape but for a python pack.json that declares
|
|
28
|
+
// `formatter` on `typecheck` — needed for the buildManifest per-field-merge
|
|
29
|
+
// regression test (a pre-`formatter`-era existing manifest must still inherit it).
|
|
30
|
+
function makePythonRegistry() {
|
|
31
|
+
const registryRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-reg-py-'));
|
|
32
|
+
const packDir = path_1.default.join(registryRoot, 'sensor-packs', 'python');
|
|
33
|
+
fs_1.default.mkdirSync(packDir, { recursive: true });
|
|
34
|
+
fs_1.default.writeFileSync(path_1.default.join(packDir, 'pack.json'), JSON.stringify({
|
|
35
|
+
name: 'python',
|
|
36
|
+
sensors: {
|
|
37
|
+
typecheck: { fast: true, defaultCmd: 'mypy .', formatter: 'mypy' },
|
|
38
|
+
lint: { fast: true, defaultCmd: 'ruff check --output-format=json .', formatter: 'ruff' },
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
return registryRoot;
|
|
42
|
+
}
|
|
27
43
|
describe('detectStack', () => {
|
|
28
44
|
let tmpDir;
|
|
29
45
|
beforeEach(() => { tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-')); });
|
|
@@ -39,6 +55,60 @@ describe('detectStack', () => {
|
|
|
39
55
|
it('falls back to generic when no indicators found', () => {
|
|
40
56
|
expect((0, init_1.detectStack)(tmpDir).pack).toBe('generic');
|
|
41
57
|
});
|
|
58
|
+
it('detects shell from a root-level *.sh file when no js-ts/python marker exists', () => {
|
|
59
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'deploy.sh'), '#!/bin/sh\n');
|
|
60
|
+
const result = (0, init_1.detectStack)(tmpDir);
|
|
61
|
+
expect(result.pack).toBe('shell');
|
|
62
|
+
expect(result.indicators).toEqual(['deploy.sh']);
|
|
63
|
+
});
|
|
64
|
+
it('detects shell from a scripts/*.sh file when root has nothing', () => {
|
|
65
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, 'scripts'));
|
|
66
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'scripts', 'build.sh'), '#!/bin/sh\n');
|
|
67
|
+
const result = (0, init_1.detectStack)(tmpDir);
|
|
68
|
+
expect(result.pack).toBe('shell');
|
|
69
|
+
expect(result.indicators).toEqual([path_1.default.join('scripts', 'build.sh')]);
|
|
70
|
+
});
|
|
71
|
+
it('js-ts wins over shell when both package.json and a root .sh file exist', () => {
|
|
72
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'package.json'), '{}');
|
|
73
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'deploy.sh'), '#!/bin/sh\n');
|
|
74
|
+
expect((0, init_1.detectStack)(tmpDir).pack).toBe('js-ts');
|
|
75
|
+
});
|
|
76
|
+
it('python wins over shell when both a python marker and a root .sh file exist', () => {
|
|
77
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'pyproject.toml'), '');
|
|
78
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'deploy.sh'), '#!/bin/sh\n');
|
|
79
|
+
expect((0, init_1.detectStack)(tmpDir).pack).toBe('python');
|
|
80
|
+
});
|
|
81
|
+
it('falls through to generic when scripts/ has only non-.sh files (glob must not over-match)', () => {
|
|
82
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, 'scripts'));
|
|
83
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'scripts', 'notes.txt'), 'not shell');
|
|
84
|
+
expect((0, init_1.detectStack)(tmpDir).pack).toBe('generic');
|
|
85
|
+
});
|
|
86
|
+
it('detects python from requirements.txt alone', () => {
|
|
87
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'requirements.txt'), '');
|
|
88
|
+
expect((0, init_1.detectStack)(tmpDir).pack).toBe('python');
|
|
89
|
+
});
|
|
90
|
+
it('detects python from Pipfile alone', () => {
|
|
91
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'Pipfile'), '');
|
|
92
|
+
expect((0, init_1.detectStack)(tmpDir).pack).toBe('python');
|
|
93
|
+
});
|
|
94
|
+
it('python (via Pipfile) wins over shell when both a Pipfile and a root .sh file exist', () => {
|
|
95
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'Pipfile'), '');
|
|
96
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'deploy.sh'), '#!/bin/sh\n');
|
|
97
|
+
expect((0, init_1.detectStack)(tmpDir).pack).toBe('python');
|
|
98
|
+
});
|
|
99
|
+
it('does not report a directory named "*.sh" as a shell indicator', () => {
|
|
100
|
+
// Directory literally named `something.sh` (not a file) — findShellIndicators'
|
|
101
|
+
// `entry.isFile()` guard must exclude it. Nothing else present → generic.
|
|
102
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, 'something.sh'));
|
|
103
|
+
expect((0, init_1.detectStack)(tmpDir).pack).toBe('generic');
|
|
104
|
+
});
|
|
105
|
+
it('ignores a directory named "*.sh" but still finds a real .sh file alongside it', () => {
|
|
106
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, 'notreal.sh'));
|
|
107
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, 'deploy.sh'), '#!/bin/sh\n');
|
|
108
|
+
const result = (0, init_1.detectStack)(tmpDir);
|
|
109
|
+
expect(result.pack).toBe('shell');
|
|
110
|
+
expect(result.indicators).toEqual(['deploy.sh']);
|
|
111
|
+
});
|
|
42
112
|
});
|
|
43
113
|
describe('detectSourceDirs', () => {
|
|
44
114
|
let tmpDir;
|
|
@@ -87,9 +157,45 @@ describe('buildManifest', () => {
|
|
|
87
157
|
expect(m.sensors.typecheck.cmd).toBe('custom-tsc');
|
|
88
158
|
expect(m.sensors.lint).toBeDefined();
|
|
89
159
|
});
|
|
90
|
-
it('
|
|
160
|
+
it('carries the formatter field through from pack.json into the built manifest', () => {
|
|
161
|
+
// readPackDefaults must copy `formatter` the same way it already copies
|
|
162
|
+
// `changedCmd`/`changedExtensions` — this is what lets run.ts's getFormatter
|
|
163
|
+
// dispatch by real tool (ruff/mypy/shellcheck) instead of guessing from the
|
|
164
|
+
// sensor name. Without this carry-through the field is read from pack.json but
|
|
165
|
+
// silently dropped before it ever reaches the manifest run.ts consumes.
|
|
166
|
+
const m = (0, init_1.buildManifest)('js-ts', undefined, registryRoot, cwd);
|
|
167
|
+
expect(m.sensors.typecheck.formatter).toBe('tsc');
|
|
168
|
+
expect(m.sensors.lint.formatter).toBe('eslint-llm');
|
|
169
|
+
});
|
|
170
|
+
it('returns an empty sensors object when the pack has no pack.json in the registry', () => {
|
|
171
|
+
// No FALLBACK_DEFAULTS anymore: `python` has no pack dir in this fixture
|
|
172
|
+
// registry (only js-ts does — see makeRegistry) → the honest floor is `{}`,
|
|
173
|
+
// never CLI-hardcoded commands that can drift from what the registry ships.
|
|
91
174
|
const m = (0, init_1.buildManifest)('python', undefined, registryRoot, cwd);
|
|
92
|
-
expect(m.sensors
|
|
175
|
+
expect(m.sensors).toEqual({});
|
|
176
|
+
});
|
|
177
|
+
it('per-field merge: an existing sensor missing a newer pack field still inherits it', () => {
|
|
178
|
+
// Regression for Finding 1: a manifest written by the old FALLBACK_DEFAULTS-era
|
|
179
|
+
// CLI has `typecheck: { cmd: 'mypy .', fast: true }` — no `formatter`, because
|
|
180
|
+
// that field didn't exist yet. A naive `{ ...defaults, ...existingSensors }`
|
|
181
|
+
// whole-sensor-object merge would replace `defaults.typecheck` wholesale,
|
|
182
|
+
// permanently dropping `formatter` even though the (upgraded) pack now declares
|
|
183
|
+
// it. The fix merges per FIELD within each sensor, so `formatter` — a field the
|
|
184
|
+
// existing manifest never specified — is inherited from the pack default.
|
|
185
|
+
const pyRegistryRoot = makePythonRegistry();
|
|
186
|
+
try {
|
|
187
|
+
const existing = {
|
|
188
|
+
pack: 'python',
|
|
189
|
+
sensors: { typecheck: { cmd: 'mypy .', fast: true } },
|
|
190
|
+
};
|
|
191
|
+
const m = (0, init_1.buildManifest)('python', existing, pyRegistryRoot, cwd);
|
|
192
|
+
expect(m.sensors.typecheck.formatter).toBe('mypy');
|
|
193
|
+
expect(m.sensors.typecheck.cmd).toBe('mypy .');
|
|
194
|
+
expect(m.sensors.typecheck.fast).toBe(true);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
fs_1.default.rmSync(pyRegistryRoot, { recursive: true });
|
|
198
|
+
}
|
|
93
199
|
});
|
|
94
200
|
});
|
|
95
201
|
describe('initSensors', () => {
|
|
@@ -135,3 +241,52 @@ describe('initSensors', () => {
|
|
|
135
241
|
expect(fs_1.default.existsSync(path_1.default.join(tmpDir, 'tsconfig.awm.json'))).toBe(false);
|
|
136
242
|
});
|
|
137
243
|
});
|
|
244
|
+
describe('initSensors — --pack override', () => {
|
|
245
|
+
let tmpDir;
|
|
246
|
+
let registryRoot;
|
|
247
|
+
beforeEach(() => {
|
|
248
|
+
tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-pack-'));
|
|
249
|
+
registryRoot = makeRegistry(); // only ships a js-ts pack dir — see makeRegistry
|
|
250
|
+
});
|
|
251
|
+
afterEach(() => {
|
|
252
|
+
fs_1.default.rmSync(tmpDir, { recursive: true });
|
|
253
|
+
fs_1.default.rmSync(registryRoot, { recursive: true });
|
|
254
|
+
});
|
|
255
|
+
it('skips detection and uses the override pack when it exists in the registry', () => {
|
|
256
|
+
// No package.json/pyproject.toml here — if detection ran, this would be 'generic'.
|
|
257
|
+
const result = (0, init_1.initSensors)({ pack: 'js-ts', registryRoot, cwd: tmpDir });
|
|
258
|
+
expect(result.detection.pack).toBe('js-ts');
|
|
259
|
+
// Indicators must reflect an override, not file-based detection.
|
|
260
|
+
expect(result.detection.indicators).not.toEqual(['package.json']);
|
|
261
|
+
expect(result.detection.indicators.join(' ')).toMatch(/pack override/i);
|
|
262
|
+
});
|
|
263
|
+
it('throws listing available packs when the override pack is not in the registry', () => {
|
|
264
|
+
expect(() => (0, init_1.initSensors)({ pack: 'bogus', registryRoot, cwd: tmpDir })).toThrow(/js-ts/);
|
|
265
|
+
try {
|
|
266
|
+
(0, init_1.initSensors)({ pack: 'bogus', registryRoot, cwd: tmpDir });
|
|
267
|
+
throw new Error('expected initSensors to throw');
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
expect(e.message).toContain('bogus');
|
|
271
|
+
expect(e.message).toContain('js-ts');
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
it('does not throw when no registryRoot is given — nothing to validate against', () => {
|
|
275
|
+
expect(() => (0, init_1.initSensors)({ pack: 'anything', cwd: tmpDir })).not.toThrow();
|
|
276
|
+
const result = (0, init_1.initSensors)({ pack: 'anything', cwd: tmpDir });
|
|
277
|
+
expect(result.detection.pack).toBe('anything');
|
|
278
|
+
});
|
|
279
|
+
it('throws a distinct message when the registry root has no sensor-packs directory at all', () => {
|
|
280
|
+
// Different failure shape from "pack not in the list": the registry root
|
|
281
|
+
// itself is missing sensor-packs/, so there's no list to show — must say
|
|
282
|
+
// so plainly instead of reporting an empty `available: `.
|
|
283
|
+
const emptyRegistryRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-empty-reg-'));
|
|
284
|
+
try {
|
|
285
|
+
expect(() => (0, init_1.initSensors)({ pack: 'js-ts', registryRoot: emptyRegistryRoot, cwd: tmpDir }))
|
|
286
|
+
.toThrow(/no sensor-packs directory/);
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
fs_1.default.rmSync(emptyRegistryRoot, { recursive: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -86,6 +86,79 @@ describe('runSensors', () => {
|
|
|
86
86
|
expect(sec.status).toBe('skipped');
|
|
87
87
|
expect(sec.skipReason).toBe('disabled');
|
|
88
88
|
});
|
|
89
|
+
it('dispatches by the formatter field, not the sensor name — ruff on a `lint` sensor', async () => {
|
|
90
|
+
// The whole point of the `formatter` field: a `lint` sensor is eslint for
|
|
91
|
+
// js-ts but ruff for python. Old name-based dispatch (lint -> eslint parser)
|
|
92
|
+
// would choke on ruff's flat JSON array (no `.messages` — TypeError) instead
|
|
93
|
+
// of parsing it. Real captured `ruff --output-format json` shape.
|
|
94
|
+
const pythonManifest = {
|
|
95
|
+
pack: 'python',
|
|
96
|
+
sensors: {
|
|
97
|
+
lint: { cmd: 'ruff check . --output-format json', fast: true, formatter: 'ruff' },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
fs_1.default.writeFileSync(path.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify(pythonManifest));
|
|
101
|
+
const ruffJson = JSON.stringify([{
|
|
102
|
+
code: 'F401',
|
|
103
|
+
filename: path.join(tmpDir, 'bad.py'),
|
|
104
|
+
location: { row: 1, column: 8 },
|
|
105
|
+
message: '`os` imported but unused',
|
|
106
|
+
}]);
|
|
107
|
+
mockRunCommand.mockResolvedValueOnce(exited(1, ruffJson));
|
|
108
|
+
const { runSensors } = load();
|
|
109
|
+
const result = await runSensors({ fast: true, cwd: tmpDir });
|
|
110
|
+
const lint = result.sensors.find((s) => s.name === 'lint');
|
|
111
|
+
expect(lint.status).toBe('fail');
|
|
112
|
+
expect(lint.errors[0].rule).toBe('F401');
|
|
113
|
+
expect(lint.errors[0].message).toMatch('SENSOR[lint]');
|
|
114
|
+
expect(lint.errors[0].message).toMatch('imported but unused');
|
|
115
|
+
});
|
|
116
|
+
it('falls back to name-based dispatch when formatter is absent (pre-existing manifest)', async () => {
|
|
117
|
+
// A manifest written before the `formatter` field existed must keep working
|
|
118
|
+
// exactly as before: `lint` -> eslint parser, no `formatter` key anywhere.
|
|
119
|
+
mockRunCommand
|
|
120
|
+
.mockResolvedValueOnce(exited(1, 'src/a.ts(1,1): error TS0001: Bad type.'))
|
|
121
|
+
.mockResolvedValueOnce(exited(1, JSON.stringify([{ filePath: '/x/a.js', messages: [{ ruleId: 'no-unused-vars', severity: 2, message: 'unused', line: 1, column: 1 }] }])));
|
|
122
|
+
const { runSensors } = load();
|
|
123
|
+
const result = await runSensors({ fast: true, cwd: tmpDir });
|
|
124
|
+
const lint = result.sensors.find((s) => s.name === 'lint');
|
|
125
|
+
expect(lint.status).toBe('fail');
|
|
126
|
+
expect(lint.errors[0].rule).toBe('no-unused-vars');
|
|
127
|
+
});
|
|
128
|
+
it('degrades to the generic formatter (never a wrong-shape misparse) for an unrecognized formatter value', async () => {
|
|
129
|
+
// Regression for Finding 5: `formatter: 'bandit'` is present but not one of the
|
|
130
|
+
// 8 known values. The OLD behavior fell through to name-based dispatch — sensor
|
|
131
|
+
// name 'security' -> parseSemgrepOutput — which would silently misparse
|
|
132
|
+
// bandit's differently-shaped `results` entries (no `path`/`start`/`check_id`
|
|
133
|
+
// keys) into garbage findings (`file: undefined, line: 0`). The fix must use
|
|
134
|
+
// parseGenericOutput instead: an honest raw-wrap, never a wrong-shape guess.
|
|
135
|
+
const banditManifest = {
|
|
136
|
+
pack: 'python',
|
|
137
|
+
sensors: {
|
|
138
|
+
security: { cmd: 'bandit -r . -f json', fast: false, formatter: 'bandit' },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
fs_1.default.writeFileSync(path.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify(banditManifest));
|
|
142
|
+
// Real bandit JSON shape (fields semgrep's parser does not know how to read).
|
|
143
|
+
const banditJson = JSON.stringify({
|
|
144
|
+
results: [
|
|
145
|
+
{ filename: 'app.py', line_number: 12, test_id: 'B105', issue_text: 'hardcoded password' },
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
mockRunCommand.mockResolvedValueOnce(exited(1, banditJson));
|
|
149
|
+
const { runSensors } = load();
|
|
150
|
+
const result = await runSensors({ all: true, cwd: tmpDir });
|
|
151
|
+
const sec = result.sensors.find((s) => s.name === 'security');
|
|
152
|
+
expect(sec.status).toBe('fail');
|
|
153
|
+
expect(sec.errors).toHaveLength(1);
|
|
154
|
+
// Never the semgrep-misparse shape (file: undefined, line: 0, rule: undefined).
|
|
155
|
+
expect(sec.errors[0].file).toBeUndefined();
|
|
156
|
+
expect(sec.errors[0].line).toBeUndefined();
|
|
157
|
+
expect(sec.errors[0].rule).toBeUndefined();
|
|
158
|
+
// The honest generic raw-wrap: the raw JSON, verbatim, inside one message.
|
|
159
|
+
expect(sec.errors[0].message).toMatch('SENSOR[raw]');
|
|
160
|
+
expect(sec.errors[0].message).toContain('B105');
|
|
161
|
+
});
|
|
89
162
|
const tcError = () => exited(1, 'src/a.ts(1,1): error TS0001: Bad type.');
|
|
90
163
|
it('baseline suppresses accepted findings — sensor passes on no NEW findings', async () => {
|
|
91
164
|
const { runSensors } = load();
|
|
@@ -160,6 +233,24 @@ describe('runSensors — missing tool is a fail, not a skip', () => {
|
|
|
160
233
|
expect(out.overall).toBe('fail');
|
|
161
234
|
});
|
|
162
235
|
});
|
|
236
|
+
describe('runSensors — sensors: null in a hand-edited manifest', () => {
|
|
237
|
+
// Regression for Finding 3: `checkManifest` (preflight) already guards
|
|
238
|
+
// `manifest.sensors ?? {}` — runSensors' `Object.entries(activeManifest.sensors)`
|
|
239
|
+
// needs the same guard, or a corrupted/hand-edited `.awm/sensors.json` with
|
|
240
|
+
// `"sensors": null` crashes `Object.entries(null)`, taking down the whole run.
|
|
241
|
+
it('degrades gracefully instead of throwing when sensors is null', async () => {
|
|
242
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-null-sensors-'));
|
|
243
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
244
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), JSON.stringify({ pack: 'python', sensors: null }));
|
|
245
|
+
try {
|
|
246
|
+
const result = await (0, run_1.runSensors)({ cwd: dir, all: true });
|
|
247
|
+
expect(result.sensors).toEqual([]);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
fs_1.default.rmSync(dir, { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
163
254
|
describe('runSensors — not_certified + auto-discovery', () => {
|
|
164
255
|
let tmpDir;
|
|
165
256
|
beforeEach(() => {
|
|
@@ -109,6 +109,35 @@ describe('computeSensorStatus', () => {
|
|
|
109
109
|
expect(result.checks.security.ok).toBe(true);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
it('is DEGRADED (never HEALTHY) when the manifest has zero sensor entries', () => {
|
|
113
|
+
// `Object.values({}).every(...)` is vacuously true — guard against reading an
|
|
114
|
+
// empty manifest (the honest floor when the registry had no pack.json for the
|
|
115
|
+
// detected stack) as a clean run that found nothing wrong.
|
|
116
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
117
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
118
|
+
pack: 'python',
|
|
119
|
+
sensors: {},
|
|
120
|
+
}));
|
|
121
|
+
const result = (0, status_1.computeSensorStatus)(tmpDir);
|
|
122
|
+
expect(result.overall).toBe('DEGRADED');
|
|
123
|
+
expect(result.pack).toBe('python');
|
|
124
|
+
});
|
|
125
|
+
it('degrades gracefully (never throws) when sensors is null in a hand-edited manifest', () => {
|
|
126
|
+
// Regression for Finding 3: `checkManifest` (preflight) already guards
|
|
127
|
+
// `manifest.sensors ?? {}` — computeSensorStatus needs the same guard, or a
|
|
128
|
+
// corrupted/hand-edited manifest with `"sensors": null` crashes
|
|
129
|
+
// `Object.entries(null)`.
|
|
130
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
131
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
132
|
+
pack: 'python',
|
|
133
|
+
sensors: null,
|
|
134
|
+
}));
|
|
135
|
+
expect(() => (0, status_1.computeSensorStatus)(tmpDir)).not.toThrow();
|
|
136
|
+
const result = (0, status_1.computeSensorStatus)(tmpDir);
|
|
137
|
+
expect(result.overall).toBe('DEGRADED');
|
|
138
|
+
expect(result.pack).toBe('python');
|
|
139
|
+
expect(result.checks).toEqual({});
|
|
140
|
+
});
|
|
112
141
|
it('marks disabled sensors as ok', () => {
|
|
113
142
|
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
114
143
|
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|