agentic-workflow-manager 9.19.0 → 9.20.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/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/run.js +1 -0
- package/dist/src/core/changes/command.js +38 -0
- package/dist/src/core/changes/derive.js +120 -0
- package/dist/src/core/changes/fs-safe.js +47 -0
- package/dist/src/core/changes/git-env.js +68 -0
- package/dist/src/core/changes/git.js +216 -0
- package/dist/src/core/changes/list-parse.js +67 -0
- package/dist/src/core/changes/list.js +408 -0
- package/dist/src/core/changes/parse.js +72 -0
- package/dist/src/core/changes/range.js +53 -0
- package/dist/src/core/changes/receipt.js +274 -0
- package/dist/src/core/changes/record-io.js +149 -0
- package/dist/src/core/changes/render-list.js +109 -0
- package/dist/src/core/changes/render.js +178 -0
- package/dist/src/core/changes/text-safety.js +116 -0
- package/dist/src/core/changes/trailers.js +76 -0
- package/dist/src/core/changes/types.js +26 -0
- package/dist/src/core/changes/validate-shape.js +219 -0
- package/dist/src/core/changes/worktree.js +53 -0
- package/dist/src/index.js +4 -0
- package/dist/src/release/orchestrator.js +3 -1
- package/dist/tests/commands/change/new.test.js +1126 -0
- package/dist/tests/commands/change/validate.test.js +1145 -0
- package/dist/tests/commands/changes/list.test.js +423 -0
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/core/changes/derive.test.js +455 -0
- package/dist/tests/core/changes/fs-safe.test.js +82 -0
- package/dist/tests/core/changes/git-env.test.js +143 -0
- package/dist/tests/core/changes/git.test.js +442 -0
- package/dist/tests/core/changes/helpers.js +269 -0
- package/dist/tests/core/changes/list-fixtures.js +122 -0
- package/dist/tests/core/changes/list-long-history.test.js +52 -0
- package/dist/tests/core/changes/list-paging.test.js +511 -0
- package/dist/tests/core/changes/list-parse.test.js +120 -0
- package/dist/tests/core/changes/list-tree.test.js +44 -0
- package/dist/tests/core/changes/list.test.js +821 -0
- package/dist/tests/core/changes/parse.test.js +142 -0
- package/dist/tests/core/changes/predicates.test.js +83 -0
- package/dist/tests/core/changes/range.test.js +92 -0
- package/dist/tests/core/changes/receipt.test.js +947 -0
- package/dist/tests/core/changes/record-io.test.js +307 -0
- package/dist/tests/core/changes/record.test.js +549 -0
- package/dist/tests/core/changes/render-list.test.js +212 -0
- package/dist/tests/core/changes/render.test.js +202 -0
- package/dist/tests/core/changes/text-safety.test.js +226 -0
- package/dist/tests/core/changes/trailers.test.js +181 -0
- package/dist/tests/core/changes/worktree.test.js +259 -0
- package/dist/tests/release/orchestrator-trailer.test.js +72 -0
- package/dist/tests/release/orchestrator.test.js +1 -0
- package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const fs_1 = __importDefault(require("fs"));
|
|
40
|
+
const path_1 = __importDefault(require("path"));
|
|
41
|
+
const commander_1 = require("commander");
|
|
42
|
+
const helpers_1 = require("../../core/changes/helpers");
|
|
43
|
+
const url_1 = require("url");
|
|
44
|
+
const list_fixtures_1 = require("../../core/changes/list-fixtures");
|
|
45
|
+
const changes_1 = require("../../../src/commands/changes");
|
|
46
|
+
const listModule = __importStar(require("../../../src/core/changes/list"));
|
|
47
|
+
const REAL_CHANGES_DIR = path_1.default.resolve(__dirname, '..', '..', '..', '..', 'docs', 'changes');
|
|
48
|
+
const listReal = () => { try {
|
|
49
|
+
return fs_1.default.readdirSync(REAL_CHANGES_DIR).sort();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
} };
|
|
54
|
+
const realBefore = listReal();
|
|
55
|
+
// The command only reads the throwaway repository: the real checkout is never touched.
|
|
56
|
+
afterEach(() => expect(listReal()).toEqual(realBefore));
|
|
57
|
+
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/;
|
|
58
|
+
let repo;
|
|
59
|
+
let template;
|
|
60
|
+
beforeAll(() => { template = (0, helpers_1.makeRepo)(); });
|
|
61
|
+
afterAll(() => template.cleanup());
|
|
62
|
+
beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
|
|
63
|
+
afterEach(() => { jest.restoreAllMocks(); repo.cleanup(); });
|
|
64
|
+
const FIRST = '2026-10-16-first';
|
|
65
|
+
function history() {
|
|
66
|
+
repo.commit({ 'old.ts': '1' }, 'feat: before adoption', '2026-10-16T09:00:00Z');
|
|
67
|
+
repo.commit({ [(0, list_fixtures_1.recordPath)(FIRST)]: (0, list_fixtures_1.record)(FIRST), 'a.ts': '1' }, 'feat: first (#10)', '2026-10-16T20:00:00Z');
|
|
68
|
+
repo.commit({ 'b.ts': '1' }, 'fix: no record', '2026-10-16T21:00:00Z');
|
|
69
|
+
repo.commit({ 'c.ts': '1' }, 'chore(release): v1.0.0 [skip ci]\n\nChange-Record: none', '2026-10-16T22:00:00Z');
|
|
70
|
+
repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-17-bad')]: '---\nschema: nope\n---\n' }, 'feat: bad record', '2026-10-17T01:00:00Z');
|
|
71
|
+
repo.gitAt('2026-10-17T02:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0');
|
|
72
|
+
}
|
|
73
|
+
const run = (opts = {}, deps = {}) => (0, changes_1.runChangesList)(repo.root, opts, { now: list_fixtures_1.now, ...deps });
|
|
74
|
+
const json = (opts = {}, deps = {}) => JSON.parse(run({ since: '48h', format: 'json', ...opts }, deps).stdout);
|
|
75
|
+
const failure = (r, code) => {
|
|
76
|
+
expect(r.code).toBe(2);
|
|
77
|
+
expect(r.stdout).toBe('');
|
|
78
|
+
expect(r.stderr.startsWith(`${code}: `)).toBe(true);
|
|
79
|
+
expect(r.stderr.endsWith('\n')).toBe(true);
|
|
80
|
+
const lines = r.stderr.split('\n').slice(0, -1);
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
expect(line).not.toMatch(UNSAFE);
|
|
83
|
+
expect(line.length).toBeLessThanOrEqual(300);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
describe('awm changes list: flags (R5.2, R5.6)', () => {
|
|
87
|
+
test('an invalid --since exits 2 with since-invalid', () => {
|
|
88
|
+
failure(run({ since: 'soon' }), 'since-invalid');
|
|
89
|
+
});
|
|
90
|
+
test('an invalid --since with --format json still prints on stderr only, like change new', () => {
|
|
91
|
+
failure(run({ since: '0h', format: 'json' }), 'since-invalid');
|
|
92
|
+
});
|
|
93
|
+
test('an invalid --format exits 2 with format-invalid', () => {
|
|
94
|
+
failure(run({ format: 'xml' }), 'format-invalid');
|
|
95
|
+
});
|
|
96
|
+
test('the format is case-sensitive', () => {
|
|
97
|
+
failure(run({ format: 'JSON' }), 'format-invalid');
|
|
98
|
+
});
|
|
99
|
+
test('hostile --since and --format values are not echoed raw', () => {
|
|
100
|
+
const hostile = `\u001b[31m\n${'x'.repeat(600)}`;
|
|
101
|
+
for (const r of [run({ since: hostile }), run({ format: hostile })]) {
|
|
102
|
+
expect(r.code).toBe(2);
|
|
103
|
+
expect(r.stderr).not.toContain('\u001b');
|
|
104
|
+
expect(r.stderr.split('\n')).toHaveLength(2);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test.each([
|
|
108
|
+
['since', 5], ['base', 5], ['format', 5], ['since', {}], ['format', ['json']], ['base', true],
|
|
109
|
+
])('the option %s with the value %j is flag-invalid', (key, value) => {
|
|
110
|
+
failure(run({ [key]: value }), 'flag-invalid');
|
|
111
|
+
});
|
|
112
|
+
test.each([5, 'x', [], null])('options %j that are not an object are flag-invalid', (opts) => {
|
|
113
|
+
failure((0, changes_1.runChangesList)(repo.root, (0, helpers_1.bad)(opts), { now: list_fixtures_1.now }), 'flag-invalid');
|
|
114
|
+
});
|
|
115
|
+
test('missing options use the defaults: 24h and text', () => {
|
|
116
|
+
history();
|
|
117
|
+
expect((0, changes_1.runChangesList)(repo.root, (0, helpers_1.bad)(undefined), { now: list_fixtures_1.now }).code).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
test('the default window is 24h', () => {
|
|
120
|
+
history();
|
|
121
|
+
const out = JSON.parse(run({ format: 'json' }).stdout);
|
|
122
|
+
expect(out.since).toBe('2026-10-16T12:00:00.000Z');
|
|
123
|
+
expect(out.changes.map((c) => c.subject)).toEqual(['feat: bad record', 'chore(release): v1.0.0 [skip ci]', 'fix: no record', 'feat: first (#10)']);
|
|
124
|
+
});
|
|
125
|
+
test('--since leaves out what is older than the window', () => {
|
|
126
|
+
history();
|
|
127
|
+
const out = JSON.parse(run({ format: 'json', since: '12h' }).stdout);
|
|
128
|
+
expect(out.changes.map((c) => c.subject)).toEqual(['feat: bad record']);
|
|
129
|
+
});
|
|
130
|
+
test('a clock that is not usable is invalid-input, not a throw', () => {
|
|
131
|
+
history();
|
|
132
|
+
for (const clock of [() => { throw new Error('x'); }, () => 'later', () => new Date(Number.NaN)])
|
|
133
|
+
failure(run({}, { now: clock }), 'invalid-input');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('awm changes list: repository and base (R5.1, R3.1, R3.2, R3.5)', () => {
|
|
137
|
+
test('outside a repository exits 2 with not-a-git-repo', () => {
|
|
138
|
+
failure((0, changes_1.runChangesList)((0, helpers_1.nonRepoDir)(repo), {}, { now: list_fixtures_1.now }), 'not-a-git-repo');
|
|
139
|
+
});
|
|
140
|
+
test.each([undefined, null, 5, ''])('a working directory %j is invalid-input', (cwd) => {
|
|
141
|
+
failure((0, changes_1.runChangesList)((0, helpers_1.bad)(cwd), {}, { now: list_fixtures_1.now }), 'invalid-input');
|
|
142
|
+
});
|
|
143
|
+
test('an unresolvable base exits 2 with base-unresolved and the remedy', () => {
|
|
144
|
+
const r = run({ base: 'nope' });
|
|
145
|
+
failure(r, 'base-unresolved');
|
|
146
|
+
expect(r.stderr).toContain('remedy: pass --base <ref>');
|
|
147
|
+
});
|
|
148
|
+
test('no default base exits 2 with base-unresolved', () => {
|
|
149
|
+
repo.git('branch', '-m', 'trunk');
|
|
150
|
+
failure(run(), 'base-unresolved');
|
|
151
|
+
});
|
|
152
|
+
test.each(['--upload-pack=x', '-x', 'a..b', 'main^', ' main'])('an unsafe --base %j exits 2 with invalid-input', (base) => {
|
|
153
|
+
failure(run({ base }), 'invalid-input');
|
|
154
|
+
});
|
|
155
|
+
test('an unsafe --base is echoed escaped and clipped', () => {
|
|
156
|
+
const r = run({ base: `-x\u001b[31m${'y'.repeat(600)}` });
|
|
157
|
+
failure(r, 'invalid-input');
|
|
158
|
+
});
|
|
159
|
+
test('--base selects the branch to read', () => {
|
|
160
|
+
history();
|
|
161
|
+
repo.git('checkout', '-q', '-b', 'release');
|
|
162
|
+
repo.commit({ 'r.ts': '1' }, 'fix: only on release', '2026-10-17T03:00:00Z');
|
|
163
|
+
repo.git('checkout', '-q', 'main');
|
|
164
|
+
expect(json({ base: 'release' }).changes[0].subject).toBe('fix: only on release');
|
|
165
|
+
expect(json({ base: 'release' }).base).toBe('release');
|
|
166
|
+
expect(json().changes[0].subject).toBe('feat: bad record');
|
|
167
|
+
});
|
|
168
|
+
test('it reads the base branch whichever branch is checked out, and does not need a clean tree', () => {
|
|
169
|
+
history();
|
|
170
|
+
repo.git('checkout', '-q', '-b', 'feat/x');
|
|
171
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'a.ts'), 'dirty');
|
|
172
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'new.txt'), 'untracked');
|
|
173
|
+
const r = run({ since: '48h' });
|
|
174
|
+
expect(r.code).toBe(0);
|
|
175
|
+
expect(r.stdout).toMatch(/recorded/);
|
|
176
|
+
});
|
|
177
|
+
test('a repository with no commit on the base resolves nothing', () => {
|
|
178
|
+
const fresh = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'fresh-'));
|
|
179
|
+
repo.git('init', '-q', '-b', 'main', fresh);
|
|
180
|
+
failure((0, changes_1.runChangesList)(fresh, {}, { now: list_fixtures_1.now }), 'base-unresolved');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('awm changes list: adoption (R5.4)', () => {
|
|
184
|
+
test('a repo without records reports non-adoption and exits 0', () => {
|
|
185
|
+
const r = run();
|
|
186
|
+
expect(r.code).toBe(0);
|
|
187
|
+
expect(r.stderr).toBe('');
|
|
188
|
+
expect(r.stdout).toMatch(/not adopted Change Records/);
|
|
189
|
+
expect(r.stdout.split('\n')).toHaveLength(2);
|
|
190
|
+
});
|
|
191
|
+
test('a repo without records reports non-adoption in voice', () => {
|
|
192
|
+
expect(run({ format: 'voice' }).stdout).toMatch(/todavía no usa Change Records/);
|
|
193
|
+
});
|
|
194
|
+
test('a repo without records prints an empty JSON document', () => {
|
|
195
|
+
const r = run({ format: 'json' });
|
|
196
|
+
expect(r.code).toBe(0);
|
|
197
|
+
expect(JSON.parse(r.stdout)).toMatchObject({ adopted: false, changes: [], truncated: false, omitted: 0, base: 'main' });
|
|
198
|
+
});
|
|
199
|
+
test('R5.4 a docs/changes/ that holds only a README prints the one-line notice saying that no record exists (not that the directory is empty)', () => {
|
|
200
|
+
repo.commit({ 'docs/changes/README.md': 'how we record changes' }, 'docs: readme');
|
|
201
|
+
const r = run();
|
|
202
|
+
expect(r.code).toBe(0);
|
|
203
|
+
expect(r.stdout).toBe(`${path_1.default.basename(repo.root)} has not adopted Change Records (no record under docs/changes/ on main).\n`);
|
|
204
|
+
expect(JSON.parse(run({ format: 'json' }).stdout)).toMatchObject({ adopted: false, changes: [] });
|
|
205
|
+
});
|
|
206
|
+
test('a repo without records is named after its directory when it has no remote', () => {
|
|
207
|
+
expect(run().stdout.startsWith(`${path_1.default.basename(repo.root)} has not adopted`)).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('awm changes list: classification and derived fields (R5.1, R5.3, R5.4, R5.5)', () => {
|
|
211
|
+
test('classifies and derives', () => {
|
|
212
|
+
history();
|
|
213
|
+
const out = json();
|
|
214
|
+
const byClass = Object.fromEntries(out.changes.map((c) => [c.subject, c.classification]));
|
|
215
|
+
expect(byClass).toEqual({ 'feat: first (#10)': 'recorded', 'fix: no record': 'missing', 'chore(release): v1.0.0 [skip ci]': 'exempt', 'feat: bad record': 'invalid-record' });
|
|
216
|
+
const first = out.changes.find((c) => c.subject === 'feat: first (#10)');
|
|
217
|
+
expect(first).toMatchObject({ pr: 10, release: 'v1.0.0', merged_at: '2026-10-16T20:00:00.000Z' });
|
|
218
|
+
expect(first.records[0]).toMatchObject({ path: (0, list_fixtures_1.recordPath)(FIRST), summary_short: 'Primer cambio.' });
|
|
219
|
+
expect(out.repo).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
test('pre-adoption commits are omitted', () => {
|
|
222
|
+
history();
|
|
223
|
+
expect(JSON.stringify(json())).not.toContain('before adoption');
|
|
224
|
+
});
|
|
225
|
+
test('repo identity strips credentials', () => {
|
|
226
|
+
history();
|
|
227
|
+
repo.git('remote', 'add', 'origin', 'https://user:tok@github.com/Kodria/belanz.git');
|
|
228
|
+
const r = run({ since: '48h', format: 'json' });
|
|
229
|
+
expect(JSON.parse(r.stdout).repo).toBe('github.com/Kodria/belanz');
|
|
230
|
+
expect(r.stdout).not.toContain('tok');
|
|
231
|
+
expect(run({ since: '48h', format: 'voice' }).stdout.startsWith('belanz: ')).toBe(true);
|
|
232
|
+
expect(run({ since: '48h' }).stdout).not.toContain('tok');
|
|
233
|
+
});
|
|
234
|
+
test('hostile commit text never reaches any format raw', () => {
|
|
235
|
+
history();
|
|
236
|
+
repo.commit({ 'h.ts': '1' }, 'fix: \u001b[31mred\u001b[0m \u202eevil\u2028 end', '2026-10-17T03:00:00Z');
|
|
237
|
+
for (const format of ['text', 'json', 'voice']) {
|
|
238
|
+
const out = run({ since: '48h', format }).stdout;
|
|
239
|
+
expect(out).not.toMatch(/\u001b|\u202e|\u2028/);
|
|
240
|
+
}
|
|
241
|
+
const subjects = json().changes.map((c) => c.subject);
|
|
242
|
+
expect(subjects[0]).toBe('fix: \\u001b[31mred\\u001b[0m \\u202eevil\\u2028 end');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
describe('awm changes list: mixed invalid commits, odd dates and shallow clones (R5.1, R5.5, R5.6)', () => {
|
|
246
|
+
const mixed = () => {
|
|
247
|
+
history();
|
|
248
|
+
repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-17-aaa')]: (0, list_fixtures_1.record)('2026-10-17-aaa', { summary: 'Valido.' }), [(0, list_fixtures_1.recordPath)('2026-10-17-zzz')]: 'junk' }, 'feat: mixed', '2026-10-17T03:00:00Z');
|
|
249
|
+
};
|
|
250
|
+
test('json lists both records of the mixed commit: the valid one with empty diagnostics, the junk one with its diagnostics', () => {
|
|
251
|
+
mixed();
|
|
252
|
+
const entry = json().changes[0];
|
|
253
|
+
expect(entry.classification).toBe('invalid-record');
|
|
254
|
+
expect(entry.records).toEqual([
|
|
255
|
+
{ path: (0, list_fixtures_1.recordPath)('2026-10-17-aaa'), diagnostics: [] },
|
|
256
|
+
{ path: (0, list_fixtures_1.recordPath)('2026-10-17-zzz'), diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'frontmatter-missing' })]) },
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
test('the text row of the mixed commit shows the junk record diagnostic, not the valid first record', () => {
|
|
260
|
+
mixed();
|
|
261
|
+
const row = run({ since: '48h' }).stdout.split('\n')[1];
|
|
262
|
+
expect(row).toContain('feat: mixed');
|
|
263
|
+
expect(row).toContain('(frontmatter-missing)');
|
|
264
|
+
});
|
|
265
|
+
test('voice counts the mixed commit as invalid and does not speak its valid record', () => {
|
|
266
|
+
mixed();
|
|
267
|
+
const out = run({ since: '48h', format: 'voice' }).stdout;
|
|
268
|
+
expect(out).not.toContain('Valido.');
|
|
269
|
+
expect(out).toMatch(/1 cambio\(s\) sin record y 2 con record inválido/);
|
|
270
|
+
});
|
|
271
|
+
test.each(['text', 'json', 'voice'])('a commit with an unusable committer date is listed in the %s format', (format) => {
|
|
272
|
+
history();
|
|
273
|
+
(0, list_fixtures_1.commitLiterally)(repo, '');
|
|
274
|
+
const r = run({ since: '48h', format });
|
|
275
|
+
expect(r.code).toBe(0);
|
|
276
|
+
if (format === 'json')
|
|
277
|
+
expect(json().changes[0]).toMatchObject({ subject: 'fix: odd date', merged_at: null });
|
|
278
|
+
if (format === 'text')
|
|
279
|
+
expect(r.stdout.split('\n')[1]).toMatch(/^unknown\s+missing\s/);
|
|
280
|
+
if (format === 'voice')
|
|
281
|
+
expect(r.stdout).toMatch(/2 cambio\(s\) sin record/);
|
|
282
|
+
});
|
|
283
|
+
test('a shallow clone exits 2 with shallow-history and its remedy', () => {
|
|
284
|
+
history();
|
|
285
|
+
const dest = path_1.default.join(repo.home, 'shallow');
|
|
286
|
+
repo.git('clone', '-q', '--depth', '1', (0, url_1.pathToFileURL)(repo.root).href, dest);
|
|
287
|
+
const r = (0, changes_1.runChangesList)(dest, { since: '48h', format: 'json' }, { now: list_fixtures_1.now });
|
|
288
|
+
failure(r, 'shallow-history');
|
|
289
|
+
expect(r.stderr).toContain('remedy: git fetch --unshallow');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
describe('awm changes list: formats (R5.6)', () => {
|
|
293
|
+
test('json is one document on one line', () => {
|
|
294
|
+
history();
|
|
295
|
+
const r = run({ since: '48h', format: 'json' });
|
|
296
|
+
expect(r.stdout.split('\n')).toHaveLength(2);
|
|
297
|
+
expect(Object.keys(JSON.parse(r.stdout))).toEqual(['schema', 'repo', 'base', 'since', 'adopted', 'truncated', 'omitted', 'changes']);
|
|
298
|
+
expect(JSON.parse(r.stdout).adopted).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
test('voice speaks recorded changes and counts the rest', () => {
|
|
301
|
+
history();
|
|
302
|
+
const r = run({ since: '48h', format: 'voice' });
|
|
303
|
+
expect(r.stdout).toContain('Primer cambio. feat, verificado, 2 validaciones manuales pendientes.');
|
|
304
|
+
expect(r.stdout).toMatch(/1 cambio\(s\) sin record y 1 con record inválido/);
|
|
305
|
+
});
|
|
306
|
+
test('text is a table with the classification of each change', () => {
|
|
307
|
+
history();
|
|
308
|
+
const out = run({ since: '48h' }).stdout;
|
|
309
|
+
for (const word of ['recorded', 'missing', 'exempt', 'invalid-record', 'v1.0.0', '#10'])
|
|
310
|
+
expect(out).toContain(word);
|
|
311
|
+
expect(out.split('\n')[0]).toMatch(/^WHEN\s+CLASSIFICATION/);
|
|
312
|
+
});
|
|
313
|
+
test('text is the default format', () => {
|
|
314
|
+
history();
|
|
315
|
+
expect(run({ since: '48h' }).stdout).toBe(run({ since: '48h', format: 'text' }).stdout);
|
|
316
|
+
});
|
|
317
|
+
test('an empty window says so in text', () => {
|
|
318
|
+
history();
|
|
319
|
+
expect(run({ since: '1h' }).stdout).toMatch(/^No changes on main since /);
|
|
320
|
+
});
|
|
321
|
+
test('beyond the limit every format ends with a notice and json reports the omitted count', () => {
|
|
322
|
+
history();
|
|
323
|
+
const deps = { limit: 2 };
|
|
324
|
+
const out = JSON.parse(run({ since: '48h', format: 'json' }, deps).stdout);
|
|
325
|
+
expect(out).toMatchObject({ truncated: true, omitted: 2 });
|
|
326
|
+
expect(out.changes).toHaveLength(2);
|
|
327
|
+
expect(run({ since: '48h' }, deps).stdout).toMatch(/2 older change\(s\) not shown/);
|
|
328
|
+
expect(run({ since: '48h', format: 'voice' }, deps).stdout).toMatch(/Hay 2 cambio\(s\) más antiguo\(s\) sin mostrar/);
|
|
329
|
+
});
|
|
330
|
+
test('no format prints anything on stderr when it succeeds', () => {
|
|
331
|
+
history();
|
|
332
|
+
for (const format of ['text', 'json', 'voice'])
|
|
333
|
+
expect(run({ since: '48h', format }).stderr).toBe('');
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
describe('awm changes list: read-only (R5.7)', () => {
|
|
337
|
+
test('leaves the repository exactly as it was', () => {
|
|
338
|
+
history();
|
|
339
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'untracked.txt'), 'u');
|
|
340
|
+
const before = [repo.git('status', '--porcelain', '--ignored'), repo.git('for-each-ref'), fs_1.default.readFileSync(path_1.default.join(repo.root, '.git', 'index')).toString('hex')];
|
|
341
|
+
for (const format of ['text', 'json', 'voice'])
|
|
342
|
+
run({ since: '48h', format }, { limit: 2 });
|
|
343
|
+
expect([repo.git('status', '--porcelain', '--ignored'), repo.git('for-each-ref'), fs_1.default.readFileSync(path_1.default.join(repo.root, '.git', 'index')).toString('hex')]).toEqual(before);
|
|
344
|
+
expect(fs_1.default.readdirSync(repo.root).sort()).toEqual(['.git', '.gitignore', 'README.md', 'a.ts', 'b.ts', 'c.ts', 'docs', 'old.ts', 'untracked.txt']);
|
|
345
|
+
});
|
|
346
|
+
test('never creates .awm or docs/changes files in the repository it reads', () => {
|
|
347
|
+
repo.commit({ 'a.ts': '1' }, 'feat: a', '2026-10-16T09:00:00Z');
|
|
348
|
+
run();
|
|
349
|
+
expect(fs_1.default.existsSync(path_1.default.join(repo.root, '.awm'))).toBe(false);
|
|
350
|
+
expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
describe('awm changes list: never throws', () => {
|
|
354
|
+
test('an unexpected failure inside is an internal-error on stderr', () => {
|
|
355
|
+
history();
|
|
356
|
+
jest.spyOn(listModule, 'listChanges').mockImplementation(() => { throw new Error('secret\u001b[31m detail'); });
|
|
357
|
+
const r = run({ since: '48h' });
|
|
358
|
+
failure(r, 'internal-error');
|
|
359
|
+
expect(r.stderr).not.toContain('secret');
|
|
360
|
+
});
|
|
361
|
+
test('a failure of git inside the run is reported as git-failed', () => {
|
|
362
|
+
history();
|
|
363
|
+
jest.spyOn(listModule, 'listChanges').mockReturnValue({ ok: false, failure: { code: 'git-failed', message: 'git could not read the history' } });
|
|
364
|
+
failure(run({ since: '48h', format: 'json' }), 'git-failed');
|
|
365
|
+
});
|
|
366
|
+
test('a failure carries its remedy on a second line', () => {
|
|
367
|
+
jest.spyOn(listModule, 'listChanges').mockReturnValue({ ok: false, failure: { code: 'base-unresolved', message: 'm', remedy: 'pass --base <ref>' } });
|
|
368
|
+
expect(run().stderr).toBe('base-unresolved: m\n remedy: pass --base <ref>\n');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
describe('registerChangesCommand', () => {
|
|
372
|
+
function program() {
|
|
373
|
+
const p = new commander_1.Command();
|
|
374
|
+
p.exitOverride();
|
|
375
|
+
(0, changes_1.registerChangesCommand)(p, { now: list_fixtures_1.now });
|
|
376
|
+
return p;
|
|
377
|
+
}
|
|
378
|
+
let savedExit;
|
|
379
|
+
let out;
|
|
380
|
+
let err;
|
|
381
|
+
beforeEach(() => {
|
|
382
|
+
savedExit = process.exitCode;
|
|
383
|
+
out = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
384
|
+
err = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
385
|
+
jest.spyOn(process, 'cwd').mockImplementation(() => repo.root);
|
|
386
|
+
});
|
|
387
|
+
afterEach(() => { process.exitCode = savedExit; });
|
|
388
|
+
const sub = () => program().commands.find((c) => c.name() === 'changes')?.commands.find((c) => c.name() === 'list');
|
|
389
|
+
test('registers changes list with its documented flags', () => {
|
|
390
|
+
expect(sub()?.options.map((o) => o.long).sort()).toEqual(['--base', '--format', '--since']);
|
|
391
|
+
expect(sub()?.registeredArguments).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
test('a run prints the result and leaves the exit code alone', async () => {
|
|
394
|
+
history();
|
|
395
|
+
await program().parseAsync(['node', 'awm', 'changes', 'list', '--since', '48h', '--format', 'json']);
|
|
396
|
+
expect(JSON.parse(out.mock.calls[0][0]).changes).toHaveLength(4);
|
|
397
|
+
expect(process.exitCode).toBe(savedExit);
|
|
398
|
+
});
|
|
399
|
+
test('flags reach the command', async () => {
|
|
400
|
+
history();
|
|
401
|
+
repo.git('branch', 'other');
|
|
402
|
+
await program().parseAsync(['node', 'awm', 'changes', 'list', '--base', 'other', '--since', '1h', '--format', 'voice']);
|
|
403
|
+
expect(out.mock.calls[0][0]).toBe(`${path_1.default.basename(repo.root)}: sin cambios desde 2026-10-17.\n`);
|
|
404
|
+
});
|
|
405
|
+
test('a failure goes to stderr and sets the exit code 2', async () => {
|
|
406
|
+
await program().parseAsync(['node', 'awm', 'changes', 'list', '--since', 'soon']);
|
|
407
|
+
expect(err.mock.calls[0][0]).toMatch(/^since-invalid: /);
|
|
408
|
+
expect(out).not.toHaveBeenCalled();
|
|
409
|
+
expect(process.exitCode).toBe(2);
|
|
410
|
+
});
|
|
411
|
+
test('a working directory that is gone is reported, not thrown', async () => {
|
|
412
|
+
jest.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('ENOENT'); });
|
|
413
|
+
await expect(program().parseAsync(['node', 'awm', 'changes', 'list'])).resolves.toBeDefined();
|
|
414
|
+
expect(err.mock.calls[0][0]).toMatch(/^not-a-git-repo: /);
|
|
415
|
+
expect(process.exitCode).toBe(2);
|
|
416
|
+
});
|
|
417
|
+
test('an unexpected throw inside the action is reported, not thrown', async () => {
|
|
418
|
+
jest.spyOn(listModule, 'listChanges').mockImplementation(() => { throw new Error('boom'); });
|
|
419
|
+
await expect(program().parseAsync(['node', 'awm', 'changes', 'list'])).resolves.toBeDefined();
|
|
420
|
+
expect(err.mock.calls[0][0]).toMatch(/^internal-error: /);
|
|
421
|
+
expect(process.exitCode).toBe(2);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
jest.mock('@clack/prompts', () => ({ log: { success: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() } }));
|
|
4
4
|
jest.mock('picocolors', () => ({ green: (s) => s, yellow: (s) => s, red: (s) => s }));
|
|
5
|
-
jest.mock('../../../src/commands/sensors/run', () => ({ runSensors: jest.fn() }));
|
|
5
|
+
jest.mock('../../../src/commands/sensors/run', () => ({ runSensors: jest.fn(), findManifestDir: jest.fn() }));
|
|
6
6
|
jest.mock('../../../src/commands/sensors/init', () => ({ initSensors: jest.fn() }));
|
|
7
7
|
jest.mock('../../../src/commands/sensors/status', () => ({ computeSensorStatus: jest.fn() }));
|
|
8
8
|
jest.mock('../../../src/commands/sensors/install', () => ({ installSensorHook: jest.fn() }));
|