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.
Files changed (61) hide show
  1. package/dist/src/commands/context/index.js +3 -1
  2. package/dist/src/commands/preflight/checks.js +2 -2
  3. package/dist/src/commands/sensors/bootstrap.js +171 -0
  4. package/dist/src/commands/sensors/compatibility/contract.js +18 -15
  5. package/dist/src/commands/sensors/compatibility/manifest.js +29 -1
  6. package/dist/src/commands/sensors/compatibility/materialize.js +256 -54
  7. package/dist/src/commands/sensors/compatibility/safe-file.js +240 -5
  8. package/dist/src/commands/sensors/coverage/index.js +2 -2
  9. package/dist/src/commands/sensors/coverage/resolve.js +20 -10
  10. package/dist/src/commands/sensors/detection.js +31 -0
  11. package/dist/src/commands/sensors/exec.js +116 -11
  12. package/dist/src/commands/sensors/index.js +51 -10
  13. package/dist/src/commands/sensors/init.js +17 -170
  14. package/dist/src/commands/sensors/migrate.js +209 -0
  15. package/dist/src/commands/sensors/run.js +2 -2
  16. package/dist/src/core/discovery.js +79 -4
  17. package/dist/src/core/init/steps.js +24 -11
  18. package/dist/src/core/orchestrators.js +34 -10
  19. package/dist/src/core/secure-fs/native-bridge.js +209 -0
  20. package/dist/src/core/text.js +7 -0
  21. package/dist/tests/commands/context.test.js +9 -1
  22. package/dist/tests/commands/hooks/install.test.js +4 -0
  23. package/dist/tests/commands/hooks/resync.test.js +2 -0
  24. package/dist/tests/commands/sensors/bootstrap-observational.test.js +24 -0
  25. package/dist/tests/commands/sensors/bootstrap.test.js +175 -0
  26. package/dist/tests/commands/sensors/compatibility/contract.test.js +38 -0
  27. package/dist/tests/commands/sensors/compatibility/manifest.test.js +67 -0
  28. package/dist/tests/commands/sensors/compatibility/materialize.test.js +410 -0
  29. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +34 -1
  30. package/dist/tests/commands/sensors/compatibility/safe-file.test.js +52 -0
  31. package/dist/tests/commands/sensors/coverage/index.test.js +20 -0
  32. package/dist/tests/commands/sensors/coverage/resolve.test.js +10 -0
  33. package/dist/tests/commands/sensors/exec.test.js +86 -0
  34. package/dist/tests/commands/sensors/index.test.js +54 -0
  35. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +21 -84
  36. package/dist/tests/commands/sensors/init.test.js +56 -490
  37. package/dist/tests/commands/sensors/migrate.test.js +513 -0
  38. package/dist/tests/core/context/orchestrator.test.js +2 -0
  39. package/dist/tests/core/discovery-multiroot.test.js +5 -0
  40. package/dist/tests/core/discovery-parent-race.test.js +95 -0
  41. package/dist/tests/core/discovery-safe-read.test.js +115 -0
  42. package/dist/tests/core/discovery.test.js +28 -1
  43. package/dist/tests/core/init/orchestrator.test.js +3 -1
  44. package/dist/tests/core/orchestrators.test.js +171 -14
  45. package/dist/tests/core/secure-fs/native-bridge.test.js +733 -0
  46. package/dist/tests/core/text.test.js +10 -0
  47. package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
  48. package/dist/tests/integration/sensor-bootstrap.e2e.test.js +165 -0
  49. package/dist/tests/integration/sensor-compatibility.e2e.test.js +10 -2
  50. package/dist/tests/integration/sensor-structured-run.e2e.test.js +3 -1
  51. package/dist/tests/structural/native-release-artifacts.test.js +22 -0
  52. package/dist/tests/structural/release-flags-are-reachable-from-ci.test.js +10 -0
  53. package/dist/tests/structural/release-registry-provenance.test.js +4 -0
  54. package/dist/tests/structural/sensor-portability-contract.test.js +44 -0
  55. package/package.json +6 -2
  56. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  57. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  58. package/prebuilds/linux-arm64/secure_fs.node +0 -0
  59. package/prebuilds/linux-x64/secure_fs.node +0 -0
  60. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  61. 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
+ }));
@@ -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;