agentic-workflow-manager 3.6.0 → 3.7.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/sensors/changed.js +95 -0
- package/dist/src/commands/sensors/index.js +6 -1
- package/dist/src/commands/sensors/init.js +8 -0
- package/dist/src/commands/sensors/run.js +51 -2
- package/dist/tests/commands/sensors/changed.test.js +114 -0
- package/dist/tests/commands/sensors/run-changed.test.js +136 -0
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
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.changedFiles = changedFiles;
|
|
7
|
+
exports.applyChangedCmd = applyChangedCmd;
|
|
8
|
+
exports.filterByExtension = filterByExtension;
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
function git(args, cwd) {
|
|
12
|
+
return (0, child_process_1.execFileSync)('git', args, {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The default comparison point. `merge-base HEAD <base>` — not `base` directly —
|
|
20
|
+
* so a branch that is merely *behind* its base does not report every file the base
|
|
21
|
+
* moved on as "changed" by this branch.
|
|
22
|
+
*/
|
|
23
|
+
function mergeBase(base, cwd) {
|
|
24
|
+
return git(['merge-base', 'HEAD', base], cwd).trim();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Files this working tree changed relative to `base`.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately a union of four sources: the committed diff since the merge base,
|
|
30
|
+
* plus staged, unstaged and untracked files. A sensor gate runs mid-work, where the
|
|
31
|
+
* interesting edits are usually not committed yet — a committed-only diff would scope
|
|
32
|
+
* the run to a stale set and certify files nobody is editing.
|
|
33
|
+
*
|
|
34
|
+
* Untracked files are included but ignored files are not (`--exclude-standard`), so
|
|
35
|
+
* `node_modules` and build output never enter the scope.
|
|
36
|
+
*
|
|
37
|
+
* Never throws: a failure to resolve the scope returns `error`, and the caller
|
|
38
|
+
* degrades to the unscoped command rather than guessing at a narrower one.
|
|
39
|
+
*/
|
|
40
|
+
function changedFiles(cwd, base = 'HEAD') {
|
|
41
|
+
let out = [];
|
|
42
|
+
try {
|
|
43
|
+
// `HEAD` means "everything not yet committed" — no merge-base needed, and it
|
|
44
|
+
// is the right default for a gate that runs before the work is committed.
|
|
45
|
+
if (base !== 'HEAD') {
|
|
46
|
+
const from = mergeBase(base, cwd);
|
|
47
|
+
out = out.concat(git(['diff', '--name-only', '--diff-filter=d', from, 'HEAD'], cwd).split('\n'));
|
|
48
|
+
}
|
|
49
|
+
out = out.concat(git(['diff', '--name-only', '--diff-filter=d', 'HEAD'], cwd).split('\n'));
|
|
50
|
+
out = out.concat(git(['diff', '--name-only', '--diff-filter=d', '--cached'], cwd).split('\n'));
|
|
51
|
+
out = out.concat(git(['ls-files', '--others', '--exclude-standard'], cwd).split('\n'));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
return { files: [], error: e.message.split('\n')[0] };
|
|
55
|
+
}
|
|
56
|
+
const files = Array.from(new Set(out.map(s => s.trim()).filter(Boolean))).sort();
|
|
57
|
+
return { files };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Quote a path for a shell command line. Sensor commands are strings run through a
|
|
61
|
+
* shell, so a path with a space or a quote in it would otherwise split into two
|
|
62
|
+
* arguments — or, worse, end the quoting and let the rest of the name be read as
|
|
63
|
+
* shell syntax. Single quotes with the `'\''` escape are the only form POSIX shells
|
|
64
|
+
* treat as fully literal.
|
|
65
|
+
*/
|
|
66
|
+
function shellQuote(file) {
|
|
67
|
+
return `'${file.replace(/'/g, `'\\''`)}'`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Substitute the file list into a `changedCmd` template.
|
|
71
|
+
*
|
|
72
|
+
* The template must contain `{files}`. A template without it would silently run over
|
|
73
|
+
* the whole repo while the output claimed the run was scoped, so that case is
|
|
74
|
+
* rejected by the caller rather than papered over here.
|
|
75
|
+
*/
|
|
76
|
+
function applyChangedCmd(template, files) {
|
|
77
|
+
return template.replace('{files}', files.map(shellQuote).join(' '));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Narrow the changed set to what a given sensor can be handed.
|
|
81
|
+
*
|
|
82
|
+
* The changed set is everything the tree touched — a README, a lockfile, a PNG. The
|
|
83
|
+
* tools do not shrug those off: eslint given a `.md` fails rather than skipping it,
|
|
84
|
+
* so an unfiltered scoped run would turn "you edited the docs" into a red gate.
|
|
85
|
+
*
|
|
86
|
+
* Case-insensitive because Windows and macOS checkouts routinely carry `.TS`/`.Ts`,
|
|
87
|
+
* and a case-sensitive match would silently drop those files from the scope — the
|
|
88
|
+
* quiet direction of wrong, where the sensor reports clean over files it never saw.
|
|
89
|
+
*/
|
|
90
|
+
function filterByExtension(files, extensions) {
|
|
91
|
+
if (!extensions || extensions.length === 0)
|
|
92
|
+
return files;
|
|
93
|
+
const allowed = new Set(extensions.map(e => e.toLowerCase()));
|
|
94
|
+
return files.filter(f => allowed.has(path_1.default.extname(f).toLowerCase()));
|
|
95
|
+
}
|
|
@@ -27,8 +27,13 @@ function registerSensorsCommand(program) {
|
|
|
27
27
|
.option('--fast', 'run fast sensors only (tsc, lint)')
|
|
28
28
|
.option('--slow', 'run slow sensors only (semgrep, mutation)')
|
|
29
29
|
.option('--all', 'run all sensors regardless of speed')
|
|
30
|
+
.option('--changed', 'scope sensors that support it to the files changed vs --base')
|
|
31
|
+
.option('--base <ref>', 'comparison point for --changed (default: HEAD, i.e. uncommitted work)')
|
|
30
32
|
.action(async (opts) => {
|
|
31
|
-
const output = await (0, run_1.runSensors)({
|
|
33
|
+
const output = await (0, run_1.runSensors)({
|
|
34
|
+
fast: opts.fast, slow: opts.slow, all: opts.all,
|
|
35
|
+
changed: opts.changed, base: opts.base,
|
|
36
|
+
});
|
|
32
37
|
// Emit the verdict ALWAYS — an empty `sensors` with overall:'not_certified'
|
|
33
38
|
// must be visible, never a silent exit-0 that reads as "clean".
|
|
34
39
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
@@ -68,6 +68,14 @@ function readPackDefaults(pack, registryRoot, cwd) {
|
|
|
68
68
|
entry.fast = def.fast;
|
|
69
69
|
if (def.enabled !== undefined)
|
|
70
70
|
entry.enabled = def.enabled;
|
|
71
|
+
// Carried through verbatim: without these in the written manifest, a pack can
|
|
72
|
+
// declare a sensor scopable and `--changed` would silently run it in full —
|
|
73
|
+
// the flag would look supported and do nothing. `{{SOURCE_DIRS}}` is not
|
|
74
|
+
// substituted here on purpose: a scoped command takes an explicit file list.
|
|
75
|
+
if (def.changedCmd)
|
|
76
|
+
entry.changedCmd = def.changedCmd;
|
|
77
|
+
if (def.changedExtensions)
|
|
78
|
+
entry.changedExtensions = def.changedExtensions;
|
|
71
79
|
sensors[name] = entry;
|
|
72
80
|
}
|
|
73
81
|
return sensors;
|
|
@@ -18,6 +18,7 @@ const semgrep_1 = require("./formatters/semgrep");
|
|
|
18
18
|
const generic_1 = require("./formatters/generic");
|
|
19
19
|
const test_1 = require("./formatters/test");
|
|
20
20
|
const baseline_1 = require("./baseline");
|
|
21
|
+
const changed_1 = require("./changed");
|
|
21
22
|
const init_1 = require("./init");
|
|
22
23
|
const registries_1 = require("../../core/registries");
|
|
23
24
|
const MANIFEST_FILE = '.awm/sensors.json';
|
|
@@ -254,6 +255,19 @@ async function runSensors(opts = {}) {
|
|
|
254
255
|
// ones (essential on repos with a large pre-existing baseline). Absent file or
|
|
255
256
|
// --ignore-baseline → every finding counts (backward-compatible).
|
|
256
257
|
const baseline = opts.ignoreBaseline ? null : (0, baseline_1.readBaseline)(cwd);
|
|
258
|
+
// A scoped run must never define the accepted set. `buildBaseline` snapshots the
|
|
259
|
+
// findings of the run it is given, so baselining a `--changed` run would write a
|
|
260
|
+
// baseline covering only the touched files and silently drop every accepted
|
|
261
|
+
// finding elsewhere in the repo — which then reports as NEW on the next full run.
|
|
262
|
+
// The two flags only ever meet by mistake, so refuse rather than pick a meaning.
|
|
263
|
+
if (opts.changed && opts.ignoreBaseline) {
|
|
264
|
+
throw new Error('refusing to combine --changed with a baseline capture: a partial run cannot define '
|
|
265
|
+
+ 'the accepted set (it would drop every accepted finding outside the diff). '
|
|
266
|
+
+ 'Run `awm sensors baseline` without --changed.');
|
|
267
|
+
}
|
|
268
|
+
// Resolved once for the whole run, not per sensor: `git` is cheap but the answer
|
|
269
|
+
// must be identical across sensors, or two of them scope to different file sets.
|
|
270
|
+
const changed = opts.changed ? (0, changed_1.changedFiles)(cwd, opts.base ?? 'HEAD') : null;
|
|
257
271
|
// Sensors are independent processes over the same tree, so they run
|
|
258
272
|
// concurrently rather than one-after-another: wall clock becomes the slowest
|
|
259
273
|
// sensor instead of the sum of all of them. Tasks are built — and dispatched —
|
|
@@ -274,11 +288,41 @@ async function runSensors(opts = {}) {
|
|
|
274
288
|
tasks.push(settled({ name, status: 'inconclusive', errors: [], skipReason: 'no cmd configured' }));
|
|
275
289
|
continue;
|
|
276
290
|
}
|
|
277
|
-
|
|
291
|
+
// Scoping applies only where the pack opted in AND the scope resolved. Any
|
|
292
|
+
// other combination falls back to the full command: slower, never wrong.
|
|
293
|
+
let cmd = config.cmd;
|
|
294
|
+
let scope;
|
|
295
|
+
if (changed && !changed.error && config.changedCmd) {
|
|
296
|
+
if (!config.changedCmd.includes('{files}')) {
|
|
297
|
+
// Running the template as-is would cover the whole repo while the
|
|
298
|
+
// result claimed to be scoped — a mislabel, not a slow path.
|
|
299
|
+
tasks.push(settled({
|
|
300
|
+
name,
|
|
301
|
+
status: 'inconclusive',
|
|
302
|
+
errors: [],
|
|
303
|
+
skipReason: 'changedCmd has no {files} placeholder',
|
|
304
|
+
}));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const inScope = (0, changed_1.filterByExtension)(changed.files, config.changedExtensions);
|
|
308
|
+
if (inScope.length === 0) {
|
|
309
|
+
tasks.push(settled({
|
|
310
|
+
name,
|
|
311
|
+
status: 'skipped',
|
|
312
|
+
errors: [],
|
|
313
|
+
skipReason: 'no changed files in scope',
|
|
314
|
+
scope: 'changed',
|
|
315
|
+
}));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
cmd = (0, changed_1.applyChangedCmd)(config.changedCmd, inScope);
|
|
319
|
+
scope = 'changed';
|
|
320
|
+
}
|
|
278
321
|
const timeout = config.timeout ?? (isFast ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT);
|
|
279
322
|
tasks.push(async () => {
|
|
280
323
|
const result = await runSensor(name, cmd, timeout, cwd);
|
|
281
|
-
|
|
324
|
+
const scoped = scope ? { ...result, scope } : result;
|
|
325
|
+
return baseline ? applyBaseline(scoped, baseline[name]) : scoped;
|
|
282
326
|
});
|
|
283
327
|
}
|
|
284
328
|
const results = await pooled(tasks, resolveConcurrency(activeManifest, tasks.length));
|
|
@@ -298,5 +342,10 @@ async function runSensors(opts = {}) {
|
|
|
298
342
|
sensors: results,
|
|
299
343
|
overall,
|
|
300
344
|
...(reconciled.upgradedFrom ? { packUpgraded: `${reconciled.upgradedFrom}→${activeManifest.pack}` } : {}),
|
|
345
|
+
// Always emitted on a --changed run, including when the scope failed to
|
|
346
|
+
// resolve: a green that came back from an unscoped fallback and a green from a
|
|
347
|
+
// genuinely scoped run are different claims, and the caller cannot tell them
|
|
348
|
+
// apart from the sensor list alone.
|
|
349
|
+
...(changed ? { changedScope: { files: changed.files.length, ...(changed.error ? { error: changed.error } : {}) } } : {}),
|
|
301
350
|
};
|
|
302
351
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
const child_process_1 = require("child_process");
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const changed_1 = require("../../../src/commands/sensors/changed");
|
|
11
|
+
/** A throwaway repo. Real git, not a mock: the whole value of this module is that its
|
|
12
|
+
* understanding of "changed" matches git's, which a mock would define into existence. */
|
|
13
|
+
function repo() {
|
|
14
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-changed-'));
|
|
15
|
+
const run = (args) => (0, child_process_1.execFileSync)('git', args, { cwd: dir, stdio: 'ignore' });
|
|
16
|
+
run(['init', '-q']);
|
|
17
|
+
run(['config', 'user.email', 'test@example.com']);
|
|
18
|
+
run(['config', 'user.name', 'test']);
|
|
19
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'base.ts'), 'export const a = 1;\n');
|
|
20
|
+
run(['add', 'base.ts']);
|
|
21
|
+
run(['commit', '-qm', 'base']);
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
describe('changedFiles', () => {
|
|
25
|
+
const dirs = [];
|
|
26
|
+
const make = () => { const d = repo(); dirs.push(d); return d; };
|
|
27
|
+
afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
|
|
28
|
+
it('sees unstaged, staged and untracked files, not just committed ones', () => {
|
|
29
|
+
// The gate runs mid-work, before anything is committed. A committed-only diff
|
|
30
|
+
// would scope the run to a stale set and certify files nobody is editing —
|
|
31
|
+
// exactly the files least likely to be broken right now.
|
|
32
|
+
const dir = make();
|
|
33
|
+
fs_1.default.appendFileSync(path_1.default.join(dir, 'base.ts'), 'export const b = 2;\n'); // unstaged
|
|
34
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'staged.ts'), 'export const c = 3;\n');
|
|
35
|
+
(0, child_process_1.execFileSync)('git', ['add', 'staged.ts'], { cwd: dir, stdio: 'ignore' }); // staged
|
|
36
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'untracked.ts'), 'export const d = 4;\n'); // untracked
|
|
37
|
+
expect((0, changed_1.changedFiles)(dir).files).toEqual(['base.ts', 'staged.ts', 'untracked.ts']);
|
|
38
|
+
});
|
|
39
|
+
it('excludes gitignored files so build output never enters the scope', () => {
|
|
40
|
+
const dir = make();
|
|
41
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, '.gitignore'), 'dist/\n');
|
|
42
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, 'dist'));
|
|
43
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'dist', 'bundle.js'), 'noise');
|
|
44
|
+
expect((0, changed_1.changedFiles)(dir).files).not.toContain('dist/bundle.js');
|
|
45
|
+
});
|
|
46
|
+
it('omits deleted files — a sensor cannot read a path that is gone', () => {
|
|
47
|
+
const dir = make();
|
|
48
|
+
fs_1.default.rmSync(path_1.default.join(dir, 'base.ts'));
|
|
49
|
+
expect((0, changed_1.changedFiles)(dir).files).not.toContain('base.ts');
|
|
50
|
+
});
|
|
51
|
+
it('compares against the merge base, not the branch tip', () => {
|
|
52
|
+
// Guards the case where the branch is merely BEHIND its base. Diffing against
|
|
53
|
+
// the tip would report every file the base moved on as "changed by this
|
|
54
|
+
// branch", scoping the run to files this branch never touched.
|
|
55
|
+
const dir = make();
|
|
56
|
+
const run = (args) => (0, child_process_1.execFileSync)('git', args, { cwd: dir, stdio: 'ignore' });
|
|
57
|
+
run(['checkout', '-qb', 'feature']);
|
|
58
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'mine.ts'), 'export const mine = 1;\n');
|
|
59
|
+
run(['add', 'mine.ts']);
|
|
60
|
+
run(['commit', '-qm', 'mine']);
|
|
61
|
+
// main moves on independently, so `feature` is behind it.
|
|
62
|
+
run(['checkout', '-q', 'master']);
|
|
63
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'theirs.ts'), 'export const theirs = 1;\n');
|
|
64
|
+
run(['add', 'theirs.ts']);
|
|
65
|
+
run(['commit', '-qm', 'theirs']);
|
|
66
|
+
run(['checkout', '-q', 'feature']);
|
|
67
|
+
const files = (0, changed_1.changedFiles)(dir, 'master').files;
|
|
68
|
+
expect(files).toContain('mine.ts');
|
|
69
|
+
expect(files).not.toContain('theirs.ts');
|
|
70
|
+
});
|
|
71
|
+
it('reports an error instead of an empty scope when git cannot answer', () => {
|
|
72
|
+
// An empty file list and a failed lookup are the same value structurally but
|
|
73
|
+
// opposite in meaning: one says "nothing to check", the other "I do not know".
|
|
74
|
+
// Collapsing them would silently scope a run to zero files and report clean.
|
|
75
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-nogit-'));
|
|
76
|
+
dirs.push(dir);
|
|
77
|
+
const res = (0, changed_1.changedFiles)(dir);
|
|
78
|
+
expect(res.error).toBeDefined();
|
|
79
|
+
expect(res.files).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('applyChangedCmd', () => {
|
|
83
|
+
it('substitutes the file list into the template', () => {
|
|
84
|
+
expect((0, changed_1.applyChangedCmd)('eslint --format json {files}', ['a.ts', 'b.ts']))
|
|
85
|
+
.toBe(`eslint --format json 'a.ts' 'b.ts'`);
|
|
86
|
+
});
|
|
87
|
+
it('quotes paths so a space cannot split one argument into two', () => {
|
|
88
|
+
expect((0, changed_1.applyChangedCmd)('eslint {files}', ['my dir/a.ts']))
|
|
89
|
+
.toBe(`eslint 'my dir/a.ts'`);
|
|
90
|
+
});
|
|
91
|
+
it("escapes a single quote in a path instead of ending the quoting", () => {
|
|
92
|
+
// Without the '\'' form the rest of the filename would be read as shell syntax.
|
|
93
|
+
expect((0, changed_1.applyChangedCmd)('eslint {files}', ["it's.ts"]))
|
|
94
|
+
.toBe(`eslint 'it'\\''s.ts'`);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('filterByExtension', () => {
|
|
98
|
+
it('drops files the sensor cannot be handed', () => {
|
|
99
|
+
// Editing a README must not turn the lint gate red: eslint given a .md fails
|
|
100
|
+
// rather than skipping it.
|
|
101
|
+
expect((0, changed_1.filterByExtension)(['src/a.ts', 'README.md', 'logo.png'], ['.ts']))
|
|
102
|
+
.toEqual(['src/a.ts']);
|
|
103
|
+
});
|
|
104
|
+
it('matches extensions case-insensitively', () => {
|
|
105
|
+
// Windows and macOS checkouts carry .TS. A case-sensitive match would drop them
|
|
106
|
+
// from the scope silently — the sensor would report clean over files it never saw.
|
|
107
|
+
expect((0, changed_1.filterByExtension)(['src/A.TS'], ['.ts'])).toEqual(['src/A.TS']);
|
|
108
|
+
});
|
|
109
|
+
it('passes everything through when the sensor declares no filter', () => {
|
|
110
|
+
const files = ['a.ts', 'b.md'];
|
|
111
|
+
expect((0, changed_1.filterByExtension)(files, undefined)).toEqual(files);
|
|
112
|
+
expect((0, changed_1.filterByExtension)(files, [])).toEqual(files);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const mockRunCommand = jest.fn();
|
|
10
|
+
jest.mock('../../../src/commands/sensors/exec', () => ({
|
|
11
|
+
runCommand: (...args) => mockRunCommand(...args),
|
|
12
|
+
}));
|
|
13
|
+
const mockChangedFiles = jest.fn();
|
|
14
|
+
jest.mock('../../../src/commands/sensors/changed', () => {
|
|
15
|
+
const actual = jest.requireActual('../../../src/commands/sensors/changed');
|
|
16
|
+
return { ...actual, changedFiles: (...args) => mockChangedFiles(...args) };
|
|
17
|
+
});
|
|
18
|
+
const { ok } = require('./exec-fixtures');
|
|
19
|
+
function project(sensors) {
|
|
20
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-changed-run-'));
|
|
21
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
22
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), JSON.stringify({ pack: 'js-ts', sensors }));
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
/** The two sensors that matter: one that opted into scoping, one that cannot. */
|
|
26
|
+
const LINT = { fast: true, cmd: 'eslint --format json .', changedCmd: 'eslint --format json {files}' };
|
|
27
|
+
const TYPECHECK = { fast: true, cmd: 'tsc --noEmit' };
|
|
28
|
+
describe('runSensors --changed', () => {
|
|
29
|
+
let dir;
|
|
30
|
+
let prevAwmHome;
|
|
31
|
+
let fakeAwmHome;
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.resetModules();
|
|
34
|
+
mockRunCommand.mockReset();
|
|
35
|
+
mockChangedFiles.mockReset();
|
|
36
|
+
mockRunCommand.mockResolvedValue(ok(''));
|
|
37
|
+
// CLAUDE.md: no test may reach the real ~/.awm.
|
|
38
|
+
fakeAwmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-home-'));
|
|
39
|
+
prevAwmHome = process.env.AWM_HOME;
|
|
40
|
+
process.env.AWM_HOME = fakeAwmHome;
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
process.env.AWM_HOME = prevAwmHome;
|
|
44
|
+
if (dir)
|
|
45
|
+
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
fs_1.default.rmSync(fakeAwmHome, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
const load = () => require('../../../src/commands/sensors/run');
|
|
49
|
+
const cmds = () => mockRunCommand.mock.calls.map(c => c[0]);
|
|
50
|
+
it('scopes a sensor that opted in and leaves one that did not at full scope', async () => {
|
|
51
|
+
// The core contract. tsc is whole-program: handed a subset it reports clean
|
|
52
|
+
// while the change breaks a caller it was never shown. Scoping is opt-in
|
|
53
|
+
// precisely so that sensor keeps measuring everything.
|
|
54
|
+
dir = project({ lint: LINT, typecheck: TYPECHECK });
|
|
55
|
+
mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
|
|
56
|
+
await load().runSensors({ cwd: dir, changed: true });
|
|
57
|
+
expect(cmds()).toContain(`eslint --format json 'src/a.ts'`);
|
|
58
|
+
expect(cmds()).toContain('tsc --noEmit');
|
|
59
|
+
});
|
|
60
|
+
it('marks the scoped result so a scoped pass is not read as a full one', async () => {
|
|
61
|
+
dir = project({ lint: LINT, typecheck: TYPECHECK });
|
|
62
|
+
mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
|
|
63
|
+
const out = await load().runSensors({ cwd: dir, changed: true });
|
|
64
|
+
expect(out.sensors.find((s) => s.name === 'lint').scope).toBe('changed');
|
|
65
|
+
expect(out.sensors.find((s) => s.name === 'typecheck').scope).toBeUndefined();
|
|
66
|
+
expect(out.changedScope).toEqual({ files: 1 });
|
|
67
|
+
});
|
|
68
|
+
it('falls back to the full command when the scope cannot be resolved', async () => {
|
|
69
|
+
// Not a git repo, git absent, bad ref. Running everything is slow; guessing at
|
|
70
|
+
// a narrower set would certify files nobody proved were the only ones touched.
|
|
71
|
+
dir = project({ lint: LINT });
|
|
72
|
+
mockChangedFiles.mockReturnValue({ files: [], error: 'not a git repository' });
|
|
73
|
+
const out = await load().runSensors({ cwd: dir, changed: true });
|
|
74
|
+
expect(cmds()).toContain('eslint --format json .');
|
|
75
|
+
expect(out.sensors[0].scope).toBeUndefined();
|
|
76
|
+
expect(out.changedScope).toEqual({ files: 0, error: 'not a git repository' });
|
|
77
|
+
});
|
|
78
|
+
it('skips an opted-in sensor when nothing changed, without touching the others', async () => {
|
|
79
|
+
dir = project({ lint: LINT, typecheck: TYPECHECK });
|
|
80
|
+
mockChangedFiles.mockReturnValue({ files: [] });
|
|
81
|
+
const out = await load().runSensors({ cwd: dir, changed: true });
|
|
82
|
+
const lint = out.sensors.find((s) => s.name === 'lint');
|
|
83
|
+
expect(lint.status).toBe('skipped');
|
|
84
|
+
expect(lint.skipReason).toBe('no changed files in scope');
|
|
85
|
+
expect(cmds()).toEqual(['tsc --noEmit']);
|
|
86
|
+
});
|
|
87
|
+
it('hands the sensor only the extensions it declared it can take', async () => {
|
|
88
|
+
// Without this, editing a README turns the lint gate red: eslint given a .md
|
|
89
|
+
// fails rather than skipping it.
|
|
90
|
+
dir = project({ lint: { ...LINT, changedExtensions: ['.ts', '.tsx'] } });
|
|
91
|
+
mockChangedFiles.mockReturnValue({ files: ['README.md', 'logo.png', 'src/a.ts'] });
|
|
92
|
+
await load().runSensors({ cwd: dir, changed: true });
|
|
93
|
+
expect(cmds()).toEqual([`eslint --format json 'src/a.ts'`]);
|
|
94
|
+
});
|
|
95
|
+
it('skips the sensor when the filter empties the scope, rather than running repo-wide', async () => {
|
|
96
|
+
// A docs-only commit means the lint sensor has nothing to say. Falling back to
|
|
97
|
+
// the full command here would reintroduce exactly the cost --changed removes.
|
|
98
|
+
dir = project({ lint: { ...LINT, changedExtensions: ['.ts'] }, typecheck: TYPECHECK });
|
|
99
|
+
mockChangedFiles.mockReturnValue({ files: ['README.md'] });
|
|
100
|
+
const out = await load().runSensors({ cwd: dir, changed: true });
|
|
101
|
+
expect(out.sensors.find((s) => s.name === 'lint').status).toBe('skipped');
|
|
102
|
+
expect(cmds()).toEqual(['tsc --noEmit']);
|
|
103
|
+
});
|
|
104
|
+
it('refuses a changedCmd without a {files} placeholder instead of running it repo-wide', async () => {
|
|
105
|
+
// Running the template as-is would cover the whole repo while the result
|
|
106
|
+
// claimed to be scoped. That is a mislabelled verdict, not a slow path.
|
|
107
|
+
dir = project({ lint: { ...LINT, changedCmd: 'eslint --format json .' } });
|
|
108
|
+
mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
|
|
109
|
+
const out = await load().runSensors({ cwd: dir, changed: true });
|
|
110
|
+
expect(out.sensors[0].status).toBe('inconclusive');
|
|
111
|
+
expect(out.overall).toBe('not_certified');
|
|
112
|
+
expect(mockRunCommand).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
it('refuses to combine --changed with a baseline capture', async () => {
|
|
115
|
+
// buildBaseline snapshots the run it is given, so baselining a scoped run
|
|
116
|
+
// would write a baseline covering only the diff and silently drop every
|
|
117
|
+
// accepted finding elsewhere — which then reports as NEW on the next full run.
|
|
118
|
+
dir = project({ lint: LINT });
|
|
119
|
+
mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
|
|
120
|
+
await expect(load().runSensors({ cwd: dir, changed: true, ignoreBaseline: true }))
|
|
121
|
+
.rejects.toThrow(/cannot define the accepted set/);
|
|
122
|
+
});
|
|
123
|
+
it('runs everything at full scope when --changed is absent', async () => {
|
|
124
|
+
dir = project({ lint: LINT, typecheck: TYPECHECK });
|
|
125
|
+
const out = await load().runSensors({ cwd: dir });
|
|
126
|
+
expect(cmds()).toEqual(['eslint --format json .', 'tsc --noEmit']);
|
|
127
|
+
expect(mockChangedFiles).not.toHaveBeenCalled();
|
|
128
|
+
expect(out.changedScope).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
it('passes the requested base through to the scope resolver', async () => {
|
|
131
|
+
dir = project({ lint: LINT });
|
|
132
|
+
mockChangedFiles.mockReturnValue({ files: ['src/a.ts'] });
|
|
133
|
+
await load().runSensors({ cwd: dir, changed: true, base: 'main' });
|
|
134
|
+
expect(mockChangedFiles).toHaveBeenCalledWith(dir, 'main');
|
|
135
|
+
});
|
|
136
|
+
});
|