speclock 4.5.7 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -9
- package/package.json +2 -2
- package/src/cli/index.js +1 -1
- package/src/core/code-graph.js +635 -0
- package/src/core/compliance.js +1 -1
- package/src/core/conflict.js +1 -0
- package/src/core/engine.js +32 -2
- package/src/core/llm-checker.js +3 -156
- package/src/core/llm-provider.js +208 -0
- package/src/core/memory.js +115 -0
- package/src/core/spec-compiler.js +315 -0
- package/src/core/typed-constraints.js +408 -0
- package/src/dashboard/index.html +5 -4
- package/src/mcp/http-server.js +598 -7
- package/src/mcp/server.js +383 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// ===================================================================
|
|
2
|
+
// SpecLock Spec Compiler — Natural Language → Structured Constraints
|
|
3
|
+
// Turns messy human inputs (PRDs, READMEs, chat logs, architecture
|
|
4
|
+
// docs) into normalized, machine-enforceable SpecLock constraints.
|
|
5
|
+
// Uses Gemini Flash by default (cheapest, free tier available).
|
|
6
|
+
//
|
|
7
|
+
// Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
8
|
+
// ===================================================================
|
|
9
|
+
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { getConfig, callLLM, parseJsonResponse } from "./llm-provider.js";
|
|
13
|
+
import { readBrain } from "./storage.js";
|
|
14
|
+
import { addLock, addTypedLock, addDecision, addNote, ensureInit } from "./memory.js";
|
|
15
|
+
import { validateTypedLock } from "./typed-constraints.js";
|
|
16
|
+
|
|
17
|
+
// --- System prompt for spec compilation ---
|
|
18
|
+
|
|
19
|
+
const COMPILER_SYSTEM_PROMPT = `You are the SpecLock Spec Compiler — an expert at extracting structured constraints from natural language documents.
|
|
20
|
+
|
|
21
|
+
Your job: Read the input text (which could be a PRD, README, architecture doc, chat conversation, or informal instructions) and extract ALL enforceable constraints, decisions, and context notes.
|
|
22
|
+
|
|
23
|
+
## Extraction Rules
|
|
24
|
+
|
|
25
|
+
### Text Locks (constraints/prohibitions/requirements)
|
|
26
|
+
Extract as "locks" — these are rules that must NEVER be violated:
|
|
27
|
+
- Prohibitions: "don't touch X", "never change Y", "leave Z alone", "X is off-limits"
|
|
28
|
+
- Requirements: "must always use X", "never deploy without Y", "always run Z before deploying"
|
|
29
|
+
- Protections: "database is X, don't change it", "the API is done, don't mess with it"
|
|
30
|
+
|
|
31
|
+
### Typed Locks (measurable/quantifiable constraints)
|
|
32
|
+
Extract as "typedLocks" when constraints have specific numbers:
|
|
33
|
+
- numerical: "response time must be under 200ms" → { constraintType: "numerical", metric: "response_time_ms", operator: "<=", value: 200, unit: "ms" }
|
|
34
|
+
- range: "keep temperature between 20-25C" → { constraintType: "range", metric: "temperature_celsius", min: 20, max: 25, unit: "C" }
|
|
35
|
+
- state: "never go from production to debug mode" → { constraintType: "state", metric: "system_mode", entity: "system", forbidden: [{ from: "production", to: "debug" }] }
|
|
36
|
+
- temporal: "heartbeat must occur every 30 seconds" → { constraintType: "temporal", metric: "heartbeat_interval_s", operator: "<=", value: 30, unit: "s" }
|
|
37
|
+
|
|
38
|
+
### Decisions (architecture/technology choices)
|
|
39
|
+
Extract as "decisions" — these are choices that have been made:
|
|
40
|
+
- Technology: "use React", "backend is FastAPI", "deploy on Vercel"
|
|
41
|
+
- Architecture: "microservices architecture", "REST API, not GraphQL"
|
|
42
|
+
- Process: "always review PRs before merge", "use trunk-based development"
|
|
43
|
+
|
|
44
|
+
### Notes (context/reference information)
|
|
45
|
+
Extract as "notes" — useful context that doesn't fit above:
|
|
46
|
+
- Background info: "the old system used PHP", "migration from MongoDB to Postgres"
|
|
47
|
+
- References: "API docs at example.com/docs", "design spec in Figma"
|
|
48
|
+
- Context: "this was built for a hackathon", "client is in healthcare"
|
|
49
|
+
|
|
50
|
+
## Output Format
|
|
51
|
+
Respond with ONLY valid JSON (no markdown, no explanation):
|
|
52
|
+
{
|
|
53
|
+
"locks": [
|
|
54
|
+
{ "text": "Never modify auth files", "tags": ["security"] }
|
|
55
|
+
],
|
|
56
|
+
"typedLocks": [
|
|
57
|
+
{
|
|
58
|
+
"constraintType": "numerical",
|
|
59
|
+
"metric": "response_time_ms",
|
|
60
|
+
"operator": "<=",
|
|
61
|
+
"value": 200,
|
|
62
|
+
"unit": "ms",
|
|
63
|
+
"description": "API response time must stay under 200ms"
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"decisions": [
|
|
67
|
+
{ "text": "Use React for frontend", "tags": ["architecture"] }
|
|
68
|
+
],
|
|
69
|
+
"notes": [
|
|
70
|
+
{ "text": "Payment integration uses Stripe API v3" }
|
|
71
|
+
],
|
|
72
|
+
"summary": "Brief summary of what was extracted"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
## Important Rules
|
|
76
|
+
1. Be thorough — extract EVERYTHING, even if implicit
|
|
77
|
+
2. Deduplicate — don't create two locks that say the same thing
|
|
78
|
+
3. Be specific — "Never modify auth" is better than "Don't change things"
|
|
79
|
+
4. Use appropriate tags — security, architecture, database, api, performance, deployment, testing, etc.
|
|
80
|
+
5. If the text mentions a metric with a number, ALWAYS make it a typedLock, not a text lock
|
|
81
|
+
6. If there's nothing to extract, return empty arrays — don't hallucinate constraints
|
|
82
|
+
7. Keep lock text concise but clear — one constraint per lock`;
|
|
83
|
+
|
|
84
|
+
// --- Core functions ---
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compile natural language text into structured SpecLock constraints.
|
|
88
|
+
* @param {string} root - Project root path (for LLM config)
|
|
89
|
+
* @param {string} inputText - Raw text to compile (PRD, README, chat, etc.)
|
|
90
|
+
* @param {{ source?: string }} options
|
|
91
|
+
* @returns {Promise<Object>} Compiled result with locks, typedLocks, decisions, notes
|
|
92
|
+
*/
|
|
93
|
+
export async function compileSpec(root, inputText, options = {}) {
|
|
94
|
+
if (!inputText || typeof inputText !== "string" || inputText.trim().length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
success: false,
|
|
97
|
+
error: "Input text is empty or invalid",
|
|
98
|
+
locks: [],
|
|
99
|
+
typedLocks: [],
|
|
100
|
+
decisions: [],
|
|
101
|
+
notes: [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check for LLM configuration
|
|
106
|
+
const config = getConfig(root);
|
|
107
|
+
if (!config) {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
error: "Spec Compiler requires an LLM API key. Set GEMINI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY environment variable.",
|
|
111
|
+
locks: [],
|
|
112
|
+
typedLocks: [],
|
|
113
|
+
decisions: [],
|
|
114
|
+
notes: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Truncate very long inputs to avoid token limits
|
|
119
|
+
const maxChars = 30000;
|
|
120
|
+
const truncated = inputText.length > maxChars
|
|
121
|
+
? inputText.substring(0, maxChars) + "\n\n[... truncated ...]"
|
|
122
|
+
: inputText;
|
|
123
|
+
|
|
124
|
+
const userPrompt = `Extract all constraints, decisions, and notes from this document:\n\n---\n${truncated}\n---`;
|
|
125
|
+
|
|
126
|
+
// Call LLM with longer timeout for compilation
|
|
127
|
+
const llmResult = await callLLM(root, COMPILER_SYSTEM_PROMPT, userPrompt, {
|
|
128
|
+
timeout: 10000,
|
|
129
|
+
maxTokens: 2000,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!llmResult) {
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
error: "LLM call failed. Check your API key and network connection.",
|
|
136
|
+
locks: [],
|
|
137
|
+
typedLocks: [],
|
|
138
|
+
decisions: [],
|
|
139
|
+
notes: [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Validate and normalize the result
|
|
144
|
+
const result = normalizeCompilerOutput(llmResult);
|
|
145
|
+
result.success = true;
|
|
146
|
+
result.inputLength = inputText.length;
|
|
147
|
+
result.source = options.source || "spec-compiler";
|
|
148
|
+
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Compile a file into structured constraints.
|
|
154
|
+
* @param {string} root - Project root path
|
|
155
|
+
* @param {string} filePath - Path to file (.md, .txt, .yaml, .json, etc.)
|
|
156
|
+
* @param {{ source?: string }} options
|
|
157
|
+
* @returns {Promise<Object>}
|
|
158
|
+
*/
|
|
159
|
+
export async function compileFile(root, filePath, options = {}) {
|
|
160
|
+
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
|
|
161
|
+
|
|
162
|
+
if (!fs.existsSync(fullPath)) {
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
error: `File not found: ${filePath}`,
|
|
166
|
+
locks: [],
|
|
167
|
+
typedLocks: [],
|
|
168
|
+
decisions: [],
|
|
169
|
+
notes: [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
174
|
+
if (!content.trim()) {
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
error: `File is empty: ${filePath}`,
|
|
178
|
+
locks: [],
|
|
179
|
+
typedLocks: [],
|
|
180
|
+
decisions: [],
|
|
181
|
+
notes: [],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = await compileSpec(root, content, {
|
|
186
|
+
source: options.source || `file:${path.basename(filePath)}`,
|
|
187
|
+
});
|
|
188
|
+
result.filePath = filePath;
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Compile text and auto-apply results to brain.json.
|
|
194
|
+
* @param {string} root - Project root path
|
|
195
|
+
* @param {string} inputText - Raw text to compile
|
|
196
|
+
* @param {{ source?: string }} options
|
|
197
|
+
* @returns {Promise<Object>} Result with applied counts
|
|
198
|
+
*/
|
|
199
|
+
export async function compileAndApply(root, inputText, options = {}) {
|
|
200
|
+
const compiled = await compileSpec(root, inputText, options);
|
|
201
|
+
|
|
202
|
+
if (!compiled.success) {
|
|
203
|
+
return compiled;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
ensureInit(root);
|
|
207
|
+
const source = compiled.source || "spec-compiler";
|
|
208
|
+
const applied = { locks: 0, typedLocks: 0, decisions: 0, notes: 0 };
|
|
209
|
+
|
|
210
|
+
// Apply text locks
|
|
211
|
+
for (const lock of compiled.locks) {
|
|
212
|
+
addLock(root, lock.text, lock.tags || [], source);
|
|
213
|
+
applied.locks++;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Apply typed locks
|
|
217
|
+
for (const tl of compiled.typedLocks) {
|
|
218
|
+
const constraint = { ...tl };
|
|
219
|
+
const description = constraint.description;
|
|
220
|
+
delete constraint.description;
|
|
221
|
+
const result = addTypedLock(root, constraint, tl.tags || [], source, description);
|
|
222
|
+
if (result.lockId) {
|
|
223
|
+
applied.typedLocks++;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Apply decisions
|
|
228
|
+
for (const dec of compiled.decisions) {
|
|
229
|
+
addDecision(root, dec.text, dec.tags || [], source);
|
|
230
|
+
applied.decisions++;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Apply notes
|
|
234
|
+
for (const note of compiled.notes) {
|
|
235
|
+
addNote(root, note.text, true);
|
|
236
|
+
applied.notes++;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
compiled.applied = applied;
|
|
240
|
+
compiled.totalApplied = applied.locks + applied.typedLocks + applied.decisions + applied.notes;
|
|
241
|
+
return compiled;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Internal helpers ---
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Normalize and validate LLM compiler output.
|
|
248
|
+
*/
|
|
249
|
+
function normalizeCompilerOutput(raw) {
|
|
250
|
+
const result = {
|
|
251
|
+
locks: [],
|
|
252
|
+
typedLocks: [],
|
|
253
|
+
decisions: [],
|
|
254
|
+
notes: [],
|
|
255
|
+
summary: raw.summary || "",
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Normalize locks
|
|
259
|
+
if (Array.isArray(raw.locks)) {
|
|
260
|
+
for (const lock of raw.locks) {
|
|
261
|
+
if (lock && typeof lock.text === "string" && lock.text.trim()) {
|
|
262
|
+
result.locks.push({
|
|
263
|
+
text: lock.text.trim(),
|
|
264
|
+
tags: Array.isArray(lock.tags) ? lock.tags : [],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Normalize typed locks — validate each one
|
|
271
|
+
if (Array.isArray(raw.typedLocks)) {
|
|
272
|
+
for (const tl of raw.typedLocks) {
|
|
273
|
+
if (!tl || !tl.constraintType) continue;
|
|
274
|
+
const validation = validateTypedLock(tl);
|
|
275
|
+
if (validation.valid) {
|
|
276
|
+
result.typedLocks.push({
|
|
277
|
+
constraintType: tl.constraintType,
|
|
278
|
+
...(tl.metric && { metric: tl.metric }),
|
|
279
|
+
...(tl.operator && { operator: tl.operator }),
|
|
280
|
+
...(tl.value !== undefined && { value: tl.value }),
|
|
281
|
+
...(tl.min !== undefined && { min: tl.min }),
|
|
282
|
+
...(tl.max !== undefined && { max: tl.max }),
|
|
283
|
+
...(tl.unit && { unit: tl.unit }),
|
|
284
|
+
...(tl.entity && { entity: tl.entity }),
|
|
285
|
+
...(tl.forbidden && { forbidden: tl.forbidden }),
|
|
286
|
+
...(tl.description && { description: tl.description }),
|
|
287
|
+
tags: Array.isArray(tl.tags) ? tl.tags : [],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Normalize decisions
|
|
294
|
+
if (Array.isArray(raw.decisions)) {
|
|
295
|
+
for (const dec of raw.decisions) {
|
|
296
|
+
if (dec && typeof dec.text === "string" && dec.text.trim()) {
|
|
297
|
+
result.decisions.push({
|
|
298
|
+
text: dec.text.trim(),
|
|
299
|
+
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Normalize notes
|
|
306
|
+
if (Array.isArray(raw.notes)) {
|
|
307
|
+
for (const note of raw.notes) {
|
|
308
|
+
if (note && typeof note.text === "string" && note.text.trim()) {
|
|
309
|
+
result.notes.push({ text: note.text.trim() });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return result;
|
|
315
|
+
}
|