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,115 @@
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 path_1 = __importDefault(require("path"));
8
+ const discovery_1 = require("../../src/core/discovery");
9
+ jest.mock('fs');
10
+ const root = path_1.default.resolve('registry');
11
+ const skillDir = path_1.default.join(root, 'skills', 'safe');
12
+ const skillFile = path_1.default.join(skillDir, 'SKILL.md');
13
+ const content = '---\ndescription: >-\n Ordinary skill\n---\n';
14
+ const descriptor = 42;
15
+ const inspected = {
16
+ dev: 7n, ino: 9007199254740993n, size: BigInt(Buffer.byteLength(content)),
17
+ isFile: () => true, isSymbolicLink: () => false, isDirectory: () => false,
18
+ };
19
+ const directory = { dev: 7n, ino: 8n, isFile: () => false, isSymbolicLink: () => false, isDirectory: () => true };
20
+ describe.each([true, false])('skill descriptor safety (O_NOFOLLOW available: %s)', (noFollowAvailable) => {
21
+ beforeEach(() => {
22
+ jest.resetAllMocks();
23
+ const actual = jest.requireActual('fs');
24
+ jest.replaceProperty(fs_1.default, 'constants', {
25
+ ...actual.constants,
26
+ O_NOFOLLOW: noFollowAvailable ? 0x20000 : undefined,
27
+ O_NONBLOCK: 0x800,
28
+ });
29
+ fs_1.default.existsSync.mockReturnValue(true);
30
+ fs_1.default.readdirSync.mockReturnValue([{ name: 'safe', isDirectory: () => true }]);
31
+ fs_1.default.lstatSync.mockImplementation((file) => {
32
+ if (file === skillFile)
33
+ return inspected;
34
+ if (file.endsWith('awm-registry.json'))
35
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
36
+ return directory;
37
+ });
38
+ fs_1.default.openSync.mockReturnValue(descriptor);
39
+ fs_1.default.fstatSync.mockReturnValue(inspected);
40
+ fs_1.default.readFileSync.mockReturnValue(content);
41
+ });
42
+ afterEach(() => jest.restoreAllMocks());
43
+ it('reads an ordinary skill from the validated descriptor and closes it', () => {
44
+ expect((0, discovery_1.discoverSkills)([root])).toEqual([{ name: 'safe', path: skillDir, description: 'Ordinary skill' }]);
45
+ expect(fs_1.default.readFileSync).toHaveBeenCalledWith(descriptor, 'utf-8');
46
+ expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
47
+ const flags = fs_1.default.openSync.mock.calls[0][1];
48
+ expect(typeof flags).toBe('number');
49
+ if (noFollowAvailable)
50
+ expect(flags & fs_1.default.constants.O_NOFOLLOW).toBe(fs_1.default.constants.O_NOFOLLOW);
51
+ expect(flags & fs_1.default.constants.O_NONBLOCK).toBe(fs_1.default.constants.O_NONBLOCK);
52
+ });
53
+ it.each([
54
+ ['another inode', { ino: inspected.ino + 1n }],
55
+ ['another device', { dev: inspected.dev + 1n }],
56
+ ['changed size', { size: inspected.size + 1n }],
57
+ ['FIFO replacement', { isFile: () => false }],
58
+ ['symlink replacement', { isFile: () => false, isSymbolicLink: () => true }],
59
+ ['unobservable device', { dev: undefined }],
60
+ ['unobservable inode', { ino: undefined }],
61
+ ])('rejects %s after inspection, without reading replacement bytes', (_label, change) => {
62
+ fs_1.default.fstatSync.mockReturnValue({ ...inspected, ...change });
63
+ expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/SKILL\.md.*(identity|regular|size|symbolic)/i);
64
+ expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
65
+ expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
66
+ });
67
+ it('opens nonblocking so a FIFO replacement cannot hang before fstat rejects it', () => {
68
+ // No FIFO is created: the open mock enforces the flag needed to avoid blocking.
69
+ fs_1.default.openSync.mockImplementation((_file, flags) => {
70
+ if (!(flags & fs_1.default.constants.O_NONBLOCK))
71
+ throw new Error('would block opening FIFO');
72
+ return descriptor;
73
+ });
74
+ fs_1.default.fstatSync.mockReturnValue({ ...inspected, isFile: () => false });
75
+ expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/regular file/i);
76
+ expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
77
+ expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
78
+ });
79
+ it.each(['openSync', 'fstatSync', 'readFileSync'])('fails closed on %s errors', (operation) => {
80
+ fs_1.default[operation].mockImplementation(() => { throw new Error('replaced or unreadable'); });
81
+ expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/SKILL\.md.*replaced or unreadable/);
82
+ if (operation === 'openSync')
83
+ expect(fs_1.default.closeSync).not.toHaveBeenCalled();
84
+ else
85
+ expect(fs_1.default.closeSync).toHaveBeenCalledWith(descriptor);
86
+ if (operation !== 'readFileSync')
87
+ expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
88
+ });
89
+ it.each([root, path_1.default.join(root, 'skills'), skillDir])('rejects an observable directory symlink at %s', (linked) => {
90
+ const lstat = fs_1.default.lstatSync.getMockImplementation();
91
+ fs_1.default.lstatSync.mockImplementation((file, options) => file === linked
92
+ ? { ...directory, isDirectory: () => false, isSymbolicLink: () => true }
93
+ : lstat(file, options));
94
+ expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/symbolic link/i);
95
+ expect(fs_1.default.openSync).not.toHaveBeenCalled();
96
+ expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
97
+ });
98
+ it.each(['dev', 'ino', 'size'])('fails closed when inspected %s is unobservable', (field) => {
99
+ const unknown = { ...inspected, [field]: undefined };
100
+ const lstat = fs_1.default.lstatSync.getMockImplementation();
101
+ fs_1.default.lstatSync.mockImplementation((file, options) => file === skillFile ? unknown : lstat(file, options));
102
+ fs_1.default.fstatSync.mockReturnValue(unknown);
103
+ expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/identity|size/i);
104
+ expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
105
+ });
106
+ it.each(['dev', 'ino'])('fails closed when parent %s is unobservable', (field) => {
107
+ const lstat = fs_1.default.lstatSync.getMockImplementation();
108
+ fs_1.default.lstatSync.mockImplementation((file, options) => file === skillDir
109
+ ? { ...directory, [field]: undefined }
110
+ : lstat(file, options));
111
+ expect(() => (0, discovery_1.discoverSkills)([root])).toThrow(/directory identity/i);
112
+ expect(fs_1.default.openSync).not.toHaveBeenCalled();
113
+ expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
114
+ });
115
+ });
@@ -15,16 +15,30 @@ const WORKFLOWS_ROOT = CONTENT_ROOT;
15
15
  // For backward-compat with mock expectations that check SKILLS_DIR-style paths:
16
16
  const SKILLS_DIR = path_1.default.join(CONTENT_ROOT, 'skills');
17
17
  const WORKFLOWS_DIR = path_1.default.join(CONTENT_ROOT, 'workflows');
18
+ const directoryStat = { dev: 1n, ino: 3n, isDirectory: () => true, isSymbolicLink: () => false };
19
+ const skillStat = { dev: 1n, ino: 2n, size: 0n, isFile: () => true, isSymbolicLink: () => false };
18
20
  describe('Artifact Discovery', () => {
19
21
  beforeEach(() => {
20
22
  jest.clearAllMocks();
21
- fs_1.default.lstatSync.mockImplementation(() => {
23
+ fs_1.default.lstatSync.mockImplementation((p) => {
24
+ if (!p.endsWith('.md') && !p.endsWith('.json'))
25
+ return directoryStat;
22
26
  const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
23
27
  throw error;
24
28
  });
29
+ fs_1.default.openSync.mockReturnValue(42);
30
+ fs_1.default.fstatSync.mockReturnValue(skillStat);
31
+ fs_1.default.readFileSync.mockReturnValue('');
25
32
  });
26
33
  describe('discoverSkills', () => {
27
34
  it('should return a list of skill directories that contain a SKILL.md', () => {
35
+ fs_1.default.lstatSync.mockImplementation((p) => {
36
+ if (p.endsWith('SKILL.md'))
37
+ return skillStat;
38
+ if (!p.endsWith('.json'))
39
+ return directoryStat;
40
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
41
+ });
28
42
  fs_1.default.existsSync.mockImplementation((p) => {
29
43
  if (p === SKILLS_DIR)
30
44
  return true;
@@ -48,6 +62,19 @@ describe('Artifact Discovery', () => {
48
62
  const skills = (0, discovery_1.discoverSkills)([SKILLS_ROOT]);
49
63
  expect(skills).toEqual([]);
50
64
  });
65
+ it.each(['fifo', 'symlink'])('rejects a %s SKILL.md before attempting any read', (kind) => {
66
+ fs_1.default.existsSync.mockReturnValue(true);
67
+ fs_1.default.readdirSync.mockReturnValue([{ name: 'unsafe', isDirectory: () => true }]);
68
+ fs_1.default.lstatSync.mockImplementation((p) => {
69
+ if (p.endsWith('SKILL.md'))
70
+ return { isFile: () => false, isSymbolicLink: () => kind === 'symlink' };
71
+ if (!p.endsWith('.json'))
72
+ return directoryStat;
73
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
74
+ });
75
+ expect(() => (0, discovery_1.discoverSkills)([SKILLS_ROOT])).toThrow(/regular file|symbolic link/);
76
+ expect(fs_1.default.readFileSync).not.toHaveBeenCalled();
77
+ });
51
78
  it('should skip skill directories without a SKILL.md', () => {
52
79
  fs_1.default.existsSync.mockImplementation((p) => {
53
80
  if (p === SKILLS_DIR)
@@ -135,7 +135,9 @@ describe('runInitSteps — orchestrator', () => {
135
135
  const deps = buildDeps(root);
136
136
  const { runInitSteps } = require('../../../src/core/init/orchestrator');
137
137
  const out = await runInitSteps(deps);
138
- expect(out.steps.some((s) => s.id === 'project.sensors' && s.action === 'applied')).toBe(true);
138
+ // The sensor step is always represented; whether this fixture's pack is
139
+ // portable is orthogonal to activation/constitution orchestration.
140
+ expect(out.steps.some((s) => s.id === 'project.sensors')).toBe(true);
139
141
  expect(out.steps.find((s) => s.id === 'project.constitution').action).toBe('pending');
140
142
  expect(out.steps.find((s) => s.id === 'project.context').action).toBe('pending');
141
143
  });
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
+ const command_result_1 = require("../../src/core/command-result");
9
10
  const orchestrators_1 = require("../../src/core/orchestrators");
10
11
  const registries_1 = require("../../src/core/registries");
11
12
  function registryWith(manifest) {
@@ -16,6 +17,7 @@ function registryWith(manifest) {
16
17
  describe('readDeclaredOrchestrators', () => {
17
18
  const created = [];
18
19
  afterEach(() => {
20
+ jest.restoreAllMocks();
19
21
  for (const r of created.splice(0))
20
22
  fs_1.default.rmSync(r, { recursive: true, force: true });
21
23
  });
@@ -176,6 +178,62 @@ describe('readDeclaredOrchestrators', () => {
176
178
  expect(diagnostics[0].split('\n')).toHaveLength(1);
177
179
  expect(diagnostics[0]).toContain(JSON.stringify('evil\nfake-warning: injected line'));
178
180
  });
181
+ describe('single-line diagnostic boundary', () => {
182
+ const hostile = 'before\u2028\u2029\u0085\r\n\t'
183
+ + String.fromCharCode(...Array.from({ length: 32 }, (_, i) => i))
184
+ + String.fromCharCode(...Array.from({ length: 33 }, (_, i) => 127 + i)) + 'after';
185
+ it('sanitizes unknown metadata keys without accepting the declaration', () => {
186
+ const root = registryWith({
187
+ orchestrator: { name: 'x', appliesWhen: 'y', terminatesTo: 'none', [hostile]: true },
188
+ });
189
+ created.push(root);
190
+ const result = (0, orchestrators_1.readDeclaredOrchestrators)(root);
191
+ expect(result.orchestrators).toEqual([]);
192
+ expect(result.diagnostics).toHaveLength(1);
193
+ expect(result.diagnostics[0]).toContain('unknown field');
194
+ expect(result.diagnostics[0]).toContain('before');
195
+ expect(result.diagnostics[0]).toContain('after');
196
+ const stderrBoundary = jest.fn(command_result_1.diagnosticsToStderr);
197
+ stderrBoundary(result.diagnostics);
198
+ expect(stderrBoundary.mock.calls[0][0].join('')).not.toMatch(/[\u2028\u0085]/);
199
+ expect(stderrBoundary.mock.results[0].value).not.toMatch(/[\u2028\u0085]/);
200
+ // eslint-disable-next-line no-control-regex -- reject all terminal controls and line separators
201
+ expect(result.diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
202
+ });
203
+ it.each(['inspection', 'read', 'parse', 'shape', 'fields'])('sanitizes %s diagnostics, including paths and error text', (branch) => {
204
+ // Mock hostile paths instead of creating filenames that are invalid on Windows.
205
+ const root = path_1.default.join(os_1.default.tmpdir(), hostile);
206
+ const error = new Error(hostile);
207
+ jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(() => {
208
+ if (branch === 'inspection')
209
+ throw error;
210
+ return { isFile: () => true, isSymbolicLink: () => false };
211
+ });
212
+ jest.spyOn(fs_1.default, 'readFileSync').mockImplementation(() => {
213
+ if (branch === 'read')
214
+ throw error;
215
+ if (branch === 'parse')
216
+ return `{ ${hostile}`;
217
+ return JSON.stringify({ orchestrator: branch === 'shape' ? [] : {} });
218
+ });
219
+ if (branch === 'parse')
220
+ jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { throw new SyntaxError(hostile); });
221
+ const result = (0, orchestrators_1.readDeclaredOrchestrators)(root);
222
+ expect(result.orchestrators).toEqual([]);
223
+ expect(result.diagnostics).toHaveLength(1);
224
+ expect(result.diagnostics[0]).toContain('awm-registry.json');
225
+ expect(result.diagnostics[0]).toContain('before');
226
+ expect(result.diagnostics[0]).toContain('after');
227
+ // eslint-disable-next-line no-control-regex -- reject all terminal controls and line separators
228
+ expect(result.diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
229
+ });
230
+ it('preserves valid raw fields and structural parsing without skill discovery', () => {
231
+ const declaration = { name: hostile, appliesWhen: hostile, terminatesTo: hostile };
232
+ const root = registryWith({ orchestrator: declaration });
233
+ created.push(root);
234
+ expect((0, orchestrators_1.readDeclaredOrchestrators)(root)).toEqual({ orchestrators: [declaration], diagnostics: [] });
235
+ });
236
+ });
179
237
  });
180
238
  describe('collectDeclaredOrchestrators', () => {
181
239
  // Isolates AWM_HOME per CLAUDE.md's testing rule ("ningun test puede tocar el ~/.awm
@@ -199,6 +257,11 @@ describe('collectDeclaredOrchestrators', () => {
199
257
  fs_1.default.mkdirSync(root, { recursive: true });
200
258
  fs_1.default.writeFileSync(path_1.default.join(root, 'awm-registry.json'), JSON.stringify(manifest));
201
259
  }
260
+ function writeSkill(registryName, skillName) {
261
+ const skillDir = path_1.default.join((0, registries_1.registryContentRoot)(registryName), 'skills', skillName);
262
+ fs_1.default.mkdirSync(skillDir, { recursive: true });
263
+ fs_1.default.writeFileSync(path_1.default.join(skillDir, 'SKILL.md'), `---\nname: ${skillName}\n---\n\n# ${skillName}\n`);
264
+ }
202
265
  it('dedupea por nombre entre dos registries, conservando el primero por orden de registries.json', () => {
203
266
  (0, registries_1.writeRegistriesConfig)([
204
267
  { name: 'first', remote: 'unused' },
@@ -210,6 +273,7 @@ describe('collectDeclaredOrchestrators', () => {
210
273
  writeManifest('second', {
211
274
  orchestrator: { name: 'shared', appliesWhen: 'segundo', terminatesTo: 'b' },
212
275
  });
276
+ writeSkill('first', 'shared');
213
277
  const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
214
278
  expect(declared).toHaveLength(1);
215
279
  expect(declared[0]).toEqual({ name: 'shared', appliesWhen: 'primero', terminatesTo: 'a' });
@@ -217,23 +281,60 @@ describe('collectDeclaredOrchestrators', () => {
217
281
  expect(diagnostics[0]).toMatch(/shared/);
218
282
  expect(diagnostics[0]).toMatch(/duplicate|shadow/i);
219
283
  });
220
- it('sanea el nombre antes de interpolarlo en el diagnostico de duplicado', () => {
221
- (0, registries_1.writeRegistriesConfig)([
222
- { name: 'first', remote: 'unused' },
223
- { name: 'second', remote: 'unused' },
224
- ]);
225
- const nombreHostil = 'shared\x1b[31m';
284
+ it('keeps missing-skill diagnostics single-line with hostile metadata and portable paths', () => {
285
+ (0, registries_1.writeRegistriesConfig)([{ name: 'first', remote: 'unused' }]);
286
+ const nombreHostil = 'shared\r\nforged\t\x1b[31m\x00\x7f\x85\x9b\u2028\u2029';
226
287
  writeManifest('first', {
227
288
  orchestrator: { name: nombreHostil, appliesWhen: 'primero', terminatesTo: 'a' },
228
289
  });
229
- writeManifest('second', {
230
- orchestrator: { name: nombreHostil, appliesWhen: 'segundo', terminatesTo: 'b' },
231
- });
290
+ writeSkill('first', 'real-skill');
232
291
  const { diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
233
292
  expect(diagnostics).toHaveLength(1);
234
- // eslint-disable-next-line no-control-regex -- verificamos la ausencia deliberada de C0
235
- expect(diagnostics[0]).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/);
236
- expect(diagnostics[0]).toContain('shared[31m');
293
+ // eslint-disable-next-line no-control-regex -- diagnostic must contain no terminal controls or line separators
294
+ expect(diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
295
+ expect(diagnostics[0]).toContain('shared');
296
+ expect(diagnostics[0]).toContain('forged');
297
+ expect(diagnostics[0]).toContain('declaration dropped');
298
+ });
299
+ it('keeps discovery diagnostics single-line for hostile override metadata', () => {
300
+ (0, registries_1.writeRegistriesConfig)([{ name: 'hostile', remote: 'unused' }]);
301
+ writeManifest('hostile', { overrides: ['../forged\r\nwarning:\t\x1b[31m\x85\u2028'] });
302
+ writeSkill('hostile', 'real-skill');
303
+ const { diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
304
+ expect(diagnostics).toHaveLength(1);
305
+ expect(diagnostics[0]).toContain('discovery unavailable');
306
+ expect(diagnostics[0]).toContain('forged');
307
+ // eslint-disable-next-line no-control-regex -- diagnostic must contain no terminal controls or line separators
308
+ expect(diagnostics[0]).not.toMatch(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/);
309
+ });
310
+ it('excludes a later colliding root without losing earlier or subsequent healthy roots', () => {
311
+ (0, registries_1.writeRegistriesConfig)(['first', 'collision', 'last'].map((name) => ({ name, remote: 'unused' })));
312
+ for (const name of ['first', 'collision', 'last']) {
313
+ writeManifest(name, { orchestrator: { name: `${name}-process`, appliesWhen: 'x', terminatesTo: 'none' } });
314
+ writeSkill(name, `${name}-process`);
315
+ }
316
+ writeSkill('first', 'shared');
317
+ writeSkill('collision', 'shared');
318
+ // This name belongs only to the excluded root and must not poison later admission.
319
+ writeSkill('collision', 'last-process');
320
+ const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
321
+ expect(declared.map((d) => d.name)).toEqual(['first-process', 'last-process']);
322
+ expect(diagnostics).toHaveLength(2);
323
+ expect(diagnostics[0]).toMatch(/collision.*discovery unavailable.*Artifact name collision/);
324
+ expect(diagnostics[1]).toMatch(/collision-process.*not discoverable/);
325
+ });
326
+ it('admits a later root with a declared override', () => {
327
+ (0, registries_1.writeRegistriesConfig)(['first', 'override'].map((name) => ({ name, remote: 'unused' })));
328
+ writeSkill('first', 'shared');
329
+ writeSkill('override', 'shared');
330
+ writeSkill('override', 'override-process');
331
+ writeManifest('override', {
332
+ overrides: ['shared'],
333
+ orchestrator: { name: 'override-process', appliesWhen: 'x', terminatesTo: 'none' },
334
+ });
335
+ const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
336
+ expect(declared.map((d) => d.name)).toEqual(['override-process']);
337
+ expect(diagnostics).toEqual([]);
237
338
  });
238
339
  it('emite un diagnostico de colision post-saneo entre nombres crudos distintos, sin descartar ninguno', () => {
239
340
  (0, registries_1.writeRegistriesConfig)([
@@ -244,12 +345,14 @@ describe('collectDeclaredOrchestrators', () => {
244
345
  orchestrator: { name: 'foo_bar', appliesWhen: 'x', terminatesTo: 'a' },
245
346
  });
246
347
  writeManifest('second', {
247
- orchestrator: { name: 'foo*bar', appliesWhen: 'y', terminatesTo: 'b' },
348
+ orchestrator: { name: 'foobar', appliesWhen: 'y', terminatesTo: 'b' },
248
349
  });
350
+ writeSkill('first', 'foo_bar');
351
+ writeSkill('second', 'foobar');
249
352
  const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
250
353
  // Ambas declaraciones son genuinamente distintas (nombre crudo distinto) — ninguna se descarta.
251
354
  expect(declared).toHaveLength(2);
252
- expect(declared.map((d) => d.name).sort()).toEqual(['foo*bar', 'foo_bar']);
355
+ expect(declared.map((d) => d.name).sort()).toEqual(['foo_bar', 'foobar']);
253
356
  // Pero se advierte de la colision post-saneo (ambas renderizan como "foobar").
254
357
  expect(diagnostics).toHaveLength(1);
255
358
  expect(diagnostics[0]).toMatch(/foobar/);
@@ -266,9 +369,63 @@ describe('collectDeclaredOrchestrators', () => {
266
369
  writeManifest('second', {
267
370
  orchestrator: { name: 'dos', appliesWhen: 'y', terminatesTo: 'b' },
268
371
  });
372
+ writeSkill('first', 'uno');
373
+ writeSkill('second', 'dos');
269
374
  const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
270
375
  expect(declared).toHaveLength(2);
271
376
  expect(declared.map((d) => d.name).sort()).toEqual(['dos', 'uno']);
272
377
  expect(diagnostics).toEqual([]);
273
378
  });
379
+ it('omite una declaracion cuyo skill no existe y la diagnostica', () => {
380
+ (0, registries_1.writeRegistriesConfig)([{ name: 'phantom', remote: 'unused' }]);
381
+ writeManifest('phantom', {
382
+ orchestrator: { name: 'phantom-process', appliesWhen: 'x', terminatesTo: 'none' },
383
+ });
384
+ writeSkill('phantom', 'real-skill');
385
+ const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
386
+ expect(declared).toEqual([]);
387
+ expect(diagnostics).toHaveLength(1);
388
+ expect(diagnostics[0]).toMatch(/phantom-process/);
389
+ expect(diagnostics[0]).toMatch(/declaration dropped|skill/i);
390
+ });
391
+ it('resuelve una declaracion contra un skill de otro registry configurado', () => {
392
+ (0, registries_1.writeRegistriesConfig)([
393
+ { name: 'declarations', remote: 'unused' },
394
+ { name: 'skills', remote: 'unused' },
395
+ ]);
396
+ writeManifest('declarations', {
397
+ orchestrator: { name: 'cross-registry', appliesWhen: 'x', terminatesTo: 'none' },
398
+ });
399
+ writeSkill('skills', 'cross-registry');
400
+ const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
401
+ expect(declared).toEqual([{ name: 'cross-registry', appliesWhen: 'x', terminatesTo: 'none' }]);
402
+ expect(diagnostics).toEqual([]);
403
+ });
404
+ it('diagnostica un root cuyo discovery falla y conserva una declaracion sana', () => {
405
+ (0, registries_1.writeRegistriesConfig)([
406
+ { name: 'broken', remote: 'unused' },
407
+ { name: 'healthy', remote: 'unused' },
408
+ ]);
409
+ writeManifest('broken', {
410
+ orchestrator: { name: 'broken-process', appliesWhen: 'x', terminatesTo: 'none' },
411
+ });
412
+ fs_1.default.writeFileSync(path_1.default.join((0, registries_1.registryContentRoot)('broken'), 'skills'), 'not a directory');
413
+ writeManifest('healthy', {
414
+ orchestrator: { name: 'healthy-process', appliesWhen: 'x', terminatesTo: 'none' },
415
+ });
416
+ writeSkill('healthy', 'healthy-process');
417
+ const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
418
+ expect(declared).toEqual([{ name: 'healthy-process', appliesWhen: 'x', terminatesTo: 'none' }]);
419
+ expect(diagnostics.join('\n')).toMatch(/broken.*discovery unavailable/i);
420
+ });
421
+ it('conserva terminatesTo aunque no corresponda a un skill descubierto', () => {
422
+ (0, registries_1.writeRegistriesConfig)([{ name: 'registry', remote: 'unused' }]);
423
+ writeManifest('registry', {
424
+ orchestrator: { name: 'entry-process', appliesWhen: 'x', terminatesTo: 'missing-successor' },
425
+ });
426
+ writeSkill('registry', 'entry-process');
427
+ const { declared, diagnostics } = (0, orchestrators_1.collectDeclaredOrchestrators)();
428
+ expect(declared).toEqual([{ name: 'entry-process', appliesWhen: 'x', terminatesTo: 'missing-successor' }]);
429
+ expect(diagnostics).toEqual([]);
430
+ });
274
431
  });