@meza/adr-tools 1.0.11 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/renovate.json +2 -1
- package/.github/workflows/ci-pr.yml +6 -23
- package/.github/workflows/ci.yml +18 -43
- package/.releaserc.json +11 -13
- package/AGENTS.engineer.md +236 -0
- package/AGENTS.md +11 -0
- package/AGENTS.reviewer.md +115 -0
- package/CONTRIBUTING.md +102 -0
- package/README.md +16 -4
- package/biome.json +18 -139
- package/dist/index.js +164 -81
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +189 -0
- package/dist/index.test.js.map +1 -0
- package/dist/inject-version.test.js +27 -0
- package/dist/inject-version.test.js.map +1 -0
- package/dist/lib/adr.js +132 -27
- package/dist/lib/adr.js.map +1 -1
- package/dist/lib/adr.test.js +308 -0
- package/dist/lib/adr.test.js.map +1 -0
- package/dist/lib/config.js +3 -2
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/config.test.js +60 -0
- package/dist/lib/config.test.js.map +1 -0
- package/dist/lib/links.test.js +5 -4
- package/dist/lib/links.test.js.map +1 -1
- package/dist/lib/manipulator-errors.test.js +21 -0
- package/dist/lib/manipulator-errors.test.js.map +1 -0
- package/dist/lib/manipulator.test.js +21 -1
- package/dist/lib/manipulator.test.js.map +1 -1
- package/dist/lib/numbering.js +1 -1
- package/dist/lib/numbering.js.map +1 -1
- package/dist/lib/numbering.test.js +7 -0
- package/dist/lib/numbering.test.js.map +1 -1
- package/dist/lib/opening.test.js +81 -0
- package/dist/lib/opening.test.js.map +1 -0
- package/dist/lib/prompt.js +1 -1
- package/dist/lib/prompt.js.map +1 -1
- package/dist/lib/template.test.js +62 -0
- package/dist/lib/template.test.js.map +1 -0
- package/dist/types/index.d.ts +25 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.test.d.ts +2 -0
- package/dist/types/index.test.d.ts.map +1 -0
- package/dist/types/inject-version.test.d.ts +2 -0
- package/dist/types/inject-version.test.d.ts.map +1 -0
- package/dist/types/lib/adr.d.ts +15 -0
- package/dist/types/lib/adr.d.ts.map +1 -1
- package/dist/types/lib/adr.test.d.ts +2 -0
- package/dist/types/lib/adr.test.d.ts.map +1 -0
- package/dist/types/lib/config.d.ts.map +1 -1
- package/dist/types/lib/config.test.d.ts +2 -0
- package/dist/types/lib/config.test.d.ts.map +1 -0
- package/dist/types/lib/manipulator-errors.test.d.ts +2 -0
- package/dist/types/lib/manipulator-errors.test.d.ts.map +1 -0
- package/dist/types/lib/opening.test.d.ts +2 -0
- package/dist/types/lib/opening.test.d.ts.map +1 -0
- package/dist/types/lib/prompt.d.ts.map +1 -1
- package/dist/types/lib/template.test.d.ts +2 -0
- package/dist/types/lib/template.test.d.ts.map +1 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/doc/adr/.adr-sequence.lock +1 -1
- package/doc/adr/0001-record-architecture-decisions.md +21 -0
- package/doc/adr/0002-using-heavy-e2e-tests.md +20 -0
- package/doc/adr/0003-esm.md +34 -0
- package/doc/adr/0004-gate-editor-opening-behind---open-and---open-with.md +55 -0
- package/doc/adr/decisions.md +4 -1
- package/lefthook.yml +14 -0
- package/package.json +24 -26
- package/scripts/inject-version.mjs +34 -0
- package/src/index.test.ts +212 -0
- package/src/index.ts +229 -108
- package/src/inject-version.test.ts +31 -0
- package/src/lib/adr.test.ts +376 -0
- package/src/lib/adr.ts +173 -27
- package/src/lib/config.test.ts +69 -0
- package/src/lib/config.ts +3 -2
- package/src/lib/links.test.ts +8 -4
- package/src/lib/manipulator-errors.test.ts +22 -0
- package/src/lib/manipulator.test.ts +25 -1
- package/src/lib/numbering.test.ts +8 -0
- package/src/lib/numbering.ts +1 -1
- package/src/lib/opening.test.ts +96 -0
- package/src/lib/prompt.ts +1 -1
- package/src/lib/template.test.ts +74 -0
- package/src/version.ts +1 -2
- package/tests/edit-on-create.e2e.test.ts +47 -16
- package/tests/fake-editor.cmd +2 -0
- package/tests/fake-visual.cmd +2 -0
- package/tests/funny-characters.e2e.test.ts +12 -11
- package/tests/generate-graph.e2e.test.ts +13 -17
- package/tests/helpers/adr-cli.ts +24 -0
- package/tests/init-adr-repository.e2e.test.ts +7 -6
- package/tests/linking-records.e2e.test.ts +14 -13
- package/tests/list-adrs.e2e.test.ts +26 -19
- package/tests/new-adr.e2e.test.ts +10 -9
- package/tests/open-with.e2e.test.ts +53 -0
- package/tests/superseding-records.e2e.test.ts +11 -10
- package/tests/toc-prefixing.e2e.test.ts +10 -9
- package/tests/use-template-override.e2e.test.ts +9 -8
- package/tests/work-form-other-directories.e2e.test.ts +10 -8
- package/vitest.config.e2e.ts +8 -1
- package/vitest.config.ts +20 -4
- package/.github/workflows/sync-deps-to-main.yml +0 -25
- package/.github/workflows/sync-to-deps.yml +0 -26
- package/.husky/commit-msg +0 -4
- package/CHANGELOG.md +0 -114
- package/scripts/inject-version.sh +0 -4
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import type { Command } from 'commander';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { buildProgram, defaultOnError, isDirectRun, maybeRun, run } from './index.js';
|
|
7
|
+
|
|
8
|
+
const createDeps = () => {
|
|
9
|
+
const deps = {
|
|
10
|
+
generateToc: vi.fn(),
|
|
11
|
+
init: vi.fn(),
|
|
12
|
+
link: vi.fn(),
|
|
13
|
+
listAdrs: vi.fn(),
|
|
14
|
+
newAdr: vi.fn(),
|
|
15
|
+
workingDir: vi.fn(),
|
|
16
|
+
readFile: vi.fn(),
|
|
17
|
+
getLinksFrom: vi.fn(),
|
|
18
|
+
getTitleFrom: vi.fn(),
|
|
19
|
+
version: '1.2.3',
|
|
20
|
+
onError: vi.fn(),
|
|
21
|
+
log: vi.fn(),
|
|
22
|
+
warn: vi.fn()
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return deps;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('adr CLI', () => {
|
|
29
|
+
it('wires program metadata', () => {
|
|
30
|
+
const deps = createDeps();
|
|
31
|
+
const program = buildProgram(deps);
|
|
32
|
+
expect(program.name()).toEqual('adr');
|
|
33
|
+
expect(program.version()).toEqual('1.2.3');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('runs the new command with options', async () => {
|
|
37
|
+
const deps = createDeps();
|
|
38
|
+
await run(['node', 'adr', 'new', 'My', 'ADR', '--open'], deps);
|
|
39
|
+
expect(deps.newAdr).toHaveBeenCalledWith('My ADR', {
|
|
40
|
+
supersedes: [],
|
|
41
|
+
date: process.env.ADR_DATE,
|
|
42
|
+
suppressPrompts: false,
|
|
43
|
+
links: [],
|
|
44
|
+
open: true,
|
|
45
|
+
openWith: undefined
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('invokes error handler when newAdr fails', async () => {
|
|
50
|
+
const deps = createDeps();
|
|
51
|
+
deps.newAdr.mockRejectedValueOnce(new Error('boom'));
|
|
52
|
+
await run(['node', 'adr', 'new', 'My', 'ADR'], deps);
|
|
53
|
+
expect(deps.onError).toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('routes generate toc', async () => {
|
|
57
|
+
const deps = createDeps();
|
|
58
|
+
await run(['node', 'adr', 'generate', 'toc', '-p', '/prefix/'], deps);
|
|
59
|
+
expect(deps.generateToc).toHaveBeenCalledWith({ prefix: '/prefix/' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('generates graph output', async () => {
|
|
63
|
+
const deps = createDeps();
|
|
64
|
+
deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md', '/repo/doc/adr/0002-two.md']);
|
|
65
|
+
deps.readFile.mockResolvedValue('# Title');
|
|
66
|
+
deps.getTitleFrom.mockReturnValue('ADR Title');
|
|
67
|
+
deps.getLinksFrom.mockReturnValue([
|
|
68
|
+
{ label: 'Relates to', targetNumber: '0002' },
|
|
69
|
+
{ label: 'Superseded by', targetNumber: '0003' }
|
|
70
|
+
]);
|
|
71
|
+
await run(['node', 'adr', 'generate', 'graph', '-e', '.md', '-p', '/p/'], deps);
|
|
72
|
+
const output = deps.log.mock.calls[0][0] as string;
|
|
73
|
+
expect(output).toContain('digraph');
|
|
74
|
+
expect(output).toContain('_1 -> _2 [style="dotted"');
|
|
75
|
+
expect(output).toContain('label="Relates to"');
|
|
76
|
+
expect(output).not.toContain('Superseded by');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('generates graph output with default prefix', async () => {
|
|
80
|
+
const deps = createDeps();
|
|
81
|
+
deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
|
|
82
|
+
deps.readFile.mockResolvedValue('# Title');
|
|
83
|
+
deps.getTitleFrom.mockReturnValue('ADR Title');
|
|
84
|
+
deps.getLinksFrom.mockReturnValue([]);
|
|
85
|
+
await run(['node', 'adr', 'generate', 'graph'], deps);
|
|
86
|
+
const output = deps.log.mock.calls[0][0] as string;
|
|
87
|
+
expect(output).toContain('0001-one.html');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('links graph nodes by adr number', async () => {
|
|
91
|
+
const deps = createDeps();
|
|
92
|
+
deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md', '/repo/doc/adr/0003-three.md']);
|
|
93
|
+
deps.readFile.mockResolvedValue('# Title');
|
|
94
|
+
deps.getTitleFrom.mockReturnValue('ADR Title');
|
|
95
|
+
deps.getLinksFrom.mockReturnValue([{ label: 'Relates to', targetNumber: '3' }]);
|
|
96
|
+
await run(['node', 'adr', 'generate', 'graph'], deps);
|
|
97
|
+
const output = deps.log.mock.calls[0][0] as string;
|
|
98
|
+
expect(output).toContain('_1 -> _3 [style="dotted"');
|
|
99
|
+
expect(output).toContain('_1 -> _3 [label="Relates to"');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('skips graph links that target missing nodes', async () => {
|
|
103
|
+
const deps = createDeps();
|
|
104
|
+
deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
|
|
105
|
+
deps.readFile.mockResolvedValue('# Title');
|
|
106
|
+
deps.getTitleFrom.mockReturnValue('ADR Title');
|
|
107
|
+
deps.getLinksFrom.mockReturnValue([{ label: 'Relates to', targetNumber: '99' }]);
|
|
108
|
+
await run(['node', 'adr', 'generate', 'graph'], deps);
|
|
109
|
+
const output = deps.log.mock.calls[0][0] as string;
|
|
110
|
+
expect(output).not.toContain('_1 -> _99');
|
|
111
|
+
expect(deps.warn).toHaveBeenCalledWith('Skipping graph link from ADR 1 to missing ADR 99: Relates to');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('skips files without numeric prefixes', async () => {
|
|
115
|
+
const deps = createDeps();
|
|
116
|
+
deps.listAdrs.mockResolvedValue(['/repo/doc/adr/notes.md', '/repo/doc/adr/0001-one.md']);
|
|
117
|
+
deps.readFile.mockResolvedValue('# Title');
|
|
118
|
+
deps.getTitleFrom.mockReturnValue('ADR Title');
|
|
119
|
+
deps.getLinksFrom.mockReturnValue([]);
|
|
120
|
+
await run(['node', 'adr', 'generate', 'graph'], deps);
|
|
121
|
+
expect(deps.readFile).toHaveBeenCalledTimes(2);
|
|
122
|
+
expect(deps.readFile).toHaveBeenNthCalledWith(1, '/repo/doc/adr/0001-one.md', 'utf8');
|
|
123
|
+
expect(deps.readFile).toHaveBeenNthCalledWith(2, '/repo/doc/adr/0001-one.md', 'utf8');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('escapes graph labels and urls', async () => {
|
|
127
|
+
const deps = createDeps();
|
|
128
|
+
deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
|
|
129
|
+
deps.readFile.mockResolvedValue('# Title');
|
|
130
|
+
deps.getTitleFrom.mockReturnValue('ADR "Title" \\\\ path\nline');
|
|
131
|
+
deps.getLinksFrom.mockReturnValue([]);
|
|
132
|
+
await run(['node', 'adr', 'generate', 'graph', '-e', '.md', '-p', '/p/"weird"/'], deps);
|
|
133
|
+
const output = deps.log.mock.calls[0][0] as string;
|
|
134
|
+
const match = output.match(/label="([\s\S]*?)"; URL="([^"]+)"/);
|
|
135
|
+
expect(match?.[1]).toContain(String.raw`ADR \"Title\" \\\\ path\\nline`);
|
|
136
|
+
expect(match?.[1]).not.toContain('\n');
|
|
137
|
+
expect(output).toContain(String.raw`URL="/p/\"weird\"/0001-one.md"`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('routes link command', async () => {
|
|
141
|
+
const deps = createDeps();
|
|
142
|
+
await run(['node', 'adr', 'link', '1', 'Amends', '2', 'Amended by'], deps);
|
|
143
|
+
const call = deps.link.mock.calls[0];
|
|
144
|
+
expect(call.slice(0, 4)).toEqual(['1', 'Amends', '2', 'Amended by']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('routes init command', async () => {
|
|
148
|
+
const deps = createDeps();
|
|
149
|
+
await run(['node', 'adr', 'init', 'doc/adr'], deps);
|
|
150
|
+
expect(deps.init).toHaveBeenCalledWith('doc/adr');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('lists adrs relative to working dir', async () => {
|
|
154
|
+
const deps = createDeps();
|
|
155
|
+
deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
|
|
156
|
+
deps.workingDir.mockReturnValue('/repo');
|
|
157
|
+
await run(['node', 'adr', 'list'], deps);
|
|
158
|
+
expect(deps.log).toHaveBeenCalledWith(path.join('doc', 'adr', '0001-one.md'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('collects repeated options for links and supersedes', async () => {
|
|
162
|
+
const deps = createDeps();
|
|
163
|
+
await run(
|
|
164
|
+
[
|
|
165
|
+
'node',
|
|
166
|
+
'adr',
|
|
167
|
+
'new',
|
|
168
|
+
'My',
|
|
169
|
+
'ADR',
|
|
170
|
+
'-l',
|
|
171
|
+
'1:Links:Linked by',
|
|
172
|
+
'-l',
|
|
173
|
+
'2:Relates:Related by',
|
|
174
|
+
'-s',
|
|
175
|
+
'1',
|
|
176
|
+
'-s',
|
|
177
|
+
'2'
|
|
178
|
+
],
|
|
179
|
+
deps
|
|
180
|
+
);
|
|
181
|
+
const [, options] = deps.newAdr.mock.calls[0];
|
|
182
|
+
expect(options.links).toEqual(['1:Links:Linked by', '2:Relates:Related by']);
|
|
183
|
+
expect(options.supersedes).toEqual(['1', '2']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('detects direct runs based on argv', () => {
|
|
187
|
+
const indexPath = path.resolve(process.cwd(), 'src/index.ts');
|
|
188
|
+
const moduleUrl = pathToFileURL(indexPath).href;
|
|
189
|
+
expect(isDirectRun(['node', indexPath], moduleUrl)).toBe(true);
|
|
190
|
+
expect(isDirectRun(['node'], moduleUrl)).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('returns false when maybeRun is not direct', () => {
|
|
194
|
+
const deps = createDeps();
|
|
195
|
+
expect(maybeRun(['node', '/other/entry'], deps)).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('runs when maybeRun detects direct execution', async () => {
|
|
199
|
+
const deps = createDeps();
|
|
200
|
+
deps.listAdrs.mockResolvedValue([]);
|
|
201
|
+
const indexPath = path.resolve(process.cwd(), 'src/index.ts');
|
|
202
|
+
expect(maybeRun(['node', indexPath, 'list'], deps)).toBe(true);
|
|
203
|
+
expect(deps.listAdrs).toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('formats errors with the default handler', () => {
|
|
207
|
+
const errorHandler = vi.fn();
|
|
208
|
+
const program = { error: errorHandler } as unknown as Command;
|
|
209
|
+
defaultOnError(program, new Error('boom'));
|
|
210
|
+
expect(errorHandler).toHaveBeenCalledWith(chalk.red('boom'), { exitCode: 1 });
|
|
211
|
+
});
|
|
212
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import chalk from 'chalk';
|
|
5
7
|
import { Command } from 'commander';
|
|
6
|
-
import fs from 'fs/promises';
|
|
7
8
|
import { generateToc, init, link, listAdrs, newAdr } from './lib/adr.js';
|
|
8
9
|
import { workingDir } from './lib/config.js';
|
|
9
10
|
import { getLinksFrom, getTitleFrom } from './lib/manipulator.js';
|
|
10
11
|
import { LIB_VERSION } from './version.js';
|
|
11
12
|
|
|
12
|
-
const program = new Command();
|
|
13
|
-
|
|
14
13
|
const collectLinks = (val: string, memo: string[]) => {
|
|
15
14
|
memo.push(val);
|
|
16
15
|
return memo;
|
|
@@ -21,120 +20,242 @@ const collectSupersedes = (val: string, memo: string[]) => {
|
|
|
21
20
|
return memo;
|
|
22
21
|
};
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
23
|
+
type ProgramDeps = {
|
|
24
|
+
generateToc: typeof generateToc;
|
|
25
|
+
init: typeof init;
|
|
26
|
+
link: typeof link;
|
|
27
|
+
listAdrs: typeof listAdrs;
|
|
28
|
+
newAdr: typeof newAdr;
|
|
29
|
+
workingDir: typeof workingDir;
|
|
30
|
+
readFile: typeof fs.readFile;
|
|
31
|
+
getLinksFrom: typeof getLinksFrom;
|
|
32
|
+
getTitleFrom: typeof getTitleFrom;
|
|
33
|
+
version: string;
|
|
34
|
+
onError: (program: Command, error: Error) => void;
|
|
35
|
+
log: (message: string) => void;
|
|
36
|
+
warn: (message: string) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const defaultOnError = (program: Command, error: Error) => {
|
|
40
|
+
program.error(chalk.red(error.message), { exitCode: 1 });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const defaultDeps: ProgramDeps = {
|
|
44
|
+
generateToc,
|
|
45
|
+
init,
|
|
46
|
+
link,
|
|
47
|
+
listAdrs,
|
|
48
|
+
newAdr,
|
|
49
|
+
workingDir,
|
|
50
|
+
readFile: fs.readFile,
|
|
51
|
+
getLinksFrom,
|
|
52
|
+
getTitleFrom,
|
|
53
|
+
version: LIB_VERSION,
|
|
54
|
+
onError: defaultOnError,
|
|
55
|
+
log: console.log,
|
|
56
|
+
warn: console.warn
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const escapeDotString = (value: string) =>
|
|
60
|
+
value
|
|
61
|
+
.replace(/\\/g, '\\\\')
|
|
62
|
+
.replace(/"/g, '\\"')
|
|
63
|
+
.replace(/\r\n|\n|\r/g, '\\\\n');
|
|
64
|
+
|
|
65
|
+
const getAdrNumberFromPath = (adrPath: string): number | undefined => {
|
|
66
|
+
const baseName = path.basename(adrPath, '.md');
|
|
67
|
+
const match = baseName.match(/^0*(\d+)-/);
|
|
68
|
+
if (!match) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return Number.parseInt(match[1], 10);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type GraphNode = { adrPath: string; number: number };
|
|
75
|
+
|
|
76
|
+
const resolveGraphNodes = (adrs: string[]): GraphNode[] =>
|
|
77
|
+
adrs
|
|
78
|
+
.map((adrPath) => ({ adrPath, number: getAdrNumberFromPath(adrPath) }))
|
|
79
|
+
.filter((node): node is GraphNode => node.number !== undefined);
|
|
80
|
+
|
|
81
|
+
const appendGraphNodes = async (
|
|
82
|
+
deps: ProgramDeps,
|
|
83
|
+
nodes: GraphNode[],
|
|
84
|
+
options: { prefix?: string; extension?: string } | undefined,
|
|
85
|
+
lines: string[]
|
|
86
|
+
) => {
|
|
87
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
88
|
+
const n = nodes[i].number;
|
|
89
|
+
const adrPath = nodes[i].adrPath;
|
|
90
|
+
const contents = await deps.readFile(adrPath, 'utf8');
|
|
91
|
+
const title = deps.getTitleFrom(contents);
|
|
92
|
+
const url = `${options?.prefix || ''}${path.basename(adrPath, '.md')}${options?.extension}`;
|
|
93
|
+
lines.push(` _${n} [label="${escapeDotString(title)}"; URL="${escapeDotString(url)}"];`);
|
|
94
|
+
const previous = nodes[i - 1]?.number;
|
|
95
|
+
if (previous !== undefined) {
|
|
96
|
+
lines.push(` _${previous} -> _${n} [style="dotted", weight=1];`);
|
|
38
97
|
}
|
|
39
98
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const resolveTargetNodeId = (nodeIds: Set<number>, targetNumber: string): number | undefined => {
|
|
102
|
+
const number = Number.parseInt(targetNumber, 10);
|
|
103
|
+
if (!Number.isFinite(number) || !nodeIds.has(number)) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const appendGraphEdges = async (deps: ProgramDeps, nodes: GraphNode[], lines: string[]) => {
|
|
110
|
+
const nodeIds = new Set(nodes.map((node) => node.number));
|
|
111
|
+
for (const node of nodes) {
|
|
112
|
+
const contents = await deps.readFile(node.adrPath, 'utf8');
|
|
113
|
+
const linksInADR = deps.getLinksFrom(contents);
|
|
114
|
+
for (const link of linksInADR) {
|
|
115
|
+
if (link.label.endsWith('by')) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const target = resolveTargetNodeId(nodeIds, link.targetNumber);
|
|
119
|
+
if (target !== undefined) {
|
|
120
|
+
lines.push(` _${node.number} -> _${target} [label="${link.label}", weight=0]`);
|
|
121
|
+
} else {
|
|
122
|
+
deps.warn(`Skipping graph link from ADR ${node.number} to missing ADR ${link.targetNumber}: ${link.label}`);
|
|
50
123
|
}
|
|
51
124
|
}
|
|
52
125
|
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const generateGraph = async (deps: ProgramDeps, options?: { prefix: string; extension: string }) => {
|
|
129
|
+
const lines: string[] = [];
|
|
130
|
+
lines.push('digraph {');
|
|
131
|
+
lines.push(' node [shape=plaintext];');
|
|
132
|
+
lines.push(' subgraph {');
|
|
53
133
|
|
|
54
|
-
|
|
55
|
-
|
|
134
|
+
const adrs = await deps.listAdrs();
|
|
135
|
+
const nodes = resolveGraphNodes(adrs);
|
|
136
|
+
await appendGraphNodes(deps, nodes, options, lines);
|
|
137
|
+
|
|
138
|
+
lines.push(' }');
|
|
139
|
+
await appendGraphEdges(deps, nodes, lines);
|
|
140
|
+
lines.push('}');
|
|
141
|
+
|
|
142
|
+
deps.log(`${lines.join('\n')}\n`);
|
|
56
143
|
};
|
|
57
144
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
program
|
|
61
|
-
.command('new')
|
|
62
|
-
.argument('<title...>', 'The title of the decision')
|
|
63
|
-
.option(
|
|
64
|
-
'-q, --quiet',
|
|
65
|
-
'Do not ask for clarification. If multiple files match the search pattern, an error will be thrown.'
|
|
66
|
-
)
|
|
67
|
-
.option(
|
|
68
|
-
'-s, --supersede <SUPERSEDE>',
|
|
69
|
-
'A reference (number or partial filename) of a previous decision that the new decision supercedes.\n' +
|
|
70
|
-
'A Markdown link to the superceded ADR is inserted into the Status section.\n' +
|
|
71
|
-
'The status of the superceded ADR is changed to record that it has been superceded by the new ADR.',
|
|
72
|
-
collectSupersedes,
|
|
73
|
-
[]
|
|
74
|
-
)
|
|
75
|
-
.option(
|
|
76
|
-
'-l, --link "<TARGET:LINK:REVERSE-LINK>"',
|
|
77
|
-
'Links the new ADR to a previous ADR.\n' +
|
|
78
|
-
`${chalk.bold('TARGET')} is a reference (number or partial filename) of a previous decision.\n` +
|
|
79
|
-
`${chalk.bold('LINK')} is the description of the link created in the new ADR.\n` +
|
|
80
|
-
`${chalk.bold('REVERSE-LINK')} is the description of the link created in the existing ADR that will refer to the new ADR`,
|
|
81
|
-
collectLinks,
|
|
82
|
-
[]
|
|
83
|
-
)
|
|
84
|
-
.action(async (title: string[], options) => {
|
|
85
|
-
try {
|
|
86
|
-
await newAdr(title.join(' '), {
|
|
87
|
-
supersedes: options.supersede,
|
|
88
|
-
date: process.env.ADR_DATE,
|
|
89
|
-
suppressPrompts: options.quiet || false,
|
|
90
|
-
links: options.link
|
|
91
|
-
});
|
|
92
|
-
} catch (e) {
|
|
93
|
-
program.error(chalk.red((e as Error).message), { exitCode: 1 });
|
|
94
|
-
}
|
|
95
|
-
});
|
|
145
|
+
export const buildProgram = (deps: ProgramDeps = defaultDeps) => {
|
|
146
|
+
const program = new Command();
|
|
147
|
+
program.name('adr').version(deps.version).description('Manage Architecture Decision Logs');
|
|
96
148
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
149
|
+
program
|
|
150
|
+
.command('new')
|
|
151
|
+
.argument('<title...>', 'The title of the decision')
|
|
152
|
+
.option(
|
|
153
|
+
'-q, --quiet',
|
|
154
|
+
'Do not ask for clarification. If multiple files match the search pattern, an error will be thrown.'
|
|
155
|
+
)
|
|
156
|
+
.option('--open', 'Open the created ADR after writing it (use OS default or `--open-with`).')
|
|
157
|
+
.option(
|
|
158
|
+
'--open-with <COMMAND>',
|
|
159
|
+
'Open the created ADR with a specific command (optionally with args); implies `--open`.'
|
|
160
|
+
)
|
|
161
|
+
.option(
|
|
162
|
+
'-s, --supersede <SUPERSEDE>',
|
|
163
|
+
'A reference (number or partial filename) of a previous decision that the new decision supercedes.\n' +
|
|
164
|
+
'A Markdown link to the superceded ADR is inserted into the Status section.\n' +
|
|
165
|
+
'The status of the superceded ADR is changed to record that it has been superceded by the new ADR.',
|
|
166
|
+
collectSupersedes,
|
|
167
|
+
[]
|
|
168
|
+
)
|
|
169
|
+
.option(
|
|
170
|
+
'-l, --link "<TARGET:LINK:REVERSE-LINK>"',
|
|
171
|
+
'Links the new ADR to a previous ADR.\n' +
|
|
172
|
+
`${chalk.bold('TARGET')} is a reference (number or partial filename) of a previous decision.\n` +
|
|
173
|
+
`${chalk.bold('LINK')} is the description of the link created in the new ADR.\n` +
|
|
174
|
+
`${chalk.bold('REVERSE-LINK')} is the description of the link created in the existing ADR that will refer to the new ADR`,
|
|
175
|
+
collectLinks,
|
|
176
|
+
[]
|
|
177
|
+
)
|
|
178
|
+
.action(async (title: string[], options) => {
|
|
179
|
+
try {
|
|
180
|
+
await deps.newAdr(title.join(' '), {
|
|
181
|
+
supersedes: options.supersede,
|
|
182
|
+
date: process.env.ADR_DATE,
|
|
183
|
+
suppressPrompts: options.quiet || false,
|
|
184
|
+
links: options.link,
|
|
185
|
+
open: options.open || Boolean(options.openWith),
|
|
186
|
+
openWith: options.openWith
|
|
187
|
+
});
|
|
188
|
+
} catch (e) {
|
|
189
|
+
deps.onError(program, e as Error);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const generate = program.command('generate');
|
|
194
|
+
|
|
195
|
+
generate
|
|
196
|
+
.command('toc')
|
|
197
|
+
.option('-p, --prefix <PREFIX>', 'The prefix to use for each file link in the generated TOC.')
|
|
198
|
+
.action((options) => deps.generateToc(options));
|
|
199
|
+
|
|
200
|
+
generate
|
|
201
|
+
.command('graph')
|
|
202
|
+
.option('-p, --prefix <PREFIX>', 'Prefix each decision file link with PREFIX.')
|
|
203
|
+
.option(
|
|
204
|
+
'-e, --extension <EXTENSION>',
|
|
205
|
+
'the file extension of the documents to which generated links refer. Defaults to .html',
|
|
206
|
+
'.html'
|
|
207
|
+
)
|
|
208
|
+
.action(async (options) => {
|
|
209
|
+
await generateGraph(deps, options);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
program
|
|
213
|
+
.command('link')
|
|
214
|
+
.argument('<SOURCE>', 'Full or Partial reference number to an ADR')
|
|
215
|
+
.argument('<LINK>', 'The description of the link created in the SOURCE')
|
|
216
|
+
.argument('<TARGET>', 'Full or Partial reference number to an ADR')
|
|
217
|
+
.argument('<REVERSE-LINK>', 'The description of the link created in the TARGET')
|
|
218
|
+
.option(
|
|
219
|
+
'-q, --quiet',
|
|
220
|
+
'Do not ask for clarification. If multiple files match the search pattern, an error will be thrown.'
|
|
221
|
+
)
|
|
222
|
+
.action(deps.link);
|
|
115
223
|
|
|
116
|
-
program
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.action(link);
|
|
127
|
-
|
|
128
|
-
program
|
|
129
|
-
.command('init')
|
|
130
|
-
.argument('[directory]', 'Initialize a new ADR directory')
|
|
131
|
-
.action(async (directory?: string) => {
|
|
132
|
-
await init(directory);
|
|
224
|
+
program
|
|
225
|
+
.command('init')
|
|
226
|
+
.argument('[directory]', 'Initialize a new ADR directory')
|
|
227
|
+
.action(async (directory?: string) => {
|
|
228
|
+
await deps.init(directory);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
program.command('list').action(async () => {
|
|
232
|
+
const adrs = await deps.listAdrs();
|
|
233
|
+
deps.log(adrs.map((adr) => path.relative(deps.workingDir(), adr)).join('\n'));
|
|
133
234
|
});
|
|
134
235
|
|
|
135
|
-
program
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
236
|
+
return program;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export const run = async (argv = process.argv, deps: ProgramDeps = defaultDeps) => {
|
|
240
|
+
const program = buildProgram(deps);
|
|
241
|
+
await program.parseAsync(argv);
|
|
242
|
+
return program;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const isDirectRun = (argv = process.argv, moduleUrl = import.meta.url) => {
|
|
246
|
+
if (!argv[1]) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
return path.resolve(argv[1]) === fileURLToPath(moduleUrl);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const maybeRun = (argv = process.argv, deps?: ProgramDeps) => {
|
|
253
|
+
if (!isDirectRun(argv)) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
const runPromise = run(argv, deps);
|
|
257
|
+
runPromise.catch(() => undefined);
|
|
258
|
+
return true;
|
|
259
|
+
};
|
|
139
260
|
|
|
140
|
-
|
|
261
|
+
maybeRun();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
// @ts-expect-error no module types for script import
|
|
6
|
+
import { buildVersionSource, resolveTargetPath, writeVersionFile } from '../scripts/inject-version.mjs';
|
|
7
|
+
|
|
8
|
+
describe('inject-version script helpers', () => {
|
|
9
|
+
it('builds the version export', () => {
|
|
10
|
+
expect(buildVersionSource('1.2.3')).toBe("export const LIB_VERSION = '1.2.3';\n");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('requires an output path', () => {
|
|
14
|
+
expect(() => resolveTargetPath(['node', 'scripts/inject-version.mjs'])).toThrow(
|
|
15
|
+
'Usage: node scripts/inject-version.mjs <output-file>'
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('writes the version file', () => {
|
|
20
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'adr-version-'));
|
|
21
|
+
const targetPath = path.join(tmpDir, 'version.ts');
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
writeVersionFile(targetPath, '9.9.9');
|
|
25
|
+
const contents = readFileSync(targetPath, 'utf8');
|
|
26
|
+
expect(contents).toBe("export const LIB_VERSION = '9.9.9';\n");
|
|
27
|
+
} finally {
|
|
28
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|