speclock 1.6.0 → 2.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.
@@ -13,8 +13,11 @@ import {
13
13
  addRecentChange,
14
14
  addRevert,
15
15
  readEvents,
16
+ addViolation,
16
17
  } from "./storage.js";
17
- import { hasGit, getHead, getDefaultBranch, captureDiff } from "./git.js";
18
+ import { hasGit, getHead, getDefaultBranch, captureDiff, getStagedFiles } from "./git.js";
19
+ import { getTemplateNames, getTemplate } from "./templates.js";
20
+ import { analyzeConflict } from "./semantics.js";
18
21
 
19
22
  // --- Internal helpers ---
20
23
 
@@ -249,7 +252,8 @@ export function handleFileEvent(root, brain, type, filePath) {
249
252
  recordEvent(root, brain, event);
250
253
  }
251
254
 
252
- // --- Synonym groups for semantic matching ---
255
+ // --- Legacy synonym groups (deprecated — kept for backward compatibility) ---
256
+ // @deprecated Use analyzeConflict() from semantics.js instead
253
257
  const SYNONYM_GROUPS = [
254
258
  ["remove", "delete", "drop", "eliminate", "destroy", "kill", "purge", "wipe"],
255
259
  ["add", "create", "introduce", "insert", "new"],
@@ -268,12 +272,13 @@ const SYNONYM_GROUPS = [
268
272
  ["enable", "activate", "turn-on", "switch-on"],
269
273
  ];
270
274
 
271
- // Negation words that invert meaning
275
+ // @deprecated
272
276
  const NEGATION_WORDS = ["no", "not", "never", "without", "dont", "don't", "cannot", "can't", "shouldn't", "mustn't", "avoid", "prevent", "prohibit", "forbid", "disallow"];
273
277
 
274
- // Destructive action words
278
+ // @deprecated
275
279
  const DESTRUCTIVE_WORDS = ["remove", "delete", "drop", "destroy", "kill", "purge", "wipe", "break", "disable", "revert", "rollback", "undo"];
276
280
 
281
+ // @deprecated — use analyzeConflict() from semantics.js
277
282
  function expandWithSynonyms(words) {
278
283
  const expanded = new Set(words);
279
284
  for (const word of words) {
@@ -286,17 +291,20 @@ function expandWithSynonyms(words) {
286
291
  return [...expanded];
287
292
  }
288
293
 
294
+ // @deprecated
289
295
  function hasNegation(text) {
290
296
  const lower = text.toLowerCase();
291
297
  return NEGATION_WORDS.some((neg) => lower.includes(neg));
292
298
  }
293
299
 
300
+ // @deprecated
294
301
  function isDestructiveAction(text) {
295
302
  const lower = text.toLowerCase();
296
303
  return DESTRUCTIVE_WORDS.some((w) => lower.includes(w));
297
304
  }
298
305
 
299
306
  // Check if a proposed action conflicts with any active SpecLock
307
+ // v2: Uses the semantic analysis engine from semantics.js
300
308
  export function checkConflict(root, proposedAction) {
301
309
  const brain = ensureInit(root);
302
310
  const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
@@ -308,61 +316,18 @@ export function checkConflict(root, proposedAction) {
308
316
  };
309
317
  }
310
318
 
311
- const actionLower = proposedAction.toLowerCase();
312
- const actionWords = actionLower.split(/\s+/).filter((w) => w.length > 2);
313
- const actionExpanded = expandWithSynonyms(actionWords);
314
- const actionIsDestructive = isDestructiveAction(actionLower);
315
-
316
319
  const conflicting = [];
317
320
  for (const lock of activeLocks) {
318
- const lockLower = lock.text.toLowerCase();
319
- const lockWords = lockLower.split(/\s+/).filter((w) => w.length > 2);
320
- const lockExpanded = expandWithSynonyms(lockWords);
321
-
322
- // Direct keyword overlap
323
- const directOverlap = actionWords.filter((w) => lockWords.includes(w));
324
-
325
- // Synonym-expanded overlap
326
- const synonymOverlap = actionExpanded.filter((w) => lockExpanded.includes(w));
327
- const uniqueSynonymMatches = synonymOverlap.filter((w) => !directOverlap.includes(w));
328
-
329
- // Negation analysis: lock says "No X" and action does X
330
- const lockHasNegation = hasNegation(lockLower);
331
- const actionHasNegation = hasNegation(actionLower);
332
- const negationConflict = lockHasNegation && !actionHasNegation && synonymOverlap.length > 0;
333
-
334
- // Calculate confidence score
335
- let confidence = 0;
336
- let reasons = [];
337
-
338
- if (directOverlap.length > 0) {
339
- confidence += directOverlap.length * 30;
340
- reasons.push(`direct keyword match: ${directOverlap.join(", ")}`);
341
- }
342
- if (uniqueSynonymMatches.length > 0) {
343
- confidence += uniqueSynonymMatches.length * 15;
344
- reasons.push(`synonym match: ${uniqueSynonymMatches.join(", ")}`);
345
- }
346
- if (negationConflict) {
347
- confidence += 40;
348
- reasons.push("lock prohibits this action (negation detected)");
349
- }
350
- if (actionIsDestructive && synonymOverlap.length > 0) {
351
- confidence += 20;
352
- reasons.push("destructive action against locked constraint");
353
- }
354
-
355
- confidence = Math.min(confidence, 100);
321
+ const result = analyzeConflict(proposedAction, lock.text);
356
322
 
357
- if (confidence >= 15) {
358
- const level = confidence >= 70 ? "HIGH" : confidence >= 40 ? "MEDIUM" : "LOW";
323
+ if (result.isConflict) {
359
324
  conflicting.push({
360
325
  id: lock.id,
361
326
  text: lock.text,
362
- matchedKeywords: [...new Set([...directOverlap, ...uniqueSynonymMatches])],
363
- confidence,
364
- level,
365
- reasons,
327
+ matchedKeywords: [],
328
+ confidence: result.confidence,
329
+ level: result.level,
330
+ reasons: result.reasons,
366
331
  });
367
332
  }
368
333
  }
@@ -371,7 +336,7 @@ export function checkConflict(root, proposedAction) {
371
336
  return {
372
337
  hasConflict: false,
373
338
  conflictingLocks: [],
374
- analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (keyword + synonym + negation analysis). Proceed with caution.`,
339
+ analysis: `Checked against ${activeLocks.length} active lock(s). No conflicts detected (semantic analysis v2). Proceed with caution.`,
375
340
  };
376
341
  }
377
342
 
@@ -385,11 +350,38 @@ export function checkConflict(root, proposedAction) {
385
350
  )
386
351
  .join("\n");
387
352
 
388
- return {
353
+ const result = {
389
354
  hasConflict: true,
390
355
  conflictingLocks: conflicting,
391
356
  analysis: `Potential conflict with ${conflicting.length} lock(s):\n${details}\nReview before proceeding.`,
392
357
  };
358
+
359
+ // Record violation for reporting
360
+ addViolation(brain, {
361
+ at: nowIso(),
362
+ action: proposedAction,
363
+ locks: conflicting.map((c) => ({ id: c.id, text: c.text, confidence: c.confidence, level: c.level })),
364
+ topLevel: conflicting[0].level,
365
+ topConfidence: conflicting[0].confidence,
366
+ });
367
+ writeBrain(root, brain);
368
+
369
+ return result;
370
+ }
371
+
372
+ // Async version — uses LLM if available, falls back to heuristic
373
+ export async function checkConflictAsync(root, proposedAction) {
374
+ // Try LLM first (if llm-checker is available)
375
+ try {
376
+ const { llmCheckConflict } = await import("./llm-checker.js");
377
+ const llmResult = await llmCheckConflict(root, proposedAction);
378
+ if (llmResult) return llmResult;
379
+ } catch (_) {
380
+ // LLM checker not available or failed — fall through to heuristic
381
+ }
382
+
383
+ // Fallback to heuristic
384
+ return checkConflict(root, proposedAction);
393
385
  }
394
386
 
395
387
  // --- Auto-lock suggestions ---
@@ -464,7 +456,7 @@ export function suggestLocks(root) {
464
456
  return { suggestions, totalLocks: brain.specLock.items.filter((l) => l.active).length };
465
457
  }
466
458
 
467
- // --- Drift detection ---
459
+ // --- Drift detection (v2: uses semantic engine) ---
468
460
  export function detectDrift(root) {
469
461
  const brain = ensureInit(root);
470
462
  const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
@@ -474,29 +466,20 @@ export function detectDrift(root) {
474
466
 
475
467
  const drifts = [];
476
468
 
477
- // Check recent changes against locks
469
+ // Check recent changes against locks using the semantic engine
478
470
  for (const change of brain.state.recentChanges) {
479
- const changeLower = change.summary.toLowerCase();
480
- const changeWords = changeLower.split(/\s+/).filter((w) => w.length > 2);
481
- const changeExpanded = expandWithSynonyms(changeWords);
482
-
483
471
  for (const lock of activeLocks) {
484
- const lockLower = lock.text.toLowerCase();
485
- const lockWords = lockLower.split(/\s+/).filter((w) => w.length > 2);
486
- const lockExpanded = expandWithSynonyms(lockWords);
487
-
488
- const overlap = changeExpanded.filter((w) => lockExpanded.includes(w));
489
- const lockHasNegation = hasNegation(lockLower);
472
+ const result = analyzeConflict(change.summary, lock.text);
490
473
 
491
- if (overlap.length >= 2 && lockHasNegation) {
474
+ if (result.isConflict) {
492
475
  drifts.push({
493
476
  lockId: lock.id,
494
477
  lockText: lock.text,
495
478
  changeEventId: change.eventId,
496
479
  changeSummary: change.summary,
497
480
  changeAt: change.at,
498
- matchedTerms: overlap,
499
- severity: overlap.length >= 3 ? "high" : "medium",
481
+ matchedTerms: result.reasons,
482
+ severity: result.level === "HIGH" ? "high" : "medium",
500
483
  });
501
484
  }
502
485
  }
@@ -777,6 +760,11 @@ npx speclock unguard <file> # Remove file protection
777
760
  npx speclock lock remove <lockId> # Unlock (only after explicit permission)
778
761
  npx speclock log-change "what changed" # Log changes
779
762
  npx speclock decide "decision" # Record a decision
763
+ npx speclock template list # List constraint templates
764
+ npx speclock template apply <name> # Apply a template (nextjs, react, etc.)
765
+ npx speclock report # Show violation stats
766
+ npx speclock hook install # Install git pre-commit hook
767
+ npx speclock audit # Audit staged files vs locks
780
768
  npx speclock context # Refresh context file
781
769
  \`\`\`
782
770
 
@@ -1042,3 +1030,188 @@ export function unguardFile(root, relativeFilePath) {
1042
1030
 
1043
1031
  return { success: true };
1044
1032
  }
1033
+
1034
+ // --- Constraint Templates ---
1035
+
1036
+ export function listTemplates() {
1037
+ const names = getTemplateNames();
1038
+ return names.map((name) => {
1039
+ const t = getTemplate(name);
1040
+ return {
1041
+ name: t.name,
1042
+ displayName: t.displayName,
1043
+ description: t.description,
1044
+ lockCount: t.locks.length,
1045
+ decisionCount: t.decisions.length,
1046
+ };
1047
+ });
1048
+ }
1049
+
1050
+ export function applyTemplate(root, templateName) {
1051
+ const template = getTemplate(templateName);
1052
+ if (!template) {
1053
+ return { applied: false, error: `Template not found: "${templateName}". Available: ${getTemplateNames().join(", ")}` };
1054
+ }
1055
+
1056
+ ensureInit(root);
1057
+
1058
+ let locksAdded = 0;
1059
+ let decisionsAdded = 0;
1060
+
1061
+ for (const lockText of template.locks) {
1062
+ addLock(root, lockText, [template.name], "agent");
1063
+ autoGuardRelatedFiles(root, lockText);
1064
+ locksAdded++;
1065
+ }
1066
+
1067
+ for (const decText of template.decisions) {
1068
+ addDecision(root, decText, [template.name], "agent");
1069
+ decisionsAdded++;
1070
+ }
1071
+
1072
+ syncLocksToPackageJson(root);
1073
+
1074
+ return {
1075
+ applied: true,
1076
+ templateName: template.name,
1077
+ displayName: template.displayName,
1078
+ locksAdded,
1079
+ decisionsAdded,
1080
+ };
1081
+ }
1082
+
1083
+ // --- Violation Report ---
1084
+
1085
+ export function generateReport(root) {
1086
+ const brain = ensureInit(root);
1087
+ const violations = brain.state.violations || [];
1088
+
1089
+ if (violations.length === 0) {
1090
+ return {
1091
+ totalViolations: 0,
1092
+ violationsByLock: {},
1093
+ mostTestedLocks: [],
1094
+ recentViolations: [],
1095
+ summary: "No violations recorded yet. SpecLock is watching.",
1096
+ };
1097
+ }
1098
+
1099
+ // Count violations per lock
1100
+ const byLock = {};
1101
+ for (const v of violations) {
1102
+ for (const lock of v.locks) {
1103
+ if (!byLock[lock.text]) {
1104
+ byLock[lock.text] = { count: 0, lockId: lock.id, text: lock.text };
1105
+ }
1106
+ byLock[lock.text].count++;
1107
+ }
1108
+ }
1109
+
1110
+ // Sort by count descending
1111
+ const mostTested = Object.values(byLock).sort((a, b) => b.count - a.count);
1112
+
1113
+ // Recent 10
1114
+ const recent = violations.slice(0, 10).map((v) => ({
1115
+ at: v.at,
1116
+ action: v.action,
1117
+ topLevel: v.topLevel,
1118
+ topConfidence: v.topConfidence,
1119
+ lockCount: v.locks.length,
1120
+ }));
1121
+
1122
+ // Time range
1123
+ const oldest = violations[violations.length - 1];
1124
+ const newest = violations[0];
1125
+
1126
+ return {
1127
+ totalViolations: violations.length,
1128
+ timeRange: { from: oldest.at, to: newest.at },
1129
+ violationsByLock: byLock,
1130
+ mostTestedLocks: mostTested.slice(0, 5),
1131
+ recentViolations: recent,
1132
+ summary: `SpecLock blocked ${violations.length} violation(s). Most tested lock: "${mostTested[0].text}" (${mostTested[0].count} blocks).`,
1133
+ };
1134
+ }
1135
+
1136
+ // --- Pre-commit Audit ---
1137
+
1138
+ export function auditStagedFiles(root) {
1139
+ const brain = ensureInit(root);
1140
+ const activeLocks = brain.specLock.items.filter((l) => l.active !== false);
1141
+
1142
+ if (activeLocks.length === 0) {
1143
+ return { passed: true, violations: [], checkedFiles: 0, activeLocks: 0, message: "No active locks. Audit passed." };
1144
+ }
1145
+
1146
+ const stagedFiles = getStagedFiles(root);
1147
+ if (stagedFiles.length === 0) {
1148
+ return { passed: true, violations: [], checkedFiles: 0, activeLocks: activeLocks.length, message: "No staged files. Audit passed." };
1149
+ }
1150
+
1151
+ const violations = [];
1152
+
1153
+ for (const file of stagedFiles) {
1154
+ // Check 1: Does the file have a SPECLOCK-GUARD header?
1155
+ const fullPath = path.join(root, file);
1156
+ if (fs.existsSync(fullPath)) {
1157
+ try {
1158
+ const content = fs.readFileSync(fullPath, "utf-8");
1159
+ if (content.includes(GUARD_TAG)) {
1160
+ violations.push({
1161
+ file,
1162
+ reason: "File has SPECLOCK-GUARD header — it is locked and must not be modified",
1163
+ lockText: "(file-level guard)",
1164
+ severity: "HIGH",
1165
+ });
1166
+ continue;
1167
+ }
1168
+ } catch (_) {}
1169
+ }
1170
+
1171
+ // Check 2: Does the file path match any lock keywords?
1172
+ const fileLower = file.toLowerCase();
1173
+ for (const lock of activeLocks) {
1174
+ const lockLower = lock.text.toLowerCase();
1175
+ const lockHasNegation = hasNegation(lockLower);
1176
+ if (!lockHasNegation) continue;
1177
+
1178
+ // Check if any FILE_KEYWORD_PATTERNS keywords from the lock match this file
1179
+ for (const group of FILE_KEYWORD_PATTERNS) {
1180
+ const lockMatchesKeyword = group.keywords.some((kw) => lockLower.includes(kw));
1181
+ if (!lockMatchesKeyword) continue;
1182
+
1183
+ const fileMatchesPattern = group.patterns.some((pattern) => patternMatchesFile(pattern, fileLower) || patternMatchesFile(pattern, fileLower.split("/").pop()));
1184
+ if (fileMatchesPattern) {
1185
+ violations.push({
1186
+ file,
1187
+ reason: `File matches lock keyword pattern`,
1188
+ lockText: lock.text,
1189
+ severity: "MEDIUM",
1190
+ });
1191
+ break;
1192
+ }
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ // Deduplicate by file
1198
+ const seen = new Set();
1199
+ const unique = violations.filter((v) => {
1200
+ if (seen.has(v.file)) return false;
1201
+ seen.add(v.file);
1202
+ return true;
1203
+ });
1204
+
1205
+ const passed = unique.length === 0;
1206
+ const message = passed
1207
+ ? `Audit passed. ${stagedFiles.length} file(s) checked against ${activeLocks.length} lock(s).`
1208
+ : `AUDIT FAILED: ${unique.length} violation(s) in ${stagedFiles.length} staged file(s).`;
1209
+
1210
+ return {
1211
+ passed,
1212
+ violations: unique,
1213
+ checkedFiles: stagedFiles.length,
1214
+ activeLocks: activeLocks.length,
1215
+ message,
1216
+ };
1217
+ }
package/src/core/git.js CHANGED
@@ -108,3 +108,9 @@ export function getDiffSummary(root) {
108
108
  if (!res.ok) return "";
109
109
  return res.stdout;
110
110
  }
111
+
112
+ export function getStagedFiles(root) {
113
+ const res = safeGit(root, ["diff", "--cached", "--name-only"]);
114
+ if (!res.ok || !res.stdout) return [];
115
+ return res.stdout.split("\n").filter(Boolean);
116
+ }
@@ -0,0 +1,87 @@
1
+ // SpecLock Git Hook Management
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+
6
+ const HOOK_MARKER = "# SPECLOCK-HOOK";
7
+
8
+ const HOOK_SCRIPT = `#!/bin/sh
9
+ ${HOOK_MARKER} — Do not remove this line
10
+ # SpecLock pre-commit hook: checks staged files against active locks
11
+ # Install: npx speclock hook install
12
+ # Remove: npx speclock hook remove
13
+
14
+ npx speclock audit
15
+ exit $?
16
+ `;
17
+
18
+ export function installHook(root) {
19
+ const hooksDir = path.join(root, ".git", "hooks");
20
+ if (!fs.existsSync(path.join(root, ".git"))) {
21
+ return { success: false, error: "Not a git repository. Run 'git init' first." };
22
+ }
23
+
24
+ // Ensure hooks directory exists
25
+ fs.mkdirSync(hooksDir, { recursive: true });
26
+
27
+ const hookPath = path.join(hooksDir, "pre-commit");
28
+
29
+ // Check if existing hook exists (not ours)
30
+ if (fs.existsSync(hookPath)) {
31
+ const existing = fs.readFileSync(hookPath, "utf-8");
32
+ if (existing.includes(HOOK_MARKER)) {
33
+ return { success: false, error: "SpecLock pre-commit hook is already installed." };
34
+ }
35
+ // Append to existing hook
36
+ const appended = existing.trimEnd() + "\n\n" + HOOK_SCRIPT;
37
+ fs.writeFileSync(hookPath, appended, { mode: 0o755 });
38
+ return { success: true, message: "SpecLock hook appended to existing pre-commit hook." };
39
+ }
40
+
41
+ fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
42
+ return { success: true, message: "SpecLock pre-commit hook installed." };
43
+ }
44
+
45
+ export function removeHook(root) {
46
+ const hookPath = path.join(root, ".git", "hooks", "pre-commit");
47
+ if (!fs.existsSync(hookPath)) {
48
+ return { success: false, error: "No pre-commit hook found." };
49
+ }
50
+
51
+ const content = fs.readFileSync(hookPath, "utf-8");
52
+ if (!content.includes(HOOK_MARKER)) {
53
+ return { success: false, error: "Pre-commit hook exists but was not installed by SpecLock." };
54
+ }
55
+
56
+ // Check if lines other than our speclock block exist
57
+ const lines = content.split("\n");
58
+ const nonSpeclockLines = lines.filter((line) => {
59
+ const trimmed = line.trim();
60
+ const lower = trimmed.toLowerCase();
61
+ if (!trimmed || trimmed === "#!/bin/sh") return false;
62
+ if (lower.includes("speclock")) return false;
63
+ if (lower.includes("npx speclock")) return false;
64
+ if (trimmed === "exit $?") return false;
65
+ return true;
66
+ });
67
+
68
+ if (nonSpeclockLines.length === 0) {
69
+ // Entire hook was ours — remove file
70
+ fs.unlinkSync(hookPath);
71
+ return { success: true, message: "SpecLock pre-commit hook removed." };
72
+ }
73
+
74
+ // Other hook content exists — remove our block, keep the rest
75
+ const cleaned = content
76
+ .replace(/\n*# SPECLOCK-HOOK[^\n]*\n.*?exit \$\?\n?/s, "\n")
77
+ .trim();
78
+ fs.writeFileSync(hookPath, cleaned + "\n", { mode: 0o755 });
79
+ return { success: true, message: "SpecLock hook removed. Other hook content preserved." };
80
+ }
81
+
82
+ export function isHookInstalled(root) {
83
+ const hookPath = path.join(root, ".git", "hooks", "pre-commit");
84
+ if (!fs.existsSync(hookPath)) return false;
85
+ const content = fs.readFileSync(hookPath, "utf-8");
86
+ return content.includes(HOOK_MARKER);
87
+ }