agentic-workflow-manager 9.18.0 → 9.19.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/plan/index.js +1 -1
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +23 -60
- package/dist/src/core/admission/index.js +11 -1
- package/dist/src/core/currentness/check.js +5 -2
- 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/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- 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,537 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const read_only_observation_1 = require("../../../src/commands/sensors/read-only-observation");
|
|
11
|
+
describe('read-only observation', () => {
|
|
12
|
+
const symlinksSupported = (() => {
|
|
13
|
+
const probe = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-symlink-probe-'));
|
|
14
|
+
try {
|
|
15
|
+
fs_1.default.writeFileSync(path_1.default.join(probe, 'target'), 'x');
|
|
16
|
+
fs_1.default.symlinkSync(path_1.default.join(probe, 'target'), path_1.default.join(probe, 'link'));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
fs_1.default.rmSync(probe, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
const symlinkOnly = symlinksSupported ? it : it.skip;
|
|
27
|
+
// Modes, fifos and ':' in names are not portable to Windows.
|
|
28
|
+
const posixOnly = process.platform === 'win32' ? it.skip : it;
|
|
29
|
+
const ENV_KEYS = ['HOME', 'AWM_HOME', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM', 'GIT_CEILING_DIRECTORIES'];
|
|
30
|
+
const saved = {};
|
|
31
|
+
let root;
|
|
32
|
+
let project;
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-readonly-'));
|
|
35
|
+
for (const key of ENV_KEYS)
|
|
36
|
+
saved[key] = process.env[key];
|
|
37
|
+
const home = path_1.default.join(root, 'home');
|
|
38
|
+
fs_1.default.mkdirSync(home, { recursive: true });
|
|
39
|
+
process.env.HOME = home;
|
|
40
|
+
process.env.AWM_HOME = path_1.default.join(home, '.awm');
|
|
41
|
+
const globalConfig = path_1.default.join(home, 'gitconfig');
|
|
42
|
+
fs_1.default.writeFileSync(globalConfig, '[user]\n\temail = test@example.test\n\tname = test\n');
|
|
43
|
+
process.env.GIT_CONFIG_GLOBAL = globalConfig;
|
|
44
|
+
process.env.GIT_CONFIG_NOSYSTEM = '1';
|
|
45
|
+
process.env.GIT_CEILING_DIRECTORIES = root;
|
|
46
|
+
project = path_1.default.join(root, 'project');
|
|
47
|
+
fs_1.default.mkdirSync(project);
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
jest.restoreAllMocks();
|
|
51
|
+
for (const key of ENV_KEYS) {
|
|
52
|
+
if (saved[key] === undefined)
|
|
53
|
+
delete process.env[key];
|
|
54
|
+
else
|
|
55
|
+
process.env[key] = saved[key];
|
|
56
|
+
}
|
|
57
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
const write = (relative, content = 'x') => {
|
|
60
|
+
const file = path_1.default.join(project, ...relative.split('/'));
|
|
61
|
+
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
|
|
62
|
+
fs_1.default.writeFileSync(file, content);
|
|
63
|
+
};
|
|
64
|
+
const writeConfig = (value) => write('.awm/admission.json', typeof value === 'string' ? value : JSON.stringify(value));
|
|
65
|
+
const git = (args) => (0, child_process_1.execFileSync)('git', args, { cwd: project, env: { ...process.env }, stdio: 'pipe', encoding: 'utf8' });
|
|
66
|
+
const tiny = { maxEntries: 50, maxBytes: 1024 };
|
|
67
|
+
const nodeModulesOnly = (verdict) => (0, read_only_observation_1.createExclusionPolicy)({ defaults: false, rules: [{ kind: 'name', name: 'node_modules' }] }, () => verdict);
|
|
68
|
+
describe('loadAdmissionConfig', () => {
|
|
69
|
+
it('applies the AWM defaults when the file is absent', () => {
|
|
70
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({
|
|
71
|
+
ok: true,
|
|
72
|
+
config: { defaults: true, rules: read_only_observation_1.DEFAULT_EXCLUSION_NAMES.map(name => ({ kind: 'name', name })) },
|
|
73
|
+
});
|
|
74
|
+
expect(read_only_observation_1.DEFAULT_EXCLUSION_NAMES).toEqual(['node_modules', '.next', '.turbo', 'dist', 'build', 'coverage', '.venv', 'target']);
|
|
75
|
+
});
|
|
76
|
+
it('adds declared name and path rules and can drop the defaults', () => {
|
|
77
|
+
writeConfig({ schemaVersion: 1, readOnlyObservation: { defaults: false, exclude: ['storybook-static', 'apps/web/.cache'] } });
|
|
78
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({
|
|
79
|
+
ok: true,
|
|
80
|
+
config: { defaults: false, rules: [{ kind: 'name', name: 'storybook-static' }, { kind: 'path', path: 'apps/web/.cache' }] },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
it.each([
|
|
84
|
+
[{ schemaVersion: 1 }],
|
|
85
|
+
[{ schemaVersion: 1, readOnlyObservation: {} }],
|
|
86
|
+
])('treats the minimal config %j as the defaults', value => {
|
|
87
|
+
writeConfig(value);
|
|
88
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({
|
|
89
|
+
ok: true,
|
|
90
|
+
config: { defaults: true, rules: read_only_observation_1.DEFAULT_EXCLUSION_NAMES.map(name => ({ kind: 'name', name })) },
|
|
91
|
+
});
|
|
92
|
+
expect(read_only_observation_1.DEFAULT_EXCLUSION_NAMES).toHaveLength(8);
|
|
93
|
+
});
|
|
94
|
+
it('keeps the defaults when only exclude is declared', () => {
|
|
95
|
+
writeConfig({ schemaVersion: 1, readOnlyObservation: { exclude: ['out'] } });
|
|
96
|
+
const result = (0, read_only_observation_1.loadAdmissionConfig)(project);
|
|
97
|
+
expect(result.ok && result.config.rules).toEqual([...read_only_observation_1.DEFAULT_EXCLUSION_NAMES.map(name => ({ kind: 'name', name })), { kind: 'name', name: 'out' }]);
|
|
98
|
+
});
|
|
99
|
+
it.each([
|
|
100
|
+
['not json', '(file)'],
|
|
101
|
+
[[], '(root)'],
|
|
102
|
+
[{ schemaVersion: 2 }, 'schemaVersion'],
|
|
103
|
+
[{ readOnlyObservation: {} }, 'schemaVersion'],
|
|
104
|
+
[{ schemaVersion: 1, extra: true }, 'extra'],
|
|
105
|
+
[{ schemaVersion: 1, readOnlyObservation: null }, 'readOnlyObservation'],
|
|
106
|
+
[{ schemaVersion: 1, readOnlyObservation: { other: 1 } }, 'readOnlyObservation.other'],
|
|
107
|
+
[{ schemaVersion: 1, readOnlyObservation: { defaults: 'yes' } }, 'readOnlyObservation.defaults'],
|
|
108
|
+
[{ schemaVersion: 1, readOnlyObservation: { exclude: 'node_modules' } }, 'readOnlyObservation.exclude'],
|
|
109
|
+
])('rejects %j at field %s', (value, field) => {
|
|
110
|
+
writeConfig(value);
|
|
111
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({ ok: false, field, message: expect.any(String) });
|
|
112
|
+
});
|
|
113
|
+
it.each([
|
|
114
|
+
'', '/abs', 'C:/abs', 'a/../b', './a', 'a//b', 'a\\b', '*.log', 'a?', '[a]', 'a\nb', 'a\u0000b', 'dist/',
|
|
115
|
+
'.git', '.awm', '.awm/cache', '.git/hooks', '.AWM', 'packages/a/.awm', 'sub/.git', 'a/.AWM/b', 42,
|
|
116
|
+
])('rejects the exclude entry %j', entry => {
|
|
117
|
+
writeConfig({ schemaVersion: 1, readOnlyObservation: { exclude: [entry] } });
|
|
118
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({ ok: false, field: 'readOnlyObservation.exclude[0]', message: expect.any(String) });
|
|
119
|
+
});
|
|
120
|
+
it('bounds and neutralizes an unknown field name in the diagnostic', () => {
|
|
121
|
+
writeConfig(`{"schemaVersion":1,"${'k'.repeat(100)}\\n":true}`);
|
|
122
|
+
const result = (0, read_only_observation_1.loadAdmissionConfig)(project);
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
expect(result.field.length).toBeLessThanOrEqual(64);
|
|
126
|
+
expect(result.field).not.toMatch(/[\u0000-\u001f]/);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
it('strips C1, separator and bidi control characters from an unknown field name', () => {
|
|
130
|
+
const hostile = 'a\u009bb\u0085c\u202ed\u2028e\u2029f\u2066g\u2069h\u202ai\u200ej\u200fk\u061cl';
|
|
131
|
+
writeConfig(JSON.stringify({ schemaVersion: 1, [hostile]: true }));
|
|
132
|
+
const result = (0, read_only_observation_1.loadAdmissionConfig)(project);
|
|
133
|
+
expect(result.ok).toBe(false);
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
expect(result.field).toBe('a?b?c?d?e?f?g?h?i?j?k?l');
|
|
136
|
+
expect(result.field.length).toBeLessThanOrEqual(64);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
describe('the config file itself', () => {
|
|
140
|
+
const configPath = () => path_1.default.join(project, '.awm', 'admission.json');
|
|
141
|
+
const jsonPaddedTo = (bytes) => `{"schemaVersion":1}${' '.repeat(bytes - '{"schemaVersion":1}'.length)}`;
|
|
142
|
+
it('fails closed when the file cannot be read for a reason other than being absent', () => {
|
|
143
|
+
fs_1.default.mkdirSync(configPath(), { recursive: true });
|
|
144
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({ ok: false, field: '(file)', message: expect.any(String) });
|
|
145
|
+
});
|
|
146
|
+
// ENOTDIR is POSIX behavior: on Windows a file in the middle of a path reads as ENOENT.
|
|
147
|
+
posixOnly('fails closed when .awm is not a directory', () => {
|
|
148
|
+
write('.awm', 'not a directory');
|
|
149
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({ ok: false, field: '(file)', message: expect.any(String) });
|
|
150
|
+
});
|
|
151
|
+
it('accepts a file of exactly 65536 bytes and rejects one byte more', () => {
|
|
152
|
+
writeConfig(jsonPaddedTo(65536));
|
|
153
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project).ok).toBe(true);
|
|
154
|
+
writeConfig(jsonPaddedTo(65537));
|
|
155
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({ ok: false, field: '(file)', message: 'is larger than 65536 bytes' });
|
|
156
|
+
});
|
|
157
|
+
it('fails closed when reading a regular, small file fails', () => {
|
|
158
|
+
writeConfig({ schemaVersion: 1 });
|
|
159
|
+
const realRead = fs_1.default.readFileSync;
|
|
160
|
+
jest.spyOn(fs_1.default, 'readFileSync').mockImplementation(((target, ...rest) => {
|
|
161
|
+
if (typeof target === 'string' && target.endsWith('admission.json'))
|
|
162
|
+
throw new Error('EIO');
|
|
163
|
+
return realRead(target, ...rest);
|
|
164
|
+
}));
|
|
165
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({ ok: false, field: '(file)', message: 'could not be read' });
|
|
166
|
+
});
|
|
167
|
+
symlinkOnly('rejects a symlink even when it points at a valid regular file', () => {
|
|
168
|
+
write('real-admission.json', '{"schemaVersion":1}');
|
|
169
|
+
fs_1.default.mkdirSync(path_1.default.dirname(configPath()), { recursive: true });
|
|
170
|
+
fs_1.default.symlinkSync(path_1.default.join(project, 'real-admission.json'), configPath());
|
|
171
|
+
expect((0, read_only_observation_1.loadAdmissionConfig)(project)).toEqual({ ok: false, field: '(file)', message: 'must be a regular file, not a link or device' });
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
it('fails loudly on an empty project root', () => {
|
|
175
|
+
expect(() => (0, read_only_observation_1.loadAdmissionConfig)('')).toThrow(/non-empty string/);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
describe('createExclusionPolicy', () => {
|
|
179
|
+
const config = (rules) => ({ defaults: false, rules });
|
|
180
|
+
it('matches a name rule at any depth and a path rule only at its exact path', () => {
|
|
181
|
+
const seen = [];
|
|
182
|
+
const policy = (0, read_only_observation_1.createExclusionPolicy)(config([{ kind: 'name', name: 'node_modules' }, { kind: 'path', path: 'apps/web/.cache' }]), relative => {
|
|
183
|
+
seen.push(relative);
|
|
184
|
+
return 'excluded';
|
|
185
|
+
});
|
|
186
|
+
expect(policy.isExcluded('node_modules', 'node_modules')).toBe(true);
|
|
187
|
+
expect(policy.isExcluded('packages/a/node_modules', 'node_modules')).toBe(true);
|
|
188
|
+
expect(policy.isExcluded('apps/web/.cache', '.cache')).toBe(true);
|
|
189
|
+
expect(policy.isExcluded('apps/api/.cache', '.cache')).toBe(false);
|
|
190
|
+
expect(policy.isExcluded('src', 'src')).toBe(false);
|
|
191
|
+
expect(seen).toEqual(['node_modules', 'packages/a/node_modules', 'apps/web/.cache']);
|
|
192
|
+
});
|
|
193
|
+
it('verifies each candidate once and reports sorted evidence', () => {
|
|
194
|
+
const verdicts = {
|
|
195
|
+
'b/node_modules': 'excluded', 'a/node_modules': 'not-ignored', dist: 'contains-tracked', node_modules: 'git-unavailable',
|
|
196
|
+
'c/dist': 'excluded',
|
|
197
|
+
};
|
|
198
|
+
const verifier = jest.fn((relative) => verdicts[relative]);
|
|
199
|
+
const policy = (0, read_only_observation_1.createExclusionPolicy)({ defaults: true, rules: [{ kind: 'name', name: 'node_modules' }, { kind: 'name', name: 'dist' }] }, verifier);
|
|
200
|
+
// Insertion order is deliberately reverse-alphabetical: evidence() must sort, not echo it.
|
|
201
|
+
for (const relative of ['node_modules', 'dist', 'c/dist', 'b/node_modules', 'a/node_modules', 'b/node_modules']) {
|
|
202
|
+
policy.isExcluded(relative, relative.split('/').pop());
|
|
203
|
+
}
|
|
204
|
+
expect(verifier).toHaveBeenCalledTimes(5);
|
|
205
|
+
expect(policy.evidence()).toEqual({
|
|
206
|
+
defaults: true,
|
|
207
|
+
excluded: ['b/node_modules', 'c/dist'],
|
|
208
|
+
rejected: [
|
|
209
|
+
{ path: 'a/node_modules', reason: 'not-ignored' },
|
|
210
|
+
{ path: 'dist', reason: 'contains-tracked' },
|
|
211
|
+
{ path: 'node_modules', reason: 'git-unavailable' },
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
it('never excludes anything under .git or .awm, whatever the verifier says', () => {
|
|
216
|
+
const verifier = jest.fn(() => 'excluded');
|
|
217
|
+
const policy = (0, read_only_observation_1.createExclusionPolicy)(config([{ kind: 'name', name: 'dist' }, { kind: 'name', name: 'build' }, { kind: 'name', name: 'node_modules' }]), verifier);
|
|
218
|
+
expect(policy.isExcluded('.awm/dist', 'dist')).toBe(false);
|
|
219
|
+
expect(policy.isExcluded('.AWM/build', 'build')).toBe(false);
|
|
220
|
+
expect(policy.isExcluded('.git/x/node_modules', 'node_modules')).toBe(false);
|
|
221
|
+
expect(verifier).not.toHaveBeenCalled();
|
|
222
|
+
expect(policy.evidence()).toEqual({ defaults: false, excluded: [], rejected: [] });
|
|
223
|
+
expect(policy.isExcluded('packages/a/dist', 'dist')).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
it('never excludes a .git or .awm directory at any depth, nor anything inside one', () => {
|
|
226
|
+
const verifier = jest.fn(() => 'excluded');
|
|
227
|
+
const policy = (0, read_only_observation_1.createExclusionPolicy)(config([{ kind: 'name', name: '.awm' }, { kind: 'name', name: '.GIT' }, { kind: 'name', name: 'dist' }, { kind: 'path', path: 'packages/a/.awm' }]), verifier);
|
|
228
|
+
expect(policy.isExcluded('packages/a/.awm', '.awm')).toBe(false);
|
|
229
|
+
expect(policy.isExcluded('sub/.GIT', '.GIT')).toBe(false);
|
|
230
|
+
expect(policy.isExcluded('packages/a/.awm/dist', 'dist')).toBe(false);
|
|
231
|
+
expect(verifier).not.toHaveBeenCalled();
|
|
232
|
+
expect(policy.evidence()).toEqual({ defaults: false, excluded: [], rejected: [] });
|
|
233
|
+
expect(policy.isExcluded('packages/a/dist', 'dist')).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
it('treats only an exact .git or .awm segment as reserved', () => {
|
|
236
|
+
writeConfig({ schemaVersion: 1, readOnlyObservation: { defaults: false, exclude: ['my.awm', '.gitignore-dir', 'x.git', '.awmx'] } });
|
|
237
|
+
const loaded = (0, read_only_observation_1.loadAdmissionConfig)(project);
|
|
238
|
+
expect(loaded.ok).toBe(true);
|
|
239
|
+
if (!loaded.ok)
|
|
240
|
+
return;
|
|
241
|
+
const policy = (0, read_only_observation_1.createExclusionPolicy)(loaded.config, () => 'excluded');
|
|
242
|
+
for (const name of ['my.awm', '.gitignore-dir', 'x.git', '.awmx'])
|
|
243
|
+
expect(policy.isExcluded(`pkg/${name}`, name)).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
it('fails loudly without a validated config or verifier', () => {
|
|
246
|
+
expect(() => (0, read_only_observation_1.createExclusionPolicy)(undefined, () => 'excluded')).toThrow(/validated admission config/);
|
|
247
|
+
expect(() => (0, read_only_observation_1.createExclusionPolicy)(config([]), undefined)).toThrow(/candidate verifier/);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
describe('filesystemSnapshot', () => {
|
|
251
|
+
it('skips an excluded directory so an ignored heavy tree stays within the limits', () => {
|
|
252
|
+
write('src/a.ts', 'export {};\n');
|
|
253
|
+
write('node_modules/pkg/big.js', 'x'.repeat(2048));
|
|
254
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, nodeModulesOnly('excluded'), tiny)).toEqual({ ok: true, digest: expect.stringMatching(/^[0-9a-f]{64}$/) });
|
|
255
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, nodeModulesOnly('not-ignored'), tiny)).toEqual({ ok: false, failure: 'limit-bytes' });
|
|
256
|
+
});
|
|
257
|
+
it('allows exactly the entry limit and rejects one entry more', () => {
|
|
258
|
+
write('a.txt');
|
|
259
|
+
write('b.txt');
|
|
260
|
+
write('c.txt');
|
|
261
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, undefined, { maxEntries: 3, maxBytes: 1024 })).toEqual({ ok: true, digest: expect.any(String) });
|
|
262
|
+
write('d.txt');
|
|
263
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, undefined, { maxEntries: 3, maxBytes: 1024 })).toEqual({ ok: false, failure: 'limit-entries' });
|
|
264
|
+
});
|
|
265
|
+
it('allows exactly the byte limit and rejects one byte more', () => {
|
|
266
|
+
write('f.txt', 'x'.repeat(10));
|
|
267
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, undefined, { maxEntries: 50, maxBytes: 10 })).toEqual({ ok: true, digest: expect.any(String) });
|
|
268
|
+
write('f.txt', 'x'.repeat(11));
|
|
269
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, undefined, { maxEntries: 50, maxBytes: 10 })).toEqual({ ok: false, failure: 'limit-bytes' });
|
|
270
|
+
});
|
|
271
|
+
it('reports the entry limit distinctly', () => {
|
|
272
|
+
write('a.txt');
|
|
273
|
+
write('b.txt');
|
|
274
|
+
write('c.txt');
|
|
275
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, undefined, { maxEntries: 2, maxBytes: 1024 })).toEqual({ ok: false, failure: 'limit-entries' });
|
|
276
|
+
});
|
|
277
|
+
it('ignores creating, changing or deleting an excluded directory but not changes elsewhere', () => {
|
|
278
|
+
write('src/a.ts', 'a');
|
|
279
|
+
const policy = nodeModulesOnly('excluded');
|
|
280
|
+
const before = (0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny);
|
|
281
|
+
write('node_modules/pkg/index.js', 'created by a sensor');
|
|
282
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
283
|
+
write('node_modules/pkg/index.js', 'changed by a sensor');
|
|
284
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
285
|
+
fs_1.default.rmSync(path_1.default.join(project, 'node_modules'), { recursive: true });
|
|
286
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
287
|
+
write('src/a.ts', 'changed');
|
|
288
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).not.toEqual(before);
|
|
289
|
+
});
|
|
290
|
+
it('hashes observed directories without their size, so edits inside a nested excluded directory leave the digest unchanged', () => {
|
|
291
|
+
// ext4 reports a constant directory size; tmpfs, APFS and NTFS do not. Simulate the
|
|
292
|
+
// size-sensitive case so the rule is pinned on every platform.
|
|
293
|
+
const realLstat = fs_1.default.lstatSync;
|
|
294
|
+
jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((target, ...rest) => {
|
|
295
|
+
const stat = realLstat(target, ...rest);
|
|
296
|
+
if (!stat.isDirectory())
|
|
297
|
+
return stat;
|
|
298
|
+
const size = 4096 + 1024 * fs_1.default.readdirSync(target).length;
|
|
299
|
+
return new Proxy(stat, { get: (object, key) => (key === 'size' ? size : Reflect.get(object, key, object)) });
|
|
300
|
+
}));
|
|
301
|
+
write('packages/x/src/a.ts', 'a');
|
|
302
|
+
write('packages/x/node_modules/pkg/index.js', 'one');
|
|
303
|
+
const policy = nodeModulesOnly('excluded');
|
|
304
|
+
const before = (0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny);
|
|
305
|
+
expect(before.ok).toBe(true);
|
|
306
|
+
write('packages/x/node_modules/pkg/extra.js', 'created');
|
|
307
|
+
write('packages/x/node_modules/other/deep/file.js', 'created');
|
|
308
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
309
|
+
write('packages/x/node_modules/pkg/index.js', 'grown'.repeat(20));
|
|
310
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
311
|
+
fs_1.default.rmSync(path_1.default.join(project, 'packages', 'x', 'node_modules', 'pkg'), { recursive: true });
|
|
312
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
313
|
+
fs_1.default.rmSync(path_1.default.join(project, 'packages', 'x', 'node_modules'), { recursive: true });
|
|
314
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
315
|
+
write('packages/x/src/a.ts', 'changed');
|
|
316
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).not.toEqual(before);
|
|
317
|
+
});
|
|
318
|
+
describe('what the digest covers', () => {
|
|
319
|
+
const digestOf = () => {
|
|
320
|
+
const result = (0, read_only_observation_1.filesystemSnapshot)(project, undefined, tiny);
|
|
321
|
+
if (!result.ok)
|
|
322
|
+
throw new Error(`snapshot failed: ${result.failure}`);
|
|
323
|
+
return result.digest;
|
|
324
|
+
};
|
|
325
|
+
it('changes on a same-size content edit', () => {
|
|
326
|
+
write('f.txt', 'aaaa');
|
|
327
|
+
const before = digestOf();
|
|
328
|
+
write('f.txt', 'bbbb');
|
|
329
|
+
expect(digestOf()).not.toBe(before);
|
|
330
|
+
});
|
|
331
|
+
posixOnly('changes on a file mode change', () => {
|
|
332
|
+
write('f.txt', 'same');
|
|
333
|
+
fs_1.default.chmodSync(path_1.default.join(project, 'f.txt'), 0o644);
|
|
334
|
+
const before = digestOf();
|
|
335
|
+
fs_1.default.chmodSync(path_1.default.join(project, 'f.txt'), 0o755);
|
|
336
|
+
expect(digestOf()).not.toBe(before);
|
|
337
|
+
});
|
|
338
|
+
posixOnly('changes on a directory mode change', () => {
|
|
339
|
+
write('sub/f.txt', 'same');
|
|
340
|
+
const dir = path_1.default.join(project, 'sub');
|
|
341
|
+
fs_1.default.chmodSync(dir, 0o755);
|
|
342
|
+
const before = digestOf();
|
|
343
|
+
try {
|
|
344
|
+
fs_1.default.chmodSync(dir, 0o700);
|
|
345
|
+
expect(digestOf()).not.toBe(before);
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
fs_1.default.chmodSync(dir, 0o755);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
symlinkOnly('changes when a symlink is retargeted to a same-size file', () => {
|
|
352
|
+
write('a.txt', 'same');
|
|
353
|
+
write('b.txt', 'same');
|
|
354
|
+
fs_1.default.symlinkSync('a.txt', path_1.default.join(project, 'link'));
|
|
355
|
+
const before = digestOf();
|
|
356
|
+
fs_1.default.rmSync(path_1.default.join(project, 'link'));
|
|
357
|
+
fs_1.default.symlinkSync('b.txt', path_1.default.join(project, 'link'));
|
|
358
|
+
expect(digestOf()).not.toBe(before);
|
|
359
|
+
});
|
|
360
|
+
it('observes a nested .git but skips the root one', () => {
|
|
361
|
+
write('.git/HEAD', 'root');
|
|
362
|
+
write('sub/.git/HEAD', 'nested one');
|
|
363
|
+
const before = digestOf();
|
|
364
|
+
write('.git/HEAD', 'root changed');
|
|
365
|
+
expect(digestOf()).toBe(before);
|
|
366
|
+
write('sub/.git/HEAD', 'nested two');
|
|
367
|
+
expect(digestOf()).not.toBe(before);
|
|
368
|
+
});
|
|
369
|
+
posixOnly('hashes a special file by path and mode without reading it', () => {
|
|
370
|
+
write('f.txt', 'x');
|
|
371
|
+
const before = digestOf();
|
|
372
|
+
(0, child_process_1.execFileSync)('mkfifo', [path_1.default.join(project, 'pipe')], { env: { ...process.env } });
|
|
373
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, undefined, tiny)).toEqual({ ok: true, digest: expect.any(String) });
|
|
374
|
+
expect(digestOf()).not.toBe(before);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
it('never descends into the root .git and never asks about a linked directory', () => {
|
|
378
|
+
write('.git/HEAD', 'ref');
|
|
379
|
+
write('real/file.txt');
|
|
380
|
+
fs_1.default.symlinkSync(path_1.default.join(project, 'real'), path_1.default.join(project, 'node_modules'), 'junction');
|
|
381
|
+
const verifier = jest.fn(() => 'excluded');
|
|
382
|
+
const policy = (0, read_only_observation_1.createExclusionPolicy)({ defaults: false, rules: [{ kind: 'name', name: 'node_modules' }] }, verifier);
|
|
383
|
+
const before = (0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny);
|
|
384
|
+
write('.git/HEAD', 'changed');
|
|
385
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(project, policy, tiny)).toEqual(before);
|
|
386
|
+
expect(verifier).not.toHaveBeenCalled();
|
|
387
|
+
});
|
|
388
|
+
it('returns unreadable for a missing root and rejects invalid arguments', () => {
|
|
389
|
+
expect((0, read_only_observation_1.filesystemSnapshot)(path_1.default.join(root, 'missing'))).toEqual({ ok: false, failure: 'unreadable' });
|
|
390
|
+
expect(() => (0, read_only_observation_1.filesystemSnapshot)('')).toThrow(/non-empty string/);
|
|
391
|
+
expect(() => (0, read_only_observation_1.filesystemSnapshot)(project, undefined, { maxEntries: 0, maxBytes: 1 })).toThrow(/positive integers/);
|
|
392
|
+
});
|
|
393
|
+
it.each([
|
|
394
|
+
[{ maxEntries: NaN, maxBytes: 10 }],
|
|
395
|
+
[{ maxEntries: 1.5, maxBytes: 10 }],
|
|
396
|
+
[{ maxEntries: Infinity, maxBytes: 10 }],
|
|
397
|
+
[{ maxEntries: 10, maxBytes: 0 }],
|
|
398
|
+
[{ maxEntries: 10, maxBytes: -1 }],
|
|
399
|
+
[{ maxEntries: 10, maxBytes: NaN }],
|
|
400
|
+
])('rejects the snapshot limits %j instead of disabling the bound', limits => {
|
|
401
|
+
expect(() => (0, read_only_observation_1.filesystemSnapshot)(project, undefined, limits)).toThrow(/positive integers/);
|
|
402
|
+
});
|
|
403
|
+
it('rejects a whitespace-only root everywhere a root is taken', () => {
|
|
404
|
+
expect(() => (0, read_only_observation_1.filesystemSnapshot)(' ')).toThrow(/non-empty string/);
|
|
405
|
+
expect(() => (0, read_only_observation_1.loadAdmissionConfig)(' ')).toThrow(/non-empty string/);
|
|
406
|
+
expect(() => (0, read_only_observation_1.gitVerifier)(' ')).toThrow(/non-empty string/);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
describe('remedies', () => {
|
|
410
|
+
it('names the exceeded limit and the admission file', () => {
|
|
411
|
+
expect((0, read_only_observation_1.snapshotUnavailableRemedy)('limit-bytes')).toMatch(/1 GiB/);
|
|
412
|
+
expect((0, read_only_observation_1.snapshotUnavailableRemedy)('limit-bytes')).toContain('.awm/admission.json');
|
|
413
|
+
expect((0, read_only_observation_1.snapshotUnavailableRemedy)('limit-entries')).toMatch(/250000 observed entries/);
|
|
414
|
+
expect((0, read_only_observation_1.snapshotUnavailableRemedy)('limit-entries')).toContain('.awm/admission.json');
|
|
415
|
+
expect((0, read_only_observation_1.snapshotUnavailableRemedy)('unreadable')).toBe('run admission from a readable git worktree');
|
|
416
|
+
});
|
|
417
|
+
it('names the offending admission field', () => {
|
|
418
|
+
expect((0, read_only_observation_1.admissionConfigRemedy)({ field: 'schemaVersion', message: 'must be 1' })).toBe('repair .awm/admission.json: schemaVersion must be 1');
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
describe('gitVerifier fail-closed branches (fake spawn)', () => {
|
|
422
|
+
const verifierWith = (lsFiles, checkIgnore = { status: 1 }) => {
|
|
423
|
+
const calls = [];
|
|
424
|
+
const spawn = ((_command, args, options) => {
|
|
425
|
+
calls.push({ args, options });
|
|
426
|
+
return (args[0] === 'ls-files' ? lsFiles : checkIgnore);
|
|
427
|
+
});
|
|
428
|
+
return { verify: (0, read_only_observation_1.gitVerifier)(project, { MARKER: 'env' }, spawn), calls };
|
|
429
|
+
};
|
|
430
|
+
it.each([
|
|
431
|
+
['ls-files ENOBUFS', { error: Object.assign(new Error('buffer'), { code: 'ENOBUFS' }) }, undefined, 'contains-tracked'],
|
|
432
|
+
['ls-files spawn error', { error: new Error('x') }, undefined, 'git-unavailable'],
|
|
433
|
+
['ls-files status 128', { status: 128, stdout: '' }, undefined, 'git-unavailable'],
|
|
434
|
+
['ls-files lists a file', { status: 0, stdout: 'a/b\0' }, { status: 0 }, 'contains-tracked'],
|
|
435
|
+
['check-ignore spawn error', { status: 0, stdout: '' }, { error: new Error('x') }, 'git-unavailable'],
|
|
436
|
+
['check-ignore status 2', { status: 0, stdout: '' }, { status: 2 }, 'git-unavailable'],
|
|
437
|
+
['check-ignore status 128', { status: 0, stdout: '' }, { status: 128 }, 'git-unavailable'],
|
|
438
|
+
['check-ignore status 1', { status: 0, stdout: '' }, { status: 1 }, 'not-ignored'],
|
|
439
|
+
['check-ignore status 0', { status: 0, stdout: '' }, { status: 0 }, 'excluded'],
|
|
440
|
+
])('%s', (_label, lsFiles, checkIgnore, expected) => {
|
|
441
|
+
expect(verifierWith(lsFiles, checkIgnore).verify('pkg/node_modules')).toBe(expected);
|
|
442
|
+
});
|
|
443
|
+
it('scrubs repository-location variables from the child env without mutating the input', () => {
|
|
444
|
+
const input = {
|
|
445
|
+
PATH: '/usr/bin', GIT_CONFIG_GLOBAL: '/tmp/gitconfig', GIT_CEILING_DIRECTORIES: '/tmp',
|
|
446
|
+
GIT_DIR: '/x', GIT_WORK_TREE: '/x', GIT_INDEX_FILE: '/x', git_index_file: '/x', GIT_OBJECT_DIRECTORY: '/x',
|
|
447
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: '/x', GIT_COMMON_DIR: '/x', GIT_NAMESPACE: 'x', GIT_PREFIX: 'x',
|
|
448
|
+
};
|
|
449
|
+
const snapshot = { ...input };
|
|
450
|
+
const seen = [];
|
|
451
|
+
const spawn = ((_command, args, options) => {
|
|
452
|
+
seen.push(options.env);
|
|
453
|
+
return (args[0] === 'ls-files' ? { status: 0, stdout: '' } : { status: 1 });
|
|
454
|
+
});
|
|
455
|
+
(0, read_only_observation_1.gitVerifier)(project, input, spawn)('pkg');
|
|
456
|
+
expect(seen).toHaveLength(2);
|
|
457
|
+
for (const env of seen) {
|
|
458
|
+
expect(env).toEqual({ PATH: '/usr/bin', GIT_CONFIG_GLOBAL: '/tmp/gitconfig', GIT_CEILING_DIRECTORIES: '/tmp' });
|
|
459
|
+
}
|
|
460
|
+
expect(input).toEqual(snapshot);
|
|
461
|
+
});
|
|
462
|
+
it('invokes git without a shell, rooted, bounded and after --', () => {
|
|
463
|
+
const { verify, calls } = verifierWith({ status: 0, stdout: '' }, { status: 0 });
|
|
464
|
+
verify('pkg/node_modules');
|
|
465
|
+
expect(calls.map(call => call.args)).toEqual([
|
|
466
|
+
['ls-files', '-z', '--', './pkg/node_modules'],
|
|
467
|
+
['check-ignore', '-q', '--', './pkg/node_modules'],
|
|
468
|
+
]);
|
|
469
|
+
for (const { options } of calls) {
|
|
470
|
+
expect(options.cwd).toBe(project);
|
|
471
|
+
expect(options.timeout).toBeGreaterThan(0);
|
|
472
|
+
expect(options.maxBuffer).toBeGreaterThan(0);
|
|
473
|
+
expect(options.shell).toBeFalsy();
|
|
474
|
+
expect(options.env).toEqual({ MARKER: 'env' });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
describe('gitVerifier (real git)', () => {
|
|
479
|
+
it('excludes only ignored directories that hold no tracked file', () => {
|
|
480
|
+
git(['init', '-q', '-b', 'main']);
|
|
481
|
+
write('.gitignore', 'node_modules/\ndist\n');
|
|
482
|
+
write('node_modules/pkg/index.js');
|
|
483
|
+
write('vendor/lib.js');
|
|
484
|
+
write('dist/keep.txt');
|
|
485
|
+
write('src/a.ts');
|
|
486
|
+
git(['add', '.gitignore', 'src', 'vendor']);
|
|
487
|
+
git(['add', '-f', 'dist/keep.txt']);
|
|
488
|
+
git(['commit', '-qm', 'initial']);
|
|
489
|
+
write('scratch/tmp.txt');
|
|
490
|
+
const verify = (0, read_only_observation_1.gitVerifier)(project, { ...process.env });
|
|
491
|
+
expect(verify('node_modules')).toBe('excluded');
|
|
492
|
+
expect(verify('dist')).toBe('contains-tracked');
|
|
493
|
+
expect(verify('vendor')).toBe('contains-tracked');
|
|
494
|
+
expect(verify('scratch')).toBe('not-ignored');
|
|
495
|
+
});
|
|
496
|
+
posixOnly.each([[':(top)x'], [':/x']])('treats the directory %s literally, never as pathspec magic', name => {
|
|
497
|
+
git(['init', '-q', '-b', 'main']);
|
|
498
|
+
write('.gitignore', 'x/\n');
|
|
499
|
+
write('x/ignored.txt');
|
|
500
|
+
write(`${name}/f.txt`);
|
|
501
|
+
git(['add', '-f', '--', `./${name}/f.txt`]);
|
|
502
|
+
git(['add', '.gitignore']);
|
|
503
|
+
git(['commit', '-qm', 'initial']);
|
|
504
|
+
expect((0, read_only_observation_1.gitVerifier)(project, { ...process.env })(name)).toBe('contains-tracked');
|
|
505
|
+
});
|
|
506
|
+
it('treats glob characters in a directory name literally', () => {
|
|
507
|
+
git(['init', '-q', '-b', 'main']);
|
|
508
|
+
write('.gitignore', 'a\\[b\\]/\nc\\[d\\]/\n');
|
|
509
|
+
write('a[b]/f.txt');
|
|
510
|
+
write('c[d]/g.txt');
|
|
511
|
+
write('src/a.ts');
|
|
512
|
+
git(['add', '.gitignore', 'src']);
|
|
513
|
+
git(['add', '-f', '--', './c[d]/g.txt']);
|
|
514
|
+
git(['commit', '-qm', 'initial']);
|
|
515
|
+
const verify = (0, read_only_observation_1.gitVerifier)(project, { ...process.env });
|
|
516
|
+
expect(verify('a[b]')).toBe('excluded');
|
|
517
|
+
expect(verify('c[d]')).toBe('contains-tracked');
|
|
518
|
+
});
|
|
519
|
+
it('is not redirected to another index by an inherited GIT_INDEX_FILE', () => {
|
|
520
|
+
git(['init', '-q', '-b', 'main']);
|
|
521
|
+
write('.gitignore', 'dist\n');
|
|
522
|
+
write('dist/f');
|
|
523
|
+
git(['add', '.gitignore']);
|
|
524
|
+
git(['add', '-f', '--', 'dist/f']);
|
|
525
|
+
git(['commit', '-qm', 'initial']);
|
|
526
|
+
const verify = (0, read_only_observation_1.gitVerifier)(project, { ...process.env, GIT_INDEX_FILE: path_1.default.join(root, 'missing-index') });
|
|
527
|
+
expect(verify('dist')).toBe('contains-tracked');
|
|
528
|
+
});
|
|
529
|
+
it('reports git-unavailable outside a worktree', () => {
|
|
530
|
+
write('node_modules/pkg/index.js');
|
|
531
|
+
expect((0, read_only_observation_1.gitVerifier)(project, { ...process.env })('node_modules')).toBe('git-unavailable');
|
|
532
|
+
});
|
|
533
|
+
it('fails loudly on an empty project root', () => {
|
|
534
|
+
expect(() => (0, read_only_observation_1.gitVerifier)('')).toThrow(/non-empty string/);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
});
|