@meza/adr-tools 1.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.
- package/.adr-dir +1 -0
- package/.editorconfig +13 -0
- package/.eslintignore +2 -0
- package/.eslintrc.json +23 -0
- package/.github/dependabot.yml +14 -0
- package/.github/stale.yml +18 -0
- package/.github/workflows/auto-merge.yml +14 -0
- package/.github/workflows/ci.yml +89 -0
- package/.husky/commit-msg +4 -0
- package/.releaserc.json +41 -0
- package/README.md +30 -0
- package/doc/adr/0001-record-architecture-decisions.md +21 -0
- package/doc/adr/0002-using-heavy-e2e-tests.md +20 -0
- package/docs/CHANGELOG.md +6 -0
- package/package.json +91 -0
- package/src/environment.d.ts +14 -0
- package/src/index.ts +115 -0
- package/src/lib/adr.ts +242 -0
- package/src/lib/config.ts +34 -0
- package/src/lib/links.test.ts +86 -0
- package/src/lib/links.ts +34 -0
- package/src/lib/manipulator.test.ts +88 -0
- package/src/lib/manipulator.ts +86 -0
- package/src/lib/numbering.test.ts +41 -0
- package/src/lib/numbering.ts +26 -0
- package/src/lib/template.ts +18 -0
- package/src/templates/init.md +21 -0
- package/src/templates/template.md +19 -0
- package/src/version.ts +1 -0
- package/tests/__snapshots__/generate-graph.e2e.test.ts.snap +39 -0
- package/tests/__snapshots__/init-adr-repository.e2e.test.ts.snap +51 -0
- package/tests/__snapshots__/linking-records.e2e.test.ts.snap +155 -0
- package/tests/__snapshots__/new-adr.e2e.test.ts.snap +54 -0
- package/tests/__snapshots__/superseding-records.e2e.test.ts.snap +122 -0
- package/tests/__snapshots__/toc-prefixing.e2e.test.ts.snap +9 -0
- package/tests/__snapshots__/use-template-override.e2e.test.ts.snap +17 -0
- package/tests/edit-on-create.e2e.test.ts +54 -0
- package/tests/fake-editor +3 -0
- package/tests/fake-visual +3 -0
- package/tests/funny-characters.e2e.test.ts +43 -0
- package/tests/generate-graph.e2e.test.ts +45 -0
- package/tests/init-adr-repository.e2e.test.ts +53 -0
- package/tests/linking-records.e2e.test.ts +64 -0
- package/tests/list-adrs.e2e.test.ts +48 -0
- package/tests/new-adr.e2e.test.ts +58 -0
- package/tests/superseding-records.e2e.test.ts +58 -0
- package/tests/toc-prefixing.e2e.test.ts +38 -0
- package/tests/use-template-override.e2e.test.ts +43 -0
- package/tests/work-form-other-directories.e2e.test.ts +44 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +12 -0
package/src/lib/adr.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
import childProcess from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
interface NewOptions {
|
|
13
|
+
supersedes?: string[];
|
|
14
|
+
date?: string | undefined;
|
|
15
|
+
suppressPrompts?: boolean;
|
|
16
|
+
template?: string;
|
|
17
|
+
links?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
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
|
+
// eslint-disable-next-line no-unused-vars
|
|
33
|
+
enum LinkType {
|
|
34
|
+
// eslint-disable-next-line no-unused-vars
|
|
35
|
+
LINK = 'link',
|
|
36
|
+
// eslint-disable-next-line no-unused-vars
|
|
37
|
+
SUPERSEDE = 'supersede'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface LinkTask {
|
|
41
|
+
type: LinkType;
|
|
42
|
+
sourcePath: string;
|
|
43
|
+
targetPath: string;
|
|
44
|
+
link: string;
|
|
45
|
+
reverseLink: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const actuallyLink = async (task: LinkTask) => {
|
|
49
|
+
const linkedFile = path.join(await getDir(), task.targetPath);
|
|
50
|
+
const newAdrContent = await fs.readFile(task.sourcePath, 'utf8');
|
|
51
|
+
const oldAdrContent = await fs.readFile(linkedFile, 'utf8');
|
|
52
|
+
const oldTitle = getTitleFrom(oldAdrContent);
|
|
53
|
+
const newTitle = getTitleFrom(newAdrContent);
|
|
54
|
+
let dirtyOld = '', dirtyNew = '';
|
|
55
|
+
switch (task.type) {
|
|
56
|
+
case LinkType.LINK:
|
|
57
|
+
dirtyOld = injectLink(oldAdrContent, `${task.reverseLink} [${newTitle}](${path.relative(await getDir(), task.sourcePath)})`);
|
|
58
|
+
dirtyNew = injectLink(newAdrContent, `${task.link} [${oldTitle}](${task.targetPath})`);
|
|
59
|
+
break;
|
|
60
|
+
case LinkType.SUPERSEDE:
|
|
61
|
+
dirtyOld = supersede(oldAdrContent, `${task.reverseLink} [${newTitle}](${path.relative(await getDir(), task.sourcePath)})`);
|
|
62
|
+
dirtyNew = injectLink(newAdrContent, `${task.link} [${oldTitle}](${task.targetPath})`);
|
|
63
|
+
break;
|
|
64
|
+
default:
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await fs.writeFile(linkedFile, dirtyOld);
|
|
69
|
+
await fs.writeFile(task.sourcePath, dirtyNew);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const processSupersedes = async (
|
|
73
|
+
sourcePath: string,
|
|
74
|
+
supersedes: string[] = [],
|
|
75
|
+
suppressPrompts: boolean = false
|
|
76
|
+
) => {
|
|
77
|
+
if (supersedes.length === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const supersedeStrings = await Promise.all(supersedes.map((link) => getLinkDetails(link, true)));
|
|
82
|
+
|
|
83
|
+
for (const targetDetails of supersedeStrings) {
|
|
84
|
+
const task: LinkTask = {
|
|
85
|
+
type: LinkType.SUPERSEDE,
|
|
86
|
+
sourcePath: sourcePath,
|
|
87
|
+
link: targetDetails.link,
|
|
88
|
+
reverseLink: targetDetails.reverseLink,
|
|
89
|
+
targetPath: targetDetails.matches[0]
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (targetDetails.matches.length > 1) {
|
|
93
|
+
if (suppressPrompts) {
|
|
94
|
+
throw new Error(`Multiple files match the search pattern for "${targetDetails.original}".\n`
|
|
95
|
+
+ 'Please specify which file you want to targetDetails to more or remove the -q or --quiet options from the command line.');
|
|
96
|
+
} else {
|
|
97
|
+
task.targetPath = await askForClarification(targetDetails.original, targetDetails.matches);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await actuallyLink(task);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const injectLinksTo = async (
|
|
106
|
+
sourcePath: string,
|
|
107
|
+
links: string[] = [],
|
|
108
|
+
suppressPrompts: boolean = false
|
|
109
|
+
) => {
|
|
110
|
+
if (links.length === 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const linkStrings = await Promise.all(links.map((link) => getLinkDetails(link)));
|
|
115
|
+
|
|
116
|
+
for (const targetDetails of linkStrings) {
|
|
117
|
+
const task: LinkTask = {
|
|
118
|
+
type: LinkType.LINK,
|
|
119
|
+
sourcePath: sourcePath,
|
|
120
|
+
link: targetDetails.link,
|
|
121
|
+
reverseLink: targetDetails.reverseLink,
|
|
122
|
+
targetPath: targetDetails.matches[0]
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (targetDetails.matches.length > 1) {
|
|
126
|
+
if (suppressPrompts) {
|
|
127
|
+
throw new Error(`Multiple files match the search pattern for "${targetDetails.original}".\n`
|
|
128
|
+
+ 'Please specify which file you want to targetDetails to more or remove the -q or --quiet options from the command line.');
|
|
129
|
+
} else {
|
|
130
|
+
task.targetPath = await askForClarification(targetDetails.original, targetDetails.matches);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await actuallyLink(task);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
};
|
|
138
|
+
//Generate a table of contents for the adr directory
|
|
139
|
+
export const generateToc = async (options?: {prefix?: string}) => {
|
|
140
|
+
|
|
141
|
+
const adrDir = await getDir();
|
|
142
|
+
const files = await fs.readdir(adrDir);
|
|
143
|
+
const toc = files.filter((file) => file.match(/^\d{4}-.*\.md$/));
|
|
144
|
+
|
|
145
|
+
const titles = toc.map(async (file) => {
|
|
146
|
+
const title = getTitleFrom(await fs.readFile(path.join(adrDir, file), 'utf8'));
|
|
147
|
+
return `[${title}](${options?.prefix || ''}${file})`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const resolvedTitles = await Promise.all(titles);
|
|
151
|
+
|
|
152
|
+
const tocFile = path.resolve(path.join(adrDir, 'decisions.md'));
|
|
153
|
+
await fs.writeFile(tocFile, `# Table of Contents\n\n${resolvedTitles.join('\n')}`);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const newAdr = async (title: string, config?: NewOptions) => {
|
|
157
|
+
const newNum = await newNumber();
|
|
158
|
+
const formattedDate = config?.date || new Date().toISOString().split('T')[0] || 'ERROR';
|
|
159
|
+
const tpl = await template(config?.template);
|
|
160
|
+
const adr = tpl.replace('DATE', formattedDate).replace('TITLE', title).replace('NUMBER', newNum.toString()).replace('STATUS', 'Accepted');
|
|
161
|
+
const paddedNumber = newNum.toString().padStart(4, '0');
|
|
162
|
+
const cleansedTitle = title.toLowerCase().replace(/\W/g, '-').replace(/^(.*)\W$/, '$1').replace(/^\W(.*)$/, '$1');
|
|
163
|
+
const fileName = `${paddedNumber}-${cleansedTitle}.md`;
|
|
164
|
+
const adrDirectory = await getDir();
|
|
165
|
+
const adrPath = path.resolve(path.join(adrDirectory, fileName));
|
|
166
|
+
await fs.writeFile(adrPath, adr);
|
|
167
|
+
await fs.writeFile(path.resolve(adrDirectory, '.adr-sequence.lock'), newNum.toString());
|
|
168
|
+
|
|
169
|
+
await processSupersedes(
|
|
170
|
+
adrPath,
|
|
171
|
+
config?.supersedes,
|
|
172
|
+
config?.suppressPrompts
|
|
173
|
+
);
|
|
174
|
+
await injectLinksTo(
|
|
175
|
+
adrPath,
|
|
176
|
+
config?.links,
|
|
177
|
+
config?.suppressPrompts
|
|
178
|
+
);
|
|
179
|
+
await generateToc();
|
|
180
|
+
const newAdrPath = path.relative(workingDir(), adrPath);
|
|
181
|
+
|
|
182
|
+
if (process.env.VISUAL) {
|
|
183
|
+
await childProcess.spawn(process.env.VISUAL, [adrPath], {
|
|
184
|
+
stdio: 'inherit',
|
|
185
|
+
shell: true
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (process.env.EDITOR) {
|
|
190
|
+
await childProcess.spawn(process.env.EDITOR, [adrPath], {
|
|
191
|
+
stdio: 'inherit',
|
|
192
|
+
shell: true
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
console.log(newAdrPath);
|
|
197
|
+
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const init = async (directory?: string) => {
|
|
201
|
+
const dir = directory || await getDir();
|
|
202
|
+
await fs.mkdir(dir, { recursive: true });
|
|
203
|
+
await fs.writeFile(path.join(workingDir(), '.adr-dir'), path.relative(workingDir(), dir));
|
|
204
|
+
await newAdr('Record Architecture Decisions', {
|
|
205
|
+
date: process.env.ADR_DATE,
|
|
206
|
+
template: path.resolve(path.dirname(__filename), '../templates/init.md')
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const link = async (source: string, link: string, target: string, reverseLink: string, options?: {quiet?: boolean}) => {
|
|
211
|
+
const getFor = async (pattern: string) => {
|
|
212
|
+
const found = await findMatchingFilesFor(pattern);
|
|
213
|
+
if (found.length === 1) {
|
|
214
|
+
return found[0];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (options?.quiet) {
|
|
218
|
+
throw new Error(`Multiple files match the search pattern for "${pattern}".\n`
|
|
219
|
+
+ 'Please specify which file you want to targetDetails to more or remove the -q or --quiet options from the command line.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return askForClarification(pattern, found);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const sourceFile = await getFor(source);
|
|
226
|
+
|
|
227
|
+
await injectLinksTo(path.resolve(await getDir(), sourceFile), [`${target}:${link}:${reverseLink}`]);
|
|
228
|
+
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export const listAdrs = async () => {
|
|
232
|
+
const dir = await getDir();
|
|
233
|
+
const files = await fs.readdir(dir);
|
|
234
|
+
const toc = files.map(f => {
|
|
235
|
+
const adrFile = f.match(/^0*(\d+)-.*$/);
|
|
236
|
+
if (adrFile) {
|
|
237
|
+
return path.resolve(dir, adrFile[0]);
|
|
238
|
+
}
|
|
239
|
+
return '';
|
|
240
|
+
}).filter(f => f !== '');
|
|
241
|
+
return toc;
|
|
242
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { constants } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export const workingDir = () => process.cwd();
|
|
6
|
+
|
|
7
|
+
const findTopLevelDir = async (dir: string): Promise<string> => {
|
|
8
|
+
try {
|
|
9
|
+
await fs.access(path.join(dir, '.adr-dir'), constants.F_OK);
|
|
10
|
+
return dir;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
if (dir === '/') {
|
|
13
|
+
throw new Error('No ADR directory config found');
|
|
14
|
+
}
|
|
15
|
+
const newDir = path.join(dir, '..');
|
|
16
|
+
return findTopLevelDir(newDir);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const getDirPath = async (): Promise<string> => {
|
|
21
|
+
try {
|
|
22
|
+
const configDir = await findTopLevelDir(workingDir());
|
|
23
|
+
const configFile = await fs.readFile(path.join(configDir, '.adr-dir'), 'utf8');
|
|
24
|
+
return path.relative(workingDir(), path.join(configDir, configFile.trim()));
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return path.resolve(path.join(workingDir(), 'doc/adr'));
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const getDir = async (): Promise<string> => {
|
|
31
|
+
const dir = await getDirPath();
|
|
32
|
+
await fs.mkdir(dir, { recursive: true });
|
|
33
|
+
return dir;
|
|
34
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { vi, describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { getDir } from './config';
|
|
4
|
+
import { getLinkDetails } from './links';
|
|
5
|
+
|
|
6
|
+
vi.mock('./config');
|
|
7
|
+
vi.mock('fs/promises');
|
|
8
|
+
|
|
9
|
+
describe('The link lib', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.resetAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('does not care if there are no matches', async () => {
|
|
15
|
+
vi.mocked(getDir).mockResolvedValueOnce('/');
|
|
16
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
|
|
17
|
+
const linkString = '1:overrides:overriden by';
|
|
18
|
+
const response = await getLinkDetails(linkString);
|
|
19
|
+
expect(response).toEqual({
|
|
20
|
+
pattern: '1',
|
|
21
|
+
original: '1:overrides:overriden by',
|
|
22
|
+
link: 'overrides',
|
|
23
|
+
reverseLink: 'overriden by',
|
|
24
|
+
matches: []
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does handles multiple matches', async () => {
|
|
29
|
+
vi.mocked(getDir).mockResolvedValueOnce('/');
|
|
30
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
|
31
|
+
'1-one',
|
|
32
|
+
'1-two',
|
|
33
|
+
'1-three'
|
|
34
|
+
] as any);
|
|
35
|
+
const linkString = '1:overrides:overriden by';
|
|
36
|
+
const response = await getLinkDetails(linkString);
|
|
37
|
+
expect(response).toEqual({
|
|
38
|
+
pattern: '1',
|
|
39
|
+
original: '1:overrides:overriden by',
|
|
40
|
+
link: 'overrides',
|
|
41
|
+
reverseLink: 'overriden by',
|
|
42
|
+
matches: [
|
|
43
|
+
'1-one',
|
|
44
|
+
'1-two',
|
|
45
|
+
'1-three'
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns only files that match the pattern', async () => {
|
|
51
|
+
vi.mocked(getDir).mockResolvedValueOnce('/');
|
|
52
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
|
53
|
+
'on1e',
|
|
54
|
+
'1-two',
|
|
55
|
+
'three'
|
|
56
|
+
] as any);
|
|
57
|
+
const linkString = '1:overrides:overriden by';
|
|
58
|
+
const response = await getLinkDetails(linkString);
|
|
59
|
+
expect(response).toEqual({
|
|
60
|
+
pattern: '1',
|
|
61
|
+
original: '1:overrides:overriden by',
|
|
62
|
+
link: 'overrides',
|
|
63
|
+
reverseLink: 'overriden by',
|
|
64
|
+
matches: [
|
|
65
|
+
'on1e',
|
|
66
|
+
'1-two'
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('handles superseding', async () => {
|
|
72
|
+
vi.mocked(getDir).mockResolvedValueOnce('/');
|
|
73
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([] as any);
|
|
74
|
+
const linkString = '1';
|
|
75
|
+
const supersede = true;
|
|
76
|
+
const response = await getLinkDetails(linkString, supersede);
|
|
77
|
+
expect(response).toEqual({
|
|
78
|
+
pattern: '1',
|
|
79
|
+
original: '1',
|
|
80
|
+
link: 'Supersedes',
|
|
81
|
+
reverseLink: 'Superseded by',
|
|
82
|
+
matches: []
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
});
|
package/src/lib/links.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { getDir } from './config';
|
|
3
|
+
|
|
4
|
+
export interface LinkDetails {
|
|
5
|
+
pattern: string;
|
|
6
|
+
original: string;
|
|
7
|
+
link: string;
|
|
8
|
+
reverseLink: string;
|
|
9
|
+
matches: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const findMatchingFilesFor = async (pattern: string) => {
|
|
13
|
+
const files = await fs.readdir(await getDir());
|
|
14
|
+
return files.filter(file => file.includes(pattern));
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const getLinkDetails = async (linkString: string, isSupersede: boolean = false): Promise<LinkDetails> => {
|
|
18
|
+
const parts = linkString.split(':');
|
|
19
|
+
const pattern = parts[0];
|
|
20
|
+
let link = 'Supersedes';
|
|
21
|
+
let reverseLink = 'Superseded by';
|
|
22
|
+
if (!isSupersede) {
|
|
23
|
+
link = parts[1];
|
|
24
|
+
reverseLink = parts[2];
|
|
25
|
+
}
|
|
26
|
+
const files = await findMatchingFilesFor(pattern);
|
|
27
|
+
return {
|
|
28
|
+
pattern: pattern,
|
|
29
|
+
original: linkString,
|
|
30
|
+
link: link,
|
|
31
|
+
reverseLink: reverseLink,
|
|
32
|
+
matches: files
|
|
33
|
+
};
|
|
34
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { injectLink, getTitleFrom, getLinksFrom } from './manipulator';
|
|
3
|
+
|
|
4
|
+
describe('The ADR manipulator', () => {
|
|
5
|
+
|
|
6
|
+
const original = '# 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';
|
|
25
|
+
|
|
26
|
+
const modified = '# NUMBER. TITLE\n'
|
|
27
|
+
+ '\n'
|
|
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';
|
|
47
|
+
|
|
48
|
+
it('can inject links to the status section', () => {
|
|
49
|
+
const test = injectLink(original, 'INJECTED STUFF');
|
|
50
|
+
expect(test).toEqual(modified);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('throws an error when status cannot be found', () => {
|
|
54
|
+
const noStatus = '# NUMBER. TITLE\n';
|
|
55
|
+
const test = () => injectLink(noStatus, 'INJECTED STUFF');
|
|
56
|
+
expect(test).toThrowError('Could not find status section');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('adds to the status section even when there are no following headers', () => {
|
|
60
|
+
const noFollowingHeaders = '## STATUS\nACCEPTED\n\n';
|
|
61
|
+
const expected = '## STATUS\nACCEPTED\n\nINJECTED STUFF\n\n';
|
|
62
|
+
const test = injectLink(noFollowingHeaders, 'INJECTED STUFF');
|
|
63
|
+
expect(test).toEqual(expected);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('can return the title and number', () => {
|
|
67
|
+
const original = '# 2. This is the title\n';
|
|
68
|
+
const test = getTitleFrom(original);
|
|
69
|
+
expect(test).toEqual('2. This is the title');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
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';
|
|
76
|
+
const extractedLink = getLinksFrom(original);
|
|
77
|
+
|
|
78
|
+
expect(extractedLink[0]).toEqual({
|
|
79
|
+
label: 'Superseded by',
|
|
80
|
+
targetNumber: '3',
|
|
81
|
+
text: '3. title here',
|
|
82
|
+
href: 'and-a-link-here.md',
|
|
83
|
+
raw: 'Superseded by [3. title here](and-a-link-here.md)'
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
|
|
3
|
+
const convertToMd = (tokens: marked.Token[]) => {
|
|
4
|
+
return tokens.map(token => token.raw).join('');
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const getLinksFrom = (markdown: string) => {
|
|
8
|
+
const tokens = marked.lexer(markdown);
|
|
9
|
+
const linkRegex = new RegExp(/^(.*)\s\[(.*)\]\((.*)\)$/);
|
|
10
|
+
const links = tokens.filter(token => token.type === 'paragraph' && linkRegex.test(token.text));
|
|
11
|
+
|
|
12
|
+
return links.map((link) => {
|
|
13
|
+
const linkToken = link as marked.Tokens.Paragraph;
|
|
14
|
+
const linkMatches = linkToken.text.match(linkRegex);
|
|
15
|
+
if (!linkMatches || linkMatches.length < 3) {
|
|
16
|
+
throw new Error(`Could not parse link from "${linkToken.text}"`);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
targetNumber: linkMatches[2].substring(0, linkMatches[2].indexOf('.')),
|
|
20
|
+
label: linkMatches[1],
|
|
21
|
+
href: linkMatches[3],
|
|
22
|
+
text: linkMatches[2],
|
|
23
|
+
raw: linkMatches[0]
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const getTitleFrom = (adr: string) => {
|
|
29
|
+
const tokens = marked.lexer(adr);
|
|
30
|
+
const mainHead = tokens.find(token => token.type === 'heading' && token.depth === 1);
|
|
31
|
+
if (!mainHead) {
|
|
32
|
+
throw new Error('No main heading found');
|
|
33
|
+
}
|
|
34
|
+
return (mainHead as marked.Tokens.Heading).text.trim();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const supersede = (markdown: string, link: string) => {
|
|
38
|
+
const tokens = marked.lexer(markdown);
|
|
39
|
+
const statusIndex = tokens.findIndex(token => token.type === 'heading' && token.text.toLowerCase() === 'status');
|
|
40
|
+
if (statusIndex < 0) {
|
|
41
|
+
throw new Error('Could not find status section');
|
|
42
|
+
}
|
|
43
|
+
const statusDepth = (tokens[statusIndex] as marked.Tokens.Heading).depth;
|
|
44
|
+
const followingHeadingIndex = tokens.findIndex((token, index) => token.type === 'heading' && token.depth === statusDepth && index > statusIndex);
|
|
45
|
+
const followingParagraphIndex = tokens.findIndex((token, index) => token.type === 'paragraph' && index > statusIndex && index < followingHeadingIndex);
|
|
46
|
+
|
|
47
|
+
if (followingParagraphIndex > followingHeadingIndex || followingParagraphIndex === -1) {
|
|
48
|
+
throw new Error('There is no status paragraph. Please format your adr properly');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
tokens[followingParagraphIndex] = {
|
|
52
|
+
type: 'paragraph', text: link, raw: link, tokens: [
|
|
53
|
+
{
|
|
54
|
+
type: 'text',
|
|
55
|
+
raw: link,
|
|
56
|
+
text: link
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
};
|
|
60
|
+
return convertToMd(tokens);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const injectLink = (markdown: string, link: string) => {
|
|
64
|
+
const tokens = marked.lexer(markdown);
|
|
65
|
+
const statusIndex = tokens.findIndex(token => token.type === 'heading' && token.text.toLowerCase() === 'status');
|
|
66
|
+
if (statusIndex < 0) {
|
|
67
|
+
throw new Error('Could not find status section');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const statusDepth = (tokens[statusIndex] as marked.Tokens.Heading).depth;
|
|
71
|
+
let followingHeadingIndex = tokens.findIndex((token, index) => token.type === 'heading' && token.depth === statusDepth && index > statusIndex);
|
|
72
|
+
if (followingHeadingIndex < 0) {
|
|
73
|
+
followingHeadingIndex = tokens.length;
|
|
74
|
+
}
|
|
75
|
+
tokens.splice(followingHeadingIndex, 0, {
|
|
76
|
+
type: 'paragraph', text: link, raw: link, tokens: [
|
|
77
|
+
{
|
|
78
|
+
type: 'text',
|
|
79
|
+
raw: link,
|
|
80
|
+
text: link
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
});
|
|
84
|
+
tokens.splice(followingHeadingIndex + 1, 0, { type: 'space', raw: '\n\n' });
|
|
85
|
+
return convertToMd(tokens);
|
|
86
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterAll, describe, it, vi, expect } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { newNumber } from './numbering';
|
|
4
|
+
|
|
5
|
+
type ReaddirMock = () => Promise<String[]>
|
|
6
|
+
|
|
7
|
+
vi.mock('fs/promises');
|
|
8
|
+
vi.mock('./config', () => ({
|
|
9
|
+
getDir: vi.fn().mockResolvedValue('/')
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe('The numbering logic', () => {
|
|
13
|
+
|
|
14
|
+
afterAll(() => {
|
|
15
|
+
vi.resetAllMocks();
|
|
16
|
+
});
|
|
17
|
+
it('can read from the sequence file', async () => {
|
|
18
|
+
const random = Math.floor(Math.random() * 100);
|
|
19
|
+
vi.mocked(fs.readFile).mockResolvedValueOnce(random.toString());
|
|
20
|
+
const num = await newNumber();
|
|
21
|
+
expect(num).toEqual(random + 1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns 1 if there are no files', async () => {
|
|
25
|
+
vi.mocked(fs.readFile).mockRejectedValueOnce('no sequence file');
|
|
26
|
+
vi.mocked(fs.readdir).mockResolvedValueOnce([]);
|
|
27
|
+
const num = await newNumber();
|
|
28
|
+
expect(num).toEqual(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
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
|
+
];
|
|
36
|
+
vi.mocked(fs.readFile).mockRejectedValueOnce('no sequence file');
|
|
37
|
+
vi.mocked(fs.readdir as unknown as ReaddirMock).mockResolvedValueOnce(fakeFiles);
|
|
38
|
+
const num = await newNumber();
|
|
39
|
+
expect(num).toEqual(3);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { getDir } from './config';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
export const newNumber = async () => {
|
|
6
|
+
try {
|
|
7
|
+
const lockfile = await fs.readFile(path.resolve(await getDir(), '.adr-sequence.lock'));
|
|
8
|
+
return parseInt(lockfile.toString().trim(), 10) + 1;
|
|
9
|
+
} catch (e) {
|
|
10
|
+
// This is for backward compatibility. If someone upgrades from an older tool without a lockfile,
|
|
11
|
+
// we create one.
|
|
12
|
+
const filePattern = /^0*(\d+)-.*$/;
|
|
13
|
+
const files = await fs.readdir(await getDir());
|
|
14
|
+
const numbers = files.map(f => {
|
|
15
|
+
const adrFile = f.match(filePattern);
|
|
16
|
+
if (adrFile) {
|
|
17
|
+
return parseInt(adrFile[1], 10);
|
|
18
|
+
}
|
|
19
|
+
return 0;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const largestNumber = numbers.reduce((a, b) => Math.max(a, b), 0);
|
|
23
|
+
return largestNumber + 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getDir } from './config';
|
|
4
|
+
|
|
5
|
+
export const template = async (templateFile?: string): Promise<string> => {
|
|
6
|
+
if (templateFile) {
|
|
7
|
+
return await fs.readFile(path.resolve(templateFile), 'utf8');
|
|
8
|
+
}
|
|
9
|
+
if (process.env.ADR_TEMPLATE) {
|
|
10
|
+
return await fs.readFile(path.resolve(process.env.ADR_TEMPLATE), 'utf8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
return await fs.readFile(path.join(await getDir(), 'templates/template.md'), 'utf8');
|
|
15
|
+
} catch (e) {
|
|
16
|
+
return await fs.readFile(path.resolve(path.join(__dirname, '../templates/template.md')), 'utf8');
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 1. Record architecture decisions
|
|
2
|
+
|
|
3
|
+
Date: DATE
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
We need to record the architectural decisions made on this project.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
We will use Architecture Decision Records, as [described by Michael Nygard](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions).
|
|
16
|
+
|
|
17
|
+
## Consequences
|
|
18
|
+
|
|
19
|
+
See Michael Nygard's article, linked above.
|
|
20
|
+
For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools).
|
|
21
|
+
> For a node version of the same tooling, see Meza's [adr-tools](https://github.com/meza/adr-tools).
|