@meza/adr-tools 1.0.12 → 2.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.
- package/.github/renovate.json +2 -1
- package/.github/workflows/ci-pr.yml +2 -25
- package/.github/workflows/ci.yml +12 -46
- package/.releaserc.json +11 -6
- package/AGENTS.engineer.md +236 -0
- package/AGENTS.md +11 -0
- package/AGENTS.reviewer.md +115 -0
- package/CONTRIBUTING.md +102 -0
- package/README.md +16 -4
- package/biome.json +18 -139
- package/dist/index.js +164 -81
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +189 -0
- package/dist/index.test.js.map +1 -0
- package/dist/inject-version.test.js +27 -0
- package/dist/inject-version.test.js.map +1 -0
- package/dist/lib/adr.js +132 -27
- package/dist/lib/adr.js.map +1 -1
- package/dist/lib/adr.test.js +308 -0
- package/dist/lib/adr.test.js.map +1 -0
- package/dist/lib/config.js +3 -2
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/config.test.js +60 -0
- package/dist/lib/config.test.js.map +1 -0
- package/dist/lib/links.test.js +5 -4
- package/dist/lib/links.test.js.map +1 -1
- package/dist/lib/manipulator-errors.test.js +21 -0
- package/dist/lib/manipulator-errors.test.js.map +1 -0
- package/dist/lib/manipulator.test.js +21 -1
- package/dist/lib/manipulator.test.js.map +1 -1
- package/dist/lib/numbering.js +1 -1
- package/dist/lib/numbering.js.map +1 -1
- package/dist/lib/numbering.test.js +7 -0
- package/dist/lib/numbering.test.js.map +1 -1
- package/dist/lib/opening.test.js +81 -0
- package/dist/lib/opening.test.js.map +1 -0
- package/dist/lib/prompt.js +1 -1
- package/dist/lib/prompt.js.map +1 -1
- package/dist/lib/template.test.js +62 -0
- package/dist/lib/template.test.js.map +1 -0
- package/dist/types/index.d.ts +25 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.test.d.ts +2 -0
- package/dist/types/index.test.d.ts.map +1 -0
- package/dist/types/inject-version.test.d.ts +2 -0
- package/dist/types/inject-version.test.d.ts.map +1 -0
- package/dist/types/lib/adr.d.ts +15 -0
- package/dist/types/lib/adr.d.ts.map +1 -1
- package/dist/types/lib/adr.test.d.ts +2 -0
- package/dist/types/lib/adr.test.d.ts.map +1 -0
- package/dist/types/lib/config.d.ts.map +1 -1
- package/dist/types/lib/config.test.d.ts +2 -0
- package/dist/types/lib/config.test.d.ts.map +1 -0
- package/dist/types/lib/manipulator-errors.test.d.ts +2 -0
- package/dist/types/lib/manipulator-errors.test.d.ts.map +1 -0
- package/dist/types/lib/opening.test.d.ts +2 -0
- package/dist/types/lib/opening.test.d.ts.map +1 -0
- package/dist/types/lib/prompt.d.ts.map +1 -1
- package/dist/types/lib/template.test.d.ts +2 -0
- package/dist/types/lib/template.test.d.ts.map +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/doc/adr/.adr-sequence.lock +1 -1
- package/doc/adr/0001-record-architecture-decisions.md +21 -0
- package/doc/adr/0002-using-heavy-e2e-tests.md +20 -0
- package/doc/adr/0003-esm.md +34 -0
- package/doc/adr/0004-gate-editor-opening-behind---open-and---open-with.md +55 -0
- package/doc/adr/decisions.md +4 -1
- package/lefthook.yml +14 -0
- package/package.json +24 -26
- package/scripts/inject-version.mjs +34 -0
- package/src/index.test.ts +212 -0
- package/src/index.ts +229 -108
- package/src/inject-version.test.ts +31 -0
- package/src/lib/adr.test.ts +376 -0
- package/src/lib/adr.ts +173 -27
- package/src/lib/config.test.ts +69 -0
- package/src/lib/config.ts +3 -2
- package/src/lib/links.test.ts +8 -4
- package/src/lib/manipulator-errors.test.ts +22 -0
- package/src/lib/manipulator.test.ts +25 -1
- package/src/lib/numbering.test.ts +8 -0
- package/src/lib/numbering.ts +1 -1
- package/src/lib/opening.test.ts +96 -0
- package/src/lib/prompt.ts +1 -1
- package/src/lib/template.test.ts +74 -0
- package/src/version.ts +1 -2
- package/tests/edit-on-create.e2e.test.ts +47 -16
- package/tests/fake-editor.cmd +2 -0
- package/tests/fake-visual.cmd +2 -0
- package/tests/funny-characters.e2e.test.ts +12 -11
- package/tests/generate-graph.e2e.test.ts +13 -17
- package/tests/helpers/adr-cli.ts +24 -0
- package/tests/init-adr-repository.e2e.test.ts +7 -6
- package/tests/linking-records.e2e.test.ts +14 -13
- package/tests/list-adrs.e2e.test.ts +26 -19
- package/tests/new-adr.e2e.test.ts +10 -9
- package/tests/open-with.e2e.test.ts +53 -0
- package/tests/superseding-records.e2e.test.ts +11 -10
- package/tests/toc-prefixing.e2e.test.ts +10 -9
- package/tests/use-template-override.e2e.test.ts +9 -8
- package/tests/work-form-other-directories.e2e.test.ts +10 -8
- package/vitest.config.e2e.ts +8 -1
- package/vitest.config.ts +20 -4
- package/.github/workflows/sync-deps-to-main.yml +0 -25
- package/.github/workflows/sync-to-deps.yml +0 -26
- package/.husky/commit-msg +0 -4
- package/CHANGELOG.md +0 -121
- 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
|
-
|
|
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
|
|
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((
|
|
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
|
};
|