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
|
@@ -6,8 +6,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const fs_1 = __importDefault(require("fs"));
|
|
7
7
|
const os_1 = __importDefault(require("os"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
9
10
|
const checks_1 = require("../../../src/commands/preflight/checks");
|
|
10
11
|
const preflight_1 = require("../../../src/commands/preflight");
|
|
12
|
+
// Only `execSync` (used by `resolveOnPath` to check for `gh`/`glab`) is mocked — `git
|
|
13
|
+
// remote get-url origin` runs for real via `execFileSync` against real tmpdir git repos,
|
|
14
|
+
// same as every other check in this file exercises the real filesystem.
|
|
15
|
+
jest.mock('child_process', () => ({
|
|
16
|
+
...jest.requireActual('child_process'),
|
|
17
|
+
execSync: jest.fn(),
|
|
18
|
+
}));
|
|
19
|
+
const mockExecSync = child_process_1.execSync;
|
|
20
|
+
/** Turn a tmpdir into a real git repo with (optionally) an `origin` remote. */
|
|
21
|
+
function gitRepo(dir, remoteUrl) {
|
|
22
|
+
(0, child_process_1.execFileSync)('git', ['init'], { cwd: dir, stdio: 'pipe' });
|
|
23
|
+
if (remoteUrl)
|
|
24
|
+
(0, child_process_1.execFileSync)('git', ['remote', 'add', 'origin', remoteUrl], { cwd: dir, stdio: 'pipe' });
|
|
25
|
+
}
|
|
11
26
|
/** CLAUDE.md: no test may reach the real ~/.awm. Everything here is a tmpdir. */
|
|
12
27
|
function project(opts = {}) {
|
|
13
28
|
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-'));
|
|
@@ -81,6 +96,36 @@ describe('preflight', () => {
|
|
|
81
96
|
expect(report.status).toBe('ready');
|
|
82
97
|
expect(check(report, 'manifest').detail).toContain('opt-out');
|
|
83
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
|
+
});
|
|
84
129
|
it('flags a manifest stuck on generic while the tree has a real stack', () => {
|
|
85
130
|
// The gate would run, report green, and have checked almost nothing.
|
|
86
131
|
const dir = make({
|
|
@@ -112,6 +157,161 @@ describe('preflight', () => {
|
|
|
112
157
|
expect((0, preflight_1.exitCodeFor)({ status: 'degraded', checks: [] })).toBe(1);
|
|
113
158
|
expect((0, preflight_1.exitCodeFor)({ status: 'ready', checks: [] })).toBe(0);
|
|
114
159
|
});
|
|
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.
|
|
166
|
+
beforeEach(() => { mockExecSync.mockReset(); });
|
|
167
|
+
it('reports github + gh available, and does not affect status', () => {
|
|
168
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
169
|
+
gitRepo(dir, 'git@github.com:kodria/agentic-workflow.git');
|
|
170
|
+
mockExecSync.mockImplementation(((cmd) => {
|
|
171
|
+
if (cmd === 'command -v gh')
|
|
172
|
+
return Buffer.from('/usr/bin/gh');
|
|
173
|
+
throw new Error(`not found: ${cmd}`);
|
|
174
|
+
}));
|
|
175
|
+
const report = (0, checks_1.preflight)(dir);
|
|
176
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
177
|
+
expect(check(report, 'host').detail).toBe('github detected, gh available');
|
|
178
|
+
expect(report.status).toBe('ready');
|
|
179
|
+
});
|
|
180
|
+
it('is still ok:true (advisory only) when gitlab is detected but glab is not on PATH, and status stays ready', () => {
|
|
181
|
+
// The only thing "wrong" in this fixture is the missing `glab` — proving the
|
|
182
|
+
// advisory contract: it must not drag an otherwise-clean repo to `degraded`.
|
|
183
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
184
|
+
gitRepo(dir, 'https://gitlab.com/kodria/agentic-workflow.git');
|
|
185
|
+
mockExecSync.mockImplementation((() => {
|
|
186
|
+
throw new Error('not found');
|
|
187
|
+
}));
|
|
188
|
+
const report = (0, checks_1.preflight)(dir);
|
|
189
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
190
|
+
expect(check(report, 'host').detail).toContain('glab not on PATH');
|
|
191
|
+
expect(check(report, 'host').remedy).toContain('glab');
|
|
192
|
+
expect(report.status).toBe('ready');
|
|
193
|
+
});
|
|
194
|
+
it('handles no origin remote gracefully — no throw, ok:true, minimal detail', () => {
|
|
195
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
196
|
+
// Not a git repo at all — the common case for `execFileSync` failing here.
|
|
197
|
+
const report = (0, checks_1.preflight)(dir);
|
|
198
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
199
|
+
expect(check(report, 'host').detail).toBe('no git remote detected — PR/MR automation not applicable');
|
|
200
|
+
expect(check(report, 'host').remedy).toBeUndefined();
|
|
201
|
+
expect(report.status).toBe('ready');
|
|
202
|
+
});
|
|
203
|
+
it('handles a git repo with no origin remote configured gracefully', () => {
|
|
204
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
205
|
+
gitRepo(dir); // git init, no remote
|
|
206
|
+
const report = (0, checks_1.preflight)(dir);
|
|
207
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
208
|
+
expect(check(report, 'host').detail).toContain('no git remote detected');
|
|
209
|
+
});
|
|
210
|
+
it('does not overclaim support for an unrecognized host', () => {
|
|
211
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
212
|
+
gitRepo(dir, 'git@bitbucket.org:kodria/agentic-workflow.git');
|
|
213
|
+
const report = (0, checks_1.preflight)(dir);
|
|
214
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
215
|
+
expect(check(report, 'host').detail).toContain('not recognized');
|
|
216
|
+
expect(report.status).toBe('ready');
|
|
217
|
+
});
|
|
218
|
+
it('does not misclassify a GitHub Enterprise host whose repo NAME contains "gitlab"', () => {
|
|
219
|
+
// The bug: a bare `remote.includes('gitlab')` matches the full remote URL
|
|
220
|
+
// string, so an org/repo name containing "gitlab" false-positives even though
|
|
221
|
+
// the actual host is unrelated. Hostname must be extracted first and matched
|
|
222
|
+
// in isolation.
|
|
223
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
224
|
+
gitRepo(dir, 'git@github.enterprise.internal:kodria/gitlab-migration-tool.git');
|
|
225
|
+
const report = (0, checks_1.preflight)(dir);
|
|
226
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
227
|
+
expect(check(report, 'host').detail).toContain('not recognized');
|
|
228
|
+
expect(report.status).toBe('ready');
|
|
229
|
+
});
|
|
230
|
+
it('does not misclassify a non-GitHub host whose repo NAME contains "github"', () => {
|
|
231
|
+
// Same class of bug on the github side: "something-github-tool" is a repo
|
|
232
|
+
// name, not the host.
|
|
233
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
234
|
+
gitRepo(dir, 'https://example.com/kodria/something-github-tool.git');
|
|
235
|
+
const report = (0, checks_1.preflight)(dir);
|
|
236
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
237
|
+
expect(check(report, 'host').detail).toContain('not recognized');
|
|
238
|
+
expect(report.status).toBe('ready');
|
|
239
|
+
});
|
|
240
|
+
it('does not misclassify a GitHub Enterprise host whose SSH USERNAME is "gitlab"', () => {
|
|
241
|
+
// The related bug: the scheme-based regex captured everything between
|
|
242
|
+
// `scheme://` and the first `/`, including `userinfo@` — so an SSH username
|
|
243
|
+
// of "gitlab" leaked into the matched "host" string and false-positived the
|
|
244
|
+
// `.includes('gitlab')` check even though the real host is GitHub
|
|
245
|
+
// Enterprise. `new URL(...).hostname` must exclude userinfo entirely.
|
|
246
|
+
//
|
|
247
|
+
// Note: `github.company-internal.com` legitimately contains the substring
|
|
248
|
+
// "github.com" (from "company"), so — with the userinfo bug fixed — this
|
|
249
|
+
// correctly classifies as github (checkHost's own substring matching is a
|
|
250
|
+
// separate, pre-existing design, not part of this fix). The regression this
|
|
251
|
+
// test guards is that it must never again read as gitlab.
|
|
252
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
253
|
+
gitRepo(dir, 'ssh://gitlab@github.company-internal.com:22/team/repo.git');
|
|
254
|
+
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
255
|
+
const report = (0, checks_1.preflight)(dir);
|
|
256
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
257
|
+
expect(check(report, 'host').detail).toContain('github detected');
|
|
258
|
+
expect(check(report, 'host').detail).not.toContain('gitlab');
|
|
259
|
+
expect(report.status).toBe('ready');
|
|
260
|
+
});
|
|
261
|
+
it('does not misclassify a host whose injected credential/token contains "gitlab"', () => {
|
|
262
|
+
// A realistic CI credential-injection remote:
|
|
263
|
+
// `git remote set-url origin https://x-access-token:$TOKEN@host/...`. If the
|
|
264
|
+
// token or password happens to contain "gitlab", it must not leak into the
|
|
265
|
+
// matched host either.
|
|
266
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
267
|
+
gitRepo(dir, 'https://user:gitlab@example-host.com/org/repo.git');
|
|
268
|
+
const report = (0, checks_1.preflight)(dir);
|
|
269
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
270
|
+
expect(check(report, 'host').detail).toContain('not recognized');
|
|
271
|
+
expect(report.status).toBe('ready');
|
|
272
|
+
});
|
|
273
|
+
it('does not misclassify an SCP-style remote with a second "@" in it', () => {
|
|
274
|
+
// `user@host:path` shorthand has no scheme for `URL` to parse, so it falls
|
|
275
|
+
// back to a regex. A second "@" (e.g. a malformed/adversarial remote) must
|
|
276
|
+
// not let a bogus "host@evil"-shaped capture slip past the colon check —
|
|
277
|
+
// the host-capture group excludes "@", so this fails to match at all and
|
|
278
|
+
// falls through to "unrecognized" rather than misclassifying as gitlab.
|
|
279
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
280
|
+
gitRepo(dir, 'user@github.com@gitlab.evil:org/repo.git');
|
|
281
|
+
const report = (0, checks_1.preflight)(dir);
|
|
282
|
+
expect(check(report, 'host').ok).toBe(true);
|
|
283
|
+
expect(check(report, 'host').detail).toContain('not recognized');
|
|
284
|
+
expect(report.status).toBe('ready');
|
|
285
|
+
});
|
|
286
|
+
it('still detects github.com over HTTPS', () => {
|
|
287
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
288
|
+
gitRepo(dir, 'https://github.com/org/repo.git');
|
|
289
|
+
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
290
|
+
const report = (0, checks_1.preflight)(dir);
|
|
291
|
+
expect(check(report, 'host').detail).toContain('github detected');
|
|
292
|
+
});
|
|
293
|
+
it('still detects github.com over SSH shorthand', () => {
|
|
294
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
295
|
+
gitRepo(dir, 'git@github.com:org/repo.git');
|
|
296
|
+
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
297
|
+
const report = (0, checks_1.preflight)(dir);
|
|
298
|
+
expect(check(report, 'host').detail).toContain('github detected');
|
|
299
|
+
});
|
|
300
|
+
it('still detects gitlab over HTTPS', () => {
|
|
301
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
302
|
+
gitRepo(dir, 'https://gitlab.example.com/org/repo.git');
|
|
303
|
+
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
304
|
+
const report = (0, checks_1.preflight)(dir);
|
|
305
|
+
expect(check(report, 'host').detail).toContain('gitlab detected');
|
|
306
|
+
});
|
|
307
|
+
it('still detects gitlab over SSH shorthand', () => {
|
|
308
|
+
const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
|
|
309
|
+
gitRepo(dir, 'git@gitlab.example.com:org/repo.git');
|
|
310
|
+
mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
|
|
311
|
+
const report = (0, checks_1.preflight)(dir);
|
|
312
|
+
expect(check(report, 'host').detail).toContain('gitlab detected');
|
|
313
|
+
});
|
|
314
|
+
});
|
|
115
315
|
it('tells the operator not to hand a broken harness to an unattended run', () => {
|
|
116
316
|
const out = (0, preflight_1.formatReport)({
|
|
117
317
|
status: 'not_configured',
|
|
@@ -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
|
+
});
|