cadr-cli 0.0.1 → 1.9.2
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/dist/adr.d.ts +50 -0
- package/dist/adr.d.ts.map +1 -0
- package/dist/adr.js +156 -0
- package/dist/adr.js.map +1 -0
- package/dist/adr.test.d.ts +8 -0
- package/dist/adr.test.d.ts.map +1 -0
- package/dist/adr.test.js +256 -0
- package/dist/adr.test.js.map +1 -0
- package/dist/analysis.d.ts +24 -0
- package/dist/analysis.d.ts.map +1 -0
- package/dist/analysis.js +281 -0
- package/dist/analysis.js.map +1 -0
- package/dist/analysis.test.d.ts +8 -0
- package/dist/analysis.test.d.ts.map +1 -0
- package/dist/analysis.test.js +351 -0
- package/dist/analysis.test.js.map +1 -0
- package/dist/commands/analyze.d.ts +14 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +56 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +93 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/init.test.d.ts +2 -0
- package/dist/commands/init.test.d.ts.map +1 -0
- package/dist/commands/init.test.js +56 -0
- package/dist/commands/init.test.js.map +1 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +208 -0
- package/dist/config.js.map +1 -0
- package/dist/config.test.d.ts +2 -0
- package/dist/config.test.d.ts.map +1 -0
- package/dist/config.test.js +97 -0
- package/dist/config.test.js.map +1 -0
- package/dist/git.d.ts +42 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +157 -0
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -62
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +51 -0
- package/dist/index.test.js.map +1 -0
- package/dist/llm.d.ts +73 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +263 -0
- package/dist/llm.js.map +1 -0
- package/dist/llm.test.d.ts +2 -0
- package/dist/llm.test.d.ts.map +1 -0
- package/dist/llm.test.js +592 -0
- package/dist/llm.test.js.map +1 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +5 -3
- package/dist/logger.js.map +1 -1
- package/dist/logger.test.d.ts +2 -0
- package/dist/logger.test.d.ts.map +1 -0
- package/dist/logger.test.js +78 -0
- package/dist/logger.test.js.map +1 -0
- package/dist/prompts.d.ts +49 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +195 -0
- package/dist/prompts.js.map +1 -0
- package/dist/prompts.test.d.ts +2 -0
- package/dist/prompts.test.d.ts.map +1 -0
- package/dist/prompts.test.js +427 -0
- package/dist/prompts.test.js.map +1 -0
- package/dist/providers/gemini.d.ts +3 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +39 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/index.d.ts +2 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +6 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +3 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +25 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/providers/registry.d.ts +4 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +16 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/providers/types.d.ts +12 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +5 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/version.test.d.ts +3 -0
- package/dist/version.test.d.ts.map +1 -0
- package/dist/version.test.js +25 -0
- package/dist/version.test.js.map +1 -0
- package/package.json +14 -5
- package/src/adr.test.ts +278 -0
- package/src/adr.ts +136 -0
- package/src/analysis.test.ts +396 -0
- package/src/analysis.ts +262 -0
- package/src/commands/analyze.ts +56 -0
- package/src/commands/init.test.ts +27 -0
- package/src/commands/init.ts +99 -0
- package/src/config.test.ts +79 -0
- package/src/config.ts +214 -0
- package/src/git.ts +240 -0
- package/src/index.test.ts +59 -0
- package/src/index.ts +80 -60
- package/src/llm.test.ts +701 -0
- package/src/llm.ts +344 -0
- package/src/logger.test.ts +90 -0
- package/src/logger.ts +6 -3
- package/src/prompts.test.ts +515 -0
- package/src/prompts.ts +174 -0
- package/src/providers/gemini.ts +41 -0
- package/src/providers/index.ts +1 -0
- package/src/providers/openai.ts +22 -0
- package/src/providers/registry.ts +16 -0
- package/src/providers/types.ts +14 -0
- package/src/version.test.ts +29 -0
- package/bin/cadr.js +0 -16
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Test version constants in CLI
|
|
3
|
+
const CORE_VERSION = '0.0.1';
|
|
4
|
+
const CLI_VERSION = '0.0.1';
|
|
5
|
+
describe('CLI Version Constants', () => {
|
|
6
|
+
test('exports CORE_VERSION constant', () => {
|
|
7
|
+
expect(CORE_VERSION).toBe('0.0.1');
|
|
8
|
+
});
|
|
9
|
+
test('CORE_VERSION is a string', () => {
|
|
10
|
+
expect(typeof CORE_VERSION).toBe('string');
|
|
11
|
+
});
|
|
12
|
+
test('CORE_VERSION matches semantic version pattern', () => {
|
|
13
|
+
expect(CORE_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
|
14
|
+
});
|
|
15
|
+
test('exports CLI_VERSION constant', () => {
|
|
16
|
+
expect(CLI_VERSION).toBe('0.0.1');
|
|
17
|
+
});
|
|
18
|
+
test('CLI_VERSION is a string', () => {
|
|
19
|
+
expect(typeof CLI_VERSION).toBe('string');
|
|
20
|
+
});
|
|
21
|
+
test('CLI_VERSION matches semantic version pattern', () => {
|
|
22
|
+
expect(CLI_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
//# sourceMappingURL=version.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.test.js","sourceRoot":"","sources":["../src/version.test.ts"],"names":[],"mappings":";AAAA,gCAAgC;AAChC,MAAM,YAAY,GAAG,OAAO,CAAC;AAC7B,MAAM,WAAW,GAAG,OAAO,CAAC;AAE5B,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,IAAI,CAAC,+BAA+B,EAAE,GAAG,EAAE;QACzC,MAAM,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,0BAA0B,EAAE,GAAG,EAAE;QACpC,MAAM,CAAC,OAAO,YAAY,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC7C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,YAAY,CAAC,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8BAA8B,EAAE,GAAG,EAAE;QACxC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACnC,MAAM,CAAC,OAAO,WAAW,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACxD,MAAM,CAAC,WAAW,CAAC,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACjD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cadr-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.9.2",
|
|
4
4
|
"description": "Continuous Architectural Decision Records - Automatically capture ADRs as you code",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"cadr": "./
|
|
7
|
+
"cadr": "./dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"build": "tsc",
|
|
10
|
+
"build": "tsc && npm run postbuild",
|
|
11
|
+
"postbuild": "if [ -f dist/index.js ]; then if ! head -1 dist/index.js | grep -q '^#!/usr/bin/env node$'; then echo '#!/usr/bin/env node' | cat - dist/index.js > dist/index.tmp && mv dist/index.tmp dist/index.js; fi; chmod +x dist/index.js; fi",
|
|
11
12
|
"test": "jest"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
"decision-records",
|
|
18
19
|
"documentation"
|
|
19
20
|
],
|
|
20
|
-
"author": "",
|
|
21
|
+
"author": "Yotpo Ltd.",
|
|
21
22
|
"license": "MIT",
|
|
22
23
|
"repository": {
|
|
23
24
|
"type": "git",
|
|
@@ -26,7 +27,15 @@
|
|
|
26
27
|
"engines": {
|
|
27
28
|
"node": ">=20.0.0"
|
|
28
29
|
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
29
33
|
"dependencies": {
|
|
30
|
-
"pino": "^8.17.2"
|
|
34
|
+
"pino": "^8.17.2",
|
|
35
|
+
"openai": "^4.0.0",
|
|
36
|
+
"@google/generative-ai": "^0.21.0",
|
|
37
|
+
"js-yaml": "^4.1.0",
|
|
38
|
+
"yup": "^1.0.0",
|
|
39
|
+
"commander": "^11.0.0"
|
|
31
40
|
}
|
|
32
41
|
}
|
package/src/adr.test.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR File Management Module Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for ADR file creation, numbering, and management.
|
|
5
|
+
* Following TDD: These tests are written BEFORE implementation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import {
|
|
11
|
+
titleToSlug,
|
|
12
|
+
getNextADRNumber,
|
|
13
|
+
generateADRFilename,
|
|
14
|
+
ensureADRDirectory,
|
|
15
|
+
saveADR,
|
|
16
|
+
DEFAULT_ADR_DIR,
|
|
17
|
+
} from './adr';
|
|
18
|
+
|
|
19
|
+
describe('ADR Module', () => {
|
|
20
|
+
const testDir = path.join(__dirname, '../test-adrs');
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
// Clean up test directory before each test
|
|
24
|
+
if (fs.existsSync(testDir)) {
|
|
25
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
// Clean up test directory after each test
|
|
31
|
+
if (fs.existsSync(testDir)) {
|
|
32
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('titleToSlug', () => {
|
|
37
|
+
it('converts title to lowercase slug', () => {
|
|
38
|
+
expect(titleToSlug('Use PostgreSQL for Storage')).toBe('use-postgresql-for-storage');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('replaces spaces with hyphens', () => {
|
|
42
|
+
expect(titleToSlug('My Great Decision')).toBe('my-great-decision');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('handles special characters', () => {
|
|
46
|
+
expect(titleToSlug('API v2.0: New Endpoints!')).toBe('api-v2-0-new-endpoints');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('removes leading and trailing hyphens', () => {
|
|
50
|
+
expect(titleToSlug('---Test Title---')).toBe('test-title');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles multiple consecutive spaces', () => {
|
|
54
|
+
expect(titleToSlug('Too Many Spaces')).toBe('too-many-spaces');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles empty string', () => {
|
|
58
|
+
expect(titleToSlug('')).toBe('');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles only special characters', () => {
|
|
62
|
+
expect(titleToSlug('!!!')).toBe('');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('generateADRFilename', () => {
|
|
67
|
+
it('generates properly formatted filename', () => {
|
|
68
|
+
expect(generateADRFilename(1, 'Use PostgreSQL')).toBe('0001-use-postgresql.md');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('pads numbers with zeros to 4 digits', () => {
|
|
72
|
+
expect(generateADRFilename(42, 'Test Decision')).toBe('0042-test-decision.md');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('handles three-digit numbers', () => {
|
|
76
|
+
expect(generateADRFilename(123, 'Another Decision')).toBe('0123-another-decision.md');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('handles four-digit numbers', () => {
|
|
80
|
+
expect(generateADRFilename(9999, 'Max Decision')).toBe('9999-max-decision.md');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('handles titles with special characters', () => {
|
|
84
|
+
expect(generateADRFilename(5, 'Switch to React v18!')).toBe('0005-switch-to-react-v18.md');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('getNextADRNumber', () => {
|
|
89
|
+
it('returns 1 for non-existent directory', () => {
|
|
90
|
+
expect(getNextADRNumber(testDir)).toBe(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns 1 for empty directory', () => {
|
|
94
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
95
|
+
expect(getNextADRNumber(testDir)).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns next number after existing ADRs', () => {
|
|
99
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
100
|
+
fs.writeFileSync(path.join(testDir, '0001-first.md'), '# First');
|
|
101
|
+
fs.writeFileSync(path.join(testDir, '0002-second.md'), '# Second');
|
|
102
|
+
expect(getNextADRNumber(testDir)).toBe(3);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('handles non-sequential numbers correctly', () => {
|
|
106
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
107
|
+
fs.writeFileSync(path.join(testDir, '0001-first.md'), '# First');
|
|
108
|
+
fs.writeFileSync(path.join(testDir, '0005-fifth.md'), '# Fifth');
|
|
109
|
+
expect(getNextADRNumber(testDir)).toBe(6);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ignores files without number prefix', () => {
|
|
113
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
114
|
+
fs.writeFileSync(path.join(testDir, '0001-first.md'), '# First');
|
|
115
|
+
fs.writeFileSync(path.join(testDir, 'README.md'), '# README');
|
|
116
|
+
fs.writeFileSync(path.join(testDir, 'template.md'), '# Template');
|
|
117
|
+
expect(getNextADRNumber(testDir)).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('ignores files with incorrect number format', () => {
|
|
121
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
122
|
+
fs.writeFileSync(path.join(testDir, '0001-first.md'), '# First');
|
|
123
|
+
fs.writeFileSync(path.join(testDir, '1-wrong.md'), '# Wrong');
|
|
124
|
+
fs.writeFileSync(path.join(testDir, '00002-also-wrong.md'), '# Also Wrong');
|
|
125
|
+
expect(getNextADRNumber(testDir)).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles directory with only non-ADR files', () => {
|
|
129
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
130
|
+
fs.writeFileSync(path.join(testDir, 'README.md'), '# README');
|
|
131
|
+
expect(getNextADRNumber(testDir)).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('ensureADRDirectory', () => {
|
|
136
|
+
it('creates directory if it does not exist', () => {
|
|
137
|
+
ensureADRDirectory(testDir);
|
|
138
|
+
expect(fs.existsSync(testDir)).toBe(true);
|
|
139
|
+
expect(fs.statSync(testDir).isDirectory()).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('creates nested directories recursively', () => {
|
|
143
|
+
const nestedDir = path.join(testDir, 'nested', 'path');
|
|
144
|
+
ensureADRDirectory(nestedDir);
|
|
145
|
+
expect(fs.existsSync(nestedDir)).toBe(true);
|
|
146
|
+
expect(fs.statSync(nestedDir).isDirectory()).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('does not error if directory already exists', () => {
|
|
150
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
151
|
+
expect(() => ensureADRDirectory(testDir)).not.toThrow();
|
|
152
|
+
expect(fs.existsSync(testDir)).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('can be called multiple times safely', () => {
|
|
156
|
+
ensureADRDirectory(testDir);
|
|
157
|
+
ensureADRDirectory(testDir);
|
|
158
|
+
ensureADRDirectory(testDir);
|
|
159
|
+
expect(fs.existsSync(testDir)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('saveADR', () => {
|
|
164
|
+
it('saves ADR with correct filename in specified directory', () => {
|
|
165
|
+
const content = '# Use PostgreSQL\n\n* Status: accepted\n\nContent here';
|
|
166
|
+
const result = saveADR(content, 'Use PostgreSQL', testDir);
|
|
167
|
+
|
|
168
|
+
expect(result.success).toBe(true);
|
|
169
|
+
expect(result.filePath).toBe(path.join(testDir, '0001-use-postgresql.md'));
|
|
170
|
+
expect(result.error).toBeUndefined();
|
|
171
|
+
expect(fs.existsSync(result.filePath!)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('creates directory automatically if it does not exist', () => {
|
|
175
|
+
const content = '# Test ADR\n\nContent';
|
|
176
|
+
const result = saveADR(content, 'Test ADR', testDir);
|
|
177
|
+
|
|
178
|
+
expect(fs.existsSync(testDir)).toBe(true);
|
|
179
|
+
expect(result.success).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('increments number for multiple ADRs', () => {
|
|
183
|
+
const result1 = saveADR('# First\n\nFirst ADR', 'First', testDir);
|
|
184
|
+
const result2 = saveADR('# Second\n\nSecond ADR', 'Second', testDir);
|
|
185
|
+
const result3 = saveADR('# Third\n\nThird ADR', 'Third', testDir);
|
|
186
|
+
|
|
187
|
+
expect(result1.filePath).toContain('0001-first.md');
|
|
188
|
+
expect(result2.filePath).toContain('0002-second.md');
|
|
189
|
+
expect(result3.filePath).toContain('0003-third.md');
|
|
190
|
+
|
|
191
|
+
expect(fs.existsSync(result1.filePath!)).toBe(true);
|
|
192
|
+
expect(fs.existsSync(result2.filePath!)).toBe(true);
|
|
193
|
+
expect(fs.existsSync(result3.filePath!)).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('saves correct content to file', () => {
|
|
197
|
+
const content = '# My Decision\n\n* Status: accepted\n* Date: 2025-10-21\n\n## Context\n\nSome context here.';
|
|
198
|
+
const result = saveADR(content, 'My Decision', testDir);
|
|
199
|
+
|
|
200
|
+
const savedContent = fs.readFileSync(result.filePath!, 'utf-8');
|
|
201
|
+
expect(savedContent).toBe(content);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('uses default directory when not specified', () => {
|
|
205
|
+
// Clean up default directory
|
|
206
|
+
const defaultPath = path.join(process.cwd(), DEFAULT_ADR_DIR);
|
|
207
|
+
if (fs.existsSync(defaultPath)) {
|
|
208
|
+
fs.rmSync(defaultPath, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const content = '# Default Location\n\nContent';
|
|
212
|
+
const result = saveADR(content, 'Default Location');
|
|
213
|
+
|
|
214
|
+
expect(result.success).toBe(true);
|
|
215
|
+
expect(result.filePath).toContain(DEFAULT_ADR_DIR);
|
|
216
|
+
|
|
217
|
+
// Cleanup
|
|
218
|
+
if (fs.existsSync(defaultPath)) {
|
|
219
|
+
fs.rmSync(defaultPath, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('handles file write errors gracefully', () => {
|
|
224
|
+
// Create a read-only directory to trigger write error
|
|
225
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
226
|
+
|
|
227
|
+
// Make directory read-only (if not Windows)
|
|
228
|
+
if (process.platform !== 'win32') {
|
|
229
|
+
fs.chmodSync(testDir, 0o444);
|
|
230
|
+
|
|
231
|
+
const content = '# Test\n\nContent';
|
|
232
|
+
const result = saveADR(content, 'Test', testDir);
|
|
233
|
+
|
|
234
|
+
expect(result.success).toBe(false);
|
|
235
|
+
expect(result.error).toBeDefined();
|
|
236
|
+
expect(result.filePath).toBeUndefined();
|
|
237
|
+
|
|
238
|
+
// Restore permissions for cleanup
|
|
239
|
+
fs.chmodSync(testDir, 0o755);
|
|
240
|
+
} else {
|
|
241
|
+
// On Windows, just verify error handling structure exists
|
|
242
|
+
// Skip actual permission test as Windows permissions work differently
|
|
243
|
+
const content = '# Test\n\nContent';
|
|
244
|
+
const result = saveADR(content, 'Test', testDir);
|
|
245
|
+
|
|
246
|
+
// Should succeed on Windows since we can't easily simulate permission errors
|
|
247
|
+
expect(result).toHaveProperty('success');
|
|
248
|
+
expect(typeof result.success).toBe('boolean');
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('handles long titles correctly', () => {
|
|
253
|
+
const longTitle = 'This is a very long title that should still work correctly when converted to a filename slug';
|
|
254
|
+
const content = `# ${longTitle}\n\nContent`;
|
|
255
|
+
const result = saveADR(content, longTitle, testDir);
|
|
256
|
+
|
|
257
|
+
expect(result.success).toBe(true);
|
|
258
|
+
expect(result.filePath).toContain('0001-this-is-a-very-long-title');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('handles titles with unicode characters', () => {
|
|
262
|
+
const content = '# Decision with émojis 🚀\n\nContent';
|
|
263
|
+
const result = saveADR(content, 'Decision with émojis 🚀', testDir);
|
|
264
|
+
|
|
265
|
+
expect(result.success).toBe(true);
|
|
266
|
+
// Should strip unicode to safe characters
|
|
267
|
+
expect(result.filePath).toMatch(/0001-.+\.md$/);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('DEFAULT_ADR_DIR constant', () => {
|
|
272
|
+
it('is defined and has expected value', () => {
|
|
273
|
+
expect(DEFAULT_ADR_DIR).toBeDefined();
|
|
274
|
+
expect(DEFAULT_ADR_DIR).toBe('docs/adr');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
package/src/adr.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR File Management Module
|
|
3
|
+
*
|
|
4
|
+
* Handles creation and management of Architectural Decision Record files.
|
|
5
|
+
* Follows MADR (Markdown Architectural Decision Records) format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { loggerInstance as logger } from './logger';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default ADR directory relative to project root
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_ADR_DIR = 'docs/adr';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert a title to a filename-safe slug
|
|
19
|
+
* Example: "Use PostgreSQL for Storage" -> "use-postgresql-for-storage"
|
|
20
|
+
*/
|
|
21
|
+
export function titleToSlug(title: string): string {
|
|
22
|
+
return title
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
|
|
25
|
+
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the next ADR number by scanning existing ADR files
|
|
30
|
+
*
|
|
31
|
+
* @param adrDir - Directory containing ADR files
|
|
32
|
+
* @returns Next available ADR number (e.g., 1, 2, 3...)
|
|
33
|
+
*/
|
|
34
|
+
export function getNextADRNumber(adrDir: string): number {
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.existsSync(adrDir)) {
|
|
37
|
+
return 1; // First ADR
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const files = fs.readdirSync(adrDir);
|
|
41
|
+
const adrNumbers: number[] = [];
|
|
42
|
+
|
|
43
|
+
// Extract numbers from existing ADR files (format: 0001-title.md)
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const match = file.match(/^(\d{4})-/);
|
|
46
|
+
if (match) {
|
|
47
|
+
adrNumbers.push(parseInt(match[1], 10));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (adrNumbers.length === 0) {
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Return next number after the highest existing number
|
|
56
|
+
return Math.max(...adrNumbers) + 1;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.warn('Failed to scan existing ADRs, defaulting to 1', { error, adrDir });
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ensure ADR directory exists, create if it doesn't
|
|
65
|
+
*
|
|
66
|
+
* @param adrDir - Directory path to ensure exists
|
|
67
|
+
*/
|
|
68
|
+
export function ensureADRDirectory(adrDir: string): void {
|
|
69
|
+
if (!fs.existsSync(adrDir)) {
|
|
70
|
+
logger.info('Creating ADR directory', { adrDir });
|
|
71
|
+
fs.mkdirSync(adrDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate ADR filename from number and title
|
|
77
|
+
*
|
|
78
|
+
* @param number - ADR number (will be zero-padded to 4 digits)
|
|
79
|
+
* @param title - ADR title (will be converted to slug)
|
|
80
|
+
* @returns Filename like "0001-use-postgresql-for-storage.md"
|
|
81
|
+
*/
|
|
82
|
+
export function generateADRFilename(number: number, title: string): string {
|
|
83
|
+
const paddedNumber = String(number).padStart(4, '0');
|
|
84
|
+
const slug = titleToSlug(title);
|
|
85
|
+
return `${paddedNumber}-${slug}.md`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Save ADR content to file
|
|
90
|
+
*
|
|
91
|
+
* @param content - Full markdown content of the ADR
|
|
92
|
+
* @param title - Title extracted from ADR content
|
|
93
|
+
* @param adrDir - Directory to save ADR in (defaults to docs/adr)
|
|
94
|
+
* @returns Object with success status, file path, and any error
|
|
95
|
+
*/
|
|
96
|
+
export function saveADR(
|
|
97
|
+
content: string,
|
|
98
|
+
title: string,
|
|
99
|
+
adrDir: string = DEFAULT_ADR_DIR
|
|
100
|
+
): { success: boolean; filePath?: string; error?: string } {
|
|
101
|
+
try {
|
|
102
|
+
// Ensure directory exists
|
|
103
|
+
ensureADRDirectory(adrDir);
|
|
104
|
+
|
|
105
|
+
// Get next ADR number
|
|
106
|
+
const adrNumber = getNextADRNumber(adrDir);
|
|
107
|
+
|
|
108
|
+
// Generate filename
|
|
109
|
+
const filename = generateADRFilename(adrNumber, title);
|
|
110
|
+
const filePath = path.join(adrDir, filename);
|
|
111
|
+
|
|
112
|
+
// Check if file already exists (shouldn't happen, but safety check)
|
|
113
|
+
if (fs.existsSync(filePath)) {
|
|
114
|
+
logger.warn('ADR file already exists, using alternative name', { filePath });
|
|
115
|
+
const alternativeFilename = generateADRFilename(adrNumber + 1, title);
|
|
116
|
+
const alternativeFilePath = path.join(adrDir, alternativeFilename);
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(alternativeFilePath, content, 'utf-8');
|
|
119
|
+
logger.info('ADR saved successfully', { filePath: alternativeFilePath });
|
|
120
|
+
|
|
121
|
+
return { success: true, filePath: alternativeFilePath };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Write ADR content to file
|
|
125
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
126
|
+
|
|
127
|
+
logger.info('ADR saved successfully', { filePath, adrNumber });
|
|
128
|
+
|
|
129
|
+
return { success: true, filePath };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
132
|
+
logger.error('Failed to save ADR', { error, adrDir });
|
|
133
|
+
return { success: false, error: errorMessage };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|