@mainahq/core 0.2.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/README.md +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture Decision Record (ADR) management.
|
|
3
|
+
*
|
|
4
|
+
* Handles auto-numbering, scaffolding MADR templates, and listing ADRs.
|
|
5
|
+
* ADRs capture WHAT and WHY — no implementation details.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tryAIGenerate } from "../ai/try-generate";
|
|
11
|
+
import type { Result } from "../db/index";
|
|
12
|
+
import { toKebabCase } from "../utils";
|
|
13
|
+
|
|
14
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface AdrSummary {
|
|
17
|
+
number: string;
|
|
18
|
+
title: string;
|
|
19
|
+
status: string;
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract numeric prefix from an ADR filename.
|
|
25
|
+
* Returns the number if the name matches NNNN-*.md pattern, or null.
|
|
26
|
+
*/
|
|
27
|
+
function extractNumber(name: string): number | null {
|
|
28
|
+
const match = name.match(/^(\d{4})-.*\.md$/);
|
|
29
|
+
if (!match?.[1]) return null;
|
|
30
|
+
return Number.parseInt(match[1], 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── MADR Template ────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function buildMadrTemplate(number: string, title: string): string {
|
|
36
|
+
const today = new Date().toISOString().split("T")[0];
|
|
37
|
+
return `# ${number}. ${title}
|
|
38
|
+
|
|
39
|
+
Date: ${today}
|
|
40
|
+
|
|
41
|
+
## Status
|
|
42
|
+
|
|
43
|
+
Proposed
|
|
44
|
+
|
|
45
|
+
## Context
|
|
46
|
+
|
|
47
|
+
What is the issue that we're seeing that is motivating this decision or change?
|
|
48
|
+
|
|
49
|
+
[NEEDS CLARIFICATION] Describe the context.
|
|
50
|
+
|
|
51
|
+
## Decision
|
|
52
|
+
|
|
53
|
+
What is the change that we're proposing and/or doing?
|
|
54
|
+
|
|
55
|
+
[NEEDS CLARIFICATION] Describe the decision.
|
|
56
|
+
|
|
57
|
+
## Consequences
|
|
58
|
+
|
|
59
|
+
What becomes easier or more difficult to do because of this change?
|
|
60
|
+
|
|
61
|
+
### Positive
|
|
62
|
+
|
|
63
|
+
- [NEEDS CLARIFICATION]
|
|
64
|
+
|
|
65
|
+
### Negative
|
|
66
|
+
|
|
67
|
+
- [NEEDS CLARIFICATION]
|
|
68
|
+
|
|
69
|
+
### Neutral
|
|
70
|
+
|
|
71
|
+
- [NEEDS CLARIFICATION]
|
|
72
|
+
|
|
73
|
+
## High-Level Design
|
|
74
|
+
|
|
75
|
+
### System Overview
|
|
76
|
+
|
|
77
|
+
[NEEDS CLARIFICATION]
|
|
78
|
+
|
|
79
|
+
### Component Boundaries
|
|
80
|
+
|
|
81
|
+
[NEEDS CLARIFICATION]
|
|
82
|
+
|
|
83
|
+
### Data Flow
|
|
84
|
+
|
|
85
|
+
[NEEDS CLARIFICATION]
|
|
86
|
+
|
|
87
|
+
### External Dependencies
|
|
88
|
+
|
|
89
|
+
[NEEDS CLARIFICATION]
|
|
90
|
+
|
|
91
|
+
## Low-Level Design
|
|
92
|
+
|
|
93
|
+
### Interfaces & Types
|
|
94
|
+
|
|
95
|
+
[NEEDS CLARIFICATION]
|
|
96
|
+
|
|
97
|
+
### Function Signatures
|
|
98
|
+
|
|
99
|
+
[NEEDS CLARIFICATION]
|
|
100
|
+
|
|
101
|
+
### DB Schema Changes
|
|
102
|
+
|
|
103
|
+
[NEEDS CLARIFICATION]
|
|
104
|
+
|
|
105
|
+
### Sequence of Operations
|
|
106
|
+
|
|
107
|
+
[NEEDS CLARIFICATION]
|
|
108
|
+
|
|
109
|
+
### Error Handling
|
|
110
|
+
|
|
111
|
+
[NEEDS CLARIFICATION]
|
|
112
|
+
|
|
113
|
+
### Edge Cases
|
|
114
|
+
|
|
115
|
+
[NEEDS CLARIFICATION]
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Scan `adr/` directory for existing ADRs (files named NNNN-*.md),
|
|
123
|
+
* and return the next number zero-padded to 4 digits.
|
|
124
|
+
*
|
|
125
|
+
* Empty dir -> "0001". Existing 0001, 0002 -> "0003".
|
|
126
|
+
* Creates adr/ if it doesn't exist.
|
|
127
|
+
*/
|
|
128
|
+
export async function getNextAdrNumber(
|
|
129
|
+
adrDir: string,
|
|
130
|
+
): Promise<Result<string>> {
|
|
131
|
+
try {
|
|
132
|
+
if (!existsSync(adrDir)) {
|
|
133
|
+
mkdirSync(adrDir, { recursive: true });
|
|
134
|
+
return { ok: true, value: "0001" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const entries = readdirSync(adrDir);
|
|
138
|
+
let maxNumber = 0;
|
|
139
|
+
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const num = extractNumber(entry);
|
|
142
|
+
if (num !== null && num > maxNumber) {
|
|
143
|
+
maxNumber = num;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const next = (maxNumber + 1).toString().padStart(4, "0");
|
|
148
|
+
return { ok: true, value: next };
|
|
149
|
+
} catch (e) {
|
|
150
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: `Failed to get next ADR number: ${message}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create `adr/NNNN-kebab-title.md` using the MADR template.
|
|
160
|
+
* Returns the file path on success.
|
|
161
|
+
*/
|
|
162
|
+
export async function scaffoldAdr(
|
|
163
|
+
adrDir: string,
|
|
164
|
+
number: string,
|
|
165
|
+
title: string,
|
|
166
|
+
): Promise<Result<string>> {
|
|
167
|
+
try {
|
|
168
|
+
const kebabTitle = toKebabCase(title);
|
|
169
|
+
const filename = `${number}-${kebabTitle}.md`;
|
|
170
|
+
const filePath = join(adrDir, filename);
|
|
171
|
+
|
|
172
|
+
if (!existsSync(adrDir)) {
|
|
173
|
+
mkdirSync(adrDir, { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const content = buildMadrTemplate(number, title);
|
|
177
|
+
await Bun.write(filePath, content);
|
|
178
|
+
|
|
179
|
+
return { ok: true, value: filePath };
|
|
180
|
+
} catch (e) {
|
|
181
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: `Failed to scaffold ADR: ${message}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* List existing ADRs with number, title, status.
|
|
191
|
+
* Reads each NNNN-*.md file to extract title (line 1) and status (after ## Status).
|
|
192
|
+
* Returns sorted by number.
|
|
193
|
+
*/
|
|
194
|
+
export async function listAdrs(adrDir: string): Promise<Result<AdrSummary[]>> {
|
|
195
|
+
try {
|
|
196
|
+
if (!existsSync(adrDir)) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: `ADR directory does not exist: ${adrDir}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const entries = readdirSync(adrDir);
|
|
204
|
+
const summaries: AdrSummary[] = [];
|
|
205
|
+
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const num = extractNumber(entry);
|
|
208
|
+
if (num === null) continue;
|
|
209
|
+
|
|
210
|
+
const filePath = join(adrDir, entry);
|
|
211
|
+
const content = readFileSync(filePath, "utf-8");
|
|
212
|
+
const lines = content.split("\n");
|
|
213
|
+
|
|
214
|
+
// Extract title from first line: "# NNNN. Title"
|
|
215
|
+
let title = "";
|
|
216
|
+
const titleMatch = lines[0]?.match(/^#\s+\d{4}\.\s+(.+)/);
|
|
217
|
+
if (titleMatch?.[1]) {
|
|
218
|
+
title = titleMatch[1];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Extract status: find "## Status" header, then take next non-empty line
|
|
222
|
+
let status = "Unknown";
|
|
223
|
+
const statusIdx = lines.findIndex((l) =>
|
|
224
|
+
l.trim().toLowerCase().startsWith("## status"),
|
|
225
|
+
);
|
|
226
|
+
if (statusIdx !== -1) {
|
|
227
|
+
for (let i = statusIdx + 1; i < lines.length; i++) {
|
|
228
|
+
const line = lines[i]?.trim();
|
|
229
|
+
if (line && line.length > 0) {
|
|
230
|
+
status = line;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
summaries.push({
|
|
237
|
+
number: num.toString().padStart(4, "0"),
|
|
238
|
+
title,
|
|
239
|
+
status,
|
|
240
|
+
path: filePath,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
summaries.sort((a, b) => a.number.localeCompare(b.number));
|
|
245
|
+
|
|
246
|
+
return { ok: true, value: summaries };
|
|
247
|
+
} catch (e) {
|
|
248
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
error: `Failed to list ADRs: ${message}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Generate HLD/LLD sections from a spec using AI (standard tier).
|
|
258
|
+
* Returns the generated markdown content, or null if AI is unavailable.
|
|
259
|
+
*/
|
|
260
|
+
export async function generateHldLld(
|
|
261
|
+
specContent: string,
|
|
262
|
+
mainaDir: string,
|
|
263
|
+
): Promise<Result<string | null>> {
|
|
264
|
+
try {
|
|
265
|
+
const variables: Record<string, string> = {
|
|
266
|
+
spec: specContent,
|
|
267
|
+
conventions: "",
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const aiResult = await tryAIGenerate(
|
|
271
|
+
"design-hld-lld",
|
|
272
|
+
mainaDir,
|
|
273
|
+
variables,
|
|
274
|
+
`Generate HLD and LLD sections for this spec:\n\n${specContent}`,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (!aiResult.text) {
|
|
278
|
+
if (aiResult.delegation) {
|
|
279
|
+
// Return delegation prompt for host to process
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
value: `<!-- AI delegation: process this prompt to generate HLD/LLD -->\n\n${aiResult.delegation.userPrompt}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
error:
|
|
288
|
+
"AI generation unavailable — set MAINA_API_KEY or OPENROUTER_API_KEY to enable HLD/LLD generation",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { ok: true, value: aiResult.text };
|
|
293
|
+
} catch (e) {
|
|
294
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
295
|
+
return { ok: false, error: `HLD/LLD generation failed: ${message}` };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR Design Review.
|
|
3
|
+
*
|
|
4
|
+
* Reviews an Architecture Decision Record against existing ADRs and
|
|
5
|
+
* the project constitution. Performs deterministic checks for MADR
|
|
6
|
+
* section completeness and [NEEDS CLARIFICATION] markers.
|
|
7
|
+
*
|
|
8
|
+
* Single LLM call per command — deterministic checks run without AI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { basename, join } from "node:path";
|
|
13
|
+
import type { Result } from "../db/index";
|
|
14
|
+
import { loadConstitution } from "../prompts/loader";
|
|
15
|
+
|
|
16
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface ReviewContext {
|
|
19
|
+
targetAdr: { path: string; content: string; title: string };
|
|
20
|
+
existingAdrs: Array<{ path: string; content: string; title: string }>;
|
|
21
|
+
constitution: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReviewOptions {
|
|
25
|
+
aiAvailable?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ReviewFinding {
|
|
29
|
+
severity: "error" | "warning" | "info";
|
|
30
|
+
message: string;
|
|
31
|
+
section?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReviewResult {
|
|
35
|
+
adrPath: string;
|
|
36
|
+
findings: ReviewFinding[];
|
|
37
|
+
passed: boolean; // true if no errors
|
|
38
|
+
sectionsPresent: string[];
|
|
39
|
+
sectionsMissing: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const REQUIRED_SECTIONS = ["Status", "Context", "Decision", "Consequences"];
|
|
45
|
+
|
|
46
|
+
const HLD_SECTIONS = [
|
|
47
|
+
"System Overview",
|
|
48
|
+
"Component Boundaries",
|
|
49
|
+
"Data Flow",
|
|
50
|
+
"External Dependencies",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const LLD_SECTIONS = [
|
|
54
|
+
"Interfaces & Types",
|
|
55
|
+
"Function Signatures",
|
|
56
|
+
"DB Schema Changes",
|
|
57
|
+
"Sequence of Operations",
|
|
58
|
+
"Error Handling",
|
|
59
|
+
"Edge Cases",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract the title from an ADR's first line.
|
|
66
|
+
* Expects format: `# NNNN. Title`
|
|
67
|
+
*/
|
|
68
|
+
function extractTitle(content: string): string {
|
|
69
|
+
const firstLine = content.split("\n")[0] ?? "";
|
|
70
|
+
const match = firstLine.match(/^#\s+\d{4}\.\s+(.+)/);
|
|
71
|
+
return match?.[1]?.trim() ?? "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract numeric prefix from an ADR filename.
|
|
76
|
+
* Returns the number if the name matches NNNN-*.md pattern, or null.
|
|
77
|
+
*/
|
|
78
|
+
function extractNumber(name: string): number | null {
|
|
79
|
+
const match = name.match(/^(\d{4})-.*\.md$/);
|
|
80
|
+
if (!match?.[1]) return null;
|
|
81
|
+
return Number.parseInt(match[1], 10);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check which MADR sections are present in the content.
|
|
86
|
+
*/
|
|
87
|
+
function detectSections(content: string): {
|
|
88
|
+
present: string[];
|
|
89
|
+
missing: string[];
|
|
90
|
+
} {
|
|
91
|
+
const present: string[] = [];
|
|
92
|
+
const missing: string[] = [];
|
|
93
|
+
|
|
94
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
95
|
+
// Match ## Section as a heading (case-insensitive)
|
|
96
|
+
const pattern = new RegExp(`^##\\s+${section}\\s*$`, "im");
|
|
97
|
+
if (pattern.test(content)) {
|
|
98
|
+
present.push(section);
|
|
99
|
+
} else {
|
|
100
|
+
missing.push(section);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { present, missing };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Count occurrences of [NEEDS CLARIFICATION] in content.
|
|
109
|
+
*/
|
|
110
|
+
function countClarificationMarkers(content: string): number {
|
|
111
|
+
const matches = content.match(/\[NEEDS CLARIFICATION\]/g);
|
|
112
|
+
return matches?.length ?? 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the review context by reading the target ADR, all existing ADRs,
|
|
119
|
+
* and the constitution.
|
|
120
|
+
*/
|
|
121
|
+
export async function buildReviewContext(
|
|
122
|
+
adrPath: string,
|
|
123
|
+
adrDir: string,
|
|
124
|
+
mainaDir: string,
|
|
125
|
+
): Promise<Result<ReviewContext>> {
|
|
126
|
+
try {
|
|
127
|
+
// Read target ADR
|
|
128
|
+
if (!existsSync(adrPath)) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: `Target ADR not found: ${adrPath}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const targetContent = readFileSync(adrPath, "utf-8");
|
|
136
|
+
const targetTitle = extractTitle(targetContent);
|
|
137
|
+
|
|
138
|
+
// Read all other ADRs from adrDir
|
|
139
|
+
const existingAdrs: ReviewContext["existingAdrs"] = [];
|
|
140
|
+
|
|
141
|
+
if (existsSync(adrDir)) {
|
|
142
|
+
const entries = readdirSync(adrDir);
|
|
143
|
+
const targetBasename = basename(adrPath);
|
|
144
|
+
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
// Skip target ADR and non-ADR files
|
|
147
|
+
if (entry === targetBasename) continue;
|
|
148
|
+
if (extractNumber(entry) === null) continue;
|
|
149
|
+
|
|
150
|
+
const filePath = join(adrDir, entry);
|
|
151
|
+
const content = readFileSync(filePath, "utf-8");
|
|
152
|
+
const title = extractTitle(content);
|
|
153
|
+
|
|
154
|
+
existingAdrs.push({ path: filePath, content, title });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Read constitution
|
|
159
|
+
const constitutionContent = await loadConstitution(mainaDir);
|
|
160
|
+
const constitution =
|
|
161
|
+
constitutionContent.length > 0 ? constitutionContent : null;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
value: {
|
|
166
|
+
targetAdr: {
|
|
167
|
+
path: adrPath,
|
|
168
|
+
content: targetContent,
|
|
169
|
+
title: targetTitle,
|
|
170
|
+
},
|
|
171
|
+
existingAdrs,
|
|
172
|
+
constitution,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
} catch (e) {
|
|
176
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
error: `Failed to build review context: ${message}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Find an ADR file in the adr directory by its number (e.g., "0001").
|
|
186
|
+
*/
|
|
187
|
+
export async function findAdrByNumber(
|
|
188
|
+
adrDir: string,
|
|
189
|
+
number: string,
|
|
190
|
+
): Promise<Result<string>> {
|
|
191
|
+
try {
|
|
192
|
+
if (!existsSync(adrDir)) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: `ADR directory does not exist: ${adrDir}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const paddedNumber = number.padStart(4, "0");
|
|
200
|
+
const entries = readdirSync(adrDir);
|
|
201
|
+
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (entry.startsWith(`${paddedNumber}-`) && entry.endsWith(".md")) {
|
|
204
|
+
return { ok: true, value: join(adrDir, entry) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
error: `No ADR found with number ${paddedNumber}`,
|
|
211
|
+
};
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: `Failed to find ADR: ${message}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Review an ADR using deterministic checks.
|
|
223
|
+
*
|
|
224
|
+
* Checks:
|
|
225
|
+
* 1. Required MADR sections present (Status, Context, Decision, Consequences)
|
|
226
|
+
* 2. [NEEDS CLARIFICATION] markers flagged as incomplete
|
|
227
|
+
*/
|
|
228
|
+
export function reviewDesign(
|
|
229
|
+
context: ReviewContext,
|
|
230
|
+
_options?: ReviewOptions,
|
|
231
|
+
): Result<ReviewResult> {
|
|
232
|
+
try {
|
|
233
|
+
const findings: ReviewFinding[] = [];
|
|
234
|
+
const { content } = context.targetAdr;
|
|
235
|
+
|
|
236
|
+
// Check required sections
|
|
237
|
+
const { present, missing } = detectSections(content);
|
|
238
|
+
|
|
239
|
+
for (const section of missing) {
|
|
240
|
+
findings.push({
|
|
241
|
+
severity: "error",
|
|
242
|
+
message: `Missing required section: "## ${section}"`,
|
|
243
|
+
section,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for [NEEDS CLARIFICATION] markers
|
|
248
|
+
// >5 markers means the ADR is effectively empty — error, not warning
|
|
249
|
+
const clarificationCount = countClarificationMarkers(content);
|
|
250
|
+
if (clarificationCount > 5) {
|
|
251
|
+
findings.push({
|
|
252
|
+
severity: "error",
|
|
253
|
+
message: `Contains ${clarificationCount} [NEEDS CLARIFICATION] markers — ADR is effectively empty and should not be committed`,
|
|
254
|
+
});
|
|
255
|
+
} else if (clarificationCount > 0) {
|
|
256
|
+
findings.push({
|
|
257
|
+
severity: "warning",
|
|
258
|
+
message: `Contains ${clarificationCount} [NEEDS CLARIFICATION] marker${clarificationCount > 1 ? "s" : ""} — ADR is incomplete`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check HLD sections (warning, not error — these are optional for simple ADRs)
|
|
263
|
+
const hasHldHeader = /^##\s+High-Level Design/im.test(content);
|
|
264
|
+
if (!hasHldHeader) {
|
|
265
|
+
findings.push({
|
|
266
|
+
severity: "warning",
|
|
267
|
+
message:
|
|
268
|
+
"Missing High-Level Design section — consider adding for complex decisions",
|
|
269
|
+
section: "High-Level Design",
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
for (const sub of HLD_SECTIONS) {
|
|
273
|
+
const escaped = sub.replace(/[&]/g, "\\$&");
|
|
274
|
+
const pattern = new RegExp(`^###\\s+${escaped}\\s*$`, "im");
|
|
275
|
+
if (!pattern.test(content)) {
|
|
276
|
+
findings.push({
|
|
277
|
+
severity: "warning",
|
|
278
|
+
message: `High-Level Design missing subsection: "${sub}"`,
|
|
279
|
+
section: `High-Level Design / ${sub}`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Check LLD sections
|
|
286
|
+
const hasLldHeader = /^##\s+Low-Level Design/im.test(content);
|
|
287
|
+
if (!hasLldHeader) {
|
|
288
|
+
findings.push({
|
|
289
|
+
severity: "warning",
|
|
290
|
+
message:
|
|
291
|
+
"Missing Low-Level Design section — consider adding for complex decisions",
|
|
292
|
+
section: "Low-Level Design",
|
|
293
|
+
});
|
|
294
|
+
} else {
|
|
295
|
+
for (const sub of LLD_SECTIONS) {
|
|
296
|
+
const escaped = sub.replace(/[&]/g, "\\$&");
|
|
297
|
+
const pattern = new RegExp(`^###\\s+${escaped}\\s*$`, "im");
|
|
298
|
+
if (!pattern.test(content)) {
|
|
299
|
+
findings.push({
|
|
300
|
+
severity: "warning",
|
|
301
|
+
message: `Low-Level Design missing subsection: "${sub}"`,
|
|
302
|
+
section: `Low-Level Design / ${sub}`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const hasErrors = findings.some((f) => f.severity === "error");
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
value: {
|
|
313
|
+
adrPath: context.targetAdr.path,
|
|
314
|
+
findings,
|
|
315
|
+
passed: !hasErrors,
|
|
316
|
+
sectionsPresent: present,
|
|
317
|
+
sectionsMissing: missing,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
} catch (e) {
|
|
321
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
322
|
+
return {
|
|
323
|
+
ok: false,
|
|
324
|
+
error: `Review failed: ${message}`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
}
|