agentic-workflow-manager 3.6.0 → 3.8.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/context-budget/budget.js +112 -0
- package/dist/src/commands/context-budget/index.js +58 -0
- 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/src/index.js +2 -0
- package/dist/tests/commands/context-budget/budget.test.js +100 -0
- 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,112 @@
|
|
|
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.CONFIG_FILE = exports.DEFAULT_FILES = void 0;
|
|
7
|
+
exports.estimateTokens = estimateTokens;
|
|
8
|
+
exports.measure = measure;
|
|
9
|
+
exports.readConfig = readConfig;
|
|
10
|
+
exports.writeConfig = writeConfig;
|
|
11
|
+
exports.checkBudget = checkBudget;
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
/**
|
|
15
|
+
* The feedforward context budget.
|
|
16
|
+
*
|
|
17
|
+
* `AGENTS.md`, `CONSTITUTION.md` and `CLAUDE.md` are injected into EVERY agent session
|
|
18
|
+
* before a single line of code is read, so their size is a per-session tax paid
|
|
19
|
+
* forever. They grow because curing a lesson is an append and pruning one is a
|
|
20
|
+
* judgement call nobody is forced to make.
|
|
21
|
+
*
|
|
22
|
+
* Prose does not hold this line. Measured on a real repo, `AGENTS.md` went 73KB → 141KB
|
|
23
|
+
* across 45 revisions and never shrank once, while `harness-retro` already carried an
|
|
24
|
+
* explicit "merge and prune, never append raw" rule. Adding a second copy of an ignored
|
|
25
|
+
* rule has no expected effect, so this measures instead.
|
|
26
|
+
*
|
|
27
|
+
* It does NOT forbid growth — it makes growth deliberate. First check pins the current
|
|
28
|
+
* total; later checks report when the files have grown past it. The ways out are pruning
|
|
29
|
+
* back under, or raising `maxBytes` in a committed diff a human reviews.
|
|
30
|
+
*
|
|
31
|
+
* **Where this runs matters as much as what it measures.** It belongs at the last moment
|
|
32
|
+
* a human is guaranteed to be present — the pre-handoff gate at the end of
|
|
33
|
+
* `writing-plans`, before execution is handed to subagents. Wired into `awm sensors run`
|
|
34
|
+
* instead, it would fire during unattended overnight runs and strand the very PR the
|
|
35
|
+
* user came back expecting. Hence a command, not a sensor.
|
|
36
|
+
*/
|
|
37
|
+
/** Injected into every session by the AWM session hook / context contract. */
|
|
38
|
+
exports.DEFAULT_FILES = ['AGENTS.md', 'CONSTITUTION.md', 'CLAUDE.md'];
|
|
39
|
+
exports.CONFIG_FILE = path_1.default.join('.awm', 'context-budget.json');
|
|
40
|
+
/** Rough but stable: ~4 bytes per token for prose. Reporting only — never a gate input. */
|
|
41
|
+
function estimateTokens(bytes) {
|
|
42
|
+
return Math.round(bytes / 4 / 1000);
|
|
43
|
+
}
|
|
44
|
+
function measure(cwd, files) {
|
|
45
|
+
const breakdown = [];
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
let s;
|
|
49
|
+
try {
|
|
50
|
+
s = fs_1.default.statSync(path_1.default.join(cwd, f));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue; // a repo need not have all three
|
|
54
|
+
}
|
|
55
|
+
if (!s.isFile())
|
|
56
|
+
continue;
|
|
57
|
+
total += s.size;
|
|
58
|
+
breakdown.push({ file: f, bytes: s.size });
|
|
59
|
+
}
|
|
60
|
+
return { total, breakdown };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read the pinned budget. A malformed or partial config returns null so the caller
|
|
64
|
+
* re-pins rather than treating an unreadable budget as an absent limit — the quiet
|
|
65
|
+
* direction of wrong, where the check silently stops checking.
|
|
66
|
+
*/
|
|
67
|
+
function readConfig(cwd) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = JSON.parse(fs_1.default.readFileSync(path_1.default.join(cwd, exports.CONFIG_FILE), 'utf-8'));
|
|
70
|
+
if (typeof raw.maxBytes !== 'number' || !Number.isFinite(raw.maxBytes))
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
files: Array.isArray(raw.files) && raw.files.length ? raw.files : exports.DEFAULT_FILES,
|
|
74
|
+
maxBytes: raw.maxBytes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function writeConfig(cwd, config) {
|
|
82
|
+
fs_1.default.mkdirSync(path_1.default.join(cwd, '.awm'), { recursive: true });
|
|
83
|
+
const body = {
|
|
84
|
+
_comment: 'Context budget for files injected into every agent session. Raising maxBytes '
|
|
85
|
+
+ 'is allowed but must be a deliberate, reviewed change — see writing-plans, '
|
|
86
|
+
+ 'Context Budget Gate.',
|
|
87
|
+
...config,
|
|
88
|
+
};
|
|
89
|
+
fs_1.default.writeFileSync(path_1.default.join(cwd, exports.CONFIG_FILE), JSON.stringify(body, null, 2) + '\n', 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Measure the injected context against the pinned budget.
|
|
93
|
+
*
|
|
94
|
+
* On the first check there is nothing to compare against, so the current total is
|
|
95
|
+
* pinned and the result is `pinned`. Pinning rather than failing means adopting this
|
|
96
|
+
* never blocks a repo that is already large — it only stops it getting larger.
|
|
97
|
+
*/
|
|
98
|
+
function checkBudget(cwd) {
|
|
99
|
+
const config = readConfig(cwd);
|
|
100
|
+
if (!config) {
|
|
101
|
+
const { total, breakdown } = measure(cwd, exports.DEFAULT_FILES);
|
|
102
|
+
writeConfig(cwd, { files: exports.DEFAULT_FILES, maxBytes: total });
|
|
103
|
+
return { status: 'pinned', totalBytes: total, maxBytes: total, breakdown };
|
|
104
|
+
}
|
|
105
|
+
const { total, breakdown } = measure(cwd, config.files);
|
|
106
|
+
return {
|
|
107
|
+
status: total > config.maxBytes ? 'over' : 'within',
|
|
108
|
+
totalBytes: total,
|
|
109
|
+
maxBytes: config.maxBytes,
|
|
110
|
+
breakdown,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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.exitCodeFor = exitCodeFor;
|
|
7
|
+
exports.formatReport = formatReport;
|
|
8
|
+
exports.registerContextBudgetCommand = registerContextBudgetCommand;
|
|
9
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
|
+
const budget_1 = require("./budget");
|
|
11
|
+
const KB = (bytes) => `${(bytes / 1024).toFixed(0)}KB`;
|
|
12
|
+
/**
|
|
13
|
+
* Exit code. `over` exits 1 so a caller that wants a hard gate can have one — but the
|
|
14
|
+
* skill that invokes this (writing-plans, Context Budget Gate) deliberately does not
|
|
15
|
+
* treat it as one. It runs at the last attended moment and presents a choice; blocking
|
|
16
|
+
* would strand the unattended runs this whole design exists to protect.
|
|
17
|
+
*/
|
|
18
|
+
function exitCodeFor(report) {
|
|
19
|
+
return report.status === 'over' ? 1 : 0;
|
|
20
|
+
}
|
|
21
|
+
function formatReport(report) {
|
|
22
|
+
const tokens = `~${(0, budget_1.estimateTokens)(report.totalBytes)}k tokens`;
|
|
23
|
+
const breakdown = report.breakdown.map(b => `${b.file} ${KB(b.bytes)}`).join(', ');
|
|
24
|
+
if (report.status === 'pinned') {
|
|
25
|
+
return `${picocolors_1.default.green('✔')} Context budget pinned at ${KB(report.totalBytes)} (${tokens} per session).\n`
|
|
26
|
+
+ ` ${breakdown}\n`
|
|
27
|
+
+ ` Saved to ${budget_1.CONFIG_FILE} — commit it. Growth past this point will report here.\n`;
|
|
28
|
+
}
|
|
29
|
+
if (report.status === 'within') {
|
|
30
|
+
return `${picocolors_1.default.green('✔')} Context budget OK: ${KB(report.totalBytes)} of ${KB(report.maxBytes)} (${tokens} per session).\n`
|
|
31
|
+
+ ` ${breakdown}\n`;
|
|
32
|
+
}
|
|
33
|
+
const over = report.totalBytes - report.maxBytes;
|
|
34
|
+
return `${picocolors_1.default.yellow('⚠')} Context budget exceeded: ${KB(report.totalBytes)} vs ${KB(report.maxBytes)} `
|
|
35
|
+
+ `(over by ${KB(over)}).\n`
|
|
36
|
+
+ ` ${breakdown}\n`
|
|
37
|
+
+ ` These files are injected into EVERY session — ${tokens} spent before any code is read.\n\n`
|
|
38
|
+
+ ` Decide now, while you are here:\n`
|
|
39
|
+
+ ` 1. Prune — cheapest moment: that context is already loaded and you are\n`
|
|
40
|
+
+ ` choosing what matters for the work about to start.\n`
|
|
41
|
+
+ ` 2. Raise — edit "maxBytes" in ${budget_1.CONFIG_FILE}; a reviewed decision to keep\n`
|
|
42
|
+
+ ` paying for this in every future session.\n`
|
|
43
|
+
+ ` 3. Accept — proceed and note it in the plan.\n`;
|
|
44
|
+
}
|
|
45
|
+
function registerContextBudgetCommand(program) {
|
|
46
|
+
program
|
|
47
|
+
.command('context-budget')
|
|
48
|
+
.description('check the size of the files injected into every agent session')
|
|
49
|
+
.option('--json', 'emit the report as JSON')
|
|
50
|
+
.option('--cwd <path>', 'directory to measure (default: current)')
|
|
51
|
+
.action((opts) => {
|
|
52
|
+
const report = (0, budget_1.checkBudget)(opts.cwd ?? process.cwd());
|
|
53
|
+
process.stdout.write(opts.json ? JSON.stringify(report, null, 2) + '\n' : formatReport(report));
|
|
54
|
+
const code = exitCodeFor(report);
|
|
55
|
+
if (code !== 0)
|
|
56
|
+
process.exit(code);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -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
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -26,6 +26,7 @@ const miro_1 = require("./core/miro");
|
|
|
26
26
|
const hooks_1 = require("./commands/hooks");
|
|
27
27
|
const sensors_1 = require("./commands/sensors");
|
|
28
28
|
const ledger_1 = require("./commands/ledger");
|
|
29
|
+
const context_budget_1 = require("./commands/context-budget");
|
|
29
30
|
const doctor_1 = require("./commands/doctor");
|
|
30
31
|
const backup_1 = require("./commands/backup");
|
|
31
32
|
const init_1 = require("./commands/init");
|
|
@@ -606,6 +607,7 @@ miroCmd.command('sync <storyMapPath>')
|
|
|
606
607
|
(0, hooks_1.registerHooksCommand)(program);
|
|
607
608
|
(0, sensors_1.registerSensorsCommand)(program);
|
|
608
609
|
(0, ledger_1.registerLedgerCommand)(program);
|
|
610
|
+
(0, context_budget_1.registerContextBudgetCommand)(program);
|
|
609
611
|
(0, doctor_1.registerDoctorCommand)(program);
|
|
610
612
|
(0, backup_1.registerBackupCommand)(program);
|
|
611
613
|
(0, init_1.registerInitCommand)(program);
|
|
@@ -0,0 +1,100 @@
|
|
|
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 budget_1 = require("../../../src/commands/context-budget/budget");
|
|
10
|
+
const context_budget_1 = require("../../../src/commands/context-budget");
|
|
11
|
+
function project(files) {
|
|
12
|
+
// CLAUDE.md: no test may reach the real ~/.awm. Everything here is a tmpdir.
|
|
13
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-budget-'));
|
|
14
|
+
for (const [name, bytes] of Object.entries(files)) {
|
|
15
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, name), 'a'.repeat(bytes));
|
|
16
|
+
}
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
describe('checkBudget', () => {
|
|
20
|
+
const dirs = [];
|
|
21
|
+
const make = (f) => { const d = project(f); dirs.push(d); return d; };
|
|
22
|
+
afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
|
|
23
|
+
it('pins the current total on the first check instead of failing', () => {
|
|
24
|
+
// Adopting this must never block a repo that is already large — it only stops
|
|
25
|
+
// it getting larger. A first-run failure would make it unadoptable exactly
|
|
26
|
+
// where it is most needed.
|
|
27
|
+
const dir = make({ 'AGENTS.md': 5000, 'CONSTITUTION.md': 3000 });
|
|
28
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
29
|
+
expect(report.status).toBe('pinned');
|
|
30
|
+
expect(report.totalBytes).toBe(8000);
|
|
31
|
+
expect((0, budget_1.readConfig)(dir).maxBytes).toBe(8000);
|
|
32
|
+
});
|
|
33
|
+
it('reports over once the files grow past the pin', () => {
|
|
34
|
+
const dir = make({ 'AGENTS.md': 5000 });
|
|
35
|
+
(0, budget_1.checkBudget)(dir); // pin at 5000
|
|
36
|
+
fs_1.default.appendFileSync(path_1.default.join(dir, 'AGENTS.md'), 'b'.repeat(2000));
|
|
37
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
38
|
+
expect(report.status).toBe('over');
|
|
39
|
+
expect(report.totalBytes).toBe(7000);
|
|
40
|
+
expect(report.maxBytes).toBe(5000);
|
|
41
|
+
});
|
|
42
|
+
it('goes quiet again once pruned back under budget', () => {
|
|
43
|
+
const dir = make({ 'AGENTS.md': 5000 });
|
|
44
|
+
(0, budget_1.checkBudget)(dir);
|
|
45
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'AGENTS.md'), 'a'.repeat(4000));
|
|
46
|
+
expect((0, budget_1.checkBudget)(dir).status).toBe('within');
|
|
47
|
+
});
|
|
48
|
+
it('does not re-pin on later checks, so the budget is a real ratchet', () => {
|
|
49
|
+
// If a later check re-pinned, growth would always look like the new normal and
|
|
50
|
+
// the budget would never mean anything.
|
|
51
|
+
const dir = make({ 'AGENTS.md': 5000 });
|
|
52
|
+
(0, budget_1.checkBudget)(dir);
|
|
53
|
+
fs_1.default.appendFileSync(path_1.default.join(dir, 'AGENTS.md'), 'b'.repeat(9000));
|
|
54
|
+
(0, budget_1.checkBudget)(dir);
|
|
55
|
+
expect((0, budget_1.readConfig)(dir).maxBytes).toBe(5000);
|
|
56
|
+
});
|
|
57
|
+
it('counts only the files that exist', () => {
|
|
58
|
+
const dir = make({ 'AGENTS.md': 1000 }); // no CONSTITUTION.md, no CLAUDE.md
|
|
59
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
60
|
+
expect(report.totalBytes).toBe(1000);
|
|
61
|
+
expect(report.breakdown.map(b => b.file)).toEqual(['AGENTS.md']);
|
|
62
|
+
});
|
|
63
|
+
it('re-pins when the config is unreadable rather than treating it as no limit', () => {
|
|
64
|
+
// An unparseable budget must not silently disable the check — that is the quiet
|
|
65
|
+
// direction of wrong, where it stops checking and still reports success.
|
|
66
|
+
const dir = make({ 'AGENTS.md': 2000 });
|
|
67
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
68
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, budget_1.CONFIG_FILE), '{ not json');
|
|
69
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
70
|
+
expect(report.status).toBe('pinned');
|
|
71
|
+
expect((0, budget_1.readConfig)(dir).maxBytes).toBe(2000);
|
|
72
|
+
});
|
|
73
|
+
it('honours a custom file list from the config', () => {
|
|
74
|
+
const dir = make({ 'AGENTS.md': 1000, 'OTHER.md': 500 });
|
|
75
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
76
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, budget_1.CONFIG_FILE), JSON.stringify({ files: ['OTHER.md'], maxBytes: 100 }));
|
|
77
|
+
const report = (0, budget_1.checkBudget)(dir);
|
|
78
|
+
expect(report.totalBytes).toBe(500);
|
|
79
|
+
expect(report.status).toBe('over');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('reporting', () => {
|
|
83
|
+
it('exits non-zero only when over budget', () => {
|
|
84
|
+
expect((0, context_budget_1.exitCodeFor)({ status: 'over', totalBytes: 2, maxBytes: 1, breakdown: [] })).toBe(1);
|
|
85
|
+
expect((0, context_budget_1.exitCodeFor)({ status: 'within', totalBytes: 1, maxBytes: 2, breakdown: [] })).toBe(0);
|
|
86
|
+
expect((0, context_budget_1.exitCodeFor)({ status: 'pinned', totalBytes: 1, maxBytes: 1, breakdown: [] })).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
it('offers the three choices when over, since this runs while a human is present', () => {
|
|
89
|
+
const out = (0, context_budget_1.formatReport)({
|
|
90
|
+
status: 'over',
|
|
91
|
+
totalBytes: 227_000,
|
|
92
|
+
maxBytes: 224_000,
|
|
93
|
+
breakdown: [{ file: 'AGENTS.md', bytes: 145_000 }],
|
|
94
|
+
});
|
|
95
|
+
expect(out).toContain('Prune');
|
|
96
|
+
expect(out).toContain('Raise');
|
|
97
|
+
expect(out).toContain('Accept');
|
|
98
|
+
expect(out).toMatch(/~5[0-9]k tokens/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -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
|
+
});
|