agentic-workflow-manager 6.5.2 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/scripts/sensor-support-matrix.js +103 -0
  2. package/dist/scripts/support-matrix.js +40 -17
  3. package/dist/src/commands/init.js +1 -3
  4. package/dist/src/commands/ledger/index.js +6 -0
  5. package/dist/src/commands/preflight/checks.js +4 -4
  6. package/dist/src/commands/preflight/index.js +2 -2
  7. package/dist/src/commands/registry/add.js +2 -2
  8. package/dist/src/commands/registry/index.js +5 -0
  9. package/dist/src/commands/registry/status.js +7 -0
  10. package/dist/src/commands/sensors/compatibility/contract.js +261 -0
  11. package/dist/src/commands/sensors/compatibility/discovery.js +207 -0
  12. package/dist/src/commands/sensors/compatibility/live.js +59 -0
  13. package/dist/src/commands/sensors/compatibility/manifest.js +197 -0
  14. package/dist/src/commands/sensors/compatibility/materialize.js +126 -0
  15. package/dist/src/commands/sensors/compatibility/pack-source.js +78 -0
  16. package/dist/src/commands/sensors/compatibility/probe.js +47 -0
  17. package/dist/src/commands/sensors/compatibility/resolve.js +96 -0
  18. package/dist/src/commands/sensors/compatibility/types.js +2 -0
  19. package/dist/src/commands/sensors/coverage/contract.js +0 -50
  20. package/dist/src/commands/sensors/coverage/empirical.js +131 -0
  21. package/dist/src/commands/sensors/coverage/evaluate.js +85 -7
  22. package/dist/src/commands/sensors/coverage/evidence.js +5 -1
  23. package/dist/src/commands/sensors/coverage/index.js +44 -7
  24. package/dist/src/commands/sensors/coverage/render.js +214 -14
  25. package/dist/src/commands/sensors/coverage/resolve.js +24 -38
  26. package/dist/src/commands/sensors/exec.js +150 -9
  27. package/dist/src/commands/sensors/index.js +18 -6
  28. package/dist/src/commands/sensors/init.js +92 -6
  29. package/dist/src/commands/sensors/run.js +71 -0
  30. package/dist/src/commands/sensors/status.js +48 -4
  31. package/dist/src/commands/sync.js +7 -0
  32. package/dist/src/core/bundles.js +38 -3
  33. package/dist/src/core/init/steps.js +7 -4
  34. package/dist/src/core/ledger/scan.js +228 -0
  35. package/dist/src/core/ledger/store.js +89 -25
  36. package/dist/src/core/ledger/types.js +54 -0
  37. package/dist/src/core/paths.js +10 -9
  38. package/dist/src/core/registries.js +134 -14
  39. package/dist/src/index.js +24 -1
  40. package/dist/tests/commands/ledger/index.test.js +11 -0
  41. package/dist/tests/commands/preflight/preflight.test.js +64 -64
  42. package/dist/tests/commands/registry/add.test.js +69 -2
  43. package/dist/tests/commands/registry/status.test.js +14 -0
  44. package/dist/tests/commands/sensors/compatibility/contract.test.js +98 -0
  45. package/dist/tests/commands/sensors/compatibility/discovery.test.js +119 -0
  46. package/dist/tests/commands/sensors/compatibility/manifest.test.js +68 -0
  47. package/dist/tests/commands/sensors/compatibility/materialize.test.js +53 -0
  48. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +55 -0
  49. package/dist/tests/commands/sensors/compatibility/probe.test.js +27 -0
  50. package/dist/tests/commands/sensors/compatibility/resolve.test.js +75 -0
  51. package/dist/tests/commands/sensors/coverage/contract.test.js +8 -22
  52. package/dist/tests/commands/sensors/coverage/empirical.test.js +111 -0
  53. package/dist/tests/commands/sensors/coverage/evaluate.test.js +29 -0
  54. package/dist/tests/commands/sensors/coverage/evidence.test.js +9 -0
  55. package/dist/tests/commands/sensors/coverage/index.test.js +40 -13
  56. package/dist/tests/commands/sensors/coverage/render.test.js +187 -12
  57. package/dist/tests/commands/sensors/coverage/resolve.test.js +25 -4
  58. package/dist/tests/commands/sensors/exec-windows.test.js +92 -2
  59. package/dist/tests/commands/sensors/exec.test.js +67 -5
  60. package/dist/tests/commands/sensors/index.test.js +8 -0
  61. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +12 -12
  62. package/dist/tests/commands/sensors/init.test.js +132 -48
  63. package/dist/tests/commands/sensors/run-inconclusive.test.js +9 -2
  64. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  65. package/dist/tests/commands/sensors/run.test.js +66 -3
  66. package/dist/tests/commands/sensors/status-windows.test.js +7 -7
  67. package/dist/tests/commands/sensors/status.test.js +74 -23
  68. package/dist/tests/commands/update.test.js +1 -1
  69. package/dist/tests/core/bundles.test.js +37 -0
  70. package/dist/tests/core/discovery.test.js +4 -0
  71. package/dist/tests/core/init/orchestrator.test.js +5 -1
  72. package/dist/tests/core/init/steps.test.js +20 -4
  73. package/dist/tests/core/ledger/scan.test.js +217 -0
  74. package/dist/tests/core/ledger/store.test.js +108 -0
  75. package/dist/tests/core/profile-pins.test.js +2 -1
  76. package/dist/tests/core/project-skill-links.test.js +22 -0
  77. package/dist/tests/core/registries-capability.test.js +10 -0
  78. package/dist/tests/core/registries-sync.test.js +16 -0
  79. package/dist/tests/core/registries.test.js +129 -1
  80. package/dist/tests/core/registry-manifest.test.js +7 -0
  81. package/dist/tests/core/same-existing-path.test.js +35 -0
  82. package/dist/tests/core/sync-gates.test.js +2 -1
  83. package/dist/tests/integration/copilot-init-isolated.test.js +18 -0
  84. package/dist/tests/integration/sensor-compatibility.e2e.test.js +141 -0
  85. package/dist/tests/integration/sensor-coverage.e2e.test.js +35 -6
  86. package/dist/tests/integration/sensor-structured-run.e2e.test.js +127 -0
  87. package/dist/tests/structural/active-documentation.test.js +128 -0
  88. package/dist/tests/structural/async-entrypoint.test.js +15 -0
  89. package/dist/tests/structural/jest-environment-is-isolated.test.js +44 -0
  90. package/dist/tests/structural/r3-cli-major-version.test.js +37 -0
  91. package/dist/tests/structural/sensor-documentation-contract.test.js +77 -0
  92. package/dist/tests/structural/support-matrix-is-current.test.js +69 -2
  93. package/package.json +8 -2
@@ -7,6 +7,7 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const store_1 = require("../../../src/core/ledger/store");
10
+ const types_1 = require("../../../src/core/ledger/types");
10
11
  function mkTmp() {
11
12
  return fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-ledger-'));
12
13
  }
@@ -32,6 +33,14 @@ describe('ledger store — add/list', () => {
32
33
  test('ledgerPath sanitizes branch slashes into the filename', () => {
33
34
  expect((0, store_1.ledgerPath)(cwd, 'feature/foo')).toBe(path_1.default.join(cwd, '.awm', 'ledger', 'feature__foo.jsonl'));
34
35
  });
36
+ test.each(['..\\outside', 'feature\\..\\outside'])('rejects a Windows path-traversal branch %p before it can escape the ledger directory', (branch) => {
37
+ // On Windows, a backslash is a separator even when this test suite runs on POSIX.
38
+ // Keep the regression explicit so future path changes cannot reintroduce the escape.
39
+ expect(branch.split(path_1.default.win32.sep)).toContain('..');
40
+ expect(() => (0, store_1.ledgerPath)(cwd, branch)).toThrow(/invalid ledger branch/i);
41
+ expect(() => (0, store_1.addEntry)(cwd, entry({ branch }))).toThrow(/invalid ledger branch/i);
42
+ expect(fs_1.default.existsSync(path_1.default.join(cwd, '.awm', 'ledger'))).toBe(false);
43
+ });
35
44
  test('addEntry creates .awm/ledger/ and appends one jsonl line', () => {
36
45
  (0, store_1.addEntry)(cwd, entry());
37
46
  const raw = fs_1.default.readFileSync((0, store_1.ledgerPath)(cwd, 'feat-x'), 'utf-8');
@@ -43,6 +52,25 @@ describe('ledger store — add/list', () => {
43
52
  (0, store_1.addEntry)(cwd, entry({ signature: 'second', desc: 'another' }));
44
53
  expect((0, store_1.listEntries)(cwd, 'feat-x')).toHaveLength(2);
45
54
  });
55
+ test('persists an optional reusable defect class', () => {
56
+ (0, store_1.addEntry)(cwd, entry({ defectClass: 'lint-errors' }));
57
+ expect(JSON.parse(fs_1.default.readFileSync((0, store_1.ledgerPath)(cwd, 'feat-x'), 'utf-8'))).toMatchObject({ defectClass: 'lint-errors' });
58
+ });
59
+ test('rejects an invalid direct entry before it can cross the durable boundary', () => {
60
+ expect(() => (0, store_1.addEntry)(cwd, { ...entry(), defectClass: 'Bad_ID' })).toThrow(/invalid-defect-class/i);
61
+ expect(fs_1.default.existsSync((0, store_1.ledgerPath)(cwd, 'feat-x'))).toBe(false);
62
+ });
63
+ test('keeps a legacy entry without defectClass valid and unclassified', () => {
64
+ const parsed = (0, types_1.parseLedgerEntry)(entry(), 'line 1');
65
+ expect(parsed).toMatchObject({ ok: true, entry: { defectClass: undefined } });
66
+ });
67
+ test.each(['', 'Bad_ID', '../escape', 'a b', '-leading', 'trailing-'])('skips a persisted invalid defect class %p', (defectClass) => {
68
+ const parsed = (0, types_1.parseLedgerEntry)({ ...entry(), defectClass }, 'line 1');
69
+ expect(parsed).toMatchObject({ ok: false, reason: 'invalid-defect-class' });
70
+ });
71
+ test('rejects a non-string durable source instead of returning an unsafe parse result', () => {
72
+ expect(() => (0, types_1.parseLedgerEntry)(entry(), null)).toThrow(/source.*string/i);
73
+ });
46
74
  test('listEntries on a branch with no ledger returns []', () => {
47
75
  expect((0, store_1.listEntries)(cwd, 'never-touched')).toEqual([]);
48
76
  });
@@ -154,4 +182,84 @@ describe('ledger store — archive', () => {
154
182
  test('archiving a non-existent ledger is a no-op returning false', () => {
155
183
  expect((0, store_1.archiveLedger)(cwd, 'feat-x', '20260606T000000')).toBe(false);
156
184
  });
185
+ test('refuses to overwrite an existing archive and preserves both ledger files', () => {
186
+ (0, store_1.addEntry)(cwd, entry({ signature: 'active-evidence' }));
187
+ const archivePath = path_1.default.join(cwd, '.awm', 'ledger', 'archive', 'feat-x-20260606T000000.jsonl');
188
+ fs_1.default.mkdirSync(path_1.default.dirname(archivePath), { recursive: true });
189
+ fs_1.default.writeFileSync(archivePath, JSON.stringify(entry({ signature: 'archived-evidence' })) + '\n');
190
+ expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', '20260606T000000')).toThrow(/archive.*already exists/i);
191
+ expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ signature: 'active-evidence' })]);
192
+ expect(fs_1.default.readFileSync(archivePath, 'utf-8')).toContain('archived-evidence');
193
+ });
194
+ test('refuses a dangling symlink archive destination and preserves the active ledger', () => {
195
+ (0, store_1.addEntry)(cwd, entry({ signature: 'active-evidence' }));
196
+ const archivePath = path_1.default.join(cwd, '.awm', 'ledger', 'archive', 'feat-x-20260606T000000.jsonl');
197
+ fs_1.default.mkdirSync(path_1.default.dirname(archivePath), { recursive: true });
198
+ try {
199
+ fs_1.default.symlinkSync(path_1.default.join(cwd, 'missing-target.jsonl'), archivePath);
200
+ }
201
+ catch (error) {
202
+ if (process.platform === 'win32')
203
+ return;
204
+ throw error;
205
+ }
206
+ expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', '20260606T000000')).toThrow(/archive.*already exists/i);
207
+ expect(fs_1.default.lstatSync(archivePath).isSymbolicLink()).toBe(true);
208
+ expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ signature: 'active-evidence' })]);
209
+ });
210
+ test('rejects a symlinked archive directory without moving the active ledger outside the project', () => {
211
+ (0, store_1.addEntry)(cwd, entry({ signature: 'active-evidence' }));
212
+ const archive = path_1.default.join(cwd, '.awm', 'ledger', 'archive');
213
+ const outside = path_1.default.join(cwd, 'outside');
214
+ fs_1.default.mkdirSync(outside, { recursive: true });
215
+ try {
216
+ fs_1.default.symlinkSync(outside, archive, 'dir');
217
+ }
218
+ catch (error) {
219
+ if (error.code === 'EPERM')
220
+ return;
221
+ throw error;
222
+ }
223
+ expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', '20260606T000000')).toThrow(/unsafe|escape|symlink/i);
224
+ expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ signature: 'active-evidence' })]);
225
+ expect(fs_1.default.readdirSync(outside)).toEqual([]);
226
+ });
227
+ test('rejects a symlinked source ledger and preserves the link target', () => {
228
+ const outside = path_1.default.join(cwd, 'outside.jsonl');
229
+ fs_1.default.writeFileSync(outside, JSON.stringify(entry({ signature: 'outside-evidence' })) + '\n');
230
+ const source = (0, store_1.ledgerPath)(cwd, 'feat-x');
231
+ fs_1.default.mkdirSync(path_1.default.dirname(source), { recursive: true });
232
+ try {
233
+ fs_1.default.symlinkSync(outside, source);
234
+ }
235
+ catch (error) {
236
+ if (error.code === 'EPERM')
237
+ return;
238
+ throw error;
239
+ }
240
+ expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', '20260606T000000')).toThrow(/unsafe|escape|symlink/i);
241
+ expect(fs_1.default.lstatSync(source).isSymbolicLink()).toBe(true);
242
+ expect(fs_1.default.readFileSync(outside, 'utf-8')).toContain('outside-evidence');
243
+ expect(fs_1.default.existsSync(path_1.default.join(cwd, '.awm', 'ledger', 'archive', 'feat-x-20260606T000000.jsonl'))).toBe(false);
244
+ });
245
+ test.each(['', '.', '..', '../outside', '/tmp/outside', 'C:\\temp\\outside'])('rejects an unsafe archive label %p before touching a ledger', (label) => {
246
+ expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', label)).toThrow(/invalid ledger label/i);
247
+ expect(fs_1.default.existsSync(path_1.default.join(cwd, '.awm', 'ledger'))).toBe(false);
248
+ });
249
+ test.each(['safe/child', 'safe\\child'])('rejects an archive label with a separator %p before it can create ledger files', (label) => {
250
+ expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', label)).toThrow(/invalid ledger label/i);
251
+ expect(fs_1.default.existsSync(path_1.default.join(cwd, '.awm', 'ledger'))).toBe(false);
252
+ expect(fs_1.default.existsSync((0, store_1.ledgerPath)(cwd, 'feat-x'))).toBe(false);
253
+ });
254
+ test('rejects a Windows traversal archive label before it can create an escaped file', () => {
255
+ const label = '..\\outside';
256
+ const archiveRoot = path_1.default.win32.join('C:\\project', '.awm', 'ledger', 'archive');
257
+ const escaped = path_1.default.win32.resolve(archiveRoot, label);
258
+ (0, store_1.addEntry)(cwd, entry());
259
+ expect(escaped.startsWith(`${archiveRoot}\\`)).toBe(false);
260
+ expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', label)).toThrow(/invalid ledger label/i);
261
+ expect(fs_1.default.existsSync(path_1.default.join(cwd, '.awm', 'ledger', 'archive'))).toBe(false);
262
+ expect(fs_1.default.existsSync(path_1.default.join(cwd, 'outside.jsonl'))).toBe(false);
263
+ expect(fs_1.default.existsSync((0, store_1.ledgerPath)(cwd, 'feat-x'))).toBe(true);
264
+ });
157
265
  });
@@ -11,7 +11,8 @@ const child_process_1 = require("child_process");
11
11
  const GIT = (cwd, cmd) => (0, child_process_1.execSync)(`git -c user.email=t@t.t -c user.name=t -c tag.gpgSign=false ${cmd}`, { cwd, stdio: 'pipe' });
12
12
  function makeTaggedRepo(base, name, versions) {
13
13
  const dir = path_1.default.join(base, name);
14
- fs_1.default.mkdirSync(dir, { recursive: true });
14
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'skills'), { recursive: true });
15
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'skills', '.keep'), 'fixture');
15
16
  GIT(dir, 'init -q -b main');
16
17
  fs_1.default.writeFileSync(path_1.default.join(dir, 'VERSION'), 'init');
17
18
  GIT(dir, 'add -A');
@@ -156,4 +156,26 @@ describe('awm sync reaches the project-scope reconciliation', () => {
156
156
  expect(out.code).toBe(0);
157
157
  expect(spy).toHaveBeenCalledTimes(1);
158
158
  });
159
+ it('fails before project reconciliation when registry sync leaves unsafe content', async () => {
160
+ writeProfile(['demo']);
161
+ const registry = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
162
+ const outside = path_1.default.join(home, 'outside-skill');
163
+ fs_1.default.writeFileSync(outside, 'outside');
164
+ fs_1.default.mkdirSync(path_1.default.join(registry, 'skills'), { recursive: true });
165
+ fs_1.default.symlinkSync(outside, path_1.default.join(registry, 'skills', 'artifact'));
166
+ fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'r' }]));
167
+ const reconcile = jest.fn(() => []);
168
+ const syncProfile = jest.fn(() => ({ installed: [], skipped: [], extensions: [], transactionIds: [], modifiedFiles: [] }));
169
+ const { runSyncCore } = require('../../src/commands/sync');
170
+ const out = await runSyncCore({ cwd: projectRoot }, {
171
+ syncRegistries: async () => [{ name: 'baseline', action: 'error', error: 'unsafe layout' }],
172
+ verifyMinCliVersions: () => [],
173
+ verifyProjectPins: async () => [],
174
+ syncProfile,
175
+ reconcileProjectSkillLinks: reconcile,
176
+ });
177
+ expect(out.code).toBe(1);
178
+ expect(reconcile).not.toHaveBeenCalled();
179
+ expect(syncProfile).not.toHaveBeenCalled();
180
+ });
159
181
  });
@@ -49,4 +49,14 @@ describe('capabilityRoot', () => {
49
49
  m.writeRegistriesConfig([]);
50
50
  expect(m.capabilityRoot('hooks')).toBeNull();
51
51
  });
52
+ it('ignora una capability que no es un directorio y selecciona la primera regular', () => {
53
+ const m = require('../../src/core/registries');
54
+ const aRoot = path_1.default.join(tmpHome, '.awm', 'registries', 'a');
55
+ const bRoot = path_1.default.join(tmpHome, '.awm', 'registries', 'b');
56
+ fs_1.default.mkdirSync(aRoot, { recursive: true });
57
+ fs_1.default.writeFileSync(path_1.default.join(aRoot, 'hooks'), 'not a directory');
58
+ fs_1.default.mkdirSync(path_1.default.join(bRoot, 'hooks'), { recursive: true });
59
+ m.writeRegistriesConfig([{ name: 'a', remote: 'x' }, { name: 'b', remote: 'y' }]);
60
+ expect(m.capabilityRoot('hooks')).toBe(bRoot);
61
+ });
52
62
  });
@@ -101,6 +101,22 @@ describe('syncRegistries (git fixtures locales)', () => {
101
101
  const synced = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/registries/personal/skills/alpha/SKILL.md'), 'utf-8');
102
102
  expect(synced).toContain('v2');
103
103
  });
104
+ it('reports an unsafe symlink introduced by an update instead of a successful pull', async () => {
105
+ const m = require('../../src/core/registries');
106
+ const source = makeSourceRepo(tmpWork, 'alpha');
107
+ m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
108
+ await m.syncRegistries();
109
+ const outside = path_1.default.join(tmpWork, 'outside-skill.md');
110
+ fs_1.default.writeFileSync(outside, 'outside');
111
+ fs_1.default.rmSync(path_1.default.join(source, 'skills', 'alpha', 'SKILL.md'));
112
+ fs_1.default.symlinkSync(outside, path_1.default.join(source, 'skills', 'alpha', 'SKILL.md'));
113
+ GIT(source, 'add -A');
114
+ GIT(source, 'commit -qm unsafe-link');
115
+ const results = await m.syncRegistries();
116
+ expect(results[0].action).toBe('error');
117
+ expect(results[0].error).toMatch(/unsafe|layout/i);
118
+ expect(m.contentRoots()).toEqual([]);
119
+ });
104
120
  it('registry con tags queda en el último tag y reporta la versión', async () => {
105
121
  const m = require('../../src/core/registries');
106
122
  const source = makeSourceRepo(tmpWork, 'alpha');
@@ -61,6 +61,16 @@ describe('core/registries', () => {
61
61
  fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'registries.json'), JSON.stringify([{ name: '.', remote: 'r' }]));
62
62
  expect(() => m.readRegistriesConfig()).toThrow(/path traversal/);
63
63
  });
64
+ it('readRegistriesConfig rejects an empty name that would resolve to the registries parent', () => {
65
+ const m = load();
66
+ fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
67
+ fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'registries.json'), JSON.stringify([{ name: '', remote: 'r' }]));
68
+ expect(() => m.readRegistriesConfig()).toThrow(/path traversal/);
69
+ expect(() => m.registryContentRoot('')).toThrow(/Invalid registry name/);
70
+ });
71
+ it.each(['.', 'baseline/skills', 'baseline\\skills'])('registryContentRoot rejects unsafe registry component %s', (name) => {
72
+ expect(() => load().registryContentRoot(name)).toThrow(/Invalid registry name/);
73
+ });
64
74
  it('listRegistries derives contentRoot under ~/.awm/registries/<name>', () => {
65
75
  const m = load();
66
76
  m.writeRegistriesConfig([{ name: 'personal', remote: 'r' }]);
@@ -72,14 +82,82 @@ describe('core/registries', () => {
72
82
  const m = load();
73
83
  // 'present' existe en disco, 'ghost' no
74
84
  const present = path_1.default.join(tmpHome, '.awm', 'registries', 'present');
75
- fs_1.default.mkdirSync(present, { recursive: true });
85
+ fs_1.default.mkdirSync(path_1.default.join(present, 'skills'), { recursive: true });
76
86
  m.writeRegistriesConfig([{ name: 'present', remote: 'r1' }, { name: 'ghost', remote: 'r2' }]);
77
87
  expect(m.contentRoots()).toEqual([present]);
78
88
  });
89
+ it('contentRoots preserves an empty configured registry for later synchronization', () => {
90
+ const m = load();
91
+ const empty = path_1.default.join(tmpHome, '.awm', 'registries', 'empty');
92
+ fs_1.default.mkdirSync(empty, { recursive: true });
93
+ m.writeRegistriesConfig([{ name: 'empty', remote: 'r1' }]);
94
+ expect(m.contentRoots()).toEqual([empty]);
95
+ });
96
+ it('contentRoots omits a configured registry with nested artifact symlinks', () => {
97
+ const m = load();
98
+ const present = path_1.default.join(tmpHome, '.awm', 'registries', 'present');
99
+ const outside = path_1.default.join(tmpHome, 'outside-skill.md');
100
+ fs_1.default.writeFileSync(outside, 'outside');
101
+ fs_1.default.mkdirSync(path_1.default.join(present, 'skills', 'linked'), { recursive: true });
102
+ fs_1.default.symlinkSync(outside, path_1.default.join(present, 'skills', 'linked', 'SKILL.md'));
103
+ m.writeRegistriesConfig([{ name: 'present', remote: 'r1' }]);
104
+ expect(m.contentRoots()).toEqual([]);
105
+ });
106
+ it('contentRoots omits a configured registry root that is itself a symlink', () => {
107
+ const m = load();
108
+ const outside = path_1.default.join(tmpHome, 'outside-registry');
109
+ const linked = path_1.default.join(tmpHome, '.awm', 'registries', 'linked');
110
+ fs_1.default.mkdirSync(path_1.default.join(outside, 'skills'), { recursive: true });
111
+ fs_1.default.mkdirSync(path_1.default.dirname(linked), { recursive: true });
112
+ fs_1.default.symlinkSync(outside, linked, 'dir');
113
+ m.writeRegistriesConfig([{ name: 'linked', remote: 'r1' }]);
114
+ expect(m.contentRoots()).toEqual([]);
115
+ });
116
+ it('contentRoots omits a registry with symlinked root metadata', () => {
117
+ const m = load();
118
+ for (const metadata of ['awm-registry.json', 'catalog.json']) {
119
+ const root = path_1.default.join(tmpHome, '.awm', 'registries', metadata);
120
+ const outside = path_1.default.join(tmpHome, `outside-${metadata}`);
121
+ fs_1.default.writeFileSync(outside, '{}');
122
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills'), { recursive: true });
123
+ fs_1.default.symlinkSync(outside, path_1.default.join(root, metadata));
124
+ m.writeRegistriesConfig([{ name: metadata, remote: 'r1' }]);
125
+ expect(m.contentRoots()).toEqual([]);
126
+ fs_1.default.rmSync(path_1.default.join(tmpHome, '.awm', 'registries', metadata), { recursive: true, force: true });
127
+ }
128
+ });
79
129
  it('contentRoots returns [] when no registries are configured', () => {
80
130
  const m = load();
81
131
  expect(m.contentRoots()).toEqual([]);
82
132
  });
133
+ it('treats an errored registry with an unsafe existing layout as unusable', () => {
134
+ const m = load();
135
+ const root = path_1.default.join(tmpHome, '.awm', 'registries', 'baseline');
136
+ const outside = path_1.default.join(tmpHome, 'outside');
137
+ fs_1.default.writeFileSync(outside, 'outside');
138
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills'), { recursive: true });
139
+ fs_1.default.symlinkSync(outside, path_1.default.join(root, 'skills', 'artifact'));
140
+ m.writeRegistriesConfig([{ name: 'baseline', remote: 'r' }]);
141
+ const results = [{ name: 'baseline', action: 'error', error: 'unsafe layout' }];
142
+ expect(m.unusableSyncedRegistries(results)).toEqual([{ name: 'baseline', error: 'unsafe layout' }]);
143
+ expect(() => m.assertSyncedRegistriesUsable(results)).toThrow(/registry sync failed/);
144
+ });
145
+ it('treats an errored registry with an empty existing root as unusable', () => {
146
+ const m = load();
147
+ const root = path_1.default.join(tmpHome, '.awm', 'registries', 'baseline');
148
+ fs_1.default.mkdirSync(root, { recursive: true });
149
+ m.writeRegistriesConfig([{ name: 'baseline', remote: 'r' }]);
150
+ const results = [{ name: 'baseline', action: 'error', error: 'network unreachable' }];
151
+ expect(m.unusableSyncedRegistries(results)).toEqual([{ name: 'baseline', error: 'network unreachable' }]);
152
+ expect(() => m.assertSyncedRegistriesUsable(results)).toThrow(/registry sync failed/);
153
+ });
154
+ it('requires synchronization before discovery when a configured root is empty', () => {
155
+ const m = load();
156
+ const root = path_1.default.join(tmpHome, '.awm', 'registries', 'baseline');
157
+ fs_1.default.mkdirSync(root, { recursive: true });
158
+ m.writeRegistriesConfig([{ name: 'baseline', remote: 'r' }]);
159
+ expect(m.registriesNeedSync()).toBe(true);
160
+ });
83
161
  it('validateRegistryLayout requires at least one content dir at the root', () => {
84
162
  const m = load();
85
163
  const root = path_1.default.join(tmpHome, 'somerepo');
@@ -88,4 +166,54 @@ describe('core/registries', () => {
88
166
  fs_1.default.mkdirSync(path_1.default.join(root, 'skills'));
89
167
  expect(m.validateRegistryLayout(root)).toBe(true);
90
168
  });
169
+ it('validateRegistryLayout accepts a regular capability-only registry', () => {
170
+ const m = load();
171
+ for (const directory of ['hooks', 'sensor-packs']) {
172
+ const root = path_1.default.join(tmpHome, `repo-${directory}`);
173
+ fs_1.default.mkdirSync(path_1.default.join(root, directory), { recursive: true });
174
+ expect(m.validateRegistryLayout(root)).toBe(true);
175
+ }
176
+ });
177
+ it('validateRegistryLayout fails closed when a content entry cannot be inspected', () => {
178
+ const m = load();
179
+ const root = path_1.default.join(tmpHome, 'somerepo');
180
+ const inaccessible = path_1.default.join(root, 'bundles');
181
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills'), { recursive: true });
182
+ const original = fs_1.default.lstatSync;
183
+ const lstat = jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((candidate) => {
184
+ if (candidate === inaccessible) {
185
+ const error = Object.assign(new Error('permission denied'), { code: 'EACCES' });
186
+ throw error;
187
+ }
188
+ return original(candidate);
189
+ }));
190
+ try {
191
+ expect(m.validateRegistryLayout(root)).toBe(false);
192
+ }
193
+ finally {
194
+ lstat.mockRestore();
195
+ }
196
+ });
197
+ it('validateRegistryLayout rejects nested symlinks in every managed registry directory', () => {
198
+ const m = load();
199
+ for (const directory of m.REGISTRY_DIR_NAMES) {
200
+ const root = path_1.default.join(tmpHome, `repo-${directory}`);
201
+ const outside = path_1.default.join(tmpHome, `outside-${directory}`);
202
+ fs_1.default.writeFileSync(outside, 'outside');
203
+ fs_1.default.mkdirSync(path_1.default.join(root, directory, 'nested'), { recursive: true });
204
+ fs_1.default.symlinkSync(outside, path_1.default.join(root, directory, 'nested', 'artifact'));
205
+ expect(m.validateRegistryLayout(root)).toBe(false);
206
+ }
207
+ });
208
+ it('validateRegistryLayout rejects symlinked root metadata', () => {
209
+ const m = load();
210
+ for (const metadata of ['awm-registry.json', 'catalog.json']) {
211
+ const root = path_1.default.join(tmpHome, `repo-${metadata}`);
212
+ const outside = path_1.default.join(tmpHome, `outside-${metadata}`);
213
+ fs_1.default.writeFileSync(outside, '{}');
214
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills'), { recursive: true });
215
+ fs_1.default.symlinkSync(outside, path_1.default.join(root, metadata));
216
+ expect(m.validateRegistryLayout(root)).toBe(false);
217
+ }
218
+ });
91
219
  });
@@ -54,6 +54,13 @@ describe('registry manifest (awm-registry.json)', () => {
54
54
  const { readRegistryManifest } = load();
55
55
  expect(() => readRegistryManifest(tmpWork)).toThrow(/awm-registry\.json/);
56
56
  });
57
+ it('rejects a manifest symlink instead of reading outside the registry', () => {
58
+ const outside = path_1.default.join(tmpHome, 'outside-manifest.json');
59
+ fs_1.default.writeFileSync(outside, JSON.stringify({ overrides: ['outside'] }));
60
+ fs_1.default.symlinkSync(outside, path_1.default.join(tmpWork, 'awm-registry.json'));
61
+ const { readRegistryManifest } = load();
62
+ expect(() => readRegistryManifest(tmpWork)).toThrow(/symbolic link/);
63
+ });
57
64
  it('throws when overrides is not an array of strings', () => {
58
65
  fs_1.default.writeFileSync(path_1.default.join(tmpWork, 'awm-registry.json'), JSON.stringify({ overrides: 'brainstorming' }));
59
66
  const { readRegistryManifest } = load();
@@ -14,6 +14,7 @@ const fs_1 = __importDefault(require("fs"));
14
14
  const os_1 = __importDefault(require("os"));
15
15
  const path_1 = __importDefault(require("path"));
16
16
  const paths_1 = require("../../src/core/paths");
17
+ const itPosix = process.platform !== 'win32' ? it : it.skip;
17
18
  describe('sameExistingPath', () => {
18
19
  let root;
19
20
  beforeEach(() => { root = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-samepath-'))); });
@@ -34,12 +35,46 @@ describe('sameExistingPath', () => {
34
35
  fs_1.default.mkdirSync(b);
35
36
  expect((0, paths_1.sameExistingPath)(a, b)).toBe(false);
36
37
  });
38
+ it('does not treat colliding inode metadata as identity when native realpaths differ', () => {
39
+ const a = path_1.default.join(root, 'inode-collision-a');
40
+ const b = path_1.default.join(root, 'inode-collision-b');
41
+ fs_1.default.mkdirSync(a);
42
+ fs_1.default.mkdirSync(b);
43
+ const stat = jest.spyOn(fs_1.default, 'statSync').mockImplementation((() => ({ dev: 1, ino: 1 })));
44
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform');
45
+ Object.defineProperty(process, 'platform', { ...platform, value: 'win32' });
46
+ try {
47
+ expect((0, paths_1.sameExistingPath)(a, b)).toBe(false);
48
+ }
49
+ finally {
50
+ Object.defineProperty(process, 'platform', platform);
51
+ stat.mockRestore();
52
+ }
53
+ });
54
+ itPosix('conserva la identidad de un archivo con hard links POSIX', () => {
55
+ const original = path_1.default.join(root, 'original');
56
+ const alias = path_1.default.join(root, 'alias');
57
+ fs_1.default.writeFileSync(original, 'contenido');
58
+ fs_1.default.linkSync(original, alias);
59
+ expect((0, paths_1.sameExistingPath)(alias, original)).toBe(true);
60
+ });
37
61
  it('una ruta inexistente nunca "coincide" — la identidad no se afirma sin prueba', () => {
38
62
  const a = path_1.default.join(root, 'a');
39
63
  fs_1.default.mkdirSync(a);
40
64
  expect((0, paths_1.sameExistingPath)(a, path_1.default.join(root, 'fantasma'))).toBe(false);
41
65
  expect((0, paths_1.sameExistingPath)(path_1.default.join(root, 'fantasma'), path_1.default.join(root, 'fantasma'))).toBe(false);
42
66
  });
67
+ it('falla cerrado si la canonicalización nativa no está disponible', () => {
68
+ const a = path_1.default.join(root, 'a');
69
+ fs_1.default.mkdirSync(a);
70
+ const realpath = jest.spyOn(fs_1.default.realpathSync, 'native').mockImplementation(() => { throw new Error('canonicalización no disponible'); });
71
+ try {
72
+ expect((0, paths_1.sameExistingPath)(a, a)).toBe(false);
73
+ }
74
+ finally {
75
+ realpath.mockRestore();
76
+ }
77
+ });
43
78
  it('sobrevive a separadores y segmentos redundantes', () => {
44
79
  const a = path_1.default.join(root, 'a');
45
80
  fs_1.default.mkdirSync(a);
@@ -41,7 +41,8 @@ async function rmSyncRetryingBusy(target, attempts = 10, delayMs = 100) {
41
41
  }
42
42
  function makeRegistryWithManifest(base, name, manifest) {
43
43
  const dir = path_1.default.join(base, name);
44
- fs_1.default.mkdirSync(dir, { recursive: true });
44
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'skills'), { recursive: true });
45
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'skills', '.keep'), 'fixture');
45
46
  GIT(dir, 'init -q -b main');
46
47
  fs_1.default.writeFileSync(path_1.default.join(dir, 'awm-registry.json'), JSON.stringify(manifest));
47
48
  GIT(dir, 'add -A');
@@ -94,6 +94,24 @@ describe('copilot provider — isolated home E2E (devCore global-scope guard reg
94
94
  function readPrefs() {
95
95
  return JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/preferences.json'), 'utf8'));
96
96
  }
97
+ it('machine-only enables Copilot without writing project artifacts (R7, R15)', async () => {
98
+ seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
99
+ fs_1.default.rmSync(path_1.default.join(tmpWork, 'package.json'));
100
+ const before = fs_1.default.readdirSync(tmpWork).sort();
101
+ const { runInit } = require('../../src/commands/init');
102
+ const code = await runInit({
103
+ cwd: tmpWork,
104
+ yes: true,
105
+ agent: 'copilot',
106
+ machineOnly: true,
107
+ });
108
+ expect(code).toBe(0);
109
+ expect(readPrefs().enabledAgents).toEqual(['copilot']);
110
+ expect(fs_1.default.readdirSync(tmpWork).sort()).toEqual(before);
111
+ expect(fs_1.default.existsSync(path_1.default.join(tmpWork, 'AGENTS.md'))).toBe(false);
112
+ expect(fs_1.default.existsSync(path_1.default.join(tmpWork, '.github'))).toBe(false);
113
+ expect(fs_1.default.existsSync(path_1.default.join(tmpWork, '.awm'))).toBe(false);
114
+ });
97
115
  it('completes without crashing/rolling back (real bug repro) and delivers content via AGENTS.md, not a doomed global-scope install', async () => {
98
116
  seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
99
117
  const { runInit } = require('../../src/commands/init');
@@ -0,0 +1,141 @@
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 crypto_1 = __importDefault(require("crypto"));
7
+ const child_process_1 = require("child_process");
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const contract_1 = require("../../src/commands/sensors/compatibility/contract");
12
+ const discovery_1 = require("../../src/commands/sensors/compatibility/discovery");
13
+ const probe_1 = require("../../src/commands/sensors/compatibility/probe");
14
+ const resolve_1 = require("../../src/commands/sensors/compatibility/resolve");
15
+ const cliDir = path_1.default.resolve(__dirname, '../..');
16
+ const bin = path_1.default.join(cliDir, 'dist/src/index.js');
17
+ const fixtureRoot = path_1.default.join(cliDir, 'tests/fixtures/sensor-compatibility');
18
+ const projectFixture = path_1.default.join(fixtureRoot, 'project');
19
+ const registryFixture = path_1.default.join(fixtureRoot, 'registry');
20
+ const noFollowUnavailableOnNativeWindows = process.platform === 'win32'
21
+ && typeof fs_1.default.constants.O_NOFOLLOW !== 'number';
22
+ // Other native platforms exercise coverage through testWithNoFollow; this
23
+ // branch preserves the deliberate Windows fail-closed behavior instead.
24
+ const testWithNoFollow = noFollowUnavailableOnNativeWindows ? test.skip : test;
25
+ const testWithoutNoFollowOnNativeWindows = noFollowUnavailableOnNativeWindows ? test : test.skip;
26
+ function hashTree(root) {
27
+ const hash = crypto_1.default.createHash('sha256');
28
+ const walk = (directory) => {
29
+ for (const entry of fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
30
+ const file = path_1.default.join(directory, entry.name);
31
+ hash.update(path_1.default.relative(root, file));
32
+ if (entry.isDirectory())
33
+ walk(file);
34
+ else if (entry.isFile())
35
+ hash.update(fs_1.default.readFileSync(file));
36
+ }
37
+ };
38
+ walk(root);
39
+ return hash.digest('hex');
40
+ }
41
+ function createFixture() {
42
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-r3-compatibility-'));
43
+ const project = path_1.default.join(root, 'project');
44
+ const awmHome = path_1.default.join(root, 'awm-home');
45
+ const registryRoot = path_1.default.join(awmHome, 'registries', 'baseline');
46
+ fs_1.default.cpSync(projectFixture, project, { recursive: true });
47
+ fs_1.default.mkdirSync(path_1.default.join(project, 'node_modules', 'eslint'), { recursive: true });
48
+ fs_1.default.copyFileSync(path_1.default.join(project, 'eslint-tool-package.json'), path_1.default.join(project, 'node_modules', 'eslint', 'package.json'));
49
+ fs_1.default.mkdirSync(path_1.default.dirname(registryRoot), { recursive: true });
50
+ fs_1.default.cpSync(registryFixture, registryRoot, { recursive: true });
51
+ fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'fixture' }]));
52
+ return { root, project, awmHome, registryRoot };
53
+ }
54
+ function runCli(fixture, ...args) {
55
+ return (0, child_process_1.spawnSync)(process.execPath, [bin, 'sensors', ...args], {
56
+ cwd: fixture.project,
57
+ encoding: 'utf8',
58
+ env: { ...process.env, AWM_HOME: fixture.awmHome, AWM_NO_UPDATE_CHECK: '1' },
59
+ });
60
+ }
61
+ function json(result) {
62
+ expect(result.status).toBe(0);
63
+ if (!(result.stdout ?? '').trim())
64
+ throw new Error(`coverage emitted no JSON: stderr=${result.stderr ?? ''}`);
65
+ return JSON.parse(result.stdout ?? '');
66
+ }
67
+ beforeAll(() => {
68
+ if (!fs_1.default.existsSync(bin))
69
+ throw new Error(`Compatibility E2E requires the compiled CLI at ${bin}; run npm run build before this test.`);
70
+ });
71
+ // This is a portable resolver *semantic* matrix: `platform` is injected into
72
+ // the resolver, so it proves the three path/selection branches but does not
73
+ // claim that this Linux process is a native macOS or Windows execution. Native
74
+ // binary coverage is exercised separately below and by the CI OS matrix.
75
+ test.each(['linux', 'darwin', 'win32'])('keeps injected resolver semantics consistent on %s (R9.1)', async (platform) => {
76
+ const fixture = createFixture();
77
+ try {
78
+ const source = JSON.parse(fs_1.default.readFileSync(path_1.default.join(fixture.registryRoot, 'sensor-packs', 'js-ts', 'pack.json'), 'utf8'));
79
+ const parsed = (0, contract_1.parseSensorPack)(source, 'fixture/pack.json');
80
+ if (parsed.kind !== 'v2')
81
+ throw new Error('fixture must be a v2 pack');
82
+ const discovered = (0, discovery_1.discoverProjectEvidence)(fixture.project, parsed.pack, { platform: () => platform });
83
+ const probe = await (0, probe_1.runCompatibilityProbe)({ kind: 'version' }, { cwd: fixture.project, toolExecutable: 'eslint' }, async () => ({
84
+ code: 0, signal: null, timedOut: false, overflowed: false, stdout: 'eslint v10.4.1', stderr: '',
85
+ }));
86
+ const result = (0, resolve_1.resolveSensorCompatibility)(parsed.pack.sensors.lint, { ...discovered, probe }, { pack: 'js-ts', sensor: 'lint' });
87
+ expect(result).toMatchObject({ state: 'certified', variantId: 'eslint-10', toolVersion: '10.4.1' });
88
+ }
89
+ finally {
90
+ fs_1.default.rmSync(fixture.root, { recursive: true, force: true });
91
+ }
92
+ });
93
+ testWithNoFollow('compiled binary dispatches coverage and emits parseable JSON on the native CI platform', () => {
94
+ const fixture = createFixture();
95
+ try {
96
+ const report = json(runCli(fixture, 'coverage', '--json'));
97
+ expect(report).toMatchObject({ schemaVersion: 2, static: expect.any(Object), empirical: expect.any(Object) });
98
+ }
99
+ finally {
100
+ fs_1.default.rmSync(fixture.root, { recursive: true, force: true });
101
+ }
102
+ });
103
+ testWithNoFollow('legacy coverage stays unverified, init migrates explicitly, and version drift is visible (R7.2, R7.8)', () => {
104
+ const fixture = createFixture();
105
+ try {
106
+ const before = [hashTree(fixture.project), hashTree(fixture.awmHome)];
107
+ const legacy = json(runCli(fixture, 'coverage', '--json'));
108
+ expect(legacy).toMatchObject({ schemaVersion: 2, static: { status: 'inconclusive' } });
109
+ expect(legacy.static.classes).toEqual(expect.arrayContaining([expect.objectContaining({
110
+ id: 'lint-errors', status: 'unverifiable', detectors: expect.arrayContaining([expect.objectContaining({ compatibility: expect.objectContaining({ state: 'compatible-unverified' }) })]),
111
+ })]));
112
+ expect([hashTree(fixture.project), hashTree(fixture.awmHome)]).toEqual(before);
113
+ const migrated = runCli(fixture, 'init', '--registry-root', fixture.registryRoot, '--pack', 'js-ts', '--no-configure');
114
+ expect(migrated.status).toBe(0);
115
+ const manifest = JSON.parse(fs_1.default.readFileSync(path_1.default.join(fixture.project, '.awm', 'sensors.json'), 'utf8'));
116
+ expect(manifest).toMatchObject({ schemaVersion: 2, pack: 'js-ts', sensors: { lint: { variantId: 'eslint-10' } } });
117
+ const certified = json(runCli(fixture, 'coverage', '--json'));
118
+ expect(certified.static.classes).toEqual(expect.arrayContaining([expect.objectContaining({
119
+ id: 'lint-errors', status: 'covered', detectors: expect.arrayContaining([expect.objectContaining({ compatibility: expect.objectContaining({ state: 'certified' }) })]),
120
+ })]));
121
+ fs_1.default.writeFileSync(path_1.default.join(fixture.project, 'node_modules', 'eslint', 'package.json'), JSON.stringify({ name: 'eslint', version: '11.0.0' }));
122
+ const drift = json(runCli(fixture, 'coverage', '--json'));
123
+ expect(drift).toMatchObject({ overall: 'inconclusive', static: { classes: [expect.objectContaining({
124
+ id: 'lint-errors', status: 'unverifiable', detectors: expect.arrayContaining([expect.objectContaining({ compatibility: expect.objectContaining({ state: 'compatible-unverified' }) })]),
125
+ })] } });
126
+ }
127
+ finally {
128
+ fs_1.default.rmSync(fixture.root, { recursive: true, force: true });
129
+ }
130
+ });
131
+ testWithoutNoFollowOnNativeWindows('coverage fails closed when native Windows cannot prevent symlink dereference', () => {
132
+ const fixture = createFixture();
133
+ try {
134
+ const result = runCli(fixture, 'coverage', '--json');
135
+ expect(result.status).toBe(1);
136
+ expect(`${result.stdout ?? ''}${result.stderr ?? ''}`).toContain('platform cannot guarantee no symlink dereference');
137
+ }
138
+ finally {
139
+ fs_1.default.rmSync(fixture.root, { recursive: true, force: true });
140
+ }
141
+ });