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.
- package/README.md +47 -13
- package/package.json +2 -2
- package/src/cli/index.js +150 -13
- package/src/core/engine.js +243 -70
- package/src/core/git.js +6 -0
- package/src/core/hooks.js +87 -0
- package/src/core/llm-checker.js +239 -0
- package/src/core/semantics.js +1096 -0
- package/src/core/storage.js +18 -0
- package/src/core/templates.js +114 -0
- package/src/mcp/http-server.js +44 -4
- package/src/mcp/server.js +119 -2
package/src/core/engine.js
CHANGED
|
@@ -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
|
-
// ---
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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 (
|
|
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: [
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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:
|
|
499
|
-
severity:
|
|
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
|
+
}
|