agentic-workflow-manager 6.5.2 → 8.1.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 (95) 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 +382 -0
  11. package/dist/src/commands/sensors/compatibility/discovery.js +207 -0
  12. package/dist/src/commands/sensors/compatibility/live.js +92 -0
  13. package/dist/src/commands/sensors/compatibility/manifest.js +207 -0
  14. package/dist/src/commands/sensors/compatibility/materialize.js +134 -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 +99 -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 +244 -9
  27. package/dist/src/commands/sensors/index.js +18 -6
  28. package/dist/src/commands/sensors/init.js +95 -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 +193 -0
  45. package/dist/tests/commands/sensors/compatibility/discovery.test.js +119 -0
  46. package/dist/tests/commands/sensors/compatibility/live.test.js +124 -0
  47. package/dist/tests/commands/sensors/compatibility/manifest.test.js +77 -0
  48. package/dist/tests/commands/sensors/compatibility/materialize.test.js +72 -0
  49. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +55 -0
  50. package/dist/tests/commands/sensors/compatibility/probe.test.js +37 -0
  51. package/dist/tests/commands/sensors/compatibility/python-venv-fixture.js +29 -0
  52. package/dist/tests/commands/sensors/compatibility/resolve.test.js +83 -0
  53. package/dist/tests/commands/sensors/coverage/contract.test.js +8 -22
  54. package/dist/tests/commands/sensors/coverage/empirical.test.js +111 -0
  55. package/dist/tests/commands/sensors/coverage/evaluate.test.js +29 -0
  56. package/dist/tests/commands/sensors/coverage/evidence.test.js +9 -0
  57. package/dist/tests/commands/sensors/coverage/index.test.js +40 -13
  58. package/dist/tests/commands/sensors/coverage/render.test.js +187 -12
  59. package/dist/tests/commands/sensors/coverage/resolve.test.js +25 -4
  60. package/dist/tests/commands/sensors/exec-windows.test.js +140 -2
  61. package/dist/tests/commands/sensors/exec.test.js +115 -5
  62. package/dist/tests/commands/sensors/index.test.js +8 -0
  63. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +12 -12
  64. package/dist/tests/commands/sensors/init.test.js +204 -48
  65. package/dist/tests/commands/sensors/run-inconclusive.test.js +9 -2
  66. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  67. package/dist/tests/commands/sensors/run.test.js +66 -3
  68. package/dist/tests/commands/sensors/status-windows.test.js +7 -7
  69. package/dist/tests/commands/sensors/status.test.js +74 -23
  70. package/dist/tests/commands/update.test.js +1 -1
  71. package/dist/tests/core/bundles.test.js +37 -0
  72. package/dist/tests/core/discovery.test.js +4 -0
  73. package/dist/tests/core/init/orchestrator.test.js +5 -1
  74. package/dist/tests/core/init/steps.test.js +20 -4
  75. package/dist/tests/core/ledger/scan.test.js +217 -0
  76. package/dist/tests/core/ledger/store.test.js +108 -0
  77. package/dist/tests/core/profile-pins.test.js +2 -1
  78. package/dist/tests/core/project-skill-links.test.js +22 -0
  79. package/dist/tests/core/registries-capability.test.js +10 -0
  80. package/dist/tests/core/registries-sync.test.js +16 -0
  81. package/dist/tests/core/registries.test.js +129 -1
  82. package/dist/tests/core/registry-manifest.test.js +7 -0
  83. package/dist/tests/core/same-existing-path.test.js +35 -0
  84. package/dist/tests/core/sync-gates.test.js +2 -1
  85. package/dist/tests/integration/copilot-init-isolated.test.js +18 -0
  86. package/dist/tests/integration/sensor-compatibility.e2e.test.js +141 -0
  87. package/dist/tests/integration/sensor-coverage.e2e.test.js +35 -6
  88. package/dist/tests/integration/sensor-structured-run.e2e.test.js +127 -0
  89. package/dist/tests/structural/active-documentation.test.js +128 -0
  90. package/dist/tests/structural/async-entrypoint.test.js +15 -0
  91. package/dist/tests/structural/jest-environment-is-isolated.test.js +44 -0
  92. package/dist/tests/structural/r3-cli-major-version.test.js +37 -0
  93. package/dist/tests/structural/sensor-documentation-contract.test.js +77 -0
  94. package/dist/tests/structural/support-matrix-is-current.test.js +69 -2
  95. package/package.json +8 -2
@@ -0,0 +1,217 @@
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 scan_1 = require("../../../src/core/ledger/scan");
10
+ function mkTmp() {
11
+ return fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-ledger-scan-'));
12
+ }
13
+ function entry(over = {}) {
14
+ return {
15
+ ts: '2026-08-14T00:00:00.000Z', branch: 'feat-x', phase: 'qa', source_skill: 'post-implementation-qa',
16
+ polarity: 'finding', class: 'logica', signature: 'lint-error', severity: 'important', desc: 'private detail',
17
+ defectClass: 'lint-errors', ...over,
18
+ };
19
+ }
20
+ function write(root, relative, lines) {
21
+ const target = path_1.default.join(root, relative);
22
+ fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
23
+ fs_1.default.writeFileSync(target, lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)).join('\n') + '\n');
24
+ }
25
+ describe('scanProjectLedgers', () => {
26
+ let root;
27
+ beforeEach(() => { root = mkTmp(); });
28
+ afterEach(() => { fs_1.default.rmSync(root, { recursive: true, force: true }); });
29
+ test('reads only direct active and archive files in deterministic path and line order', () => {
30
+ write(root, '.awm/ledger/z.jsonl', [entry({ signature: 'z' })]);
31
+ write(root, '.awm/ledger/a.jsonl', [entry({ signature: 'a' })]);
32
+ write(root, '.awm/ledger/archive/b.jsonl', [entry({ signature: 'b' })]);
33
+ write(root, '.awm/ledger/archive/nested/ignored.jsonl', [entry({ signature: 'ignored' })]);
34
+ const scan = (0, scan_1.scanProjectLedgers)(root);
35
+ expect(scan.entries.map(item => item.source)).toEqual([
36
+ '.awm/ledger/a.jsonl:1', '.awm/ledger/archive/b.jsonl:1', '.awm/ledger/z.jsonl:1',
37
+ ]);
38
+ expect(scan.sources).toMatchObject({ activeFiles: 2, archivedFiles: 1, validFindings: 3, skippedFindings: 0 });
39
+ });
40
+ test('orders direct active and archive sources together by their relative path', () => {
41
+ write(root, '.awm/ledger/b.jsonl', [entry({ signature: 'active-b' })]);
42
+ write(root, '.awm/ledger/archive/a.jsonl', [entry({ signature: 'archive-a' })]);
43
+ const scan = (0, scan_1.scanProjectLedgers)(root);
44
+ expect(scan.entries.map(item => item.source)).toEqual([
45
+ '.awm/ledger/archive/a.jsonl:1', '.awm/ledger/b.jsonl:1',
46
+ ]);
47
+ });
48
+ test('counts wins as valid but excludes them from findings', () => {
49
+ write(root, '.awm/ledger/a.jsonl', [entry({ polarity: 'win' }), entry({ signature: 'finding' })]);
50
+ const scan = (0, scan_1.scanProjectLedgers)(root);
51
+ expect(scan.entries.map(item => item.entry.signature)).toEqual(['finding']);
52
+ expect(scan.sources).toMatchObject({ validEntries: 2, validFindings: 1 });
53
+ });
54
+ test.each([
55
+ ['invalid JSON', 'invalid-json', ['{not-json'], {}],
56
+ ['invalid persisted class', 'invalid-defect-class', [{ ...entry(), defectClass: 'Bad_ID' }], {}],
57
+ ['oversized line', 'line-too-large', [JSON.stringify(entry()) + 'x'.repeat(100)], { maxLineBytes: 64 }],
58
+ ])('reports %s as a typed skipped finding', (_name, reason, lines, limits) => {
59
+ write(root, '.awm/ledger/a.jsonl', lines);
60
+ const scan = (0, scan_1.scanProjectLedgers)(root, limits);
61
+ expect(scan.sources.skippedFindings).toBe(1);
62
+ expect(scan.sources.skippedByReason).toMatchObject({ [reason]: 1 });
63
+ });
64
+ test('rejects a symlink without reading its target', () => {
65
+ const outside = path_1.default.join(root, 'outside.jsonl');
66
+ fs_1.default.writeFileSync(outside, JSON.stringify(entry()));
67
+ const ledger = path_1.default.join(root, '.awm', 'ledger');
68
+ fs_1.default.mkdirSync(ledger, { recursive: true });
69
+ fs_1.default.symlinkSync(outside, path_1.default.join(ledger, 'linked.jsonl'));
70
+ const scan = (0, scan_1.scanProjectLedgers)(root);
71
+ expect(scan.entries).toEqual([]);
72
+ expect(scan.sources.skippedByReason).toMatchObject({ 'symlink-entry': 1 });
73
+ });
74
+ test('records direct non-regular entries and never descends into them', () => {
75
+ fs_1.default.mkdirSync(path_1.default.join(root, '.awm', 'ledger', 'directory.jsonl'), { recursive: true });
76
+ write(root, '.awm/ledger/directory.jsonl/hidden.jsonl', [entry({ signature: 'hidden' })]);
77
+ const scan = (0, scan_1.scanProjectLedgers)(root);
78
+ expect(scan.entries).toEqual([]);
79
+ expect(scan.sources.skippedByReason).toMatchObject({ 'nonregular-entry': 1 });
80
+ });
81
+ test('reports file and entry limits once without reading candidates beyond the bound', () => {
82
+ write(root, '.awm/ledger/a.jsonl', [entry({ signature: 'a' })]);
83
+ write(root, '.awm/ledger/b.jsonl', [entry({ signature: 'b' })]);
84
+ const files = (0, scan_1.scanProjectLedgers)(root, { maxFiles: 1 });
85
+ expect(files.entries.map(item => item.entry.signature)).toEqual(['a']);
86
+ expect(files.sources.skippedByReason).toMatchObject({ 'file-limit': 1 });
87
+ write(root, '.awm/ledger/entries.jsonl', [entry({ signature: 'first' }), entry({ signature: 'second' })]);
88
+ const entries = (0, scan_1.scanProjectLedgers)(root, { maxEntries: 1, maxFiles: 10 });
89
+ expect(entries.entries).toHaveLength(1);
90
+ expect(entries.sources.skippedByReason).toMatchObject({ 'entry-limit': 1 });
91
+ });
92
+ test('enumerates file candidates with bounded memory and never opens a source after maxFiles', () => {
93
+ write(root, '.awm/ledger/a.jsonl', [entry({ signature: 'first' })]);
94
+ write(root, '.awm/ledger/b.jsonl', [entry({ signature: 'must-not-open' })]);
95
+ write(root, '.awm/ledger/archive/c.jsonl', [entry({ signature: 'also-must-not-open' })]);
96
+ const readSpy = jest.spyOn(fs_1.default, 'readFileSync');
97
+ const readdirSpy = jest.spyOn(fs_1.default, 'readdirSync').mockImplementation(() => {
98
+ throw new Error('unbounded readdir must not be used for ledger candidates');
99
+ });
100
+ try {
101
+ const scan = (0, scan_1.scanProjectLedgers)(root, { maxFiles: 1 });
102
+ expect(scan.entries.map(item => item.entry.signature)).toEqual(['first']);
103
+ expect(scan.sources.skippedByReason).toMatchObject({ 'file-limit': 1 });
104
+ expect(readSpy.mock.calls.map(([candidate]) => String(candidate))).not.toContain(path_1.default.join(root, '.awm', 'ledger', 'b.jsonl'));
105
+ expect(readSpy.mock.calls.map(([candidate]) => String(candidate))).not.toContain(path_1.default.join(root, '.awm', 'ledger', 'archive', 'c.jsonl'));
106
+ }
107
+ finally {
108
+ readdirSpy.mockRestore();
109
+ readSpy.mockRestore();
110
+ }
111
+ });
112
+ test('sorts the retained candidate window instead of assuming host directory order', () => {
113
+ write(root, '.awm/ledger/a.jsonl', [entry({ signature: 'first' })]);
114
+ write(root, '.awm/ledger/b.jsonl', [entry({ signature: 'witness' })]);
115
+ write(root, '.awm/ledger/c.jsonl', [entry({ signature: 'must-not-enumerate' })]);
116
+ const ledgerDirectory = path_1.default.join(root, '.awm', 'ledger');
117
+ const originalOpen = fs_1.default.opendirSync;
118
+ let readCalls = 0;
119
+ const openSpy = jest.spyOn(fs_1.default, 'opendirSync').mockImplementation((directory, options) => {
120
+ if (String(directory) === ledgerDirectory) {
121
+ const names = ['b.jsonl', 'a.jsonl', 'c.jsonl'];
122
+ let index = 0;
123
+ return {
124
+ closeSync: jest.fn(),
125
+ readSync: jest.fn(() => {
126
+ readCalls += 1;
127
+ if (readCalls > 2)
128
+ throw new Error('candidate enumeration exceeded bounded window');
129
+ const name = names[index++];
130
+ return name === undefined ? null : { name };
131
+ }),
132
+ };
133
+ }
134
+ const handle = originalOpen(directory, options);
135
+ const originalRead = handle.readSync.bind(handle);
136
+ jest.spyOn(handle, 'readSync').mockImplementation(() => {
137
+ readCalls += 1;
138
+ if (readCalls > 2)
139
+ throw new Error('candidate enumeration exceeded bounded window');
140
+ return originalRead();
141
+ });
142
+ return handle;
143
+ });
144
+ try {
145
+ const scan = (0, scan_1.scanProjectLedgers)(root, { maxFiles: 1 });
146
+ expect(scan.entries.map(item => item.entry.signature)).toEqual(['first']);
147
+ expect(scan.sources.skippedByReason).toMatchObject({ 'file-limit': 1 });
148
+ expect(readCalls).toBe(2);
149
+ }
150
+ finally {
151
+ openSpy.mockRestore();
152
+ }
153
+ });
154
+ test('shares the candidate window across active and archive directories', () => {
155
+ write(root, '.awm/ledger/a.jsonl', [entry({ signature: 'active-first' })]);
156
+ write(root, '.awm/ledger/b.jsonl', [entry({ signature: 'active-witness' })]);
157
+ write(root, '.awm/ledger/archive/c.jsonl', [entry({ signature: 'archive-must-not-enumerate' })]);
158
+ const originalOpen = fs_1.default.opendirSync;
159
+ let candidateReads = 0;
160
+ const openSpy = jest.spyOn(fs_1.default, 'opendirSync').mockImplementation((directory, options) => {
161
+ const handle = originalOpen(directory, options);
162
+ const originalRead = handle.readSync.bind(handle);
163
+ jest.spyOn(handle, 'readSync').mockImplementation(() => {
164
+ const item = originalRead();
165
+ if (item?.name.endsWith('.jsonl')) {
166
+ candidateReads += 1;
167
+ if (candidateReads > 2)
168
+ throw new Error('candidate enumeration exceeded the global bounded window');
169
+ }
170
+ return item;
171
+ });
172
+ return handle;
173
+ });
174
+ try {
175
+ const scan = (0, scan_1.scanProjectLedgers)(root, { maxFiles: 1 });
176
+ expect(scan.entries.map(item => item.entry.signature)).toEqual(['active-first']);
177
+ expect(scan.sources.skippedByReason).toMatchObject({ 'file-limit': 1 });
178
+ expect(candidateReads).toBe(2);
179
+ }
180
+ finally {
181
+ openSpy.mockRestore();
182
+ }
183
+ });
184
+ test('reports a too-large file without parsing it', () => {
185
+ write(root, '.awm/ledger/a.jsonl', [entry()]);
186
+ const scan = (0, scan_1.scanProjectLedgers)(root, { maxFileBytes: 1 });
187
+ expect(scan.entries).toEqual([]);
188
+ expect(scan.sources.skippedByReason).toMatchObject({ 'file-too-large': 1 });
189
+ });
190
+ test('reports over-deep JSON before parsing it', () => {
191
+ write(root, '.awm/ledger/a.jsonl', ['{"x":{"x":{"x":{"x":{"x":1}}}}}']);
192
+ const scan = (0, scan_1.scanProjectLedgers)(root, { maxJsonDepth: 3 });
193
+ expect(scan.entries).toEqual([]);
194
+ expect(scan.sources.skippedByReason).toMatchObject({ 'json-too-deep': 1 });
195
+ });
196
+ test('keeps every finding for analysis while bounding renderable references per defect class', () => {
197
+ write(root, '.awm/ledger/a.jsonl', [
198
+ entry({ signature: 'first', defectClass: 'lint-errors' }),
199
+ entry({ signature: 'second', defectClass: 'lint-errors' }),
200
+ entry({ signature: 'other', defectClass: 'static-type-errors' }),
201
+ ]);
202
+ const scan = (0, scan_1.scanProjectLedgers)(root, { maxRefsPerClass: 1 });
203
+ expect(scan.entries.map(item => item.entry.signature)).toEqual(['first', 'second', 'other']);
204
+ expect(scan.entries.map(item => item.evidenceRef)).toEqual([
205
+ '.awm/ledger/a.jsonl:1', null, '.awm/ledger/a.jsonl:3',
206
+ ]);
207
+ expect(scan.omittedEvidenceRefs).toBe(1);
208
+ expect(scan.sources).toMatchObject({
209
+ validFindings: 3,
210
+ skippedFindings: 1,
211
+ skippedByReason: { 'evidence-ref-limit': 1 },
212
+ });
213
+ });
214
+ test('rejects non-object limit overrides instead of accepting an impossible public input', () => {
215
+ expect(() => (0, scan_1.scanProjectLedgers)(root, null)).toThrow(/limits.*object/i);
216
+ });
217
+ });
@@ -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();