agentic-workflow-manager 9.18.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/plan/index.js +1 -1
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +24 -60
- package/dist/src/core/admission/index.js +11 -1
- 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/core/currentness/check.js +5 -2
- 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/commands/sensors/read-only-observation.test.js +537 -0
- package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
- package/dist/tests/core/admission/index.test.js +60 -0
- 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/core/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- 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/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -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,947 @@
|
|
|
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
|
+
// Only the sensor execution is faked; shouldRun stays real so the selection-parity test checks the production rule.
|
|
40
|
+
jest.mock('../../../src/commands/sensors/run', () => ({
|
|
41
|
+
runSensors: jest.fn(), findManifestDir: jest.fn(),
|
|
42
|
+
shouldRun: jest.requireActual('../../../src/commands/sensors/run').shouldRun,
|
|
43
|
+
}));
|
|
44
|
+
const fs_1 = __importDefault(require("fs"));
|
|
45
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
46
|
+
const os_1 = __importDefault(require("os"));
|
|
47
|
+
const path_1 = __importDefault(require("path"));
|
|
48
|
+
const child_process_1 = __importStar(require("child_process"));
|
|
49
|
+
const commander_1 = require("commander");
|
|
50
|
+
const helpers_1 = require("./helpers");
|
|
51
|
+
const receipt_1 = require("../../../src/core/changes/receipt");
|
|
52
|
+
const index_1 = require("../../../src/commands/sensors/index");
|
|
53
|
+
const run_1 = require("../../../src/commands/sensors/run");
|
|
54
|
+
const project_1 = require("../../../src/commands/sensors/project");
|
|
55
|
+
const verdict_1 = require("../../../src/commands/sensors/verdict");
|
|
56
|
+
const ENV_KEYS = ['HOME', 'AWM_HOME', 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_GLOBAL'];
|
|
57
|
+
const ORIGINAL = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
58
|
+
// The real checkout must stay untouched. Receipts are written under the git TOPLEVEL of the cwd (not the cwd itself),
|
|
59
|
+
// so both that runs directory and the cwd-relative one are watched. Outside a git repository only the latter exists.
|
|
60
|
+
const checkoutToplevel = () => {
|
|
61
|
+
try {
|
|
62
|
+
return path_1.default.resolve((0, child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' } }).trim());
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const TOPLEVEL = checkoutToplevel();
|
|
69
|
+
const WATCHED_RUNS = [...new Set([path_1.default.resolve('.awm', 'sensors', 'runs'), ...(TOPLEVEL === null ? [] : [path_1.default.join(TOPLEVEL, '.awm', 'sensors', 'runs')])])];
|
|
70
|
+
const listRuns = () => Object.fromEntries(WATCHED_RUNS.map((d) => [d, fs_1.default.existsSync(d) ? fs_1.default.readdirSync(d).sort() : null]));
|
|
71
|
+
const RUNS_BEFORE = listRuns();
|
|
72
|
+
let repo;
|
|
73
|
+
let root;
|
|
74
|
+
let home;
|
|
75
|
+
// Child git processes get an explicit identity and config isolation; the shared makeRepo already points HOME and AWM_HOME at a throwaway home.
|
|
76
|
+
const gitEnv = () => ({ ...process.env, HOME: home, GIT_CONFIG_GLOBAL: path_1.default.join(home, 'gitconfig'), GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t', GIT_CONFIG_NOSYSTEM: '1' });
|
|
77
|
+
const g = (...args) => repo.git(...args);
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
repo = (0, helpers_1.makeRepo)();
|
|
80
|
+
root = repo.root;
|
|
81
|
+
home = repo.home;
|
|
82
|
+
// makeRepo's own .gitignore ignores `.awm/`; these tests need a repository that does NOT (R2.2b), so the first commit is replaced.
|
|
83
|
+
repo.commit({ '.gitignore': 'node_modules\n', 'a.txt': 'a' }, 'feat: a');
|
|
84
|
+
});
|
|
85
|
+
afterEach(() => repo.cleanup());
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
for (const k of ENV_KEYS)
|
|
88
|
+
expect(process.env[k]).toBe(ORIGINAL[k]);
|
|
89
|
+
expect(listRuns()).toEqual(RUNS_BEFORE);
|
|
90
|
+
});
|
|
91
|
+
// `manifestPath` is the manifest the run reports (SensorAuthority): the repository root's, by default.
|
|
92
|
+
const output = {
|
|
93
|
+
overall: 'pass',
|
|
94
|
+
sensors: [{ name: 'lint', status: 'pass', errors: [] }, { name: 'a-typecheck', status: 'pass', errors: [] }],
|
|
95
|
+
get manifestPath() { return path_1.default.join(root, '.awm', 'sensors.json'); },
|
|
96
|
+
};
|
|
97
|
+
const runsFile = (head) => path_1.default.join(root, '.awm', 'sensors', 'runs', `${head}.json`);
|
|
98
|
+
const listTree = (dir) => fs_1.default.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.name === '.git' ? [] : e.isDirectory() ? [e.name + '/', ...listTree(path_1.default.join(dir, e.name)).map((c) => `${e.name}/${c}`)] : [e.name]));
|
|
99
|
+
const writeValid = () => {
|
|
100
|
+
const head = g('rev-parse', 'HEAD');
|
|
101
|
+
(0, receipt_1.writeReceipt)(root, (0, receipt_1.buildReceipt)({ commit: head, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '9.20.0', output: output }));
|
|
102
|
+
return head;
|
|
103
|
+
};
|
|
104
|
+
/** Rewrites the on-disk receipt with `mutate` applied, keeping (or not) the old digest. */
|
|
105
|
+
const mutateFile = (head, mutate, redigest = false) => {
|
|
106
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(runsFile(head), 'utf8'));
|
|
107
|
+
mutate(parsed);
|
|
108
|
+
if (redigest) {
|
|
109
|
+
const { digest: _d, ...body } = parsed;
|
|
110
|
+
// computeReceiptDigest refuses a body with non-object sensors; such a fixture keeps its old digest (the shape check rejects it first).
|
|
111
|
+
try {
|
|
112
|
+
parsed.digest = (0, receipt_1.computeReceiptDigest)(body);
|
|
113
|
+
}
|
|
114
|
+
catch { /* keep the old digest */ }
|
|
115
|
+
}
|
|
116
|
+
fs_1.default.writeFileSync(runsFile(head), JSON.stringify(parsed));
|
|
117
|
+
};
|
|
118
|
+
describe('receipt', () => {
|
|
119
|
+
test('isolation: HOME and AWM_HOME point at a throwaway directory, never the real ones', () => {
|
|
120
|
+
expect(process.env.HOME).toBe(home);
|
|
121
|
+
expect(process.env.HOME).not.toBe(ORIGINAL.HOME);
|
|
122
|
+
expect(process.env.AWM_HOME).toBe(path_1.default.join(home, '.awm'));
|
|
123
|
+
expect(process.env.AWM_HOME).not.toBe(ORIGINAL.AWM_HOME);
|
|
124
|
+
expect(process.env.GIT_CONFIG_GLOBAL).toBe(path_1.default.join(home, 'gitconfig'));
|
|
125
|
+
expect(fs_1.default.readFileSync(path_1.default.join(home, 'gitconfig'), 'utf8')).toBe('');
|
|
126
|
+
});
|
|
127
|
+
test('R2.1 full selection on a clean tree writes a 0600 receipt keyed by HEAD', () => {
|
|
128
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
129
|
+
expect(pre.eligible).toBe(true);
|
|
130
|
+
const note = (0, receipt_1.finalizeReceipt)(pre, output, { now: () => new Date('2026-10-17T10:00:00.000Z'), version: () => '9.20.0' });
|
|
131
|
+
expect(note).toBe('');
|
|
132
|
+
const head = g('rev-parse', 'HEAD');
|
|
133
|
+
const file = path_1.default.join(root, '.awm', 'sensors', 'runs', `${head}.json`);
|
|
134
|
+
expect(fs_1.default.existsSync(file)).toBe(true);
|
|
135
|
+
if (process.platform !== 'win32')
|
|
136
|
+
expect(fs_1.default.statSync(file).mode & 0o777).toBe(0o600);
|
|
137
|
+
const read = (0, receipt_1.readReceipt)(root, head);
|
|
138
|
+
expect(read.ok && read.receipt.sensors).toEqual({ 'a-typecheck': 'pass', lint: 'pass' });
|
|
139
|
+
expect(read.ok && Object.keys(read.receipt.sensors)).toEqual(['a-typecheck', 'lint']);
|
|
140
|
+
});
|
|
141
|
+
test('R2.3 the receipt carries schema, commit, ranAt, cliVersion and overall from the run', () => {
|
|
142
|
+
const head = g('rev-parse', 'HEAD');
|
|
143
|
+
const r = (0, receipt_1.buildReceipt)({ commit: head, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '9.20.0', output: { overall: 'fail', sensors: [{ name: 'lint', status: 'fail', errors: [] }] } });
|
|
144
|
+
expect(r).toMatchObject({ schema: 1, commit: head, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '9.20.0', overall: 'fail', sensors: { lint: 'fail' } });
|
|
145
|
+
});
|
|
146
|
+
(0, helpers_1.posixOnly)('R2.1 a receipt replacing an existing 0666 file ends with mode 0600', () => {
|
|
147
|
+
const head = g('rev-parse', 'HEAD');
|
|
148
|
+
fs_1.default.mkdirSync(path_1.default.dirname(runsFile(head)), { recursive: true });
|
|
149
|
+
fs_1.default.writeFileSync(runsFile(head), 'stale');
|
|
150
|
+
fs_1.default.chmodSync(runsFile(head), 0o666);
|
|
151
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
152
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toBe('');
|
|
153
|
+
expect(fs_1.default.statSync(runsFile(head)).mode & 0o777).toBe(0o600);
|
|
154
|
+
expect((0, receipt_1.readReceipt)(root, head).ok).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
(0, helpers_1.posixOnly)('R2.1 writeReceipt forces mode 0600 on an existing file', () => {
|
|
157
|
+
const head = writeValid();
|
|
158
|
+
fs_1.default.chmodSync(runsFile(head), 0o644);
|
|
159
|
+
writeValid();
|
|
160
|
+
expect(fs_1.default.statSync(runsFile(head)).mode & 0o777).toBe(0o600);
|
|
161
|
+
});
|
|
162
|
+
(0, helpers_1.posixOnly)('R2.1 the receipt directory is 0700', () => {
|
|
163
|
+
writeValid();
|
|
164
|
+
expect(fs_1.default.statSync(path_1.default.join(root, '.awm', 'sensors', 'runs')).mode & 0o777).toBe(0o700);
|
|
165
|
+
});
|
|
166
|
+
describe('R2.2b receipt hygiene: a receipt can never be committed by git add -A', () => {
|
|
167
|
+
const ignoreFile = () => path_1.default.join(root, '.awm', 'sensors', 'runs', '.gitignore');
|
|
168
|
+
const stagedAfterAddAll = () => { g('add', '-A'); return g('diff', '--cached', '--name-only'); };
|
|
169
|
+
test('a repository that does not ignore .awm stages nothing under the runs directory', () => {
|
|
170
|
+
expect(g('ls-files', '--others', '--exclude-standard')).toBe('');
|
|
171
|
+
expect((0, receipt_1.finalizeReceipt)((0, receipt_1.captureReceiptPrecondition)(root), output)).toBe('');
|
|
172
|
+
expect(stagedAfterAddAll()).toBe('');
|
|
173
|
+
// ... and the receipt is still there, untracked and ignored.
|
|
174
|
+
const head = g('rev-parse', 'HEAD');
|
|
175
|
+
expect((0, receipt_1.readReceipt)(root, head).ok).toBe(true);
|
|
176
|
+
expect(g('check-ignore', `.awm/sensors/runs/${head}.json`)).toBe(`.awm/sensors/runs/${head}.json`);
|
|
177
|
+
});
|
|
178
|
+
test('the runs directory gets .gitignore containing exactly `*`', () => {
|
|
179
|
+
writeValid();
|
|
180
|
+
expect(fs_1.default.readFileSync(ignoreFile(), 'utf8')).toBe('*\n');
|
|
181
|
+
});
|
|
182
|
+
test('writing again is idempotent and keeps the .gitignore (same inode and content)', () => {
|
|
183
|
+
writeValid();
|
|
184
|
+
const before = fs_1.default.statSync(ignoreFile());
|
|
185
|
+
writeValid();
|
|
186
|
+
expect(fs_1.default.readFileSync(ignoreFile(), 'utf8')).toBe('*\n');
|
|
187
|
+
expect(fs_1.default.statSync(ignoreFile()).ino).toBe(before.ino);
|
|
188
|
+
});
|
|
189
|
+
test('a .gitignore with other content is replaced by `*`', () => {
|
|
190
|
+
fs_1.default.mkdirSync(path_1.default.dirname(ignoreFile()), { recursive: true });
|
|
191
|
+
fs_1.default.writeFileSync(ignoreFile(), '!*\n');
|
|
192
|
+
writeValid();
|
|
193
|
+
expect(fs_1.default.readFileSync(ignoreFile(), 'utf8')).toBe('*\n');
|
|
194
|
+
});
|
|
195
|
+
(0, helpers_1.posixOnly)('a symlinked .gitignore is refused and its target left alone, with no receipt written', () => {
|
|
196
|
+
const target = path_1.default.join(root, 'elsewhere.txt');
|
|
197
|
+
fs_1.default.writeFileSync(target, 'keep');
|
|
198
|
+
fs_1.default.mkdirSync(path_1.default.dirname(ignoreFile()), { recursive: true });
|
|
199
|
+
fs_1.default.symlinkSync(target, ignoreFile());
|
|
200
|
+
expect(() => writeValid()).toThrow(/symlink/);
|
|
201
|
+
expect(fs_1.default.readFileSync(target, 'utf8')).toBe('keep');
|
|
202
|
+
expect(fs_1.default.readdirSync(path_1.default.dirname(ignoreFile()))).toEqual(['.gitignore']);
|
|
203
|
+
});
|
|
204
|
+
test('the dirty-tree logic is unchanged: the runs directory and its .gitignore never make the tree dirty', () => {
|
|
205
|
+
writeValid();
|
|
206
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(true);
|
|
207
|
+
g('add', '-f', '.awm/sensors/runs/.gitignore');
|
|
208
|
+
g('commit', '-q', '-m', 'chore: force-add the ignore file');
|
|
209
|
+
fs_1.default.writeFileSync(ignoreFile(), 'edited\n');
|
|
210
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
test('R2.1 selection rules', () => {
|
|
214
|
+
expect((0, receipt_1.isFullSelection)({})).toBe(true);
|
|
215
|
+
expect((0, receipt_1.isFullSelection)({ all: true })).toBe(true);
|
|
216
|
+
expect((0, receipt_1.isFullSelection)({ fast: true, slow: true })).toBe(true);
|
|
217
|
+
expect((0, receipt_1.isFullSelection)({ fast: true })).toBe(false);
|
|
218
|
+
expect((0, receipt_1.isFullSelection)({ slow: true })).toBe(false);
|
|
219
|
+
expect((0, receipt_1.isFullSelection)({ all: true, changed: true })).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
test('R2.1 isFullSelection agrees with shouldRun for every combination of fast, slow, all and changed', () => {
|
|
222
|
+
const values = [undefined, false, true];
|
|
223
|
+
let combinations = 0;
|
|
224
|
+
for (const fast of values)
|
|
225
|
+
for (const slow of values)
|
|
226
|
+
for (const all of values)
|
|
227
|
+
for (const changed of values) {
|
|
228
|
+
const opts = { fast, slow, all, changed };
|
|
229
|
+
// shouldRun does not model `changed` (it only scopes files), but a --changed run is never a full selection.
|
|
230
|
+
const expected = (0, run_1.shouldRun)(true, opts) && (0, run_1.shouldRun)(false, opts) && !changed;
|
|
231
|
+
expect({ opts, full: (0, receipt_1.isFullSelection)(opts) }).toEqual({ opts, full: expected });
|
|
232
|
+
combinations++;
|
|
233
|
+
}
|
|
234
|
+
expect(combinations).toBe(81);
|
|
235
|
+
});
|
|
236
|
+
test('R2.1 changed alone is a partial selection', () => {
|
|
237
|
+
expect((0, receipt_1.isFullSelection)({ changed: true })).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
test('R2.1 all wins over fast or slow', () => {
|
|
240
|
+
expect((0, receipt_1.isFullSelection)({ all: true, fast: true })).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
test('R2.2 a non-object selection is not a full selection', () => {
|
|
243
|
+
expect((0, receipt_1.isFullSelection)(undefined)).toBe(false);
|
|
244
|
+
expect((0, receipt_1.isFullSelection)(null)).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
test('R2.2 an untracked file yields no receipt and a reason', () => {
|
|
247
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'b.txt'), 'b');
|
|
248
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
249
|
+
expect(pre.eligible).toBe(false);
|
|
250
|
+
const note = (0, receipt_1.finalizeReceipt)(pre, output);
|
|
251
|
+
expect(note).toMatch(/no sensor receipt written: .*uncommitted/);
|
|
252
|
+
expect(note.endsWith('\n')).toBe(true);
|
|
253
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm', 'sensors', 'runs'))).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
test('R2.2 a modified tracked file makes the tree dirty', () => {
|
|
256
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'a.txt'), 'changed');
|
|
257
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
test('R2.2 a staged change makes the tree dirty', () => {
|
|
260
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'a.txt'), 'staged');
|
|
261
|
+
g('add', 'a.txt');
|
|
262
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
test('R2.1 a modified tracked .awm/sensors.json makes the tree dirty', () => {
|
|
265
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
266
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), '{}');
|
|
267
|
+
g('add', '.');
|
|
268
|
+
g('commit', '-q', '-m', 'chore: sensors');
|
|
269
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(true);
|
|
270
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), '{"edited":true}');
|
|
271
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
test('R2.1 a modified tracked .awm/profile.json makes the tree dirty', () => {
|
|
274
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
275
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'profile.json'), '{}');
|
|
276
|
+
g('add', '.');
|
|
277
|
+
g('commit', '-q', '-m', 'chore: profile');
|
|
278
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'profile.json'), '{"edited":true}');
|
|
279
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(false);
|
|
280
|
+
});
|
|
281
|
+
test('R2.1 an untracked file elsewhere under .awm/ makes the tree dirty', () => {
|
|
282
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
283
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'scratch.txt'), 'x');
|
|
284
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
test.each([
|
|
287
|
+
['.awm/sensors/runs/x.json'], ['.awm/journal/entry.json'], ['.awm/ledger/l.jsonl'], ['.awm/evidence/e.txt'], ['.awm/track.json'],
|
|
288
|
+
])('R2.1 an untracked %s does not make the tree dirty', (rel) => {
|
|
289
|
+
fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.join(root, rel)), { recursive: true });
|
|
290
|
+
fs_1.default.writeFileSync(path_1.default.join(root, rel), 'x');
|
|
291
|
+
expect(g('status', '--porcelain')).not.toBe('');
|
|
292
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
test('R2.1 a modified tracked file under an excluded runtime path does not make the tree dirty', () => {
|
|
295
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm', 'journal'), { recursive: true });
|
|
296
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'journal', 'j.json'), '{}');
|
|
297
|
+
g('add', '.');
|
|
298
|
+
g('commit', '-q', '-m', 'chore: journal');
|
|
299
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'journal', 'j.json'), '{"edited":true}');
|
|
300
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
test('R2.1 the pre-run check sees untracked files even when status.showUntrackedFiles is no', () => {
|
|
303
|
+
g('config', 'status.showUntrackedFiles', 'no');
|
|
304
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'new.ts'), 'export {};');
|
|
305
|
+
expect(g('status', '--porcelain')).toBe('');
|
|
306
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
307
|
+
expect(pre.eligible).toBe(false);
|
|
308
|
+
expect(pre.eligible === false && pre.reason).toMatch(/uncommitted/);
|
|
309
|
+
});
|
|
310
|
+
test('R2.1 with status.showUntrackedFiles no, an untracked runtime-state file stays eligible', () => {
|
|
311
|
+
g('config', 'status.showUntrackedFiles', 'no');
|
|
312
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm', 'journal'), { recursive: true });
|
|
313
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'journal', 'j.json'), 'x');
|
|
314
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root).eligible).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
test.each([
|
|
317
|
+
['a path traversal', '../../../x'], ['a slash', 'a/b'], ['39 hex', 'a'.repeat(39)], ['41 hex', 'a'.repeat(41)],
|
|
318
|
+
['uppercase hex', 'A'.repeat(40)], ['a non-string', 5], ['undefined', undefined],
|
|
319
|
+
])('R2.5 writeReceipt rejects %s as commit and writes nothing', (_n, commit) => {
|
|
320
|
+
const valid = (0, receipt_1.buildReceipt)({ commit: g('rev-parse', 'HEAD'), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
321
|
+
const before = listTree(root);
|
|
322
|
+
const attempt = () => (0, receipt_1.writeReceipt)(root, { ...valid, commit });
|
|
323
|
+
expect(attempt).toThrow(/invalid receipt/);
|
|
324
|
+
expect(attempt).not.toThrow(TypeError);
|
|
325
|
+
expect(listTree(root)).toEqual(before);
|
|
326
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm'))).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
test.each([['null', null], ['a string', 'x'], ['an array', []]])('R2.5 writeReceipt rejects %s as receipt without a raw TypeError', (_n, receipt) => {
|
|
329
|
+
const attempt = () => (0, receipt_1.writeReceipt)(root, receipt);
|
|
330
|
+
expect(attempt).toThrow(/invalid receipt/);
|
|
331
|
+
expect(attempt).not.toThrow(TypeError);
|
|
332
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm'))).toBe(false);
|
|
333
|
+
});
|
|
334
|
+
test.each([['undefined', undefined], ['empty', ''], ['a number', 5]])('R2.5 writeReceipt rejects %s as root', (_n, bad) => {
|
|
335
|
+
const valid = (0, receipt_1.buildReceipt)({ commit: g('rev-parse', 'HEAD'), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
336
|
+
const attempt = () => (0, receipt_1.writeReceipt)(bad, valid);
|
|
337
|
+
expect(attempt).toThrow(/root must be a non-empty string/);
|
|
338
|
+
expect(attempt).not.toThrow(TypeError);
|
|
339
|
+
});
|
|
340
|
+
test('R2.5 writeReceipt rejects a receipt whose digest does not match', () => {
|
|
341
|
+
const valid = (0, receipt_1.buildReceipt)({ commit: g('rev-parse', 'HEAD'), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
342
|
+
expect(() => (0, receipt_1.writeReceipt)(root, { ...valid, overall: 'fail' })).toThrow(/invalid receipt: digest/);
|
|
343
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm'))).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
test('R2.3 a valid receipt round-trips write then read', () => {
|
|
346
|
+
const head = writeValid();
|
|
347
|
+
const read = (0, receipt_1.readReceipt)(root, head);
|
|
348
|
+
expect(read.ok && read.receipt.commit).toBe(head);
|
|
349
|
+
expect(read.ok && read.receipt.digest).toMatch(/^sha256:/);
|
|
350
|
+
});
|
|
351
|
+
test.each([
|
|
352
|
+
['a multi-line sensor name', { overall: 'pass', sensors: [{ name: 'a\nb', status: 'pass', errors: [] }] }, undefined],
|
|
353
|
+
['an empty sensor name', { overall: 'pass', sensors: [{ name: '', status: 'pass', errors: [] }] }, undefined],
|
|
354
|
+
['a 129 code point sensor name', { overall: 'pass', sensors: [{ name: '\u{1F600}'.repeat(129), status: 'pass', errors: [] }] }, undefined],
|
|
355
|
+
['an unknown overall', { overall: 'great', sensors: [] }, undefined],
|
|
356
|
+
['an unknown sensor status', { overall: 'pass', sensors: [{ name: 'lint', status: 'ok', errors: [] }] }, undefined],
|
|
357
|
+
['non-array sensors', { overall: 'pass', sensors: 'x' }, undefined],
|
|
358
|
+
['a multi-line cliVersion', output, '1\n2'],
|
|
359
|
+
])('R2.5 finalizeReceipt fails closed on %s: one escaped stderr line and nothing written', (_n, out, version) => {
|
|
360
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
361
|
+
const note = (0, receipt_1.finalizeReceipt)(pre, out, { version: () => version ?? '1.0.0' });
|
|
362
|
+
expect(note).toMatch(/^no sensor receipt written: invalid receipt: [^\n]+\n$/);
|
|
363
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm', 'sensors', 'runs'))).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
test('R2.5 a sensor name of exactly 128 code points is accepted and round-trips', () => {
|
|
366
|
+
const name = '\u{1F600}'.repeat(128);
|
|
367
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
368
|
+
expect((0, receipt_1.finalizeReceipt)(pre, { overall: 'pass', sensors: [{ name, status: 'pass', errors: [] }] })).toBe('');
|
|
369
|
+
const read = (0, receipt_1.readReceipt)(root, g('rev-parse', 'HEAD'));
|
|
370
|
+
expect(read.ok && Object.keys(read.receipt.sensors)).toEqual([name]);
|
|
371
|
+
});
|
|
372
|
+
test('R2.5 a receipt larger than readReceipt accepts is refused on write', () => {
|
|
373
|
+
const sensors = Array.from({ length: 12000 }, (_v, i) => ({ name: `${String(i).padStart(5, '0')}-${'x'.repeat(100)}`, status: 'pass', errors: [] }));
|
|
374
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
375
|
+
expect((0, receipt_1.finalizeReceipt)(pre, { overall: 'pass', sensors })).toMatch(/^no sensor receipt written: invalid receipt: larger than/);
|
|
376
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm', 'sensors', 'runs'))).toBe(false);
|
|
377
|
+
});
|
|
378
|
+
test('R2.5 buildReceipt rejects a null input without a raw TypeError', () => {
|
|
379
|
+
expect(() => (0, receipt_1.buildReceipt)(null)).toThrow(/invalid receipt/);
|
|
380
|
+
expect(() => (0, receipt_1.buildReceipt)(null)).not.toThrow(TypeError);
|
|
381
|
+
});
|
|
382
|
+
test('R2.2 a real directory outside git still reports not a git repository', () => {
|
|
383
|
+
const plain = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plain-'));
|
|
384
|
+
try {
|
|
385
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(plain);
|
|
386
|
+
expect(pre.eligible === false && pre.reason).toBe('not inside a git repository with a commit');
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
fs_1.default.rmSync(plain, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
describe('git failures', () => {
|
|
393
|
+
let spy;
|
|
394
|
+
const failStatusWith = (code) => {
|
|
395
|
+
const real = child_process_1.default.execFileSync;
|
|
396
|
+
spy = jest.spyOn(child_process_1.default, 'execFileSync').mockImplementation(((cmd, args, opts) => {
|
|
397
|
+
if (cmd === 'git' && args[0] === 'status') {
|
|
398
|
+
throw Object.assign(new Error(`spawnSync git ${code}`), { code });
|
|
399
|
+
}
|
|
400
|
+
return real(cmd, args, opts);
|
|
401
|
+
}));
|
|
402
|
+
};
|
|
403
|
+
afterEach(() => spy?.mockRestore());
|
|
404
|
+
test.each([['ENOBUFS'], ['ETIMEDOUT']])('R2.2 a %s from git status is reported as a git failure, not a missing repository', (code) => {
|
|
405
|
+
failStatusWith(code);
|
|
406
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
407
|
+
expect(pre).toEqual({ eligible: false, reason: 'git command failed or timed out' });
|
|
408
|
+
});
|
|
409
|
+
test('R2.2 a git failure during the post-run recheck withholds the receipt with the failure reason', () => {
|
|
410
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
411
|
+
failStatusWith('ETIMEDOUT');
|
|
412
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toBe('no sensor receipt written: git command failed or timed out\n');
|
|
413
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm', 'sensors', 'runs'))).toBe(false);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
test('R2.1 the precondition resolves the repository root from a subdirectory', () => {
|
|
417
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'sub'));
|
|
418
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(path_1.default.join(root, 'sub'));
|
|
419
|
+
// git prints forward slashes on Windows and may resolve short names: compare both sides through the OS.
|
|
420
|
+
expect(pre.eligible).toBe(true);
|
|
421
|
+
expect(pre.eligible && fs_1.default.realpathSync.native(pre.root)).toBe(fs_1.default.realpathSync.native(root));
|
|
422
|
+
expect(pre.eligible && pre.root).toBe(path_1.default.resolve(pre.eligible ? pre.root : ''));
|
|
423
|
+
expect(pre.eligible && pre.commit).toBe(g('rev-parse', 'HEAD'));
|
|
424
|
+
});
|
|
425
|
+
test('R2.2 HEAD moving during the run yields no receipt', () => {
|
|
426
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
427
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'c.txt'), 'c');
|
|
428
|
+
g('add', '.');
|
|
429
|
+
g('commit', '-q', '-m', 'fix: c');
|
|
430
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toMatch(/HEAD or tracked files changed/);
|
|
431
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm', 'sensors', 'runs'))).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
test('R2.2 a tracked file modified during the run yields no receipt and a note', () => {
|
|
434
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
435
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'a.txt'), 'auto-fixed by a sensor');
|
|
436
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toMatch(/^no sensor receipt written: HEAD or tracked files changed during the run\n$/);
|
|
437
|
+
expect(fs_1.default.existsSync(path_1.default.join(root, '.awm', 'sensors', 'runs'))).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
test('R2.2 a tracked file staged for deletion during the run yields no receipt', () => {
|
|
440
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
441
|
+
g('rm', '-q', 'a.txt');
|
|
442
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toMatch(/HEAD or tracked files changed/);
|
|
443
|
+
});
|
|
444
|
+
describe('a submodule (hidden by .gitmodules ignore=all and diff.ignoreSubmodules=all)', () => {
|
|
445
|
+
let sm = '';
|
|
446
|
+
let commitInSm = () => '';
|
|
447
|
+
beforeEach(() => {
|
|
448
|
+
const hidden = (0, helpers_1.makeHiddenSubmodule)(root, (cwd, ...args) => (0, child_process_1.execFileSync)('git', ['-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', env: gitEnv() }).trim());
|
|
449
|
+
sm = hidden.dir;
|
|
450
|
+
commitInSm = hidden.commitInSm;
|
|
451
|
+
});
|
|
452
|
+
test('R2.2 a sensor writing an untracked file inside a clean submodule still gets a receipt', () => {
|
|
453
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
454
|
+
expect(pre.eligible).toBe(true);
|
|
455
|
+
fs_1.default.writeFileSync(path_1.default.join(sm, 'coverage.out'), 'x');
|
|
456
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toBe('');
|
|
457
|
+
expect((0, receipt_1.readReceipt)(root, g('rev-parse', 'HEAD')).ok).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
test('R2.2 a sensor that moves the submodule checkout withholds the receipt', () => {
|
|
460
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
461
|
+
commitInSm('two');
|
|
462
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toMatch(/^no sensor receipt written: HEAD or tracked files changed during the run\n$/);
|
|
463
|
+
});
|
|
464
|
+
test('R2.2 a sensor that modifies tracked content inside the submodule withholds the receipt', () => {
|
|
465
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
466
|
+
fs_1.default.writeFileSync(path_1.default.join(sm, 'one'), 'changed');
|
|
467
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toMatch(/HEAD or tracked files changed/);
|
|
468
|
+
});
|
|
469
|
+
test('R2.1 a submodule moved before the run makes the tree ineligible', () => {
|
|
470
|
+
commitInSm('two');
|
|
471
|
+
expect((0, receipt_1.captureReceiptPrecondition)(root)).toEqual({ eligible: false, reason: 'working tree has uncommitted changes (commit them first)' });
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
test('R2.2 an untracked file created by a sensor during the run does not prevent the receipt', () => {
|
|
475
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
476
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'coverage'));
|
|
477
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'coverage', 'lcov.info'), 'x');
|
|
478
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toBe('');
|
|
479
|
+
expect((0, receipt_1.readReceipt)(root, g('rev-parse', 'HEAD')).ok).toBe(true);
|
|
480
|
+
});
|
|
481
|
+
test('R2.2 a tracked change under an excluded runtime path during the run does not prevent the receipt', () => {
|
|
482
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm', 'journal'), { recursive: true });
|
|
483
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'journal', 'j.json'), '{}');
|
|
484
|
+
g('add', '.');
|
|
485
|
+
g('commit', '-q', '-m', 'chore: journal');
|
|
486
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
487
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'journal', 'j.json'), '{"edited":true}');
|
|
488
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toBe('');
|
|
489
|
+
});
|
|
490
|
+
test('R2.2 a tracked .awm/sensors.json modified during the run yields no receipt', () => {
|
|
491
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
492
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), '{}');
|
|
493
|
+
g('add', '.');
|
|
494
|
+
g('commit', '-q', '-m', 'chore: sensors');
|
|
495
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
496
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), '{"edited":true}');
|
|
497
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toMatch(/HEAD or tracked files changed/);
|
|
498
|
+
});
|
|
499
|
+
test('R2.2 outside git yields no receipt', () => {
|
|
500
|
+
const plain = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plain-'));
|
|
501
|
+
try {
|
|
502
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(plain);
|
|
503
|
+
expect(pre.eligible).toBe(false);
|
|
504
|
+
expect((0, receipt_1.finalizeReceipt)(pre, output)).toMatch(/no sensor receipt written: not inside a git repository/);
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
fs_1.default.rmSync(plain, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
test('R2.2 a repository without a commit yields no receipt', () => {
|
|
511
|
+
const empty = fs_1.default.realpathSync.native(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-empty-')));
|
|
512
|
+
try {
|
|
513
|
+
(0, child_process_1.execFileSync)('git', ['init', '-q', '-b', 'main'], { cwd: empty, env: gitEnv() });
|
|
514
|
+
expect((0, receipt_1.captureReceiptPrecondition)(empty).eligible).toBe(false);
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
fs_1.default.rmSync(empty, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
test('R2.2 a non-string cwd yields no receipt without throwing', () => {
|
|
521
|
+
expect((0, receipt_1.captureReceiptPrecondition)(undefined)).toEqual({ eligible: false, reason: 'cwd must be a non-empty string' });
|
|
522
|
+
});
|
|
523
|
+
test('R2.2 an ineligible or malformed precondition reports a reason without throwing', () => {
|
|
524
|
+
expect((0, receipt_1.finalizeReceipt)(undefined, output)).toMatch(/^no sensor receipt written: /);
|
|
525
|
+
});
|
|
526
|
+
(0, helpers_1.posixOnly)('R2.2 a write failure is reported as a single escaped line and never thrown', () => {
|
|
527
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
528
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm', 'sensors'), { recursive: true });
|
|
529
|
+
const elsewhere = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-else\n-'));
|
|
530
|
+
fs_1.default.symlinkSync(elsewhere, path_1.default.join(root, '.awm', 'sensors', 'runs'));
|
|
531
|
+
try {
|
|
532
|
+
const note = (0, receipt_1.finalizeReceipt)(pre, output);
|
|
533
|
+
expect(note).toMatch(/^no sensor receipt written: .*symlink/);
|
|
534
|
+
expect(note.slice(0, -1)).not.toMatch(/[\n\r]/);
|
|
535
|
+
expect(note.endsWith('\n')).toBe(true);
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
fs_1.default.rmSync(elsewhere, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
test('R2.2 an error message with control characters is escaped to a single line', () => {
|
|
542
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
543
|
+
const note = (0, receipt_1.finalizeReceipt)(pre, output, { version: () => { throw new Error('bad\nline\u001b[31m'); } });
|
|
544
|
+
expect(note).toBe('no sensor receipt written: bad\\nline\\u001b[31m\n');
|
|
545
|
+
});
|
|
546
|
+
test('R2.5 an oversized receipt file is invalid', () => {
|
|
547
|
+
const head = writeValid();
|
|
548
|
+
fs_1.default.appendFileSync(runsFile(head), ' '.repeat(1024 * 1024)); // still valid JSON with a matching digest
|
|
549
|
+
expect(fs_1.default.statSync(runsFile(head)).size).toBeGreaterThan(1024 * 1024);
|
|
550
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
551
|
+
});
|
|
552
|
+
test('R2.5 a directory in place of the receipt file is invalid', () => {
|
|
553
|
+
const head = writeValid();
|
|
554
|
+
fs_1.default.rmSync(runsFile(head));
|
|
555
|
+
fs_1.default.mkdirSync(runsFile(head));
|
|
556
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
557
|
+
});
|
|
558
|
+
test('R2.3/R2.5 digest covers every field and tampering invalidates', () => {
|
|
559
|
+
const head = g('rev-parse', 'HEAD');
|
|
560
|
+
const r = (0, receipt_1.buildReceipt)({ commit: head, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '9.20.0', output: output });
|
|
561
|
+
expect(r.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
562
|
+
(0, receipt_1.writeReceipt)(root, r);
|
|
563
|
+
const file = path_1.default.join(root, '.awm', 'sensors', 'runs', `${head}.json`);
|
|
564
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
|
|
565
|
+
parsed.overall = 'fail';
|
|
566
|
+
fs_1.default.writeFileSync(file, JSON.stringify(parsed));
|
|
567
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
568
|
+
expect((0, receipt_1.readReceipt)(root, 'f'.repeat(40))).toEqual({ ok: false, code: 'receipt-missing' });
|
|
569
|
+
expect((0, receipt_1.readReceipt)(root, '../x')).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
570
|
+
});
|
|
571
|
+
test.each([
|
|
572
|
+
['ranAt', (r) => { r.ranAt = '2026-10-17T11:00:00.000Z'; }],
|
|
573
|
+
['cliVersion', (r) => { r.cliVersion = '0.0.1'; }],
|
|
574
|
+
['sensors', (r) => { r.sensors.lint = 'fail'; }],
|
|
575
|
+
])('R2.5 changing %s without re-digesting invalidates the receipt', (_field, mutate) => {
|
|
576
|
+
const head = writeValid();
|
|
577
|
+
mutateFile(head, mutate);
|
|
578
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
579
|
+
});
|
|
580
|
+
test('R2.2c the digest covers the sensors as name-sorted (code unit) pairs, whatever the insertion order and integer-like names', () => {
|
|
581
|
+
const base = { schema: 1, commit: 'a'.repeat(40), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', overall: 'pass' };
|
|
582
|
+
const orders = [['9', '10', 'tsc'], ['tsc', '10', '9'], ['10', 'tsc', '9']];
|
|
583
|
+
const digests = orders.map((names) => (0, receipt_1.computeReceiptDigest)({ ...base, sensors: Object.fromEntries(names.map((n) => [n, 'pass'])) }));
|
|
584
|
+
expect(new Set(digests).size).toBe(1);
|
|
585
|
+
// Pinned: the canonical form is an array of [name, status] pairs sorted by code unit ('10' < '9' < 'tsc'), not JS object order.
|
|
586
|
+
const canonical = JSON.stringify({ schema: 1, commit: base.commit, ranAt: base.ranAt, cliVersion: base.cliVersion, overall: 'pass', sensors: [['10', 'pass'], ['9', 'pass'], ['tsc', 'pass']] });
|
|
587
|
+
expect(digests[0]).toBe('sha256:' + crypto_1.default.createHash('sha256').update(canonical).digest('hex'));
|
|
588
|
+
});
|
|
589
|
+
test('R2.2c a receipt with integer-like sensor names round-trips and its sensors read back as the same pairs', () => {
|
|
590
|
+
const names = ['9', '10', 'tsc'];
|
|
591
|
+
const pre = (0, receipt_1.captureReceiptPrecondition)(root);
|
|
592
|
+
expect((0, receipt_1.finalizeReceipt)(pre, { overall: 'pass', sensors: names.map((name) => ({ name, status: 'pass', errors: [] })) })).toBe('');
|
|
593
|
+
const read = (0, receipt_1.readReceipt)(root, g('rev-parse', 'HEAD'));
|
|
594
|
+
expect(read.ok && (0, receipt_1.sensorPairs)(read.receipt.sensors)).toEqual([['10', 'pass'], ['9', 'pass'], ['tsc', 'pass']]);
|
|
595
|
+
});
|
|
596
|
+
test('R2.3 the digest changes with each covered field', () => {
|
|
597
|
+
const base = { schema: 1, commit: 'a'.repeat(40), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', overall: 'pass', sensors: { lint: 'pass' } };
|
|
598
|
+
const d = (0, receipt_1.computeReceiptDigest)(base);
|
|
599
|
+
expect((0, receipt_1.computeReceiptDigest)({ ...base, commit: 'b'.repeat(40) })).not.toBe(d);
|
|
600
|
+
expect((0, receipt_1.computeReceiptDigest)({ ...base, ranAt: '2026-10-17T10:00:00.001Z' })).not.toBe(d);
|
|
601
|
+
expect((0, receipt_1.computeReceiptDigest)({ ...base, cliVersion: '1.0.1' })).not.toBe(d);
|
|
602
|
+
expect((0, receipt_1.computeReceiptDigest)({ ...base, overall: 'fail' })).not.toBe(d);
|
|
603
|
+
expect((0, receipt_1.computeReceiptDigest)({ ...base, sensors: { lint: 'fail' } })).not.toBe(d);
|
|
604
|
+
expect((0, receipt_1.computeReceiptDigest)({ ...base, sensors: { a: 'pass', b: 'pass' } })).toBe((0, receipt_1.computeReceiptDigest)({ ...base, sensors: { b: 'pass', a: 'pass' } }));
|
|
605
|
+
});
|
|
606
|
+
test.each([
|
|
607
|
+
['null', null], ['undefined', undefined], ['a number', 42], ['a string', 'x'], ['an array', []],
|
|
608
|
+
['a body whose sensors is null', { schema: 1, commit: 'a'.repeat(40), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1', overall: 'pass', sensors: null }],
|
|
609
|
+
['a body whose sensors is a string', { schema: 1, commit: 'a'.repeat(40), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1', overall: 'pass', sensors: 'x' }],
|
|
610
|
+
['a body whose sensors is an array', { schema: 1, commit: 'a'.repeat(40), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1', overall: 'pass', sensors: [] }],
|
|
611
|
+
])('R2.3 computeReceiptDigest rejects %s with a typed Error, never a TypeError', (_n, body) => {
|
|
612
|
+
let caught;
|
|
613
|
+
try {
|
|
614
|
+
(0, receipt_1.computeReceiptDigest)(body);
|
|
615
|
+
}
|
|
616
|
+
catch (e) {
|
|
617
|
+
caught = e;
|
|
618
|
+
}
|
|
619
|
+
expect(caught).toBeInstanceOf(Error);
|
|
620
|
+
expect(caught).not.toBeInstanceOf(TypeError);
|
|
621
|
+
expect(caught.message).toMatch(/^invalid receipt: digest body (sensors )?must be an object$/);
|
|
622
|
+
if (!/sensors/.test(_n))
|
|
623
|
+
expect(caught.message).toBe('invalid receipt: digest body must be an object');
|
|
624
|
+
});
|
|
625
|
+
test('R2.3 buildReceipt rejects a commit that is not lowercase hex', () => {
|
|
626
|
+
const mk = (commit) => () => (0, receipt_1.buildReceipt)({ commit, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1', output: output });
|
|
627
|
+
expect(mk('../x')).toThrow(/lowercase hex/);
|
|
628
|
+
expect(mk('A'.repeat(40))).toThrow(/lowercase hex/);
|
|
629
|
+
expect(mk('a'.repeat(40))).not.toThrow();
|
|
630
|
+
});
|
|
631
|
+
test('R2.5 an extra key invalidates the receipt even with a recomputed digest', () => {
|
|
632
|
+
const head = writeValid();
|
|
633
|
+
mutateFile(head, (r) => { r.extra = 1; }, true);
|
|
634
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
635
|
+
});
|
|
636
|
+
test('R2.5 a receipt whose commit differs from the requested one is invalid', () => {
|
|
637
|
+
const head = writeValid();
|
|
638
|
+
mutateFile(head, (r) => { r.commit = 'e'.repeat(40); }, true);
|
|
639
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
640
|
+
});
|
|
641
|
+
test.each([
|
|
642
|
+
['non-canonical ranAt', (r) => { r.ranAt = '2026-10-17'; }],
|
|
643
|
+
['unparseable ranAt', (r) => { r.ranAt = 'not-a-date'; }],
|
|
644
|
+
['numeric ranAt', (r) => { r.ranAt = 5; }],
|
|
645
|
+
['unknown overall', (r) => { r.overall = 'great'; }],
|
|
646
|
+
['unknown sensor status', (r) => { r.sensors.lint = 'ok'; }],
|
|
647
|
+
['array sensors', (r) => { r.sensors = []; }],
|
|
648
|
+
['null sensors', (r) => { r.sensors = null; }],
|
|
649
|
+
['schema 2', (r) => { r.schema = 2; }],
|
|
650
|
+
['non-string cliVersion', (r) => { r.cliVersion = 1; }],
|
|
651
|
+
['multi-line cliVersion', (r) => { r.cliVersion = '1.0\n2.0'; }],
|
|
652
|
+
['multi-line sensor name', (r) => { r.sensors['bad\nname'] = 'pass'; }],
|
|
653
|
+
])('R2.5 strict shape rejects %s even with a recomputed digest', (_name, mutate) => {
|
|
654
|
+
const head = writeValid();
|
|
655
|
+
mutateFile(head, mutate, true);
|
|
656
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
657
|
+
});
|
|
658
|
+
test.each([['not json', 'nope{'], ['null', 'null'], ['an array', '[]'], ['a string', '"x"']])('R2.5 a file containing %s is invalid', (_n, body) => {
|
|
659
|
+
const head = writeValid();
|
|
660
|
+
fs_1.default.writeFileSync(runsFile(head), body);
|
|
661
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
662
|
+
});
|
|
663
|
+
(0, helpers_1.posixOnly)('R2.5 a symlinked receipt file is invalid', () => {
|
|
664
|
+
const head = writeValid();
|
|
665
|
+
const real = path_1.default.join(root, 'real.json');
|
|
666
|
+
fs_1.default.renameSync(runsFile(head), real);
|
|
667
|
+
fs_1.default.symlinkSync(real, runsFile(head));
|
|
668
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
669
|
+
});
|
|
670
|
+
(0, helpers_1.posixOnly)('R2.5 a receipt read through a symlinked .awm directory is invalid', () => {
|
|
671
|
+
const head = writeValid();
|
|
672
|
+
const moved = path_1.default.join(root, 'moved-awm');
|
|
673
|
+
fs_1.default.renameSync(path_1.default.join(root, '.awm'), moved);
|
|
674
|
+
fs_1.default.symlinkSync(moved, path_1.default.join(root, '.awm'));
|
|
675
|
+
expect((0, receipt_1.readReceipt)(root, head)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
676
|
+
});
|
|
677
|
+
test('R2.5 a missing runs directory reads as receipt-missing', () => {
|
|
678
|
+
expect((0, receipt_1.readReceipt)(root, g('rev-parse', 'HEAD'))).toEqual({ ok: false, code: 'receipt-missing' });
|
|
679
|
+
});
|
|
680
|
+
test('R2.5 readReceipt never throws on non-string input', () => {
|
|
681
|
+
expect((0, receipt_1.readReceipt)(undefined, 'a'.repeat(40))).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
682
|
+
expect((0, receipt_1.readReceipt)(root, undefined)).toEqual({ ok: false, code: 'receipt-invalid' });
|
|
683
|
+
});
|
|
684
|
+
(0, helpers_1.posixOnly)('write side: a symlink already sitting at the receipt path is refused and its target is left untouched (atomic write, never a plain writeFileSync)', () => {
|
|
685
|
+
const head = g('rev-parse', 'HEAD');
|
|
686
|
+
const target = path_1.default.join(root, 'precious.txt');
|
|
687
|
+
fs_1.default.writeFileSync(target, 'keep me');
|
|
688
|
+
fs_1.default.mkdirSync(path_1.default.dirname(runsFile(head)), { recursive: true });
|
|
689
|
+
fs_1.default.symlinkSync(target, runsFile(head));
|
|
690
|
+
const receipt = (0, receipt_1.buildReceipt)({ commit: head, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
691
|
+
expect(() => (0, receipt_1.writeReceipt)(root, receipt)).toThrow(/symlink/);
|
|
692
|
+
expect(fs_1.default.readFileSync(target, 'utf8')).toBe('keep me');
|
|
693
|
+
expect(fs_1.default.lstatSync(runsFile(head)).isSymbolicLink()).toBe(true);
|
|
694
|
+
});
|
|
695
|
+
(0, helpers_1.posixOnly)('write side: a runs directory swapped for a symlink right after it was created is refused, and nothing is written through it', () => {
|
|
696
|
+
const head = g('rev-parse', 'HEAD');
|
|
697
|
+
const elsewhere = fs_1.default.mkdtempSync(path_1.default.join(home, 'elsewhere-'));
|
|
698
|
+
const real = fs_1.default.mkdirSync;
|
|
699
|
+
const spy = jest.spyOn(fs_1.default, 'mkdirSync').mockImplementation(((target, options) => {
|
|
700
|
+
const made = real(target, options);
|
|
701
|
+
if (String(target).endsWith(path_1.default.join('sensors', 'runs'))) {
|
|
702
|
+
// The race: between the mkdir and the write, the directory is replaced by a link to somewhere else.
|
|
703
|
+
fs_1.default.rmdirSync(target);
|
|
704
|
+
fs_1.default.symlinkSync(elsewhere, target);
|
|
705
|
+
}
|
|
706
|
+
return made;
|
|
707
|
+
}));
|
|
708
|
+
try {
|
|
709
|
+
const receipt = (0, receipt_1.buildReceipt)({ commit: head, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
710
|
+
expect(() => (0, receipt_1.writeReceipt)(root, receipt)).toThrow(/symlink/);
|
|
711
|
+
expect(fs_1.default.readdirSync(elsewhere)).toEqual([]);
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
spy.mockRestore();
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
(0, helpers_1.posixOnly)('refuses a symlinked runs directory', () => {
|
|
718
|
+
const head = g('rev-parse', 'HEAD');
|
|
719
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm', 'sensors'), { recursive: true });
|
|
720
|
+
const elsewhere = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-else-'));
|
|
721
|
+
fs_1.default.symlinkSync(elsewhere, path_1.default.join(root, '.awm', 'sensors', 'runs'));
|
|
722
|
+
const r = (0, receipt_1.buildReceipt)({ commit: head, ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
723
|
+
expect(() => (0, receipt_1.writeReceipt)(root, r)).toThrow(/symlink/);
|
|
724
|
+
expect(fs_1.default.readdirSync(elsewhere)).toEqual([]);
|
|
725
|
+
fs_1.default.rmSync(elsewhere, { recursive: true, force: true });
|
|
726
|
+
});
|
|
727
|
+
(0, helpers_1.posixOnly)('a symlink refusal escapes control characters in the path it names', () => {
|
|
728
|
+
const weirdRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-weird\n-'));
|
|
729
|
+
const elsewhere = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-else-'));
|
|
730
|
+
try {
|
|
731
|
+
fs_1.default.symlinkSync(elsewhere, path_1.default.join(weirdRoot, '.awm'));
|
|
732
|
+
const r = (0, receipt_1.buildReceipt)({ commit: g('rev-parse', 'HEAD'), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
733
|
+
let message = '';
|
|
734
|
+
try {
|
|
735
|
+
(0, receipt_1.writeReceipt)(weirdRoot, r);
|
|
736
|
+
}
|
|
737
|
+
catch (e) {
|
|
738
|
+
message = e.message;
|
|
739
|
+
}
|
|
740
|
+
expect(message).toMatch(/^refusing to use symlinked path /);
|
|
741
|
+
expect(message).not.toMatch(/[\n\r]/);
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
fs_1.default.rmSync(weirdRoot, { recursive: true, force: true });
|
|
745
|
+
fs_1.default.rmSync(elsewhere, { recursive: true, force: true });
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
(0, helpers_1.posixOnly)('refuses a symlinked .awm directory on write', () => {
|
|
749
|
+
const elsewhere = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-else-'));
|
|
750
|
+
fs_1.default.symlinkSync(elsewhere, path_1.default.join(root, '.awm'));
|
|
751
|
+
const r = (0, receipt_1.buildReceipt)({ commit: g('rev-parse', 'HEAD'), ranAt: '2026-10-17T10:00:00.000Z', cliVersion: '1.0.0', output: output });
|
|
752
|
+
expect(() => (0, receipt_1.writeReceipt)(root, r)).toThrow(/symlink/);
|
|
753
|
+
expect(fs_1.default.readdirSync(elsewhere)).toEqual([]);
|
|
754
|
+
fs_1.default.rmSync(elsewhere, { recursive: true, force: true });
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
describe('sensors run wiring (R2.2, R2.4)', () => {
|
|
758
|
+
let stdout;
|
|
759
|
+
let stderr;
|
|
760
|
+
let cwd;
|
|
761
|
+
const runCli = async (...flags) => {
|
|
762
|
+
const program = new commander_1.Command();
|
|
763
|
+
program.exitOverride();
|
|
764
|
+
(0, index_1.registerSensorsCommand)(program);
|
|
765
|
+
await program.parseAsync(['node', 'awm', 'sensors', 'run', ...flags]);
|
|
766
|
+
};
|
|
767
|
+
const written = (spy) => spy.mock.calls.map((c) => String(c[0])).join('');
|
|
768
|
+
const receiptsDir = () => path_1.default.join(root, '.awm', 'sensors', 'runs');
|
|
769
|
+
const rootManifest = () => path_1.default.join(root, '.awm', 'sensors.json');
|
|
770
|
+
// What `runSensors` reports as the manifest it ran: whatever the real resolver finds from the (mocked) cwd.
|
|
771
|
+
const resolvedManifest = () => (0, project_1.resolveSensorProject)(process.cwd()).manifestPath;
|
|
772
|
+
const commitManifest = (dir) => {
|
|
773
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
774
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), '{"v":1}');
|
|
775
|
+
g('add', '-A');
|
|
776
|
+
g('commit', '-q', '-m', `chore: manifest in ${path_1.default.basename(dir)}`);
|
|
777
|
+
};
|
|
778
|
+
const receiptNames = () => fs_1.default.readdirSync(receiptsDir()).sort();
|
|
779
|
+
beforeEach(() => {
|
|
780
|
+
process.exitCode = undefined;
|
|
781
|
+
commitManifest(root);
|
|
782
|
+
run_1.findManifestDir.mockReset().mockImplementation((start) => {
|
|
783
|
+
const project = (0, project_1.resolveSensorProject)(start);
|
|
784
|
+
return project.state === 'configured' ? project.projectRoot : null;
|
|
785
|
+
});
|
|
786
|
+
run_1.runSensors.mockReset().mockImplementation(async () => ({ ...output, manifestPath: resolvedManifest() }));
|
|
787
|
+
stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
788
|
+
stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
789
|
+
cwd = jest.spyOn(process, 'cwd').mockReturnValue(root);
|
|
790
|
+
});
|
|
791
|
+
afterEach(() => { stdout.mockRestore(); stderr.mockRestore(); cwd.mockRestore(); process.exitCode = undefined; });
|
|
792
|
+
test('R2.4 a full run on a clean tree writes a receipt and leaves stdout and the exit code unchanged', async () => {
|
|
793
|
+
await runCli();
|
|
794
|
+
expect(written(stdout)).toBe(JSON.stringify(output, null, 2) + '\n');
|
|
795
|
+
expect(written(stderr)).toBe('');
|
|
796
|
+
expect(process.exitCode).toBe(0);
|
|
797
|
+
expect(receiptNames()).toEqual(['.gitignore', `${g('rev-parse', 'HEAD')}.json`]);
|
|
798
|
+
});
|
|
799
|
+
test('R2.1 --all is a full selection', async () => {
|
|
800
|
+
await runCli('--all');
|
|
801
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(true);
|
|
802
|
+
});
|
|
803
|
+
test('R2.2 --fast writes no receipt and prints nothing on stderr', async () => {
|
|
804
|
+
await runCli('--fast');
|
|
805
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
806
|
+
expect(written(stderr)).toBe('');
|
|
807
|
+
});
|
|
808
|
+
test('R2.2 --changed writes no receipt and prints nothing on stderr', async () => {
|
|
809
|
+
await runCli('--changed');
|
|
810
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
811
|
+
expect(written(stderr)).toBe('');
|
|
812
|
+
});
|
|
813
|
+
test('R2.2 a full run on a dirty tree prints one stderr line and keeps verdict and exit code', async () => {
|
|
814
|
+
run_1.runSensors.mockResolvedValue({ overall: 'fail', sensors: [{ name: 'lint', status: 'fail', errors: [] }], manifestPath: rootManifest() });
|
|
815
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'b.txt'), 'b');
|
|
816
|
+
await runCli();
|
|
817
|
+
expect(written(stderr)).toMatch(/^no sensor receipt written: .*uncommitted.*\n$/);
|
|
818
|
+
expect(written(stdout)).toContain('"overall": "fail"');
|
|
819
|
+
expect(process.exitCode).toBe(1);
|
|
820
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
821
|
+
});
|
|
822
|
+
test('R2.5 a sensor name the receipt cannot hold withholds the receipt but leaves stdout and the exit code unchanged', async () => {
|
|
823
|
+
const bad = { overall: 'pass', sensors: [{ name: 'a\nb', status: 'pass', errors: [] }], manifestPath: rootManifest() };
|
|
824
|
+
run_1.runSensors.mockResolvedValue(bad);
|
|
825
|
+
await runCli();
|
|
826
|
+
expect(written(stdout)).toBe(JSON.stringify(bad, null, 2) + '\n');
|
|
827
|
+
expect(process.exitCode).toBe(0);
|
|
828
|
+
expect(written(stderr)).toMatch(/^no sensor receipt written: invalid receipt: [^\n]+\n$/);
|
|
829
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
830
|
+
});
|
|
831
|
+
test('R2.2 untracked sensor output during the run still gets a receipt with no stderr', async () => {
|
|
832
|
+
run_1.runSensors.mockImplementation(async () => { fs_1.default.writeFileSync(path_1.default.join(root, 'c.txt'), 'c'); return { ...output, manifestPath: rootManifest() }; });
|
|
833
|
+
await runCli();
|
|
834
|
+
expect(written(stderr)).toBe('');
|
|
835
|
+
expect(receiptNames()).toEqual(['.gitignore', `${g('rev-parse', 'HEAD')}.json`]);
|
|
836
|
+
});
|
|
837
|
+
test('R2.2 a tracked file changed during the run withholds the receipt with one stderr line', async () => {
|
|
838
|
+
run_1.runSensors.mockImplementation(async () => { fs_1.default.writeFileSync(path_1.default.join(root, 'a.txt'), 'fixed'); return { ...output, manifestPath: rootManifest() }; });
|
|
839
|
+
await runCli();
|
|
840
|
+
expect(written(stderr)).toMatch(/HEAD or tracked files changed/);
|
|
841
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
842
|
+
expect(process.exitCode).toBe(0);
|
|
843
|
+
});
|
|
844
|
+
describe('R2.2b scope: only the repository root manifest, only when a sensor ran', () => {
|
|
845
|
+
const stdoutOf = (out) => JSON.stringify(out, null, 2) + '\n';
|
|
846
|
+
test('from a sub-directory WITHOUT its own manifest the root manifest runs (the resolver walks up) and certifies the commit', async () => {
|
|
847
|
+
const sub = path_1.default.join(root, 'packages', 'app');
|
|
848
|
+
fs_1.default.mkdirSync(sub, { recursive: true });
|
|
849
|
+
cwd.mockReturnValue(sub);
|
|
850
|
+
expect(resolvedManifest()).toBe(rootManifest());
|
|
851
|
+
await runCli();
|
|
852
|
+
expect(written(stderr)).toBe('');
|
|
853
|
+
expect(receiptNames()).toEqual(['.gitignore', `${g('rev-parse', 'HEAD')}.json`]);
|
|
854
|
+
});
|
|
855
|
+
test('a monorepo sub-manifest run writes no receipt, says so on one stderr line, and leaves stdout and the exit code alone', async () => {
|
|
856
|
+
const sub = path_1.default.join(root, 'packages', 'app');
|
|
857
|
+
fs_1.default.mkdirSync(sub, { recursive: true });
|
|
858
|
+
commitManifest(sub);
|
|
859
|
+
cwd.mockReturnValue(sub);
|
|
860
|
+
expect(fs_1.default.realpathSync.native(resolvedManifest())).toBe(fs_1.default.realpathSync.native(path_1.default.join(sub, '.awm', 'sensors.json')));
|
|
861
|
+
await runCli();
|
|
862
|
+
expect(written(stdout)).toBe(stdoutOf({ ...output, manifestPath: resolvedManifest() }));
|
|
863
|
+
expect(process.exitCode).toBe(0);
|
|
864
|
+
expect(written(stderr)).toMatch(/^no sensor receipt written: the manifest that ran is not the repository root's \.awm\/sensors\.json[^\n]*\n$/);
|
|
865
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
866
|
+
});
|
|
867
|
+
test('the precondition is anchored at the manifest directory, not at the cwd', async () => {
|
|
868
|
+
const plain = fs_1.default.realpathSync.native(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plain-')));
|
|
869
|
+
try {
|
|
870
|
+
cwd.mockReturnValue(plain); // not a git repository: only the manifest directory leads to one
|
|
871
|
+
run_1.findManifestDir.mockReturnValue(root);
|
|
872
|
+
run_1.runSensors.mockResolvedValue({ ...output, manifestPath: rootManifest() });
|
|
873
|
+
await runCli();
|
|
874
|
+
expect(written(stderr)).toBe('');
|
|
875
|
+
expect(receiptNames()).toEqual(['.gitignore', `${g('rev-parse', 'HEAD')}.json`]);
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
fs_1.default.rmSync(plain, { recursive: true, force: true });
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
test.each([
|
|
882
|
+
['an empty sensors list with a pass verdict', { overall: 'pass', sensors: [] }],
|
|
883
|
+
['not_certified with an empty list (absent or invalid manifest)', { overall: 'not_certified', sensors: [], mode: 'invalid', reason: 'manifest-malformed' }],
|
|
884
|
+
])('%s: no receipt, one stderr line, stdout and exit code unchanged', async (_n, partial) => {
|
|
885
|
+
const out = { ...partial, manifestPath: rootManifest() };
|
|
886
|
+
run_1.runSensors.mockResolvedValue(out);
|
|
887
|
+
await runCli();
|
|
888
|
+
expect(written(stdout)).toBe(stdoutOf(out));
|
|
889
|
+
expect(process.exitCode).toBe((0, verdict_1.exitCodeForVerdict)(out.overall));
|
|
890
|
+
expect(written(stderr)).toBe('no sensor receipt written: no sensor ran, so there is nothing to certify\n');
|
|
891
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
892
|
+
});
|
|
893
|
+
test('R2.2c a not_certified verdict that did run sensors is reported as not certified, not as "no sensor ran"', async () => {
|
|
894
|
+
const out = { overall: 'not_certified', sensors: [{ name: 'lint', status: 'pass', errors: [] }], manifestPath: rootManifest(), reason: 'legacy-v1' };
|
|
895
|
+
run_1.runSensors.mockResolvedValue(out);
|
|
896
|
+
await runCli();
|
|
897
|
+
expect(written(stdout)).toBe(stdoutOf(out));
|
|
898
|
+
expect(process.exitCode).toBe((0, verdict_1.exitCodeForVerdict)('not_certified'));
|
|
899
|
+
expect(written(stderr)).toBe('no sensor receipt written: the verdict was not certified (not_certified)\n');
|
|
900
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
901
|
+
});
|
|
902
|
+
test('R2.2c schema-invalid sensor entries that were dropped withhold the receipt: it would list only the entries that ran', async () => {
|
|
903
|
+
const out = { ...output, manifestPath: rootManifest(), invalidEntries: [{ name: 'security', reason: 'command is required' }] };
|
|
904
|
+
run_1.runSensors.mockResolvedValue(out);
|
|
905
|
+
await runCli();
|
|
906
|
+
expect(written(stdout)).toBe(stdoutOf(out));
|
|
907
|
+
expect(process.exitCode).toBe(0);
|
|
908
|
+
expect(written(stderr)).toBe('no sensor receipt written: some sensor entries of the manifest were invalid and skipped, so the verdict does not cover it\n');
|
|
909
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
910
|
+
});
|
|
911
|
+
test('R2.2c pack drift withholds the receipt', async () => {
|
|
912
|
+
const out = { ...output, manifestPath: rootManifest(), packDrift: { manifest: 'generic', detected: 'js-ts', remedy: 'run awm sensors init' } };
|
|
913
|
+
run_1.runSensors.mockResolvedValue(out);
|
|
914
|
+
await runCli();
|
|
915
|
+
expect(written(stdout)).toBe(stdoutOf(out));
|
|
916
|
+
expect(written(stderr)).toBe('no sensor receipt written: the manifest is on the generic fallback while the tree has real stack indicators (pack drift)\n');
|
|
917
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
918
|
+
});
|
|
919
|
+
test('R2.2c a run in which every sensor was skipped certified nothing: no receipt, a line of its own, stdout and exit code unchanged', async () => {
|
|
920
|
+
const out = { overall: 'skipped', sensors: [{ name: 'lint', status: 'skipped', errors: [] }, { name: 'tsc', status: 'skipped', errors: [] }], manifestPath: rootManifest() };
|
|
921
|
+
run_1.runSensors.mockResolvedValue(out);
|
|
922
|
+
await runCli();
|
|
923
|
+
expect(written(stdout)).toBe(stdoutOf(out));
|
|
924
|
+
expect(process.exitCode).toBe((0, verdict_1.exitCodeForVerdict)('skipped'));
|
|
925
|
+
expect(written(stderr)).toBe('no sensor receipt written: every sensor was skipped, so the run certified nothing\n');
|
|
926
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
927
|
+
});
|
|
928
|
+
test('R2.2c one sensor that ran among skipped ones is enough for a receipt', async () => {
|
|
929
|
+
run_1.runSensors.mockResolvedValue({ overall: 'pass', sensors: [{ name: 'lint', status: 'skipped', errors: [] }, { name: 'tsc', status: 'pass', errors: [] }], manifestPath: rootManifest() });
|
|
930
|
+
await runCli();
|
|
931
|
+
expect(written(stderr)).toBe('');
|
|
932
|
+
expect(receiptNames()).toEqual(['.gitignore', `${g('rev-parse', 'HEAD')}.json`]);
|
|
933
|
+
});
|
|
934
|
+
test('R2.2c an empty invalidEntries list does not withhold the receipt', async () => {
|
|
935
|
+
run_1.runSensors.mockResolvedValue({ ...output, manifestPath: rootManifest(), invalidEntries: [] });
|
|
936
|
+
await runCli();
|
|
937
|
+
expect(written(stderr)).toBe('');
|
|
938
|
+
expect(receiptNames()).toEqual(['.gitignore', `${g('rev-parse', 'HEAD')}.json`]);
|
|
939
|
+
});
|
|
940
|
+
test('an output that does not say which manifest ran certifies nothing (fail closed)', async () => {
|
|
941
|
+
run_1.runSensors.mockResolvedValue({ overall: 'pass', sensors: output.sensors });
|
|
942
|
+
await runCli();
|
|
943
|
+
expect(written(stderr)).toMatch(/^no sensor receipt written: the manifest that ran is not the repository root's/);
|
|
944
|
+
expect(fs_1.default.existsSync(receiptsDir())).toBe(false);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
});
|