@meza/adr-tools 1.0.10 → 1.0.12
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/.gitattributes +40 -0
- package/.github/renovate.json +5 -0
- package/.github/workflows/ci-pr.yml +50 -0
- package/.github/workflows/ci.yml +28 -20
- package/.github/workflows/sync-deps-to-main.yml +25 -0
- package/.github/workflows/sync-to-deps.yml +26 -0
- package/.releaserc.json +1 -13
- package/CHANGELOG.md +76 -0
- package/LICENSE +674 -0
- package/README.md +64 -5
- package/biome.json +148 -0
- package/dist/index.js +52 -82
- package/dist/index.js.map +1 -1
- package/dist/lib/adr.js +100 -126
- package/dist/lib/adr.js.map +1 -1
- package/dist/lib/config.js +20 -37
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/links.js +8 -25
- package/dist/lib/links.js.map +1 -1
- package/dist/lib/links.test.js +35 -64
- package/dist/lib/links.test.js.map +1 -1
- package/dist/lib/manipulator.js +22 -23
- package/dist/lib/manipulator.js.map +1 -1
- package/dist/lib/manipulator.test.js +59 -63
- package/dist/lib/manipulator.test.js.map +1 -1
- package/dist/lib/numbering.js +9 -48
- package/dist/lib/numbering.js.map +1 -1
- package/dist/lib/numbering.test.js +27 -44
- package/dist/lib/numbering.test.js.map +1 -1
- package/dist/lib/prompt.js +14 -0
- package/dist/lib/prompt.js.map +1 -0
- package/dist/lib/prompt.test.js +33 -0
- package/dist/lib/prompt.test.js.map +1 -0
- package/dist/lib/template.js +13 -26
- package/dist/lib/template.js.map +1 -1
- package/dist/types/lib/adr.d.ts.map +1 -1
- package/dist/types/lib/config.d.ts.map +1 -1
- package/dist/types/lib/links.d.ts.map +1 -1
- package/dist/types/lib/manipulator.d.ts.map +1 -1
- package/dist/types/lib/numbering.d.ts.map +1 -1
- package/dist/types/lib/prompt.d.ts +2 -0
- package/dist/types/lib/prompt.d.ts.map +1 -0
- package/dist/types/lib/prompt.test.d.ts +2 -0
- package/dist/types/lib/prompt.test.d.ts.map +1 -0
- package/dist/types/lib/template.d.ts.map +1 -1
- package/dist/types/version.d.ts +1 -1
- package/dist/version.js +1 -4
- package/dist/version.js.map +1 -1
- package/doc/adr/.adr-sequence.lock +1 -0
- package/doc/adr/decisions.md +3 -0
- package/package.json +73 -47
- package/src/index.ts +52 -27
- package/src/lib/adr.ts +68 -73
- package/src/lib/config.ts +3 -3
- package/src/lib/links.test.ts +8 -24
- package/src/lib/links.ts +2 -2
- package/src/lib/manipulator.test.ts +44 -47
- package/src/lib/manipulator.ts +22 -10
- package/src/lib/numbering.test.ts +5 -9
- package/src/lib/numbering.ts +4 -5
- package/src/lib/prompt.test.ts +42 -0
- package/src/lib/prompt.ts +14 -0
- package/src/lib/template.ts +7 -3
- package/src/version.ts +1 -1
- package/tests/.adr-dir +1 -0
- package/tests/__snapshots__/generate-graph.e2e.test.ts.snap +23 -23
- package/tests/__snapshots__/init-adr-repository.e2e.test.ts.snap +1 -1
- package/tests/__snapshots__/linking-records.e2e.test.ts.snap +1 -1
- package/tests/__snapshots__/new-adr.e2e.test.ts.snap +1 -1
- package/tests/__snapshots__/superseding-records.e2e.test.ts.snap +1 -1
- package/tests/__snapshots__/toc-prefixing.e2e.test.ts.snap +1 -1
- package/tests/__snapshots__/use-template-override.e2e.test.ts.snap +1 -1
- package/tests/edit-on-create.e2e.test.ts +17 -12
- package/tests/funny-characters.e2e.test.ts +28 -21
- package/tests/generate-graph.e2e.test.ts +21 -13
- package/tests/init-adr-repository.e2e.test.ts +12 -8
- package/tests/linking-records.e2e.test.ts +21 -14
- package/tests/list-adrs.e2e.test.ts +23 -18
- package/tests/new-adr.e2e.test.ts +15 -12
- package/tests/superseding-records.e2e.test.ts +16 -11
- package/tests/toc-prefixing.e2e.test.ts +15 -11
- package/tests/use-template-override.e2e.test.ts +18 -10
- package/tests/work-form-other-directories.e2e.test.ts +14 -12
- package/tsconfig.json +9 -8
- package/vitest.config.e2e.ts +13 -0
- package/vitest.config.ts +8 -1
- package/.eslintignore +0 -2
- package/.eslintrc.json +0 -23
- package/.github/dependabot.yml +0 -14
- package/.github/workflows/auto-merge.yml +0 -14
- package/doc/adr/0001-record-architecture-decisions.md +0 -21
- package/doc/adr/0002-using-heavy-e2e-tests.md +0 -20
- /package/src/{environment.d.ts → types/environment.d.ts} +0 -0
package/src/lib/adr.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { newNumber } from './numbering';
|
|
2
|
-
import { template } from './template';
|
|
3
|
-
import fs from 'fs/promises';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { getDir, workingDir } from './config';
|
|
6
|
-
import { prompt } from 'inquirer';
|
|
7
|
-
import chalk from 'chalk';
|
|
8
|
-
import { getTitleFrom, injectLink, supersede } from './manipulator';
|
|
9
|
-
import { findMatchingFilesFor, getLinkDetails } from './links';
|
|
10
1
|
import childProcess from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
import { getDir, workingDir } from './config.js';
|
|
6
|
+
import { findMatchingFilesFor, getLinkDetails } from './links.js';
|
|
7
|
+
import { getTitleFrom, injectLink, supersede } from './manipulator.js';
|
|
8
|
+
import { newNumber } from './numbering.js';
|
|
9
|
+
import { askForClarification } from './prompt.js';
|
|
10
|
+
import { template } from './template.js';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
13
|
|
|
12
14
|
interface NewOptions {
|
|
13
15
|
supersedes?: string[];
|
|
@@ -17,18 +19,6 @@ interface NewOptions {
|
|
|
17
19
|
links?: string[];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const askForClarification = async (searchString: string, matches: string[]) => {
|
|
21
|
-
const selection = await prompt([
|
|
22
|
-
{
|
|
23
|
-
type: 'list',
|
|
24
|
-
name: 'target',
|
|
25
|
-
message: `Which file do you want to link to for ${chalk.blue(searchString)}?`,
|
|
26
|
-
choices: matches
|
|
27
|
-
}
|
|
28
|
-
]);
|
|
29
|
-
return selection.target;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
22
|
// eslint-disable-next-line no-unused-vars
|
|
33
23
|
enum LinkType {
|
|
34
24
|
// eslint-disable-next-line no-unused-vars
|
|
@@ -51,14 +41,21 @@ const actuallyLink = async (task: LinkTask) => {
|
|
|
51
41
|
const oldAdrContent = await fs.readFile(linkedFile, 'utf8');
|
|
52
42
|
const oldTitle = getTitleFrom(oldAdrContent);
|
|
53
43
|
const newTitle = getTitleFrom(newAdrContent);
|
|
54
|
-
let dirtyOld = '',
|
|
44
|
+
let dirtyOld = '',
|
|
45
|
+
dirtyNew = '';
|
|
55
46
|
switch (task.type) {
|
|
56
47
|
case LinkType.LINK:
|
|
57
|
-
dirtyOld = injectLink(
|
|
48
|
+
dirtyOld = injectLink(
|
|
49
|
+
oldAdrContent,
|
|
50
|
+
`${task.reverseLink} [${newTitle}](${path.relative(await getDir(), task.sourcePath)})`
|
|
51
|
+
);
|
|
58
52
|
dirtyNew = injectLink(newAdrContent, `${task.link} [${oldTitle}](${task.targetPath})`);
|
|
59
53
|
break;
|
|
60
54
|
case LinkType.SUPERSEDE:
|
|
61
|
-
dirtyOld = supersede(
|
|
55
|
+
dirtyOld = supersede(
|
|
56
|
+
oldAdrContent,
|
|
57
|
+
`${task.reverseLink} [${newTitle}](${path.relative(await getDir(), task.sourcePath)})`
|
|
58
|
+
);
|
|
62
59
|
dirtyNew = injectLink(newAdrContent, `${task.link} [${oldTitle}](${task.targetPath})`);
|
|
63
60
|
break;
|
|
64
61
|
default:
|
|
@@ -69,11 +66,7 @@ const actuallyLink = async (task: LinkTask) => {
|
|
|
69
66
|
await fs.writeFile(task.sourcePath, dirtyNew);
|
|
70
67
|
};
|
|
71
68
|
|
|
72
|
-
const processSupersedes = async (
|
|
73
|
-
sourcePath: string,
|
|
74
|
-
supersedes: string[] = [],
|
|
75
|
-
suppressPrompts: boolean = false
|
|
76
|
-
) => {
|
|
69
|
+
const processSupersedes = async (sourcePath: string, supersedes: string[] = [], suppressPrompts: boolean = false) => {
|
|
77
70
|
if (supersedes.length === 0) {
|
|
78
71
|
return;
|
|
79
72
|
}
|
|
@@ -91,22 +84,19 @@ const processSupersedes = async (
|
|
|
91
84
|
|
|
92
85
|
if (targetDetails.matches.length > 1) {
|
|
93
86
|
if (suppressPrompts) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Multiple files match the search pattern for "${targetDetails.original}".\n` +
|
|
89
|
+
'Please specify which file you want to targetDetails to more or remove the -q or --quiet options from the command line.'
|
|
90
|
+
);
|
|
98
91
|
}
|
|
92
|
+
task.targetPath = await askForClarification(targetDetails.original, targetDetails.matches);
|
|
99
93
|
}
|
|
100
94
|
|
|
101
95
|
await actuallyLink(task);
|
|
102
96
|
}
|
|
103
97
|
};
|
|
104
98
|
|
|
105
|
-
const injectLinksTo = async (
|
|
106
|
-
sourcePath: string,
|
|
107
|
-
links: string[] = [],
|
|
108
|
-
suppressPrompts: boolean = false
|
|
109
|
-
) => {
|
|
99
|
+
const injectLinksTo = async (sourcePath: string, links: string[] = [], suppressPrompts: boolean = false) => {
|
|
110
100
|
if (links.length === 0) {
|
|
111
101
|
return;
|
|
112
102
|
}
|
|
@@ -124,23 +114,22 @@ const injectLinksTo = async (
|
|
|
124
114
|
|
|
125
115
|
if (targetDetails.matches.length > 1) {
|
|
126
116
|
if (suppressPrompts) {
|
|
127
|
-
throw new Error(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Multiple files match the search pattern for "${targetDetails.original}".\n` +
|
|
119
|
+
'Please specify which file you want to targetDetails to more or remove the -q or --quiet options from the command line.'
|
|
120
|
+
);
|
|
131
121
|
}
|
|
122
|
+
task.targetPath = await askForClarification(targetDetails.original, targetDetails.matches);
|
|
132
123
|
}
|
|
133
124
|
|
|
134
125
|
await actuallyLink(task);
|
|
135
126
|
}
|
|
136
|
-
|
|
137
127
|
};
|
|
138
128
|
//Generate a table of contents for the adr directory
|
|
139
|
-
export const generateToc = async (options?: {prefix?: string}) => {
|
|
140
|
-
|
|
129
|
+
export const generateToc = async (options?: { prefix?: string }) => {
|
|
141
130
|
const adrDir = await getDir();
|
|
142
131
|
const files = await fs.readdir(adrDir);
|
|
143
|
-
const toc = files.filter((file) => file.match(/^\d{4}-.*\.md$/));
|
|
132
|
+
const toc = files.filter((file) => file.match(/^\d{4}-.*\.md$/)).sort();
|
|
144
133
|
|
|
145
134
|
const titles = toc.map(async (file) => {
|
|
146
135
|
const title = getTitleFrom(await fs.readFile(path.join(adrDir, file), 'utf8'));
|
|
@@ -157,27 +146,26 @@ export const newAdr = async (title: string, config?: NewOptions) => {
|
|
|
157
146
|
const newNum = await newNumber();
|
|
158
147
|
const formattedDate = config?.date || new Date().toISOString().split('T')[0] || 'ERROR';
|
|
159
148
|
const tpl = await template(config?.template);
|
|
160
|
-
const adr = tpl
|
|
149
|
+
const adr = tpl
|
|
150
|
+
.replace('DATE', formattedDate)
|
|
151
|
+
.replace('TITLE', title)
|
|
152
|
+
.replace('NUMBER', newNum.toString())
|
|
153
|
+
.replace('STATUS', 'Accepted');
|
|
161
154
|
const paddedNumber = newNum.toString().padStart(4, '0');
|
|
162
|
-
const cleansedTitle = title
|
|
155
|
+
const cleansedTitle = title
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
.replace(/\W/g, '-')
|
|
158
|
+
.replace(/^(.*)\W$/, '$1')
|
|
159
|
+
.replace(/^\W(.*)$/, '$1');
|
|
163
160
|
const fileName = `${paddedNumber}-${cleansedTitle}.md`;
|
|
164
161
|
const adrDirectory = await getDir();
|
|
165
162
|
const adrPath = path.resolve(path.join(adrDirectory, fileName));
|
|
166
163
|
await fs.writeFile(adrPath, adr);
|
|
167
164
|
await fs.writeFile(path.resolve(adrDirectory, '.adr-sequence.lock'), newNum.toString());
|
|
168
165
|
|
|
169
|
-
await processSupersedes(
|
|
170
|
-
|
|
171
|
-
config?.supersedes,
|
|
172
|
-
config?.suppressPrompts
|
|
173
|
-
);
|
|
174
|
-
await injectLinksTo(
|
|
175
|
-
adrPath,
|
|
176
|
-
config?.links,
|
|
177
|
-
config?.suppressPrompts
|
|
178
|
-
);
|
|
166
|
+
await processSupersedes(adrPath, config?.supersedes, config?.suppressPrompts);
|
|
167
|
+
await injectLinksTo(adrPath, config?.links, config?.suppressPrompts);
|
|
179
168
|
await generateToc();
|
|
180
|
-
const newAdrPath = path.relative(workingDir(), adrPath);
|
|
181
169
|
|
|
182
170
|
if (process.env.VISUAL) {
|
|
183
171
|
await childProcess.spawn(process.env.VISUAL, [adrPath], {
|
|
@@ -193,12 +181,10 @@ export const newAdr = async (title: string, config?: NewOptions) => {
|
|
|
193
181
|
});
|
|
194
182
|
return;
|
|
195
183
|
}
|
|
196
|
-
console.log(newAdrPath);
|
|
197
|
-
|
|
198
184
|
};
|
|
199
185
|
|
|
200
186
|
export const init = async (directory?: string) => {
|
|
201
|
-
const dir = directory || await getDir();
|
|
187
|
+
const dir = directory || (await getDir());
|
|
202
188
|
await fs.mkdir(dir, { recursive: true });
|
|
203
189
|
await fs.writeFile(path.join(workingDir(), '.adr-dir'), path.relative(workingDir(), dir));
|
|
204
190
|
await newAdr('Record Architecture Decisions', {
|
|
@@ -207,7 +193,13 @@ export const init = async (directory?: string) => {
|
|
|
207
193
|
});
|
|
208
194
|
};
|
|
209
195
|
|
|
210
|
-
export const link = async (
|
|
196
|
+
export const link = async (
|
|
197
|
+
source: string,
|
|
198
|
+
link: string,
|
|
199
|
+
target: string,
|
|
200
|
+
reverseLink: string,
|
|
201
|
+
options?: { quiet?: boolean }
|
|
202
|
+
) => {
|
|
211
203
|
const getFor = async (pattern: string) => {
|
|
212
204
|
const found = await findMatchingFilesFor(pattern);
|
|
213
205
|
if (found.length === 1) {
|
|
@@ -215,8 +207,10 @@ export const link = async (source: string, link: string, target: string, reverse
|
|
|
215
207
|
}
|
|
216
208
|
|
|
217
209
|
if (options?.quiet) {
|
|
218
|
-
throw new Error(
|
|
219
|
-
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Multiple files match the search pattern for "${pattern}".\n` +
|
|
212
|
+
'Please specify which file you want to targetDetails to more or remove the -q or --quiet options from the command line.'
|
|
213
|
+
);
|
|
220
214
|
}
|
|
221
215
|
|
|
222
216
|
return askForClarification(pattern, found);
|
|
@@ -225,18 +219,19 @@ export const link = async (source: string, link: string, target: string, reverse
|
|
|
225
219
|
const sourceFile = await getFor(source);
|
|
226
220
|
|
|
227
221
|
await injectLinksTo(path.resolve(await getDir(), sourceFile), [`${target}:${link}:${reverseLink}`]);
|
|
228
|
-
|
|
229
222
|
};
|
|
230
223
|
|
|
231
224
|
export const listAdrs = async () => {
|
|
232
225
|
const dir = await getDir();
|
|
233
226
|
const files = await fs.readdir(dir);
|
|
234
|
-
const toc = files
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
227
|
+
const toc = files
|
|
228
|
+
.map((f) => {
|
|
229
|
+
const adrFile = f.match(/^0*(\d+)-.*$/);
|
|
230
|
+
if (adrFile) {
|
|
231
|
+
return path.resolve(dir, adrFile[0]);
|
|
232
|
+
}
|
|
233
|
+
return '';
|
|
234
|
+
})
|
|
235
|
+
.filter((f) => f !== '');
|
|
241
236
|
return toc;
|
|
242
237
|
};
|
package/src/lib/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
1
|
import { constants } from 'fs';
|
|
3
2
|
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
4
|
|
|
5
5
|
export const workingDir = () => process.cwd();
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ const findTopLevelDir = async (dir: string): Promise<string> => {
|
|
|
8
8
|
try {
|
|
9
9
|
await fs.access(path.join(dir, '.adr-dir'), constants.F_OK);
|
|
10
10
|
return dir;
|
|
11
|
-
} catch (
|
|
11
|
+
} catch (_e) {
|
|
12
12
|
if (dir === '/') {
|
|
13
13
|
throw new Error('No ADR directory config found');
|
|
14
14
|
}
|
|
@@ -22,7 +22,7 @@ const getDirPath = async (): Promise<string> => {
|
|
|
22
22
|
const configDir = await findTopLevelDir(workingDir());
|
|
23
23
|
const configFile = await fs.readFile(path.join(configDir, '.adr-dir'), 'utf8');
|
|
24
24
|
return path.relative(workingDir(), path.join(configDir, configFile.trim()));
|
|
25
|
-
} catch (
|
|
25
|
+
} catch (_e) {
|
|
26
26
|
return path.resolve(path.join(workingDir(), 'doc/adr'));
|
|
27
27
|
}
|
|
28
28
|
};
|
package/src/lib/links.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { vi, describe, it, expect, afterEach } from 'vitest';
|
|
2
1
|
import fs from 'fs/promises';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { getDir } from './config.js';
|
|
4
|
+
import { getLinkDetails } from './links.js';
|
|
5
5
|
|
|
6
|
-
vi.mock('./config');
|
|
6
|
+
vi.mock('./config.js');
|
|
7
7
|
vi.mock('fs/promises');
|
|
8
8
|
|
|
9
9
|
describe('The link lib', () => {
|
|
@@ -27,11 +27,7 @@ describe('The link lib', () => {
|
|
|
27
27
|
|
|
28
28
|
it('does handles multiple matches', async () => {
|
|
29
29
|
vi.mocked(getDir).mockResolvedValueOnce('/');
|
|
30
|
-
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
|
31
|
-
'1-one',
|
|
32
|
-
'1-two',
|
|
33
|
-
'1-three'
|
|
34
|
-
] as any);
|
|
30
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce(['1-one', '1-two', '1-three'] as any);
|
|
35
31
|
const linkString = '1:overrides:overriden by';
|
|
36
32
|
const response = await getLinkDetails(linkString);
|
|
37
33
|
expect(response).toEqual({
|
|
@@ -39,21 +35,13 @@ describe('The link lib', () => {
|
|
|
39
35
|
original: '1:overrides:overriden by',
|
|
40
36
|
link: 'overrides',
|
|
41
37
|
reverseLink: 'overriden by',
|
|
42
|
-
matches: [
|
|
43
|
-
'1-one',
|
|
44
|
-
'1-two',
|
|
45
|
-
'1-three'
|
|
46
|
-
]
|
|
38
|
+
matches: ['1-one', '1-two', '1-three']
|
|
47
39
|
});
|
|
48
40
|
});
|
|
49
41
|
|
|
50
42
|
it('returns only files that match the pattern', async () => {
|
|
51
43
|
vi.mocked(getDir).mockResolvedValueOnce('/');
|
|
52
|
-
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
|
53
|
-
'on1e',
|
|
54
|
-
'1-two',
|
|
55
|
-
'three'
|
|
56
|
-
] as any);
|
|
44
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce(['on1e', '1-two', 'three'] as any);
|
|
57
45
|
const linkString = '1:overrides:overriden by';
|
|
58
46
|
const response = await getLinkDetails(linkString);
|
|
59
47
|
expect(response).toEqual({
|
|
@@ -61,10 +49,7 @@ describe('The link lib', () => {
|
|
|
61
49
|
original: '1:overrides:overriden by',
|
|
62
50
|
link: 'overrides',
|
|
63
51
|
reverseLink: 'overriden by',
|
|
64
|
-
matches: [
|
|
65
|
-
'on1e',
|
|
66
|
-
'1-two'
|
|
67
|
-
]
|
|
52
|
+
matches: ['on1e', '1-two']
|
|
68
53
|
});
|
|
69
54
|
});
|
|
70
55
|
|
|
@@ -82,5 +67,4 @@ describe('The link lib', () => {
|
|
|
82
67
|
matches: []
|
|
83
68
|
});
|
|
84
69
|
});
|
|
85
|
-
|
|
86
70
|
});
|
package/src/lib/links.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
|
-
import { getDir } from './config';
|
|
2
|
+
import { getDir } from './config.js';
|
|
3
3
|
|
|
4
4
|
export interface LinkDetails {
|
|
5
5
|
pattern: string;
|
|
@@ -11,7 +11,7 @@ export interface LinkDetails {
|
|
|
11
11
|
|
|
12
12
|
export const findMatchingFilesFor = async (pattern: string) => {
|
|
13
13
|
const files = await fs.readdir(await getDir());
|
|
14
|
-
return files.filter(file => file.includes(pattern));
|
|
14
|
+
return files.filter((file) => file.includes(pattern));
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export const getLinkDetails = async (linkString: string, isSupersede: boolean = false): Promise<LinkDetails> => {
|
|
@@ -1,49 +1,50 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { getLinksFrom, getTitleFrom, injectLink } from './manipulator.js';
|
|
3
3
|
|
|
4
4
|
describe('The ADR manipulator', () => {
|
|
5
|
+
const original =
|
|
6
|
+
'# NUMBER. TITLE\n' +
|
|
7
|
+
'\n' +
|
|
8
|
+
'Date: DATE\n' +
|
|
9
|
+
'\n' +
|
|
10
|
+
'## Status\n' +
|
|
11
|
+
'\n' +
|
|
12
|
+
'STATUS\n' +
|
|
13
|
+
'\n' +
|
|
14
|
+
'## Context\n' +
|
|
15
|
+
'\n' +
|
|
16
|
+
'The issue motivating this decision, and any context that influences or constrains the decision.\n' +
|
|
17
|
+
'\n' +
|
|
18
|
+
'## Decision\n' +
|
|
19
|
+
'\n' +
|
|
20
|
+
"The change that we're proposing or have agreed to implement.\n" +
|
|
21
|
+
'\n' +
|
|
22
|
+
'## Consequences\n' +
|
|
23
|
+
'\n' +
|
|
24
|
+
'What becomes easier or more difficult to do and any risks introduced by the change that will need to be mitigated.\n';
|
|
5
25
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
+ 'Date: DATE\n'
|
|
29
|
-
+ '\n'
|
|
30
|
-
+ '## Status\n'
|
|
31
|
-
+ '\n'
|
|
32
|
-
+ 'STATUS\n'
|
|
33
|
-
+ '\n'
|
|
34
|
-
+ 'INJECTED STUFF\n'
|
|
35
|
-
+ '\n'
|
|
36
|
-
+ '## Context\n'
|
|
37
|
-
+ '\n'
|
|
38
|
-
+ 'The issue motivating this decision, and any context that influences or constrains the decision.\n'
|
|
39
|
-
+ '\n'
|
|
40
|
-
+ '## Decision\n'
|
|
41
|
-
+ '\n'
|
|
42
|
-
+ 'The change that we\'re proposing or have agreed to implement.\n'
|
|
43
|
-
+ '\n'
|
|
44
|
-
+ '## Consequences\n'
|
|
45
|
-
+ '\n'
|
|
46
|
-
+ 'What becomes easier or more difficult to do and any risks introduced by the change that will need to be mitigated.\n';
|
|
26
|
+
const modified =
|
|
27
|
+
'# NUMBER. TITLE\n' +
|
|
28
|
+
'\n' +
|
|
29
|
+
'Date: DATE\n' +
|
|
30
|
+
'\n' +
|
|
31
|
+
'## Status\n' +
|
|
32
|
+
'\n' +
|
|
33
|
+
'STATUS\n' +
|
|
34
|
+
'\n' +
|
|
35
|
+
'INJECTED STUFF\n' +
|
|
36
|
+
'\n' +
|
|
37
|
+
'## Context\n' +
|
|
38
|
+
'\n' +
|
|
39
|
+
'The issue motivating this decision, and any context that influences or constrains the decision.\n' +
|
|
40
|
+
'\n' +
|
|
41
|
+
'## Decision\n' +
|
|
42
|
+
'\n' +
|
|
43
|
+
"The change that we're proposing or have agreed to implement.\n" +
|
|
44
|
+
'\n' +
|
|
45
|
+
'## Consequences\n' +
|
|
46
|
+
'\n' +
|
|
47
|
+
'What becomes easier or more difficult to do and any risks introduced by the change that will need to be mitigated.\n';
|
|
47
48
|
|
|
48
49
|
it('can inject links to the status section', () => {
|
|
49
50
|
const test = injectLink(original, 'INJECTED STUFF');
|
|
@@ -70,9 +71,7 @@ describe('The ADR manipulator', () => {
|
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
it('can extract links from the status section', () => {
|
|
73
|
-
const original = '## Status\n'
|
|
74
|
-
+ '\n'
|
|
75
|
-
+ 'Superseded by [3. title here](and-a-link-here.md)\n';
|
|
74
|
+
const original = '## Status\n' + '\n' + 'Superseded by [3. title here](and-a-link-here.md)\n';
|
|
76
75
|
const extractedLink = getLinksFrom(original);
|
|
77
76
|
|
|
78
77
|
expect(extractedLink[0]).toEqual({
|
|
@@ -82,7 +81,5 @@ describe('The ADR manipulator', () => {
|
|
|
82
81
|
href: 'and-a-link-here.md',
|
|
83
82
|
raw: 'Superseded by [3. title here](and-a-link-here.md)'
|
|
84
83
|
});
|
|
85
|
-
|
|
86
84
|
});
|
|
87
|
-
|
|
88
85
|
});
|
package/src/lib/manipulator.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { marked } from 'marked';
|
|
2
2
|
|
|
3
3
|
const convertToMd = (tokens: marked.Token[]) => {
|
|
4
|
-
return tokens.map(token => token.raw).join('');
|
|
4
|
+
return tokens.map((token) => token.raw).join('');
|
|
5
5
|
};
|
|
6
6
|
|
|
7
7
|
export const getLinksFrom = (markdown: string) => {
|
|
8
8
|
const tokens = marked.lexer(markdown);
|
|
9
9
|
const linkRegex = new RegExp(/^(.*)\s\[(.*)\]\((.*)\)$/);
|
|
10
|
-
const links = tokens.filter(token => token.type === 'paragraph' && linkRegex.test(token.text));
|
|
10
|
+
const links = tokens.filter((token) => token.type === 'paragraph' && linkRegex.test(token.text));
|
|
11
11
|
|
|
12
12
|
return links.map((link) => {
|
|
13
13
|
const linkToken = link as marked.Tokens.Paragraph;
|
|
@@ -27,7 +27,7 @@ export const getLinksFrom = (markdown: string) => {
|
|
|
27
27
|
|
|
28
28
|
export const getTitleFrom = (adr: string) => {
|
|
29
29
|
const tokens = marked.lexer(adr);
|
|
30
|
-
const mainHead = tokens.find(token => token.type === 'heading' && token.depth === 1);
|
|
30
|
+
const mainHead = tokens.find((token) => token.type === 'heading' && token.depth === 1);
|
|
31
31
|
if (!mainHead) {
|
|
32
32
|
throw new Error('No main heading found');
|
|
33
33
|
}
|
|
@@ -36,20 +36,27 @@ export const getTitleFrom = (adr: string) => {
|
|
|
36
36
|
|
|
37
37
|
export const supersede = (markdown: string, link: string) => {
|
|
38
38
|
const tokens = marked.lexer(markdown);
|
|
39
|
-
const statusIndex = tokens.findIndex(token => token.type === 'heading' && token.text.toLowerCase() === 'status');
|
|
39
|
+
const statusIndex = tokens.findIndex((token) => token.type === 'heading' && token.text.toLowerCase() === 'status');
|
|
40
40
|
if (statusIndex < 0) {
|
|
41
41
|
throw new Error('Could not find status section');
|
|
42
42
|
}
|
|
43
43
|
const statusDepth = (tokens[statusIndex] as marked.Tokens.Heading).depth;
|
|
44
|
-
const followingHeadingIndex = tokens.findIndex(
|
|
45
|
-
|
|
44
|
+
const followingHeadingIndex = tokens.findIndex(
|
|
45
|
+
(token, index) => token.type === 'heading' && token.depth === statusDepth && index > statusIndex
|
|
46
|
+
);
|
|
47
|
+
const followingParagraphIndex = tokens.findIndex(
|
|
48
|
+
(token, index) => token.type === 'paragraph' && index > statusIndex && index < followingHeadingIndex
|
|
49
|
+
);
|
|
46
50
|
|
|
47
51
|
if (followingParagraphIndex > followingHeadingIndex || followingParagraphIndex === -1) {
|
|
48
52
|
throw new Error('There is no status paragraph. Please format your adr properly');
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
tokens[followingParagraphIndex] = {
|
|
52
|
-
type: 'paragraph',
|
|
56
|
+
type: 'paragraph',
|
|
57
|
+
text: link,
|
|
58
|
+
raw: link,
|
|
59
|
+
tokens: [
|
|
53
60
|
{
|
|
54
61
|
type: 'text',
|
|
55
62
|
raw: link,
|
|
@@ -62,18 +69,23 @@ export const supersede = (markdown: string, link: string) => {
|
|
|
62
69
|
|
|
63
70
|
export const injectLink = (markdown: string, link: string) => {
|
|
64
71
|
const tokens = marked.lexer(markdown);
|
|
65
|
-
const statusIndex = tokens.findIndex(token => token.type === 'heading' && token.text.toLowerCase() === 'status');
|
|
72
|
+
const statusIndex = tokens.findIndex((token) => token.type === 'heading' && token.text.toLowerCase() === 'status');
|
|
66
73
|
if (statusIndex < 0) {
|
|
67
74
|
throw new Error('Could not find status section');
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
const statusDepth = (tokens[statusIndex] as marked.Tokens.Heading).depth;
|
|
71
|
-
let followingHeadingIndex = tokens.findIndex(
|
|
78
|
+
let followingHeadingIndex = tokens.findIndex(
|
|
79
|
+
(token, index) => token.type === 'heading' && token.depth === statusDepth && index > statusIndex
|
|
80
|
+
);
|
|
72
81
|
if (followingHeadingIndex < 0) {
|
|
73
82
|
followingHeadingIndex = tokens.length;
|
|
74
83
|
}
|
|
75
84
|
tokens.splice(followingHeadingIndex, 0, {
|
|
76
|
-
type: 'paragraph',
|
|
85
|
+
type: 'paragraph',
|
|
86
|
+
text: link,
|
|
87
|
+
raw: link,
|
|
88
|
+
tokens: [
|
|
77
89
|
{
|
|
78
90
|
type: 'text',
|
|
79
91
|
raw: link,
|
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import { afterAll, describe, it, vi, expect } from 'vitest';
|
|
2
1
|
import fs from 'fs/promises';
|
|
3
|
-
import {
|
|
2
|
+
import { afterAll, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { newNumber } from './numbering.js';
|
|
4
4
|
|
|
5
|
-
type ReaddirMock = () => Promise<
|
|
5
|
+
type ReaddirMock = () => Promise<string[]>;
|
|
6
6
|
|
|
7
7
|
vi.mock('fs/promises');
|
|
8
|
-
vi.mock('./config', () => ({
|
|
8
|
+
vi.mock('./config.js', () => ({
|
|
9
9
|
getDir: vi.fn().mockResolvedValue('/')
|
|
10
10
|
}));
|
|
11
11
|
|
|
12
12
|
describe('The numbering logic', () => {
|
|
13
|
-
|
|
14
13
|
afterAll(() => {
|
|
15
14
|
vi.resetAllMocks();
|
|
16
15
|
});
|
|
@@ -29,10 +28,7 @@ describe('The numbering logic', () => {
|
|
|
29
28
|
});
|
|
30
29
|
|
|
31
30
|
it('processes existing files if there is no lockfile', async () => {
|
|
32
|
-
const fakeFiles: string[] = [
|
|
33
|
-
'0001-first-file.md',
|
|
34
|
-
'0002-first-file.md'
|
|
35
|
-
];
|
|
31
|
+
const fakeFiles: string[] = ['0001-first-file.md', '0002-first-file.md'];
|
|
36
32
|
vi.mocked(fs.readFile).mockRejectedValueOnce('no sequence file');
|
|
37
33
|
vi.mocked(fs.readdir as unknown as ReaddirMock).mockResolvedValueOnce(fakeFiles);
|
|
38
34
|
const num = await newNumber();
|
package/src/lib/numbering.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import { getDir } from './config';
|
|
3
1
|
import * as path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { getDir } from './config.js';
|
|
4
4
|
|
|
5
5
|
export const newNumber = async () => {
|
|
6
6
|
try {
|
|
7
7
|
const lockfile = await fs.readFile(path.resolve(await getDir(), '.adr-sequence.lock'));
|
|
8
8
|
return parseInt(lockfile.toString().trim(), 10) + 1;
|
|
9
|
-
} catch (
|
|
9
|
+
} catch (_e) {
|
|
10
10
|
// This is for backward compatibility. If someone upgrades from an older tool without a lockfile,
|
|
11
11
|
// we create one.
|
|
12
12
|
const filePattern = /^0*(\d+)-.*$/;
|
|
13
13
|
const files = await fs.readdir(await getDir());
|
|
14
|
-
const numbers = files.map(f => {
|
|
14
|
+
const numbers = files.map((f) => {
|
|
15
15
|
const adrFile = f.match(filePattern);
|
|
16
16
|
if (adrFile) {
|
|
17
17
|
return parseInt(adrFile[1], 10);
|
|
@@ -22,5 +22,4 @@ export const newNumber = async () => {
|
|
|
22
22
|
const largestNumber = numbers.reduce((a, b) => Math.max(a, b), 0);
|
|
23
23
|
return largestNumber + 1;
|
|
24
24
|
}
|
|
25
|
-
|
|
26
25
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import inquirer, { ListQuestion, Question } from 'inquirer';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { askForClarification } from './prompt.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('inquirer');
|
|
7
|
+
vi.mock('chalk', () => ({
|
|
8
|
+
default: {
|
|
9
|
+
blue: (str: string) => {
|
|
10
|
+
return `BLUE[${str}]`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}));
|
|
14
|
+
describe('The prompt helper', () => {
|
|
15
|
+
it('calls inquirer correctly', async () => {
|
|
16
|
+
const prompt = vi.mocked(inquirer.prompt);
|
|
17
|
+
prompt.mockResolvedValueOnce({ target: 'does not matter' });
|
|
18
|
+
|
|
19
|
+
const randomChoices = [faker.system.filePath(), faker.system.filePath(), faker.system.filePath()];
|
|
20
|
+
await askForClarification('this is the search string', randomChoices);
|
|
21
|
+
|
|
22
|
+
expect(prompt).toHaveBeenCalledOnce();
|
|
23
|
+
const calledWith = prompt.mock.calls[0][0] as Question[];
|
|
24
|
+
expect(calledWith[0].type).toEqual('list');
|
|
25
|
+
expect((calledWith[0] as ListQuestion).choices).toEqual(randomChoices);
|
|
26
|
+
expect(calledWith[0].message).toMatchInlineSnapshot(
|
|
27
|
+
'"Which file do you want to link to for BLUE[this is the search string]?"'
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns the selected choice', async () => {
|
|
32
|
+
const prompt = vi.mocked(inquirer.prompt);
|
|
33
|
+
|
|
34
|
+
const randomResult = faker.system.filePath();
|
|
35
|
+
|
|
36
|
+
prompt.mockResolvedValueOnce({ target: randomResult });
|
|
37
|
+
|
|
38
|
+
const result = await askForClarification('this is the search string', ['does not matter']);
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual(randomResult);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
|
|
4
|
+
export const askForClarification = async (searchString: string, matches: string[]) => {
|
|
5
|
+
const selection = await inquirer.prompt([
|
|
6
|
+
{
|
|
7
|
+
type: 'list',
|
|
8
|
+
name: 'target',
|
|
9
|
+
message: `Which file do you want to link to for ${chalk.blue(searchString)}?`,
|
|
10
|
+
choices: matches
|
|
11
|
+
}
|
|
12
|
+
]);
|
|
13
|
+
return selection.target;
|
|
14
|
+
};
|