specvector 0.1.3 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specvector",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Context-aware AI code review using Model Context Protocol (MCP)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -24,6 +24,8 @@ export interface SpecVectorConfig {
24
24
  maxFileSize: number;
25
25
  /** Maximum iterations for agent */
26
26
  maxIterations: number;
27
+ /** Path to ADR directory: string = use path, null = disabled, undefined = auto-detect */
28
+ adrPath?: string | null;
27
29
  }
28
30
 
29
31
  /** Default configuration */
@@ -47,6 +49,7 @@ export const DEFAULT_CONFIG: SpecVectorConfig = {
47
49
  strictness: "normal",
48
50
  maxFileSize: 100 * 1024, // 100KB
49
51
  maxIterations: 15,
52
+ // adrPath omitted = auto-detect; set to null to explicitly disable
50
53
  };
51
54
 
52
55
  /** Config file name */
@@ -112,7 +115,7 @@ function parseYamlConfig(content: string): Partial<SpecVectorConfig> {
112
115
  const lines = content.split("\n");
113
116
  const warnings: string[] = [];
114
117
 
115
- const validKeys = ["provider", "model", "strictness", "maxFileSize", "maxIterations", "ignore"];
118
+ const validKeys = ["provider", "model", "strictness", "maxFileSize", "maxIterations", "ignore", "adrPath"];
116
119
 
117
120
  let currentKey: string | null = null;
118
121
  let currentArray: string[] = [];
@@ -199,6 +202,16 @@ function parseYamlConfig(content: string): Partial<SpecVectorConfig> {
199
202
  }
200
203
  break;
201
204
  }
205
+ case "adrPath": {
206
+ // Support explicit disable with null, false, none, or empty
207
+ const lower = cleanValue.toLowerCase();
208
+ if (lower === "null" || lower === "false" || lower === "none" || lower === "") {
209
+ result.adrPath = null; // Explicitly disabled
210
+ } else {
211
+ result.adrPath = cleanValue;
212
+ }
213
+ break;
214
+ }
202
215
  }
203
216
  }
204
217
  }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * ADR Context Provider - Reads Architecture Decision Records for reviews.
3
+ *
4
+ * Scans a configurable directory for markdown files and includes them
5
+ * as context in the review prompt.
6
+ */
7
+
8
+ import { readdir, readFile, stat } from "fs/promises";
9
+ import { join, basename, resolve, isAbsolute, relative } from "path";
10
+ import type { Result } from "../types/result";
11
+ import { ok, err } from "../types/result";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export interface ADRFile {
18
+ /** Filename (e.g., "001-use-bun-runtime.md") */
19
+ name: string;
20
+ /** Full file content */
21
+ content: string;
22
+ }
23
+
24
+ export interface ADRContext {
25
+ /** List of ADR files found */
26
+ files: ADRFile[];
27
+ /** Number of files loaded */
28
+ count: number;
29
+ /** Path that was scanned */
30
+ path: string;
31
+ }
32
+
33
+ export interface ADRContextError {
34
+ code: "PATH_NOT_FOUND" | "READ_ERROR" | "NO_FILES";
35
+ message: string;
36
+ }
37
+
38
+ // ============================================================================
39
+ // Constants
40
+ // ============================================================================
41
+
42
+ /** Maximum ADR file size (50KB) */
43
+ const MAX_ADR_FILE_SIZE = 50 * 1024;
44
+
45
+ /** Maximum number of ADR files to read */
46
+ const MAX_ADR_FILES = 20;
47
+
48
+ /** Supported file extensions */
49
+ const ADR_EXTENSIONS = [".md", ".markdown"];
50
+
51
+ /** Common paths to auto-detect (in priority order) */
52
+ const AUTO_DETECT_PATHS = [
53
+ "_bmad-output/planning-artifacts", // BMAD projects
54
+ "docs/adr", // Standard ADR location
55
+ "docs/architecture", // Common alternative
56
+ "adr", // Simple ADR folder
57
+ ];
58
+
59
+ // ============================================================================
60
+ // Main Function
61
+ // ============================================================================
62
+
63
+ /**
64
+ * Read ADR files from a directory.
65
+ *
66
+ * @param workingDir - The project root directory
67
+ * @param adrPath - Relative path to ADR directory (e.g., "docs/adr")
68
+ */
69
+ export async function getADRContext(
70
+ workingDir: string,
71
+ adrPath: string
72
+ ): Promise<Result<ADRContext, ADRContextError>> {
73
+ // Security: reject absolute paths
74
+ if (isAbsolute(adrPath)) {
75
+ return err({
76
+ code: "PATH_NOT_FOUND",
77
+ message: `ADR path must be relative, not absolute: ${adrPath}`,
78
+ });
79
+ }
80
+
81
+ // Security: reject path traversal attempts
82
+ if (adrPath.includes("..")) {
83
+ return err({
84
+ code: "PATH_NOT_FOUND",
85
+ message: `ADR path cannot contain '..': ${adrPath}`,
86
+ });
87
+ }
88
+
89
+ const fullPath = resolve(workingDir, adrPath);
90
+ const resolvedWorkingDir = resolve(workingDir);
91
+
92
+ // Security: use path.relative for portable containment check
93
+ const relativePath = relative(resolvedWorkingDir, fullPath);
94
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
95
+ return err({
96
+ code: "PATH_NOT_FOUND",
97
+ message: `ADR path must be within project directory: ${adrPath}`,
98
+ });
99
+ }
100
+
101
+ // Check if directory exists
102
+ try {
103
+ const stats = await stat(fullPath);
104
+ if (!stats.isDirectory()) {
105
+ return err({
106
+ code: "PATH_NOT_FOUND",
107
+ message: `ADR path is not a directory: ${adrPath}`,
108
+ });
109
+ }
110
+ } catch {
111
+ return err({
112
+ code: "PATH_NOT_FOUND",
113
+ message: `ADR directory not found: ${adrPath}`,
114
+ });
115
+ }
116
+
117
+ // Read directory contents
118
+ let entries: string[];
119
+ try {
120
+ entries = await readdir(fullPath);
121
+ } catch (error) {
122
+ return err({
123
+ code: "READ_ERROR",
124
+ message: `Failed to read ADR directory: ${error instanceof Error ? error.message : "Unknown error"}`,
125
+ });
126
+ }
127
+
128
+ // Filter to markdown files and sort
129
+ const mdFiles = entries
130
+ .filter((f) => ADR_EXTENSIONS.some((ext) => f.toLowerCase().endsWith(ext)))
131
+ .sort(); // Sort alphabetically (ADRs often numbered: 001-xxx.md)
132
+
133
+ if (mdFiles.length === 0) {
134
+ return err({
135
+ code: "NO_FILES",
136
+ message: `No ADR markdown files found in: ${adrPath}`,
137
+ });
138
+ }
139
+
140
+ // Read files until we have MAX_ADR_FILES (don't slice upfront)
141
+ const files: ADRFile[] = [];
142
+ for (const filename of mdFiles) {
143
+ if (files.length >= MAX_ADR_FILES) break;
144
+
145
+ const filePath = join(fullPath, filename);
146
+ try {
147
+ const fileStats = await stat(filePath);
148
+
149
+ // Skip files that are too large
150
+ if (fileStats.size > MAX_ADR_FILE_SIZE) {
151
+ console.log(`⚠️ Skipping large ADR: ${filename} (${Math.round(fileStats.size / 1024)}KB)`);
152
+ continue;
153
+ }
154
+
155
+ const content = await readFile(filePath, "utf-8");
156
+ files.push({
157
+ name: basename(filename),
158
+ content: content.trim(),
159
+ });
160
+ } catch {
161
+ // Skip files we can't read
162
+ console.log(`⚠️ Could not read ADR: ${filename}`);
163
+ }
164
+ }
165
+
166
+ if (files.length === 0) {
167
+ return err({
168
+ code: "NO_FILES",
169
+ message: `No readable ADR files in: ${adrPath}`,
170
+ });
171
+ }
172
+
173
+ return ok({
174
+ files,
175
+ count: files.length,
176
+ path: adrPath,
177
+ });
178
+ }
179
+
180
+ // ============================================================================
181
+ // Formatting
182
+ // ============================================================================
183
+
184
+ /**
185
+ * Format ADR context for inclusion in review prompt.
186
+ */
187
+ export function formatADRContext(context: ADRContext): string {
188
+ const lines: string[] = [
189
+ `## Architecture Decision Records (${context.count} files from ${context.path})`,
190
+ "",
191
+ ];
192
+
193
+ for (const file of context.files) {
194
+ lines.push(`### ${file.name}`);
195
+ lines.push("");
196
+ lines.push(file.content);
197
+ lines.push("");
198
+ lines.push("---");
199
+ lines.push("");
200
+ }
201
+
202
+ return lines.join("\n");
203
+ }
204
+
205
+ /**
206
+ * Get ADR context for a review, with graceful error handling.
207
+ * Returns null if ADRs are not available (no error thrown).
208
+ *
209
+ * Behavior:
210
+ * - adrPath = string: use the specified path
211
+ * - adrPath = null: explicitly disabled, no ADR context
212
+ * - adrPath = undefined: auto-detect common paths
213
+ */
214
+ export async function getADRContextForReview(
215
+ workingDir: string,
216
+ adrPath: string | null | undefined
217
+ ): Promise<{ context: ADRContext; formatted: string } | null> {
218
+ // Explicitly disabled with null
219
+ if (adrPath === null) {
220
+ return null;
221
+ }
222
+
223
+ // If path is explicitly set, use it
224
+ if (adrPath !== undefined) {
225
+ const result = await getADRContext(workingDir, adrPath);
226
+ if (!result.ok) {
227
+ console.log(`⚠️ ADR context unavailable: ${result.error.message}`);
228
+ return null;
229
+ }
230
+ return {
231
+ context: result.value,
232
+ formatted: formatADRContext(result.value),
233
+ };
234
+ }
235
+
236
+ // Auto-detect: try common paths in priority order (adrPath is undefined)
237
+ for (const path of AUTO_DETECT_PATHS) {
238
+ const result = await getADRContext(workingDir, path);
239
+ if (result.ok) {
240
+ return {
241
+ context: result.value,
242
+ formatted: formatADRContext(result.value),
243
+ };
244
+ }
245
+ }
246
+
247
+ // No docs found - this is fine, not all projects have ADRs
248
+ return null;
249
+ }
@@ -9,3 +9,12 @@ export {
9
9
  type LinearTicket,
10
10
  type LinearContextError,
11
11
  } from "./linear";
12
+
13
+ export {
14
+ getADRContext,
15
+ getADRContextForReview,
16
+ formatADRContext,
17
+ type ADRContext,
18
+ type ADRFile,
19
+ type ADRContextError,
20
+ } from "./adr";
@@ -20,7 +20,7 @@ import type { ReviewResult, ReviewFinding, Severity } from "../types/review";
20
20
  import type { Result } from "../types/result";
21
21
  import { ok, err } from "../types/result";
22
22
  import { loadConfig, getStrictnessModifier } from "../config";
23
- import { getLinearContextForReview } from "../context";
23
+ import { getLinearContextForReview, getADRContextForReview } from "../context";
24
24
 
25
25
  /** Review engine configuration */
26
26
  export interface ReviewConfig {
@@ -109,12 +109,18 @@ export async function runReview(
109
109
 
110
110
  if (linearResult.context) {
111
111
  systemPrompt = linearResult.context + "\n\n" + systemPrompt;
112
- // linearResult.ticketId available for future use (e.g., in review output)
113
112
  console.log(`📎 Loaded Linear context for ticket: ${linearResult.ticketId}`);
114
113
  } else if (linearResult.warning) {
115
114
  console.warn(`⚠️ ${linearResult.warning}`);
116
115
  }
117
116
 
117
+ // Fetch ADR context if configured
118
+ const adrResult = await getADRContextForReview(config.workingDir, fileConfig.adrPath);
119
+ if (adrResult) {
120
+ systemPrompt = adrResult.formatted + "\n\n" + systemPrompt;
121
+ console.log(`📚 Loaded ${adrResult.context.count} ADR files from ${adrResult.context.path}`);
122
+ }
123
+
118
124
  // Create agent
119
125
  const agent = createAgentLoop(providerResult.value, tools, {
120
126
  maxIterations: config.maxIterations || fileConfig.maxIterations || 15,