agentic-workflow-manager 3.9.1 → 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 +115 -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 +9 -14
- package/dist/src/core/paths.js +13 -0
- package/dist/tests/commands/preflight/preflight.test.js +200 -0
- 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/dist/tests/core/paths.test.js +34 -0
- package/package.json +1 -1
|
@@ -4,10 +4,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.preflight = preflight;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
9
10
|
const status_1 = require("../sensors/status");
|
|
10
11
|
const init_1 = require("../sensors/init");
|
|
12
|
+
const paths_1 = require("../../core/paths");
|
|
11
13
|
const MANIFEST = path_1.default.join('.awm', 'sensors.json');
|
|
12
14
|
/**
|
|
13
15
|
* The agent needs project context delivered every session. A repo with neither file
|
|
@@ -62,6 +64,19 @@ function checkManifest(cwd, manifest) {
|
|
|
62
64
|
}
|
|
63
65
|
const total = Object.keys(manifest.sensors ?? {}).length;
|
|
64
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
|
+
}
|
|
65
80
|
return {
|
|
66
81
|
id: 'manifest',
|
|
67
82
|
ok: true,
|
|
@@ -75,6 +90,20 @@ function checkTools(cwd) {
|
|
|
75
90
|
if (status.overall === 'NOT_CONFIGURED') {
|
|
76
91
|
return { id: 'tools', ok: false, detail: 'no manifest to check', remedy: 'run `awm sensors init`' };
|
|
77
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
|
+
}
|
|
78
107
|
const broken = Object.entries(status.checks).filter(([, c]) => !c.ok);
|
|
79
108
|
if (broken.length > 0) {
|
|
80
109
|
return {
|
|
@@ -106,6 +135,89 @@ function checkPack(cwd, manifest) {
|
|
|
106
135
|
}
|
|
107
136
|
return { id: 'pack', ok: true, detail: `${manifest.pack} matches the detected stack` };
|
|
108
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Extract just the hostname portion of a git remote URL — never match against the
|
|
140
|
+
* full URL string. A bare substring check against the whole remote (`remote.includes
|
|
141
|
+
* ('gitlab')`) false-positives on an org/repo name that happens to contain the word,
|
|
142
|
+
* e.g. `git@github.enterprise.internal:kodria/gitlab-migration-tool.git` is a GitHub
|
|
143
|
+
* Enterprise remote, not GitLab — "gitlab" only appears in the repo name.
|
|
144
|
+
*
|
|
145
|
+
* Covers the two common remote URL shapes:
|
|
146
|
+
* HTTPS: `https://github.com/org/repo.git` -> `github.com`
|
|
147
|
+
* SSH: `git@github.com:org/repo.git` -> `github.com`
|
|
148
|
+
*
|
|
149
|
+
* Scheme-prefixed remotes (`https://`, `ssh://`, ...) are parsed with the built-in
|
|
150
|
+
* `URL` class rather than a hand-rolled regex — `.hostname` is spec-defined to exclude
|
|
151
|
+
* both userinfo (`user:pass@`/`user@`) and `:port`, so a userinfo or password/token
|
|
152
|
+
* that happens to contain "github"/"gitlab" (e.g. an SSH username `ssh://gitlab@host/`
|
|
153
|
+
* or a CI credential-injection URL `https://x-access-token:$TOKEN@host/...`) can never
|
|
154
|
+
* leak into the matched host, and IPv6 literals in brackets are also handled correctly.
|
|
155
|
+
*
|
|
156
|
+
* The SCP-style shorthand (`user@host:path`, no scheme — not a real URI, so `URL`
|
|
157
|
+
* rejects it) falls back to a regex whose host-capture group excludes `@`, so a second
|
|
158
|
+
* `@` in the remote (`user@host@evil:path`) can't smuggle a bogus "host" past the colon
|
|
159
|
+
* check either — it simply fails to match and returns `undefined`.
|
|
160
|
+
*
|
|
161
|
+
* Returns `undefined` when neither shape matches, so callers fall through to the same
|
|
162
|
+
* "unrecognized host" handling as any other unmatched URL.
|
|
163
|
+
*/
|
|
164
|
+
function extractHost(remote) {
|
|
165
|
+
try {
|
|
166
|
+
return new URL(remote).hostname;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Not a valid URL — likely git's SCP-like shorthand (user@host:path, no scheme).
|
|
170
|
+
// Node's URL class does not parse this form (it's not a real URI).
|
|
171
|
+
}
|
|
172
|
+
const scpMatch = remote.match(/^[^@\s]+@([^:\s@]+):/);
|
|
173
|
+
return scpMatch?.[1];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Advisory only — `ok` is ALWAYS `true` here, no matter what it finds. The
|
|
177
|
+
* `finishing-a-development-branch`/`receiving-code-review` skills detect the git host
|
|
178
|
+
* (GitHub vs GitLab) and shell out to `gh`/`glab` to open a PR/MR, degrading honestly
|
|
179
|
+
* when neither is on PATH. This check tells the operator, in advance, whether that
|
|
180
|
+
* downstream step will actually work — but plenty of legitimate workflows never create
|
|
181
|
+
* a PR/MR at all (merge locally, keep the branch as-is), so gating the whole harness on
|
|
182
|
+
* missing tooling here would be wrong. It is FYI, never a blocker.
|
|
183
|
+
*/
|
|
184
|
+
function checkHost(cwd) {
|
|
185
|
+
let remote;
|
|
186
|
+
try {
|
|
187
|
+
remote = (0, child_process_1.execFileSync)('git', ['remote', 'get-url', 'origin'], {
|
|
188
|
+
cwd,
|
|
189
|
+
encoding: 'utf-8',
|
|
190
|
+
stdio: ['ignore', 'pipe', 'ignore'], // no origin / not a repo prints to stderr — keep it off the operator's screen
|
|
191
|
+
}).trim();
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// No `origin`, or not a git repo at all — nothing to advise on.
|
|
195
|
+
return { id: 'host', ok: true, detail: 'no git remote detected — PR/MR automation not applicable' };
|
|
196
|
+
}
|
|
197
|
+
const host = extractHost(remote);
|
|
198
|
+
if (host?.includes('github.com')) {
|
|
199
|
+
return (0, paths_1.resolveOnPath)('gh')
|
|
200
|
+
? { id: 'host', ok: true, detail: 'github detected, gh available' }
|
|
201
|
+
: {
|
|
202
|
+
id: 'host',
|
|
203
|
+
ok: true,
|
|
204
|
+
detail: 'github detected, gh not on PATH — PR creation will require manual steps',
|
|
205
|
+
remedy: 'install the GitHub CLI (gh), or PR creation will need to be done manually',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (host?.includes('gitlab')) {
|
|
209
|
+
return (0, paths_1.resolveOnPath)('glab')
|
|
210
|
+
? { id: 'host', ok: true, detail: 'gitlab detected, glab available' }
|
|
211
|
+
: {
|
|
212
|
+
id: 'host',
|
|
213
|
+
ok: true,
|
|
214
|
+
detail: 'gitlab detected, glab not on PATH — MR creation will require manual steps',
|
|
215
|
+
remedy: 'install the GitLab CLI (glab), or MR creation will need to be done manually',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// Bitbucket, Azure DevOps, an internal git server, etc. — don't overclaim support.
|
|
219
|
+
return { id: 'host', ok: true, detail: 'git host not recognized (github/gitlab) — PR/MR automation not applicable' };
|
|
220
|
+
}
|
|
109
221
|
function preflight(cwd = process.cwd()) {
|
|
110
222
|
const manifest = readManifest(cwd);
|
|
111
223
|
const manifestExists = fs_1.default.existsSync(path_1.default.join(cwd, MANIFEST));
|
|
@@ -115,6 +227,9 @@ function preflight(cwd = process.cwd()) {
|
|
|
115
227
|
// Skipped when there is no manifest: reporting "tools broken" on a repo that was
|
|
116
228
|
// never set up buries the one thing the operator needs to read.
|
|
117
229
|
...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest)] : []),
|
|
230
|
+
// Runs unconditionally — orthogonal to sensor configuration entirely, this is
|
|
231
|
+
// about PR/MR tooling, not sensors.
|
|
232
|
+
checkHost(cwd),
|
|
118
233
|
];
|
|
119
234
|
const status = !manifestExists ? 'not_configured'
|
|
120
235
|
: checks.every(c => c.ok) ? 'ready'
|
|
@@ -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
|
});
|
|
@@ -4,7 +4,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.computeSensorStatus = computeSensorStatus;
|
|
7
|
-
const child_process_1 = require("child_process");
|
|
8
7
|
const fs_1 = __importDefault(require("fs"));
|
|
9
8
|
const path_1 = __importDefault(require("path"));
|
|
10
9
|
const paths_1 = require("../../core/paths");
|
|
@@ -16,17 +15,6 @@ function npxTool(parts) {
|
|
|
16
15
|
}
|
|
17
16
|
return undefined;
|
|
18
17
|
}
|
|
19
|
-
/** Resolve a binary on PATH portably: `where` on win32, POSIX `command -v` elsewhere. */
|
|
20
|
-
function resolveOnPath(bin) {
|
|
21
|
-
const cmd = (0, paths_1.isWindowsNative)() ? `where ${bin}` : `command -v ${bin}`;
|
|
22
|
-
try {
|
|
23
|
-
(0, child_process_1.execSync)(cmd, { stdio: 'pipe' });
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
18
|
/** If the command references `--config <file>`, that file must exist in the repo. */
|
|
31
19
|
function configCheck(parts, cwd) {
|
|
32
20
|
const i = parts.indexOf('--config');
|
|
@@ -60,7 +48,7 @@ function checkCmd(cmd, cwd) {
|
|
|
60
48
|
}
|
|
61
49
|
return configCheck(parts, cwd) ?? { ok: true, detail: `${tool} (node_modules/.bin)` };
|
|
62
50
|
}
|
|
63
|
-
if (!resolveOnPath(bin)) {
|
|
51
|
+
if (!(0, paths_1.resolveOnPath)(bin)) {
|
|
64
52
|
return { ok: false, detail: `${bin} not found in PATH` };
|
|
65
53
|
}
|
|
66
54
|
return configCheck(parts, cwd) ?? { ok: true, detail: bin };
|
|
@@ -78,7 +66,7 @@ function computeSensorStatus(cwd = process.cwd()) {
|
|
|
78
66
|
return { overall: 'NOT_CONFIGURED', pack: null, checks: {} };
|
|
79
67
|
}
|
|
80
68
|
const checks = {};
|
|
81
|
-
for (const [name, config] of Object.entries(manifest.sensors)) {
|
|
69
|
+
for (const [name, config] of Object.entries(manifest.sensors ?? {})) {
|
|
82
70
|
if (config.enabled === false) {
|
|
83
71
|
checks[name] = { ok: true, detail: 'disabled' };
|
|
84
72
|
continue;
|
|
@@ -89,6 +77,13 @@ function computeSensorStatus(cwd = process.cwd()) {
|
|
|
89
77
|
}
|
|
90
78
|
checks[name] = checkCmd(config.cmd, cwd);
|
|
91
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
|
+
}
|
|
92
87
|
const allOk = Object.values(checks).every(c => c.ok);
|
|
93
88
|
return { overall: allOk ? 'HEALTHY' : 'DEGRADED', pack: manifest.pack, checks };
|
|
94
89
|
}
|
package/dist/src/core/paths.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.platform = platform;
|
|
|
10
10
|
exports.isWindowsNative = isWindowsNative;
|
|
11
11
|
exports.platformLabel = platformLabel;
|
|
12
12
|
exports.warnIfUnsupportedPlatform = warnIfUnsupportedPlatform;
|
|
13
|
+
exports.resolveOnPath = resolveOnPath;
|
|
13
14
|
// cli/src/core/paths.ts
|
|
14
15
|
//
|
|
15
16
|
// Single source of truth for home / AWM_HOME resolution and platform detection.
|
|
@@ -17,6 +18,7 @@ exports.warnIfUnsupportedPlatform = warnIfUnsupportedPlatform;
|
|
|
17
18
|
// always honored and tests need no jest.resetModules().
|
|
18
19
|
const os_1 = __importDefault(require("os"));
|
|
19
20
|
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
20
22
|
/** User home directory with a robust fallback. Never returns a raw, possibly-empty process.env.HOME. */
|
|
21
23
|
function homeDir() {
|
|
22
24
|
return process.env.HOME || os_1.default.homedir();
|
|
@@ -54,3 +56,14 @@ function warnIfUnsupportedPlatform(log) {
|
|
|
54
56
|
if (isWindowsNative())
|
|
55
57
|
log(exports.WINDOWS_NATIVE_WARNING);
|
|
56
58
|
}
|
|
59
|
+
/** Resolve a binary on PATH portably: `where` on win32, POSIX `command -v` elsewhere. */
|
|
60
|
+
function resolveOnPath(bin) {
|
|
61
|
+
const cmd = isWindowsNative() ? `where ${bin}` : `command -v ${bin}`;
|
|
62
|
+
try {
|
|
63
|
+
(0, child_process_1.execSync)(cmd, { stdio: 'pipe' });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|