agentic-workflow-manager 9.5.0 → 9.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/context/index.js +3 -1
- package/dist/src/commands/preflight/checks.js +2 -2
- package/dist/src/commands/sensors/bootstrap.js +171 -0
- package/dist/src/commands/sensors/compatibility/contract.js +18 -15
- package/dist/src/commands/sensors/compatibility/manifest.js +29 -1
- package/dist/src/commands/sensors/compatibility/materialize.js +256 -54
- package/dist/src/commands/sensors/compatibility/safe-file.js +240 -5
- package/dist/src/commands/sensors/coverage/index.js +2 -2
- package/dist/src/commands/sensors/coverage/resolve.js +20 -10
- package/dist/src/commands/sensors/detection.js +31 -0
- package/dist/src/commands/sensors/exec.js +116 -11
- package/dist/src/commands/sensors/index.js +51 -10
- package/dist/src/commands/sensors/init.js +17 -170
- package/dist/src/commands/sensors/migrate.js +209 -0
- package/dist/src/commands/sensors/run.js +2 -2
- package/dist/src/core/discovery.js +79 -4
- package/dist/src/core/init/steps.js +24 -11
- package/dist/src/core/orchestrators.js +34 -10
- package/dist/src/core/secure-fs/native-bridge.js +209 -0
- package/dist/src/core/text.js +7 -0
- package/dist/tests/commands/context.test.js +9 -1
- package/dist/tests/commands/hooks/install.test.js +4 -0
- package/dist/tests/commands/hooks/resync.test.js +2 -0
- package/dist/tests/commands/sensors/bootstrap-observational.test.js +24 -0
- package/dist/tests/commands/sensors/bootstrap.test.js +175 -0
- package/dist/tests/commands/sensors/compatibility/contract.test.js +38 -0
- package/dist/tests/commands/sensors/compatibility/manifest.test.js +67 -0
- package/dist/tests/commands/sensors/compatibility/materialize.test.js +410 -0
- package/dist/tests/commands/sensors/compatibility/pack-source.test.js +34 -1
- package/dist/tests/commands/sensors/compatibility/safe-file.test.js +52 -0
- package/dist/tests/commands/sensors/coverage/index.test.js +20 -0
- package/dist/tests/commands/sensors/coverage/resolve.test.js +10 -0
- package/dist/tests/commands/sensors/exec.test.js +86 -0
- package/dist/tests/commands/sensors/index.test.js +54 -0
- package/dist/tests/commands/sensors/init-pack-unavailable.test.js +21 -84
- package/dist/tests/commands/sensors/init.test.js +56 -490
- package/dist/tests/commands/sensors/migrate.test.js +513 -0
- package/dist/tests/core/context/orchestrator.test.js +2 -0
- package/dist/tests/core/discovery-multiroot.test.js +5 -0
- package/dist/tests/core/discovery-parent-race.test.js +95 -0
- package/dist/tests/core/discovery-safe-read.test.js +115 -0
- package/dist/tests/core/discovery.test.js +28 -1
- package/dist/tests/core/init/orchestrator.test.js +3 -1
- package/dist/tests/core/orchestrators.test.js +171 -14
- package/dist/tests/core/secure-fs/native-bridge.test.js +733 -0
- package/dist/tests/core/text.test.js +10 -0
- package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
- package/dist/tests/integration/sensor-bootstrap.e2e.test.js +165 -0
- package/dist/tests/integration/sensor-compatibility.e2e.test.js +10 -2
- package/dist/tests/integration/sensor-structured-run.e2e.test.js +3 -1
- package/dist/tests/structural/native-release-artifacts.test.js +22 -0
- package/dist/tests/structural/release-flags-are-reachable-from-ci.test.js +10 -0
- package/dist/tests/structural/release-registry-provenance.test.js +4 -0
- package/dist/tests/structural/sensor-portability-contract.test.js +44 -0
- package/package.json +6 -2
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/linux-arm64/secure_fs.node +0 -0
- package/prebuilds/linux-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,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.secureFs = exports.PROJECT_DESTINATION_ALREADY_EXISTS_MESSAGE = void 0;
|
|
7
|
+
exports.nativeArtifactStatus = nativeArtifactStatus;
|
|
8
|
+
exports.loadNativeSecureFsBridge = loadNativeSecureFsBridge;
|
|
9
|
+
exports.createSecureFsBoundary = createSecureFsBoundary;
|
|
10
|
+
exports.resolvePackageRoot = resolvePackageRoot;
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const supportedPlatforms = new Set(['linux', 'darwin', 'win32']);
|
|
14
|
+
const supportedArchitectures = new Set(['x64', 'arm64']);
|
|
15
|
+
function artifactPath(root, platform, arch) {
|
|
16
|
+
if (!supportedPlatforms.has(platform) || !supportedArchitectures.has(arch)) {
|
|
17
|
+
throw new Error(`secure-fs native bridge does not support ${platform}-${arch}`);
|
|
18
|
+
}
|
|
19
|
+
return path_1.default.join(root, 'prebuilds', `${platform}-${arch}`, 'secure_fs.node');
|
|
20
|
+
}
|
|
21
|
+
function nativeArtifactStatus(options) {
|
|
22
|
+
const artifact = artifactPath(options.root, options.platform, options.arch);
|
|
23
|
+
return { status: fs_1.default.existsSync(artifact) ? 'available' : 'missing', platform: options.platform, arch: options.arch, path: artifact };
|
|
24
|
+
}
|
|
25
|
+
function loadNativeSecureFsBridge(options) {
|
|
26
|
+
const status = nativeArtifactStatus(options);
|
|
27
|
+
if (status.status !== 'available')
|
|
28
|
+
throw new Error(`secure-fs native artifact is unavailable for ${status.platform}-${status.arch}`);
|
|
29
|
+
let loaded;
|
|
30
|
+
try {
|
|
31
|
+
loaded = options.load(status.path);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error(`secure-fs native artifact is incompatible for ${status.platform}-${status.arch}`);
|
|
35
|
+
}
|
|
36
|
+
if (!loaded || typeof loaded !== 'object'
|
|
37
|
+
|| typeof loaded.acquireProjectLease !== 'function'
|
|
38
|
+
|| typeof loaded.releaseProjectLease !== 'function'
|
|
39
|
+
|| typeof loaded.readRegularFile !== 'function'
|
|
40
|
+
|| typeof loaded.writeProjectTransaction !== 'function'
|
|
41
|
+
|| typeof loaded.removeObservedProjectFile !== 'function') {
|
|
42
|
+
throw new Error(`secure-fs native artifact is incompatible for ${status.platform}-${status.arch}`);
|
|
43
|
+
}
|
|
44
|
+
return loaded;
|
|
45
|
+
}
|
|
46
|
+
const IDENTITY_TOKEN_BYTES = 24;
|
|
47
|
+
const NATIVE_DESTINATION_EXISTS_CODE = 'AWM_SECURE_FS_DESTINATION_EXISTS';
|
|
48
|
+
exports.PROJECT_DESTINATION_ALREADY_EXISTS_MESSAGE = 'project destination already exists';
|
|
49
|
+
const identityObservations = new WeakMap();
|
|
50
|
+
function absoluteFile(value, label) {
|
|
51
|
+
if (typeof value === 'string' && value.includes('\0'))
|
|
52
|
+
throw new Error(`${label} must not contain a NUL byte`);
|
|
53
|
+
if (typeof value !== 'string' || !path_1.default.isAbsolute(value) || path_1.default.normalize(value) !== value)
|
|
54
|
+
throw new Error(`${label} must be an absolute normalized path`);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function isNativeDestinationExistsError(error) {
|
|
58
|
+
return !!error && typeof error === 'object'
|
|
59
|
+
&& error.code === NATIVE_DESTINATION_EXISTS_CODE;
|
|
60
|
+
}
|
|
61
|
+
function boundedBytes(value, label) {
|
|
62
|
+
if (!Buffer.isBuffer(value))
|
|
63
|
+
throw new Error(`${label} must be a Buffer`);
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function projectDestination(value) {
|
|
67
|
+
if (typeof value !== 'string' || !value || value.includes('\\')
|
|
68
|
+
|| path_1.default.posix.isAbsolute(value) || path_1.default.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value)) {
|
|
69
|
+
throw new Error('destination must be a canonical project-relative path');
|
|
70
|
+
}
|
|
71
|
+
const components = value.split('/');
|
|
72
|
+
if (components.some(component => !component || component === '.' || component === '..' || component.includes(':') || component.includes('\0'))) {
|
|
73
|
+
throw new Error('destination must contain only safe project-relative components');
|
|
74
|
+
}
|
|
75
|
+
return components.join(path_1.default.sep);
|
|
76
|
+
}
|
|
77
|
+
function positiveSafeInteger(value, label) {
|
|
78
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
79
|
+
throw new Error(`${label} must be a positive safe integer`);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function nativeIdentity(value) {
|
|
83
|
+
if (!Buffer.isBuffer(value) || value.length !== IDENTITY_TOKEN_BYTES
|
|
84
|
+
|| value.toString('ascii', 0, 4) !== 'SFSI' || value[4] !== 1
|
|
85
|
+
|| (value[5] !== 1 && value[5] !== 2) || value[6] !== 0 || value[7] !== 0) {
|
|
86
|
+
throw new Error('secure-fs native bridge returned invalid read identity');
|
|
87
|
+
}
|
|
88
|
+
const expectedKind = process.platform === 'win32' ? 2 : 1;
|
|
89
|
+
if (value[5] !== expectedKind)
|
|
90
|
+
throw new Error('secure-fs native bridge returned identity for the wrong platform');
|
|
91
|
+
return Buffer.from(value);
|
|
92
|
+
}
|
|
93
|
+
function secureReadResult(file, value) {
|
|
94
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
95
|
+
throw new Error('secure-fs native bridge returned invalid read result');
|
|
96
|
+
const candidate = value;
|
|
97
|
+
if (!Buffer.isBuffer(candidate.bytes))
|
|
98
|
+
throw new Error('secure-fs native bridge returned invalid read bytes');
|
|
99
|
+
const bytes = Buffer.from(candidate.bytes);
|
|
100
|
+
const token = Object.freeze({});
|
|
101
|
+
identityObservations.set(token, { file, bytes: Buffer.from(bytes), identity: nativeIdentity(candidate.identity) });
|
|
102
|
+
return Object.freeze({ bytes, identity: token });
|
|
103
|
+
}
|
|
104
|
+
function writeOptions(value) {
|
|
105
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
106
|
+
throw new Error('transaction options must be an object');
|
|
107
|
+
const candidate = value;
|
|
108
|
+
if (candidate.mode !== 'create' && candidate.mode !== 'replace')
|
|
109
|
+
throw new Error('transaction mode must be create or replace');
|
|
110
|
+
if (typeof candidate.createParents !== 'boolean')
|
|
111
|
+
throw new Error('transaction createParents must be boolean');
|
|
112
|
+
if (candidate.mode === 'create') {
|
|
113
|
+
if (candidate.expected !== undefined || candidate.expectedIdentity !== undefined)
|
|
114
|
+
throw new Error('create transaction cannot have replacement expectations');
|
|
115
|
+
return { mode: 'create', createParents: candidate.createParents };
|
|
116
|
+
}
|
|
117
|
+
if (!Buffer.isBuffer(candidate.expected))
|
|
118
|
+
throw new Error('replace transaction requires expected Buffer bytes');
|
|
119
|
+
if (!candidate.expectedIdentity || typeof candidate.expectedIdentity !== 'object')
|
|
120
|
+
throw new Error('replace transaction requires expected identity token');
|
|
121
|
+
const observation = identityObservations.get(candidate.expectedIdentity);
|
|
122
|
+
if (!observation)
|
|
123
|
+
throw new Error('replace transaction requires an opaque identity token from readRegularFile');
|
|
124
|
+
if (!candidate.expected.equals(observation.bytes))
|
|
125
|
+
throw new Error('replace expected bytes do not match the identity observation');
|
|
126
|
+
return {
|
|
127
|
+
mode: 'replace',
|
|
128
|
+
expected: Buffer.from(observation.bytes),
|
|
129
|
+
expectedIdentity: Buffer.from(observation.identity),
|
|
130
|
+
createParents: candidate.createParents,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function createSecureFsBoundary(load) {
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
withProjectLease(projectRoot, operation) {
|
|
136
|
+
const validatedRoot = absoluteFile(projectRoot, 'projectRoot');
|
|
137
|
+
if (typeof operation !== 'function')
|
|
138
|
+
throw new Error('project lease operation must be a function');
|
|
139
|
+
const binding = load();
|
|
140
|
+
let token;
|
|
141
|
+
try {
|
|
142
|
+
token = binding.acquireProjectLease(validatedRoot);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const message = String(error);
|
|
146
|
+
if (message.includes('project lease is already held'))
|
|
147
|
+
throw new Error('project lease conflict');
|
|
148
|
+
if (message.includes('rejected path ancestor'))
|
|
149
|
+
throw new Error('project destination is unsafe for lease');
|
|
150
|
+
throw new Error('project lease acquisition failed');
|
|
151
|
+
}
|
|
152
|
+
if (!token || typeof token !== 'object' || Array.isArray(token)) {
|
|
153
|
+
throw new Error('secure-fs native bridge returned invalid project lease token');
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
return operation();
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
binding.releaseProjectLease(token);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
readRegularFile(file, maxBytes) {
|
|
163
|
+
const validatedFile = absoluteFile(file, 'file');
|
|
164
|
+
const validatedMaxBytes = positiveSafeInteger(maxBytes, 'maxBytes');
|
|
165
|
+
const result = load().readRegularFile(validatedFile, validatedMaxBytes);
|
|
166
|
+
return secureReadResult(validatedFile, result);
|
|
167
|
+
},
|
|
168
|
+
writeProjectTransaction(projectRoot, destination, content, options) {
|
|
169
|
+
const validatedRoot = absoluteFile(projectRoot, 'projectRoot');
|
|
170
|
+
const validatedDestination = projectDestination(destination);
|
|
171
|
+
const validatedContent = boundedBytes(content, 'content');
|
|
172
|
+
const validatedOptions = writeOptions(options);
|
|
173
|
+
try {
|
|
174
|
+
load().writeProjectTransaction(validatedRoot, validatedDestination, validatedContent, validatedOptions);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (isNativeDestinationExistsError(error))
|
|
178
|
+
throw new Error(exports.PROJECT_DESTINATION_ALREADY_EXISTS_MESSAGE);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
removeObservedProjectFile(projectRoot, destination, identity) {
|
|
183
|
+
const validatedRoot = absoluteFile(projectRoot, 'projectRoot');
|
|
184
|
+
const validatedDestination = projectDestination(destination);
|
|
185
|
+
if (!identity || typeof identity !== 'object')
|
|
186
|
+
throw new Error('remove requires an opaque identity token from readRegularFile');
|
|
187
|
+
const observation = identityObservations.get(identity);
|
|
188
|
+
if (!observation)
|
|
189
|
+
throw new Error('remove requires an opaque identity token from readRegularFile');
|
|
190
|
+
const observedDestination = path_1.default.join(validatedRoot, validatedDestination);
|
|
191
|
+
if (observation.file !== observedDestination)
|
|
192
|
+
throw new Error('remove requires the exact observed destination');
|
|
193
|
+
load().removeObservedProjectFile(validatedRoot, validatedDestination, Buffer.from(observation.identity));
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function resolvePackageRoot(moduleDirectory) {
|
|
198
|
+
const sourceLayout = path_1.default.resolve(moduleDirectory, '..', '..', '..');
|
|
199
|
+
return fs_1.default.existsSync(path_1.default.join(sourceLayout, 'package.json'))
|
|
200
|
+
? sourceLayout
|
|
201
|
+
: path_1.default.resolve(sourceLayout, '..');
|
|
202
|
+
}
|
|
203
|
+
const packageRoot = resolvePackageRoot(__dirname);
|
|
204
|
+
exports.secureFs = createSecureFsBoundary(() => loadNativeSecureFsBridge({
|
|
205
|
+
root: packageRoot,
|
|
206
|
+
platform: process.platform,
|
|
207
|
+
arch: process.arch,
|
|
208
|
+
load: artifact => require(artifact),
|
|
209
|
+
}));
|
package/dist/src/core/text.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.stripControlChars = stripControlChars;
|
|
4
|
+
exports.sanitizeDiagnosticText = sanitizeDiagnosticText;
|
|
4
5
|
exports.sanitizeDeclaredField = sanitizeDeclaredField;
|
|
5
6
|
// cli/src/core/text.ts
|
|
6
7
|
/** Neutraliza bytes de control C0 (incluyendo ESC `\x1b`) y DEL de texto que
|
|
@@ -16,6 +17,12 @@ function stripControlChars(text) {
|
|
|
16
17
|
// eslint-disable-next-line no-control-regex -- necesitamos matchear C0 deliberadamente
|
|
17
18
|
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
18
19
|
}
|
|
20
|
+
/** Keeps an untrusted diagnostic field on one terminal line, without changing
|
|
21
|
+
* the whitespace policy for multiline public text or composed markdown. */
|
|
22
|
+
function sanitizeDiagnosticText(text) {
|
|
23
|
+
// eslint-disable-next-line no-control-regex -- remove all C0/C1 controls, including terminal escape bytes
|
|
24
|
+
return text.replace(/\r\n|[\r\n\t\u2028\u2029]/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
25
|
+
}
|
|
19
26
|
/**
|
|
20
27
|
* Neutraliza marcado markdown (colapsa `\r?\n` a un espacio y quita
|
|
21
28
|
* `` ` * _ # < > ``) de contenido no confiable antes de interpolarlo en un
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const context_1 = require("../../src/commands/context");
|
|
4
|
-
const collected = (declared, diagnostics = []) => ({ declared, diagnostics });
|
|
4
|
+
const collected = (declared, diagnostics = []) => ({ declared, diagnostics, droppedNames: [] });
|
|
5
5
|
const uno = { name: 'mi-proceso', appliesWhen: 'cuando hay una tarea', terminatesTo: 'development-process' };
|
|
6
6
|
describe('awm context orchestrators', () => {
|
|
7
7
|
it('lista los orquestadores compuestos', () => {
|
|
@@ -55,6 +55,14 @@ describe('awm context orchestrators', () => {
|
|
|
55
55
|
const r = (0, context_1.runContextOrchestrators)(collected([uno]), { json: false, verify: 'de-verdad-no-existe' });
|
|
56
56
|
expect(r.code).toBe(2);
|
|
57
57
|
});
|
|
58
|
+
it('--verify rejects a dropped raw identity that sanitizes to a retained name', () => {
|
|
59
|
+
const input = {
|
|
60
|
+
...collected([{ name: 'foobar', appliesWhen: 'x', terminatesTo: 'y' }]),
|
|
61
|
+
droppedNames: ['foo_bar'],
|
|
62
|
+
};
|
|
63
|
+
expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foo_bar' }).code).toBe(2);
|
|
64
|
+
expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foobar' }).code).toBe(0);
|
|
65
|
+
});
|
|
58
66
|
it('sanea bytes de control antes de escribir a la terminal', () => {
|
|
59
67
|
const hostil = { name: 'x\x1b[31m', appliesWhen: 'w\x07', terminatesTo: 't\x00' };
|
|
60
68
|
const r = (0, context_1.runContextOrchestrators)(collected([hostil]), { json: false });
|
|
@@ -186,6 +186,8 @@ describe('installHook (happy path + merge)', () => {
|
|
|
186
186
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
187
187
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
188
188
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
189
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
|
|
190
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
189
191
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
190
192
|
installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
191
193
|
const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
|
|
@@ -246,6 +248,8 @@ describe('installHook (happy path + merge)', () => {
|
|
|
246
248
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
247
249
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
248
250
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
251
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido'), { recursive: true });
|
|
252
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido', 'SKILL.md'), '---\nname: proceso-valido\n---\n');
|
|
249
253
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'proceso-valido', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
250
254
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
251
255
|
let result;
|
|
@@ -101,6 +101,8 @@ describe('resyncInstalledHooks', () => {
|
|
|
101
101
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
|
|
102
102
|
fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
|
|
103
103
|
fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
|
|
104
|
+
fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
|
|
105
|
+
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
|
|
104
106
|
fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
|
|
105
107
|
const { installHook } = require('../../../src/commands/hooks/install');
|
|
106
108
|
installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
|
|
@@ -0,0 +1,24 @@
|
|
|
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 bootstrap_1 = require("../../../src/commands/sensors/bootstrap");
|
|
10
|
+
describe('planSensorBootstrap observational boundary', () => {
|
|
11
|
+
it('does not create project files while reporting a missing explicit mode', async () => {
|
|
12
|
+
const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bootstrap-observe-'));
|
|
13
|
+
try {
|
|
14
|
+
fs_1.default.writeFileSync(path_1.default.join(project, 'package.json'), '{"name":"fixture"}\n');
|
|
15
|
+
const before = fs_1.default.readdirSync(project).sort();
|
|
16
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(project)).resolves.toMatchObject({ kind: 'blocked', reason: 'mode-required', changes: [] });
|
|
17
|
+
expect(fs_1.default.readdirSync(project).sort()).toEqual(before);
|
|
18
|
+
expect(fs_1.default.existsSync(path_1.default.join(project, '.awm'))).toBe(false);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
fs_1.default.rmSync(project, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
jest.mock('../../../src/commands/sensors/project', () => ({ resolveSensorProject: jest.fn() }));
|
|
7
|
+
jest.mock('../../../src/commands/sensors/detection', () => ({ detectStack: jest.fn() }));
|
|
8
|
+
jest.mock('../../../src/core/registries', () => ({ listRegistries: jest.fn(() => []) }));
|
|
9
|
+
jest.mock('../../../src/commands/sensors/compatibility/pack-source', () => ({ listPackSources: jest.fn() }));
|
|
10
|
+
jest.mock('../../../src/commands/sensors/compatibility/contract', () => ({ ...jest.requireActual('../../../src/commands/sensors/compatibility/contract'), parseSensorPack: jest.fn() }));
|
|
11
|
+
jest.mock('../../../src/commands/sensors/compatibility/live', () => ({ resolveParsedPackCompatibility: jest.fn() }));
|
|
12
|
+
jest.mock('../../../src/commands/sensors/compatibility/source', () => ({ resolveSensorSource: jest.fn() }));
|
|
13
|
+
jest.mock('../../../src/commands/sensors/migrate', () => ({ planV2Migration: jest.fn(), replaceV2ManifestWithV3: jest.fn() }));
|
|
14
|
+
jest.mock('../../../src/commands/sensors/compatibility/materialize', () => ({ materializePortableSensors: jest.fn() }));
|
|
15
|
+
jest.mock('../../../src/commands/sensors/compatibility/safe-file', () => ({ writeProjectFile: jest.fn(), withProjectLease: jest.fn((_root, operation) => operation()) }));
|
|
16
|
+
const bootstrap_1 = require("../../../src/commands/sensors/bootstrap");
|
|
17
|
+
const project_1 = require("../../../src/commands/sensors/project");
|
|
18
|
+
const detection_1 = require("../../../src/commands/sensors/detection");
|
|
19
|
+
const pack_source_1 = require("../../../src/commands/sensors/compatibility/pack-source");
|
|
20
|
+
const contract_1 = require("../../../src/commands/sensors/compatibility/contract");
|
|
21
|
+
const live_1 = require("../../../src/commands/sensors/compatibility/live");
|
|
22
|
+
const source_1 = require("../../../src/commands/sensors/compatibility/source");
|
|
23
|
+
const migrate_1 = require("../../../src/commands/sensors/migrate");
|
|
24
|
+
const migrate_2 = require("../../../src/commands/sensors/migrate");
|
|
25
|
+
const materialize_1 = require("../../../src/commands/sensors/compatibility/materialize");
|
|
26
|
+
const safe_file_1 = require("../../../src/commands/sensors/compatibility/safe-file");
|
|
27
|
+
const fs_1 = __importDefault(require("fs"));
|
|
28
|
+
const os_1 = __importDefault(require("os"));
|
|
29
|
+
const path_1 = __importDefault(require("path"));
|
|
30
|
+
const crypto_1 = require("crypto");
|
|
31
|
+
const root = '/project';
|
|
32
|
+
const missing = { state: 'missing', projectRoot: root, manifestPath: `${root}/.awm/sensors.json` };
|
|
33
|
+
const source = { path: '/registry/sensor-packs/js-ts/pack.json', content: '{}', registry: { name: 'baseline', remote: 'local', contentRoot: '/registry' } };
|
|
34
|
+
describe('planSensorBootstrap', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
project_1.resolveSensorProject.mockReturnValue(missing);
|
|
38
|
+
detection_1.detectStack.mockReturnValue({ pack: 'js-ts', indicators: ['package.json'] });
|
|
39
|
+
});
|
|
40
|
+
it('requires an explicit mode for a missing declaration and never calls a writer', async () => {
|
|
41
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root)).resolves.toMatchObject({ kind: 'blocked', reason: 'mode-required', changes: [] });
|
|
42
|
+
expect(pack_source_1.listPackSources).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
it.each(['native-gate', 'opt-out'])('requires a reason for missing %s', async (mode) => {
|
|
45
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode })).resolves.toMatchObject({ kind: 'blocked', reason: 'reason-required', changes: [] });
|
|
46
|
+
});
|
|
47
|
+
it('plans a versioned native declaration without writing it', async () => {
|
|
48
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'native-gate', reason: 'remote CI', dryRun: true })).resolves.toEqual({
|
|
49
|
+
kind: 'create', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, dryRun: true, configure: true,
|
|
50
|
+
changes: [{ path: '.awm/sensors.json', action: 'create' }],
|
|
51
|
+
manifest: { schemaVersion: 3, mode: 'native-gate', reason: 'remote CI' },
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it('returns an idempotent no-op for an existing v3 declaration', async () => {
|
|
55
|
+
project_1.resolveSensorProject.mockReturnValue({ state: 'configured', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, packageRoot: root, manifest: { kind: 'v3', pack: { schemaVersion: 3, mode: 'opt-out', reason: 'declared' } } });
|
|
56
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root)).resolves.toMatchObject({ kind: 'noop', changes: [] });
|
|
57
|
+
});
|
|
58
|
+
it('rejects a mode that conflicts with an existing v3 declaration', async () => {
|
|
59
|
+
project_1.resolveSensorProject.mockReturnValue({ state: 'configured', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, packageRoot: root, manifest: { kind: 'v3', pack: { schemaVersion: 3, mode: 'opt-out', reason: 'declared' } } });
|
|
60
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'native-gate', reason: 'other' })).resolves.toMatchObject({ kind: 'blocked', reason: 'mode-conflicts-with-existing-declaration', changes: [] });
|
|
61
|
+
});
|
|
62
|
+
it('blocks source-unavailable v2 migration without attempting a candidate', async () => {
|
|
63
|
+
const v2 = { schemaVersion: 2, pack: 'js-ts', sensors: {} };
|
|
64
|
+
project_1.resolveSensorProject.mockReturnValue({ state: 'configured', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, packageRoot: root, manifest: { kind: 'v2', pack: v2 } });
|
|
65
|
+
source_1.resolveSensorSource.mockReturnValue({ kind: 'source-unavailable', reason: 'no-compatible-registry', remedy: 'install-registry-or-run-awm-update' });
|
|
66
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root)).resolves.toMatchObject({ kind: 'blocked', reason: 'no-compatible-registry', changes: [] });
|
|
67
|
+
expect(migrate_1.planV2Migration).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
it('preserves a legacy v1 manifest and blocks an ambiguous v2 source without writes', async () => {
|
|
70
|
+
project_1.resolveSensorProject.mockReturnValueOnce({ state: 'configured', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, packageRoot: root, manifest: { kind: 'legacy', pack: { pack: 'js-ts', sensors: {}, compatibility: {} } } });
|
|
71
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root)).resolves.toMatchObject({ kind: 'blocked', reason: 'legacy-v1-preserved', changes: [] });
|
|
72
|
+
project_1.resolveSensorProject.mockReturnValueOnce({ state: 'configured', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, packageRoot: root, manifest: { kind: 'v2', pack: { schemaVersion: 2, pack: 'js-ts', sensors: {} } } });
|
|
73
|
+
source_1.resolveSensorSource.mockReturnValue({ kind: 'source-ambiguous', reason: 'multiple-compatible-registries', remedy: 'configure-one-logical-registry', candidates: ['a', 'b'] });
|
|
74
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root)).resolves.toMatchObject({ kind: 'blocked', reason: 'multiple-compatible-registries', changes: [] });
|
|
75
|
+
expect(migrate_1.planV2Migration).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it('plans a v2 replacement only after semantic migration proves equivalence', async () => {
|
|
78
|
+
const v2 = { schemaVersion: 2, pack: 'js-ts', sensors: {} };
|
|
79
|
+
const resolution = { kind: 'logical', source };
|
|
80
|
+
const migration = { candidate: { schemaVersion: 3, mode: 'project-sensors', pack: 'js-ts', source: { registry: 'baseline' }, sensors: {} }, equivalent: true, equivalence: {} };
|
|
81
|
+
const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bootstrap-plan-'));
|
|
82
|
+
const manifestPath = path_1.default.join(project, 'sensors.json');
|
|
83
|
+
try {
|
|
84
|
+
fs_1.default.writeFileSync(manifestPath, JSON.stringify(v2));
|
|
85
|
+
project_1.resolveSensorProject.mockReturnValue({ state: 'configured', projectRoot: project, manifestPath, packageRoot: project, manifest: { kind: 'v2', pack: v2 } });
|
|
86
|
+
source_1.resolveSensorSource.mockReturnValue(resolution);
|
|
87
|
+
migrate_1.planV2Migration.mockReturnValue(migration);
|
|
88
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(project)).resolves.toMatchObject({ kind: 'migrate', changes: [{ path: '.awm/sensors.json', action: 'replace' }], migration });
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
fs_1.default.rmSync(project, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
it('plans a concrete portable project-sensors declaration from one logical source', async () => {
|
|
95
|
+
const variant = { id: 'eslint-9', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: ['eslint.config.mjs'] };
|
|
96
|
+
pack_source_1.listPackSources.mockReturnValue([source]);
|
|
97
|
+
contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { fast: true, variants: [variant] } } } });
|
|
98
|
+
live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { fast: true, variants: [variant] } } }, sensors: { lint: { state: 'certified', reason: 'ok', variantId: 'eslint-9', toolVersion: '9.0.0', runtimeVersion: '24.0.0', certifiedRange: '>=9 <10', evidence: [] } } });
|
|
99
|
+
const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
|
|
100
|
+
expect(plan).toMatchObject({ kind: 'create', changes: [{ path: '.awm/sensors.json', action: 'create' }, { path: 'eslint.config.mjs', action: 'create' }], manifest: { schemaVersion: 3, mode: 'project-sensors', pack: 'js-ts', source: { registry: 'baseline' } } });
|
|
101
|
+
expect(JSON.stringify(plan.manifest)).not.toContain('/registry');
|
|
102
|
+
});
|
|
103
|
+
it('resolves compatibility from the selected package root', async () => {
|
|
104
|
+
const variant = { id: 'eslint-9', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: [] };
|
|
105
|
+
const pack = { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } };
|
|
106
|
+
pack_source_1.listPackSources.mockReturnValue([source]);
|
|
107
|
+
contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack });
|
|
108
|
+
live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack, sensors: { lint: { state: 'certified', reason: 'ok', variantId: 'eslint-9', toolVersion: '9.0.0', runtimeVersion: '24.0.0', certifiedRange: '>=9 <10', evidence: [] } } });
|
|
109
|
+
await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors', packageRoot: 'packages/web' });
|
|
110
|
+
expect(live_1.resolveParsedPackCompatibility).toHaveBeenCalledWith(path_1.default.resolve(root, 'packages/web'), pack);
|
|
111
|
+
});
|
|
112
|
+
it('blocks project-sensors when compatibility cannot select every declared sensor variant', async () => {
|
|
113
|
+
const variant = { id: 'eslint-9', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: [] };
|
|
114
|
+
pack_source_1.listPackSources.mockReturnValue([source]);
|
|
115
|
+
contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } } });
|
|
116
|
+
live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } }, sensors: { lint: { variantId: null } } });
|
|
117
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' })).resolves.toMatchObject({ kind: 'blocked', reason: 'sensor-variant-unresolvable', changes: [] });
|
|
118
|
+
});
|
|
119
|
+
it('converts a compatibility probe failure into a stable blocked plan', async () => {
|
|
120
|
+
pack_source_1.listPackSources.mockReturnValue([source]);
|
|
121
|
+
contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack: { schemaVersion: 2, name: 'js-ts', sensors: {} } });
|
|
122
|
+
live_1.resolveParsedPackCompatibility.mockRejectedValue(new Error('tool probe failed'));
|
|
123
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' })).resolves.toMatchObject({ kind: 'blocked', reason: 'compatibility-unresolvable', changes: [] });
|
|
124
|
+
});
|
|
125
|
+
it.each([{ mode: 'invalid' }, { reason: 'line\nbreak' }, { dryRun: 'yes' }, { unknown: true }])('rejects invalid bootstrap options %j before planning', async (input) => {
|
|
126
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, input)).rejects.toThrow(/bootstrap (mode|reason|dryRun|options)/);
|
|
127
|
+
expect(pack_source_1.listPackSources).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
it('accepts a Windows absolute registry path without treating it as project state', async () => {
|
|
130
|
+
await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors', registryRoot: 'C:\\awm\\registries\\baseline' })).resolves.toMatchObject({
|
|
131
|
+
kind: 'blocked', reason: 'registry-root-not-configured', changes: [],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
it('rejects applying a dry-run plan before any publisher runs', () => {
|
|
135
|
+
expect(() => (0, bootstrap_1.applySensorBootstrap)({ kind: 'create', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, dryRun: true, changes: [], manifest: { schemaVersion: 3, mode: 'native-gate', reason: 'CI' } })).toThrow('dry-run');
|
|
136
|
+
expect(safe_file_1.writeProjectFile).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
it('publishes native declarations and project sensors through distinct native boundaries', () => {
|
|
139
|
+
expect((0, bootstrap_1.applySensorBootstrap)({ kind: 'create', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, dryRun: false, changes: [{ path: '.awm/sensors.json', action: 'create' }], manifest: { schemaVersion: 3, mode: 'native-gate', reason: 'CI' } })).toBe('created');
|
|
140
|
+
expect(safe_file_1.writeProjectFile).toHaveBeenCalledWith(root, '.awm/sensors.json', expect.any(Buffer), { mode: 'create', createParents: true });
|
|
141
|
+
expect(safe_file_1.withProjectLease).toHaveBeenCalledWith(root, expect.any(Function));
|
|
142
|
+
const manifest = { schemaVersion: 3, mode: 'project-sensors', pack: 'js-ts', source: { registry: 'baseline' }, sensors: {} };
|
|
143
|
+
expect((0, bootstrap_1.applySensorBootstrap)({ kind: 'create', projectRoot: root, manifestPath: `${root}/.awm/sensors.json`, dryRun: false, changes: [{ path: '.awm/sensors.json', action: 'create' }], manifest, source })).toBe('created');
|
|
144
|
+
expect(materialize_1.materializePortableSensors).toHaveBeenCalledWith(expect.objectContaining({ projectRoot: root, pack: 'js-ts', source, sensors: {} }));
|
|
145
|
+
});
|
|
146
|
+
it('delegates a validated migration to the fenced replacement', () => {
|
|
147
|
+
const candidate = { schemaVersion: 3, mode: 'project-sensors', pack: 'js-ts', source: { registry: 'baseline' }, sensors: {} };
|
|
148
|
+
const migration = { candidate, equivalent: true, equivalence: {} };
|
|
149
|
+
const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bootstrap-plan-'));
|
|
150
|
+
const manifestPath = path_1.default.join(project, 'sensors.json');
|
|
151
|
+
try {
|
|
152
|
+
const bytes = Buffer.from('{"schemaVersion":2}\n');
|
|
153
|
+
fs_1.default.writeFileSync(manifestPath, bytes);
|
|
154
|
+
const plan = { kind: 'migrate', projectRoot: project, manifestPath, dryRun: false, changes: [{ path: '.awm/sensors.json', action: 'replace' }], migration, source, originalDigest: (0, crypto_1.createHash)('sha256').update(bytes).digest('hex') };
|
|
155
|
+
expect((0, bootstrap_1.applySensorBootstrap)(plan)).toBe('migrated');
|
|
156
|
+
expect(migrate_2.replaceV2ManifestWithV3).toHaveBeenCalledWith(plan.manifestPath, candidate, source);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
fs_1.default.rmSync(project, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
it('rejects a migration plan whose manifest changed after planning', () => {
|
|
163
|
+
const project = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-bootstrap-stale-'));
|
|
164
|
+
const manifestPath = path_1.default.join(project, 'sensors.json');
|
|
165
|
+
try {
|
|
166
|
+
fs_1.default.writeFileSync(manifestPath, 'replacement');
|
|
167
|
+
const plan = { kind: 'migrate', projectRoot: project, manifestPath, dryRun: false, changes: [{ path: '.awm/sensors.json', action: 'replace' }], migration: { candidate: {}, equivalent: true, equivalence: {} }, source, originalDigest: (0, crypto_1.createHash)('sha256').update('original').digest('hex') };
|
|
168
|
+
expect(() => (0, bootstrap_1.applySensorBootstrap)(plan)).toThrow('stale');
|
|
169
|
+
expect(migrate_2.replaceV2ManifestWithV3).not.toHaveBeenCalled();
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
fs_1.default.rmSync(project, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -8,6 +8,7 @@ const timeout_1 = require("../../../../src/commands/sensors/compatibility/timeou
|
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const os_1 = __importDefault(require("os"));
|
|
10
10
|
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const safe_file_1 = require("../../../../src/commands/sensors/compatibility/safe-file");
|
|
11
12
|
const coverage = {
|
|
12
13
|
schemaVersion: 1,
|
|
13
14
|
classes: {
|
|
@@ -44,6 +45,8 @@ function validPack() {
|
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
describe('sensor pack v2 contract', () => {
|
|
48
|
+
beforeEach(() => (0, safe_file_1.setPortableReadForTests)(true));
|
|
49
|
+
afterEach(() => (0, safe_file_1.setPortableReadForTests)(false));
|
|
47
50
|
it('exports bounded timeout validation and resolution (R3.1, R3.4)', () => {
|
|
48
51
|
expect((0, timeout_1.positiveTimeout)(1, 'sensor timeout')).toBe(1);
|
|
49
52
|
expect((0, timeout_1.resolveTimeout)({ project: 90_000, pack: 30_000, fast: true })).toEqual({ timeoutMs: 90_000, source: 'project' });
|
|
@@ -84,6 +87,41 @@ describe('sensor pack v2 contract', () => {
|
|
|
84
87
|
fs_1.default.rmSync(sensorPacks, { recursive: true, force: true });
|
|
85
88
|
}
|
|
86
89
|
});
|
|
90
|
+
it('rejects a Semgrep policy that changes between inspection and safe open', () => {
|
|
91
|
+
const sensorPacks = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-semgrep-policy-'));
|
|
92
|
+
const packDir = path_1.default.join(sensorPacks, 'python');
|
|
93
|
+
const policyPath = path_1.default.join(sensorPacks, 'shared', 'semgrep-policy.json');
|
|
94
|
+
const originalOpen = fs_1.default.openSync.bind(fs_1.default);
|
|
95
|
+
const open = jest.spyOn(fs_1.default, 'openSync');
|
|
96
|
+
let replaced = false;
|
|
97
|
+
try {
|
|
98
|
+
fs_1.default.mkdirSync(path_1.default.dirname(policyPath), { recursive: true });
|
|
99
|
+
fs_1.default.mkdirSync(packDir);
|
|
100
|
+
fs_1.default.writeFileSync(policyPath, JSON.stringify({
|
|
101
|
+
tool: 'semgrep', toolRange: '>=1.0.0', runtime: 'python', runtimeRange: '>=3.9.0', probe: 'semgrep-validate',
|
|
102
|
+
}));
|
|
103
|
+
const pack = validPack();
|
|
104
|
+
pack.sensors.lint.variants[0] = {
|
|
105
|
+
id: 'semgrep-python', priority: 10, certifiedRange: '>=1.0.0', policyRef: 'shared/semgrep-policy.json',
|
|
106
|
+
command: { executable: 'semgrep', resolution: 'path', args: ['--config', '.semgrep.awm.yml', '--json', '.'] },
|
|
107
|
+
assets: ['.semgrep.awm.yml'], formatter: 'semgrep',
|
|
108
|
+
};
|
|
109
|
+
open.mockImplementation(((file, flags, mode) => {
|
|
110
|
+
if (file === policyPath && !replaced) {
|
|
111
|
+
replaced = true;
|
|
112
|
+
fs_1.default.writeFileSync(policyPath, JSON.stringify({
|
|
113
|
+
tool: 'semgrep', toolRange: '>=2.0.0', runtime: 'python', runtimeRange: '>=3.10.0', probe: 'semgrep-validate',
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
return originalOpen(file, flags, mode);
|
|
117
|
+
}));
|
|
118
|
+
expect(() => (0, contract_1.parseSensorPack)(pack, path_1.default.join(packDir, 'pack.json'))).toThrow(/changed|identity|policyRef/i);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
open.mockRestore();
|
|
122
|
+
fs_1.default.rmSync(sensorPacks, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
87
125
|
it('fails closed when a policy reference is not the AWM-owned shared Semgrep policy', () => {
|
|
88
126
|
const pack = validPack();
|
|
89
127
|
pack.sensors.lint.variants[0] = {
|
|
@@ -61,6 +61,73 @@ describe('sensor manifest contract', () => {
|
|
|
61
61
|
expect(() => (0, manifest_1.parseSensorManifest)({ schemaVersion: 3, mode: 'native-gate' }, 'sensors.json')).toThrow('reason');
|
|
62
62
|
expect(() => (0, manifest_1.parseSensorManifest)({ ...project, registryRoot: '/machine/root' }, 'sensors.json')).toThrow('unknown field');
|
|
63
63
|
});
|
|
64
|
+
it('serializes v3 deterministically with a newline and round-trips portable semantics', () => {
|
|
65
|
+
const manifest = {
|
|
66
|
+
schemaVersion: 3,
|
|
67
|
+
mode: 'project-sensors',
|
|
68
|
+
pack: 'js-ts',
|
|
69
|
+
packSelection: 'explicit',
|
|
70
|
+
source: { registry: 'baseline' },
|
|
71
|
+
packageRoot: 'cli',
|
|
72
|
+
sensors: { lint: { ...validV2Manifest().sensors.lint, enabled: false, fast: true, timeout: 45_000, assets: ['eslint.config.awm.mjs'], policyRef: 'shared/semgrep-policy.json' } },
|
|
73
|
+
concurrency: 2,
|
|
74
|
+
};
|
|
75
|
+
const serialized = (0, manifest_1.serializeManifestV3)(manifest);
|
|
76
|
+
expect(serialized).toBe((0, manifest_1.serializeManifestV3)(manifest));
|
|
77
|
+
expect(serialized.endsWith('\n')).toBe(true);
|
|
78
|
+
expect(serialized).not.toContain('registryRoot');
|
|
79
|
+
expect(serialized).not.toContain('/home/');
|
|
80
|
+
expect((0, manifest_1.parseSensorManifest)(JSON.parse(serialized), 'sensors.json')).toEqual({ kind: 'v3', pack: manifest });
|
|
81
|
+
});
|
|
82
|
+
it.each(['native-gate', 'opt-out'])('serializes a versioned %s declaration canonically', mode => {
|
|
83
|
+
const manifest = { schemaVersion: 3, mode, reason: 'declared by project policy' };
|
|
84
|
+
expect((0, manifest_1.serializeManifestV3)(manifest)).toBe(JSON.stringify(manifest, null, 2) + '\n');
|
|
85
|
+
});
|
|
86
|
+
it('refuses to serialize a v3 declaration containing a nested physical path', () => {
|
|
87
|
+
const manifest = {
|
|
88
|
+
schemaVersion: 3,
|
|
89
|
+
mode: 'project-sensors',
|
|
90
|
+
pack: 'js-ts',
|
|
91
|
+
source: { registry: 'baseline' },
|
|
92
|
+
sensors: {
|
|
93
|
+
lint: {
|
|
94
|
+
...validV2Manifest().sensors.lint,
|
|
95
|
+
command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['--config=/home/operator/.config/eslint.config.mjs'] },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
expect(() => (0, manifest_1.serializeManifestV3)(manifest)).toThrow('physical path');
|
|
100
|
+
});
|
|
101
|
+
it('serializes a portable URL in a structured command argument', () => {
|
|
102
|
+
const manifest = {
|
|
103
|
+
schemaVersion: 3,
|
|
104
|
+
mode: 'project-sensors',
|
|
105
|
+
pack: 'js-ts',
|
|
106
|
+
source: { registry: 'baseline' },
|
|
107
|
+
sensors: {
|
|
108
|
+
lint: {
|
|
109
|
+
...validV2Manifest().sensors.lint,
|
|
110
|
+
command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['--rules=https://example.invalid/rules.json'] },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
expect(JSON.parse((0, manifest_1.serializeManifestV3)(manifest)).sensors.lint.command.args).toEqual(['--rules=https://example.invalid/rules.json']);
|
|
115
|
+
});
|
|
116
|
+
it('retains a portable URL in a structured command argument', () => {
|
|
117
|
+
const manifest = {
|
|
118
|
+
schemaVersion: 3,
|
|
119
|
+
mode: 'project-sensors',
|
|
120
|
+
pack: 'js-ts',
|
|
121
|
+
source: { registry: 'baseline' },
|
|
122
|
+
sensors: {
|
|
123
|
+
lint: {
|
|
124
|
+
...validV2Manifest().sensors.lint,
|
|
125
|
+
command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['--rules-url=https://example.test/eslint/rules.json'] },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
expect(() => (0, manifest_1.serializeManifestV3)(manifest)).not.toThrow();
|
|
130
|
+
});
|
|
64
131
|
it('accepts and serializes a positive v2 project timeout (R3)', () => {
|
|
65
132
|
const manifest = validV2Manifest();
|
|
66
133
|
manifest.sensors.lint.timeout = 45_000;
|