@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.
Files changed (93) hide show
  1. package/.gitattributes +40 -0
  2. package/.github/renovate.json +5 -0
  3. package/.github/workflows/ci-pr.yml +50 -0
  4. package/.github/workflows/ci.yml +28 -20
  5. package/.github/workflows/sync-deps-to-main.yml +25 -0
  6. package/.github/workflows/sync-to-deps.yml +26 -0
  7. package/.releaserc.json +1 -13
  8. package/CHANGELOG.md +76 -0
  9. package/LICENSE +674 -0
  10. package/README.md +64 -5
  11. package/biome.json +148 -0
  12. package/dist/index.js +52 -82
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/adr.js +100 -126
  15. package/dist/lib/adr.js.map +1 -1
  16. package/dist/lib/config.js +20 -37
  17. package/dist/lib/config.js.map +1 -1
  18. package/dist/lib/links.js +8 -25
  19. package/dist/lib/links.js.map +1 -1
  20. package/dist/lib/links.test.js +35 -64
  21. package/dist/lib/links.test.js.map +1 -1
  22. package/dist/lib/manipulator.js +22 -23
  23. package/dist/lib/manipulator.js.map +1 -1
  24. package/dist/lib/manipulator.test.js +59 -63
  25. package/dist/lib/manipulator.test.js.map +1 -1
  26. package/dist/lib/numbering.js +9 -48
  27. package/dist/lib/numbering.js.map +1 -1
  28. package/dist/lib/numbering.test.js +27 -44
  29. package/dist/lib/numbering.test.js.map +1 -1
  30. package/dist/lib/prompt.js +14 -0
  31. package/dist/lib/prompt.js.map +1 -0
  32. package/dist/lib/prompt.test.js +33 -0
  33. package/dist/lib/prompt.test.js.map +1 -0
  34. package/dist/lib/template.js +13 -26
  35. package/dist/lib/template.js.map +1 -1
  36. package/dist/types/lib/adr.d.ts.map +1 -1
  37. package/dist/types/lib/config.d.ts.map +1 -1
  38. package/dist/types/lib/links.d.ts.map +1 -1
  39. package/dist/types/lib/manipulator.d.ts.map +1 -1
  40. package/dist/types/lib/numbering.d.ts.map +1 -1
  41. package/dist/types/lib/prompt.d.ts +2 -0
  42. package/dist/types/lib/prompt.d.ts.map +1 -0
  43. package/dist/types/lib/prompt.test.d.ts +2 -0
  44. package/dist/types/lib/prompt.test.d.ts.map +1 -0
  45. package/dist/types/lib/template.d.ts.map +1 -1
  46. package/dist/types/version.d.ts +1 -1
  47. package/dist/version.js +1 -4
  48. package/dist/version.js.map +1 -1
  49. package/doc/adr/.adr-sequence.lock +1 -0
  50. package/doc/adr/decisions.md +3 -0
  51. package/package.json +73 -47
  52. package/src/index.ts +52 -27
  53. package/src/lib/adr.ts +68 -73
  54. package/src/lib/config.ts +3 -3
  55. package/src/lib/links.test.ts +8 -24
  56. package/src/lib/links.ts +2 -2
  57. package/src/lib/manipulator.test.ts +44 -47
  58. package/src/lib/manipulator.ts +22 -10
  59. package/src/lib/numbering.test.ts +5 -9
  60. package/src/lib/numbering.ts +4 -5
  61. package/src/lib/prompt.test.ts +42 -0
  62. package/src/lib/prompt.ts +14 -0
  63. package/src/lib/template.ts +7 -3
  64. package/src/version.ts +1 -1
  65. package/tests/.adr-dir +1 -0
  66. package/tests/__snapshots__/generate-graph.e2e.test.ts.snap +23 -23
  67. package/tests/__snapshots__/init-adr-repository.e2e.test.ts.snap +1 -1
  68. package/tests/__snapshots__/linking-records.e2e.test.ts.snap +1 -1
  69. package/tests/__snapshots__/new-adr.e2e.test.ts.snap +1 -1
  70. package/tests/__snapshots__/superseding-records.e2e.test.ts.snap +1 -1
  71. package/tests/__snapshots__/toc-prefixing.e2e.test.ts.snap +1 -1
  72. package/tests/__snapshots__/use-template-override.e2e.test.ts.snap +1 -1
  73. package/tests/edit-on-create.e2e.test.ts +17 -12
  74. package/tests/funny-characters.e2e.test.ts +28 -21
  75. package/tests/generate-graph.e2e.test.ts +21 -13
  76. package/tests/init-adr-repository.e2e.test.ts +12 -8
  77. package/tests/linking-records.e2e.test.ts +21 -14
  78. package/tests/list-adrs.e2e.test.ts +23 -18
  79. package/tests/new-adr.e2e.test.ts +15 -12
  80. package/tests/superseding-records.e2e.test.ts +16 -11
  81. package/tests/toc-prefixing.e2e.test.ts +15 -11
  82. package/tests/use-template-override.e2e.test.ts +18 -10
  83. package/tests/work-form-other-directories.e2e.test.ts +14 -12
  84. package/tsconfig.json +9 -8
  85. package/vitest.config.e2e.ts +13 -0
  86. package/vitest.config.ts +8 -1
  87. package/.eslintignore +0 -2
  88. package/.eslintrc.json +0 -23
  89. package/.github/dependabot.yml +0 -14
  90. package/.github/workflows/auto-merge.yml +0 -14
  91. package/doc/adr/0001-record-architecture-decisions.md +0 -21
  92. package/doc/adr/0002-using-heavy-e2e-tests.md +0 -20
  93. /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 = '', dirtyNew = '';
44
+ let dirtyOld = '',
45
+ dirtyNew = '';
55
46
  switch (task.type) {
56
47
  case LinkType.LINK:
57
- dirtyOld = injectLink(oldAdrContent, `${task.reverseLink} [${newTitle}](${path.relative(await getDir(), task.sourcePath)})`);
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(oldAdrContent, `${task.reverseLink} [${newTitle}](${path.relative(await getDir(), task.sourcePath)})`);
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(`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);
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(`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);
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.replace('DATE', formattedDate).replace('TITLE', title).replace('NUMBER', newNum.toString()).replace('STATUS', 'Accepted');
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.toLowerCase().replace(/\W/g, '-').replace(/^(.*)\W$/, '$1').replace(/^\W(.*)$/, '$1');
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
- adrPath,
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 (source: string, link: string, target: string, reverseLink: string, options?: {quiet?: boolean}) => {
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(`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.');
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.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 !== '');
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 (e) {
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 (e) {
25
+ } catch (_e) {
26
26
  return path.resolve(path.join(workingDir(), 'doc/adr'));
27
27
  }
28
28
  };
@@ -1,9 +1,9 @@
1
- import { vi, describe, it, expect, afterEach } from 'vitest';
2
1
  import fs from 'fs/promises';
3
- import { getDir } from './config';
4
- import { getLinkDetails } from './links';
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 { injectLink, getTitleFrom, getLinksFrom } from './manipulator';
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 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';
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
  });
@@ -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((token, index) => token.type === 'heading' && token.depth === statusDepth && index > statusIndex);
45
- const followingParagraphIndex = tokens.findIndex((token, index) => token.type === 'paragraph' && index > statusIndex && index < followingHeadingIndex);
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', text: link, raw: link, tokens: [
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((token, index) => token.type === 'heading' && token.depth === statusDepth && index > statusIndex);
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', text: link, raw: link, tokens: [
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 { newNumber } from './numbering';
2
+ import { afterAll, describe, expect, it, vi } from 'vitest';
3
+ import { newNumber } from './numbering.js';
4
4
 
5
- type ReaddirMock = () => Promise<String[]>
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();
@@ -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 (e) {
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
+ };