@meza/adr-tools 1.0.12 → 2.0.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 (111) hide show
  1. package/.github/renovate.json +2 -1
  2. package/.github/workflows/ci-pr.yml +2 -25
  3. package/.github/workflows/ci.yml +12 -46
  4. package/.releaserc.json +18 -8
  5. package/AGENTS.engineer.md +236 -0
  6. package/AGENTS.md +11 -0
  7. package/AGENTS.reviewer.md +115 -0
  8. package/CONTRIBUTING.md +102 -0
  9. package/README.md +16 -4
  10. package/biome.json +18 -139
  11. package/dist/index.js +164 -81
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.test.js +189 -0
  14. package/dist/index.test.js.map +1 -0
  15. package/dist/inject-version.test.js +27 -0
  16. package/dist/inject-version.test.js.map +1 -0
  17. package/dist/lib/adr.js +132 -27
  18. package/dist/lib/adr.js.map +1 -1
  19. package/dist/lib/adr.test.js +308 -0
  20. package/dist/lib/adr.test.js.map +1 -0
  21. package/dist/lib/config.js +3 -2
  22. package/dist/lib/config.js.map +1 -1
  23. package/dist/lib/config.test.js +60 -0
  24. package/dist/lib/config.test.js.map +1 -0
  25. package/dist/lib/links.test.js +5 -4
  26. package/dist/lib/links.test.js.map +1 -1
  27. package/dist/lib/manipulator-errors.test.js +21 -0
  28. package/dist/lib/manipulator-errors.test.js.map +1 -0
  29. package/dist/lib/manipulator.test.js +21 -1
  30. package/dist/lib/manipulator.test.js.map +1 -1
  31. package/dist/lib/numbering.js +1 -1
  32. package/dist/lib/numbering.js.map +1 -1
  33. package/dist/lib/numbering.test.js +7 -0
  34. package/dist/lib/numbering.test.js.map +1 -1
  35. package/dist/lib/opening.test.js +81 -0
  36. package/dist/lib/opening.test.js.map +1 -0
  37. package/dist/lib/prompt.js +1 -1
  38. package/dist/lib/prompt.js.map +1 -1
  39. package/dist/lib/template.test.js +62 -0
  40. package/dist/lib/template.test.js.map +1 -0
  41. package/dist/types/index.d.ts +25 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/dist/types/index.test.d.ts +2 -0
  44. package/dist/types/index.test.d.ts.map +1 -0
  45. package/dist/types/inject-version.test.d.ts +2 -0
  46. package/dist/types/inject-version.test.d.ts.map +1 -0
  47. package/dist/types/lib/adr.d.ts +15 -0
  48. package/dist/types/lib/adr.d.ts.map +1 -1
  49. package/dist/types/lib/adr.test.d.ts +2 -0
  50. package/dist/types/lib/adr.test.d.ts.map +1 -0
  51. package/dist/types/lib/config.d.ts.map +1 -1
  52. package/dist/types/lib/config.test.d.ts +2 -0
  53. package/dist/types/lib/config.test.d.ts.map +1 -0
  54. package/dist/types/lib/manipulator-errors.test.d.ts +2 -0
  55. package/dist/types/lib/manipulator-errors.test.d.ts.map +1 -0
  56. package/dist/types/lib/opening.test.d.ts +2 -0
  57. package/dist/types/lib/opening.test.d.ts.map +1 -0
  58. package/dist/types/lib/prompt.d.ts.map +1 -1
  59. package/dist/types/lib/template.test.d.ts +2 -0
  60. package/dist/types/lib/template.test.d.ts.map +1 -0
  61. package/dist/types/version.d.ts +1 -1
  62. package/dist/types/version.d.ts.map +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/version.js.map +1 -1
  65. package/doc/adr/.adr-sequence.lock +1 -1
  66. package/doc/adr/0001-record-architecture-decisions.md +21 -0
  67. package/doc/adr/0002-using-heavy-e2e-tests.md +20 -0
  68. package/doc/adr/0003-esm.md +34 -0
  69. package/doc/adr/0004-gate-editor-opening-behind---open-and---open-with.md +55 -0
  70. package/doc/adr/decisions.md +4 -1
  71. package/lefthook.yml +14 -0
  72. package/package.json +24 -26
  73. package/scripts/inject-version.mjs +34 -0
  74. package/src/index.test.ts +212 -0
  75. package/src/index.ts +229 -108
  76. package/src/inject-version.test.ts +31 -0
  77. package/src/lib/adr.test.ts +376 -0
  78. package/src/lib/adr.ts +173 -27
  79. package/src/lib/config.test.ts +69 -0
  80. package/src/lib/config.ts +3 -2
  81. package/src/lib/links.test.ts +8 -4
  82. package/src/lib/manipulator-errors.test.ts +22 -0
  83. package/src/lib/manipulator.test.ts +25 -1
  84. package/src/lib/numbering.test.ts +8 -0
  85. package/src/lib/numbering.ts +1 -1
  86. package/src/lib/opening.test.ts +96 -0
  87. package/src/lib/prompt.ts +1 -1
  88. package/src/lib/template.test.ts +74 -0
  89. package/src/version.ts +1 -2
  90. package/tests/edit-on-create.e2e.test.ts +47 -16
  91. package/tests/fake-editor.cmd +2 -0
  92. package/tests/fake-visual.cmd +2 -0
  93. package/tests/funny-characters.e2e.test.ts +12 -11
  94. package/tests/generate-graph.e2e.test.ts +13 -17
  95. package/tests/helpers/adr-cli.ts +24 -0
  96. package/tests/init-adr-repository.e2e.test.ts +7 -6
  97. package/tests/linking-records.e2e.test.ts +14 -13
  98. package/tests/list-adrs.e2e.test.ts +26 -19
  99. package/tests/new-adr.e2e.test.ts +10 -9
  100. package/tests/open-with.e2e.test.ts +53 -0
  101. package/tests/superseding-records.e2e.test.ts +11 -10
  102. package/tests/toc-prefixing.e2e.test.ts +10 -9
  103. package/tests/use-template-override.e2e.test.ts +9 -8
  104. package/tests/work-form-other-directories.e2e.test.ts +10 -8
  105. package/vitest.config.e2e.ts +8 -1
  106. package/vitest.config.ts +20 -4
  107. package/.github/workflows/sync-deps-to-main.yml +0 -25
  108. package/.github/workflows/sync-to-deps.yml +0 -26
  109. package/.husky/commit-msg +0 -4
  110. package/CHANGELOG.md +0 -121
  111. package/scripts/inject-version.sh +0 -4
@@ -0,0 +1,376 @@
1
+ import fs from 'fs/promises';
2
+ import open from 'open';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { generateToc, init, link, listAdrs, newAdr } from './adr.js';
6
+ import { getDir, workingDir } from './config.js';
7
+ import { findMatchingFilesFor, getLinkDetails } from './links.js';
8
+ import { getTitleFrom, injectLink, supersede } from './manipulator.js';
9
+ import { newNumber } from './numbering.js';
10
+ import { askForClarification } from './prompt.js';
11
+ import { template } from './template.js';
12
+
13
+ vi.mock('fs/promises', () => ({
14
+ default: {
15
+ readFile: vi.fn(),
16
+ writeFile: vi.fn(),
17
+ readdir: vi.fn(),
18
+ mkdir: vi.fn()
19
+ }
20
+ }));
21
+ vi.mock('open', () => ({ default: vi.fn() }));
22
+ vi.mock('./config.js', () => ({
23
+ getDir: vi.fn(),
24
+ workingDir: vi.fn()
25
+ }));
26
+ vi.mock('./links.js', () => ({
27
+ getLinkDetails: vi.fn(),
28
+ findMatchingFilesFor: vi.fn()
29
+ }));
30
+ vi.mock('./manipulator.js', () => ({
31
+ getTitleFrom: vi.fn(),
32
+ injectLink: vi.fn(),
33
+ supersede: vi.fn()
34
+ }));
35
+ vi.mock('./numbering.js', () => ({
36
+ newNumber: vi.fn()
37
+ }));
38
+ vi.mock('./prompt.js', () => ({
39
+ askForClarification: vi.fn()
40
+ }));
41
+ vi.mock('./template.js', () => ({
42
+ template: vi.fn()
43
+ }));
44
+
45
+ describe('adr helpers', () => {
46
+ const mockReaddir = vi.mocked(fs.readdir) as unknown as {
47
+ mockResolvedValueOnce: (value: string[]) => void;
48
+ };
49
+
50
+ afterEach(() => {
51
+ vi.resetAllMocks();
52
+ vi.unstubAllEnvs();
53
+ });
54
+
55
+ it('generates the toc from adr files', async () => {
56
+ vi.mocked(getDir).mockResolvedValueOnce('/repo/doc/adr');
57
+ mockReaddir.mockResolvedValueOnce(['0001-first.md', 'notes.txt']);
58
+ vi.mocked(fs.readFile).mockResolvedValueOnce('# First ADR');
59
+ vi.mocked(getTitleFrom).mockReturnValueOnce('ADR 1: First');
60
+
61
+ await generateToc({ prefix: '/p/' });
62
+ expect(fs.writeFile).toHaveBeenCalledWith(
63
+ path.resolve('/repo/doc/adr', 'decisions.md'),
64
+ '# Table of Contents\n\n- [ADR 1: First](/p/0001-first.md)'
65
+ );
66
+ });
67
+
68
+ it('generates the toc without a prefix', async () => {
69
+ vi.mocked(getDir).mockResolvedValueOnce('/repo/doc/adr');
70
+ mockReaddir.mockResolvedValueOnce(['0001-first.md']);
71
+ vi.mocked(fs.readFile).mockResolvedValueOnce('# First ADR');
72
+ vi.mocked(getTitleFrom).mockReturnValueOnce('ADR 1: First');
73
+
74
+ await generateToc();
75
+ expect(fs.writeFile).toHaveBeenCalledWith(
76
+ path.resolve('/repo/doc/adr', 'decisions.md'),
77
+ '# Table of Contents\n\n- [ADR 1: First](0001-first.md)'
78
+ );
79
+ });
80
+
81
+ it('includes ADRs with more than four digits', async () => {
82
+ vi.mocked(getDir).mockResolvedValueOnce('/repo/doc/adr');
83
+ mockReaddir.mockResolvedValueOnce(['10000-future.md']);
84
+ vi.mocked(fs.readFile).mockResolvedValueOnce('# Future ADR');
85
+ vi.mocked(getTitleFrom).mockReturnValueOnce('ADR 10000: Future');
86
+
87
+ await generateToc();
88
+ expect(fs.writeFile).toHaveBeenCalledWith(
89
+ path.resolve('/repo/doc/adr', 'decisions.md'),
90
+ '# Table of Contents\n\n- [ADR 10000: Future](10000-future.md)'
91
+ );
92
+ });
93
+
94
+ it('excludes ADRs with fewer than four digits', async () => {
95
+ vi.mocked(getDir).mockResolvedValueOnce('/repo/doc/adr');
96
+ mockReaddir.mockResolvedValueOnce(['123-short.md', '0001-first.md']);
97
+ vi.mocked(fs.readFile).mockResolvedValueOnce('# First ADR');
98
+ vi.mocked(getTitleFrom).mockReturnValueOnce('ADR 1: First');
99
+
100
+ await generateToc();
101
+ expect(fs.writeFile).toHaveBeenCalledWith(
102
+ path.resolve('/repo/doc/adr', 'decisions.md'),
103
+ '# Table of Contents\n\n- [ADR 1: First](0001-first.md)'
104
+ );
105
+ });
106
+
107
+ it('creates a new adr without opening', async () => {
108
+ vi.mocked(newNumber).mockResolvedValueOnce(1);
109
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
110
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
111
+ mockReaddir.mockResolvedValueOnce([]);
112
+
113
+ await newAdr('Example ADR', { open: false, date: 'DATE' });
114
+ expect(fs.writeFile).toHaveBeenCalledWith(
115
+ path.resolve('/repo/doc/adr', '0001-example-adr.md'),
116
+ 'DATE\nExample ADR\n1\nAccepted\n'
117
+ );
118
+ expect(open).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('falls back to error date when the system date is empty', async () => {
122
+ const dateSpy = vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('');
123
+ vi.mocked(newNumber).mockResolvedValueOnce(1);
124
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
125
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
126
+ mockReaddir.mockResolvedValueOnce([]);
127
+
128
+ await newAdr('Example ADR', { open: false });
129
+ expect(fs.writeFile).toHaveBeenCalledWith(
130
+ path.resolve('/repo/doc/adr', '0001-example-adr.md'),
131
+ 'ERROR\nExample ADR\n1\nAccepted\n'
132
+ );
133
+ dateSpy.mockRestore();
134
+ });
135
+
136
+ it('opens adr using open-with when provided', async () => {
137
+ vi.mocked(newNumber).mockResolvedValueOnce(1);
138
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
139
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
140
+ mockReaddir.mockResolvedValueOnce([]);
141
+
142
+ await newAdr('Example ADR', { openWith: 'code --wait' });
143
+ const adrPath = path.resolve('/repo/doc/adr', '0001-example-adr.md');
144
+ expect(open).toHaveBeenCalledWith(adrPath, {
145
+ app: { name: 'code', arguments: ['--wait'] },
146
+ wait: false
147
+ });
148
+ });
149
+
150
+ it('opens adr with default opener when enabled without an editor', async () => {
151
+ vi.mocked(newNumber).mockResolvedValueOnce(1);
152
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
153
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
154
+ mockReaddir.mockResolvedValueOnce([]);
155
+
156
+ await newAdr('Example ADR', { open: true });
157
+ expect(open).toHaveBeenCalledWith(path.resolve('/repo/doc/adr', '0001-example-adr.md'), { wait: false });
158
+ });
159
+
160
+ it('uses open on windows for open-with', async () => {
161
+ const originalPlatform = process.platform;
162
+ Object.defineProperty(process, 'platform', { value: 'win32' });
163
+ vi.mocked(newNumber).mockResolvedValueOnce(1);
164
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
165
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
166
+ mockReaddir.mockResolvedValueOnce([]);
167
+
168
+ try {
169
+ await newAdr('Example ADR', { openWith: 'code --wait' });
170
+ expect(open).toHaveBeenCalledWith(path.resolve('/repo/doc/adr', '0001-example-adr.md'), {
171
+ app: { name: 'code', arguments: ['--wait'] },
172
+ wait: false
173
+ });
174
+ } finally {
175
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
176
+ }
177
+ });
178
+
179
+ it('links superseded adrs when provided', async () => {
180
+ vi.mocked(newNumber).mockResolvedValueOnce(2);
181
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
182
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
183
+ mockReaddir.mockResolvedValueOnce([]);
184
+ vi.mocked(fs.readFile).mockResolvedValue('# ADR\n');
185
+ vi.mocked(getTitleFrom).mockReturnValue('ADR Title');
186
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
187
+ link: 'Supersedes',
188
+ reverseLink: 'Superseded by',
189
+ matches: ['0001-first.md'],
190
+ original: '1',
191
+ pattern: '1'
192
+ });
193
+
194
+ await newAdr('Next ADR', { supersedes: ['1'] });
195
+ expect(supersede).toHaveBeenCalled();
196
+ expect(injectLink).toHaveBeenCalled();
197
+ });
198
+
199
+ it('asks for clarification when multiple supersede matches exist', async () => {
200
+ vi.mocked(newNumber).mockResolvedValueOnce(2);
201
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
202
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
203
+ mockReaddir.mockResolvedValueOnce([]);
204
+ vi.mocked(fs.readFile).mockResolvedValue('# ADR\n');
205
+ vi.mocked(getTitleFrom).mockReturnValue('ADR Title');
206
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
207
+ link: 'Supersedes',
208
+ reverseLink: 'Superseded by',
209
+ matches: ['0001-first.md', '0001-other.md'],
210
+ original: '1',
211
+ pattern: '1'
212
+ });
213
+ vi.mocked(askForClarification).mockResolvedValue('0001-other.md');
214
+
215
+ await newAdr('Next ADR', { supersedes: ['1'] });
216
+ expect(askForClarification).toHaveBeenCalled();
217
+ });
218
+
219
+ it('throws when multiple supersede matches exist in quiet mode', async () => {
220
+ vi.mocked(newNumber).mockResolvedValueOnce(2);
221
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
222
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
223
+ mockReaddir.mockResolvedValueOnce([]);
224
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
225
+ link: 'Supersedes',
226
+ reverseLink: 'Superseded by',
227
+ matches: ['0001-first.md', '0001-other.md'],
228
+ original: '1',
229
+ pattern: '1'
230
+ });
231
+
232
+ await expect(newAdr('Next ADR', { supersedes: ['1'], suppressPrompts: true })).rejects.toThrow(
233
+ 'Multiple files match the search pattern'
234
+ );
235
+ });
236
+
237
+ it('links related adrs when provided', async () => {
238
+ vi.mocked(newNumber).mockResolvedValueOnce(2);
239
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
240
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
241
+ mockReaddir.mockResolvedValueOnce([]);
242
+ vi.mocked(fs.readFile).mockResolvedValue('# ADR\n');
243
+ vi.mocked(getTitleFrom).mockReturnValue('ADR Title');
244
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
245
+ link: 'Relates to',
246
+ reverseLink: 'Related by',
247
+ matches: ['0001-first.md'],
248
+ original: '1:Relates to:Related by',
249
+ pattern: '1'
250
+ });
251
+
252
+ await newAdr('Next ADR', { links: ['1:Relates to:Related by'] });
253
+ expect(injectLink).toHaveBeenCalled();
254
+ });
255
+
256
+ it('asks for clarification when multiple link matches exist', async () => {
257
+ vi.mocked(newNumber).mockResolvedValueOnce(2);
258
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
259
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
260
+ mockReaddir.mockResolvedValueOnce([]);
261
+ vi.mocked(fs.readFile).mockResolvedValue('# ADR\n');
262
+ vi.mocked(getTitleFrom).mockReturnValue('ADR Title');
263
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
264
+ link: 'Relates to',
265
+ reverseLink: 'Related by',
266
+ matches: ['0001-first.md', '0001-other.md'],
267
+ original: '1:Relates to:Related by',
268
+ pattern: '1'
269
+ });
270
+ vi.mocked(askForClarification).mockResolvedValue('0001-first.md');
271
+
272
+ await newAdr('Next ADR', { links: ['1:Relates to:Related by'] });
273
+ expect(askForClarification).toHaveBeenCalled();
274
+ });
275
+
276
+ it('initializes a new adr directory', async () => {
277
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
278
+ vi.mocked(workingDir).mockReturnValue('/repo');
279
+ vi.mocked(newNumber).mockResolvedValue(1);
280
+ vi.mocked(template).mockResolvedValue('DATE\nTITLE\nNUMBER\nSTATUS\n');
281
+ mockReaddir.mockResolvedValueOnce([]);
282
+
283
+ await init('/repo/doc/adr');
284
+ expect(fs.mkdir).toHaveBeenCalledWith('/repo/doc/adr', { recursive: true });
285
+ expect(fs.writeFile).toHaveBeenCalledWith(path.join('/repo', '.adr-dir'), path.join('doc', 'adr'));
286
+ });
287
+
288
+ it('initializes using default directory when none provided', async () => {
289
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
290
+ vi.mocked(workingDir).mockReturnValue('/repo');
291
+ vi.mocked(newNumber).mockResolvedValue(1);
292
+ vi.mocked(template).mockResolvedValue('DATE\nTITLE\nNUMBER\nSTATUS\n');
293
+ mockReaddir.mockResolvedValueOnce([]);
294
+
295
+ await init();
296
+ expect(fs.mkdir).toHaveBeenCalledWith('/repo/doc/adr', { recursive: true });
297
+ });
298
+
299
+ it('links adrs with explicit command', async () => {
300
+ vi.mocked(findMatchingFilesFor).mockResolvedValueOnce(['0001-first.md']);
301
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
302
+ vi.mocked(fs.readFile).mockResolvedValue('# ADR\n');
303
+ vi.mocked(getTitleFrom).mockReturnValue('ADR Title');
304
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
305
+ link: 'Relates to',
306
+ reverseLink: 'Related by',
307
+ matches: ['0002-second.md'],
308
+ original: '2:Relates to:Related by',
309
+ pattern: '2'
310
+ });
311
+
312
+ await link('0001-first.md', 'Relates to', '2', 'Related by');
313
+ expect(injectLink).toHaveBeenCalled();
314
+ });
315
+
316
+ it('asks for clarification when multiple link sources exist', async () => {
317
+ vi.mocked(findMatchingFilesFor).mockResolvedValueOnce(['0001-first.md', '0001-second.md']);
318
+ vi.mocked(askForClarification).mockResolvedValueOnce('0001-first.md');
319
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
320
+ vi.mocked(fs.readFile).mockResolvedValue('# ADR\n');
321
+ vi.mocked(getTitleFrom).mockReturnValue('ADR Title');
322
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
323
+ link: 'Relates to',
324
+ reverseLink: 'Related by',
325
+ matches: ['0002-second.md'],
326
+ original: '2:Relates to:Related by',
327
+ pattern: '2'
328
+ });
329
+
330
+ await link('0001', 'Relates to', '2', 'Related by');
331
+ expect(askForClarification).toHaveBeenCalled();
332
+ });
333
+
334
+ it('throws when multiple link matches exist in quiet mode', async () => {
335
+ vi.mocked(findMatchingFilesFor).mockResolvedValueOnce(['0001-first.md', '0001-second.md']);
336
+ await expect(link('0001', 'Relates', '2', 'Related by', { quiet: true })).rejects.toThrow(
337
+ 'Multiple files match the search pattern'
338
+ );
339
+ });
340
+
341
+ it('throws when multiple link matches exist in quiet mode for newAdr links', async () => {
342
+ vi.mocked(newNumber).mockResolvedValueOnce(2);
343
+ vi.mocked(template).mockResolvedValueOnce('DATE\nTITLE\nNUMBER\nSTATUS\n');
344
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
345
+ vi.mocked(fs.readdir).mockResolvedValue([]);
346
+ vi.mocked(getLinkDetails).mockResolvedValueOnce({
347
+ link: 'Relates to',
348
+ reverseLink: 'Related by',
349
+ matches: ['0001-first.md', '0001-second.md'],
350
+ original: '1:Relates to:Related by',
351
+ pattern: '1'
352
+ });
353
+
354
+ await expect(newAdr('Next ADR', { links: ['1:Relates to:Related by'], suppressPrompts: true })).rejects.toThrow(
355
+ 'Multiple files match the search pattern'
356
+ );
357
+ });
358
+
359
+ it('lists adr files only', async () => {
360
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
361
+ mockReaddir.mockResolvedValueOnce(['0001-first.md', 'notes.txt']);
362
+ const result = await listAdrs();
363
+ expect(result).toEqual([path.resolve('/repo/doc/adr', '0001-first.md')]);
364
+ });
365
+
366
+ it('lists adr files sorted by number', async () => {
367
+ vi.mocked(getDir).mockResolvedValue('/repo/doc/adr');
368
+ mockReaddir.mockResolvedValueOnce(['0010-ten.md', '0002-two.md', 'notes.txt', '0001-one.md']);
369
+ const result = await listAdrs();
370
+ expect(result).toEqual([
371
+ path.resolve('/repo/doc/adr', '0001-one.md'),
372
+ path.resolve('/repo/doc/adr', '0002-two.md'),
373
+ path.resolve('/repo/doc/adr', '0010-ten.md')
374
+ ]);
375
+ });
376
+ });
package/src/lib/adr.ts CHANGED
@@ -1,4 +1,3 @@
1
- import childProcess from 'node:child_process';
2
1
  import path from 'node:path';
3
2
  import { fileURLToPath } from 'node:url';
4
3
  import fs from 'fs/promises';
@@ -11,12 +10,170 @@ import { template } from './template.js';
11
10
 
12
11
  const __filename = fileURLToPath(import.meta.url);
13
12
 
13
+ const normalizeEditorCommand = (raw: string | undefined): string | undefined => {
14
+ if (!raw) {
15
+ return undefined;
16
+ }
17
+
18
+ const trimmed = raw.trim();
19
+ if (!trimmed) {
20
+ return undefined;
21
+ }
22
+
23
+ const unquoted = trimmed.replace(/^['"](.+)['"]$/, '$1').trim();
24
+ if (!unquoted) {
25
+ return undefined;
26
+ }
27
+
28
+ const lowered = unquoted.toLowerCase();
29
+ if (lowered === 'true' || lowered === 'false') {
30
+ return undefined;
31
+ }
32
+
33
+ return unquoted;
34
+ };
35
+
36
+ const unescapeDoubleQuoted = (value: string): string => {
37
+ let result = '';
38
+ for (let i = 0; i < value.length; i++) {
39
+ const ch = value[i];
40
+ if (ch === '\\' && i + 1 < value.length) {
41
+ const next = value[i + 1];
42
+ if (next === '\\' || next === '"') {
43
+ result += next;
44
+ i++;
45
+ continue;
46
+ }
47
+ }
48
+ result += ch;
49
+ }
50
+ return result;
51
+ };
52
+
53
+ const splitCommandLine = (commandLine: string): string[] => {
54
+ const tokens = commandLine.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
55
+ if (!tokens) {
56
+ return [];
57
+ }
58
+
59
+ return tokens.map((token) => {
60
+ if (token.startsWith('"') && token.endsWith('"')) {
61
+ return unescapeDoubleQuoted(token.slice(1, -1));
62
+ }
63
+
64
+ if (token.startsWith("'") && token.endsWith("'")) {
65
+ return token.slice(1, -1);
66
+ }
67
+
68
+ return token;
69
+ });
70
+ };
71
+
72
+ type OpenPlan = { type: 'none' } | { type: 'default' } | { type: 'app'; name: string; args: string[] };
73
+
74
+ const openAdr = async (
75
+ adrPath: string,
76
+ options: { app?: { name: string; arguments: string[] }; wait: false },
77
+ openPromise: Promise<typeof import('open')>
78
+ ) => {
79
+ const { default: open } = await openPromise;
80
+ await open(adrPath, options);
81
+ };
82
+
83
+ const resolveOpenPlan = (config?: NewOptions): OpenPlan => {
84
+ if (!config || (config.open === undefined && !config.openWith)) {
85
+ return chooseOpenPlan(undefined);
86
+ }
87
+
88
+ return chooseOpenPlan({
89
+ ...(config.open !== undefined ? { open: config.open } : {}),
90
+ ...(config.openWith ? { openWith: config.openWith } : {})
91
+ });
92
+ };
93
+
94
+ const shouldLoadOpenLibrary = (openPlan: OpenPlan) => openPlan.type !== 'none';
95
+
96
+ const openNewAdr = async (openPlan: OpenPlan, adrPath: string, openPromise?: Promise<typeof import('open')>) => {
97
+ if (openPlan.type === 'none') {
98
+ return;
99
+ }
100
+
101
+ if (openPlan.type === 'default') {
102
+ await openAdr(adrPath, { wait: false }, openPromise!);
103
+ return;
104
+ }
105
+
106
+ await openAdr(adrPath, { app: { name: openPlan.name, arguments: openPlan.args }, wait: false }, openPromise!);
107
+ };
108
+
109
+ const equalsIgnoreCase = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
110
+
111
+ const isNpmInjectedEditor = (editor: string) => {
112
+ const env = process.env as NodeJS.ProcessEnv & {
113
+ npm_config_editor?: string;
114
+ npm_execpath?: string;
115
+ };
116
+ const npmEditor = normalizeEditorCommand(env.npm_config_editor);
117
+ if (!npmEditor) {
118
+ return false;
119
+ }
120
+
121
+ if (!equalsIgnoreCase(editor, npmEditor)) {
122
+ return false;
123
+ }
124
+
125
+ return Boolean(env.npm_execpath);
126
+ };
127
+
128
+ const toAppPlan = (commandLine: string | undefined): OpenPlan | undefined => {
129
+ const normalized = normalizeEditorCommand(commandLine);
130
+ if (!normalized) {
131
+ return undefined;
132
+ }
133
+
134
+ const [name, ...args] = splitCommandLine(normalized);
135
+ if (!name) {
136
+ return undefined;
137
+ }
138
+
139
+ return { type: 'app', name, args };
140
+ };
141
+
142
+ export const chooseOpenPlan = (options?: { open?: boolean; openWith?: string }): OpenPlan => {
143
+ const openWithPlan = toAppPlan(options?.openWith);
144
+ const shouldOpen = Boolean(options?.open) || Boolean(openWithPlan);
145
+ if (!shouldOpen) {
146
+ return { type: 'none' };
147
+ }
148
+
149
+ if (openWithPlan) {
150
+ return openWithPlan;
151
+ }
152
+
153
+ const visualPlan = toAppPlan(process.env.VISUAL);
154
+ if (visualPlan) {
155
+ return visualPlan;
156
+ }
157
+
158
+ const editorCommand = normalizeEditorCommand(process.env.EDITOR);
159
+ if (editorCommand && !isNpmInjectedEditor(editorCommand)) {
160
+ const editorPlan = toAppPlan(editorCommand);
161
+ if (editorPlan) {
162
+ return editorPlan;
163
+ }
164
+ }
165
+
166
+ return { type: 'default' };
167
+ };
168
+
14
169
  interface NewOptions {
15
170
  supersedes?: string[];
16
171
  date?: string | undefined;
17
172
  suppressPrompts?: boolean;
18
173
  template?: string;
19
174
  links?: string[];
175
+ open?: boolean;
176
+ openWith?: string;
20
177
  }
21
178
 
22
179
  // eslint-disable-next-line no-unused-vars
@@ -58,8 +215,6 @@ const actuallyLink = async (task: LinkTask) => {
58
215
  );
59
216
  dirtyNew = injectLink(newAdrContent, `${task.link} [${oldTitle}](${task.targetPath})`);
60
217
  break;
61
- default:
62
- break;
63
218
  }
64
219
 
65
220
  await fs.writeFile(linkedFile, dirtyOld);
@@ -129,7 +284,7 @@ const injectLinksTo = async (sourcePath: string, links: string[] = [], suppressP
129
284
  export const generateToc = async (options?: { prefix?: string }) => {
130
285
  const adrDir = await getDir();
131
286
  const files = await fs.readdir(adrDir);
132
- const toc = files.filter((file) => file.match(/^\d{4}-.*\.md$/)).sort();
287
+ const toc = files.filter((file) => file.match(/^\d{4,}-.*\.md$/)).sort();
133
288
 
134
289
  const titles = toc.map(async (file) => {
135
290
  const title = getTitleFrom(await fs.readFile(path.join(adrDir, file), 'utf8'));
@@ -143,6 +298,9 @@ export const generateToc = async (options?: { prefix?: string }) => {
143
298
  };
144
299
 
145
300
  export const newAdr = async (title: string, config?: NewOptions) => {
301
+ const openPlan = resolveOpenPlan(config);
302
+ const openPromise = shouldLoadOpenLibrary(openPlan) ? import('open') : undefined;
303
+
146
304
  const newNum = await newNumber();
147
305
  const formattedDate = config?.date || new Date().toISOString().split('T')[0] || 'ERROR';
148
306
  const tpl = await template(config?.template);
@@ -152,11 +310,7 @@ export const newAdr = async (title: string, config?: NewOptions) => {
152
310
  .replace('NUMBER', newNum.toString())
153
311
  .replace('STATUS', 'Accepted');
154
312
  const paddedNumber = newNum.toString().padStart(4, '0');
155
- const cleansedTitle = title
156
- .toLowerCase()
157
- .replace(/\W/g, '-')
158
- .replace(/^(.*)\W$/, '$1')
159
- .replace(/^\W(.*)$/, '$1');
313
+ const cleansedTitle = title.toLowerCase().replace(/\W+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
160
314
  const fileName = `${paddedNumber}-${cleansedTitle}.md`;
161
315
  const adrDirectory = await getDir();
162
316
  const adrPath = path.resolve(path.join(adrDirectory, fileName));
@@ -167,20 +321,7 @@ export const newAdr = async (title: string, config?: NewOptions) => {
167
321
  await injectLinksTo(adrPath, config?.links, config?.suppressPrompts);
168
322
  await generateToc();
169
323
 
170
- if (process.env.VISUAL) {
171
- await childProcess.spawn(process.env.VISUAL, [adrPath], {
172
- stdio: 'inherit',
173
- shell: true
174
- });
175
- return;
176
- }
177
- if (process.env.EDITOR) {
178
- await childProcess.spawn(process.env.EDITOR, [adrPath], {
179
- stdio: 'inherit',
180
- shell: true
181
- });
182
- return;
183
- }
324
+ await openNewAdr(openPlan, adrPath, openPromise);
184
325
  };
185
326
 
186
327
  export const init = async (directory?: string) => {
@@ -227,11 +368,16 @@ export const listAdrs = async () => {
227
368
  const toc = files
228
369
  .map((f) => {
229
370
  const adrFile = f.match(/^0*(\d+)-.*$/);
230
- if (adrFile) {
231
- return path.resolve(dir, adrFile[0]);
371
+ if (!adrFile) {
372
+ return null;
232
373
  }
233
- return '';
374
+ return {
375
+ number: Number.parseInt(adrFile[1], 10),
376
+ path: path.resolve(dir, adrFile[0])
377
+ };
234
378
  })
235
- .filter((f) => f !== '');
379
+ .filter((entry): entry is { number: number; path: string } => Boolean(entry))
380
+ .sort((a, b) => a.number - b.number)
381
+ .map((entry) => entry.path);
236
382
  return toc;
237
383
  };