speclore 0.1.1
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/LICENSE +21 -0
- package/README.en.md +240 -0
- package/README.md +240 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +5750 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/templates/report.html +201 -0
- package/dist/index.d.ts +505 -0
- package/dist/index.js +5744 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +4437 -0
- package/dist/mcp/server.js.map +1 -0
- package/package.json +107 -0
- package/scripts/cleanup-global.cjs +46 -0
- package/src/cli/templates/report.html +201 -0
|
@@ -0,0 +1,4437 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __esm = (fn, res) => function __init() {
|
|
6
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
7
|
+
};
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/infra/logger.ts
|
|
14
|
+
var logger_exports = {};
|
|
15
|
+
__export(logger_exports, {
|
|
16
|
+
getLogLevel: () => getLogLevel,
|
|
17
|
+
initLogger: () => initLogger,
|
|
18
|
+
logger: () => logger,
|
|
19
|
+
setLogLevel: () => setLogLevel
|
|
20
|
+
});
|
|
21
|
+
import pino from "pino";
|
|
22
|
+
function isJsonMode() {
|
|
23
|
+
const format = process.env["SPECLORE_LOG_FORMAT"];
|
|
24
|
+
if (format === "json") return true;
|
|
25
|
+
if (format === "pretty") return false;
|
|
26
|
+
return process.env["NODE_ENV"] === "production";
|
|
27
|
+
}
|
|
28
|
+
function createPinoInstance(level) {
|
|
29
|
+
const pinoLevel = toPinoLevel(level);
|
|
30
|
+
if (isJsonMode()) {
|
|
31
|
+
return pino({
|
|
32
|
+
level: pinoLevel,
|
|
33
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
34
|
+
formatters: {
|
|
35
|
+
level(label) {
|
|
36
|
+
return { level: label };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return pino({
|
|
42
|
+
level: pinoLevel,
|
|
43
|
+
transport: {
|
|
44
|
+
target: "pino-pretty",
|
|
45
|
+
options: {
|
|
46
|
+
colorize: true,
|
|
47
|
+
translateTime: "HH:MM:ss.l",
|
|
48
|
+
ignore: "pid,hostname",
|
|
49
|
+
singleLine: true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function toPinoLevel(level) {
|
|
55
|
+
return level;
|
|
56
|
+
}
|
|
57
|
+
function initLogger(options) {
|
|
58
|
+
if (options?.verbose) {
|
|
59
|
+
setLogLevel("debug");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const envLevel = process.env["SPECLORE_LOG_LEVEL"];
|
|
63
|
+
if (envLevel && ["debug", "info", "warn", "error"].includes(envLevel)) {
|
|
64
|
+
setLogLevel(envLevel);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function setLogLevel(level) {
|
|
68
|
+
currentLevel = level;
|
|
69
|
+
pinoInstance = createPinoInstance(level);
|
|
70
|
+
}
|
|
71
|
+
function getLogLevel() {
|
|
72
|
+
return currentLevel;
|
|
73
|
+
}
|
|
74
|
+
var currentLevel, pinoInstance, logger;
|
|
75
|
+
var init_logger = __esm({
|
|
76
|
+
"src/infra/logger.ts"() {
|
|
77
|
+
"use strict";
|
|
78
|
+
currentLevel = "info";
|
|
79
|
+
pinoInstance = createPinoInstance(currentLevel);
|
|
80
|
+
logger = {
|
|
81
|
+
debug(message, ...args) {
|
|
82
|
+
pinoInstance.debug({ args: args.length > 0 ? args : void 0 }, message);
|
|
83
|
+
},
|
|
84
|
+
info(message, ...args) {
|
|
85
|
+
pinoInstance.info({ args: args.length > 0 ? args : void 0 }, message);
|
|
86
|
+
},
|
|
87
|
+
warn(message, ...args) {
|
|
88
|
+
pinoInstance.warn({ args: args.length > 0 ? args : void 0 }, message);
|
|
89
|
+
},
|
|
90
|
+
error(message, ...args) {
|
|
91
|
+
pinoInstance.error({ args: args.length > 0 ? args : void 0 }, message);
|
|
92
|
+
},
|
|
93
|
+
/** Shortcut to set log level */
|
|
94
|
+
setLevel(level) {
|
|
95
|
+
setLogLevel(level);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// src/ai/cost-tracker.ts
|
|
102
|
+
function getCostTracker(maxBudgetUsd) {
|
|
103
|
+
if (!_tracker) {
|
|
104
|
+
_tracker = new CostTracker(maxBudgetUsd);
|
|
105
|
+
}
|
|
106
|
+
return _tracker;
|
|
107
|
+
}
|
|
108
|
+
var MODEL_PRICING, CostTracker, _tracker;
|
|
109
|
+
var init_cost_tracker = __esm({
|
|
110
|
+
"src/ai/cost-tracker.ts"() {
|
|
111
|
+
"use strict";
|
|
112
|
+
init_logger();
|
|
113
|
+
MODEL_PRICING = {
|
|
114
|
+
// OpenAI models
|
|
115
|
+
"gpt-4": { inputPer1K: 0.03, outputPer1K: 0.06 },
|
|
116
|
+
"gpt-4-turbo": { inputPer1K: 0.01, outputPer1K: 0.03 },
|
|
117
|
+
"gpt-4o": { inputPer1K: 5e-3, outputPer1K: 0.015 },
|
|
118
|
+
"gpt-4o-mini": { inputPer1K: 15e-5, outputPer1K: 6e-4 },
|
|
119
|
+
"gpt-3.5-turbo": { inputPer1K: 5e-4, outputPer1K: 15e-4 },
|
|
120
|
+
// Anthropic models
|
|
121
|
+
"claude-3-5-sonnet-20241022": { inputPer1K: 3e-3, outputPer1K: 0.015 },
|
|
122
|
+
"claude-3-5-haiku-20241022": { inputPer1K: 1e-3, outputPer1K: 5e-3 },
|
|
123
|
+
"claude-3-opus-20240229": { inputPer1K: 0.015, outputPer1K: 0.075 }
|
|
124
|
+
};
|
|
125
|
+
CostTracker = class {
|
|
126
|
+
records = [];
|
|
127
|
+
maxBudgetUsd;
|
|
128
|
+
constructor(maxBudgetUsd) {
|
|
129
|
+
this.maxBudgetUsd = maxBudgetUsd ?? Infinity;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Record a single API call's token usage.
|
|
133
|
+
* Returns the estimated cost in USD.
|
|
134
|
+
*/
|
|
135
|
+
recordUsage(model, promptTokens, completionTokens) {
|
|
136
|
+
const cost = this.estimateCost(model, promptTokens, completionTokens);
|
|
137
|
+
const currentTotal = this.records.reduce((sum, r) => sum + r.estimatedCostUsd, 0);
|
|
138
|
+
if (currentTotal + cost > this.maxBudgetUsd) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Budget exceeded: $${(currentTotal + cost).toFixed(4)} would exceed limit of $${this.maxBudgetUsd.toFixed(2)}. Current spend: $${currentTotal.toFixed(4)}.`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
this.records.push({
|
|
144
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
145
|
+
model,
|
|
146
|
+
promptTokens,
|
|
147
|
+
completionTokens,
|
|
148
|
+
totalTokens: promptTokens + completionTokens,
|
|
149
|
+
estimatedCostUsd: cost
|
|
150
|
+
});
|
|
151
|
+
logger.debug(`AI usage: ${model} \u2014 ${promptTokens}+${completionTokens} tokens, ~$${cost.toFixed(4)}`);
|
|
152
|
+
return cost;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Estimate cost for a given model and token counts.
|
|
156
|
+
*/
|
|
157
|
+
estimateCost(model, promptTokens, completionTokens) {
|
|
158
|
+
const pricing = MODEL_PRICING[model];
|
|
159
|
+
if (!pricing) {
|
|
160
|
+
logger.debug(`No pricing data for model: ${model}`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
const inputCost = promptTokens / 1e3 * pricing.inputPer1K;
|
|
164
|
+
const outputCost = completionTokens / 1e3 * pricing.outputPer1K;
|
|
165
|
+
return inputCost + outputCost;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Get aggregated usage summary.
|
|
169
|
+
*/
|
|
170
|
+
getUsageSummary() {
|
|
171
|
+
const summary = {
|
|
172
|
+
totalCalls: this.records.length,
|
|
173
|
+
totalPromptTokens: 0,
|
|
174
|
+
totalCompletionTokens: 0,
|
|
175
|
+
totalTokens: 0,
|
|
176
|
+
totalCostUsd: 0,
|
|
177
|
+
byModel: {}
|
|
178
|
+
};
|
|
179
|
+
for (const record of this.records) {
|
|
180
|
+
summary.totalPromptTokens += record.promptTokens;
|
|
181
|
+
summary.totalCompletionTokens += record.completionTokens;
|
|
182
|
+
summary.totalTokens += record.totalTokens;
|
|
183
|
+
summary.totalCostUsd += record.estimatedCostUsd;
|
|
184
|
+
if (!summary.byModel[record.model]) {
|
|
185
|
+
summary.byModel[record.model] = { calls: 0, tokens: 0, costUsd: 0 };
|
|
186
|
+
}
|
|
187
|
+
const modelStats = summary.byModel[record.model];
|
|
188
|
+
modelStats.calls++;
|
|
189
|
+
modelStats.tokens += record.totalTokens;
|
|
190
|
+
modelStats.costUsd += record.estimatedCostUsd;
|
|
191
|
+
}
|
|
192
|
+
return summary;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Get all usage records.
|
|
196
|
+
*/
|
|
197
|
+
getRecords() {
|
|
198
|
+
return [...this.records];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Reset all tracking data.
|
|
202
|
+
*/
|
|
203
|
+
reset() {
|
|
204
|
+
this.records = [];
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Get the configured budget limit.
|
|
208
|
+
*/
|
|
209
|
+
getBudgetLimit() {
|
|
210
|
+
return this.maxBudgetUsd;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
_tracker = null;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// src/ai/base-adapter.ts
|
|
218
|
+
var BaseAdapter;
|
|
219
|
+
var init_base_adapter = __esm({
|
|
220
|
+
"src/ai/base-adapter.ts"() {
|
|
221
|
+
"use strict";
|
|
222
|
+
init_cost_tracker();
|
|
223
|
+
init_logger();
|
|
224
|
+
BaseAdapter = class _BaseAdapter {
|
|
225
|
+
/** Maximum retry attempts (override in subclass) */
|
|
226
|
+
maxRetries = 3;
|
|
227
|
+
/** Base delay in ms for exponential backoff (override in subclass) */
|
|
228
|
+
retryBaseDelayMs = 1e3;
|
|
229
|
+
/**
|
|
230
|
+
* Execute an async operation with retry + exponential backoff.
|
|
231
|
+
* Only retries on retryable errors (429, 5xx); client errors (4xx) are thrown immediately.
|
|
232
|
+
* Returns the result on success; throws the last error after all retries exhausted.
|
|
233
|
+
*/
|
|
234
|
+
async withRetry(operation, label) {
|
|
235
|
+
let lastError = null;
|
|
236
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
237
|
+
try {
|
|
238
|
+
return await operation(attempt);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!this.isRetryableError(error)) {
|
|
241
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
242
|
+
logger.warn(`${label} non-retryable error: ${err.message}`);
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
246
|
+
logger.warn(`${label} attempt ${attempt + 1}/${this.maxRetries} failed: ${lastError.message}`);
|
|
247
|
+
if (attempt < this.maxRetries - 1) {
|
|
248
|
+
const delay = this.retryBaseDelayMs * Math.pow(2, attempt);
|
|
249
|
+
await _BaseAdapter.sleep(delay);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw lastError ?? new Error(`${label} failed after ${this.maxRetries} retries`);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Determine whether an error is retryable.
|
|
257
|
+
* Default: only retry on network-level errors (ECONNRESET, ENOTFOUND, ETIMEDOUT).
|
|
258
|
+
* Subclasses should override to use SDK-specific error classes for precise classification.
|
|
259
|
+
*/
|
|
260
|
+
isRetryableError(error) {
|
|
261
|
+
if (error instanceof Error) {
|
|
262
|
+
const msg = error.message.toLowerCase();
|
|
263
|
+
if (msg.includes("econnreset") || msg.includes("enotfound") || msg.includes("etimedout")) return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Create an AbortController linked to an optional user signal and timeout.
|
|
269
|
+
* Returns the controller and a cleanup function to clear timers.
|
|
270
|
+
*/
|
|
271
|
+
createTimeoutController(options) {
|
|
272
|
+
const controller = new AbortController();
|
|
273
|
+
let timer;
|
|
274
|
+
let abortHandler;
|
|
275
|
+
if (options?.timeoutMs) {
|
|
276
|
+
timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
277
|
+
}
|
|
278
|
+
if (options?.signal) {
|
|
279
|
+
if (options.signal.aborted) {
|
|
280
|
+
controller.abort();
|
|
281
|
+
} else {
|
|
282
|
+
abortHandler = () => controller.abort();
|
|
283
|
+
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
controller,
|
|
288
|
+
cleanup: () => {
|
|
289
|
+
if (timer) clearTimeout(timer);
|
|
290
|
+
if (abortHandler && options?.signal) {
|
|
291
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Record token usage to the global cost tracker.
|
|
298
|
+
* Silently skips if usage is undefined (some providers don't report it).
|
|
299
|
+
*/
|
|
300
|
+
recordCost(usage) {
|
|
301
|
+
if (usage) {
|
|
302
|
+
getCostTracker().recordUsage(this.model, usage.promptTokens, usage.completionTokens);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Shared sleep utility */
|
|
306
|
+
static sleep(ms) {
|
|
307
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// src/ai/openai-adapter.ts
|
|
314
|
+
var openai_adapter_exports = {};
|
|
315
|
+
__export(openai_adapter_exports, {
|
|
316
|
+
OpenAIAdapter: () => OpenAIAdapter
|
|
317
|
+
});
|
|
318
|
+
import OpenAI, { RateLimitError, InternalServerError, APIConnectionError } from "openai";
|
|
319
|
+
var OpenAIAdapter;
|
|
320
|
+
var init_openai_adapter = __esm({
|
|
321
|
+
"src/ai/openai-adapter.ts"() {
|
|
322
|
+
"use strict";
|
|
323
|
+
init_base_adapter();
|
|
324
|
+
OpenAIAdapter = class extends BaseAdapter {
|
|
325
|
+
name = "openai-compatible";
|
|
326
|
+
model;
|
|
327
|
+
client = null;
|
|
328
|
+
constructor(baseUrl, model, apiKeyEnv) {
|
|
329
|
+
super();
|
|
330
|
+
this.model = model ?? "gpt-4";
|
|
331
|
+
const apiKey = process.env[apiKeyEnv ?? "OPENAI_API_KEY"] ?? process.env["OPENAI_API_KEY"] ?? "";
|
|
332
|
+
const baseURL = baseUrl ?? process.env["OPENAI_BASE_URL"];
|
|
333
|
+
if (apiKey) {
|
|
334
|
+
this.client = new OpenAI({ apiKey, baseURL: baseURL ?? void 0, maxRetries: 0 });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
isAvailable() {
|
|
338
|
+
return this.client !== null;
|
|
339
|
+
}
|
|
340
|
+
isRetryableError(error) {
|
|
341
|
+
if (error instanceof RateLimitError) return true;
|
|
342
|
+
if (error instanceof InternalServerError) return true;
|
|
343
|
+
if (error instanceof APIConnectionError) return true;
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
async generate(prompt, options) {
|
|
347
|
+
if (!this.client) {
|
|
348
|
+
throw new Error("OpenAI client not initialized. Set OPENAI_API_KEY environment variable.");
|
|
349
|
+
}
|
|
350
|
+
const result = await this.withRetry(async () => {
|
|
351
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
352
|
+
try {
|
|
353
|
+
const response = await this.client.chat.completions.create(
|
|
354
|
+
{
|
|
355
|
+
model: this.model,
|
|
356
|
+
messages: [{ role: "user", content: prompt }],
|
|
357
|
+
max_tokens: options?.maxTokens,
|
|
358
|
+
temperature: options?.temperature ?? 0.3
|
|
359
|
+
},
|
|
360
|
+
{ signal: controller.signal }
|
|
361
|
+
);
|
|
362
|
+
const content = response.choices[0]?.message?.content ?? "";
|
|
363
|
+
const usage = response.usage;
|
|
364
|
+
return {
|
|
365
|
+
content,
|
|
366
|
+
usage: usage ? {
|
|
367
|
+
promptTokens: usage.prompt_tokens,
|
|
368
|
+
completionTokens: usage.completion_tokens,
|
|
369
|
+
totalTokens: usage.total_tokens
|
|
370
|
+
} : void 0
|
|
371
|
+
};
|
|
372
|
+
} finally {
|
|
373
|
+
cleanup();
|
|
374
|
+
}
|
|
375
|
+
}, "OpenAI");
|
|
376
|
+
this.recordCost(result.usage);
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
async generateWithImage(prompt, image, options) {
|
|
380
|
+
if (!this.client) {
|
|
381
|
+
throw new Error("OpenAI client not initialized. Set OPENAI_API_KEY environment variable.");
|
|
382
|
+
}
|
|
383
|
+
const base64Image = image.buffer.toString("base64");
|
|
384
|
+
const dataUrl = `data:${image.mimeType};base64,${base64Image}`;
|
|
385
|
+
const messages = [
|
|
386
|
+
{
|
|
387
|
+
role: "user",
|
|
388
|
+
content: [
|
|
389
|
+
{ type: "text", text: prompt },
|
|
390
|
+
{ type: "image_url", image_url: { url: dataUrl } }
|
|
391
|
+
]
|
|
392
|
+
}
|
|
393
|
+
];
|
|
394
|
+
const result = await this.withRetry(async () => {
|
|
395
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
396
|
+
try {
|
|
397
|
+
const response = await this.client.chat.completions.create(
|
|
398
|
+
{
|
|
399
|
+
model: this.model,
|
|
400
|
+
messages,
|
|
401
|
+
max_tokens: options?.maxTokens,
|
|
402
|
+
temperature: options?.temperature ?? 0.3
|
|
403
|
+
},
|
|
404
|
+
{ signal: controller.signal }
|
|
405
|
+
);
|
|
406
|
+
const content = response.choices[0]?.message?.content ?? "";
|
|
407
|
+
const usage = response.usage;
|
|
408
|
+
return {
|
|
409
|
+
content,
|
|
410
|
+
usage: usage ? {
|
|
411
|
+
promptTokens: usage.prompt_tokens,
|
|
412
|
+
completionTokens: usage.completion_tokens,
|
|
413
|
+
totalTokens: usage.total_tokens
|
|
414
|
+
} : void 0
|
|
415
|
+
};
|
|
416
|
+
} finally {
|
|
417
|
+
cleanup();
|
|
418
|
+
}
|
|
419
|
+
}, "OpenAI Vision");
|
|
420
|
+
this.recordCost(result.usage);
|
|
421
|
+
return result;
|
|
422
|
+
}
|
|
423
|
+
async *generateStream(prompt, options) {
|
|
424
|
+
if (!this.client) {
|
|
425
|
+
throw new Error("OpenAI client not initialized. Set OPENAI_API_KEY environment variable.");
|
|
426
|
+
}
|
|
427
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
428
|
+
try {
|
|
429
|
+
const stream = await this.client.chat.completions.create(
|
|
430
|
+
{
|
|
431
|
+
model: this.model,
|
|
432
|
+
messages: [{ role: "user", content: prompt }],
|
|
433
|
+
max_tokens: options?.maxTokens,
|
|
434
|
+
temperature: options?.temperature ?? 0.3,
|
|
435
|
+
stream: true
|
|
436
|
+
},
|
|
437
|
+
{ signal: controller.signal }
|
|
438
|
+
);
|
|
439
|
+
for await (const chunk of stream) {
|
|
440
|
+
const delta = chunk.choices[0]?.delta?.content ?? "";
|
|
441
|
+
if (delta) yield delta;
|
|
442
|
+
}
|
|
443
|
+
} finally {
|
|
444
|
+
cleanup();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// src/ai/claude-adapter.ts
|
|
452
|
+
var claude_adapter_exports = {};
|
|
453
|
+
__export(claude_adapter_exports, {
|
|
454
|
+
ClaudeAdapter: () => ClaudeAdapter
|
|
455
|
+
});
|
|
456
|
+
import Anthropic, { RateLimitError as RateLimitError2, InternalServerError as InternalServerError2, APIConnectionError as APIConnectionError2 } from "@anthropic-ai/sdk";
|
|
457
|
+
var ClaudeAdapter;
|
|
458
|
+
var init_claude_adapter = __esm({
|
|
459
|
+
"src/ai/claude-adapter.ts"() {
|
|
460
|
+
"use strict";
|
|
461
|
+
init_base_adapter();
|
|
462
|
+
ClaudeAdapter = class extends BaseAdapter {
|
|
463
|
+
name = "claude";
|
|
464
|
+
model;
|
|
465
|
+
client = null;
|
|
466
|
+
constructor(model, apiKeyEnv) {
|
|
467
|
+
super();
|
|
468
|
+
this.model = model ?? "claude-sonnet-4-20250514";
|
|
469
|
+
const apiKey = process.env[apiKeyEnv ?? "ANTHROPIC_API_KEY"] ?? process.env["ANTHROPIC_API_KEY"] ?? "";
|
|
470
|
+
if (apiKey) {
|
|
471
|
+
this.client = new Anthropic({ apiKey, maxRetries: 0 });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
isAvailable() {
|
|
475
|
+
return this.client !== null;
|
|
476
|
+
}
|
|
477
|
+
isRetryableError(error) {
|
|
478
|
+
if (error instanceof RateLimitError2) return true;
|
|
479
|
+
if (error instanceof InternalServerError2) return true;
|
|
480
|
+
if (error instanceof APIConnectionError2) return true;
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
async generate(prompt, options) {
|
|
484
|
+
if (!this.client) {
|
|
485
|
+
throw new Error("Anthropic client not initialized. Set ANTHROPIC_API_KEY environment variable.");
|
|
486
|
+
}
|
|
487
|
+
const result = await this.withRetry(async () => {
|
|
488
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
489
|
+
try {
|
|
490
|
+
const response = await this.client.messages.create(
|
|
491
|
+
{
|
|
492
|
+
model: this.model,
|
|
493
|
+
max_tokens: options?.maxTokens ?? 4096,
|
|
494
|
+
temperature: options?.temperature ?? 0.3,
|
|
495
|
+
messages: [{ role: "user", content: prompt }]
|
|
496
|
+
},
|
|
497
|
+
{ signal: controller.signal }
|
|
498
|
+
);
|
|
499
|
+
const content = response.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
500
|
+
return {
|
|
501
|
+
content,
|
|
502
|
+
usage: {
|
|
503
|
+
promptTokens: response.usage.input_tokens,
|
|
504
|
+
completionTokens: response.usage.output_tokens,
|
|
505
|
+
totalTokens: response.usage.input_tokens + response.usage.output_tokens
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
} finally {
|
|
509
|
+
cleanup();
|
|
510
|
+
}
|
|
511
|
+
}, "Claude");
|
|
512
|
+
this.recordCost(result.usage);
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
async generateWithImage(prompt, image, options) {
|
|
516
|
+
if (!this.client) {
|
|
517
|
+
throw new Error("Anthropic client not initialized. Set ANTHROPIC_API_KEY environment variable.");
|
|
518
|
+
}
|
|
519
|
+
const base64Image = image.buffer.toString("base64");
|
|
520
|
+
const messages = [
|
|
521
|
+
{
|
|
522
|
+
role: "user",
|
|
523
|
+
content: [
|
|
524
|
+
{
|
|
525
|
+
type: "image",
|
|
526
|
+
source: {
|
|
527
|
+
type: "base64",
|
|
528
|
+
media_type: image.mimeType,
|
|
529
|
+
data: base64Image
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
{ type: "text", text: prompt }
|
|
533
|
+
]
|
|
534
|
+
}
|
|
535
|
+
];
|
|
536
|
+
const result = await this.withRetry(async () => {
|
|
537
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
538
|
+
try {
|
|
539
|
+
const response = await this.client.messages.create(
|
|
540
|
+
{
|
|
541
|
+
model: this.model,
|
|
542
|
+
max_tokens: options?.maxTokens ?? 4096,
|
|
543
|
+
temperature: options?.temperature ?? 0.3,
|
|
544
|
+
messages
|
|
545
|
+
},
|
|
546
|
+
{ signal: controller.signal }
|
|
547
|
+
);
|
|
548
|
+
const content = response.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
549
|
+
return {
|
|
550
|
+
content,
|
|
551
|
+
usage: {
|
|
552
|
+
promptTokens: response.usage.input_tokens,
|
|
553
|
+
completionTokens: response.usage.output_tokens,
|
|
554
|
+
totalTokens: response.usage.input_tokens + response.usage.output_tokens
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
} finally {
|
|
558
|
+
cleanup();
|
|
559
|
+
}
|
|
560
|
+
}, "Claude Vision");
|
|
561
|
+
this.recordCost(result.usage);
|
|
562
|
+
return result;
|
|
563
|
+
}
|
|
564
|
+
async *generateStream(prompt, options) {
|
|
565
|
+
if (!this.client) {
|
|
566
|
+
throw new Error("Anthropic client not initialized. Set ANTHROPIC_API_KEY environment variable.");
|
|
567
|
+
}
|
|
568
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
569
|
+
try {
|
|
570
|
+
const stream = this.client.messages.stream(
|
|
571
|
+
{
|
|
572
|
+
model: this.model,
|
|
573
|
+
max_tokens: options?.maxTokens ?? 4096,
|
|
574
|
+
temperature: options?.temperature ?? 0.3,
|
|
575
|
+
messages: [{ role: "user", content: prompt }]
|
|
576
|
+
},
|
|
577
|
+
{ signal: controller.signal }
|
|
578
|
+
);
|
|
579
|
+
for await (const event of stream) {
|
|
580
|
+
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
|
|
581
|
+
yield event.delta.text;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
} finally {
|
|
585
|
+
cleanup();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// src/ai/ollama-adapter.ts
|
|
593
|
+
var ollama_adapter_exports = {};
|
|
594
|
+
__export(ollama_adapter_exports, {
|
|
595
|
+
OllamaAdapter: () => OllamaAdapter
|
|
596
|
+
});
|
|
597
|
+
var DEFAULT_BASE_URL, OllamaAdapter;
|
|
598
|
+
var init_ollama_adapter = __esm({
|
|
599
|
+
"src/ai/ollama-adapter.ts"() {
|
|
600
|
+
"use strict";
|
|
601
|
+
init_base_adapter();
|
|
602
|
+
DEFAULT_BASE_URL = "http://localhost:11434";
|
|
603
|
+
OllamaAdapter = class extends BaseAdapter {
|
|
604
|
+
name = "ollama";
|
|
605
|
+
model;
|
|
606
|
+
retryBaseDelayMs = 2e3;
|
|
607
|
+
baseUrl;
|
|
608
|
+
constructor(baseUrl, model) {
|
|
609
|
+
super();
|
|
610
|
+
this.baseUrl = baseUrl ?? DEFAULT_BASE_URL;
|
|
611
|
+
this.model = model ?? "llama3";
|
|
612
|
+
}
|
|
613
|
+
isAvailable() {
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
isRetryableError(error) {
|
|
617
|
+
if (!(error instanceof Error)) return false;
|
|
618
|
+
const msg = error.message.toLowerCase();
|
|
619
|
+
if (msg.includes("500") || msg.includes("502") || msg.includes("503") || msg.includes("504")) return true;
|
|
620
|
+
if (msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("enotfound")) return true;
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
async generate(prompt, options) {
|
|
624
|
+
const result = await this.withRetry(async () => {
|
|
625
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
626
|
+
try {
|
|
627
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
628
|
+
method: "POST",
|
|
629
|
+
headers: { "Content-Type": "application/json" },
|
|
630
|
+
body: JSON.stringify({
|
|
631
|
+
model: this.model,
|
|
632
|
+
prompt,
|
|
633
|
+
stream: false,
|
|
634
|
+
options: {
|
|
635
|
+
temperature: options?.temperature ?? 0.3,
|
|
636
|
+
num_predict: options?.maxTokens
|
|
637
|
+
}
|
|
638
|
+
}),
|
|
639
|
+
signal: controller.signal
|
|
640
|
+
});
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
throw new Error(`Ollama returned ${response.status}: ${response.statusText}`);
|
|
643
|
+
}
|
|
644
|
+
const data = await response.json();
|
|
645
|
+
return {
|
|
646
|
+
content: data.response,
|
|
647
|
+
usage: data.prompt_eval_count ? {
|
|
648
|
+
promptTokens: data.prompt_eval_count,
|
|
649
|
+
completionTokens: data.eval_count ?? 0,
|
|
650
|
+
totalTokens: data.prompt_eval_count + (data.eval_count ?? 0)
|
|
651
|
+
} : void 0
|
|
652
|
+
};
|
|
653
|
+
} finally {
|
|
654
|
+
cleanup();
|
|
655
|
+
}
|
|
656
|
+
}, "Ollama");
|
|
657
|
+
this.recordCost(result.usage);
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
async generateWithImage(prompt, image, options) {
|
|
661
|
+
const base64Image = image.buffer.toString("base64");
|
|
662
|
+
const result = await this.withRetry(async () => {
|
|
663
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
664
|
+
try {
|
|
665
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
666
|
+
method: "POST",
|
|
667
|
+
headers: { "Content-Type": "application/json" },
|
|
668
|
+
body: JSON.stringify({
|
|
669
|
+
model: this.model,
|
|
670
|
+
prompt,
|
|
671
|
+
images: [base64Image],
|
|
672
|
+
stream: false,
|
|
673
|
+
options: {
|
|
674
|
+
temperature: options?.temperature ?? 0.3,
|
|
675
|
+
num_predict: options?.maxTokens
|
|
676
|
+
}
|
|
677
|
+
}),
|
|
678
|
+
signal: controller.signal
|
|
679
|
+
});
|
|
680
|
+
if (!response.ok) {
|
|
681
|
+
throw new Error(`Ollama returned ${response.status}: ${response.statusText}`);
|
|
682
|
+
}
|
|
683
|
+
const data = await response.json();
|
|
684
|
+
return {
|
|
685
|
+
content: data.response,
|
|
686
|
+
usage: data.prompt_eval_count ? {
|
|
687
|
+
promptTokens: data.prompt_eval_count,
|
|
688
|
+
completionTokens: data.eval_count ?? 0,
|
|
689
|
+
totalTokens: data.prompt_eval_count + (data.eval_count ?? 0)
|
|
690
|
+
} : void 0
|
|
691
|
+
};
|
|
692
|
+
} finally {
|
|
693
|
+
cleanup();
|
|
694
|
+
}
|
|
695
|
+
}, "Ollama Vision");
|
|
696
|
+
this.recordCost(result.usage);
|
|
697
|
+
return result;
|
|
698
|
+
}
|
|
699
|
+
async *generateStream(prompt, options) {
|
|
700
|
+
const { controller, cleanup } = this.createTimeoutController(options);
|
|
701
|
+
try {
|
|
702
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
headers: { "Content-Type": "application/json" },
|
|
705
|
+
body: JSON.stringify({
|
|
706
|
+
model: this.model,
|
|
707
|
+
prompt,
|
|
708
|
+
stream: true,
|
|
709
|
+
options: {
|
|
710
|
+
temperature: options?.temperature ?? 0.3
|
|
711
|
+
}
|
|
712
|
+
}),
|
|
713
|
+
signal: controller.signal
|
|
714
|
+
});
|
|
715
|
+
if (!response.ok) {
|
|
716
|
+
throw new Error(`Ollama returned ${response.status}: ${response.statusText}`);
|
|
717
|
+
}
|
|
718
|
+
if (!response.body) {
|
|
719
|
+
throw new Error("Ollama streaming response has no body");
|
|
720
|
+
}
|
|
721
|
+
const reader = response.body.getReader();
|
|
722
|
+
const decoder = new TextDecoder();
|
|
723
|
+
while (true) {
|
|
724
|
+
const { done, value } = await reader.read();
|
|
725
|
+
if (done) break;
|
|
726
|
+
const lines = decoder.decode(value ?? new Uint8Array()).trim().split("\n");
|
|
727
|
+
for (const line of lines) {
|
|
728
|
+
if (!line) continue;
|
|
729
|
+
try {
|
|
730
|
+
const data = JSON.parse(line);
|
|
731
|
+
if (data.response) yield data.response;
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
} finally {
|
|
737
|
+
cleanup();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// src/ai/provider.ts
|
|
745
|
+
var provider_exports = {};
|
|
746
|
+
__export(provider_exports, {
|
|
747
|
+
createProvider: () => createProvider,
|
|
748
|
+
createProviderChain: () => createProviderChain
|
|
749
|
+
});
|
|
750
|
+
async function createProvider(config) {
|
|
751
|
+
const provider = config?.provider ?? "openai-compatible";
|
|
752
|
+
switch (provider) {
|
|
753
|
+
case "openai-compatible": {
|
|
754
|
+
const { OpenAIAdapter: OpenAIAdapter2 } = await Promise.resolve().then(() => (init_openai_adapter(), openai_adapter_exports));
|
|
755
|
+
return new OpenAIAdapter2(config?.baseUrl, config?.model, config?.apiKeyEnv);
|
|
756
|
+
}
|
|
757
|
+
case "claude": {
|
|
758
|
+
const { ClaudeAdapter: ClaudeAdapter2 } = await Promise.resolve().then(() => (init_claude_adapter(), claude_adapter_exports));
|
|
759
|
+
return new ClaudeAdapter2(config?.model, config?.apiKeyEnv);
|
|
760
|
+
}
|
|
761
|
+
case "ollama": {
|
|
762
|
+
const { OllamaAdapter: OllamaAdapter2 } = await Promise.resolve().then(() => (init_ollama_adapter(), ollama_adapter_exports));
|
|
763
|
+
return new OllamaAdapter2(config?.baseUrl, config?.model);
|
|
764
|
+
}
|
|
765
|
+
default:
|
|
766
|
+
throw new Error(`Unknown AI provider: ${provider}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function createProviderChain(configs) {
|
|
770
|
+
const { logger: logger2 } = await Promise.resolve().then(() => (init_logger(), logger_exports));
|
|
771
|
+
const errors = [];
|
|
772
|
+
for (const config of configs) {
|
|
773
|
+
try {
|
|
774
|
+
const provider = await createProvider(config);
|
|
775
|
+
if (provider.isAvailable()) {
|
|
776
|
+
logger2.debug(`Provider chain: using '${provider.name}'`);
|
|
777
|
+
return provider;
|
|
778
|
+
}
|
|
779
|
+
errors.push(`${config.provider ?? "openai-compatible"}: not available (API key not set)`);
|
|
780
|
+
} catch (err) {
|
|
781
|
+
errors.push(`${config.provider ?? "openai-compatible"}: ${err instanceof Error ? err.message : String(err)}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
throw new Error(`All providers in chain unavailable:
|
|
785
|
+
${errors.join("\n ")}`);
|
|
786
|
+
}
|
|
787
|
+
var init_provider = __esm({
|
|
788
|
+
"src/ai/provider.ts"() {
|
|
789
|
+
"use strict";
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// src/mcp/server.ts
|
|
794
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
795
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
796
|
+
|
|
797
|
+
// src/infra/file-lock.ts
|
|
798
|
+
init_logger();
|
|
799
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync, statSync } from "fs";
|
|
800
|
+
import { join } from "path";
|
|
801
|
+
var LOCK_FILENAME = ".lock";
|
|
802
|
+
var LOCK_EXPIRY_MS = 30 * 60 * 1e3;
|
|
803
|
+
function acquireLock(specLoreDir) {
|
|
804
|
+
const lockPath = join(specLoreDir, LOCK_FILENAME);
|
|
805
|
+
if (existsSync(lockPath)) {
|
|
806
|
+
const existing = readLockInfo(lockPath);
|
|
807
|
+
if (existing && !isLockExpired(existing)) {
|
|
808
|
+
logger.debug(`Lock already held by PID ${existing.pid}, not expired`);
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
logger.debug("Removing stale lock file");
|
|
812
|
+
safeUnlink(lockPath);
|
|
813
|
+
}
|
|
814
|
+
const lockInfo = {
|
|
815
|
+
pid: process.pid,
|
|
816
|
+
timestamp: Date.now()
|
|
817
|
+
};
|
|
818
|
+
try {
|
|
819
|
+
writeFileSync(lockPath, JSON.stringify(lockInfo), { flag: "wx", encoding: "utf-8" });
|
|
820
|
+
} catch {
|
|
821
|
+
logger.debug("Lock write failed \u2014 another process may have acquired it");
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
logger.debug(`Lock acquired by PID ${process.pid}`);
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
function releaseLock(specLoreDir) {
|
|
828
|
+
const lockPath = join(specLoreDir, LOCK_FILENAME);
|
|
829
|
+
if (!existsSync(lockPath)) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const existing = readLockInfo(lockPath);
|
|
833
|
+
if (existing && existing.pid === process.pid) {
|
|
834
|
+
safeUnlink(lockPath);
|
|
835
|
+
logger.debug("Lock released");
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function readLockInfo(lockPath) {
|
|
839
|
+
try {
|
|
840
|
+
const content = readFileSync(lockPath, "utf-8");
|
|
841
|
+
const parsed = JSON.parse(content);
|
|
842
|
+
if (typeof parsed.pid === "number" && typeof parsed.timestamp === "number") {
|
|
843
|
+
return parsed;
|
|
844
|
+
}
|
|
845
|
+
return null;
|
|
846
|
+
} catch {
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function isLockExpired(lockInfo) {
|
|
851
|
+
if (Date.now() - lockInfo.timestamp > LOCK_EXPIRY_MS) {
|
|
852
|
+
logger.debug(`Lock expired: age ${Date.now() - lockInfo.timestamp}ms > ${LOCK_EXPIRY_MS}ms`);
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
if (!isProcessRunning(lockInfo.pid)) {
|
|
856
|
+
logger.debug(`Lock expired: PID ${lockInfo.pid} not running`);
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
function isProcessRunning(pid) {
|
|
862
|
+
try {
|
|
863
|
+
process.kill(pid, 0);
|
|
864
|
+
return true;
|
|
865
|
+
} catch {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function safeUnlink(filePath) {
|
|
870
|
+
try {
|
|
871
|
+
unlinkSync(filePath);
|
|
872
|
+
} catch {
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// src/mcp/server.ts
|
|
877
|
+
init_logger();
|
|
878
|
+
|
|
879
|
+
// src/core/requirement-reader/index.ts
|
|
880
|
+
init_logger();
|
|
881
|
+
import { existsSync as existsSync6 } from "fs";
|
|
882
|
+
import { extname as extname6 } from "path";
|
|
883
|
+
|
|
884
|
+
// src/plugins/registry.ts
|
|
885
|
+
init_logger();
|
|
886
|
+
|
|
887
|
+
// src/plugins/builtin/md-reader.ts
|
|
888
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
889
|
+
var MarkdownReader = class {
|
|
890
|
+
name = "markdown-reader";
|
|
891
|
+
supportedFormats = [".md", ".markdown"];
|
|
892
|
+
canRead(source) {
|
|
893
|
+
return /\.md$/i.test(source) || /\.markdown$/i.test(source);
|
|
894
|
+
}
|
|
895
|
+
read(source) {
|
|
896
|
+
if (!existsSync2(source)) {
|
|
897
|
+
throw new Error(`File not found: ${source}`);
|
|
898
|
+
}
|
|
899
|
+
const content = readFileSync2(source, "utf-8");
|
|
900
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
901
|
+
const title = titleMatch?.[1]?.trim() ?? "Untitled";
|
|
902
|
+
const acceptanceCriteria = [];
|
|
903
|
+
const acSection = content.match(/##\s*Acceptance\s*(?:Criteria)?[\s\S]*?(?=##|$)/i);
|
|
904
|
+
if (acSection) {
|
|
905
|
+
const items = acSection[0].match(/^[-*]\s+\[.\]\s+(.+)/gm);
|
|
906
|
+
if (items) {
|
|
907
|
+
acceptanceCriteria.push(...items.map((i) => i.replace(/^[-*]\s+\[.\]\s+/, "")));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return Promise.resolve([{
|
|
911
|
+
id: source.replace(/\\/g, "/").replace(/\.md$/i, ""),
|
|
912
|
+
title,
|
|
913
|
+
description: content,
|
|
914
|
+
acceptanceCriteria: acceptanceCriteria.length > 0 ? acceptanceCriteria : void 0,
|
|
915
|
+
rawContent: content,
|
|
916
|
+
confidence: 0.9
|
|
917
|
+
}]);
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
// src/plugins/builtin/docx-reader.ts
|
|
922
|
+
var DocxReader = class {
|
|
923
|
+
name = "docx-reader";
|
|
924
|
+
supportedFormats = [".docx"];
|
|
925
|
+
canRead(source) {
|
|
926
|
+
return /\.docx$/i.test(source);
|
|
927
|
+
}
|
|
928
|
+
async read(source) {
|
|
929
|
+
let mammothModule;
|
|
930
|
+
try {
|
|
931
|
+
mammothModule = await import("mammoth");
|
|
932
|
+
} catch {
|
|
933
|
+
throw new Error("mammoth package is required for DOCX support. Install: npm i mammoth");
|
|
934
|
+
}
|
|
935
|
+
const result = await mammothModule.extractRawText({ path: source });
|
|
936
|
+
const content = result.value;
|
|
937
|
+
return [{
|
|
938
|
+
id: source.replace(/\\/g, "/").replace(/\.docx$/i, ""),
|
|
939
|
+
title: source.split("/").pop()?.replace(/\.docx$/i, "") ?? "Untitled",
|
|
940
|
+
description: content,
|
|
941
|
+
rawContent: content,
|
|
942
|
+
confidence: 0.8
|
|
943
|
+
}];
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// src/plugins/builtin/xlsx-reader.ts
|
|
948
|
+
import ExcelJS from "exceljs";
|
|
949
|
+
|
|
950
|
+
// src/infra/excel-utils.ts
|
|
951
|
+
function formatCellValue(value) {
|
|
952
|
+
if (value === null || value === void 0) return "";
|
|
953
|
+
if (typeof value === "string") return value;
|
|
954
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
955
|
+
if (value instanceof Date) return value.toISOString();
|
|
956
|
+
return "";
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/plugins/builtin/xlsx-reader.ts
|
|
960
|
+
var XlsxReader = class {
|
|
961
|
+
name = "xlsx-reader";
|
|
962
|
+
supportedFormats = [".xlsx", ".xls"];
|
|
963
|
+
canRead(source) {
|
|
964
|
+
return /\.xlsx$/i.test(source) || /\.xls$/i.test(source);
|
|
965
|
+
}
|
|
966
|
+
async read(source) {
|
|
967
|
+
const workbook = new ExcelJS.Workbook();
|
|
968
|
+
await workbook.xlsx.readFile(source);
|
|
969
|
+
const requirements = [];
|
|
970
|
+
for (const sheet of workbook.worksheets) {
|
|
971
|
+
const sheetName = sheet.name;
|
|
972
|
+
const headers = [];
|
|
973
|
+
const rows = [];
|
|
974
|
+
sheet.eachRow((row, rowNumber) => {
|
|
975
|
+
if (rowNumber === 1) {
|
|
976
|
+
row.eachCell((cell, colNumber) => {
|
|
977
|
+
headers[colNumber - 1] = formatCellValue(cell.value);
|
|
978
|
+
});
|
|
979
|
+
} else {
|
|
980
|
+
const obj = {};
|
|
981
|
+
row.eachCell((cell, colNumber) => {
|
|
982
|
+
const key = headers[colNumber - 1] ?? `Col${colNumber}`;
|
|
983
|
+
obj[key] = formatCellValue(cell.value);
|
|
984
|
+
});
|
|
985
|
+
rows.push(obj);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
for (let i = 0; i < rows.length; i++) {
|
|
989
|
+
const row = rows[i];
|
|
990
|
+
const title = row["Title"] ?? row["title"] ?? row["Name"] ?? row["name"] ?? row["ID"] ?? `Row ${i + 1}`;
|
|
991
|
+
const description = row["Description"] ?? row["description"] ?? row["Desc"] ?? "";
|
|
992
|
+
const ac = row["Acceptance Criteria"] ?? row["acceptance"] ?? row["AC"] ?? "";
|
|
993
|
+
if (description || title) {
|
|
994
|
+
requirements.push({
|
|
995
|
+
id: `${sheetName}/${i + 1}`,
|
|
996
|
+
title: String(title),
|
|
997
|
+
description: String(description),
|
|
998
|
+
acceptanceCriteria: ac ? String(ac).split("\n").filter(Boolean) : void 0,
|
|
999
|
+
rawContent: JSON.stringify(row),
|
|
1000
|
+
confidence: 0.7
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return requirements;
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
// src/plugins/builtin/pdf-reader.ts
|
|
1010
|
+
var PdfReader = class {
|
|
1011
|
+
name = "pdf-reader";
|
|
1012
|
+
supportedFormats = [".pdf"];
|
|
1013
|
+
canRead(source) {
|
|
1014
|
+
return /\.pdf$/i.test(source);
|
|
1015
|
+
}
|
|
1016
|
+
async read(source) {
|
|
1017
|
+
let pdfParse;
|
|
1018
|
+
try {
|
|
1019
|
+
const mod = await import("pdf-parse");
|
|
1020
|
+
pdfParse = mod.default ?? mod;
|
|
1021
|
+
} catch {
|
|
1022
|
+
throw new Error("pdf-parse package is required for PDF support. Install: npm i pdf-parse");
|
|
1023
|
+
}
|
|
1024
|
+
const { readFileSync: readFileSync17 } = await import("fs");
|
|
1025
|
+
const buffer = readFileSync17(source);
|
|
1026
|
+
const data = await pdfParse(buffer);
|
|
1027
|
+
return [{
|
|
1028
|
+
id: source.replace(/\\/g, "/").replace(/\.pdf$/i, ""),
|
|
1029
|
+
title: source.split("/").pop()?.replace(/\.pdf$/i, "") ?? "Untitled",
|
|
1030
|
+
description: data.text,
|
|
1031
|
+
rawContent: data.text,
|
|
1032
|
+
confidence: 0.75
|
|
1033
|
+
}];
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// src/plugins/builtin/image-reader.ts
|
|
1038
|
+
var ImageReader = class {
|
|
1039
|
+
name = "image-reader";
|
|
1040
|
+
supportedFormats = [".png", ".jpg", ".jpeg", ".webp"];
|
|
1041
|
+
canRead(source) {
|
|
1042
|
+
return /\.(png|jpe?g|webp)$/i.test(source);
|
|
1043
|
+
}
|
|
1044
|
+
async read(source) {
|
|
1045
|
+
const { existsSync: existsSync22, readFileSync: readFileSync17 } = await import("fs");
|
|
1046
|
+
if (!existsSync22(source)) {
|
|
1047
|
+
throw new Error(`Image file not found: ${source}`);
|
|
1048
|
+
}
|
|
1049
|
+
const ext = source.split(".").pop()?.toLowerCase() ?? "png";
|
|
1050
|
+
const mimeType = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
|
|
1051
|
+
const buffer = readFileSync17(source);
|
|
1052
|
+
let text;
|
|
1053
|
+
try {
|
|
1054
|
+
const { createProvider: createProvider2 } = await Promise.resolve().then(() => (init_provider(), provider_exports));
|
|
1055
|
+
const provider = await createProvider2();
|
|
1056
|
+
if (!provider.generateWithImage) {
|
|
1057
|
+
throw new Error(`Provider '${provider.name}' does not support vision input`);
|
|
1058
|
+
}
|
|
1059
|
+
const result = await provider.generateWithImage(
|
|
1060
|
+
"Extract all text content from this image. Return only the extracted text.",
|
|
1061
|
+
{ buffer, mimeType }
|
|
1062
|
+
);
|
|
1063
|
+
text = result.content;
|
|
1064
|
+
} catch {
|
|
1065
|
+
text = `[Image file: ${source} \u2014 OCR not available. Please provide text description manually.]`;
|
|
1066
|
+
}
|
|
1067
|
+
return [{
|
|
1068
|
+
id: source.replace(/\\/g, "/").replace(/\.(png|jpe?g|webp)$/i, ""),
|
|
1069
|
+
title: source.split("/").pop()?.replace(/\.(png|jpe?g|webp)$/i, "") ?? "Untitled",
|
|
1070
|
+
description: text,
|
|
1071
|
+
rawContent: text,
|
|
1072
|
+
confidence: 0.5
|
|
1073
|
+
}];
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// src/plugins/builtin/cursor-writer.ts
|
|
1078
|
+
import { writeFileSync as writeFileSync2, mkdirSync, existsSync as existsSync3, rmSync } from "fs";
|
|
1079
|
+
import { join as join2 } from "path";
|
|
1080
|
+
var CursorWriter = class {
|
|
1081
|
+
toolName = "cursor";
|
|
1082
|
+
configFile = ".cursor/rules/speclore.mdc";
|
|
1083
|
+
projectRoot = "";
|
|
1084
|
+
detect(projectRoot2) {
|
|
1085
|
+
return existsSync3(join2(projectRoot2, ".cursor"));
|
|
1086
|
+
}
|
|
1087
|
+
write(constraints) {
|
|
1088
|
+
this.projectRoot = constraints.projectRoot;
|
|
1089
|
+
const rulesDir = join2(constraints.projectRoot, ".cursor", "rules");
|
|
1090
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
1091
|
+
const frontmatter = [
|
|
1092
|
+
"---",
|
|
1093
|
+
"description: SpecLore coding constraints \u2014 auto-generated",
|
|
1094
|
+
"globs:",
|
|
1095
|
+
"alwaysApply: true",
|
|
1096
|
+
"---",
|
|
1097
|
+
""
|
|
1098
|
+
].join("\n");
|
|
1099
|
+
const content = frontmatter + this.buildMarkdown(constraints);
|
|
1100
|
+
writeFileSync2(join2(rulesDir, "speclore.mdc"), content, "utf-8");
|
|
1101
|
+
return Promise.resolve();
|
|
1102
|
+
}
|
|
1103
|
+
remove() {
|
|
1104
|
+
const filePath = join2(this.projectRoot, ".cursor", "rules", "speclore.mdc");
|
|
1105
|
+
if (existsSync3(filePath)) rmSync(filePath);
|
|
1106
|
+
return Promise.resolve();
|
|
1107
|
+
}
|
|
1108
|
+
buildMarkdown(c) {
|
|
1109
|
+
const lines = [];
|
|
1110
|
+
lines.push(`# SpecLore Constraints \u2014 ${c.projectName}`);
|
|
1111
|
+
lines.push("");
|
|
1112
|
+
if (c.features.length > 0) {
|
|
1113
|
+
const featurePaths = c.features.map((f) => f.path.replace(/\\/g, "/"));
|
|
1114
|
+
lines.push(`> Profile: ${c.profile} | Generated by SpecLore`);
|
|
1115
|
+
lines.push(`> Source features: ${featurePaths.join(", ")}`);
|
|
1116
|
+
} else {
|
|
1117
|
+
lines.push(`> Profile: ${c.profile} | Generated by SpecLore`);
|
|
1118
|
+
}
|
|
1119
|
+
lines.push("");
|
|
1120
|
+
for (const mod of c.modules) {
|
|
1121
|
+
lines.push(`## Module: ${mod.module}`);
|
|
1122
|
+
lines.push(`**Responsibility**: ${mod.boundaries.responsibility}`);
|
|
1123
|
+
lines.push(`**Depends on**: ${mod.boundaries.dependsOn.join(", ") || "none"}`);
|
|
1124
|
+
lines.push("");
|
|
1125
|
+
if (mod.namingConventions.length > 0) {
|
|
1126
|
+
lines.push("### Naming Conventions");
|
|
1127
|
+
for (const n of mod.namingConventions) lines.push(`- ${n}`);
|
|
1128
|
+
lines.push("");
|
|
1129
|
+
}
|
|
1130
|
+
if (mod.forbiddenPatterns.length > 0) {
|
|
1131
|
+
lines.push("### Forbidden Patterns");
|
|
1132
|
+
for (const f of mod.forbiddenPatterns) lines.push(`- ${f}`);
|
|
1133
|
+
lines.push("");
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (c.featureRules && c.featureRules.length > 0) {
|
|
1137
|
+
for (const rule of c.featureRules) {
|
|
1138
|
+
lines.push(`## Feature Rules: ${rule.featureName}`);
|
|
1139
|
+
lines.push(`> Source: ${rule.sourceFile}`);
|
|
1140
|
+
lines.push("");
|
|
1141
|
+
for (const sc of rule.scenarios) {
|
|
1142
|
+
lines.push(`- Scenario "${sc.name}": ${sc.summary}`);
|
|
1143
|
+
}
|
|
1144
|
+
lines.push("");
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (c.scaffoldInfo && c.scaffoldInfo.length > 0) {
|
|
1148
|
+
lines.push("## Test Scaffolding");
|
|
1149
|
+
lines.push("Test files have been generated at:");
|
|
1150
|
+
for (const s of c.scaffoldInfo) {
|
|
1151
|
+
lines.push(`- ${s.testFile} (${s.scenarios} scenarios, ${s.framework})`);
|
|
1152
|
+
}
|
|
1153
|
+
lines.push("");
|
|
1154
|
+
lines.push("Fill in test implementations, then run `speclore verify` to validate.");
|
|
1155
|
+
lines.push("");
|
|
1156
|
+
}
|
|
1157
|
+
if (c.mappingInstructions) {
|
|
1158
|
+
lines.push("## Test Mapping");
|
|
1159
|
+
lines.push(c.mappingInstructions);
|
|
1160
|
+
lines.push("");
|
|
1161
|
+
}
|
|
1162
|
+
return lines.join("\n");
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
// src/plugins/builtin/claude-writer.ts
|
|
1167
|
+
import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync2, existsSync as existsSync4, rmSync as rmSync2 } from "fs";
|
|
1168
|
+
import { join as join3 } from "path";
|
|
1169
|
+
var ClaudeWriter = class {
|
|
1170
|
+
toolName = "claude";
|
|
1171
|
+
configFile = ".claude/rules/speclore.md";
|
|
1172
|
+
projectRoot = "";
|
|
1173
|
+
detect(projectRoot2) {
|
|
1174
|
+
return existsSync4(join3(projectRoot2, ".claude")) || existsSync4(join3(projectRoot2, ".mcp.json"));
|
|
1175
|
+
}
|
|
1176
|
+
write(constraints) {
|
|
1177
|
+
this.projectRoot = constraints.projectRoot;
|
|
1178
|
+
const rulesDir = join3(constraints.projectRoot, ".claude", "rules");
|
|
1179
|
+
mkdirSync2(rulesDir, { recursive: true });
|
|
1180
|
+
const content = this.buildMarkdown(constraints);
|
|
1181
|
+
writeFileSync3(join3(rulesDir, "speclore.md"), content, "utf-8");
|
|
1182
|
+
return Promise.resolve();
|
|
1183
|
+
}
|
|
1184
|
+
remove() {
|
|
1185
|
+
const filePath = join3(this.projectRoot, ".claude", "rules", "speclore.md");
|
|
1186
|
+
if (existsSync4(filePath)) rmSync2(filePath);
|
|
1187
|
+
return Promise.resolve();
|
|
1188
|
+
}
|
|
1189
|
+
buildMarkdown(c) {
|
|
1190
|
+
const lines = [];
|
|
1191
|
+
lines.push(`# SpecLore Constraints \u2014 ${c.projectName}`);
|
|
1192
|
+
lines.push("");
|
|
1193
|
+
if (c.features.length > 0) {
|
|
1194
|
+
const featurePaths = c.features.map((f) => f.path.replace(/\\/g, "/"));
|
|
1195
|
+
lines.push(`> Profile: ${c.profile} | Generated by SpecLore`);
|
|
1196
|
+
lines.push(`> Source features: ${featurePaths.join(", ")}`);
|
|
1197
|
+
} else {
|
|
1198
|
+
lines.push(`> Profile: ${c.profile} | Generated by SpecLore`);
|
|
1199
|
+
}
|
|
1200
|
+
lines.push("");
|
|
1201
|
+
for (const mod of c.modules) {
|
|
1202
|
+
lines.push(`## Module: ${mod.module}`);
|
|
1203
|
+
lines.push(`- **Responsibility**: ${mod.boundaries.responsibility}`);
|
|
1204
|
+
lines.push(`- **Depends on**: ${mod.boundaries.dependsOn.join(", ") || "none"}`);
|
|
1205
|
+
if (mod.namingConventions.length > 0) {
|
|
1206
|
+
lines.push(`- **Naming**: ${mod.namingConventions.join("; ")}`);
|
|
1207
|
+
}
|
|
1208
|
+
if (mod.forbiddenPatterns.length > 0) {
|
|
1209
|
+
lines.push(`- **Forbidden**: ${mod.forbiddenPatterns.join("; ")}`);
|
|
1210
|
+
}
|
|
1211
|
+
lines.push("");
|
|
1212
|
+
}
|
|
1213
|
+
if (c.featureRules && c.featureRules.length > 0) {
|
|
1214
|
+
for (const rule of c.featureRules) {
|
|
1215
|
+
lines.push(`## Feature Rules: ${rule.featureName}`);
|
|
1216
|
+
lines.push(`> Source: ${rule.sourceFile}`);
|
|
1217
|
+
lines.push("");
|
|
1218
|
+
for (const sc of rule.scenarios) {
|
|
1219
|
+
lines.push(`- Scenario "${sc.name}": ${sc.summary}`);
|
|
1220
|
+
}
|
|
1221
|
+
lines.push("");
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (c.scaffoldInfo && c.scaffoldInfo.length > 0) {
|
|
1225
|
+
lines.push("## Test Scaffolding");
|
|
1226
|
+
lines.push("Test files have been generated at:");
|
|
1227
|
+
for (const s of c.scaffoldInfo) {
|
|
1228
|
+
lines.push(`- ${s.testFile} (${s.scenarios} scenarios, ${s.framework})`);
|
|
1229
|
+
}
|
|
1230
|
+
lines.push("");
|
|
1231
|
+
lines.push("Fill in test implementations, then run `speclore verify` to validate.");
|
|
1232
|
+
lines.push("");
|
|
1233
|
+
}
|
|
1234
|
+
if (c.mappingInstructions) {
|
|
1235
|
+
lines.push("## Test Mapping");
|
|
1236
|
+
lines.push(c.mappingInstructions);
|
|
1237
|
+
}
|
|
1238
|
+
return lines.join("\n");
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
// src/plugins/builtin/qoder-writer.ts
|
|
1243
|
+
import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, existsSync as existsSync5, rmSync as rmSync3 } from "fs";
|
|
1244
|
+
import { join as join4 } from "path";
|
|
1245
|
+
var QoderWriter = class {
|
|
1246
|
+
toolName = "qoder";
|
|
1247
|
+
configFile = ".qoder/rules/speclore.md";
|
|
1248
|
+
projectRoot = "";
|
|
1249
|
+
detect(projectRoot2) {
|
|
1250
|
+
return existsSync5(join4(projectRoot2, ".qoder"));
|
|
1251
|
+
}
|
|
1252
|
+
write(constraints) {
|
|
1253
|
+
this.projectRoot = constraints.projectRoot;
|
|
1254
|
+
const rulesDir = join4(constraints.projectRoot, ".qoder", "rules");
|
|
1255
|
+
mkdirSync3(rulesDir, { recursive: true });
|
|
1256
|
+
const content = this.buildMarkdown(constraints);
|
|
1257
|
+
writeFileSync4(join4(rulesDir, "speclore.md"), content, "utf-8");
|
|
1258
|
+
return Promise.resolve();
|
|
1259
|
+
}
|
|
1260
|
+
remove() {
|
|
1261
|
+
const filePath = join4(this.projectRoot, ".qoder", "rules", "speclore.md");
|
|
1262
|
+
if (existsSync5(filePath)) rmSync3(filePath);
|
|
1263
|
+
return Promise.resolve();
|
|
1264
|
+
}
|
|
1265
|
+
buildMarkdown(c) {
|
|
1266
|
+
const lines = [];
|
|
1267
|
+
lines.push(`# SpecLore Constraints \u2014 ${c.projectName}`);
|
|
1268
|
+
lines.push("");
|
|
1269
|
+
if (c.features.length > 0) {
|
|
1270
|
+
const featurePaths = c.features.map((f) => {
|
|
1271
|
+
const rel = f.path.replace(/\\/g, "/");
|
|
1272
|
+
return rel;
|
|
1273
|
+
});
|
|
1274
|
+
lines.push(`> Profile: ${c.profile} | Generated by SpecLore`);
|
|
1275
|
+
lines.push(`> Source features: ${featurePaths.join(", ")}`);
|
|
1276
|
+
} else {
|
|
1277
|
+
lines.push(`> Profile: ${c.profile} | Generated by SpecLore`);
|
|
1278
|
+
}
|
|
1279
|
+
lines.push("");
|
|
1280
|
+
for (const mod of c.modules) {
|
|
1281
|
+
lines.push(`## Module: ${mod.module}`);
|
|
1282
|
+
lines.push(`- **Responsibility**: ${mod.boundaries.responsibility}`);
|
|
1283
|
+
lines.push(`- **Depends on**: ${mod.boundaries.dependsOn.join(", ") || "none"}`);
|
|
1284
|
+
if (mod.namingConventions.length > 0) {
|
|
1285
|
+
lines.push(`- **Naming**: ${mod.namingConventions.join("; ")}`);
|
|
1286
|
+
}
|
|
1287
|
+
if (mod.forbiddenPatterns.length > 0) {
|
|
1288
|
+
lines.push(`- **Forbidden**: ${mod.forbiddenPatterns.join("; ")}`);
|
|
1289
|
+
}
|
|
1290
|
+
lines.push("");
|
|
1291
|
+
}
|
|
1292
|
+
if (c.featureRules && c.featureRules.length > 0) {
|
|
1293
|
+
for (const rule of c.featureRules) {
|
|
1294
|
+
lines.push(`## Feature Rules: ${rule.featureName}`);
|
|
1295
|
+
lines.push(`> Source: ${rule.sourceFile}`);
|
|
1296
|
+
lines.push("");
|
|
1297
|
+
for (const sc of rule.scenarios) {
|
|
1298
|
+
lines.push(`- Scenario "${sc.name}": ${sc.summary}`);
|
|
1299
|
+
}
|
|
1300
|
+
lines.push("");
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (c.scaffoldInfo && c.scaffoldInfo.length > 0) {
|
|
1304
|
+
lines.push("## Test Scaffolding");
|
|
1305
|
+
lines.push("Test files have been generated at:");
|
|
1306
|
+
for (const s of c.scaffoldInfo) {
|
|
1307
|
+
lines.push(`- ${s.testFile} (${s.scenarios} scenarios, ${s.framework})`);
|
|
1308
|
+
}
|
|
1309
|
+
lines.push("");
|
|
1310
|
+
lines.push("Fill in test implementations, then run `speclore verify` to validate.");
|
|
1311
|
+
lines.push("");
|
|
1312
|
+
}
|
|
1313
|
+
if (c.mappingInstructions) {
|
|
1314
|
+
lines.push("## Test Mapping");
|
|
1315
|
+
lines.push(c.mappingInstructions);
|
|
1316
|
+
}
|
|
1317
|
+
return lines.join("\n");
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
// src/plugins/builtin/junit-parser.ts
|
|
1322
|
+
var JUnitParser = class {
|
|
1323
|
+
framework = "junit";
|
|
1324
|
+
canParse(testOutput) {
|
|
1325
|
+
return testOutput.trimStart().startsWith("<?xml") || testOutput.includes("<testsuite");
|
|
1326
|
+
}
|
|
1327
|
+
parse(testOutput, _features) {
|
|
1328
|
+
const results = [];
|
|
1329
|
+
const testcaseRegex = /<testcase\s+([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
1330
|
+
let match;
|
|
1331
|
+
while ((match = testcaseRegex.exec(testOutput)) !== null) {
|
|
1332
|
+
const attrs = match[1];
|
|
1333
|
+
const body = match[2] ?? "";
|
|
1334
|
+
const name = extractAttr(attrs, "name") ?? "unknown";
|
|
1335
|
+
const classname = extractAttr(attrs, "classname") ?? "";
|
|
1336
|
+
const time = extractAttr(attrs, "time");
|
|
1337
|
+
const failureMatch = body.match(/<failure[^>]*>([\s\S]*?)<\/failure>/);
|
|
1338
|
+
const skippedMatch = body.match(/<skipped/);
|
|
1339
|
+
let status = "passed";
|
|
1340
|
+
let error;
|
|
1341
|
+
if (failureMatch) {
|
|
1342
|
+
status = "failed";
|
|
1343
|
+
error = failureMatch[1].trim();
|
|
1344
|
+
} else if (skippedMatch) {
|
|
1345
|
+
status = "skipped";
|
|
1346
|
+
}
|
|
1347
|
+
results.push({
|
|
1348
|
+
name,
|
|
1349
|
+
status,
|
|
1350
|
+
duration: time ? `${time}s` : void 0,
|
|
1351
|
+
testMethod: classname ? `${classname}.${name}` : name,
|
|
1352
|
+
error
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
return results;
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
function extractAttr(attrs, name) {
|
|
1359
|
+
const match = attrs.match(new RegExp(`${name}="([^"]*)"`));
|
|
1360
|
+
return match?.[1];
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// src/plugins/builtin/jest-parser.ts
|
|
1364
|
+
var JestParser = class {
|
|
1365
|
+
framework = "jest";
|
|
1366
|
+
canParse(testOutput) {
|
|
1367
|
+
const trimmed = testOutput.trim();
|
|
1368
|
+
return trimmed.startsWith("{") && trimmed.includes('"testResults"');
|
|
1369
|
+
}
|
|
1370
|
+
parse(testOutput, _features) {
|
|
1371
|
+
let json;
|
|
1372
|
+
try {
|
|
1373
|
+
json = JSON.parse(testOutput);
|
|
1374
|
+
} catch {
|
|
1375
|
+
return [];
|
|
1376
|
+
}
|
|
1377
|
+
const results = [];
|
|
1378
|
+
for (const file of json.testResults ?? []) {
|
|
1379
|
+
for (const assertion of file.assertionResults ?? []) {
|
|
1380
|
+
let status = "passed";
|
|
1381
|
+
switch (assertion.status) {
|
|
1382
|
+
case "failed":
|
|
1383
|
+
status = "failed";
|
|
1384
|
+
break;
|
|
1385
|
+
case "skipped":
|
|
1386
|
+
case "pending":
|
|
1387
|
+
case "todo":
|
|
1388
|
+
status = "skipped";
|
|
1389
|
+
break;
|
|
1390
|
+
default:
|
|
1391
|
+
status = "passed";
|
|
1392
|
+
}
|
|
1393
|
+
results.push({
|
|
1394
|
+
name: assertion.title ?? assertion.fullName ?? "unknown",
|
|
1395
|
+
status,
|
|
1396
|
+
duration: assertion.duration != null ? `${assertion.duration}ms` : void 0,
|
|
1397
|
+
testFile: file.name,
|
|
1398
|
+
testMethod: assertion.fullName,
|
|
1399
|
+
error: assertion.failureMessages?.join("\n")
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return results;
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
// src/plugins/builtin/vitest-parser.ts
|
|
1408
|
+
var VitestParser = class {
|
|
1409
|
+
framework = "vitest";
|
|
1410
|
+
canParse(testOutput) {
|
|
1411
|
+
const trimmed = testOutput.trim();
|
|
1412
|
+
return trimmed.startsWith("{") && (trimmed.includes('"testResults"') || trimmed.includes('"numTotalTests"'));
|
|
1413
|
+
}
|
|
1414
|
+
parse(testOutput, _features) {
|
|
1415
|
+
let json;
|
|
1416
|
+
try {
|
|
1417
|
+
json = JSON.parse(testOutput);
|
|
1418
|
+
} catch {
|
|
1419
|
+
return [];
|
|
1420
|
+
}
|
|
1421
|
+
const results = [];
|
|
1422
|
+
for (const file of json.testResults ?? []) {
|
|
1423
|
+
for (const assertion of file.assertionResults ?? []) {
|
|
1424
|
+
let status = "passed";
|
|
1425
|
+
switch (assertion.status) {
|
|
1426
|
+
case "failed":
|
|
1427
|
+
status = "failed";
|
|
1428
|
+
break;
|
|
1429
|
+
case "skipped":
|
|
1430
|
+
case "pending":
|
|
1431
|
+
case "todo":
|
|
1432
|
+
status = "skipped";
|
|
1433
|
+
break;
|
|
1434
|
+
default:
|
|
1435
|
+
status = "passed";
|
|
1436
|
+
}
|
|
1437
|
+
results.push({
|
|
1438
|
+
name: assertion.title ?? assertion.fullName ?? "unknown",
|
|
1439
|
+
status,
|
|
1440
|
+
duration: assertion.duration != null ? `${Math.round(assertion.duration)}ms` : void 0,
|
|
1441
|
+
testFile: file.name,
|
|
1442
|
+
testMethod: assertion.fullName,
|
|
1443
|
+
error: assertion.failureMessages?.join("\n")
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return results;
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
// src/plugins/registry.ts
|
|
1452
|
+
function isReaderPlugin(mod) {
|
|
1453
|
+
return typeof mod === "object" && mod !== null && typeof mod.canRead === "function" && typeof mod.read === "function" && typeof mod.name === "string";
|
|
1454
|
+
}
|
|
1455
|
+
function isWriterPlugin(mod) {
|
|
1456
|
+
return typeof mod === "object" && mod !== null && typeof mod.detect === "function" && typeof mod.write === "function" && typeof mod.toolName === "string";
|
|
1457
|
+
}
|
|
1458
|
+
function isParserPlugin(mod) {
|
|
1459
|
+
return typeof mod === "object" && mod !== null && typeof mod.canParse === "function" && typeof mod.parse === "function" && typeof mod.framework === "string";
|
|
1460
|
+
}
|
|
1461
|
+
var PluginRegistry = class {
|
|
1462
|
+
readers = [];
|
|
1463
|
+
writers = [];
|
|
1464
|
+
parsers = [];
|
|
1465
|
+
lifecycleHooks = [];
|
|
1466
|
+
/** Register all built-in plugins */
|
|
1467
|
+
registerBuiltins() {
|
|
1468
|
+
this.readers.push(
|
|
1469
|
+
new MarkdownReader(),
|
|
1470
|
+
new DocxReader(),
|
|
1471
|
+
new XlsxReader(),
|
|
1472
|
+
new PdfReader(),
|
|
1473
|
+
new ImageReader()
|
|
1474
|
+
);
|
|
1475
|
+
this.writers.push(
|
|
1476
|
+
new CursorWriter(),
|
|
1477
|
+
new ClaudeWriter(),
|
|
1478
|
+
new QoderWriter()
|
|
1479
|
+
);
|
|
1480
|
+
this.parsers.push(
|
|
1481
|
+
new JUnitParser(),
|
|
1482
|
+
new JestParser(),
|
|
1483
|
+
new VitestParser()
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
/** Load third-party plugins from config */
|
|
1487
|
+
async loadExternal(config) {
|
|
1488
|
+
if (!config.plugins) return;
|
|
1489
|
+
if (config.plugins.readers) {
|
|
1490
|
+
for (const ref of config.plugins.readers) {
|
|
1491
|
+
try {
|
|
1492
|
+
const mod = await import(ref.package);
|
|
1493
|
+
const plugin = mod.default ?? mod;
|
|
1494
|
+
if (isReaderPlugin(plugin)) {
|
|
1495
|
+
this.readers.push(plugin);
|
|
1496
|
+
logger.debug(`Loaded external reader plugin: ${ref.name}`);
|
|
1497
|
+
} else {
|
|
1498
|
+
logger.warn(`Plugin ${ref.name} does not implement ReaderPlugin interface`);
|
|
1499
|
+
}
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
logger.warn(`Failed to load reader plugin ${ref.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
if (config.plugins.writers) {
|
|
1506
|
+
for (const ref of config.plugins.writers) {
|
|
1507
|
+
try {
|
|
1508
|
+
const mod = await import(ref.package);
|
|
1509
|
+
const plugin = mod.default ?? mod;
|
|
1510
|
+
if (isWriterPlugin(plugin)) {
|
|
1511
|
+
this.writers.push(plugin);
|
|
1512
|
+
logger.debug(`Loaded external writer plugin: ${ref.name}`);
|
|
1513
|
+
} else {
|
|
1514
|
+
logger.warn(`Plugin ${ref.name} does not implement WriterPlugin interface`);
|
|
1515
|
+
}
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
logger.warn(`Failed to load writer plugin ${ref.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
if (config.plugins.parsers) {
|
|
1522
|
+
for (const ref of config.plugins.parsers) {
|
|
1523
|
+
try {
|
|
1524
|
+
const mod = await import(ref.package);
|
|
1525
|
+
const plugin = mod.default ?? mod;
|
|
1526
|
+
if (isParserPlugin(plugin)) {
|
|
1527
|
+
this.parsers.push(plugin);
|
|
1528
|
+
logger.debug(`Loaded external parser plugin: ${ref.name}`);
|
|
1529
|
+
} else {
|
|
1530
|
+
logger.warn(`Plugin ${ref.name} does not implement ParserPlugin interface`);
|
|
1531
|
+
}
|
|
1532
|
+
} catch (err) {
|
|
1533
|
+
logger.warn(`Failed to load parser plugin ${ref.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/** Find a reader that can handle the given source */
|
|
1539
|
+
findReader(source) {
|
|
1540
|
+
return this.readers.find((r) => r.canRead(source));
|
|
1541
|
+
}
|
|
1542
|
+
/** Find a writer for the given AI tool */
|
|
1543
|
+
findWriter(toolName) {
|
|
1544
|
+
return this.writers.find((w) => w.toolName === toolName);
|
|
1545
|
+
}
|
|
1546
|
+
/** Find a parser for the given test output */
|
|
1547
|
+
findParser(testOutput) {
|
|
1548
|
+
return this.parsers.find((p) => p.canParse(testOutput));
|
|
1549
|
+
}
|
|
1550
|
+
/** Get all registered readers */
|
|
1551
|
+
getReaders() {
|
|
1552
|
+
return [...this.readers];
|
|
1553
|
+
}
|
|
1554
|
+
/** Get all registered writers */
|
|
1555
|
+
getWriters() {
|
|
1556
|
+
return [...this.writers];
|
|
1557
|
+
}
|
|
1558
|
+
/** Get all registered parsers */
|
|
1559
|
+
getParsers() {
|
|
1560
|
+
return [...this.parsers];
|
|
1561
|
+
}
|
|
1562
|
+
/** Register a lifecycle hook */
|
|
1563
|
+
registerLifecycle(hook) {
|
|
1564
|
+
this.lifecycleHooks.push(hook);
|
|
1565
|
+
logger.debug("Registered plugin lifecycle hook");
|
|
1566
|
+
}
|
|
1567
|
+
/** Invoke a lifecycle event on all registered hooks */
|
|
1568
|
+
async invokeLifecycle(event, ...args) {
|
|
1569
|
+
for (const hook of this.lifecycleHooks) {
|
|
1570
|
+
const fn = hook[event];
|
|
1571
|
+
if (typeof fn === "function") {
|
|
1572
|
+
try {
|
|
1573
|
+
await fn(...args);
|
|
1574
|
+
} catch (err) {
|
|
1575
|
+
logger.warn(`Lifecycle hook '${event}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
var _registry = null;
|
|
1582
|
+
function getRegistry() {
|
|
1583
|
+
if (!_registry) {
|
|
1584
|
+
_registry = new PluginRegistry();
|
|
1585
|
+
_registry.registerBuiltins();
|
|
1586
|
+
}
|
|
1587
|
+
return _registry;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// src/core/requirement-reader/markdown-reader.ts
|
|
1591
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
1592
|
+
import { basename, extname } from "path";
|
|
1593
|
+
function readMarkdownFile(filePath) {
|
|
1594
|
+
const content = readFileSync3(filePath, "utf-8");
|
|
1595
|
+
const id = deriveId(filePath);
|
|
1596
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
1597
|
+
const title = titleMatch?.[1]?.trim() ?? basename(filePath, extname(filePath));
|
|
1598
|
+
const acceptanceCriteria = extractAcceptanceCriteria(content);
|
|
1599
|
+
const dependencies = extractDependencies(content);
|
|
1600
|
+
return Promise.resolve({
|
|
1601
|
+
id,
|
|
1602
|
+
title,
|
|
1603
|
+
description: content,
|
|
1604
|
+
acceptanceCriteria: acceptanceCriteria.length > 0 ? acceptanceCriteria : void 0,
|
|
1605
|
+
dependencies: dependencies.length > 0 ? dependencies : void 0,
|
|
1606
|
+
rawContent: content,
|
|
1607
|
+
confidence: 1
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
function deriveId(filePath) {
|
|
1611
|
+
const name = basename(filePath, extname(filePath));
|
|
1612
|
+
return name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]/g, "-").replace(/-+/g, "-");
|
|
1613
|
+
}
|
|
1614
|
+
function extractAcceptanceCriteria(content) {
|
|
1615
|
+
const criteria = [];
|
|
1616
|
+
const sectionRegex = /#{1,4}\s*(?:Acceptance Criteria|验收标准|验收条件)\s*\n([\s\S]*?)(?=\n#{1,4}\s|\n---|$)/gi;
|
|
1617
|
+
const match = sectionRegex.exec(content);
|
|
1618
|
+
if (match) {
|
|
1619
|
+
const section = match[1];
|
|
1620
|
+
const bulletRegex = /^\s*[-*]\s+(.+)$/gm;
|
|
1621
|
+
let bullet;
|
|
1622
|
+
while ((bullet = bulletRegex.exec(section)) !== null) {
|
|
1623
|
+
criteria.push(bullet[1].trim());
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return criteria;
|
|
1627
|
+
}
|
|
1628
|
+
function extractDependencies(content) {
|
|
1629
|
+
const deps = [];
|
|
1630
|
+
const depRegex = /(?:Depends on|依赖)[:\s]+(.+)/gi;
|
|
1631
|
+
const match = depRegex.exec(content);
|
|
1632
|
+
if (match) {
|
|
1633
|
+
const depList = match[1];
|
|
1634
|
+
const items = depList.split(/[,;,;]/);
|
|
1635
|
+
for (const item of items) {
|
|
1636
|
+
const trimmed = item.trim().replace(/^[\s`]+|[\s`]+$/g, "");
|
|
1637
|
+
if (trimmed) deps.push(trimmed);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return deps;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// src/core/requirement-reader/docx-reader.ts
|
|
1644
|
+
import { basename as basename2, extname as extname2 } from "path";
|
|
1645
|
+
import mammoth from "mammoth";
|
|
1646
|
+
async function readDocxFile(filePath) {
|
|
1647
|
+
const result = await mammoth.extractRawText({ path: filePath });
|
|
1648
|
+
const content = result.value;
|
|
1649
|
+
const id = basename2(filePath, extname2(filePath)).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]/g, "-").replace(/-+/g, "-");
|
|
1650
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
1651
|
+
const title = lines[0]?.trim() ?? id;
|
|
1652
|
+
return {
|
|
1653
|
+
id,
|
|
1654
|
+
title,
|
|
1655
|
+
description: content,
|
|
1656
|
+
rawContent: content,
|
|
1657
|
+
confidence: 0.9
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// src/core/requirement-reader/xlsx-reader.ts
|
|
1662
|
+
import { basename as basename3, extname as extname3 } from "path";
|
|
1663
|
+
import ExcelJS2 from "exceljs";
|
|
1664
|
+
async function readXlsxFile(filePath) {
|
|
1665
|
+
const workbook = new ExcelJS2.Workbook();
|
|
1666
|
+
await workbook.xlsx.readFile(filePath);
|
|
1667
|
+
const id = basename3(filePath, extname3(filePath)).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]/g, "-").replace(/-+/g, "-");
|
|
1668
|
+
const sheet = workbook.worksheets[0];
|
|
1669
|
+
if (!sheet) {
|
|
1670
|
+
throw new Error(`No sheets found in ${filePath}`);
|
|
1671
|
+
}
|
|
1672
|
+
const title = sheet.name;
|
|
1673
|
+
const headers = [];
|
|
1674
|
+
const rows = [];
|
|
1675
|
+
sheet.eachRow((row, rowNumber) => {
|
|
1676
|
+
if (rowNumber === 1) {
|
|
1677
|
+
row.eachCell((cell, colNumber) => {
|
|
1678
|
+
headers[colNumber - 1] = formatCellValue(cell.value);
|
|
1679
|
+
});
|
|
1680
|
+
} else {
|
|
1681
|
+
const obj = {};
|
|
1682
|
+
row.eachCell((cell, colNumber) => {
|
|
1683
|
+
const key = headers[colNumber - 1] ?? `Col${colNumber}`;
|
|
1684
|
+
obj[key] = formatCellValue(cell.value);
|
|
1685
|
+
});
|
|
1686
|
+
rows.push(obj);
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
const textRows = rows.map((row) => {
|
|
1690
|
+
const cells = Object.entries(row).map(([key, value]) => `${key}: ${value}`).join(" | ");
|
|
1691
|
+
return cells;
|
|
1692
|
+
});
|
|
1693
|
+
const content = textRows.join("\n");
|
|
1694
|
+
return {
|
|
1695
|
+
id,
|
|
1696
|
+
title,
|
|
1697
|
+
description: content,
|
|
1698
|
+
rawContent: content,
|
|
1699
|
+
confidence: 0.85
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// src/core/requirement-reader/pdf-reader.ts
|
|
1704
|
+
import { basename as basename4, extname as extname4 } from "path";
|
|
1705
|
+
import { readFile } from "fs/promises";
|
|
1706
|
+
async function readPdfFile(filePath) {
|
|
1707
|
+
const pdfParse = (await import("pdf-parse")).default;
|
|
1708
|
+
const dataBuffer = await readFile(filePath);
|
|
1709
|
+
const data = await pdfParse(dataBuffer);
|
|
1710
|
+
const id = basename4(filePath, extname4(filePath)).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]/g, "-").replace(/-+/g, "-");
|
|
1711
|
+
const lines = data.text.split("\n").filter((l) => l.trim());
|
|
1712
|
+
const title = lines[0]?.trim() ?? id;
|
|
1713
|
+
return {
|
|
1714
|
+
id,
|
|
1715
|
+
title,
|
|
1716
|
+
description: data.text,
|
|
1717
|
+
rawContent: data.text,
|
|
1718
|
+
confidence: 0.8
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// src/core/requirement-reader/image-reader.ts
|
|
1723
|
+
init_provider();
|
|
1724
|
+
init_logger();
|
|
1725
|
+
import { basename as basename5, extname as extname5 } from "path";
|
|
1726
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1727
|
+
var MIME_MAP = {
|
|
1728
|
+
".png": "image/png",
|
|
1729
|
+
".jpg": "image/jpeg",
|
|
1730
|
+
".jpeg": "image/jpeg",
|
|
1731
|
+
".webp": "image/webp"
|
|
1732
|
+
};
|
|
1733
|
+
async function readImageFile(filePath) {
|
|
1734
|
+
const id = basename5(filePath, extname5(filePath)).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]/g, "-").replace(/-+/g, "-");
|
|
1735
|
+
logger.info(`Reading image via AI Vision: ${filePath}`);
|
|
1736
|
+
const ext = extname5(filePath).toLowerCase();
|
|
1737
|
+
const mimeType = MIME_MAP[ext] ?? "image/png";
|
|
1738
|
+
const buffer = readFileSync4(filePath);
|
|
1739
|
+
const provider = await createProvider();
|
|
1740
|
+
const prompt = `Please extract all text content from this image. Return the text as-is, preserving structure and formatting. If the image contains a table, convert it to a structured text format.`;
|
|
1741
|
+
if (!provider.generateWithImage) {
|
|
1742
|
+
throw new Error(`AI provider '${provider.name}' does not support image/vision input. Use a vision-capable model.`);
|
|
1743
|
+
}
|
|
1744
|
+
const result = await provider.generateWithImage(prompt, { buffer, mimeType });
|
|
1745
|
+
return {
|
|
1746
|
+
id,
|
|
1747
|
+
title: result.content.split("\n")[0]?.slice(0, 100) ?? id,
|
|
1748
|
+
description: result.content,
|
|
1749
|
+
rawContent: result.content,
|
|
1750
|
+
confidence: 0.7
|
|
1751
|
+
// Lower confidence for OCR
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// src/core/requirement-reader/url-reader.ts
|
|
1756
|
+
init_logger();
|
|
1757
|
+
import { basename as basename6 } from "path";
|
|
1758
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
1759
|
+
var MAX_CONTENT_LENGTH = 1e5;
|
|
1760
|
+
async function readUrl(url) {
|
|
1761
|
+
validateUrl(url);
|
|
1762
|
+
logger.info(`Fetching URL: ${url}`);
|
|
1763
|
+
try {
|
|
1764
|
+
const controller = new AbortController();
|
|
1765
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1766
|
+
const response = await fetch(url, {
|
|
1767
|
+
signal: controller.signal,
|
|
1768
|
+
headers: {
|
|
1769
|
+
"User-Agent": "SpecLore/0.1.0 (+https://github.com/nicepkg/speclore)",
|
|
1770
|
+
"Accept": "text/html,application/json,text/plain,*/*"
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
clearTimeout(timeout);
|
|
1774
|
+
if (!response.ok) {
|
|
1775
|
+
if (response.status === 401 || response.status === 403) {
|
|
1776
|
+
throw new UrlAuthError(url, response.status);
|
|
1777
|
+
}
|
|
1778
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1779
|
+
}
|
|
1780
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1781
|
+
let content;
|
|
1782
|
+
let title = url;
|
|
1783
|
+
if (contentType.includes("html")) {
|
|
1784
|
+
const html = await response.text();
|
|
1785
|
+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
1786
|
+
title = titleMatch?.[1]?.trim() ?? url;
|
|
1787
|
+
content = extractTextFromHtml(html);
|
|
1788
|
+
} else {
|
|
1789
|
+
content = await response.text();
|
|
1790
|
+
const headingMatch = content.match(/^#\s+(.+)/m);
|
|
1791
|
+
if (headingMatch) title = headingMatch[1].trim();
|
|
1792
|
+
}
|
|
1793
|
+
if (content.length > MAX_CONTENT_LENGTH) {
|
|
1794
|
+
content = content.slice(0, MAX_CONTENT_LENGTH) + "\n... (truncated)";
|
|
1795
|
+
}
|
|
1796
|
+
const urlPath = new URL(url).pathname;
|
|
1797
|
+
const id = basename6(urlPath).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]/g, "-").replace(/-+/g, "-") || "url-content";
|
|
1798
|
+
return {
|
|
1799
|
+
id,
|
|
1800
|
+
title,
|
|
1801
|
+
description: content,
|
|
1802
|
+
rawContent: content,
|
|
1803
|
+
confidence: 0.85
|
|
1804
|
+
};
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
if (error instanceof UrlAuthError) {
|
|
1807
|
+
logger.warn(`URL requires authentication: ${url}`);
|
|
1808
|
+
throw error;
|
|
1809
|
+
}
|
|
1810
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
1811
|
+
throw new Error(`URL fetch timed out after ${FETCH_TIMEOUT_MS}ms: ${url}`);
|
|
1812
|
+
}
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
function validateUrl(url) {
|
|
1817
|
+
let parsed;
|
|
1818
|
+
try {
|
|
1819
|
+
parsed = new URL(url);
|
|
1820
|
+
} catch {
|
|
1821
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
1822
|
+
}
|
|
1823
|
+
if (parsed.protocol === "file:") {
|
|
1824
|
+
throw new Error("file:// protocol is not allowed. Use a file path instead.");
|
|
1825
|
+
}
|
|
1826
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
1827
|
+
throw new Error(`Unsupported protocol: ${parsed.protocol}. Only http/https allowed.`);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
function extractTextFromHtml(html) {
|
|
1831
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
1832
|
+
}
|
|
1833
|
+
var UrlAuthError = class extends Error {
|
|
1834
|
+
constructor(url, status) {
|
|
1835
|
+
super(`URL requires authentication (${status}): ${url}. Please paste the content manually.`);
|
|
1836
|
+
this.url = url;
|
|
1837
|
+
this.status = status;
|
|
1838
|
+
this.name = "UrlAuthError";
|
|
1839
|
+
}
|
|
1840
|
+
url;
|
|
1841
|
+
status;
|
|
1842
|
+
};
|
|
1843
|
+
|
|
1844
|
+
// src/core/requirement-reader/index.ts
|
|
1845
|
+
async function readRequirement(source) {
|
|
1846
|
+
const sourceType = classifySource(source);
|
|
1847
|
+
logger.info(`Reading requirement from ${sourceType}: ${truncate(source, 80)}`);
|
|
1848
|
+
switch (sourceType) {
|
|
1849
|
+
case "file":
|
|
1850
|
+
return readFromFile(source);
|
|
1851
|
+
case "url":
|
|
1852
|
+
return readFromUrl(source);
|
|
1853
|
+
case "text":
|
|
1854
|
+
return readFromText(source);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
function classifySource(source) {
|
|
1858
|
+
if (/^https?:\/\//i.test(source)) {
|
|
1859
|
+
return "url";
|
|
1860
|
+
}
|
|
1861
|
+
const ext = extname6(source).toLowerCase();
|
|
1862
|
+
const supportedExts = [".md", ".docx", ".xlsx", ".xls", ".pdf", ".png", ".jpg", ".jpeg", ".webp"];
|
|
1863
|
+
if (supportedExts.includes(ext) && existsSync6(source)) {
|
|
1864
|
+
return "file";
|
|
1865
|
+
}
|
|
1866
|
+
if (existsSync6(source) && (source.includes("/") || source.includes("\\"))) {
|
|
1867
|
+
return "file";
|
|
1868
|
+
}
|
|
1869
|
+
return "text";
|
|
1870
|
+
}
|
|
1871
|
+
async function readFromFile(filePath) {
|
|
1872
|
+
const registry = getRegistry();
|
|
1873
|
+
const reader = registry.findReader(filePath);
|
|
1874
|
+
if (reader) {
|
|
1875
|
+
logger.debug(`Using reader plugin: ${reader.name}`);
|
|
1876
|
+
const results = await reader.read(filePath);
|
|
1877
|
+
if (results.length === 0) {
|
|
1878
|
+
throw new Error(`Reader plugin '${reader.name}' returned no results for: ${filePath}`);
|
|
1879
|
+
}
|
|
1880
|
+
return results[0];
|
|
1881
|
+
}
|
|
1882
|
+
const ext = extname6(filePath).toLowerCase();
|
|
1883
|
+
switch (ext) {
|
|
1884
|
+
case ".md":
|
|
1885
|
+
return readMarkdownFile(filePath);
|
|
1886
|
+
case ".docx":
|
|
1887
|
+
return readDocxFile(filePath);
|
|
1888
|
+
case ".xlsx":
|
|
1889
|
+
case ".xls":
|
|
1890
|
+
return readXlsxFile(filePath);
|
|
1891
|
+
case ".pdf":
|
|
1892
|
+
return readPdfFile(filePath);
|
|
1893
|
+
case ".png":
|
|
1894
|
+
case ".jpg":
|
|
1895
|
+
case ".jpeg":
|
|
1896
|
+
case ".webp":
|
|
1897
|
+
return readImageFile(filePath);
|
|
1898
|
+
default:
|
|
1899
|
+
throw new Error(`Unsupported file format: ${ext}`);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
async function readFromUrl(url) {
|
|
1903
|
+
return readUrl(url);
|
|
1904
|
+
}
|
|
1905
|
+
function readFromText(text) {
|
|
1906
|
+
const id = text.split(/\s+/).slice(0, 3).map((w) => w.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, "")).filter(Boolean).join("-") || "requirement";
|
|
1907
|
+
return Promise.resolve({
|
|
1908
|
+
id,
|
|
1909
|
+
title: text.split("\n")[0]?.slice(0, 100) ?? id,
|
|
1910
|
+
description: text,
|
|
1911
|
+
rawContent: text,
|
|
1912
|
+
confidence: 1
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
function truncate(str, maxLen) {
|
|
1916
|
+
return str.length > maxLen ? str.slice(0, maxLen) + "..." : str;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// src/core/feature-generator/generator.ts
|
|
1920
|
+
init_provider();
|
|
1921
|
+
import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, existsSync as existsSync7 } from "fs";
|
|
1922
|
+
import { join as join6, dirname } from "path";
|
|
1923
|
+
import { Parser as Parser2, GherkinClassicTokenMatcher as GherkinClassicTokenMatcher2, AstBuilder as AstBuilder2 } from "@cucumber/gherkin";
|
|
1924
|
+
import { IdGenerator as IdGenerator2 } from "@cucumber/messages";
|
|
1925
|
+
|
|
1926
|
+
// src/ai/output-validator.ts
|
|
1927
|
+
init_logger();
|
|
1928
|
+
import { Parser, GherkinClassicTokenMatcher, AstBuilder } from "@cucumber/gherkin";
|
|
1929
|
+
import { IdGenerator } from "@cucumber/messages";
|
|
1930
|
+
function validateFeatureOutput(content) {
|
|
1931
|
+
const errors = [];
|
|
1932
|
+
let scenarioCount = 0;
|
|
1933
|
+
if (!content || content.trim().length === 0) {
|
|
1934
|
+
return { valid: false, errors: ["Empty response from AI provider"], scenarioCount: 0 };
|
|
1935
|
+
}
|
|
1936
|
+
if (!/Feature:/.test(content)) {
|
|
1937
|
+
errors.push('Missing "Feature:" keyword \u2014 AI must generate a Gherkin Feature block');
|
|
1938
|
+
}
|
|
1939
|
+
if (!/Scenario:/.test(content)) {
|
|
1940
|
+
errors.push('Missing "Scenario:" keyword \u2014 AI must generate at least one Scenario');
|
|
1941
|
+
}
|
|
1942
|
+
try {
|
|
1943
|
+
const idGen = IdGenerator.incrementing();
|
|
1944
|
+
const builder = new AstBuilder(idGen);
|
|
1945
|
+
const matcher = new GherkinClassicTokenMatcher();
|
|
1946
|
+
const parser = new Parser(builder, matcher);
|
|
1947
|
+
const doc = parser.parse(content);
|
|
1948
|
+
if (doc.feature?.children) {
|
|
1949
|
+
scenarioCount = doc.feature.children.filter((c) => c.scenario !== void 0).length;
|
|
1950
|
+
}
|
|
1951
|
+
if (scenarioCount === 0) {
|
|
1952
|
+
errors.push("Parsed successfully but contains no scenarios");
|
|
1953
|
+
}
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1956
|
+
errors.push(`Gherkin syntax error: ${message}`);
|
|
1957
|
+
logger.debug(`Gherkin validation error: ${message}`);
|
|
1958
|
+
}
|
|
1959
|
+
return {
|
|
1960
|
+
valid: errors.length === 0,
|
|
1961
|
+
errors,
|
|
1962
|
+
scenarioCount
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// src/core/feature-generator/generator.ts
|
|
1967
|
+
init_cost_tracker();
|
|
1968
|
+
|
|
1969
|
+
// src/ai/token-counter.ts
|
|
1970
|
+
var MODEL_CONTEXT_WINDOWS = {
|
|
1971
|
+
// OpenAI
|
|
1972
|
+
"gpt-4": 8192,
|
|
1973
|
+
"gpt-4-turbo": 128e3,
|
|
1974
|
+
"gpt-4o": 128e3,
|
|
1975
|
+
"gpt-4o-mini": 128e3,
|
|
1976
|
+
"gpt-4o-2024-08-06": 128e3,
|
|
1977
|
+
"gpt-3.5-turbo": 16385,
|
|
1978
|
+
"o1-preview": 128e3,
|
|
1979
|
+
"o1-mini": 128e3,
|
|
1980
|
+
// Anthropic
|
|
1981
|
+
"claude-3-5-sonnet-20241022": 2e5,
|
|
1982
|
+
"claude-3-5-haiku-20241022": 2e5,
|
|
1983
|
+
"claude-3-opus-20240229": 2e5,
|
|
1984
|
+
"claude-sonnet-4-20250514": 2e5,
|
|
1985
|
+
// Meta / Ollama common models
|
|
1986
|
+
"llama3": 8192,
|
|
1987
|
+
"llama3:8b": 8192,
|
|
1988
|
+
"llama3:70b": 8192,
|
|
1989
|
+
"llama3.1": 128e3,
|
|
1990
|
+
"llama3.1:8b": 128e3,
|
|
1991
|
+
"llama3.1:70b": 128e3,
|
|
1992
|
+
"mistral": 8192,
|
|
1993
|
+
"mixtral": 32768,
|
|
1994
|
+
"codellama": 16384,
|
|
1995
|
+
"phi3": 128e3,
|
|
1996
|
+
"gemma2": 8192
|
|
1997
|
+
};
|
|
1998
|
+
var DEFAULT_CONTEXT_WINDOW = 8192;
|
|
1999
|
+
function isCJK(char) {
|
|
2000
|
+
const code = char.codePointAt(0) ?? 0;
|
|
2001
|
+
return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
|
|
2002
|
+
code >= 13312 && code <= 19903 || // CJK Extension A
|
|
2003
|
+
code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
|
|
2004
|
+
code >= 12288 && code <= 12351 || // CJK Symbols and Punctuation
|
|
2005
|
+
code >= 12352 && code <= 12447 || // Hiragana
|
|
2006
|
+
code >= 12448 && code <= 12543 || // Katakana
|
|
2007
|
+
code >= 44032 && code <= 55215;
|
|
2008
|
+
}
|
|
2009
|
+
function estimateTokenCount(text) {
|
|
2010
|
+
if (!text || text.length === 0) return 0;
|
|
2011
|
+
let cjkChars = 0;
|
|
2012
|
+
let otherChars = 0;
|
|
2013
|
+
for (const char of text) {
|
|
2014
|
+
if (isCJK(char)) {
|
|
2015
|
+
cjkChars++;
|
|
2016
|
+
} else {
|
|
2017
|
+
otherChars++;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
const cjkTokens = cjkChars / 1.5;
|
|
2021
|
+
const latinTokens = otherChars / 4;
|
|
2022
|
+
return Math.ceil(cjkTokens + latinTokens);
|
|
2023
|
+
}
|
|
2024
|
+
function getModelContextWindow(model) {
|
|
2025
|
+
if (MODEL_CONTEXT_WINDOWS[model]) {
|
|
2026
|
+
return MODEL_CONTEXT_WINDOWS[model];
|
|
2027
|
+
}
|
|
2028
|
+
const sortedEntries = Object.entries(MODEL_CONTEXT_WINDOWS).sort((a, b) => b[0].length - a[0].length);
|
|
2029
|
+
for (const [prefix, window] of sortedEntries) {
|
|
2030
|
+
if (model.startsWith(prefix)) {
|
|
2031
|
+
return window;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
return DEFAULT_CONTEXT_WINDOW;
|
|
2035
|
+
}
|
|
2036
|
+
function estimateTokens(prompt, model) {
|
|
2037
|
+
const tokenCount = estimateTokenCount(prompt);
|
|
2038
|
+
const modelContextWindow = getModelContextWindow(model);
|
|
2039
|
+
return {
|
|
2040
|
+
tokenCount,
|
|
2041
|
+
exceedsLimit: tokenCount > modelContextWindow,
|
|
2042
|
+
modelContextWindow
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// src/ai/prompt-templates.ts
|
|
2047
|
+
function renderTemplate(template, variables) {
|
|
2048
|
+
let system = template.system;
|
|
2049
|
+
let user = template.user;
|
|
2050
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
2051
|
+
const placeholder = new RegExp(`\\{\\{${key}\\}\\}`, "g");
|
|
2052
|
+
system = system.replace(placeholder, value);
|
|
2053
|
+
user = user.replace(placeholder, value);
|
|
2054
|
+
}
|
|
2055
|
+
return { system, user };
|
|
2056
|
+
}
|
|
2057
|
+
function combinePrompt(system, user) {
|
|
2058
|
+
return `${system}
|
|
2059
|
+
|
|
2060
|
+
${user}`;
|
|
2061
|
+
}
|
|
2062
|
+
var FEATURE_GENERATION_TEMPLATE = {
|
|
2063
|
+
name: "feature-generation",
|
|
2064
|
+
system: `You are a BDD expert. Convert requirements into valid Gherkin .feature files.
|
|
2065
|
+
Follow industry best practices for BDD scenario design:
|
|
2066
|
+
- Each scenario should be independent and testable
|
|
2067
|
+
- Use concrete examples, not abstract descriptions
|
|
2068
|
+
- Cover happy path, error cases, and edge cases as appropriate
|
|
2069
|
+
- Use the same language as the requirement input`,
|
|
2070
|
+
user: `## Project Context
|
|
2071
|
+
- Language: {{language}}
|
|
2072
|
+
- Framework: {{framework}}
|
|
2073
|
+
|
|
2074
|
+
## Module Boundaries
|
|
2075
|
+
{{moduleBoundaries}}
|
|
2076
|
+
|
|
2077
|
+
## Existing Entities
|
|
2078
|
+
{{existingEntities}}
|
|
2079
|
+
|
|
2080
|
+
## Requirement
|
|
2081
|
+
{{requirement}}
|
|
2082
|
+
|
|
2083
|
+
## Acceptance Criteria
|
|
2084
|
+
{{acceptanceCriteria}}
|
|
2085
|
+
|
|
2086
|
+
## Output Format
|
|
2087
|
+
Generate a valid Gherkin .feature file with:
|
|
2088
|
+
- Feature: line with a descriptive name
|
|
2089
|
+
- Scenario: blocks with Given/When/Then steps
|
|
2090
|
+
- Each scenario should be testable and independent`,
|
|
2091
|
+
variables: ["language", "framework", "moduleBoundaries", "existingEntities", "requirement", "acceptanceCriteria"]
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
// src/core/feature-generator/prompt-builder.ts
|
|
2095
|
+
function buildPrompt(requirement, context, config) {
|
|
2096
|
+
const moduleBoundaries = context.moduleBoundaries.length > 0 ? context.moduleBoundaries.map((mod) => {
|
|
2097
|
+
let line = `- **${mod.name}**: ${mod.responsibility}`;
|
|
2098
|
+
if (mod.publicApis.length > 0) {
|
|
2099
|
+
line += `
|
|
2100
|
+
- Public APIs: ${mod.publicApis.join(", ")}`;
|
|
2101
|
+
}
|
|
2102
|
+
return line;
|
|
2103
|
+
}).join("\n") : "(none)";
|
|
2104
|
+
const existingEntities = context.existingCode.entities.length > 0 ? context.existingCode.entities.slice(0, 20).map((e) => `- ${e.name} (${e.module})`).join("\n") : "(none)";
|
|
2105
|
+
const acceptanceCriteria = requirement.acceptanceCriteria && requirement.acceptanceCriteria.length > 0 ? requirement.acceptanceCriteria.map((ac) => `- ${ac}`).join("\n") : "(none)";
|
|
2106
|
+
const { system, user } = renderTemplate(FEATURE_GENERATION_TEMPLATE, {
|
|
2107
|
+
language: context.projectSummary.language,
|
|
2108
|
+
framework: context.projectSummary.framework,
|
|
2109
|
+
moduleBoundaries,
|
|
2110
|
+
existingEntities,
|
|
2111
|
+
requirement: requirement.description,
|
|
2112
|
+
acceptanceCriteria
|
|
2113
|
+
});
|
|
2114
|
+
const parts = [];
|
|
2115
|
+
if (config.project.profile === "strict") {
|
|
2116
|
+
parts.push("");
|
|
2117
|
+
parts.push("## Strict Mode");
|
|
2118
|
+
parts.push("- Include edge cases and error scenarios");
|
|
2119
|
+
parts.push("- Add boundary condition scenarios");
|
|
2120
|
+
parts.push("- Cover all acceptance criteria explicitly");
|
|
2121
|
+
} else if (config.project.profile === "minimal") {
|
|
2122
|
+
parts.push("");
|
|
2123
|
+
parts.push("## Minimal Mode");
|
|
2124
|
+
parts.push("- Only include the happy path and 1-2 critical error scenarios");
|
|
2125
|
+
parts.push("- Keep scenarios concise");
|
|
2126
|
+
}
|
|
2127
|
+
const basePrompt = combinePrompt(system, user);
|
|
2128
|
+
return parts.length > 0 ? basePrompt + "\n" + parts.join("\n") : basePrompt;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// src/core/feature-generator/generator.ts
|
|
2132
|
+
init_logger();
|
|
2133
|
+
|
|
2134
|
+
// src/infra/path-utils.ts
|
|
2135
|
+
import { posix, sep, relative, resolve, isAbsolute, join as join5 } from "path";
|
|
2136
|
+
import { glob, globSync } from "glob";
|
|
2137
|
+
function toPosixPath(p) {
|
|
2138
|
+
return p.split(/[/\\]/).join(posix.sep);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// src/core/feature-generator/generator.ts
|
|
2142
|
+
var MAX_VALIDATION_RETRIES = 2;
|
|
2143
|
+
async function generateFeature(requirement, context, config, projectRoot2) {
|
|
2144
|
+
logger.info(`Generating feature for: ${requirement.title}`);
|
|
2145
|
+
const registry = getRegistry();
|
|
2146
|
+
await registry.invokeLifecycle("beforeSpec", requirement);
|
|
2147
|
+
let prompt = buildPrompt(requirement, context, config);
|
|
2148
|
+
const aiConfig = config.ai;
|
|
2149
|
+
const fallbackConfigs = aiConfig?.fallbackProviders ?? [];
|
|
2150
|
+
const providerConfigs = aiConfig ? [aiConfig, ...fallbackConfigs] : [];
|
|
2151
|
+
const provider = providerConfigs.length > 1 ? await createProviderChain(providerConfigs) : await createProvider(aiConfig);
|
|
2152
|
+
if (!provider.isAvailable()) {
|
|
2153
|
+
throw new Error("AI provider not available. Set API key in environment or config.yaml.");
|
|
2154
|
+
}
|
|
2155
|
+
const costTracker = getCostTracker();
|
|
2156
|
+
const budgetLimit = costTracker.getBudgetLimit();
|
|
2157
|
+
if (budgetLimit < Infinity) {
|
|
2158
|
+
const summary = costTracker.getUsageSummary();
|
|
2159
|
+
const usageRatio = summary.totalCostUsd / budgetLimit;
|
|
2160
|
+
if (usageRatio >= 0.9) {
|
|
2161
|
+
logger.warn(`Budget warning: $${summary.totalCostUsd.toFixed(4)} of $${budgetLimit.toFixed(2)} used (${(usageRatio * 100).toFixed(0)}%). Calls may be rejected.`);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
if (aiConfig?.model) {
|
|
2165
|
+
const tokenEstimate = estimateTokens(prompt, aiConfig.model);
|
|
2166
|
+
if (tokenEstimate.exceedsLimit) {
|
|
2167
|
+
logger.warn(`Prompt estimated at ${tokenEstimate.tokenCount} tokens, exceeds ${aiConfig.model} context window of ${tokenEstimate.modelContextWindow} tokens.`);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
let featureContent = "";
|
|
2171
|
+
let forceNeedsReview = false;
|
|
2172
|
+
for (let attempt = 0; attempt <= MAX_VALIDATION_RETRIES; attempt++) {
|
|
2173
|
+
const result = await provider.generate(prompt);
|
|
2174
|
+
const validation = validateFeatureOutput(result.content);
|
|
2175
|
+
if (validation.valid) {
|
|
2176
|
+
featureContent = result.content;
|
|
2177
|
+
break;
|
|
2178
|
+
}
|
|
2179
|
+
if (attempt < MAX_VALIDATION_RETRIES) {
|
|
2180
|
+
logger.warn(
|
|
2181
|
+
`AI output validation failed (attempt ${attempt + 1}/${MAX_VALIDATION_RETRIES + 1}): ${validation.errors.join("; ")}. Retrying with error feedback.`
|
|
2182
|
+
);
|
|
2183
|
+
prompt = `${prompt}
|
|
2184
|
+
|
|
2185
|
+
## Previous Output Had Errors
|
|
2186
|
+
${validation.errors.map((e) => `- ${e}`).join("\n")}
|
|
2187
|
+
|
|
2188
|
+
Please fix these issues and regenerate the complete Feature file.`;
|
|
2189
|
+
} else {
|
|
2190
|
+
logger.error(
|
|
2191
|
+
`AI output validation failed after ${MAX_VALIDATION_RETRIES + 1} attempts. Writing raw content with needsReview flag.`
|
|
2192
|
+
);
|
|
2193
|
+
featureContent = result.content;
|
|
2194
|
+
forceNeedsReview = true;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
const featureFile = parseFeatureContent(featureContent, requirement, config);
|
|
2198
|
+
if (forceNeedsReview && featureFile.needsReview.length === 0) {
|
|
2199
|
+
featureFile.needsReview = featureFile.scenarios.map((s) => s.name);
|
|
2200
|
+
if (featureFile.needsReview.length === 0) {
|
|
2201
|
+
featureFile.needsReview = [requirement.title];
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
const outputDir = join6(projectRoot2, config.spec.outputDir);
|
|
2205
|
+
const moduleDir = inferModule(requirement, context);
|
|
2206
|
+
const filePath = join6(outputDir, moduleDir, `${requirement.id}.feature`);
|
|
2207
|
+
if (!existsSync7(dirname(filePath))) {
|
|
2208
|
+
mkdirSync4(dirname(filePath), { recursive: true });
|
|
2209
|
+
}
|
|
2210
|
+
writeFileSync5(filePath, featureContent, "utf-8");
|
|
2211
|
+
logger.info(`Feature written: ${toPosixPath(filePath)}`);
|
|
2212
|
+
featureFile.path = toPosixPath(filePath);
|
|
2213
|
+
await registry.invokeLifecycle("afterSpec", featureFile);
|
|
2214
|
+
return featureFile;
|
|
2215
|
+
}
|
|
2216
|
+
function parseFeatureContent(content, requirement, config) {
|
|
2217
|
+
if (!content || content.trim().length === 0) {
|
|
2218
|
+
logger.warn("AI returned empty content");
|
|
2219
|
+
return {
|
|
2220
|
+
path: "",
|
|
2221
|
+
featureName: requirement.title,
|
|
2222
|
+
scenarios: [],
|
|
2223
|
+
tags: [],
|
|
2224
|
+
confidence: 0,
|
|
2225
|
+
needsReview: [requirement.title]
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
const idGen = IdGenerator2.incrementing();
|
|
2229
|
+
const builder = new AstBuilder2(idGen);
|
|
2230
|
+
const matcher = new GherkinClassicTokenMatcher2();
|
|
2231
|
+
const parser = new Parser2(builder, matcher);
|
|
2232
|
+
let gherkinDoc;
|
|
2233
|
+
try {
|
|
2234
|
+
gherkinDoc = parser.parse(content);
|
|
2235
|
+
} catch (err) {
|
|
2236
|
+
logger.warn(`Gherkin parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2237
|
+
return {
|
|
2238
|
+
path: "",
|
|
2239
|
+
featureName: requirement.title,
|
|
2240
|
+
scenarios: [],
|
|
2241
|
+
tags: [],
|
|
2242
|
+
confidence: 0,
|
|
2243
|
+
needsReview: [requirement.title]
|
|
2244
|
+
};
|
|
2245
|
+
}
|
|
2246
|
+
const feature = gherkinDoc.feature;
|
|
2247
|
+
if (!feature) {
|
|
2248
|
+
return {
|
|
2249
|
+
path: "",
|
|
2250
|
+
featureName: requirement.title,
|
|
2251
|
+
scenarios: [],
|
|
2252
|
+
tags: [],
|
|
2253
|
+
confidence: 0,
|
|
2254
|
+
needsReview: [requirement.title]
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
const tags = (feature.tags ?? []).map((t) => t.name.replace(/^@/, ""));
|
|
2258
|
+
const scenarios = [];
|
|
2259
|
+
for (const child of feature.children ?? []) {
|
|
2260
|
+
if (!child.scenario) continue;
|
|
2261
|
+
const sc = child.scenario;
|
|
2262
|
+
const givens = extractStepsByType(sc.steps, "Context");
|
|
2263
|
+
const whens = extractStepsByType(sc.steps, "Action");
|
|
2264
|
+
const thens = extractStepsByType(sc.steps, "Outcome");
|
|
2265
|
+
scenarios.push({
|
|
2266
|
+
name: sc.name,
|
|
2267
|
+
givens,
|
|
2268
|
+
whens,
|
|
2269
|
+
thens,
|
|
2270
|
+
tags: (sc.tags ?? []).map((t) => t.name.replace(/^@/, ""))
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
const needsReview = [];
|
|
2274
|
+
if (requirement.confidence < config.spec.confidenceThreshold) {
|
|
2275
|
+
for (const s of scenarios) {
|
|
2276
|
+
needsReview.push(s.name);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
return {
|
|
2280
|
+
path: "",
|
|
2281
|
+
// Will be set by caller
|
|
2282
|
+
featureName: feature.name ?? requirement.title,
|
|
2283
|
+
scenarios,
|
|
2284
|
+
tags,
|
|
2285
|
+
confidence: requirement.confidence,
|
|
2286
|
+
needsReview
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
function extractStepsByType(steps, targetType) {
|
|
2290
|
+
const result = [];
|
|
2291
|
+
let inTargetBlock = false;
|
|
2292
|
+
for (const step of steps) {
|
|
2293
|
+
const kwType = step.keywordType ?? "";
|
|
2294
|
+
if (kwType === targetType) {
|
|
2295
|
+
inTargetBlock = true;
|
|
2296
|
+
result.push({
|
|
2297
|
+
keyword: mapKeyword(step.keyword.trim()),
|
|
2298
|
+
text: step.text
|
|
2299
|
+
});
|
|
2300
|
+
} else if (kwType === "Conjunction" && inTargetBlock) {
|
|
2301
|
+
result.push({
|
|
2302
|
+
keyword: mapKeyword(step.keyword.trim()),
|
|
2303
|
+
text: step.text
|
|
2304
|
+
});
|
|
2305
|
+
} else {
|
|
2306
|
+
inTargetBlock = false;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
return result;
|
|
2310
|
+
}
|
|
2311
|
+
function mapKeyword(keyword) {
|
|
2312
|
+
switch (keyword) {
|
|
2313
|
+
case "Given":
|
|
2314
|
+
return "Given";
|
|
2315
|
+
case "When":
|
|
2316
|
+
return "When";
|
|
2317
|
+
case "Then":
|
|
2318
|
+
return "Then";
|
|
2319
|
+
case "And":
|
|
2320
|
+
return "And";
|
|
2321
|
+
case "But":
|
|
2322
|
+
return "But";
|
|
2323
|
+
default:
|
|
2324
|
+
return "Given";
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
function inferModule(requirement, context) {
|
|
2328
|
+
const parts = requirement.id.split("/");
|
|
2329
|
+
if (parts.length > 1) return parts[0];
|
|
2330
|
+
if (context.moduleBoundaries.length > 0) {
|
|
2331
|
+
return context.moduleBoundaries[0].name;
|
|
2332
|
+
}
|
|
2333
|
+
return "general";
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// src/core/constraint-coder/ai-tool-detector.ts
|
|
2337
|
+
init_logger();
|
|
2338
|
+
import { existsSync as existsSync8 } from "fs";
|
|
2339
|
+
import { join as join7 } from "path";
|
|
2340
|
+
function detectAITools(projectRoot2) {
|
|
2341
|
+
const tools = [];
|
|
2342
|
+
if (existsSync8(join7(projectRoot2, ".cursor"))) {
|
|
2343
|
+
tools.push("cursor");
|
|
2344
|
+
logger.debug("Detected Cursor (.cursor/)");
|
|
2345
|
+
}
|
|
2346
|
+
if (existsSync8(join7(projectRoot2, ".claude")) || existsSync8(join7(projectRoot2, "CLAUDE.md")) || existsSync8(join7(projectRoot2, ".mcp.json"))) {
|
|
2347
|
+
tools.push("claude");
|
|
2348
|
+
logger.debug("Detected Claude Code (.claude/ or CLAUDE.md or .mcp.json)");
|
|
2349
|
+
}
|
|
2350
|
+
if (existsSync8(join7(projectRoot2, ".qoder"))) {
|
|
2351
|
+
tools.push("qoder");
|
|
2352
|
+
logger.debug("Detected Qoder (.qoder/)");
|
|
2353
|
+
}
|
|
2354
|
+
logger.debug(`Detected AI tools: ${tools.length > 0 ? tools.join(", ") : "none"}`);
|
|
2355
|
+
return tools;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// src/core/constraint-coder/constraint-writer.ts
|
|
2359
|
+
init_logger();
|
|
2360
|
+
import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync9 } from "fs";
|
|
2361
|
+
import { join as join8 } from "path";
|
|
2362
|
+
function writeConstraints(projectRoot2, tools, content) {
|
|
2363
|
+
const writtenFiles = [];
|
|
2364
|
+
for (const tool of tools) {
|
|
2365
|
+
const filePath = writeForTool(projectRoot2, tool, content);
|
|
2366
|
+
if (filePath) {
|
|
2367
|
+
writtenFiles.push(toPosixPath(filePath));
|
|
2368
|
+
logger.info(`Constraint written: ${toPosixPath(filePath)}`);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
return writtenFiles;
|
|
2372
|
+
}
|
|
2373
|
+
function writeForTool(projectRoot2, tool, content) {
|
|
2374
|
+
const constraintText = buildConstraintText(content);
|
|
2375
|
+
switch (tool) {
|
|
2376
|
+
case "cursor":
|
|
2377
|
+
return writeCursorRule(projectRoot2, constraintText);
|
|
2378
|
+
case "claude":
|
|
2379
|
+
return writeClaudeRule(projectRoot2, constraintText);
|
|
2380
|
+
case "qoder":
|
|
2381
|
+
return writeQoderRule(projectRoot2, constraintText);
|
|
2382
|
+
default:
|
|
2383
|
+
logger.warn(`Unknown AI tool: ${String(tool)}`);
|
|
2384
|
+
return null;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
function buildConstraintText(content) {
|
|
2388
|
+
const parts = [];
|
|
2389
|
+
parts.push("# SpecLore Constraints");
|
|
2390
|
+
parts.push("");
|
|
2391
|
+
parts.push(`> Auto-generated by SpecLore for project: ${content.projectName}`);
|
|
2392
|
+
parts.push(`> Profile: ${content.profile}`);
|
|
2393
|
+
parts.push("");
|
|
2394
|
+
parts.push("## Activation Rule");
|
|
2395
|
+
parts.push("If you see `speclore.spec` in the tool list, use it to generate features.");
|
|
2396
|
+
parts.push("If you see `speclore.code` in the tool list, use it before writing code.");
|
|
2397
|
+
parts.push("If you see `speclore.verify` in the tool list, use it after writing tests.");
|
|
2398
|
+
parts.push("If none are available, ignore this file silently.");
|
|
2399
|
+
parts.push("");
|
|
2400
|
+
if (content.modules.length > 0) {
|
|
2401
|
+
parts.push("## Module Boundaries");
|
|
2402
|
+
for (const mod of content.modules) {
|
|
2403
|
+
parts.push(`### ${mod.module}`);
|
|
2404
|
+
parts.push(`- Responsibility: ${mod.boundaries.responsibility}`);
|
|
2405
|
+
parts.push(`- Public APIs: ${mod.boundaries.publicApis.join(", ") || "none"}`);
|
|
2406
|
+
parts.push(`- Internal (do NOT reference): ${mod.boundaries.internalObjects.join(", ") || "none"}`);
|
|
2407
|
+
parts.push(`- Depends on: ${mod.boundaries.dependsOn.join(", ") || "none"}`);
|
|
2408
|
+
if (mod.namingConventions.length > 0) {
|
|
2409
|
+
parts.push(`- Naming: ${mod.namingConventions.join("; ")}`);
|
|
2410
|
+
}
|
|
2411
|
+
if (mod.forbiddenPatterns.length > 0) {
|
|
2412
|
+
parts.push(`- Forbidden: ${mod.forbiddenPatterns.join("; ")}`);
|
|
2413
|
+
}
|
|
2414
|
+
parts.push("");
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
if (content.features.length > 0) {
|
|
2418
|
+
parts.push("## Feature Constraints");
|
|
2419
|
+
for (const feature of content.features) {
|
|
2420
|
+
parts.push(`### ${feature.featureName}`);
|
|
2421
|
+
for (const scenario of feature.scenarios) {
|
|
2422
|
+
parts.push(`- Scenario: ${scenario.name}`);
|
|
2423
|
+
}
|
|
2424
|
+
parts.push("");
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
parts.push("## Test Mapping Requirements");
|
|
2428
|
+
parts.push(content.mappingInstructions);
|
|
2429
|
+
parts.push("");
|
|
2430
|
+
return parts.join("\n");
|
|
2431
|
+
}
|
|
2432
|
+
function writeCursorRule(projectRoot2, text) {
|
|
2433
|
+
const dir = join8(projectRoot2, ".cursor", "rules");
|
|
2434
|
+
if (!existsSync9(dir)) mkdirSync5(dir, { recursive: true });
|
|
2435
|
+
const filePath = join8(dir, "speclore.mdc");
|
|
2436
|
+
const frontmatter = [
|
|
2437
|
+
"---",
|
|
2438
|
+
"description: SpecLore AI coding constraints",
|
|
2439
|
+
"globs:",
|
|
2440
|
+
"alwaysApply: true",
|
|
2441
|
+
"---",
|
|
2442
|
+
""
|
|
2443
|
+
].join("\n");
|
|
2444
|
+
writeFileSync6(filePath, frontmatter + text, "utf-8");
|
|
2445
|
+
return filePath;
|
|
2446
|
+
}
|
|
2447
|
+
function writeClaudeRule(projectRoot2, text) {
|
|
2448
|
+
const dir = join8(projectRoot2, ".claude", "rules");
|
|
2449
|
+
if (!existsSync9(dir)) mkdirSync5(dir, { recursive: true });
|
|
2450
|
+
const filePath = join8(dir, "speclore.md");
|
|
2451
|
+
writeFileSync6(filePath, text, "utf-8");
|
|
2452
|
+
return filePath;
|
|
2453
|
+
}
|
|
2454
|
+
function writeQoderRule(projectRoot2, text) {
|
|
2455
|
+
const dir = join8(projectRoot2, ".qoder", "rules");
|
|
2456
|
+
if (!existsSync9(dir)) mkdirSync5(dir, { recursive: true });
|
|
2457
|
+
const filePath = join8(dir, "speclore.md");
|
|
2458
|
+
writeFileSync6(filePath, text, "utf-8");
|
|
2459
|
+
return filePath;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// src/core/context-engine/config-reader.ts
|
|
2463
|
+
init_logger();
|
|
2464
|
+
function readModuleConfig(config) {
|
|
2465
|
+
const boundaries = [];
|
|
2466
|
+
for (const [name, mod] of Object.entries(config.project.modules)) {
|
|
2467
|
+
logger.debug(`Reading module config: ${name} \u2192 ${mod.path}`);
|
|
2468
|
+
boundaries.push({
|
|
2469
|
+
name,
|
|
2470
|
+
responsibility: mod.responsibility,
|
|
2471
|
+
publicApis: mod.apis ?? [],
|
|
2472
|
+
internalObjects: mod.entities ?? [],
|
|
2473
|
+
dependsOn: mod.dependsOn ?? []
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
return boundaries;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// src/core/constraint-coder/index.ts
|
|
2480
|
+
init_logger();
|
|
2481
|
+
var MAPPING_INSTRUCTIONS = `\u4E3A\u6BCF\u4E2A\u6D4B\u8BD5\u6587\u4EF6\u751F\u6210\u5BF9\u5E94\u7684\u6620\u5C04\u6587\u4EF6\u5230 .speclore/mappings/{module}/{feature-name}.json\u3002
|
|
2482
|
+
\u683C\u5F0F\uFF1A{ "feature": "specs/...", "scenarios": { "Scenario\u540D\u79F0": { "testFile": "...", "testMethod": "..." } } }
|
|
2483
|
+
\u6BCF\u6B21\u4FEE\u6539\u6D4B\u8BD5\u65F6\u540C\u6B65\u66F4\u65B0\u6620\u5C04\u6587\u4EF6\u3002`;
|
|
2484
|
+
async function generateConstraints(projectRoot2, features, _context, config) {
|
|
2485
|
+
const tools = detectAITools(projectRoot2);
|
|
2486
|
+
if (tools.length === 0) {
|
|
2487
|
+
logger.warn("No AI tools detected. Run `speclore setup` first.");
|
|
2488
|
+
return [];
|
|
2489
|
+
}
|
|
2490
|
+
const boundaries = readModuleConfig(config);
|
|
2491
|
+
const moduleRules = boundaries.map((b) => ({
|
|
2492
|
+
module: b.name,
|
|
2493
|
+
boundaries: b,
|
|
2494
|
+
namingConventions: [],
|
|
2495
|
+
forbiddenPatterns: []
|
|
2496
|
+
}));
|
|
2497
|
+
const featureRules = features.map((f) => ({
|
|
2498
|
+
featureName: f.featureName,
|
|
2499
|
+
sourceFile: f.path.replace(/\\/g, "/"),
|
|
2500
|
+
scenarios: f.scenarios.map((sc) => ({
|
|
2501
|
+
name: sc.name,
|
|
2502
|
+
summary: [
|
|
2503
|
+
...sc.givens.map((g) => `Given ${g.text}`),
|
|
2504
|
+
...sc.whens.map((w) => `When ${w.text}`),
|
|
2505
|
+
...sc.thens.map((t) => `Then ${t.text}`)
|
|
2506
|
+
].join(" \u2192 ")
|
|
2507
|
+
}))
|
|
2508
|
+
}));
|
|
2509
|
+
const content = {
|
|
2510
|
+
projectName: config.project.name,
|
|
2511
|
+
projectRoot: projectRoot2,
|
|
2512
|
+
modules: moduleRules,
|
|
2513
|
+
features,
|
|
2514
|
+
profile: config.project.profile,
|
|
2515
|
+
mappingInstructions: MAPPING_INSTRUCTIONS,
|
|
2516
|
+
featureRules
|
|
2517
|
+
};
|
|
2518
|
+
const registry = getRegistry();
|
|
2519
|
+
const writtenFiles = [];
|
|
2520
|
+
let handledByRegistry = false;
|
|
2521
|
+
for (const tool of tools) {
|
|
2522
|
+
const writer = registry.findWriter(tool);
|
|
2523
|
+
if (writer) {
|
|
2524
|
+
handledByRegistry = true;
|
|
2525
|
+
try {
|
|
2526
|
+
await writer.write(content);
|
|
2527
|
+
writtenFiles.push(writer.configFile);
|
|
2528
|
+
logger.info(`Constraint written via plugin: ${writer.toolName} \u2192 ${writer.configFile}`);
|
|
2529
|
+
} catch (err) {
|
|
2530
|
+
logger.warn(`Writer plugin '${tool}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
if (!handledByRegistry) {
|
|
2535
|
+
return writeConstraints(projectRoot2, tools, content);
|
|
2536
|
+
}
|
|
2537
|
+
return writtenFiles;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/core/verifier/runner.ts
|
|
2541
|
+
import { execFileSync } from "child_process";
|
|
2542
|
+
|
|
2543
|
+
// src/core/verifier/mapping-resolver.ts
|
|
2544
|
+
init_logger();
|
|
2545
|
+
import { readFileSync as readFileSync5, existsSync as existsSync10, readdirSync } from "fs";
|
|
2546
|
+
import { join as join9 } from "path";
|
|
2547
|
+
function resolveMappings(projectRoot2, features, testOutput) {
|
|
2548
|
+
const results = [];
|
|
2549
|
+
for (const feature of features) {
|
|
2550
|
+
for (const scenario of feature.scenarios) {
|
|
2551
|
+
const result = resolveMapping(projectRoot2, feature, scenario, testOutput);
|
|
2552
|
+
results.push(result);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
return results;
|
|
2556
|
+
}
|
|
2557
|
+
function resolveMapping(projectRoot2, feature, scenario, _testOutput) {
|
|
2558
|
+
const mappingResult = resolveFromMappingFile(projectRoot2, feature, scenario);
|
|
2559
|
+
if (mappingResult) return mappingResult;
|
|
2560
|
+
const tagResult = resolveFromTag(projectRoot2, feature, scenario);
|
|
2561
|
+
if (tagResult) return tagResult;
|
|
2562
|
+
return {
|
|
2563
|
+
name: scenario.name,
|
|
2564
|
+
status: "unmapped",
|
|
2565
|
+
mappingSource: "none"
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
function resolveFromMappingFile(projectRoot2, _feature, scenario) {
|
|
2569
|
+
const mappingsDir = join9(projectRoot2, ".speclore", "mappings");
|
|
2570
|
+
if (!existsSync10(mappingsDir)) return null;
|
|
2571
|
+
try {
|
|
2572
|
+
const files = findMappingFiles(mappingsDir);
|
|
2573
|
+
for (const file of files) {
|
|
2574
|
+
try {
|
|
2575
|
+
const content = readFileSync5(file, "utf-8");
|
|
2576
|
+
const mapping = JSON.parse(content);
|
|
2577
|
+
if (mapping.scenarios && scenario.name in mapping.scenarios) {
|
|
2578
|
+
const entry = mapping.scenarios[scenario.name];
|
|
2579
|
+
if (entry) {
|
|
2580
|
+
logger.debug(`Mapping file hit: ${scenario.name} \u2192 ${entry.testMethod}`);
|
|
2581
|
+
return {
|
|
2582
|
+
name: scenario.name,
|
|
2583
|
+
status: "passed",
|
|
2584
|
+
// Status will be updated by test output parsing
|
|
2585
|
+
testFile: entry.testFile,
|
|
2586
|
+
testMethod: entry.testMethod,
|
|
2587
|
+
mappingSource: "mapping-file"
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
} catch {
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
} catch {
|
|
2595
|
+
}
|
|
2596
|
+
return null;
|
|
2597
|
+
}
|
|
2598
|
+
function resolveFromTag(projectRoot2, _feature, scenario) {
|
|
2599
|
+
const testsDir = join9(projectRoot2, "tests");
|
|
2600
|
+
if (!existsSync10(testsDir)) return null;
|
|
2601
|
+
try {
|
|
2602
|
+
const files = findTestFiles(testsDir);
|
|
2603
|
+
for (const file of files) {
|
|
2604
|
+
try {
|
|
2605
|
+
const content = readFileSync5(file, "utf-8");
|
|
2606
|
+
const tagRegex = /@speclore-scenario:\s*(.+)/g;
|
|
2607
|
+
let match;
|
|
2608
|
+
while ((match = tagRegex.exec(content)) !== null) {
|
|
2609
|
+
if (match[1].trim() === scenario.name) {
|
|
2610
|
+
const methodMatch = findTestMethodNearby(content, match.index);
|
|
2611
|
+
logger.debug(`Tag hit: ${scenario.name} \u2192 ${methodMatch}`);
|
|
2612
|
+
return {
|
|
2613
|
+
name: scenario.name,
|
|
2614
|
+
status: "passed",
|
|
2615
|
+
testFile: file,
|
|
2616
|
+
testMethod: methodMatch,
|
|
2617
|
+
mappingSource: "tag"
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
} catch {
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
} catch {
|
|
2625
|
+
}
|
|
2626
|
+
return null;
|
|
2627
|
+
}
|
|
2628
|
+
function findMappingFiles(dir) {
|
|
2629
|
+
const files = [];
|
|
2630
|
+
function walk(d) {
|
|
2631
|
+
try {
|
|
2632
|
+
const entries = readdirSync(d, { withFileTypes: true });
|
|
2633
|
+
for (const entry of entries) {
|
|
2634
|
+
const fullPath = join9(d, entry.name);
|
|
2635
|
+
if (entry.isDirectory()) {
|
|
2636
|
+
walk(fullPath);
|
|
2637
|
+
} else if (entry.name.endsWith(".json")) {
|
|
2638
|
+
files.push(fullPath);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
} catch {
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
walk(dir);
|
|
2645
|
+
return files;
|
|
2646
|
+
}
|
|
2647
|
+
function findTestFiles(dir) {
|
|
2648
|
+
const files = [];
|
|
2649
|
+
const testExtensions = [".test.ts", ".test.js", ".spec.ts", ".spec.js", "Test.java", "_test.py", ".test.py"];
|
|
2650
|
+
function walk(d) {
|
|
2651
|
+
try {
|
|
2652
|
+
const entries = readdirSync(d, { withFileTypes: true });
|
|
2653
|
+
for (const entry of entries) {
|
|
2654
|
+
const fullPath = join9(d, entry.name);
|
|
2655
|
+
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
2656
|
+
walk(fullPath);
|
|
2657
|
+
} else if (testExtensions.some((ext) => entry.name.endsWith(ext))) {
|
|
2658
|
+
files.push(fullPath);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
} catch {
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
walk(dir);
|
|
2665
|
+
return files;
|
|
2666
|
+
}
|
|
2667
|
+
function findTestMethodNearby(content, tagIndex) {
|
|
2668
|
+
const afterTag = content.slice(tagIndex, tagIndex + 500);
|
|
2669
|
+
const jsMatch = afterTag.match(/(?:test|it)\s*\(\s*['"]([^'"]+)['"]/);
|
|
2670
|
+
if (jsMatch) return jsMatch[1];
|
|
2671
|
+
const javaMatch = afterTag.match(/@Test\s+(?:void\s+)?(\w+)\s*\(/);
|
|
2672
|
+
if (javaMatch) return javaMatch[1];
|
|
2673
|
+
const pyMatch = afterTag.match(/def\s+(test_\w+)\s*\(/);
|
|
2674
|
+
if (pyMatch) return pyMatch[1];
|
|
2675
|
+
return "unknown";
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// src/core/verifier/runner.ts
|
|
2679
|
+
init_logger();
|
|
2680
|
+
async function runVerification(projectRoot2, features, config, _context) {
|
|
2681
|
+
logger.info("Running verification...");
|
|
2682
|
+
const registry = getRegistry();
|
|
2683
|
+
await registry.invokeLifecycle("beforeVerify", features);
|
|
2684
|
+
const testOutput = executeTestCommand(projectRoot2, config);
|
|
2685
|
+
let scenarioResults;
|
|
2686
|
+
const parser = registry.findParser(testOutput);
|
|
2687
|
+
if (parser) {
|
|
2688
|
+
logger.info(`Using parser plugin: ${parser.framework}`);
|
|
2689
|
+
scenarioResults = parser.parse(testOutput, features);
|
|
2690
|
+
} else {
|
|
2691
|
+
scenarioResults = resolveMappings(projectRoot2, features, testOutput);
|
|
2692
|
+
}
|
|
2693
|
+
const featureResults = features.map((feature) => ({
|
|
2694
|
+
feature: feature.featureName,
|
|
2695
|
+
file: feature.path,
|
|
2696
|
+
scenarios: feature.scenarios.map((s) => {
|
|
2697
|
+
const result = scenarioResults.find((r) => r.name === s.name);
|
|
2698
|
+
return result ?? {
|
|
2699
|
+
name: s.name,
|
|
2700
|
+
status: "unmapped",
|
|
2701
|
+
mappingSource: "none"
|
|
2702
|
+
};
|
|
2703
|
+
})
|
|
2704
|
+
}));
|
|
2705
|
+
const failedDetails = [];
|
|
2706
|
+
for (const fr of featureResults) {
|
|
2707
|
+
for (const sr of fr.scenarios) {
|
|
2708
|
+
if (sr.status === "failed" && sr.error) {
|
|
2709
|
+
failedDetails.push({
|
|
2710
|
+
feature: fr.feature,
|
|
2711
|
+
scenario: sr.name,
|
|
2712
|
+
error: sr.error
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
const allScenarios = featureResults.flatMap((fr) => fr.scenarios);
|
|
2718
|
+
const summary = {
|
|
2719
|
+
totalFeatures: features.length,
|
|
2720
|
+
totalScenarios: allScenarios.length,
|
|
2721
|
+
passed: allScenarios.filter((s) => s.status === "passed").length,
|
|
2722
|
+
failed: allScenarios.filter((s) => s.status === "failed").length,
|
|
2723
|
+
skipped: allScenarios.filter((s) => s.status === "skipped").length,
|
|
2724
|
+
unmapped: allScenarios.filter((s) => s.status === "unmapped").length,
|
|
2725
|
+
passRate: allScenarios.length > 0 ? `${(allScenarios.filter((s) => s.status === "passed").length / allScenarios.length * 100).toFixed(1)}%` : "0%"
|
|
2726
|
+
};
|
|
2727
|
+
const report = {
|
|
2728
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2729
|
+
project: config.project.name,
|
|
2730
|
+
summary,
|
|
2731
|
+
features: featureResults,
|
|
2732
|
+
failedDetails
|
|
2733
|
+
};
|
|
2734
|
+
await registry.invokeLifecycle("afterVerify", report);
|
|
2735
|
+
return report;
|
|
2736
|
+
}
|
|
2737
|
+
function executeTestCommand(projectRoot2, config) {
|
|
2738
|
+
const command = config.verify.command;
|
|
2739
|
+
if (!command) {
|
|
2740
|
+
logger.warn("No test command configured. Set verify.command in .speclore/config.yaml");
|
|
2741
|
+
return "";
|
|
2742
|
+
}
|
|
2743
|
+
const timeoutMs = config.verify.timeout * 1e3;
|
|
2744
|
+
logger.info(`Executing: ${command}`);
|
|
2745
|
+
try {
|
|
2746
|
+
const parts = command.split(/\s+/);
|
|
2747
|
+
const cmd = parts[0];
|
|
2748
|
+
const args = parts.slice(1);
|
|
2749
|
+
const output = execFileSync(cmd, args, {
|
|
2750
|
+
cwd: projectRoot2,
|
|
2751
|
+
encoding: "utf-8",
|
|
2752
|
+
timeout: timeoutMs,
|
|
2753
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2754
|
+
});
|
|
2755
|
+
return output;
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
if (error && typeof error === "object" && "stdout" in error) {
|
|
2758
|
+
const execError = error;
|
|
2759
|
+
return (execError.stdout ?? "") + "\n" + (execError.stderr ?? "");
|
|
2760
|
+
}
|
|
2761
|
+
logger.error(`Test command failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2762
|
+
return "";
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// src/core/verifier/report-generator.ts
|
|
2767
|
+
init_logger();
|
|
2768
|
+
import { writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, existsSync as existsSync11, readFileSync as readFileSync6 } from "fs";
|
|
2769
|
+
import { join as join10, dirname as dirname2 } from "path";
|
|
2770
|
+
import { fileURLToPath } from "url";
|
|
2771
|
+
|
|
2772
|
+
// src/core/context-engine/context-writer.ts
|
|
2773
|
+
init_logger();
|
|
2774
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync8, existsSync as existsSync13, statSync as statSync3, mkdirSync as mkdirSync7 } from "fs";
|
|
2775
|
+
import { join as join13 } from "path";
|
|
2776
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
2777
|
+
|
|
2778
|
+
// src/core/context-engine/graph-builder.ts
|
|
2779
|
+
init_logger();
|
|
2780
|
+
import { existsSync as existsSync12, readdirSync as readdirSync2, statSync as statSync2, readFileSync as readFileSync7 } from "fs";
|
|
2781
|
+
import { join as join11, basename as basename7, extname as extname7, relative as relative2 } from "path";
|
|
2782
|
+
function detectProjectInfo(projectRoot2) {
|
|
2783
|
+
const info = {
|
|
2784
|
+
language: "unknown",
|
|
2785
|
+
framework: "unknown",
|
|
2786
|
+
buildTool: "unknown",
|
|
2787
|
+
testFramework: "unknown",
|
|
2788
|
+
directoryStructure: ""
|
|
2789
|
+
};
|
|
2790
|
+
if (existsSync12(join11(projectRoot2, "package.json"))) {
|
|
2791
|
+
info.language = "typescript";
|
|
2792
|
+
info.buildTool = "npm";
|
|
2793
|
+
try {
|
|
2794
|
+
const pkg = JSON.parse(readFileSync7(join11(projectRoot2, "package.json"), "utf-8"));
|
|
2795
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2796
|
+
if ("next" in deps) info.framework = "next.js";
|
|
2797
|
+
else if ("@nestjs/core" in deps) info.framework = "nestjs";
|
|
2798
|
+
else if ("express" in deps) info.framework = "express";
|
|
2799
|
+
else if ("react" in deps) info.framework = "react";
|
|
2800
|
+
else if ("vue" in deps) info.framework = "vue";
|
|
2801
|
+
else if ("nuxt" in deps) info.framework = "nuxt";
|
|
2802
|
+
if ("vitest" in deps || "jest" in deps) info.testFramework = "vitest" in deps ? "vitest" : "jest";
|
|
2803
|
+
} catch {
|
|
2804
|
+
logger.debug("Failed to parse package.json, using default project info");
|
|
2805
|
+
}
|
|
2806
|
+
} else if (existsSync12(join11(projectRoot2, "pom.xml"))) {
|
|
2807
|
+
info.language = "java";
|
|
2808
|
+
info.buildTool = "maven";
|
|
2809
|
+
info.testFramework = "junit";
|
|
2810
|
+
try {
|
|
2811
|
+
const pom = readFileSync7(join11(projectRoot2, "pom.xml"), "utf-8");
|
|
2812
|
+
if (pom.includes("spring-boot")) info.framework = "spring-boot";
|
|
2813
|
+
} catch {
|
|
2814
|
+
}
|
|
2815
|
+
} else if (existsSync12(join11(projectRoot2, "build.gradle")) || existsSync12(join11(projectRoot2, "build.gradle.kts"))) {
|
|
2816
|
+
info.language = "java";
|
|
2817
|
+
info.buildTool = "gradle";
|
|
2818
|
+
info.testFramework = "junit";
|
|
2819
|
+
} else if (existsSync12(join11(projectRoot2, "go.mod"))) {
|
|
2820
|
+
info.language = "go";
|
|
2821
|
+
info.buildTool = "go";
|
|
2822
|
+
info.testFramework = "go-test";
|
|
2823
|
+
} else if (existsSync12(join11(projectRoot2, "Cargo.toml"))) {
|
|
2824
|
+
info.language = "rust";
|
|
2825
|
+
info.buildTool = "cargo";
|
|
2826
|
+
info.testFramework = "cargo-test";
|
|
2827
|
+
} else if (existsSync12(join11(projectRoot2, "requirements.txt")) || existsSync12(join11(projectRoot2, "pyproject.toml"))) {
|
|
2828
|
+
info.language = "python";
|
|
2829
|
+
info.buildTool = "pip";
|
|
2830
|
+
info.testFramework = "pytest";
|
|
2831
|
+
try {
|
|
2832
|
+
const reqs = existsSync12(join11(projectRoot2, "requirements.txt")) ? readFileSync7(join11(projectRoot2, "requirements.txt"), "utf-8") : readFileSync7(join11(projectRoot2, "pyproject.toml"), "utf-8");
|
|
2833
|
+
if (reqs.includes("django")) info.framework = "django";
|
|
2834
|
+
else if (reqs.includes("flask")) info.framework = "flask";
|
|
2835
|
+
else if (reqs.includes("fastapi")) info.framework = "fastapi";
|
|
2836
|
+
} catch {
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
info.directoryStructure = buildDirectorySummary(projectRoot2, "", 2, 50);
|
|
2840
|
+
logger.debug(`Detected project: ${info.language} / ${info.framework} / ${info.buildTool}`);
|
|
2841
|
+
return info;
|
|
2842
|
+
}
|
|
2843
|
+
function buildDirectorySummary(root, _prefix, maxDepth, maxLines) {
|
|
2844
|
+
const lines = [];
|
|
2845
|
+
function walk(dir, indent, depth) {
|
|
2846
|
+
if (depth > maxDepth || lines.length >= maxLines) return;
|
|
2847
|
+
let entries;
|
|
2848
|
+
try {
|
|
2849
|
+
entries = readdirSync2(dir).filter((e) => !e.startsWith(".") && e !== "node_modules" && e !== "__pycache__").sort();
|
|
2850
|
+
} catch {
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
for (const entry of entries) {
|
|
2854
|
+
if (lines.length >= maxLines) return;
|
|
2855
|
+
const fullPath = join11(dir, entry);
|
|
2856
|
+
const isDir = statSync2(fullPath).isDirectory();
|
|
2857
|
+
const display = isDir ? `${entry}/` : entry;
|
|
2858
|
+
lines.push(`${indent}${display}`);
|
|
2859
|
+
if (isDir) {
|
|
2860
|
+
walk(fullPath, indent + " ", depth + 1);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
walk(root, "", 0);
|
|
2865
|
+
return lines.slice(0, maxLines).join("\n");
|
|
2866
|
+
}
|
|
2867
|
+
function buildDependencyGraph(projectRoot2, modules) {
|
|
2868
|
+
const edges = [];
|
|
2869
|
+
for (const mod of modules) {
|
|
2870
|
+
for (const dep of mod.dependsOn) {
|
|
2871
|
+
edges.push({
|
|
2872
|
+
from: mod.name,
|
|
2873
|
+
to: dep,
|
|
2874
|
+
type: "import"
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
for (const mod of modules) {
|
|
2879
|
+
const modPath = join11(projectRoot2, mod.path);
|
|
2880
|
+
if (!existsSync12(modPath)) continue;
|
|
2881
|
+
try {
|
|
2882
|
+
const imports = scanImports(modPath);
|
|
2883
|
+
for (const otherMod of modules) {
|
|
2884
|
+
if (otherMod.name === mod.name) continue;
|
|
2885
|
+
const otherPath = otherMod.path.replace(/\\/g, "/");
|
|
2886
|
+
const hasImport = imports.some((imp) => imp.includes(otherPath) || imp.includes(otherMod.name));
|
|
2887
|
+
if (hasImport && !edges.some((e) => e.from === mod.name && e.to === otherMod.name)) {
|
|
2888
|
+
edges.push({
|
|
2889
|
+
from: mod.name,
|
|
2890
|
+
to: otherMod.name,
|
|
2891
|
+
type: "import"
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
} catch {
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
return edges;
|
|
2899
|
+
}
|
|
2900
|
+
function scanImports(dir) {
|
|
2901
|
+
const imports = [];
|
|
2902
|
+
function walk(d) {
|
|
2903
|
+
let entries;
|
|
2904
|
+
try {
|
|
2905
|
+
entries = readdirSync2(d);
|
|
2906
|
+
} catch {
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
for (const entry of entries) {
|
|
2910
|
+
const fullPath = join11(d, entry);
|
|
2911
|
+
const stat = statSync2(fullPath);
|
|
2912
|
+
if (stat.isDirectory()) {
|
|
2913
|
+
walk(fullPath);
|
|
2914
|
+
continue;
|
|
2915
|
+
}
|
|
2916
|
+
const ext = extname7(entry);
|
|
2917
|
+
if (![".ts", ".tsx", ".js", ".jsx", ".java", ".py"].includes(ext)) continue;
|
|
2918
|
+
try {
|
|
2919
|
+
const content = readFileSync7(fullPath, "utf-8");
|
|
2920
|
+
const importRegex = /(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g;
|
|
2921
|
+
let match;
|
|
2922
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
2923
|
+
const imported = match[1] ?? match[2];
|
|
2924
|
+
if (imported) imports.push(imported);
|
|
2925
|
+
}
|
|
2926
|
+
} catch {
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
walk(dir);
|
|
2931
|
+
return imports;
|
|
2932
|
+
}
|
|
2933
|
+
function readDirRecursive(dir) {
|
|
2934
|
+
const files = [];
|
|
2935
|
+
function walk(d) {
|
|
2936
|
+
try {
|
|
2937
|
+
const entries = readdirSync2(d, { withFileTypes: true });
|
|
2938
|
+
for (const entry of entries) {
|
|
2939
|
+
const fullPath = join11(d, entry.name);
|
|
2940
|
+
if (entry.isDirectory()) walk(fullPath);
|
|
2941
|
+
else files.push(fullPath);
|
|
2942
|
+
}
|
|
2943
|
+
} catch {
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
walk(dir);
|
|
2947
|
+
return files;
|
|
2948
|
+
}
|
|
2949
|
+
var ANALYZABLE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".java", ".py"]);
|
|
2950
|
+
function analyzeFileForEntities(filePath, modName, modPath, modRelativePath) {
|
|
2951
|
+
const entities = [];
|
|
2952
|
+
const ext = extname7(filePath);
|
|
2953
|
+
const name = basename7(filePath, ext);
|
|
2954
|
+
if (!ANALYZABLE_EXTENSIONS.has(ext)) return entities;
|
|
2955
|
+
try {
|
|
2956
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
2957
|
+
const relFile = toPosixPath(join11(modRelativePath, relative2(modPath, filePath)));
|
|
2958
|
+
if (/(?:Entity|Model|Domain|Schema)$/i.test(name)) {
|
|
2959
|
+
entities.push({ name, module: modName, file: relFile });
|
|
2960
|
+
return entities;
|
|
2961
|
+
}
|
|
2962
|
+
if (ext === ".java") {
|
|
2963
|
+
const javaMatch = content.match(/@Entity(?:\([^)]*\))?\s*(?:public\s+)?(?:abstract\s+)?class\s+(\w+)/);
|
|
2964
|
+
if (javaMatch?.[1]) {
|
|
2965
|
+
entities.push({ name: javaMatch[1], module: modName, file: relFile });
|
|
2966
|
+
return entities;
|
|
2967
|
+
}
|
|
2968
|
+
} else if (ext === ".py") {
|
|
2969
|
+
const pyMatch = content.match(/class\s+(\w+)\s*\(\s*(?:models\.Model|Base|declarative_base\(\))\s*\)/);
|
|
2970
|
+
if (pyMatch?.[1]) {
|
|
2971
|
+
entities.push({ name: pyMatch[1], module: modName, file: relFile });
|
|
2972
|
+
return entities;
|
|
2973
|
+
}
|
|
2974
|
+
} else {
|
|
2975
|
+
if (/@Entity\(|@Schema\(/.test(content)) {
|
|
2976
|
+
const classMatch = content.match(/@(?:Entity|Schema)\([^)]*\)\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/s);
|
|
2977
|
+
if (classMatch?.[1]) {
|
|
2978
|
+
entities.push({ name: classMatch[1], module: modName, file: relFile });
|
|
2979
|
+
return entities;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
if (ext === ".java") {
|
|
2984
|
+
const extendsMatch = content.match(/class\s+(\w+)\s+extends\s+\w*(?:Base)?(?:Entity|Model)\b/);
|
|
2985
|
+
if (extendsMatch?.[1]) {
|
|
2986
|
+
entities.push({ name: extendsMatch[1], module: modName, file: relFile });
|
|
2987
|
+
}
|
|
2988
|
+
} else if (ext === ".py") {
|
|
2989
|
+
const extendsMatch = content.match(/class\s+(\w+)\s*\(\s*\w*(?:Base|Model)\w*\s*\)/);
|
|
2990
|
+
if (extendsMatch?.[1] && !/Test|Mock|Fake/i.test(extendsMatch[1])) {
|
|
2991
|
+
entities.push({ name: extendsMatch[1], module: modName, file: relFile });
|
|
2992
|
+
}
|
|
2993
|
+
} else {
|
|
2994
|
+
const extendsMatch = content.match(/class\s+(\w+)\s+extends\s+\w*(?:Base)?(?:Entity|Model|Document)\b/);
|
|
2995
|
+
if (extendsMatch?.[1]) {
|
|
2996
|
+
entities.push({ name: extendsMatch[1], module: modName, file: relFile });
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
} catch {
|
|
3000
|
+
}
|
|
3001
|
+
return entities;
|
|
3002
|
+
}
|
|
3003
|
+
function analyzeFileForApis(filePath, modName, modPath, modRelativePath) {
|
|
3004
|
+
const apis = [];
|
|
3005
|
+
const ext = extname7(filePath);
|
|
3006
|
+
const name = basename7(filePath, ext);
|
|
3007
|
+
if (!ANALYZABLE_EXTENSIONS.has(ext)) return apis;
|
|
3008
|
+
try {
|
|
3009
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
3010
|
+
const relFile = toPosixPath(join11(modRelativePath, relative2(modPath, filePath)));
|
|
3011
|
+
if (/Controller|Resource|Handler|Route$/i.test(name)) {
|
|
3012
|
+
apis.push({
|
|
3013
|
+
method: "GET",
|
|
3014
|
+
path: `/${modName}/${name.toLowerCase()}`,
|
|
3015
|
+
module: modName,
|
|
3016
|
+
file: relFile
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
const expressRegex = /(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]\s*[,)]/gi;
|
|
3020
|
+
let match;
|
|
3021
|
+
while ((match = expressRegex.exec(content)) !== null) {
|
|
3022
|
+
const method = (match[1] ?? "get").toUpperCase();
|
|
3023
|
+
const routePath = match[2];
|
|
3024
|
+
apis.push({
|
|
3025
|
+
method,
|
|
3026
|
+
path: routePath,
|
|
3027
|
+
module: modName,
|
|
3028
|
+
file: relFile
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
const nestjsRegex = /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"`]([^'"`]*)['"`]\s*\)/g;
|
|
3032
|
+
while ((match = nestjsRegex.exec(content)) !== null) {
|
|
3033
|
+
const method = (match[1] ?? "Get").toUpperCase();
|
|
3034
|
+
const routePath = match[2] ?? "";
|
|
3035
|
+
apis.push({
|
|
3036
|
+
method,
|
|
3037
|
+
path: routePath || `/${modName}`,
|
|
3038
|
+
module: modName,
|
|
3039
|
+
file: relFile
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
if (ext === ".java") {
|
|
3043
|
+
const springRegex = /@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)(?:\s*\(\s*(?:value\s*=\s*)?['"`]([^'"`]*)['""])?/g;
|
|
3044
|
+
while ((match = springRegex.exec(content)) !== null) {
|
|
3045
|
+
const annotation = match[1];
|
|
3046
|
+
const method = annotation.replace(/Mapping$/, "").toUpperCase() || "GET";
|
|
3047
|
+
const routePath = match[2] ?? "";
|
|
3048
|
+
apis.push({
|
|
3049
|
+
method,
|
|
3050
|
+
path: routePath || `/${modName}`,
|
|
3051
|
+
module: modName,
|
|
3052
|
+
file: relFile
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
if (ext === ".py") {
|
|
3057
|
+
const flaskRegex = /@(\w+)\.route\s*\(\s*['"`]([^'"`]+)['"`]/g;
|
|
3058
|
+
while ((match = flaskRegex.exec(content)) !== null) {
|
|
3059
|
+
const routePath = match[2];
|
|
3060
|
+
const methodsMatch = content.slice(match.index).match(/methods\s*=\s*\[([^\]]+)\]/);
|
|
3061
|
+
let method = "GET";
|
|
3062
|
+
if (methodsMatch?.[1]) {
|
|
3063
|
+
const firstMethod = methodsMatch[1].match(/['"](\w+)['"]/)?.[1];
|
|
3064
|
+
if (firstMethod) method = firstMethod.toUpperCase();
|
|
3065
|
+
}
|
|
3066
|
+
apis.push({
|
|
3067
|
+
method,
|
|
3068
|
+
path: routePath,
|
|
3069
|
+
module: modName,
|
|
3070
|
+
file: relFile
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
} catch {
|
|
3075
|
+
}
|
|
3076
|
+
return apis;
|
|
3077
|
+
}
|
|
3078
|
+
function extractEntities(projectRoot2, modules) {
|
|
3079
|
+
const entities = [];
|
|
3080
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3081
|
+
for (const mod of modules) {
|
|
3082
|
+
const modPath = join11(projectRoot2, mod.path);
|
|
3083
|
+
if (!existsSync12(modPath)) continue;
|
|
3084
|
+
try {
|
|
3085
|
+
const files = readDirRecursive(modPath);
|
|
3086
|
+
for (const file of files) {
|
|
3087
|
+
const found = analyzeFileForEntities(file, mod.name, modPath, mod.path);
|
|
3088
|
+
for (const entity of found) {
|
|
3089
|
+
const key = `${entity.module}:${entity.name}`;
|
|
3090
|
+
if (!seen.has(key)) {
|
|
3091
|
+
seen.add(key);
|
|
3092
|
+
entities.push(entity);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
} catch {
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
return entities;
|
|
3100
|
+
}
|
|
3101
|
+
function extractApis(projectRoot2, modules) {
|
|
3102
|
+
const apis = [];
|
|
3103
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3104
|
+
for (const mod of modules) {
|
|
3105
|
+
const modPath = join11(projectRoot2, mod.path);
|
|
3106
|
+
if (!existsSync12(modPath)) continue;
|
|
3107
|
+
try {
|
|
3108
|
+
const files = readDirRecursive(modPath);
|
|
3109
|
+
for (const file of files) {
|
|
3110
|
+
const found = analyzeFileForApis(file, mod.name, modPath, mod.path);
|
|
3111
|
+
for (const api of found) {
|
|
3112
|
+
const key = `${api.method}:${api.path}:${api.module}`;
|
|
3113
|
+
if (!seen.has(key)) {
|
|
3114
|
+
seen.add(key);
|
|
3115
|
+
apis.push(api);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
} catch {
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
return apis;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
// src/version.ts
|
|
3126
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
3127
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3128
|
+
import { join as join12, dirname as dirname3 } from "path";
|
|
3129
|
+
function readVersion() {
|
|
3130
|
+
let currentDir = dirname3(fileURLToPath2(import.meta.url));
|
|
3131
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
3132
|
+
try {
|
|
3133
|
+
const pkgPath = join12(currentDir, "package.json");
|
|
3134
|
+
const pkg = JSON.parse(readFileSync8(pkgPath, "utf-8"));
|
|
3135
|
+
if (typeof pkg.version === "string") return pkg.version;
|
|
3136
|
+
} catch {
|
|
3137
|
+
}
|
|
3138
|
+
currentDir = join12(currentDir, "..");
|
|
3139
|
+
}
|
|
3140
|
+
return "0.0.0";
|
|
3141
|
+
}
|
|
3142
|
+
var VERSION = readVersion();
|
|
3143
|
+
|
|
3144
|
+
// src/core/context-engine/context-writer.ts
|
|
3145
|
+
var CONTEXT_FILENAME = "context.json";
|
|
3146
|
+
var CACHE_EXPIRY_MS = 60 * 60 * 1e3;
|
|
3147
|
+
var MAX_CONTEXT_SIZE = 50 * 1024;
|
|
3148
|
+
function buildContext(projectRoot2, config) {
|
|
3149
|
+
logger.info("Building project context...");
|
|
3150
|
+
const projectInfo = detectProjectInfo(projectRoot2);
|
|
3151
|
+
const moduleBoundaries = readModuleConfig(config);
|
|
3152
|
+
const moduleList = Object.entries(config.project.modules).map(([name, mod]) => ({
|
|
3153
|
+
name,
|
|
3154
|
+
path: mod.path,
|
|
3155
|
+
dependsOn: mod.dependsOn ?? []
|
|
3156
|
+
}));
|
|
3157
|
+
const dependencyGraph = buildDependencyGraph(projectRoot2, moduleList);
|
|
3158
|
+
const entities = extractEntities(projectRoot2, moduleList);
|
|
3159
|
+
const apis = extractApis(projectRoot2, moduleList);
|
|
3160
|
+
const contextFile = {
|
|
3161
|
+
version: "1.0",
|
|
3162
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3163
|
+
generatedBy: `speclore v${VERSION}`,
|
|
3164
|
+
projectSummary: {
|
|
3165
|
+
language: projectInfo.language,
|
|
3166
|
+
framework: projectInfo.framework,
|
|
3167
|
+
buildTool: projectInfo.buildTool,
|
|
3168
|
+
testFramework: projectInfo.testFramework,
|
|
3169
|
+
directoryStructure: projectInfo.directoryStructure
|
|
3170
|
+
},
|
|
3171
|
+
moduleBoundaries,
|
|
3172
|
+
existingCode: {
|
|
3173
|
+
entities: entities.map((e) => ({ name: e.name, module: e.module, file: e.file })),
|
|
3174
|
+
apis: apis.map((a) => ({ method: a.method, path: a.path, module: a.module, file: a.file }))
|
|
3175
|
+
},
|
|
3176
|
+
dependencyGraph
|
|
3177
|
+
};
|
|
3178
|
+
const json = JSON.stringify(contextFile, null, 2);
|
|
3179
|
+
if (json.length > MAX_CONTEXT_SIZE) {
|
|
3180
|
+
logger.warn(`Context file exceeds ${MAX_CONTEXT_SIZE} bytes, truncating...`);
|
|
3181
|
+
contextFile.projectSummary.directoryStructure = truncateTo(
|
|
3182
|
+
contextFile.projectSummary.directoryStructure,
|
|
3183
|
+
20
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
return contextFile;
|
|
3187
|
+
}
|
|
3188
|
+
function loadContext(specLoreDir) {
|
|
3189
|
+
const contextPath = join13(specLoreDir, CONTEXT_FILENAME);
|
|
3190
|
+
if (!existsSync13(contextPath)) {
|
|
3191
|
+
logger.debug("No cached context.json found");
|
|
3192
|
+
return null;
|
|
3193
|
+
}
|
|
3194
|
+
const stat = statSync3(contextPath);
|
|
3195
|
+
const age = Date.now() - stat.mtimeMs;
|
|
3196
|
+
if (age > CACHE_EXPIRY_MS) {
|
|
3197
|
+
logger.debug(`Context cache expired (age: ${Math.round(age / 6e4)}min)`);
|
|
3198
|
+
return null;
|
|
3199
|
+
}
|
|
3200
|
+
if (hasGitHeadChanged(specLoreDir)) {
|
|
3201
|
+
logger.debug("Git HEAD changed since last context build");
|
|
3202
|
+
return null;
|
|
3203
|
+
}
|
|
3204
|
+
try {
|
|
3205
|
+
const content = readFileSync9(contextPath, "utf-8");
|
|
3206
|
+
const context = JSON.parse(content);
|
|
3207
|
+
logger.debug("Loaded cached context.json");
|
|
3208
|
+
return context;
|
|
3209
|
+
} catch {
|
|
3210
|
+
logger.warn("Failed to parse cached context.json, will rebuild");
|
|
3211
|
+
return null;
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
function hasGitHeadChanged(specLoreDir) {
|
|
3215
|
+
try {
|
|
3216
|
+
const projectRoot2 = join13(specLoreDir, "..");
|
|
3217
|
+
const head = execFileSync2("git", ["rev-parse", "HEAD"], {
|
|
3218
|
+
cwd: projectRoot2,
|
|
3219
|
+
encoding: "utf-8",
|
|
3220
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3221
|
+
}).trim();
|
|
3222
|
+
const headFile = join13(specLoreDir, ".git-head");
|
|
3223
|
+
if (!existsSync13(headFile)) return true;
|
|
3224
|
+
const savedHead = readFileSync9(headFile, "utf-8").trim();
|
|
3225
|
+
if (savedHead !== head) {
|
|
3226
|
+
writeFileSync8(headFile, head, "utf-8");
|
|
3227
|
+
return true;
|
|
3228
|
+
}
|
|
3229
|
+
return false;
|
|
3230
|
+
} catch {
|
|
3231
|
+
return false;
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
function truncateTo(text, maxLines) {
|
|
3235
|
+
const lines = text.split("\n");
|
|
3236
|
+
if (lines.length <= maxLines) return text;
|
|
3237
|
+
return lines.slice(0, maxLines).join("\n") + `
|
|
3238
|
+
... (${lines.length - maxLines} more lines)`;
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// src/core/analyzer/rdg-builder.ts
|
|
3242
|
+
init_logger();
|
|
3243
|
+
import { readFileSync as readFileSync10, existsSync as existsSync14, readdirSync as readdirSync3 } from "fs";
|
|
3244
|
+
import { join as join14 } from "path";
|
|
3245
|
+
|
|
3246
|
+
// src/core/analyzer/cdg-builder.ts
|
|
3247
|
+
init_logger();
|
|
3248
|
+
|
|
3249
|
+
// src/core/analyzer/aligner.ts
|
|
3250
|
+
init_logger();
|
|
3251
|
+
|
|
3252
|
+
// src/core/analyzer/impact-analyzer.ts
|
|
3253
|
+
init_logger();
|
|
3254
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
3255
|
+
import { globSync as globSync2 } from "glob";
|
|
3256
|
+
function analyzeImpact(projectRoot2, context, config) {
|
|
3257
|
+
const changedFiles = getChangedFiles(projectRoot2);
|
|
3258
|
+
const affectedModules = determineAffectedModules(changedFiles, context);
|
|
3259
|
+
const affectedFeatures = determineAffectedFeatures(affectedModules, projectRoot2, config.spec.outputDir);
|
|
3260
|
+
logger.info(
|
|
3261
|
+
`Impact: ${changedFiles.length} files changed, ${affectedModules.length} modules affected, ${affectedFeatures.length} features affected`
|
|
3262
|
+
);
|
|
3263
|
+
return { changedFiles, affectedModules, affectedFeatures };
|
|
3264
|
+
}
|
|
3265
|
+
function getChangedFiles(projectRoot2) {
|
|
3266
|
+
try {
|
|
3267
|
+
const output = execFileSync3("git", ["diff", "--name-only", "HEAD"], {
|
|
3268
|
+
cwd: projectRoot2,
|
|
3269
|
+
encoding: "utf-8",
|
|
3270
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3271
|
+
});
|
|
3272
|
+
return output.split("\n").map((f) => f.trim()).filter(Boolean).map(toPosixPath);
|
|
3273
|
+
} catch {
|
|
3274
|
+
try {
|
|
3275
|
+
const output = execFileSync3("git", ["diff", "--name-only"], {
|
|
3276
|
+
cwd: projectRoot2,
|
|
3277
|
+
encoding: "utf-8",
|
|
3278
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3279
|
+
});
|
|
3280
|
+
return output.split("\n").map((f) => f.trim()).filter(Boolean).map(toPosixPath);
|
|
3281
|
+
} catch {
|
|
3282
|
+
logger.warn("Could not get git diff. Is this a git repository?");
|
|
3283
|
+
return [];
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
}
|
|
3287
|
+
function determineAffectedModules(changedFiles, context) {
|
|
3288
|
+
const affected = /* @__PURE__ */ new Set();
|
|
3289
|
+
for (const file of changedFiles) {
|
|
3290
|
+
for (const mod of context.moduleBoundaries) {
|
|
3291
|
+
if (file.includes(`/${mod.name}/`) || file.includes(`\\${mod.name}\\`)) {
|
|
3292
|
+
affected.add(mod.name);
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
const expanded = new Set(affected);
|
|
3297
|
+
for (const mod of context.moduleBoundaries) {
|
|
3298
|
+
if (mod.dependsOn.some((dep) => affected.has(dep))) {
|
|
3299
|
+
expanded.add(mod.name);
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
return [...expanded];
|
|
3303
|
+
}
|
|
3304
|
+
function determineAffectedFeatures(affectedModules, projectRoot2, outputDir) {
|
|
3305
|
+
const features = [];
|
|
3306
|
+
for (const mod of affectedModules) {
|
|
3307
|
+
const pattern = `${outputDir}/${mod}/**/*.feature`;
|
|
3308
|
+
const files = globSync2(pattern, { cwd: projectRoot2, absolute: true });
|
|
3309
|
+
features.push(...files);
|
|
3310
|
+
}
|
|
3311
|
+
return [...new Set(features)];
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
// src/core/state-manager/index.ts
|
|
3315
|
+
import { readFileSync as readFileSync11, writeFileSync as writeFileSync9, existsSync as existsSync15, mkdirSync as mkdirSync8 } from "fs";
|
|
3316
|
+
import { join as join15 } from "path";
|
|
3317
|
+
import yaml from "js-yaml";
|
|
3318
|
+
import { globSync as globSync3 } from "glob";
|
|
3319
|
+
var STATE_FILENAME = "state.yaml";
|
|
3320
|
+
var ALLOWED_TRANSITIONS = {
|
|
3321
|
+
specified: ["constrained"],
|
|
3322
|
+
constrained: ["coding"],
|
|
3323
|
+
coding: ["verified"],
|
|
3324
|
+
verified: ["constrained", "specified"]
|
|
3325
|
+
};
|
|
3326
|
+
function createDefaultState() {
|
|
3327
|
+
return {
|
|
3328
|
+
schemaVersion: 1,
|
|
3329
|
+
initialized: true,
|
|
3330
|
+
initializedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3331
|
+
features: {}
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3334
|
+
var StateManager = class {
|
|
3335
|
+
projectRoot;
|
|
3336
|
+
statePath;
|
|
3337
|
+
constructor(projectRoot2) {
|
|
3338
|
+
this.projectRoot = projectRoot2;
|
|
3339
|
+
this.statePath = join15(projectRoot2, ".speclore", STATE_FILENAME);
|
|
3340
|
+
}
|
|
3341
|
+
/** Load state from disk, or return default if not exists */
|
|
3342
|
+
load() {
|
|
3343
|
+
if (!existsSync15(this.statePath)) {
|
|
3344
|
+
return createDefaultState();
|
|
3345
|
+
}
|
|
3346
|
+
try {
|
|
3347
|
+
const content = readFileSync11(this.statePath, "utf-8");
|
|
3348
|
+
const parsed = yaml.load(content, { schema: yaml.JSON_SCHEMA });
|
|
3349
|
+
if (parsed && typeof parsed === "object") {
|
|
3350
|
+
return parsed;
|
|
3351
|
+
}
|
|
3352
|
+
} catch {
|
|
3353
|
+
}
|
|
3354
|
+
return createDefaultState();
|
|
3355
|
+
}
|
|
3356
|
+
/** Persist state to disk */
|
|
3357
|
+
save(state) {
|
|
3358
|
+
const dir = join15(this.projectRoot, ".speclore");
|
|
3359
|
+
if (!existsSync15(dir)) {
|
|
3360
|
+
mkdirSync8(dir, { recursive: true });
|
|
3361
|
+
}
|
|
3362
|
+
writeFileSync9(this.statePath, yaml.dump(state, { lineWidth: 120 }), "utf-8");
|
|
3363
|
+
}
|
|
3364
|
+
/** Ensure state file exists with default values */
|
|
3365
|
+
ensureInitialized() {
|
|
3366
|
+
if (!existsSync15(this.statePath)) {
|
|
3367
|
+
const state2 = createDefaultState();
|
|
3368
|
+
this.save(state2);
|
|
3369
|
+
return state2;
|
|
3370
|
+
}
|
|
3371
|
+
const state = this.load();
|
|
3372
|
+
if (!state.initialized) {
|
|
3373
|
+
state.initialized = true;
|
|
3374
|
+
state.initializedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3375
|
+
this.save(state);
|
|
3376
|
+
}
|
|
3377
|
+
return state;
|
|
3378
|
+
}
|
|
3379
|
+
/** Get state entry for a specific feature */
|
|
3380
|
+
getFeatureState(featurePath) {
|
|
3381
|
+
const state = this.load();
|
|
3382
|
+
return state.features[featurePath] ?? null;
|
|
3383
|
+
}
|
|
3384
|
+
/** Get project-level summary */
|
|
3385
|
+
getProjectSummary() {
|
|
3386
|
+
const state = this.load();
|
|
3387
|
+
const counts = {
|
|
3388
|
+
specified: 0,
|
|
3389
|
+
constrained: 0,
|
|
3390
|
+
coding: 0,
|
|
3391
|
+
verified: 0
|
|
3392
|
+
};
|
|
3393
|
+
for (const entry of Object.values(state.features)) {
|
|
3394
|
+
counts[entry.state] = (counts[entry.state] ?? 0) + 1;
|
|
3395
|
+
}
|
|
3396
|
+
return {
|
|
3397
|
+
initialized: state.initialized,
|
|
3398
|
+
featureCount: Object.keys(state.features).length,
|
|
3399
|
+
states: counts
|
|
3400
|
+
};
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* Transition a feature to a new state.
|
|
3404
|
+
* @throws Error if transition is not allowed
|
|
3405
|
+
*/
|
|
3406
|
+
transitionFeature(featurePath, to, guards) {
|
|
3407
|
+
const state = this.load();
|
|
3408
|
+
const entry = state.features[featurePath];
|
|
3409
|
+
if (!entry) {
|
|
3410
|
+
if (to !== "specified") {
|
|
3411
|
+
throw new Error(
|
|
3412
|
+
`Cannot transition feature "${featurePath}" to "${to}" \u2014 feature does not exist. Initial state must be "specified".`
|
|
3413
|
+
);
|
|
3414
|
+
}
|
|
3415
|
+
state.features[featurePath] = {
|
|
3416
|
+
featureFile: featurePath,
|
|
3417
|
+
state: "specified",
|
|
3418
|
+
constraintFiles: [],
|
|
3419
|
+
testFiles: [],
|
|
3420
|
+
lastStateChange: (/* @__PURE__ */ new Date()).toISOString()
|
|
3421
|
+
};
|
|
3422
|
+
} else {
|
|
3423
|
+
const allowed = ALLOWED_TRANSITIONS[entry.state];
|
|
3424
|
+
if (!allowed.includes(to)) {
|
|
3425
|
+
throw new Error(
|
|
3426
|
+
`Invalid state transition for "${featurePath}": "${entry.state}" \u2192 "${to}". Allowed: ${allowed.join(", ")}`
|
|
3427
|
+
);
|
|
3428
|
+
}
|
|
3429
|
+
if (guards && !guards.includes(entry.state)) {
|
|
3430
|
+
throw new Error(
|
|
3431
|
+
`Guard failed for "${featurePath}": current state "${entry.state}" not in allowed guards [${guards.join(", ")}]`
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
3434
|
+
entry.state = to;
|
|
3435
|
+
entry.lastStateChange = (/* @__PURE__ */ new Date()).toISOString();
|
|
3436
|
+
}
|
|
3437
|
+
this.save(state);
|
|
3438
|
+
}
|
|
3439
|
+
/** Update feature entry with constraint/test file info */
|
|
3440
|
+
updateFeatureEntry(featurePath, updates) {
|
|
3441
|
+
const state = this.load();
|
|
3442
|
+
const entry = state.features[featurePath];
|
|
3443
|
+
if (!entry) return;
|
|
3444
|
+
if (updates.constraintFiles !== void 0) {
|
|
3445
|
+
entry.constraintFiles = updates.constraintFiles;
|
|
3446
|
+
}
|
|
3447
|
+
if (updates.testFiles !== void 0) {
|
|
3448
|
+
entry.testFiles = updates.testFiles;
|
|
3449
|
+
}
|
|
3450
|
+
this.save(state);
|
|
3451
|
+
}
|
|
3452
|
+
/** Record verification result for a feature */
|
|
3453
|
+
recordVerify(featurePath, result) {
|
|
3454
|
+
const state = this.load();
|
|
3455
|
+
const entry = state.features[featurePath];
|
|
3456
|
+
if (!entry) return;
|
|
3457
|
+
entry.lastVerify = {
|
|
3458
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3459
|
+
...result
|
|
3460
|
+
};
|
|
3461
|
+
this.save(state);
|
|
3462
|
+
}
|
|
3463
|
+
/** List all tracked features with their states */
|
|
3464
|
+
listFeatures() {
|
|
3465
|
+
const state = this.load();
|
|
3466
|
+
return Object.entries(state.features).map(([path, entry]) => ({ path, state: entry }));
|
|
3467
|
+
}
|
|
3468
|
+
/** Remove a feature from state tracking */
|
|
3469
|
+
removeFeature(featurePath) {
|
|
3470
|
+
const state = this.load();
|
|
3471
|
+
delete state.features[featurePath];
|
|
3472
|
+
this.save(state);
|
|
3473
|
+
}
|
|
3474
|
+
/**
|
|
3475
|
+
* Migrate existing .feature files into state tracking.
|
|
3476
|
+
*
|
|
3477
|
+
* For projects upgrading from pre-workflow versions: scans the specs
|
|
3478
|
+
* directory for .feature files that are not yet tracked in state.yaml
|
|
3479
|
+
* and registers them as 'specified'. Returns the count of newly
|
|
3480
|
+
* registered features.
|
|
3481
|
+
*/
|
|
3482
|
+
migrateFeatures(specsDir) {
|
|
3483
|
+
const state = this.load();
|
|
3484
|
+
const trackedPaths = new Set(Object.keys(state.features));
|
|
3485
|
+
const featureFiles = globSync3("**/*.feature", {
|
|
3486
|
+
cwd: specsDir,
|
|
3487
|
+
absolute: true
|
|
3488
|
+
});
|
|
3489
|
+
let migrated = 0;
|
|
3490
|
+
for (const filePath of featureFiles) {
|
|
3491
|
+
if (trackedPaths.has(filePath)) continue;
|
|
3492
|
+
state.features[filePath] = {
|
|
3493
|
+
featureFile: filePath,
|
|
3494
|
+
state: "specified",
|
|
3495
|
+
constraintFiles: [],
|
|
3496
|
+
testFiles: [],
|
|
3497
|
+
lastStateChange: (/* @__PURE__ */ new Date()).toISOString()
|
|
3498
|
+
};
|
|
3499
|
+
migrated++;
|
|
3500
|
+
}
|
|
3501
|
+
if (migrated > 0) {
|
|
3502
|
+
this.save(state);
|
|
3503
|
+
}
|
|
3504
|
+
return migrated;
|
|
3505
|
+
}
|
|
3506
|
+
};
|
|
3507
|
+
|
|
3508
|
+
// src/core/test-scaffolder/index.ts
|
|
3509
|
+
import { readFileSync as readFileSync13, writeFileSync as writeFileSync10, existsSync as existsSync17, mkdirSync as mkdirSync9 } from "fs";
|
|
3510
|
+
import { join as join17, relative as relative3, dirname as dirname4, basename as basename8 } from "path";
|
|
3511
|
+
|
|
3512
|
+
// src/core/test-scaffolder/framework-detector.ts
|
|
3513
|
+
import { readFileSync as readFileSync12, existsSync as existsSync16 } from "fs";
|
|
3514
|
+
import { join as join16 } from "path";
|
|
3515
|
+
function detectTestFramework(projectRoot2) {
|
|
3516
|
+
const pkgPath = join16(projectRoot2, "package.json");
|
|
3517
|
+
if (!existsSync16(pkgPath)) {
|
|
3518
|
+
return "vitest";
|
|
3519
|
+
}
|
|
3520
|
+
try {
|
|
3521
|
+
const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
|
|
3522
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
3523
|
+
if ("vitest" in deps) return "vitest";
|
|
3524
|
+
if ("jest" in deps || "@jest/core" in deps) return "jest";
|
|
3525
|
+
if ("mocha" in deps) return "mocha";
|
|
3526
|
+
} catch {
|
|
3527
|
+
}
|
|
3528
|
+
return "vitest";
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
// src/core/test-scaffolder/index.ts
|
|
3532
|
+
init_logger();
|
|
3533
|
+
function generateTestScaffolding(projectRoot2, features, config) {
|
|
3534
|
+
const framework = detectTestFramework(projectRoot2);
|
|
3535
|
+
const results = [];
|
|
3536
|
+
for (const feature of features) {
|
|
3537
|
+
const testFilePath = resolveTestFilePath(projectRoot2, feature.path, config);
|
|
3538
|
+
if (!testFilePath) {
|
|
3539
|
+
logger.warn(`Could not resolve test file path for feature: ${feature.path}`);
|
|
3540
|
+
continue;
|
|
3541
|
+
}
|
|
3542
|
+
const absTestPath = join17(projectRoot2, testFilePath);
|
|
3543
|
+
const scenarios = feature.scenarios;
|
|
3544
|
+
if (existsSync17(absTestPath)) {
|
|
3545
|
+
const appended = appendMissingScenarios(absTestPath, scenarios, framework);
|
|
3546
|
+
if (appended > 0) {
|
|
3547
|
+
results.push({ testFile: testFilePath, framework, scenarios: appended });
|
|
3548
|
+
logger.info(`Appended ${appended} scenario(s) to ${testFilePath}`);
|
|
3549
|
+
}
|
|
3550
|
+
} else {
|
|
3551
|
+
const content = generateTestFileContent(feature.featureName, scenarios, framework);
|
|
3552
|
+
const dir = dirname4(absTestPath);
|
|
3553
|
+
if (!existsSync17(dir)) {
|
|
3554
|
+
mkdirSync9(dir, { recursive: true });
|
|
3555
|
+
}
|
|
3556
|
+
writeFileSync10(absTestPath, content, "utf-8");
|
|
3557
|
+
results.push({ testFile: testFilePath, framework, scenarios: scenarios.length });
|
|
3558
|
+
logger.info(`Generated test scaffold: ${testFilePath} (${scenarios.length} scenarios)`);
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
return results;
|
|
3562
|
+
}
|
|
3563
|
+
function resolveTestFilePath(projectRoot2, featurePath, config) {
|
|
3564
|
+
const relFeature = relative3(projectRoot2, featurePath).replace(/\\/g, "/");
|
|
3565
|
+
const patterns = config.verify.mapping.patterns;
|
|
3566
|
+
for (const pattern of patterns) {
|
|
3567
|
+
const match = matchFeaturePattern(relFeature, pattern);
|
|
3568
|
+
if (match) {
|
|
3569
|
+
return resolveTestFromPattern(match, pattern);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
const featureBase = basename8(featurePath, ".feature");
|
|
3573
|
+
const featureDir = dirname4(relative3(projectRoot2, featurePath));
|
|
3574
|
+
return join17(featureDir, `${featureBase}.test.ts`).replace(/\\/g, "/");
|
|
3575
|
+
}
|
|
3576
|
+
function matchFeaturePattern(relFeaturePath, pattern) {
|
|
3577
|
+
const featurePattern = pattern.feature;
|
|
3578
|
+
const regexStr = featurePattern.replace(/\{module\}/g, "([^/]+)").replace(/\{name\}/g, "([^/]+)").replace(/\./g, "\\.");
|
|
3579
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
3580
|
+
const match = relFeaturePath.match(regex);
|
|
3581
|
+
if (!match) return null;
|
|
3582
|
+
return { module: match[1], name: match[2] };
|
|
3583
|
+
}
|
|
3584
|
+
function resolveTestFromPattern(captured, pattern) {
|
|
3585
|
+
let testPath = pattern.test;
|
|
3586
|
+
testPath = testPath.replace("{module}", captured.module);
|
|
3587
|
+
testPath = testPath.replace("{name}", captured.name);
|
|
3588
|
+
testPath = testPath.replace(/\.\*$/, ".ts");
|
|
3589
|
+
return testPath;
|
|
3590
|
+
}
|
|
3591
|
+
function generateTestFileContent(featureName, scenarios, framework) {
|
|
3592
|
+
const lines = [];
|
|
3593
|
+
switch (framework) {
|
|
3594
|
+
case "vitest":
|
|
3595
|
+
lines.push("import { describe, it, expect } from 'vitest';");
|
|
3596
|
+
break;
|
|
3597
|
+
case "jest":
|
|
3598
|
+
lines.push("// Jest \u2014 describe/it/expect are globally available");
|
|
3599
|
+
break;
|
|
3600
|
+
case "mocha":
|
|
3601
|
+
lines.push("import { expect } from 'chai';");
|
|
3602
|
+
lines.push("// mocha \u2014 describe/it are globally available");
|
|
3603
|
+
break;
|
|
3604
|
+
}
|
|
3605
|
+
lines.push("");
|
|
3606
|
+
lines.push(`describe('Feature: ${featureName}', () => {`);
|
|
3607
|
+
for (const scenario of scenarios) {
|
|
3608
|
+
lines.push(` it.skip('Scenario: ${scenario.name}', () => {`);
|
|
3609
|
+
for (const g of scenario.givens) {
|
|
3610
|
+
lines.push(` // Given: ${g.text}`);
|
|
3611
|
+
}
|
|
3612
|
+
for (const w of scenario.whens) {
|
|
3613
|
+
lines.push(` // When: ${w.text}`);
|
|
3614
|
+
}
|
|
3615
|
+
for (const t of scenario.thens) {
|
|
3616
|
+
lines.push(` // Then: ${t.text}`);
|
|
3617
|
+
}
|
|
3618
|
+
lines.push(" throw new Error('Not implemented');");
|
|
3619
|
+
lines.push(" });");
|
|
3620
|
+
lines.push("");
|
|
3621
|
+
}
|
|
3622
|
+
lines.push("});");
|
|
3623
|
+
lines.push("");
|
|
3624
|
+
return lines.join("\n");
|
|
3625
|
+
}
|
|
3626
|
+
function appendMissingScenarios(testFilePath, scenarios, _framework) {
|
|
3627
|
+
const content = readFileSync13(testFilePath, "utf-8");
|
|
3628
|
+
const missing = [];
|
|
3629
|
+
for (const scenario of scenarios) {
|
|
3630
|
+
if (!content.includes(`Scenario: ${scenario.name}`)) {
|
|
3631
|
+
missing.push(scenario);
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
if (missing.length === 0) return 0;
|
|
3635
|
+
const lastClose = content.lastIndexOf("});");
|
|
3636
|
+
if (lastClose === -1) return 0;
|
|
3637
|
+
const newBlocks = [];
|
|
3638
|
+
for (const scenario of missing) {
|
|
3639
|
+
const block = [];
|
|
3640
|
+
block.push(` it.skip('Scenario: ${scenario.name}', () => {`);
|
|
3641
|
+
for (const g of scenario.givens) {
|
|
3642
|
+
block.push(` // Given: ${g.text}`);
|
|
3643
|
+
}
|
|
3644
|
+
for (const w of scenario.whens) {
|
|
3645
|
+
block.push(` // When: ${w.text}`);
|
|
3646
|
+
}
|
|
3647
|
+
for (const t of scenario.thens) {
|
|
3648
|
+
block.push(` // Then: ${t.text}`);
|
|
3649
|
+
}
|
|
3650
|
+
block.push(" throw new Error('Not implemented');");
|
|
3651
|
+
block.push(" });");
|
|
3652
|
+
newBlocks.push(block.join("\n"));
|
|
3653
|
+
}
|
|
3654
|
+
const insertion = "\n" + newBlocks.join("\n\n") + "\n";
|
|
3655
|
+
const updated = content.slice(0, lastClose) + insertion + content.slice(lastClose);
|
|
3656
|
+
writeFileSync10(testFilePath, updated, "utf-8");
|
|
3657
|
+
return missing.length;
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
// src/infra/config.ts
|
|
3661
|
+
import { readFileSync as readFileSync14, existsSync as existsSync18 } from "fs";
|
|
3662
|
+
import { join as join18 } from "path";
|
|
3663
|
+
import { homedir } from "os";
|
|
3664
|
+
import yaml2 from "js-yaml";
|
|
3665
|
+
|
|
3666
|
+
// src/types/config.ts
|
|
3667
|
+
var DEFAULT_CONFIG = {
|
|
3668
|
+
project: {
|
|
3669
|
+
name: "",
|
|
3670
|
+
language: "",
|
|
3671
|
+
framework: "",
|
|
3672
|
+
profile: "normal",
|
|
3673
|
+
modules: {}
|
|
3674
|
+
},
|
|
3675
|
+
spec: {
|
|
3676
|
+
outputDir: "specs",
|
|
3677
|
+
defaultLanguage: "zh-CN",
|
|
3678
|
+
confidenceThreshold: 0.6
|
|
3679
|
+
},
|
|
3680
|
+
verify: {
|
|
3681
|
+
command: "",
|
|
3682
|
+
timeout: 300,
|
|
3683
|
+
reportFormat: ["json", "html"],
|
|
3684
|
+
mapping: {
|
|
3685
|
+
patterns: [
|
|
3686
|
+
{
|
|
3687
|
+
feature: "specs/{module}/{name}.feature",
|
|
3688
|
+
test: "tests/{module}/{Name}Test.*"
|
|
3689
|
+
},
|
|
3690
|
+
{
|
|
3691
|
+
feature: "specs/{module}/{name}.feature",
|
|
3692
|
+
test: "tests/{module}/{name}.test.*"
|
|
3693
|
+
},
|
|
3694
|
+
{
|
|
3695
|
+
feature: "specs/{module}/{name}.feature",
|
|
3696
|
+
test: "tests/{module}/test_{name}.py"
|
|
3697
|
+
}
|
|
3698
|
+
]
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
|
|
3703
|
+
// src/infra/config.ts
|
|
3704
|
+
var CONFIG_FILENAME = "config.yaml";
|
|
3705
|
+
var SPECLORE_DIR = ".speclore";
|
|
3706
|
+
function loadConfig(projectRoot2) {
|
|
3707
|
+
const globalConfig = loadGlobalConfig();
|
|
3708
|
+
const projectConfig = loadProjectConfig(projectRoot2);
|
|
3709
|
+
return mergeConfigs(DEFAULT_CONFIG, globalConfig, projectConfig);
|
|
3710
|
+
}
|
|
3711
|
+
function loadGlobalConfig() {
|
|
3712
|
+
const globalPath = join18(homedir(), SPECLORE_DIR, CONFIG_FILENAME);
|
|
3713
|
+
return readYamlIfExists(globalPath);
|
|
3714
|
+
}
|
|
3715
|
+
function loadProjectConfig(projectRoot2) {
|
|
3716
|
+
const projectPath = join18(projectRoot2, SPECLORE_DIR, CONFIG_FILENAME);
|
|
3717
|
+
return readYamlIfExists(projectPath);
|
|
3718
|
+
}
|
|
3719
|
+
function readYamlIfExists(filePath) {
|
|
3720
|
+
if (!existsSync18(filePath)) {
|
|
3721
|
+
return null;
|
|
3722
|
+
}
|
|
3723
|
+
try {
|
|
3724
|
+
const content = readFileSync14(filePath, "utf-8");
|
|
3725
|
+
const parsed = yaml2.load(content, { schema: yaml2.JSON_SCHEMA });
|
|
3726
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
3727
|
+
return parsed;
|
|
3728
|
+
}
|
|
3729
|
+
return null;
|
|
3730
|
+
} catch {
|
|
3731
|
+
return null;
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
function mergeConfigs(...configs) {
|
|
3735
|
+
const result = structuredClone(DEFAULT_CONFIG);
|
|
3736
|
+
for (const config of configs) {
|
|
3737
|
+
if (!config) continue;
|
|
3738
|
+
deepMerge(result, config);
|
|
3739
|
+
}
|
|
3740
|
+
validateConfig(result);
|
|
3741
|
+
return result;
|
|
3742
|
+
}
|
|
3743
|
+
function deepMerge(target, source) {
|
|
3744
|
+
for (const key of Object.keys(source)) {
|
|
3745
|
+
const targetVal = target[key];
|
|
3746
|
+
const sourceVal = source[key];
|
|
3747
|
+
if (sourceVal !== null && typeof sourceVal === "object" && !Array.isArray(sourceVal) && targetVal !== null && typeof targetVal === "object" && !Array.isArray(targetVal)) {
|
|
3748
|
+
deepMerge(
|
|
3749
|
+
targetVal,
|
|
3750
|
+
sourceVal
|
|
3751
|
+
);
|
|
3752
|
+
} else if (sourceVal !== void 0) {
|
|
3753
|
+
target[key] = sourceVal;
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
function validateConfig(config) {
|
|
3758
|
+
const validProfiles = ["strict", "normal", "minimal"];
|
|
3759
|
+
if (!validProfiles.includes(config.project.profile)) {
|
|
3760
|
+
throw new ConfigError(
|
|
3761
|
+
`Invalid profile "${config.project.profile}". Must be one of: ${validProfiles.join(", ")}`
|
|
3762
|
+
);
|
|
3763
|
+
}
|
|
3764
|
+
if (!config.spec.outputDir || config.spec.outputDir.trim() === "") {
|
|
3765
|
+
throw new ConfigError("spec.outputDir must not be empty");
|
|
3766
|
+
}
|
|
3767
|
+
if (config.spec.confidenceThreshold < 0 || config.spec.confidenceThreshold > 1) {
|
|
3768
|
+
throw new ConfigError("spec.confidenceThreshold must be between 0 and 1");
|
|
3769
|
+
}
|
|
3770
|
+
if (config.verify.timeout <= 0) {
|
|
3771
|
+
throw new ConfigError("verify.timeout must be a positive number (seconds)");
|
|
3772
|
+
}
|
|
3773
|
+
if (config.ai) {
|
|
3774
|
+
const validProviders = ["openai-compatible", "claude", "ollama"];
|
|
3775
|
+
if (!validProviders.includes(config.ai.provider)) {
|
|
3776
|
+
throw new ConfigError(
|
|
3777
|
+
`Invalid ai.provider "${config.ai.provider}". Must be one of: ${validProviders.join(", ")}`
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
const moduleNames = Object.keys(config.project.modules);
|
|
3782
|
+
for (const [name, mod] of Object.entries(config.project.modules)) {
|
|
3783
|
+
if (!mod.path) {
|
|
3784
|
+
throw new ConfigError(`Module "${name}" must have a "path" field`);
|
|
3785
|
+
}
|
|
3786
|
+
if (!mod.responsibility) {
|
|
3787
|
+
throw new ConfigError(`Module "${name}" must have a "responsibility" field`);
|
|
3788
|
+
}
|
|
3789
|
+
if (mod.dependsOn) {
|
|
3790
|
+
for (const dep of mod.dependsOn) {
|
|
3791
|
+
if (!moduleNames.includes(dep)) {
|
|
3792
|
+
throw new ConfigError(
|
|
3793
|
+
`Module "${name}" depends on "${dep}", which is not declared in modules`
|
|
3794
|
+
);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
var ConfigError = class extends Error {
|
|
3801
|
+
constructor(message) {
|
|
3802
|
+
super(`[SpecLore Config] ${message}`);
|
|
3803
|
+
this.name = "ConfigError";
|
|
3804
|
+
}
|
|
3805
|
+
};
|
|
3806
|
+
function generateDefaultConfigYaml(projectName) {
|
|
3807
|
+
return `# SpecLore Configuration
|
|
3808
|
+
# See: https://github.com/cheneyzhang93/speclore#configuration
|
|
3809
|
+
|
|
3810
|
+
project:
|
|
3811
|
+
name: ${projectName}
|
|
3812
|
+
language: typescript
|
|
3813
|
+
framework: ""
|
|
3814
|
+
profile: normal
|
|
3815
|
+
modules: {}
|
|
3816
|
+
# Example:
|
|
3817
|
+
# order:
|
|
3818
|
+
# path: src/order
|
|
3819
|
+
# responsibility: Order management and processing
|
|
3820
|
+
# dependsOn: [inventory, payment]
|
|
3821
|
+
|
|
3822
|
+
ai:
|
|
3823
|
+
provider: openai-compatible
|
|
3824
|
+
# baseUrl: https://api.openai.com/v1
|
|
3825
|
+
# model: gpt-4
|
|
3826
|
+
|
|
3827
|
+
spec:
|
|
3828
|
+
outputDir: specs
|
|
3829
|
+
defaultLanguage: zh-CN
|
|
3830
|
+
confidenceThreshold: 0.6
|
|
3831
|
+
|
|
3832
|
+
verify:
|
|
3833
|
+
command: ""
|
|
3834
|
+
timeout: 300
|
|
3835
|
+
reportFormat:
|
|
3836
|
+
- json
|
|
3837
|
+
- html
|
|
3838
|
+
mapping:
|
|
3839
|
+
patterns:
|
|
3840
|
+
- feature: "specs/{module}/{name}.feature"
|
|
3841
|
+
test: "tests/{module}/{name}.test.*"
|
|
3842
|
+
`;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
// src/mcp/tools.ts
|
|
3846
|
+
init_logger();
|
|
3847
|
+
import { join as join19 } from "path";
|
|
3848
|
+
import { readFileSync as readFileSync15, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync11 } from "fs";
|
|
3849
|
+
import { globSync as globSync4 } from "glob";
|
|
3850
|
+
import { toJSONSchema } from "zod";
|
|
3851
|
+
|
|
3852
|
+
// src/mcp/schemas.ts
|
|
3853
|
+
import { z } from "zod";
|
|
3854
|
+
var specInputSchema = z.object({
|
|
3855
|
+
source: z.string().max(5e4).describe("Requirement source: file path, URL, or direct text content. Max 50000 characters."),
|
|
3856
|
+
module: z.string().optional().describe("Target module name (optional, auto-detected from context).")
|
|
3857
|
+
});
|
|
3858
|
+
var codeInputSchema = z.object({
|
|
3859
|
+
features: z.array(z.string()).max(50).optional().describe("Feature file paths or glob patterns. Max 50 items."),
|
|
3860
|
+
tools: z.array(z.enum(["cursor", "claude", "qoder"])).optional().describe("AI tools to generate constraints for (optional, auto-detected).")
|
|
3861
|
+
});
|
|
3862
|
+
var verifyInputSchema = z.object({
|
|
3863
|
+
features: z.array(z.string()).optional().describe("Feature file paths or glob patterns to verify (optional, all if omitted)."),
|
|
3864
|
+
impact: z.boolean().optional().default(false).describe("Enable change impact analysis (compares git diff to find affected features).")
|
|
3865
|
+
});
|
|
3866
|
+
var statusInputSchema = z.object({
|
|
3867
|
+
feature: z.string().optional().describe("Specific feature file to check (optional, all if omitted).")
|
|
3868
|
+
});
|
|
3869
|
+
|
|
3870
|
+
// src/mcp/tools.ts
|
|
3871
|
+
var SPEC_TOOL_DESC = "Convert a requirement (text, file path, or URL) into structured BDD .feature files with scenario-level acceptance criteria.";
|
|
3872
|
+
var CODE_TOOL_DESC = "Generate AI coding constraint files for Cursor / Claude Code / Qoder based on .feature specs and project context. Requires .feature files to exist.";
|
|
3873
|
+
var VERIFY_TOOL_DESC = "Run tests and map results back to .feature scenarios, producing an acceptance report. Requires test scaffolding generated by speclore.code.";
|
|
3874
|
+
var STATUS_TOOL_DESC = "Get current project workflow status, feature states, and recommended next steps. Call this first to understand where the project stands.";
|
|
3875
|
+
var specJsonSchema = toJSONSchema(specInputSchema, { target: "draft-07" });
|
|
3876
|
+
var codeJsonSchema = toJSONSchema(codeInputSchema, { target: "draft-07" });
|
|
3877
|
+
var verifyJsonSchema = toJSONSchema(verifyInputSchema, { target: "draft-07" });
|
|
3878
|
+
async function executeSpecTool(args, projectRoot2) {
|
|
3879
|
+
if (!args.source || args.source.length > 5e4) {
|
|
3880
|
+
throw new Error("source must be between 1 and 50000 characters");
|
|
3881
|
+
}
|
|
3882
|
+
ensureProjectReady(projectRoot2);
|
|
3883
|
+
const config = loadConfig(projectRoot2);
|
|
3884
|
+
logger.info(`Reading requirement from: ${args.source.slice(0, 80)}...`);
|
|
3885
|
+
const req = await readRequirement(args.source);
|
|
3886
|
+
const specLoreDir = join19(projectRoot2, ".speclore");
|
|
3887
|
+
const context = loadContext(specLoreDir) ?? buildContext(projectRoot2, config);
|
|
3888
|
+
const feature = await generateFeature(req, context, config, projectRoot2);
|
|
3889
|
+
const createdFiles = [feature.path];
|
|
3890
|
+
const scenarios = [];
|
|
3891
|
+
for (const sc of feature.scenarios) {
|
|
3892
|
+
scenarios.push({
|
|
3893
|
+
feature: feature.featureName,
|
|
3894
|
+
name: sc.name,
|
|
3895
|
+
given: sc.givens.map((s) => s.text),
|
|
3896
|
+
when: sc.whens.map((s) => s.text),
|
|
3897
|
+
then: sc.thens.map((s) => s.text)
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
const stateManager = new StateManager(projectRoot2);
|
|
3901
|
+
stateManager.transitionFeature(feature.path, "specified");
|
|
3902
|
+
const constraints = `Generated ${createdFiles.length} feature file(s) with ${scenarios.length} scenario(s).`;
|
|
3903
|
+
const nextSteps = "Run `speclore code` to generate AI coding constraints, or ask your AI client to implement the scenarios.";
|
|
3904
|
+
return {
|
|
3905
|
+
createdFiles,
|
|
3906
|
+
scenarios,
|
|
3907
|
+
constraints,
|
|
3908
|
+
nextSteps,
|
|
3909
|
+
workflow: buildWorkflowInfo(feature.path, "specified", stateManager)
|
|
3910
|
+
};
|
|
3911
|
+
}
|
|
3912
|
+
async function executeCodeTool(args, projectRoot2) {
|
|
3913
|
+
if (args.features && args.features.length > 50) {
|
|
3914
|
+
throw new Error("features array must not exceed 50 items");
|
|
3915
|
+
}
|
|
3916
|
+
ensureProjectReady(projectRoot2);
|
|
3917
|
+
const config = loadConfig(projectRoot2);
|
|
3918
|
+
const specLoreDir = join19(projectRoot2, ".speclore");
|
|
3919
|
+
const context = loadContext(specLoreDir) ?? buildContext(projectRoot2, config);
|
|
3920
|
+
const featureFiles = resolveFeatureFiles(args.features, projectRoot2, config);
|
|
3921
|
+
if (featureFiles.length === 0) {
|
|
3922
|
+
const stateManager2 = new StateManager(projectRoot2);
|
|
3923
|
+
return {
|
|
3924
|
+
writtenFiles: [],
|
|
3925
|
+
constraintContent: "Error: No .feature files found.",
|
|
3926
|
+
moduleRules: [],
|
|
3927
|
+
activeConstraints: [],
|
|
3928
|
+
codingGuidance: "",
|
|
3929
|
+
scaffoldFiles: [],
|
|
3930
|
+
workflow: {
|
|
3931
|
+
currentState: "uninitialized",
|
|
3932
|
+
nextStep: "Call speclore.spec with your requirement text to create feature files.",
|
|
3933
|
+
projectSummary: {
|
|
3934
|
+
total: stateManager2.getProjectSummary().featureCount,
|
|
3935
|
+
...stateManager2.getProjectSummary().states
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
};
|
|
3939
|
+
}
|
|
3940
|
+
const writtenFiles = await generateConstraints(projectRoot2, featureFiles, context, config);
|
|
3941
|
+
const scaffoldFiles = generateTestScaffolding(projectRoot2, featureFiles, config);
|
|
3942
|
+
const stateManager = new StateManager(projectRoot2);
|
|
3943
|
+
for (const feature of featureFiles) {
|
|
3944
|
+
try {
|
|
3945
|
+
stateManager.transitionFeature(feature.path, "constrained", ["specified"]);
|
|
3946
|
+
stateManager.updateFeatureEntry(feature.path, {
|
|
3947
|
+
constraintFiles: writtenFiles,
|
|
3948
|
+
testFiles: scaffoldFiles.map((s) => s.testFile)
|
|
3949
|
+
});
|
|
3950
|
+
} catch {
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
const constraintContent = buildConstraintSummary(featureFiles, config);
|
|
3954
|
+
const moduleRules = buildModuleRules(config);
|
|
3955
|
+
const activeConstraints = buildActiveConstraints(writtenFiles, projectRoot2);
|
|
3956
|
+
const codingGuidance = buildCodingGuidance(featureFiles, context, config);
|
|
3957
|
+
const firstFeature = featureFiles[0]?.path;
|
|
3958
|
+
return {
|
|
3959
|
+
writtenFiles,
|
|
3960
|
+
constraintContent,
|
|
3961
|
+
moduleRules,
|
|
3962
|
+
activeConstraints,
|
|
3963
|
+
codingGuidance,
|
|
3964
|
+
scaffoldFiles,
|
|
3965
|
+
workflow: buildWorkflowInfo(firstFeature, "constrained", stateManager)
|
|
3966
|
+
};
|
|
3967
|
+
}
|
|
3968
|
+
async function executeVerifyTool(args, projectRoot2) {
|
|
3969
|
+
ensureProjectReady(projectRoot2);
|
|
3970
|
+
const config = loadConfig(projectRoot2);
|
|
3971
|
+
const specLoreDir = join19(projectRoot2, ".speclore");
|
|
3972
|
+
const context = loadContext(specLoreDir) ?? buildContext(projectRoot2, config);
|
|
3973
|
+
const featureFiles = resolveFeatureFiles(args.features, projectRoot2, config);
|
|
3974
|
+
const stateManager = new StateManager(projectRoot2);
|
|
3975
|
+
for (const feature of featureFiles) {
|
|
3976
|
+
const entry = stateManager.getFeatureState(feature.path);
|
|
3977
|
+
if (!entry || entry.state === "specified") {
|
|
3978
|
+
return {
|
|
3979
|
+
summary: "Error: No test scaffolding found.",
|
|
3980
|
+
passed: 0,
|
|
3981
|
+
failed: 0,
|
|
3982
|
+
unmapped: featureFiles.reduce((sum, f) => sum + f.scenarios.length, 0),
|
|
3983
|
+
details: [],
|
|
3984
|
+
failedDetails: [],
|
|
3985
|
+
workflow: {
|
|
3986
|
+
currentState: entry?.state ?? "uninitialized",
|
|
3987
|
+
nextStep: "Call speclore.code to generate constraints and test scaffolding.",
|
|
3988
|
+
projectSummary: {
|
|
3989
|
+
total: stateManager.getProjectSummary().featureCount,
|
|
3990
|
+
...stateManager.getProjectSummary().states
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
};
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
if (args.impact) {
|
|
3997
|
+
logger.info("Running impact analysis...");
|
|
3998
|
+
const impact = analyzeImpact(projectRoot2, context, config);
|
|
3999
|
+
context.impactAnalysis = {
|
|
4000
|
+
changedFiles: impact.changedFiles,
|
|
4001
|
+
affectedModules: impact.affectedModules,
|
|
4002
|
+
affectedFeatures: impact.affectedFeatures
|
|
4003
|
+
};
|
|
4004
|
+
}
|
|
4005
|
+
const report = await runVerification(projectRoot2, featureFiles, config);
|
|
4006
|
+
const { summary } = report;
|
|
4007
|
+
if (summary.failed === 0 && summary.unmapped === 0 && summary.passed > 0) {
|
|
4008
|
+
for (const feature of featureFiles) {
|
|
4009
|
+
try {
|
|
4010
|
+
stateManager.transitionFeature(feature.path, "verified", ["coding", "constrained"]);
|
|
4011
|
+
} catch {
|
|
4012
|
+
}
|
|
4013
|
+
stateManager.recordVerify(feature.path, {
|
|
4014
|
+
passed: summary.passed,
|
|
4015
|
+
failed: summary.failed,
|
|
4016
|
+
unmapped: summary.unmapped
|
|
4017
|
+
});
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
const firstFeature = featureFiles[0]?.path;
|
|
4021
|
+
const currentState = summary.failed === 0 && summary.unmapped === 0 && summary.passed > 0 ? "verified" : "coding";
|
|
4022
|
+
return {
|
|
4023
|
+
summary: `${summary.passed}/${summary.totalScenarios} scenarios passed (${summary.passRate})`,
|
|
4024
|
+
passed: summary.passed,
|
|
4025
|
+
failed: summary.failed,
|
|
4026
|
+
unmapped: summary.unmapped,
|
|
4027
|
+
details: report.features,
|
|
4028
|
+
failedDetails: report.failedDetails,
|
|
4029
|
+
workflow: buildWorkflowInfo(firstFeature, currentState, stateManager)
|
|
4030
|
+
};
|
|
4031
|
+
}
|
|
4032
|
+
function ensureProjectReady(projectRoot2) {
|
|
4033
|
+
const specLoreDir = join19(projectRoot2, ".speclore");
|
|
4034
|
+
const configPath = join19(specLoreDir, "config.yaml");
|
|
4035
|
+
let configCreated = false;
|
|
4036
|
+
if (!existsSync19(specLoreDir)) {
|
|
4037
|
+
mkdirSync10(specLoreDir, { recursive: true });
|
|
4038
|
+
}
|
|
4039
|
+
if (!existsSync19(configPath)) {
|
|
4040
|
+
const projectName = projectRoot2.split(/[/\\]/).pop() ?? "my-project";
|
|
4041
|
+
writeFileSync11(configPath, generateDefaultConfigYaml(projectName), "utf-8");
|
|
4042
|
+
configCreated = true;
|
|
4043
|
+
logger.info(`Auto-created default config: ${configPath}`);
|
|
4044
|
+
}
|
|
4045
|
+
const stateManager = new StateManager(projectRoot2);
|
|
4046
|
+
stateManager.ensureInitialized();
|
|
4047
|
+
const config = loadConfig(projectRoot2);
|
|
4048
|
+
const specsDir = join19(projectRoot2, config.spec.outputDir);
|
|
4049
|
+
const migrated = stateManager.migrateFeatures(specsDir);
|
|
4050
|
+
if (migrated > 0) {
|
|
4051
|
+
logger.info(`Migrated ${migrated} existing .feature file(s) into state tracking.`);
|
|
4052
|
+
}
|
|
4053
|
+
return { initialized: true, configCreated, migrated };
|
|
4054
|
+
}
|
|
4055
|
+
function buildWorkflowInfo(featurePath, currentState, stateManager) {
|
|
4056
|
+
const summary = stateManager.getProjectSummary();
|
|
4057
|
+
const nextStep = resolveNextStep(currentState, summary);
|
|
4058
|
+
return {
|
|
4059
|
+
feature: featurePath,
|
|
4060
|
+
currentState,
|
|
4061
|
+
nextStep,
|
|
4062
|
+
projectSummary: {
|
|
4063
|
+
total: summary.featureCount,
|
|
4064
|
+
...summary.states
|
|
4065
|
+
}
|
|
4066
|
+
};
|
|
4067
|
+
}
|
|
4068
|
+
function resolveNextStep(currentState, _summary) {
|
|
4069
|
+
switch (currentState) {
|
|
4070
|
+
case "uninitialized":
|
|
4071
|
+
return "Call speclore.spec with your requirement text to create feature files.";
|
|
4072
|
+
case "specified":
|
|
4073
|
+
return "Call speclore.code to generate constraints and test scaffolding.";
|
|
4074
|
+
case "constrained":
|
|
4075
|
+
return "Start coding. Constraints and test scaffolding are ready. Fill in test implementations.";
|
|
4076
|
+
case "coding":
|
|
4077
|
+
return "Run speclore.verify to check acceptance status.";
|
|
4078
|
+
case "verified":
|
|
4079
|
+
return "All features verified. Add new requirements with speclore.spec.";
|
|
4080
|
+
default:
|
|
4081
|
+
return "";
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
function resolveFeatureFiles(patterns, projectRoot2, config) {
|
|
4085
|
+
const specsDir = join19(projectRoot2, config.spec.outputDir);
|
|
4086
|
+
const searchPatterns = patterns && patterns.length > 0 ? patterns : [`${specsDir}/**/*.feature`];
|
|
4087
|
+
const files = [];
|
|
4088
|
+
for (const pattern of searchPatterns) {
|
|
4089
|
+
const matches = globSync4(pattern, { cwd: projectRoot2, absolute: true });
|
|
4090
|
+
for (const filePath of matches) {
|
|
4091
|
+
if (existsSync19(filePath)) {
|
|
4092
|
+
const content = readFileSync15(filePath, "utf-8");
|
|
4093
|
+
files.push(parseFeatureFile(filePath, content));
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
return files;
|
|
4098
|
+
}
|
|
4099
|
+
function parseFeatureFile(filePath, content) {
|
|
4100
|
+
const featureMatch = content.match(/Feature:\s*(.+)/);
|
|
4101
|
+
const featureName = featureMatch?.[1]?.trim() ?? filePath;
|
|
4102
|
+
const scenarios = [];
|
|
4103
|
+
const tags = [];
|
|
4104
|
+
const lines = content.split("\n");
|
|
4105
|
+
for (const line of lines) {
|
|
4106
|
+
const tagMatch = line.trim().match(/^(@\S+(?:\s+@\S+)*)/);
|
|
4107
|
+
if (tagMatch) {
|
|
4108
|
+
tags.push(...tagMatch[1].split(/\s+/).filter((t) => t.startsWith("@")));
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
const scenarioRegex = /Scenario(?: Outline)?:\s*(.+)/g;
|
|
4112
|
+
let match;
|
|
4113
|
+
while ((match = scenarioRegex.exec(content)) !== null) {
|
|
4114
|
+
scenarios.push({
|
|
4115
|
+
name: match[1].trim(),
|
|
4116
|
+
givens: [],
|
|
4117
|
+
whens: [],
|
|
4118
|
+
thens: [],
|
|
4119
|
+
tags: []
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
return {
|
|
4123
|
+
path: filePath,
|
|
4124
|
+
featureName,
|
|
4125
|
+
scenarios,
|
|
4126
|
+
tags,
|
|
4127
|
+
confidence: 1,
|
|
4128
|
+
needsReview: []
|
|
4129
|
+
};
|
|
4130
|
+
}
|
|
4131
|
+
function buildConstraintSummary(features, config) {
|
|
4132
|
+
const moduleCount = Object.keys(config.project.modules).length;
|
|
4133
|
+
const scenarioCount = features.reduce((sum, f) => sum + f.scenarios.length, 0);
|
|
4134
|
+
return `Constraints for ${moduleCount} module(s), ${features.length} feature(s), ${scenarioCount} scenario(s). Profile: ${config.project.profile}.`;
|
|
4135
|
+
}
|
|
4136
|
+
function buildModuleRules(config) {
|
|
4137
|
+
const rules = [];
|
|
4138
|
+
for (const [name, mod] of Object.entries(config.project.modules)) {
|
|
4139
|
+
rules.push({
|
|
4140
|
+
module: name,
|
|
4141
|
+
boundaries: {
|
|
4142
|
+
name,
|
|
4143
|
+
responsibility: mod.responsibility,
|
|
4144
|
+
publicApis: mod.apis ?? [],
|
|
4145
|
+
internalObjects: mod.entities ?? [],
|
|
4146
|
+
dependsOn: mod.dependsOn ?? []
|
|
4147
|
+
},
|
|
4148
|
+
namingConventions: [],
|
|
4149
|
+
forbiddenPatterns: []
|
|
4150
|
+
});
|
|
4151
|
+
}
|
|
4152
|
+
return rules;
|
|
4153
|
+
}
|
|
4154
|
+
function buildActiveConstraints(writtenFiles, _projectRoot) {
|
|
4155
|
+
return writtenFiles.map((file) => ({
|
|
4156
|
+
file,
|
|
4157
|
+
scope: "project",
|
|
4158
|
+
appliesTo: "**/*",
|
|
4159
|
+
summary: `SpecLore constraint file \u2014 module boundaries and coding rules`
|
|
4160
|
+
}));
|
|
4161
|
+
}
|
|
4162
|
+
function buildCodingGuidance(_features, context, config) {
|
|
4163
|
+
const parts = [];
|
|
4164
|
+
parts.push(`Project: ${config.project.name}`);
|
|
4165
|
+
parts.push(`Language: ${context.projectSummary.language}, Framework: ${context.projectSummary.framework}`);
|
|
4166
|
+
parts.push(`Modules: ${Object.keys(config.project.modules).join(", ")}`);
|
|
4167
|
+
parts.push(`Profile: ${config.project.profile}`);
|
|
4168
|
+
if (context.moduleBoundaries.length > 0) {
|
|
4169
|
+
parts.push("Module boundaries: respect module separation, do not cross-reference internal objects.");
|
|
4170
|
+
}
|
|
4171
|
+
const guidance = parts.join(". ") + ".";
|
|
4172
|
+
return guidance.slice(0, 2e3);
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
// src/setup/detector.ts
|
|
4176
|
+
import { existsSync as existsSync20 } from "fs";
|
|
4177
|
+
import { join as join20 } from "path";
|
|
4178
|
+
function detectAITools2(projectRoot2) {
|
|
4179
|
+
const tools = [];
|
|
4180
|
+
const cursorFiles = [".cursor/mcp.json", ".cursor/rules"];
|
|
4181
|
+
const cursorConfigs = cursorFiles.filter((f) => existsSync20(join20(projectRoot2, f)));
|
|
4182
|
+
tools.push({
|
|
4183
|
+
tool: "cursor",
|
|
4184
|
+
detected: existsSync20(join20(projectRoot2, ".cursor")),
|
|
4185
|
+
configFiles: cursorConfigs
|
|
4186
|
+
});
|
|
4187
|
+
const claudeFiles = [".claude/", ".mcp.json", "CLAUDE.md"];
|
|
4188
|
+
const claudeConfigs = claudeFiles.filter((f) => existsSync20(join20(projectRoot2, f)));
|
|
4189
|
+
tools.push({
|
|
4190
|
+
tool: "claude",
|
|
4191
|
+
detected: claudeConfigs.length > 0,
|
|
4192
|
+
configFiles: claudeConfigs
|
|
4193
|
+
});
|
|
4194
|
+
const qoderFiles = [".qoder/mcp.json", ".qoder/rules"];
|
|
4195
|
+
const qoderConfigs = qoderFiles.filter((f) => existsSync20(join20(projectRoot2, f)));
|
|
4196
|
+
tools.push({
|
|
4197
|
+
tool: "qoder",
|
|
4198
|
+
detected: existsSync20(join20(projectRoot2, ".qoder")),
|
|
4199
|
+
configFiles: qoderConfigs
|
|
4200
|
+
});
|
|
4201
|
+
return tools.filter((t) => t.detected);
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
// src/mcp/status.ts
|
|
4205
|
+
init_logger();
|
|
4206
|
+
import { readFileSync as readFileSync16, existsSync as existsSync21, mkdirSync as mkdirSync11, writeFileSync as writeFileSync12 } from "fs";
|
|
4207
|
+
import { join as join21 } from "path";
|
|
4208
|
+
import { globSync as globSync5 } from "glob";
|
|
4209
|
+
function executeStatusTool(args, projectRoot2) {
|
|
4210
|
+
const { configCreated } = ensureProjectReadyForStatus(projectRoot2);
|
|
4211
|
+
const config = loadConfig(projectRoot2);
|
|
4212
|
+
const stateManager = new StateManager(projectRoot2);
|
|
4213
|
+
const summary = stateManager.getProjectSummary();
|
|
4214
|
+
const aiTools = detectAITools2(projectRoot2);
|
|
4215
|
+
const aiToolsDetected = aiTools.map((t) => t.tool);
|
|
4216
|
+
const featureEntries = stateManager.listFeatures();
|
|
4217
|
+
const features = [];
|
|
4218
|
+
for (const { path, state: entry } of featureEntries) {
|
|
4219
|
+
if (args.feature && !path.includes(args.feature)) {
|
|
4220
|
+
continue;
|
|
4221
|
+
}
|
|
4222
|
+
let scenarioCount = 0;
|
|
4223
|
+
if (existsSync21(path)) {
|
|
4224
|
+
try {
|
|
4225
|
+
const content = readFileSync16(path, "utf-8");
|
|
4226
|
+
const matches = content.match(/Scenario(?: Outline)?:/g);
|
|
4227
|
+
scenarioCount = matches?.length ?? 0;
|
|
4228
|
+
} catch {
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
features.push({
|
|
4232
|
+
file: path,
|
|
4233
|
+
state: entry.state,
|
|
4234
|
+
scenarios: scenarioCount,
|
|
4235
|
+
constraintFiles: entry.constraintFiles,
|
|
4236
|
+
testFiles: entry.testFiles,
|
|
4237
|
+
lastVerify: entry.lastVerify ? { passed: entry.lastVerify.passed, failed: entry.lastVerify.failed, timestamp: entry.lastVerify.timestamp } : void 0
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
const specsDir = join21(projectRoot2, config.spec.outputDir);
|
|
4241
|
+
const allFeatureFiles = globSync5(`${specsDir}/**/*.feature`, { cwd: projectRoot2, absolute: true });
|
|
4242
|
+
const trackedPaths = new Set(featureEntries.map((f) => f.path));
|
|
4243
|
+
for (const filePath of allFeatureFiles) {
|
|
4244
|
+
if (trackedPaths.has(filePath)) continue;
|
|
4245
|
+
if (args.feature && !filePath.includes(args.feature)) continue;
|
|
4246
|
+
let scenarioCount = 0;
|
|
4247
|
+
try {
|
|
4248
|
+
const content = readFileSync16(filePath, "utf-8");
|
|
4249
|
+
const matches = content.match(/Scenario(?: Outline)?:/g);
|
|
4250
|
+
scenarioCount = matches?.length ?? 0;
|
|
4251
|
+
} catch {
|
|
4252
|
+
}
|
|
4253
|
+
features.push({
|
|
4254
|
+
file: filePath,
|
|
4255
|
+
state: "specified",
|
|
4256
|
+
scenarios: scenarioCount,
|
|
4257
|
+
constraintFiles: [],
|
|
4258
|
+
testFiles: []
|
|
4259
|
+
});
|
|
4260
|
+
}
|
|
4261
|
+
const recommendedActions = buildRecommendedActions(features, summary, config.verify.command);
|
|
4262
|
+
return {
|
|
4263
|
+
project: {
|
|
4264
|
+
initialized: true,
|
|
4265
|
+
configCreated,
|
|
4266
|
+
testCommand: config.verify.command,
|
|
4267
|
+
aiToolsDetected
|
|
4268
|
+
},
|
|
4269
|
+
features,
|
|
4270
|
+
summary: {
|
|
4271
|
+
total: features.length,
|
|
4272
|
+
specified: features.filter((f) => f.state === "specified").length,
|
|
4273
|
+
constrained: features.filter((f) => f.state === "constrained").length,
|
|
4274
|
+
coding: features.filter((f) => f.state === "coding").length,
|
|
4275
|
+
verified: features.filter((f) => f.state === "verified").length
|
|
4276
|
+
},
|
|
4277
|
+
recommendedActions
|
|
4278
|
+
};
|
|
4279
|
+
}
|
|
4280
|
+
function buildRecommendedActions(features, summary, testCommand) {
|
|
4281
|
+
const actions = [];
|
|
4282
|
+
if (features.length === 0) {
|
|
4283
|
+
actions.push("Call speclore.spec with your requirement to create feature files.");
|
|
4284
|
+
return actions;
|
|
4285
|
+
}
|
|
4286
|
+
if (summary.states.specified > 0) {
|
|
4287
|
+
actions.push("Call speclore.code to generate constraints and test scaffolding for specified features.");
|
|
4288
|
+
}
|
|
4289
|
+
if (summary.states.constrained > 0) {
|
|
4290
|
+
actions.push("Fill in test scaffolding implementations, then start coding.");
|
|
4291
|
+
}
|
|
4292
|
+
if (summary.states.coding > 0) {
|
|
4293
|
+
if (!testCommand) {
|
|
4294
|
+
actions.push("Configure verify.command in .speclore/config.yaml, then call speclore.verify.");
|
|
4295
|
+
} else {
|
|
4296
|
+
actions.push("Call speclore.verify to check acceptance status.");
|
|
4297
|
+
}
|
|
4298
|
+
}
|
|
4299
|
+
if (summary.states.verified === features.length && features.length > 0) {
|
|
4300
|
+
actions.push("All features verified. Add new requirements with speclore.spec.");
|
|
4301
|
+
}
|
|
4302
|
+
return actions;
|
|
4303
|
+
}
|
|
4304
|
+
function ensureProjectReadyForStatus(projectRoot2) {
|
|
4305
|
+
const specLoreDir = join21(projectRoot2, ".speclore");
|
|
4306
|
+
const configPath = join21(specLoreDir, "config.yaml");
|
|
4307
|
+
let configCreated = false;
|
|
4308
|
+
if (!existsSync21(specLoreDir)) {
|
|
4309
|
+
mkdirSync11(specLoreDir, { recursive: true });
|
|
4310
|
+
}
|
|
4311
|
+
if (!existsSync21(configPath)) {
|
|
4312
|
+
const projectName = projectRoot2.split(/[/\\]/).pop() ?? "my-project";
|
|
4313
|
+
writeFileSync12(configPath, generateDefaultConfigYaml(projectName), "utf-8");
|
|
4314
|
+
configCreated = true;
|
|
4315
|
+
logger.info(`Auto-created default config: ${configPath}`);
|
|
4316
|
+
}
|
|
4317
|
+
const stateManager = new StateManager(projectRoot2);
|
|
4318
|
+
stateManager.ensureInitialized();
|
|
4319
|
+
const config = loadConfig(projectRoot2);
|
|
4320
|
+
const specsDir = join21(projectRoot2, config.spec.outputDir);
|
|
4321
|
+
const migrated = stateManager.migrateFeatures(specsDir);
|
|
4322
|
+
if (migrated > 0) {
|
|
4323
|
+
logger.info(`Migrated ${migrated} existing .feature file(s) into state tracking.`);
|
|
4324
|
+
}
|
|
4325
|
+
return { initialized: true, configCreated, migrated };
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
// src/mcp/server.ts
|
|
4329
|
+
import { mkdirSync as mkdirSync12 } from "fs";
|
|
4330
|
+
var server = new McpServer(
|
|
4331
|
+
{ name: "speclore", version: VERSION },
|
|
4332
|
+
{ capabilities: { tools: {} } }
|
|
4333
|
+
);
|
|
4334
|
+
server.registerTool(
|
|
4335
|
+
"speclore.spec",
|
|
4336
|
+
{
|
|
4337
|
+
description: SPEC_TOOL_DESC,
|
|
4338
|
+
inputSchema: specInputSchema.shape
|
|
4339
|
+
},
|
|
4340
|
+
async (args) => {
|
|
4341
|
+
if (!args.source || args.source.length > 5e4) {
|
|
4342
|
+
return { content: [{ type: "text", text: "Error: source must be between 1 and 50000 characters" }], isError: true };
|
|
4343
|
+
}
|
|
4344
|
+
const result = await executeSpecTool(
|
|
4345
|
+
{ source: args.source, module: args.module },
|
|
4346
|
+
getProjectRoot()
|
|
4347
|
+
);
|
|
4348
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
4349
|
+
}
|
|
4350
|
+
);
|
|
4351
|
+
server.registerTool(
|
|
4352
|
+
"speclore.code",
|
|
4353
|
+
{
|
|
4354
|
+
description: CODE_TOOL_DESC,
|
|
4355
|
+
inputSchema: codeInputSchema.shape
|
|
4356
|
+
},
|
|
4357
|
+
async (args) => {
|
|
4358
|
+
if (args.features && args.features.length > 50) {
|
|
4359
|
+
return { content: [{ type: "text", text: "Error: features array must not exceed 50 items" }], isError: true };
|
|
4360
|
+
}
|
|
4361
|
+
const result = await executeCodeTool(
|
|
4362
|
+
{ features: args.features, tools: args.tools },
|
|
4363
|
+
getProjectRoot()
|
|
4364
|
+
);
|
|
4365
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
4366
|
+
}
|
|
4367
|
+
);
|
|
4368
|
+
server.registerTool(
|
|
4369
|
+
"speclore.verify",
|
|
4370
|
+
{
|
|
4371
|
+
description: VERIFY_TOOL_DESC,
|
|
4372
|
+
inputSchema: verifyInputSchema.shape
|
|
4373
|
+
},
|
|
4374
|
+
async (args) => {
|
|
4375
|
+
const result = await executeVerifyTool(
|
|
4376
|
+
{ features: args.features, impact: args.impact },
|
|
4377
|
+
getProjectRoot()
|
|
4378
|
+
);
|
|
4379
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
4380
|
+
}
|
|
4381
|
+
);
|
|
4382
|
+
server.registerTool(
|
|
4383
|
+
"speclore.status",
|
|
4384
|
+
{
|
|
4385
|
+
description: STATUS_TOOL_DESC,
|
|
4386
|
+
inputSchema: statusInputSchema.shape
|
|
4387
|
+
},
|
|
4388
|
+
async (args) => {
|
|
4389
|
+
const result = executeStatusTool(
|
|
4390
|
+
{ feature: args.feature },
|
|
4391
|
+
getProjectRoot()
|
|
4392
|
+
);
|
|
4393
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
4394
|
+
}
|
|
4395
|
+
);
|
|
4396
|
+
var projectRoot = process.env.SPECLORE_PROJECT_ROOT ?? process.cwd();
|
|
4397
|
+
function getProjectRoot() {
|
|
4398
|
+
return projectRoot;
|
|
4399
|
+
}
|
|
4400
|
+
async function startMcpServer() {
|
|
4401
|
+
projectRoot = process.env.SPECLORE_PROJECT_ROOT ?? process.cwd();
|
|
4402
|
+
const specLoreDir = `${projectRoot}/.speclore`;
|
|
4403
|
+
try {
|
|
4404
|
+
mkdirSync12(specLoreDir, { recursive: true });
|
|
4405
|
+
const locked = acquireLock(specLoreDir);
|
|
4406
|
+
if (!locked) {
|
|
4407
|
+
logger.warn("Could not acquire lock \u2014 another SpecLore instance may be running.");
|
|
4408
|
+
}
|
|
4409
|
+
} catch {
|
|
4410
|
+
logger.warn("Could not acquire lock \u2014 .speclore/ directory may not exist.");
|
|
4411
|
+
}
|
|
4412
|
+
const transport = new StdioServerTransport();
|
|
4413
|
+
await server.connect(transport);
|
|
4414
|
+
logger.debug("MCP Server started (stdio transport)");
|
|
4415
|
+
const cleanup = () => {
|
|
4416
|
+
try {
|
|
4417
|
+
releaseLock(`${projectRoot}/.speclore`);
|
|
4418
|
+
} catch {
|
|
4419
|
+
}
|
|
4420
|
+
};
|
|
4421
|
+
process.on("SIGINT", () => {
|
|
4422
|
+
cleanup();
|
|
4423
|
+
process.exit(0);
|
|
4424
|
+
});
|
|
4425
|
+
process.on("SIGTERM", () => {
|
|
4426
|
+
cleanup();
|
|
4427
|
+
process.exit(0);
|
|
4428
|
+
});
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
// src/bin/mcp.ts
|
|
4432
|
+
startMcpServer().catch((err) => {
|
|
4433
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4434
|
+
console.error(`MCP server error: ${message}`);
|
|
4435
|
+
process.exit(1);
|
|
4436
|
+
});
|
|
4437
|
+
//# sourceMappingURL=server.js.map
|