speclock 2.1.1 → 2.5.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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * SpecLock Memory Module
3
+ * Goal, lock, decision, note, deploy facts CRUD operations.
4
+ * Extracted from engine.js for modularity.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import {
12
+ nowIso,
13
+ newId,
14
+ readBrain,
15
+ writeBrain,
16
+ appendEvent,
17
+ bumpEvents,
18
+ ensureSpeclockDirs,
19
+ speclockDir,
20
+ makeBrain,
21
+ } from "./storage.js";
22
+ import { hasGit, getHead, getDefaultBranch } from "./git.js";
23
+ import { ensureAuditKeyGitignored } from "./audit.js";
24
+
25
+ // --- Internal helpers ---
26
+
27
+ function recordEvent(root, brain, event) {
28
+ bumpEvents(brain, event.eventId);
29
+ appendEvent(root, event);
30
+ writeBrain(root, brain);
31
+ }
32
+
33
+ // --- Core functions ---
34
+
35
+ export function ensureInit(root) {
36
+ ensureSpeclockDirs(root);
37
+ try { ensureAuditKeyGitignored(root); } catch { /* non-critical */ }
38
+ let brain = readBrain(root);
39
+ if (!brain) {
40
+ const gitExists = hasGit(root);
41
+ const defaultBranch = gitExists ? getDefaultBranch(root) : "";
42
+ brain = makeBrain(root, gitExists, defaultBranch);
43
+ if (gitExists) {
44
+ const head = getHead(root);
45
+ brain.state.head.gitBranch = head.gitBranch;
46
+ brain.state.head.gitCommit = head.gitCommit;
47
+ brain.state.head.capturedAt = nowIso();
48
+ }
49
+ const eventId = newId("evt");
50
+ const event = {
51
+ eventId,
52
+ type: "init",
53
+ at: nowIso(),
54
+ files: [],
55
+ summary: "Initialized SpecLock",
56
+ patchPath: "",
57
+ };
58
+ bumpEvents(brain, eventId);
59
+ appendEvent(root, event);
60
+ writeBrain(root, brain);
61
+ }
62
+ return brain;
63
+ }
64
+
65
+ export function setGoal(root, text) {
66
+ const brain = ensureInit(root);
67
+ brain.goal.text = text;
68
+ brain.goal.updatedAt = nowIso();
69
+ const eventId = newId("evt");
70
+ const event = {
71
+ eventId,
72
+ type: "goal_updated",
73
+ at: nowIso(),
74
+ files: [],
75
+ summary: `Goal set: ${text.substring(0, 80)}`,
76
+ patchPath: "",
77
+ };
78
+ recordEvent(root, brain, event);
79
+ return brain;
80
+ }
81
+
82
+ export function addLock(root, text, tags, source) {
83
+ const brain = ensureInit(root);
84
+ const lockId = newId("lock");
85
+ brain.specLock.items.unshift({
86
+ id: lockId,
87
+ text,
88
+ createdAt: nowIso(),
89
+ source: source || "user",
90
+ tags: tags || [],
91
+ active: true,
92
+ });
93
+ const eventId = newId("evt");
94
+ const event = {
95
+ eventId,
96
+ type: "lock_added",
97
+ at: nowIso(),
98
+ files: [],
99
+ summary: `Lock added: ${text.substring(0, 80)}`,
100
+ patchPath: "",
101
+ };
102
+ recordEvent(root, brain, event);
103
+ return { brain, lockId };
104
+ }
105
+
106
+ export function removeLock(root, lockId) {
107
+ const brain = ensureInit(root);
108
+ const lock = brain.specLock.items.find((l) => l.id === lockId);
109
+ if (!lock) {
110
+ return { brain, removed: false, error: `Lock not found: ${lockId}` };
111
+ }
112
+ lock.active = false;
113
+ const eventId = newId("evt");
114
+ const event = {
115
+ eventId,
116
+ type: "lock_removed",
117
+ at: nowIso(),
118
+ files: [],
119
+ summary: `Lock removed: ${lock.text.substring(0, 80)}`,
120
+ patchPath: "",
121
+ };
122
+ recordEvent(root, brain, event);
123
+ return { brain, removed: true, lockText: lock.text };
124
+ }
125
+
126
+ export function addDecision(root, text, tags, source) {
127
+ const brain = ensureInit(root);
128
+ const decId = newId("dec");
129
+ brain.decisions.unshift({
130
+ id: decId,
131
+ text,
132
+ createdAt: nowIso(),
133
+ source: source || "user",
134
+ tags: tags || [],
135
+ });
136
+ const eventId = newId("evt");
137
+ const event = {
138
+ eventId,
139
+ type: "decision_added",
140
+ at: nowIso(),
141
+ files: [],
142
+ summary: `Decision: ${text.substring(0, 80)}`,
143
+ patchPath: "",
144
+ };
145
+ recordEvent(root, brain, event);
146
+ return { brain, decId };
147
+ }
148
+
149
+ export function addNote(root, text, pinned = true) {
150
+ const brain = ensureInit(root);
151
+ const noteId = newId("note");
152
+ brain.notes.unshift({
153
+ id: noteId,
154
+ text,
155
+ createdAt: nowIso(),
156
+ pinned,
157
+ });
158
+ const eventId = newId("evt");
159
+ const event = {
160
+ eventId,
161
+ type: "note_added",
162
+ at: nowIso(),
163
+ files: [],
164
+ summary: `Note: ${text.substring(0, 80)}`,
165
+ patchPath: "",
166
+ };
167
+ recordEvent(root, brain, event);
168
+ return { brain, noteId };
169
+ }
170
+
171
+ export function updateDeployFacts(root, payload) {
172
+ const brain = ensureInit(root);
173
+ const deploy = brain.facts.deploy;
174
+ if (payload.provider !== undefined) deploy.provider = payload.provider;
175
+ if (typeof payload.autoDeploy === "boolean")
176
+ deploy.autoDeploy = payload.autoDeploy;
177
+ if (payload.branch !== undefined) deploy.branch = payload.branch;
178
+ if (payload.url !== undefined) deploy.url = payload.url;
179
+ if (payload.notes !== undefined) deploy.notes = payload.notes;
180
+ const eventId = newId("evt");
181
+ const event = {
182
+ eventId,
183
+ type: "fact_updated",
184
+ at: nowIso(),
185
+ files: [],
186
+ summary: "Updated deploy facts",
187
+ patchPath: "",
188
+ };
189
+ recordEvent(root, brain, event);
190
+ return brain;
191
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * SpecLock Semantic Pre-Commit Engine
3
+ * Replaces filename-only pre-commit with actual code-level semantic analysis.
4
+ *
5
+ * Parses git diff output, extracts code changes per file, and runs
6
+ * analyzeConflict() against each change block + active locks.
7
+ *
8
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
9
+ */
10
+
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { execSync } from "child_process";
14
+ import { readBrain } from "./storage.js";
15
+ import { analyzeConflict } from "./semantics.js";
16
+ import { getEnforcementConfig } from "./enforcer.js";
17
+
18
+ const GUARD_TAG = "SPECLOCK-GUARD";
19
+ const MAX_LINES_PER_FILE = 500;
20
+ const BINARY_EXTENSIONS = new Set([
21
+ "png", "jpg", "jpeg", "gif", "bmp", "ico", "svg", "webp",
22
+ "mp3", "mp4", "wav", "avi", "mov", "mkv",
23
+ "zip", "tar", "gz", "rar", "7z",
24
+ "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
25
+ "exe", "dll", "so", "dylib", "bin",
26
+ "woff", "woff2", "ttf", "eot", "otf",
27
+ "lock", "map",
28
+ ]);
29
+
30
+ /**
31
+ * Parse a unified diff into per-file change blocks.
32
+ * Returns array of { file, addedLines, removedLines, hunks }.
33
+ */
34
+ export function parseDiff(diffText) {
35
+ if (!diffText || !diffText.trim()) return [];
36
+
37
+ const files = [];
38
+ const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
39
+
40
+ for (const section of fileSections) {
41
+ const lines = section.split("\n");
42
+
43
+ // Extract filename from "a/path b/path"
44
+ const headerMatch = lines[0]?.match(/a\/(.+?) b\/(.+)/);
45
+ if (!headerMatch) continue;
46
+
47
+ const file = headerMatch[2];
48
+ const ext = path.extname(file).slice(1).toLowerCase();
49
+
50
+ // Skip binary files
51
+ if (BINARY_EXTENSIONS.has(ext)) continue;
52
+ if (section.includes("Binary files")) continue;
53
+
54
+ const addedLines = [];
55
+ const removedLines = [];
56
+ const hunks = [];
57
+ let currentHunk = null;
58
+ let lineCount = 0;
59
+
60
+ for (const line of lines) {
61
+ if (lineCount >= MAX_LINES_PER_FILE) break;
62
+
63
+ if (line.startsWith("@@")) {
64
+ // New hunk
65
+ const hunkMatch = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/);
66
+ if (hunkMatch) {
67
+ currentHunk = {
68
+ oldStart: parseInt(hunkMatch[1]),
69
+ newStart: parseInt(hunkMatch[3]),
70
+ context: hunkMatch[5]?.trim() || "",
71
+ changes: [],
72
+ };
73
+ hunks.push(currentHunk);
74
+ }
75
+ continue;
76
+ }
77
+
78
+ if (!currentHunk) continue;
79
+
80
+ if (line.startsWith("+") && !line.startsWith("+++")) {
81
+ const content = line.substring(1).trim();
82
+ if (content) {
83
+ addedLines.push(content);
84
+ currentHunk.changes.push({ type: "add", content });
85
+ lineCount++;
86
+ }
87
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
88
+ const content = line.substring(1).trim();
89
+ if (content) {
90
+ removedLines.push(content);
91
+ currentHunk.changes.push({ type: "remove", content });
92
+ lineCount++;
93
+ }
94
+ }
95
+ }
96
+
97
+ if (addedLines.length > 0 || removedLines.length > 0) {
98
+ files.push({ file, addedLines, removedLines, hunks });
99
+ }
100
+ }
101
+
102
+ return files;
103
+ }
104
+
105
+ /**
106
+ * Get the staged diff from git.
107
+ */
108
+ export function getStagedDiff(root) {
109
+ try {
110
+ return execSync("git diff --cached --unified=3", {
111
+ cwd: root,
112
+ encoding: "utf-8",
113
+ maxBuffer: 5 * 1024 * 1024, // 5MB
114
+ timeout: 10000,
115
+ });
116
+ } catch {
117
+ return "";
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Build a semantic summary of changes in a file for conflict checking.
123
+ * Combines added/removed lines into meaningful phrases.
124
+ */
125
+ function buildChangeSummary(fileChanges) {
126
+ const summaries = [];
127
+
128
+ // Summarize removals (deletions are more dangerous)
129
+ if (fileChanges.removedLines.length > 0) {
130
+ const sample = fileChanges.removedLines.slice(0, 10).join(" ");
131
+ summaries.push(`Removing code: ${sample}`);
132
+ }
133
+
134
+ // Summarize additions
135
+ if (fileChanges.addedLines.length > 0) {
136
+ const sample = fileChanges.addedLines.slice(0, 10).join(" ");
137
+ summaries.push(`Adding code: ${sample}`);
138
+ }
139
+
140
+ // Add hunk contexts (function names, class names)
141
+ for (const hunk of fileChanges.hunks) {
142
+ if (hunk.context) {
143
+ summaries.push(`In context: ${hunk.context}`);
144
+ }
145
+ }
146
+
147
+ return summaries.join(". ");
148
+ }
149
+
150
+ /**
151
+ * Run semantic pre-commit audit.
152
+ * Parses the staged diff, analyzes each file's changes against locks.
153
+ */
154
+ export function semanticAudit(root) {
155
+ const brain = readBrain(root);
156
+ if (!brain) {
157
+ return {
158
+ passed: true,
159
+ violations: [],
160
+ filesChecked: 0,
161
+ activeLocks: 0,
162
+ mode: "advisory",
163
+ message: "SpecLock not initialized. Audit skipped.",
164
+ };
165
+ }
166
+
167
+ const config = getEnforcementConfig(brain);
168
+ const activeLocks = (brain.specLock?.items || []).filter((l) => l.active !== false);
169
+
170
+ if (activeLocks.length === 0) {
171
+ return {
172
+ passed: true,
173
+ violations: [],
174
+ filesChecked: 0,
175
+ activeLocks: 0,
176
+ mode: config.mode,
177
+ message: "No active locks. Semantic audit passed.",
178
+ };
179
+ }
180
+
181
+ // Get staged diff
182
+ const diff = getStagedDiff(root);
183
+ if (!diff) {
184
+ return {
185
+ passed: true,
186
+ violations: [],
187
+ filesChecked: 0,
188
+ activeLocks: activeLocks.length,
189
+ mode: config.mode,
190
+ message: "No staged changes. Semantic audit passed.",
191
+ };
192
+ }
193
+
194
+ // Parse diff into per-file changes
195
+ const fileChanges = parseDiff(diff);
196
+ const violations = [];
197
+
198
+ for (const fc of fileChanges) {
199
+ // Check 1: Guard tag violation
200
+ const fullPath = path.join(root, fc.file);
201
+ if (fs.existsSync(fullPath)) {
202
+ try {
203
+ const content = fs.readFileSync(fullPath, "utf-8");
204
+ if (content.includes(GUARD_TAG)) {
205
+ violations.push({
206
+ file: fc.file,
207
+ lockId: null,
208
+ lockText: "(file-level guard)",
209
+ confidence: 100,
210
+ level: "HIGH",
211
+ reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
212
+ source: "guard",
213
+ });
214
+ continue; // Don't double-report guarded files
215
+ }
216
+ } catch { /* file read error, continue */ }
217
+ }
218
+
219
+ // Check 2: Semantic analysis of code changes against each lock
220
+ const changeSummary = buildChangeSummary(fc);
221
+ if (!changeSummary) continue;
222
+
223
+ // Prepend file path for context
224
+ const fullSummary = `Modifying file ${fc.file}: ${changeSummary}`;
225
+
226
+ for (const lock of activeLocks) {
227
+ const result = analyzeConflict(fullSummary, lock.text);
228
+
229
+ if (result.isConflict) {
230
+ violations.push({
231
+ file: fc.file,
232
+ lockId: lock.id,
233
+ lockText: lock.text,
234
+ confidence: result.confidence,
235
+ level: result.level,
236
+ reason: result.reasons.join("; "),
237
+ source: "semantic",
238
+ addedLines: fc.addedLines.length,
239
+ removedLines: fc.removedLines.length,
240
+ });
241
+ }
242
+ }
243
+ }
244
+
245
+ // Deduplicate: keep highest confidence per file+lock pair
246
+ const dedupKey = (v) => `${v.file}::${v.lockId || v.lockText}`;
247
+ const bestByKey = new Map();
248
+ for (const v of violations) {
249
+ const key = dedupKey(v);
250
+ const existing = bestByKey.get(key);
251
+ if (!existing || v.confidence > existing.confidence) {
252
+ bestByKey.set(key, v);
253
+ }
254
+ }
255
+ const uniqueViolations = [...bestByKey.values()];
256
+
257
+ // Sort by confidence descending
258
+ uniqueViolations.sort((a, b) => b.confidence - a.confidence);
259
+
260
+ // In hard mode, check if any violation meets the block threshold
261
+ const blocked = config.mode === "hard" &&
262
+ uniqueViolations.some((v) => v.confidence >= config.blockThreshold);
263
+
264
+ const passed = uniqueViolations.length === 0;
265
+ let message;
266
+ if (passed) {
267
+ message = `Semantic audit passed. ${fileChanges.length} file(s) analyzed against ${activeLocks.length} lock(s).`;
268
+ } else if (blocked) {
269
+ message = `BLOCKED: ${uniqueViolations.length} violation(s) detected in ${fileChanges.length} file(s). Hard enforcement active — commit rejected.`;
270
+ } else {
271
+ message = `WARNING: ${uniqueViolations.length} violation(s) detected in ${fileChanges.length} file(s). Advisory mode — review before proceeding.`;
272
+ }
273
+
274
+ return {
275
+ passed,
276
+ blocked,
277
+ violations: uniqueViolations,
278
+ filesChecked: fileChanges.length,
279
+ activeLocks: activeLocks.length,
280
+ mode: config.mode,
281
+ threshold: config.blockThreshold,
282
+ message,
283
+ };
284
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SpecLock Sessions Module
3
+ * Session briefing, start, end, and history management.
4
+ * Extracted from engine.js for modularity.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import {
10
+ nowIso,
11
+ newId,
12
+ writeBrain,
13
+ appendEvent,
14
+ bumpEvents,
15
+ readEvents,
16
+ } from "./storage.js";
17
+ import { ensureInit } from "./memory.js";
18
+
19
+ // --- Internal helpers ---
20
+
21
+ function recordEvent(root, brain, event) {
22
+ bumpEvents(brain, event.eventId);
23
+ appendEvent(root, event);
24
+ writeBrain(root, brain);
25
+ }
26
+
27
+ // --- Core functions ---
28
+
29
+ export function startSession(root, toolName = "unknown") {
30
+ const brain = ensureInit(root);
31
+
32
+ // Auto-close previous session if open
33
+ if (brain.sessions.current) {
34
+ const prev = brain.sessions.current;
35
+ prev.endedAt = nowIso();
36
+ prev.summary = prev.summary || "Session auto-closed (new session started)";
37
+ brain.sessions.history.unshift(prev);
38
+ if (brain.sessions.history.length > 50) {
39
+ brain.sessions.history = brain.sessions.history.slice(0, 50);
40
+ }
41
+ }
42
+
43
+ const session = {
44
+ id: newId("ses"),
45
+ startedAt: nowIso(),
46
+ endedAt: null,
47
+ summary: "",
48
+ toolUsed: toolName,
49
+ eventsInSession: 0,
50
+ };
51
+ brain.sessions.current = session;
52
+
53
+ const eventId = newId("evt");
54
+ const event = {
55
+ eventId,
56
+ type: "session_started",
57
+ at: nowIso(),
58
+ files: [],
59
+ summary: `Session started (${toolName})`,
60
+ patchPath: "",
61
+ };
62
+ recordEvent(root, brain, event);
63
+ return { brain, session };
64
+ }
65
+
66
+ export function endSession(root, summary) {
67
+ const brain = ensureInit(root);
68
+ if (!brain.sessions.current) {
69
+ return { brain, ended: false, error: "No active session to end." };
70
+ }
71
+
72
+ const session = brain.sessions.current;
73
+ session.endedAt = nowIso();
74
+ session.summary = summary;
75
+
76
+ const events = readEvents(root, { since: session.startedAt });
77
+ session.eventsInSession = events.length;
78
+
79
+ brain.sessions.history.unshift(session);
80
+ if (brain.sessions.history.length > 50) {
81
+ brain.sessions.history = brain.sessions.history.slice(0, 50);
82
+ }
83
+ brain.sessions.current = null;
84
+
85
+ const eventId = newId("evt");
86
+ const event = {
87
+ eventId,
88
+ type: "session_ended",
89
+ at: nowIso(),
90
+ files: [],
91
+ summary: `Session ended: ${summary.substring(0, 100)}`,
92
+ patchPath: "",
93
+ };
94
+ recordEvent(root, brain, event);
95
+ return { brain, ended: true, session };
96
+ }
97
+
98
+ export function getSessionBriefing(root, toolName = "unknown") {
99
+ const { brain, session } = startSession(root, toolName);
100
+
101
+ const lastSession =
102
+ brain.sessions.history.length > 0 ? brain.sessions.history[0] : null;
103
+
104
+ let changesSinceLastSession = 0;
105
+ let warnings = [];
106
+
107
+ if (lastSession && lastSession.endedAt) {
108
+ const eventsSince = readEvents(root, { since: lastSession.endedAt });
109
+ changesSinceLastSession = eventsSince.length;
110
+
111
+ const revertsSince = eventsSince.filter(
112
+ (e) => e.type === "revert_detected"
113
+ );
114
+ if (revertsSince.length > 0) {
115
+ warnings.push(
116
+ `${revertsSince.length} revert(s) detected since last session. Verify current state before proceeding.`
117
+ );
118
+ }
119
+ }
120
+
121
+ return {
122
+ brain,
123
+ session,
124
+ lastSession,
125
+ changesSinceLastSession,
126
+ warnings,
127
+ };
128
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * SpecLock Tracking Module
3
+ * Change logging, file event handling, event management.
4
+ * Extracted from engine.js for modularity.
5
+ *
6
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import {
12
+ nowIso,
13
+ newId,
14
+ writeBrain,
15
+ appendEvent,
16
+ bumpEvents,
17
+ speclockDir,
18
+ addRecentChange,
19
+ addRevert,
20
+ } from "./storage.js";
21
+ import { captureDiff } from "./git.js";
22
+ import { ensureInit } from "./memory.js";
23
+
24
+ // --- Internal helpers ---
25
+
26
+ function recordEvent(root, brain, event) {
27
+ bumpEvents(brain, event.eventId);
28
+ appendEvent(root, event);
29
+ writeBrain(root, brain);
30
+ }
31
+
32
+ function writePatch(root, eventId, content) {
33
+ const patchPath = path.join(
34
+ speclockDir(root),
35
+ "patches",
36
+ `${eventId}.patch`
37
+ );
38
+ fs.writeFileSync(patchPath, content);
39
+ return path.join(".speclock", "patches", `${eventId}.patch`);
40
+ }
41
+
42
+ // --- Core functions ---
43
+
44
+ export function logChange(root, summary, files = []) {
45
+ const brain = ensureInit(root);
46
+ const eventId = newId("evt");
47
+ let patchPath = "";
48
+ if (brain.facts.repo.hasGit) {
49
+ const diff = captureDiff(root);
50
+ if (diff && diff.trim().length > 0) {
51
+ patchPath = writePatch(root, eventId, diff);
52
+ }
53
+ }
54
+ const event = {
55
+ eventId,
56
+ type: "manual_change",
57
+ at: nowIso(),
58
+ files,
59
+ summary,
60
+ patchPath,
61
+ };
62
+ addRecentChange(brain, {
63
+ eventId,
64
+ summary,
65
+ files,
66
+ at: event.at,
67
+ });
68
+ recordEvent(root, brain, event);
69
+ return { brain, eventId };
70
+ }
71
+
72
+ export function handleFileEvent(root, brain, type, filePath) {
73
+ const eventId = newId("evt");
74
+ const rel = path.relative(root, filePath);
75
+ let patchPath = "";
76
+ if (brain.facts.repo.hasGit) {
77
+ const diff = captureDiff(root);
78
+ const patchContent =
79
+ diff && diff.trim().length > 0 ? diff : "(no diff available)";
80
+ patchPath = writePatch(root, eventId, patchContent);
81
+ }
82
+ const summary = `${type.replace("_", " ")}: ${rel}`;
83
+ const event = {
84
+ eventId,
85
+ type,
86
+ at: nowIso(),
87
+ files: [rel],
88
+ summary,
89
+ patchPath,
90
+ };
91
+ addRecentChange(brain, {
92
+ eventId,
93
+ summary,
94
+ files: [rel],
95
+ at: event.at,
96
+ });
97
+ recordEvent(root, brain, event);
98
+ }