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.
- package/README.md +32 -4
- package/package.json +4 -3
- package/src/cli/index.js +107 -3
- package/src/core/compliance.js +1 -1
- package/src/core/conflict.js +363 -0
- package/src/core/enforcer.js +314 -0
- package/src/core/engine.js +87 -781
- package/src/core/memory.js +191 -0
- package/src/core/pre-commit-semantic.js +284 -0
- package/src/core/sessions.js +128 -0
- package/src/core/tracking.js +98 -0
- package/src/mcp/http-server.js +42 -5
- package/src/mcp/server.js +183 -4
package/src/core/engine.js
CHANGED
|
@@ -1,622 +1,79 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
writeBrain(root, brain);
|
|
72
|
-
}
|
|
73
|
-
return brain;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function setGoal(root, text) {
|
|
77
|
-
const brain = ensureInit(root);
|
|
78
|
-
brain.goal.text = text;
|
|
79
|
-
brain.goal.updatedAt = nowIso();
|
|
80
|
-
const eventId = newId("evt");
|
|
81
|
-
const event = {
|
|
82
|
-
eventId,
|
|
83
|
-
type: "goal_updated",
|
|
84
|
-
at: nowIso(),
|
|
85
|
-
files: [],
|
|
86
|
-
summary: `Goal set: ${text.substring(0, 80)}`,
|
|
87
|
-
patchPath: "",
|
|
88
|
-
};
|
|
89
|
-
recordEvent(root, brain, event);
|
|
90
|
-
return brain;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function addLock(root, text, tags, source) {
|
|
94
|
-
const brain = ensureInit(root);
|
|
95
|
-
const lockId = newId("lock");
|
|
96
|
-
brain.specLock.items.unshift({
|
|
97
|
-
id: lockId,
|
|
98
|
-
text,
|
|
99
|
-
createdAt: nowIso(),
|
|
100
|
-
source: source || "user",
|
|
101
|
-
tags: tags || [],
|
|
102
|
-
active: true,
|
|
103
|
-
});
|
|
104
|
-
const eventId = newId("evt");
|
|
105
|
-
const event = {
|
|
106
|
-
eventId,
|
|
107
|
-
type: "lock_added",
|
|
108
|
-
at: nowIso(),
|
|
109
|
-
files: [],
|
|
110
|
-
summary: `Lock added: ${text.substring(0, 80)}`,
|
|
111
|
-
patchPath: "",
|
|
112
|
-
};
|
|
113
|
-
recordEvent(root, brain, event);
|
|
114
|
-
return { brain, lockId };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function removeLock(root, lockId) {
|
|
118
|
-
const brain = ensureInit(root);
|
|
119
|
-
const lock = brain.specLock.items.find((l) => l.id === lockId);
|
|
120
|
-
if (!lock) {
|
|
121
|
-
return { brain, removed: false, error: `Lock not found: ${lockId}` };
|
|
122
|
-
}
|
|
123
|
-
lock.active = false;
|
|
124
|
-
const eventId = newId("evt");
|
|
125
|
-
const event = {
|
|
126
|
-
eventId,
|
|
127
|
-
type: "lock_removed",
|
|
128
|
-
at: nowIso(),
|
|
129
|
-
files: [],
|
|
130
|
-
summary: `Lock removed: ${lock.text.substring(0, 80)}`,
|
|
131
|
-
patchPath: "",
|
|
132
|
-
};
|
|
133
|
-
recordEvent(root, brain, event);
|
|
134
|
-
return { brain, removed: true, lockText: lock.text };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function addDecision(root, text, tags, source) {
|
|
138
|
-
const brain = ensureInit(root);
|
|
139
|
-
const decId = newId("dec");
|
|
140
|
-
brain.decisions.unshift({
|
|
141
|
-
id: decId,
|
|
142
|
-
text,
|
|
143
|
-
createdAt: nowIso(),
|
|
144
|
-
source: source || "user",
|
|
145
|
-
tags: tags || [],
|
|
146
|
-
});
|
|
147
|
-
const eventId = newId("evt");
|
|
148
|
-
const event = {
|
|
149
|
-
eventId,
|
|
150
|
-
type: "decision_added",
|
|
151
|
-
at: nowIso(),
|
|
152
|
-
files: [],
|
|
153
|
-
summary: `Decision: ${text.substring(0, 80)}`,
|
|
154
|
-
patchPath: "",
|
|
155
|
-
};
|
|
156
|
-
recordEvent(root, brain, event);
|
|
157
|
-
return { brain, decId };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function addNote(root, text, pinned = true) {
|
|
161
|
-
const brain = ensureInit(root);
|
|
162
|
-
const noteId = newId("note");
|
|
163
|
-
brain.notes.unshift({
|
|
164
|
-
id: noteId,
|
|
165
|
-
text,
|
|
166
|
-
createdAt: nowIso(),
|
|
167
|
-
pinned,
|
|
168
|
-
});
|
|
169
|
-
const eventId = newId("evt");
|
|
170
|
-
const event = {
|
|
171
|
-
eventId,
|
|
172
|
-
type: "note_added",
|
|
173
|
-
at: nowIso(),
|
|
174
|
-
files: [],
|
|
175
|
-
summary: `Note: ${text.substring(0, 80)}`,
|
|
176
|
-
patchPath: "",
|
|
177
|
-
};
|
|
178
|
-
recordEvent(root, brain, event);
|
|
179
|
-
return { brain, noteId };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function updateDeployFacts(root, payload) {
|
|
183
|
-
const brain = ensureInit(root);
|
|
184
|
-
const deploy = brain.facts.deploy;
|
|
185
|
-
if (payload.provider !== undefined) deploy.provider = payload.provider;
|
|
186
|
-
if (typeof payload.autoDeploy === "boolean")
|
|
187
|
-
deploy.autoDeploy = payload.autoDeploy;
|
|
188
|
-
if (payload.branch !== undefined) deploy.branch = payload.branch;
|
|
189
|
-
if (payload.url !== undefined) deploy.url = payload.url;
|
|
190
|
-
if (payload.notes !== undefined) deploy.notes = payload.notes;
|
|
191
|
-
const eventId = newId("evt");
|
|
192
|
-
const event = {
|
|
193
|
-
eventId,
|
|
194
|
-
type: "fact_updated",
|
|
195
|
-
at: nowIso(),
|
|
196
|
-
files: [],
|
|
197
|
-
summary: "Updated deploy facts",
|
|
198
|
-
patchPath: "",
|
|
199
|
-
};
|
|
200
|
-
recordEvent(root, brain, event);
|
|
201
|
-
return brain;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function logChange(root, summary, files = []) {
|
|
205
|
-
const brain = ensureInit(root);
|
|
206
|
-
const eventId = newId("evt");
|
|
207
|
-
let patchPath = "";
|
|
208
|
-
if (brain.facts.repo.hasGit) {
|
|
209
|
-
const diff = captureDiff(root);
|
|
210
|
-
if (diff && diff.trim().length > 0) {
|
|
211
|
-
patchPath = writePatch(root, eventId, diff);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
const event = {
|
|
215
|
-
eventId,
|
|
216
|
-
type: "manual_change",
|
|
217
|
-
at: nowIso(),
|
|
218
|
-
files,
|
|
219
|
-
summary,
|
|
220
|
-
patchPath,
|
|
221
|
-
};
|
|
222
|
-
addRecentChange(brain, {
|
|
223
|
-
eventId,
|
|
224
|
-
summary,
|
|
225
|
-
files,
|
|
226
|
-
at: event.at,
|
|
227
|
-
});
|
|
228
|
-
recordEvent(root, brain, event);
|
|
229
|
-
return { brain, eventId };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function handleFileEvent(root, brain, type, filePath) {
|
|
233
|
-
const eventId = newId("evt");
|
|
234
|
-
const rel = path.relative(root, filePath);
|
|
235
|
-
let patchPath = "";
|
|
236
|
-
if (brain.facts.repo.hasGit) {
|
|
237
|
-
const diff = captureDiff(root);
|
|
238
|
-
const patchContent =
|
|
239
|
-
diff && diff.trim().length > 0 ? diff : "(no diff available)";
|
|
240
|
-
patchPath = writePatch(root, eventId, patchContent);
|
|
241
|
-
}
|
|
242
|
-
const summary = `${type.replace("_", " ")}: ${rel}`;
|
|
243
|
-
const event = {
|
|
244
|
-
eventId,
|
|
245
|
-
type,
|
|
246
|
-
at: nowIso(),
|
|
247
|
-
files: [rel],
|
|
248
|
-
summary,
|
|
249
|
-
patchPath,
|
|
250
|
-
};
|
|
251
|
-
addRecentChange(brain, {
|
|
252
|
-
eventId,
|
|
253
|
-
summary,
|
|
254
|
-
files: [rel],
|
|
255
|
-
at: event.at,
|
|
256
|
-
});
|
|
257
|
-
recordEvent(root, brain, event);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// --- Legacy synonym groups (deprecated — kept for backward compatibility) ---
|
|
261
|
-
// @deprecated Use analyzeConflict() from semantics.js instead
|
|
262
|
-
const SYNONYM_GROUPS = [
|
|
263
|
-
["remove", "delete", "drop", "eliminate", "destroy", "kill", "purge", "wipe"],
|
|
264
|
-
["add", "create", "introduce", "insert", "new"],
|
|
265
|
-
["change", "modify", "alter", "update", "mutate", "transform", "rewrite"],
|
|
266
|
-
["break", "breaking", "incompatible", "destabilize"],
|
|
267
|
-
["public", "external", "exposed", "user-facing", "client-facing"],
|
|
268
|
-
["private", "internal", "hidden", "encapsulated"],
|
|
269
|
-
["database", "db", "schema", "table", "migration", "sql"],
|
|
270
|
-
["api", "endpoint", "route", "rest", "graphql"],
|
|
271
|
-
["test", "testing", "spec", "coverage", "assertion"],
|
|
272
|
-
["deploy", "deployment", "release", "ship", "publish", "production"],
|
|
273
|
-
["security", "auth", "authentication", "authorization", "token", "credential"],
|
|
274
|
-
["dependency", "package", "library", "module", "import"],
|
|
275
|
-
["refactor", "restructure", "reorganize", "cleanup"],
|
|
276
|
-
["disable", "deactivate", "turn-off", "switch-off"],
|
|
277
|
-
["enable", "activate", "turn-on", "switch-on"],
|
|
278
|
-
];
|
|
279
|
-
|
|
280
|
-
// @deprecated
|
|
281
|
-
const NEGATION_WORDS = ["no", "not", "never", "without", "dont", "don't", "cannot", "can't", "shouldn't", "mustn't", "avoid", "prevent", "prohibit", "forbid", "disallow"];
|
|
282
|
-
|
|
283
|
-
// @deprecated
|
|
284
|
-
const DESTRUCTIVE_WORDS = ["remove", "delete", "drop", "destroy", "kill", "purge", "wipe", "break", "disable", "revert", "rollback", "undo"];
|
|
285
|
-
|
|
286
|
-
// @deprecated — use analyzeConflict() from semantics.js
|
|
287
|
-
function expandWithSynonyms(words) {
|
|
288
|
-
const expanded = new Set(words);
|
|
289
|
-
for (const word of words) {
|
|
290
|
-
for (const group of SYNONYM_GROUPS) {
|
|
291
|
-
if (group.includes(word)) {
|
|
292
|
-
for (const syn of group) expanded.add(syn);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
return [...expanded];
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// @deprecated
|
|
300
|
-
function hasNegation(text) {
|
|
301
|
-
const lower = text.toLowerCase();
|
|
302
|
-
return NEGATION_WORDS.some((neg) => lower.includes(neg));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// @deprecated
|
|
306
|
-
function isDestructiveAction(text) {
|
|
307
|
-
const lower = text.toLowerCase();
|
|
308
|
-
return DESTRUCTIVE_WORDS.some((w) => lower.includes(w));
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Check if a proposed action conflicts with any active SpecLock
|
|
312
|
-
// v2: Uses the semantic analysis engine from semantics.js
|
|
313
|
-
export function checkConflict(root, proposedAction) {
|
|
314
|
-
const brain = ensureInit(root);
|
|
315
|
-
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
316
|
-
if (activeLocks.length === 0) {
|
|
317
|
-
return {
|
|
318
|
-
hasConflict: false,
|
|
319
|
-
conflictingLocks: [],
|
|
320
|
-
analysis: "No active locks. No constraints to check against.",
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const conflicting = [];
|
|
325
|
-
for (const lock of activeLocks) {
|
|
326
|
-
const result = analyzeConflict(proposedAction, lock.text);
|
|
327
|
-
|
|
328
|
-
if (result.isConflict) {
|
|
329
|
-
conflicting.push({
|
|
330
|
-
id: lock.id,
|
|
331
|
-
text: lock.text,
|
|
332
|
-
matchedKeywords: [],
|
|
333
|
-
confidence: result.confidence,
|
|
334
|
-
level: result.level,
|
|
335
|
-
reasons: result.reasons,
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (conflicting.length === 0) {
|
|
341
|
-
return {
|
|
342
|
-
hasConflict: false,
|
|
343
|
-
conflictingLocks: [],
|
|
344
|
-
analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Sort by confidence descending
|
|
349
|
-
conflicting.sort((a, b) => b.confidence - a.confidence);
|
|
350
|
-
|
|
351
|
-
const details = conflicting
|
|
352
|
-
.map(
|
|
353
|
-
(c) =>
|
|
354
|
-
`- [${c.level}] "${c.text}" (confidence: ${c.confidence}%)\n Reasons: ${c.reasons.join("; ")}`
|
|
355
|
-
)
|
|
356
|
-
.join("\n");
|
|
357
|
-
|
|
358
|
-
const result = {
|
|
359
|
-
hasConflict: true,
|
|
360
|
-
conflictingLocks: conflicting,
|
|
361
|
-
analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
// Record violation for reporting
|
|
365
|
-
addViolation(brain, {
|
|
366
|
-
at: nowIso(),
|
|
367
|
-
action: proposedAction,
|
|
368
|
-
locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
|
|
369
|
-
topLevel: conflicting[0].level,
|
|
370
|
-
topConfidence: conflicting[0].confidence,
|
|
371
|
-
});
|
|
372
|
-
writeBrain(root, brain);
|
|
373
|
-
|
|
374
|
-
return result;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Async version — uses LLM if available, falls back to heuristic
|
|
378
|
-
export async function checkConflictAsync(root, proposedAction) {
|
|
379
|
-
// Try LLM first (if llm-checker is available)
|
|
380
|
-
try {
|
|
381
|
-
const { llmCheckConflict } = await import("./llm-checker.js");
|
|
382
|
-
const llmResult = await llmCheckConflict(root, proposedAction);
|
|
383
|
-
if (llmResult) return llmResult;
|
|
384
|
-
} catch (_) {
|
|
385
|
-
// LLM checker not available or failed — fall through to heuristic
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Fallback to heuristic
|
|
389
|
-
return checkConflict(root, proposedAction);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// --- Auto-lock suggestions ---
|
|
393
|
-
export function suggestLocks(root) {
|
|
394
|
-
const brain = ensureInit(root);
|
|
395
|
-
const suggestions = [];
|
|
396
|
-
|
|
397
|
-
// Analyze decisions for implicit constraints
|
|
398
|
-
for (const dec of brain.decisions) {
|
|
399
|
-
const lower = dec.text.toLowerCase();
|
|
400
|
-
// Decisions with strong commitment language become lock candidates
|
|
401
|
-
if (/\b(always|must|only|exclusively|never|required)\b/.test(lower)) {
|
|
402
|
-
suggestions.push({
|
|
403
|
-
text: dec.text,
|
|
404
|
-
source: "decision",
|
|
405
|
-
sourceId: dec.id,
|
|
406
|
-
reason: `Decision contains strong commitment language — consider promoting to a lock`,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// Analyze notes for implicit constraints
|
|
412
|
-
for (const note of brain.notes) {
|
|
413
|
-
const lower = note.text.toLowerCase();
|
|
414
|
-
if (/\b(never|must not|do not|don't|avoid|prohibit|forbidden)\b/.test(lower)) {
|
|
415
|
-
suggestions.push({
|
|
416
|
-
text: note.text,
|
|
417
|
-
source: "note",
|
|
418
|
-
sourceId: note.id,
|
|
419
|
-
reason: `Note contains prohibitive language — consider promoting to a lock`,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* SpecLock Engine — Orchestrator
|
|
3
|
+
* Re-exports all functionality from focused modules.
|
|
4
|
+
* This file exists for backward compatibility — all imports from engine.js still work.
|
|
5
|
+
*
|
|
6
|
+
* Module structure (v2.5):
|
|
7
|
+
* - memory.js: Goal, lock, decision, note, deploy facts CRUD
|
|
8
|
+
* - tracking.js: Change logging, file event handling
|
|
9
|
+
* - conflict.js: Conflict checking, drift detection, suggestions, audit
|
|
10
|
+
* - sessions.js: Session management (briefing, start, end)
|
|
11
|
+
* - enforcer.js: Hard/advisory enforcement, overrides, escalation
|
|
12
|
+
* - pre-commit-semantic.js: Semantic pre-commit analysis
|
|
13
|
+
* - audit.js: HMAC audit chain (v2.1)
|
|
14
|
+
* - compliance.js: SOC 2/HIPAA/CSV exports (v2.1)
|
|
15
|
+
* - license.js: Freemium tier system (v2.1)
|
|
16
|
+
*
|
|
17
|
+
* Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// --- Memory (CRUD) ---
|
|
21
|
+
export {
|
|
22
|
+
ensureInit,
|
|
23
|
+
setGoal,
|
|
24
|
+
addLock,
|
|
25
|
+
removeLock,
|
|
26
|
+
addDecision,
|
|
27
|
+
addNote,
|
|
28
|
+
updateDeployFacts,
|
|
29
|
+
} from "./memory.js";
|
|
30
|
+
|
|
31
|
+
// --- Tracking ---
|
|
32
|
+
export {
|
|
33
|
+
logChange,
|
|
34
|
+
handleFileEvent,
|
|
35
|
+
} from "./tracking.js";
|
|
36
|
+
|
|
37
|
+
// --- Conflict Detection & Enforcement ---
|
|
38
|
+
export {
|
|
39
|
+
checkConflict,
|
|
40
|
+
checkConflictAsync,
|
|
41
|
+
suggestLocks,
|
|
42
|
+
detectDrift,
|
|
43
|
+
generateReport,
|
|
44
|
+
auditStagedFiles,
|
|
45
|
+
} from "./conflict.js";
|
|
46
|
+
|
|
47
|
+
// --- Sessions ---
|
|
48
|
+
export {
|
|
49
|
+
startSession,
|
|
50
|
+
endSession,
|
|
51
|
+
getSessionBriefing,
|
|
52
|
+
} from "./sessions.js";
|
|
53
|
+
|
|
54
|
+
// --- Hard Enforcement (v2.5) ---
|
|
55
|
+
export {
|
|
56
|
+
getEnforcementConfig,
|
|
57
|
+
setEnforcementMode,
|
|
58
|
+
enforceConflictCheck,
|
|
59
|
+
overrideLock,
|
|
60
|
+
getOverrideHistory,
|
|
61
|
+
} from "./enforcer.js";
|
|
62
|
+
|
|
63
|
+
// --- Semantic Pre-Commit (v2.5) ---
|
|
64
|
+
export {
|
|
65
|
+
parseDiff,
|
|
66
|
+
semanticAudit,
|
|
67
|
+
} from "./pre-commit-semantic.js";
|
|
68
|
+
|
|
69
|
+
// --- File Watcher ---
|
|
70
|
+
// watchRepo stays here because it uses multiple modules
|
|
423
71
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// Suggest common locks if not already present
|
|
430
|
-
const commonPatterns = [
|
|
431
|
-
{ keyword: "api", suggestion: "No breaking changes to public API" },
|
|
432
|
-
{ keyword: "database", suggestion: "No destructive database migrations without backup" },
|
|
433
|
-
{ keyword: "deploy", suggestion: "All deployments must pass CI checks" },
|
|
434
|
-
{ keyword: "security", suggestion: "No secrets or credentials in source code" },
|
|
435
|
-
{ keyword: "test", suggestion: "No merging without passing tests" },
|
|
436
|
-
];
|
|
437
|
-
|
|
438
|
-
// Check if project context suggests these
|
|
439
|
-
const allText = [
|
|
440
|
-
brain.goal.text,
|
|
441
|
-
...brain.decisions.map((d) => d.text),
|
|
442
|
-
...brain.notes.map((n) => n.text),
|
|
443
|
-
].join(" ").toLowerCase();
|
|
444
|
-
|
|
445
|
-
for (const pattern of commonPatterns) {
|
|
446
|
-
if (allText.includes(pattern.keyword)) {
|
|
447
|
-
const alreadyLocked = existingLockTexts.some((t) =>
|
|
448
|
-
t.includes(pattern.keyword)
|
|
449
|
-
);
|
|
450
|
-
if (!alreadyLocked) {
|
|
451
|
-
suggestions.push({
|
|
452
|
-
text: pattern.suggestion,
|
|
453
|
-
source: "pattern",
|
|
454
|
-
sourceId: null,
|
|
455
|
-
reason: `Project mentions "${pattern.keyword}" but has no lock protecting it`,
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return { suggestions, totalLocks: brain.specLock.items.filter((l) => l.active).length };
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// --- Drift detection (v2: uses semantic engine) ---
|
|
465
|
-
export function detectDrift(root) {
|
|
466
|
-
const brain = ensureInit(root);
|
|
467
|
-
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
468
|
-
if (activeLocks.length === 0) {
|
|
469
|
-
return { drifts: [], status: "no_locks", message: "No active locks to check against." };
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
const drifts = [];
|
|
473
|
-
|
|
474
|
-
// Check recent changes against locks using the semantic engine
|
|
475
|
-
for (const change of brain.state.recentChanges) {
|
|
476
|
-
for (const lock of activeLocks) {
|
|
477
|
-
const result = analyzeConflict(change.summary, lock.text);
|
|
478
|
-
|
|
479
|
-
if (result.isConflict) {
|
|
480
|
-
drifts.push({
|
|
481
|
-
lockId: lock.id,
|
|
482
|
-
lockText: lock.text,
|
|
483
|
-
changeEventId: change.eventId,
|
|
484
|
-
changeSummary: change.summary,
|
|
485
|
-
changeAt: change.at,
|
|
486
|
-
matchedTerms: result.reasons,
|
|
487
|
-
severity: result.level === "HIGH" ? "high" : "medium",
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Check for reverts (always a drift signal)
|
|
494
|
-
for (const revert of brain.state.reverts) {
|
|
495
|
-
drifts.push({
|
|
496
|
-
lockId: null,
|
|
497
|
-
lockText: "(git revert detected)",
|
|
498
|
-
changeEventId: revert.eventId,
|
|
499
|
-
changeSummary: `Git ${revert.kind} to ${revert.target.substring(0, 12)}`,
|
|
500
|
-
changeAt: revert.at,
|
|
501
|
-
matchedTerms: ["revert"],
|
|
502
|
-
severity: "high",
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const status = drifts.length === 0 ? "clean" : "drift_detected";
|
|
507
|
-
const message = drifts.length === 0
|
|
508
|
-
? `All clear. ${activeLocks.length} lock(s) checked against ${brain.state.recentChanges.length} recent change(s). No drift detected.`
|
|
509
|
-
: `WARNING: ${drifts.length} potential drift(s) detected. Review immediately.`;
|
|
510
|
-
|
|
511
|
-
return { drifts, status, message };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// --- Session management ---
|
|
515
|
-
|
|
516
|
-
export function startSession(root, toolName = "unknown") {
|
|
517
|
-
const brain = ensureInit(root);
|
|
518
|
-
|
|
519
|
-
// Auto-close previous session if open
|
|
520
|
-
if (brain.sessions.current) {
|
|
521
|
-
const prev = brain.sessions.current;
|
|
522
|
-
prev.endedAt = nowIso();
|
|
523
|
-
prev.summary = prev.summary || "Session auto-closed (new session started)";
|
|
524
|
-
brain.sessions.history.unshift(prev);
|
|
525
|
-
if (brain.sessions.history.length > 50) {
|
|
526
|
-
brain.sessions.history = brain.sessions.history.slice(0, 50);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const session = {
|
|
531
|
-
id: newId("ses"),
|
|
532
|
-
startedAt: nowIso(),
|
|
533
|
-
endedAt: null,
|
|
534
|
-
summary: "",
|
|
535
|
-
toolUsed: toolName,
|
|
536
|
-
eventsInSession: 0,
|
|
537
|
-
};
|
|
538
|
-
brain.sessions.current = session;
|
|
539
|
-
|
|
540
|
-
const eventId = newId("evt");
|
|
541
|
-
const event = {
|
|
542
|
-
eventId,
|
|
543
|
-
type: "session_started",
|
|
544
|
-
at: nowIso(),
|
|
545
|
-
files: [],
|
|
546
|
-
summary: `Session started (${toolName})`,
|
|
547
|
-
patchPath: "",
|
|
548
|
-
};
|
|
549
|
-
recordEvent(root, brain, event);
|
|
550
|
-
return { brain, session };
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
export function endSession(root, summary) {
|
|
554
|
-
const brain = ensureInit(root);
|
|
555
|
-
if (!brain.sessions.current) {
|
|
556
|
-
return { brain, ended: false, error: "No active session to end." };
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
const session = brain.sessions.current;
|
|
560
|
-
session.endedAt = nowIso();
|
|
561
|
-
session.summary = summary;
|
|
562
|
-
|
|
563
|
-
// Count events during this session
|
|
564
|
-
const events = readEvents(root, { since: session.startedAt });
|
|
565
|
-
session.eventsInSession = events.length;
|
|
566
|
-
|
|
567
|
-
brain.sessions.history.unshift(session);
|
|
568
|
-
if (brain.sessions.history.length > 50) {
|
|
569
|
-
brain.sessions.history = brain.sessions.history.slice(0, 50);
|
|
570
|
-
}
|
|
571
|
-
brain.sessions.current = null;
|
|
572
|
-
|
|
573
|
-
const eventId = newId("evt");
|
|
574
|
-
const event = {
|
|
575
|
-
eventId,
|
|
576
|
-
type: "session_ended",
|
|
577
|
-
at: nowIso(),
|
|
578
|
-
files: [],
|
|
579
|
-
summary: `Session ended: ${summary.substring(0, 100)}`,
|
|
580
|
-
patchPath: "",
|
|
581
|
-
};
|
|
582
|
-
recordEvent(root, brain, event);
|
|
583
|
-
return { brain, ended: true, session };
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
export function getSessionBriefing(root, toolName = "unknown") {
|
|
587
|
-
const { brain, session } = startSession(root, toolName);
|
|
588
|
-
|
|
589
|
-
const lastSession =
|
|
590
|
-
brain.sessions.history.length > 0 ? brain.sessions.history[0] : null;
|
|
591
|
-
|
|
592
|
-
let changesSinceLastSession = 0;
|
|
593
|
-
let warnings = [];
|
|
594
|
-
|
|
595
|
-
if (lastSession && lastSession.endedAt) {
|
|
596
|
-
const eventsSince = readEvents(root, { since: lastSession.endedAt });
|
|
597
|
-
changesSinceLastSession = eventsSince.length;
|
|
598
|
-
|
|
599
|
-
// Check for reverts since last session
|
|
600
|
-
const revertsSince = eventsSince.filter(
|
|
601
|
-
(e) => e.type === "revert_detected"
|
|
602
|
-
);
|
|
603
|
-
if (revertsSince.length > 0) {
|
|
604
|
-
warnings.push(
|
|
605
|
-
`${revertsSince.length} revert(s) detected since last session. Verify current state before proceeding.`
|
|
606
|
-
);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
return {
|
|
611
|
-
brain,
|
|
612
|
-
session,
|
|
613
|
-
lastSession,
|
|
614
|
-
changesSinceLastSession,
|
|
615
|
-
warnings,
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// --- File watcher ---
|
|
72
|
+
import path from "path";
|
|
73
|
+
import { nowIso, newId, readBrain, writeBrain, appendEvent, bumpEvents, addRevert } from "./storage.js";
|
|
74
|
+
import { getHead } from "./git.js";
|
|
75
|
+
import { ensureInit, addLock as addLockFn, addDecision as addDecisionFn } from "./memory.js";
|
|
76
|
+
import { handleFileEvent } from "./tracking.js";
|
|
620
77
|
|
|
621
78
|
export async function watchRepo(root) {
|
|
622
79
|
const { default: chokidar } = await import("chokidar");
|
|
@@ -672,7 +129,9 @@ export async function watchRepo(root) {
|
|
|
672
129
|
at: event.at,
|
|
673
130
|
note: "",
|
|
674
131
|
});
|
|
675
|
-
|
|
132
|
+
bumpEvents(brain, eventId);
|
|
133
|
+
appendEvent(root, event);
|
|
134
|
+
writeBrain(root, brain);
|
|
676
135
|
}
|
|
677
136
|
brain.state.head.gitBranch = head.gitBranch;
|
|
678
137
|
brain.state.head.gitCommit = head.gitCommit;
|
|
@@ -685,7 +144,9 @@ export async function watchRepo(root) {
|
|
|
685
144
|
return watcher;
|
|
686
145
|
}
|
|
687
146
|
|
|
688
|
-
// --- SPECLOCK.md generator
|
|
147
|
+
// --- SPECLOCK.md generator ---
|
|
148
|
+
|
|
149
|
+
import fs from "fs";
|
|
689
150
|
|
|
690
151
|
export function createSpecLockMd(root) {
|
|
691
152
|
const mdContent = `# SpecLock — AI Constraint Engine Active
|
|
@@ -819,7 +280,6 @@ export function guardFile(root, relativeFilePath, lockText) {
|
|
|
819
280
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
820
281
|
const style = getCommentStyle(fullPath);
|
|
821
282
|
|
|
822
|
-
// Check if already guarded
|
|
823
283
|
if (content.includes(GUARD_TAG)) {
|
|
824
284
|
return { success: false, error: `File already guarded: ${relativeFilePath}` };
|
|
825
285
|
}
|
|
@@ -842,8 +302,6 @@ export function guardFile(root, relativeFilePath, lockText) {
|
|
|
842
302
|
return { success: true };
|
|
843
303
|
}
|
|
844
304
|
|
|
845
|
-
// --- Package.json lock sync (Solution 2: embed active locks directly in package.json) ---
|
|
846
|
-
|
|
847
305
|
export function syncLocksToPackageJson(root) {
|
|
848
306
|
const pkgPath = path.join(root, "package.json");
|
|
849
307
|
if (!fs.existsSync(pkgPath)) {
|
|
@@ -879,12 +337,11 @@ export function syncLocksToPackageJson(root) {
|
|
|
879
337
|
}
|
|
880
338
|
}
|
|
881
339
|
|
|
882
|
-
// Backward-compatible alias
|
|
883
340
|
export function injectPackageJsonMarker(root) {
|
|
884
341
|
return syncLocksToPackageJson(root);
|
|
885
342
|
}
|
|
886
343
|
|
|
887
|
-
// --- Auto-guard related files
|
|
344
|
+
// --- Auto-guard related files ---
|
|
888
345
|
|
|
889
346
|
const FILE_KEYWORD_PATTERNS = [
|
|
890
347
|
{ keywords: ["auth", "authentication", "login", "signup", "signin", "sign-in", "sign-up"], patterns: ["**/Auth*", "**/auth*", "**/Login*", "**/login*", "**/SignUp*", "**/signup*", "**/SignIn*", "**/signin*", "**/*Auth*", "**/*auth*"] },
|
|
@@ -898,7 +355,6 @@ function findRelatedFiles(root, lockText) {
|
|
|
898
355
|
const lockLower = lockText.toLowerCase();
|
|
899
356
|
const matchedFiles = [];
|
|
900
357
|
|
|
901
|
-
// Find which keyword patterns match this lock text
|
|
902
358
|
const matchingPatterns = [];
|
|
903
359
|
for (const group of FILE_KEYWORD_PATTERNS) {
|
|
904
360
|
const hasMatch = group.keywords.some((kw) => lockLower.includes(kw));
|
|
@@ -909,7 +365,6 @@ function findRelatedFiles(root, lockText) {
|
|
|
909
365
|
|
|
910
366
|
if (matchingPatterns.length === 0) return matchedFiles;
|
|
911
367
|
|
|
912
|
-
// Scan the src/ directory (and common directories) for matching files
|
|
913
368
|
const searchDirs = ["src", "app", "components", "pages", "lib", "utils", "contexts", "hooks", "services"];
|
|
914
369
|
|
|
915
370
|
for (const dir of searchDirs) {
|
|
@@ -918,7 +373,6 @@ function findRelatedFiles(root, lockText) {
|
|
|
918
373
|
scanDirForMatches(root, dirPath, matchingPatterns, matchedFiles);
|
|
919
374
|
}
|
|
920
375
|
|
|
921
|
-
// Also check root-level files
|
|
922
376
|
try {
|
|
923
377
|
const rootFiles = fs.readdirSync(root);
|
|
924
378
|
for (const file of rootFiles) {
|
|
@@ -964,27 +418,17 @@ function scanDirForMatches(root, dirPath, patterns, results) {
|
|
|
964
418
|
}
|
|
965
419
|
|
|
966
420
|
function patternMatchesFile(pattern, filePath) {
|
|
967
|
-
// Simple glob matching: convert glob to regex
|
|
968
|
-
// Handle ** (any path), * (any chars in segment)
|
|
969
421
|
const clean = pattern.replace(/\\/g, "/");
|
|
970
422
|
const fileLower = filePath.toLowerCase();
|
|
971
423
|
const patternLower = clean.toLowerCase();
|
|
972
|
-
|
|
973
|
-
// Strip leading **/ for simple name matching
|
|
974
424
|
const namePattern = patternLower.replace(/^\*\*\//, "");
|
|
975
|
-
|
|
976
|
-
// Check if pattern is just a name pattern (no path separators)
|
|
977
425
|
if (!namePattern.includes("/")) {
|
|
978
426
|
const fileName = fileLower.split("/").pop();
|
|
979
|
-
// Convert glob * to regex .*
|
|
980
427
|
const regex = new RegExp("^" + namePattern.replace(/\*/g, ".*") + "$");
|
|
981
428
|
if (regex.test(fileName)) return true;
|
|
982
|
-
// Also check if the pattern appears anywhere in the filename
|
|
983
429
|
const corePattern = namePattern.replace(/\*/g, "");
|
|
984
430
|
if (corePattern.length > 2 && fileName.includes(corePattern)) return true;
|
|
985
431
|
}
|
|
986
|
-
|
|
987
|
-
// Full path match
|
|
988
432
|
const regex = new RegExp("^" + patternLower.replace(/\*\*\//g, "(.*/)?").replace(/\*/g, "[^/]*") + "$");
|
|
989
433
|
return regex.test(fileLower);
|
|
990
434
|
}
|
|
@@ -1017,14 +461,13 @@ export function unguardFile(root, relativeFilePath) {
|
|
|
1017
461
|
return { success: false, error: `File is not guarded: ${relativeFilePath}` };
|
|
1018
462
|
}
|
|
1019
463
|
|
|
1020
|
-
// Remove everything from first marker line to the blank line after last marker
|
|
1021
464
|
const lines = content.split("\n");
|
|
1022
465
|
let guardEnd = 0;
|
|
1023
466
|
let inGuard = false;
|
|
1024
467
|
for (let i = 0; i < lines.length; i++) {
|
|
1025
468
|
if (lines[i].includes(GUARD_TAG)) inGuard = true;
|
|
1026
469
|
if (inGuard && lines[i].includes("=".repeat(60)) && i > 0) {
|
|
1027
|
-
guardEnd = i + 1;
|
|
470
|
+
guardEnd = i + 1;
|
|
1028
471
|
if (lines[guardEnd] === "") guardEnd++;
|
|
1029
472
|
break;
|
|
1030
473
|
}
|
|
@@ -1036,7 +479,8 @@ export function unguardFile(root, relativeFilePath) {
|
|
|
1036
479
|
return { success: true };
|
|
1037
480
|
}
|
|
1038
481
|
|
|
1039
|
-
// ---
|
|
482
|
+
// --- Templates ---
|
|
483
|
+
import { getTemplateNames, getTemplate } from "./templates.js";
|
|
1040
484
|
|
|
1041
485
|
export function listTemplates() {
|
|
1042
486
|
const names = getTemplateNames();
|
|
@@ -1062,15 +506,14 @@ export function applyTemplate(root, templateName) {
|
|
|
1062
506
|
|
|
1063
507
|
let locksAdded = 0;
|
|
1064
508
|
let decisionsAdded = 0;
|
|
1065
|
-
|
|
1066
509
|
for (const lockText of template.locks) {
|
|
1067
|
-
|
|
510
|
+
addLockFn(root, lockText, [template.name], "agent");
|
|
1068
511
|
autoGuardRelatedFiles(root, lockText);
|
|
1069
512
|
locksAdded++;
|
|
1070
513
|
}
|
|
1071
514
|
|
|
1072
515
|
for (const decText of template.decisions) {
|
|
1073
|
-
|
|
516
|
+
addDecisionFn(root, decText, [template.name], "agent");
|
|
1074
517
|
decisionsAdded++;
|
|
1075
518
|
}
|
|
1076
519
|
|
|
@@ -1085,144 +528,7 @@ export function applyTemplate(root, templateName) {
|
|
|
1085
528
|
};
|
|
1086
529
|
}
|
|
1087
530
|
|
|
1088
|
-
// --- Violation Report ---
|
|
1089
|
-
|
|
1090
|
-
export function generateReport(root) {
|
|
1091
|
-
const brain = ensureInit(root);
|
|
1092
|
-
const violations = brain.state.violations || [];
|
|
1093
|
-
|
|
1094
|
-
if (violations.length === 0) {
|
|
1095
|
-
return {
|
|
1096
|
-
totalViolations: 0,
|
|
1097
|
-
violationsByLock: {},
|
|
1098
|
-
mostTestedLocks: [],
|
|
1099
|
-
recentViolations: [],
|
|
1100
|
-
summary: "No violations recorded yet. SpecLock is watching.",
|
|
1101
|
-
};
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// Count violations per lock
|
|
1105
|
-
const byLock = {};
|
|
1106
|
-
for (const v of violations) {
|
|
1107
|
-
for (const lock of v.locks) {
|
|
1108
|
-
if (!byLock[lock.text]) {
|
|
1109
|
-
byLock[lock.text] = { count: 0, lockId: lock.id, text: lock.text };
|
|
1110
|
-
}
|
|
1111
|
-
byLock[lock.text].count++;
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
// Sort by count descending
|
|
1116
|
-
const mostTested = Object.values(byLock).sort((a, b) => b.count - a.count);
|
|
1117
|
-
|
|
1118
|
-
// Recent 10
|
|
1119
|
-
const recent = violations.slice(0, 10).map((v) => ({
|
|
1120
|
-
at: v.at,
|
|
1121
|
-
action: v.action,
|
|
1122
|
-
topLevel: v.topLevel,
|
|
1123
|
-
topConfidence: v.topConfidence,
|
|
1124
|
-
lockCount: v.locks.length,
|
|
1125
|
-
}));
|
|
1126
|
-
|
|
1127
|
-
// Time range
|
|
1128
|
-
const oldest = violations[violations.length - 1];
|
|
1129
|
-
const newest = violations[0];
|
|
1130
|
-
|
|
1131
|
-
return {
|
|
1132
|
-
totalViolations: violations.length,
|
|
1133
|
-
timeRange: { from: oldest.at, to: newest.at },
|
|
1134
|
-
violationsByLock: byLock,
|
|
1135
|
-
mostTestedLocks: mostTested.slice(0, 5),
|
|
1136
|
-
recentViolations: recent,
|
|
1137
|
-
summary: `SpecLock blocked ${violations.length} violation(s). Most tested lock: "${mostTested[0].text}" (${mostTested[0].count} blocks).`,
|
|
1138
|
-
};
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// --- Pre-commit Audit ---
|
|
1142
|
-
|
|
1143
|
-
export function auditStagedFiles(root) {
|
|
1144
|
-
const brain = ensureInit(root);
|
|
1145
|
-
const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
|
|
1146
|
-
|
|
1147
|
-
if (activeLocks.length === 0) {
|
|
1148
|
-
return { passed: true, violations: [], checkedFiles: 0, activeLocks: 0, message: "No active locks. Audit passed." };
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
const stagedFiles = getStagedFiles(root);
|
|
1152
|
-
if (stagedFiles.length === 0) {
|
|
1153
|
-
return { passed: true, violations: [], checkedFiles: 0, activeLocks: activeLocks.length, message: "No staged files. Audit passed." };
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
const violations = [];
|
|
1157
|
-
|
|
1158
|
-
for (const file of stagedFiles) {
|
|
1159
|
-
// Check 1: Does the file have a SPECLOCK-GUARD header?
|
|
1160
|
-
const fullPath = path.join(root, file);
|
|
1161
|
-
if (fs.existsSync(fullPath)) {
|
|
1162
|
-
try {
|
|
1163
|
-
const content = fs.readFileSync(fullPath, "utf-8");
|
|
1164
|
-
if (content.includes(GUARD_TAG)) {
|
|
1165
|
-
violations.push({
|
|
1166
|
-
file,
|
|
1167
|
-
reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
|
|
1168
|
-
lockText: "(file-level guard)",
|
|
1169
|
-
severity: "HIGH",
|
|
1170
|
-
});
|
|
1171
|
-
continue;
|
|
1172
|
-
}
|
|
1173
|
-
} catch (_) {}
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// Check 2: Does the file path match any lock keywords?
|
|
1177
|
-
const fileLower = file.toLowerCase();
|
|
1178
|
-
for (const lock of activeLocks) {
|
|
1179
|
-
const lockLower = lock.text.toLowerCase();
|
|
1180
|
-
const lockHasNegation = hasNegation(lockLower);
|
|
1181
|
-
if (!lockHasNegation) continue;
|
|
1182
|
-
|
|
1183
|
-
// Check if any FILE_KEYWORD_PATTERNS keywords from the lock match this file
|
|
1184
|
-
for (const group of FILE_KEYWORD_PATTERNS) {
|
|
1185
|
-
const lockMatchesKeyword = group.keywords.some((kw) => lockLower.includes(kw));
|
|
1186
|
-
if (!lockMatchesKeyword) continue;
|
|
1187
|
-
|
|
1188
|
-
const fileMatchesPattern = group.patterns.some((pattern) => patternMatchesFile(pattern, fileLower) || patternMatchesFile(pattern, fileLower.split("/").pop()));
|
|
1189
|
-
if (fileMatchesPattern) {
|
|
1190
|
-
violations.push({
|
|
1191
|
-
file,
|
|
1192
|
-
reason: `File matches lock keyword pattern`,
|
|
1193
|
-
lockText: lock.text,
|
|
1194
|
-
severity: "MEDIUM",
|
|
1195
|
-
});
|
|
1196
|
-
break;
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
// Deduplicate by file
|
|
1203
|
-
const seen = new Set();
|
|
1204
|
-
const unique = violations.filter((v) => {
|
|
1205
|
-
if (seen.has(v.file)) return false;
|
|
1206
|
-
seen.add(v.file);
|
|
1207
|
-
return true;
|
|
1208
|
-
});
|
|
1209
|
-
|
|
1210
|
-
const passed = unique.length === 0;
|
|
1211
|
-
const message = passed
|
|
1212
|
-
? `Audit passed. ${stagedFiles.length} file(s) checked against ${activeLocks.length} lock(s).`
|
|
1213
|
-
: `AUDIT FAILED: ${unique.length} violation(s) in ${stagedFiles.length} staged file(s).`;
|
|
1214
|
-
|
|
1215
|
-
return {
|
|
1216
|
-
passed,
|
|
1217
|
-
violations: unique,
|
|
1218
|
-
checkedFiles: stagedFiles.length,
|
|
1219
|
-
activeLocks: activeLocks.length,
|
|
1220
|
-
message,
|
|
1221
|
-
};
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
531
|
// --- Enterprise features (v2.1) ---
|
|
1225
|
-
|
|
1226
532
|
export { verifyAuditChain } from "./audit.js";
|
|
1227
533
|
export { exportCompliance } from "./compliance.js";
|
|
1228
534
|
export { checkFeature, checkLimits, getLicenseInfo } from "./license.js";
|