speclock 5.5.5 → 5.5.7
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/package.json +1 -1
- package/src/cli/index.js +354 -24
- package/src/core/compliance.js +1 -1
- package/src/core/guardian.js +466 -457
- package/src/core/hooks.js +109 -91
- package/src/core/pre-commit-semantic.js +102 -2
- package/src/core/semantics.js +3019 -2717
- package/src/core/telemetry.js +940 -852
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +1 -1
- package/src/mcp/server.js +1 -1
package/src/core/hooks.js
CHANGED
|
@@ -1,91 +1,109 @@
|
|
|
1
|
-
// SpecLock Git Hook Management
|
|
2
|
-
// Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
3
|
-
|
|
4
|
-
import fs from "fs";
|
|
5
|
-
import path from "path";
|
|
6
|
-
|
|
7
|
-
const HOOK_MARKER = "# SPECLOCK-HOOK";
|
|
8
|
-
|
|
9
|
-
const HOOK_SCRIPT = `#!/bin/sh
|
|
10
|
-
${HOOK_MARKER} — Do not remove this line
|
|
11
|
-
# SpecLock pre-commit hook: runs semantic audit of staged diff + commit message
|
|
12
|
-
# against active locks. Unlike the legacy 'audit' subcommand, this one feeds
|
|
13
|
-
# the actual diff content AND the commit message through the semantic conflict
|
|
14
|
-
# engine — the same one used by 'speclock check'.
|
|
15
|
-
# Install: npx speclock hook install
|
|
16
|
-
# Remove: npx speclock hook remove
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
// SpecLock Git Hook Management
|
|
2
|
+
// Developed by Sandeep Roy (https://github.com/sgroy10)
|
|
3
|
+
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
const HOOK_MARKER = "# SPECLOCK-HOOK";
|
|
8
|
+
|
|
9
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
10
|
+
${HOOK_MARKER} — Do not remove this line
|
|
11
|
+
# SpecLock pre-commit hook: runs semantic audit of staged diff + commit message
|
|
12
|
+
# against active locks. Unlike the legacy 'audit' subcommand, this one feeds
|
|
13
|
+
# the actual diff content AND the commit message through the semantic conflict
|
|
14
|
+
# engine — the same one used by 'speclock check'.
|
|
15
|
+
# Install: npx speclock hook install
|
|
16
|
+
# Remove: npx speclock hook remove
|
|
17
|
+
#
|
|
18
|
+
# Enforcement mode precedence (first match wins):
|
|
19
|
+
# 1. SPECLOCK_STRICT=1 in the environment when git commit runs
|
|
20
|
+
# 2. brain.enforcement.mode === "hard" in .speclock/brain.json
|
|
21
|
+
# (set with: speclock enforce hard)
|
|
22
|
+
# 3. Default: warn mode — violations printed, commit allowed
|
|
23
|
+
#
|
|
24
|
+
# NOTE: Some git versions/shells sanitize the environment before running
|
|
25
|
+
# hooks, which can strip SPECLOCK_STRICT. The persistent brain mode set by
|
|
26
|
+
# 'speclock enforce hard' is the reliable way to enforce strict blocking.
|
|
27
|
+
|
|
28
|
+
# Explicitly export SPECLOCK_STRICT so it survives any sh -c subshells the
|
|
29
|
+
# CLI may spawn. If unset, leave it unset — the CLI will then fall back to
|
|
30
|
+
# reading brain.enforcement.mode from .speclock/brain.json.
|
|
31
|
+
if [ -n "\${SPECLOCK_STRICT:-}" ]; then
|
|
32
|
+
export SPECLOCK_STRICT
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# Marker so the CLI knows it's running inside the pre-commit hook.
|
|
36
|
+
export SPECLOCK_HOOK=1
|
|
37
|
+
|
|
38
|
+
npx speclock audit-semantic --pre-commit
|
|
39
|
+
exit $?
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
export function installHook(root) {
|
|
43
|
+
const hooksDir = path.join(root, ".git", "hooks");
|
|
44
|
+
if (!fs.existsSync(path.join(root, ".git"))) {
|
|
45
|
+
return { success: false, error: "Not a git repository. Run 'git init' first." };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Ensure hooks directory exists
|
|
49
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
52
|
+
|
|
53
|
+
// Check if existing hook exists (not ours)
|
|
54
|
+
if (fs.existsSync(hookPath)) {
|
|
55
|
+
const existing = fs.readFileSync(hookPath, "utf-8");
|
|
56
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
57
|
+
return { success: false, error: "SpecLock pre-commit hook is already installed." };
|
|
58
|
+
}
|
|
59
|
+
// Append to existing hook
|
|
60
|
+
const appended = existing.trimEnd() + "\n\n" + HOOK_SCRIPT;
|
|
61
|
+
fs.writeFileSync(hookPath, appended, { mode: 0o755 });
|
|
62
|
+
return { success: true, message: "SpecLock hook appended to existing pre-commit hook." };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fs.writeFileSync(hookPath, HOOK_SCRIPT, { mode: 0o755 });
|
|
66
|
+
return { success: true, message: "SpecLock pre-commit hook installed." };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function removeHook(root) {
|
|
70
|
+
const hookPath = path.join(root, ".git", "hooks", "pre-commit");
|
|
71
|
+
if (!fs.existsSync(hookPath)) {
|
|
72
|
+
return { success: false, error: "No pre-commit hook found." };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
76
|
+
if (!content.includes(HOOK_MARKER)) {
|
|
77
|
+
return { success: false, error: "Pre-commit hook exists but was not installed by SpecLock." };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Strip our block (from the SPECLOCK-HOOK marker through the trailing
|
|
81
|
+
// `exit $?` that terminates the script snippet). Everything inside the
|
|
82
|
+
// block is ours — comments, env exports, the npx invocation, etc.
|
|
83
|
+
const cleaned = content
|
|
84
|
+
.replace(/\n*# SPECLOCK-HOOK[^\n]*\n[\s\S]*?exit \$\?\n?/, "\n")
|
|
85
|
+
.trim();
|
|
86
|
+
|
|
87
|
+
// If nothing meaningful remains (just #!/bin/sh or empty), remove file.
|
|
88
|
+
const remaining = cleaned
|
|
89
|
+
.split("\n")
|
|
90
|
+
.map((l) => l.trim())
|
|
91
|
+
.filter((l) => l && l !== "#!/bin/sh")
|
|
92
|
+
.join("\n");
|
|
93
|
+
|
|
94
|
+
if (remaining.length === 0) {
|
|
95
|
+
fs.unlinkSync(hookPath);
|
|
96
|
+
return { success: true, message: "SpecLock pre-commit hook removed." };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Other hook content exists — keep the rest.
|
|
100
|
+
fs.writeFileSync(hookPath, cleaned + "\n", { mode: 0o755 });
|
|
101
|
+
return { success: true, message: "SpecLock hook removed. Other hook content preserved." };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isHookInstalled(root) {
|
|
105
|
+
const hookPath = path.join(root, ".git", "hooks", "pre-commit");
|
|
106
|
+
if (!fs.existsSync(hookPath)) return false;
|
|
107
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
108
|
+
return content.includes(HOOK_MARKER);
|
|
109
|
+
}
|
|
@@ -16,6 +16,7 @@ import { analyzeConflict } from "./semantics.js";
|
|
|
16
16
|
import { getEnforcementConfig } from "./enforcer.js";
|
|
17
17
|
|
|
18
18
|
const GUARD_TAG = "SPECLOCK-GUARD";
|
|
19
|
+
const SPECLOCK_AUTOGEN_MARKER = "SpecLock";
|
|
19
20
|
const MAX_LINES_PER_FILE = 500;
|
|
20
21
|
const BINARY_EXTENSIONS = new Set([
|
|
21
22
|
"png", "jpg", "jpeg", "gif", "bmp", "ico", "svg", "webp",
|
|
@@ -27,6 +28,98 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
27
28
|
"lock", "map",
|
|
28
29
|
]);
|
|
29
30
|
|
|
31
|
+
// Files / dirs that SpecLock itself auto-creates during `protect` or that
|
|
32
|
+
// are just noise for semantic analysis. These are ALWAYS skipped from the
|
|
33
|
+
// diff-level semantic audit because matching against them produces nothing
|
|
34
|
+
// but false positives (e.g. rules files describe the same concepts the
|
|
35
|
+
// locks describe, so they always "conflict" with themselves).
|
|
36
|
+
const ALWAYS_SKIP_EXACT = new Set([
|
|
37
|
+
".cursor/rules/speclock.mdc",
|
|
38
|
+
".windsurf/rules/speclock.md",
|
|
39
|
+
".aider.conf.yml",
|
|
40
|
+
".mcp.json",
|
|
41
|
+
"package-lock.json",
|
|
42
|
+
"yarn.lock",
|
|
43
|
+
"pnpm-lock.yaml",
|
|
44
|
+
"Cargo.lock",
|
|
45
|
+
"poetry.lock",
|
|
46
|
+
"Gemfile.lock",
|
|
47
|
+
"composer.lock",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Directory prefixes that are always skipped.
|
|
51
|
+
const ALWAYS_SKIP_DIR_PREFIXES = [
|
|
52
|
+
".speclock/",
|
|
53
|
+
"node_modules/",
|
|
54
|
+
"dist/",
|
|
55
|
+
"build/",
|
|
56
|
+
".next/",
|
|
57
|
+
".nuxt/",
|
|
58
|
+
"__pycache__/",
|
|
59
|
+
".venv/",
|
|
60
|
+
"venv/",
|
|
61
|
+
".cache/",
|
|
62
|
+
"coverage/",
|
|
63
|
+
".turbo/",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// Files that are skipped ONLY if their content carries the SpecLock
|
|
67
|
+
// auto-generated marker (so hand-written AGENTS.md etc. still get audited).
|
|
68
|
+
// CLAUDE.md is included because `speclock protect` seeds it with the active
|
|
69
|
+
// locks — on the initial commit after protect, the file literally IS the
|
|
70
|
+
// locks, so every semantic check would produce a false positive.
|
|
71
|
+
const CONDITIONAL_SKIP_IF_AUTOGEN = new Set([
|
|
72
|
+
"AGENTS.md",
|
|
73
|
+
"GEMINI.md",
|
|
74
|
+
"CLAUDE.md",
|
|
75
|
+
".github/copilot-instructions.md",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize a path to forward slashes for comparison.
|
|
80
|
+
*/
|
|
81
|
+
function normalizePath(p) {
|
|
82
|
+
return (p || "").replace(/\\/g, "/");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Decide whether a file should be skipped by the semantic pre-commit audit.
|
|
87
|
+
* This is the single source of truth for "is this a SpecLock internal file
|
|
88
|
+
* or generated noise we should not audit".
|
|
89
|
+
*
|
|
90
|
+
* @param {string} file - repo-relative path
|
|
91
|
+
* @param {string} root - repo root (to check content of conditional files)
|
|
92
|
+
* @returns {boolean} true if the file should be skipped
|
|
93
|
+
*/
|
|
94
|
+
export function shouldSkipForSemanticAudit(file, root) {
|
|
95
|
+
const norm = normalizePath(file);
|
|
96
|
+
|
|
97
|
+
// 1. Binary extensions
|
|
98
|
+
const ext = path.extname(norm).slice(1).toLowerCase();
|
|
99
|
+
if (BINARY_EXTENSIONS.has(ext)) return true;
|
|
100
|
+
|
|
101
|
+
// 2. Exact path matches (lockfiles, auto-generated rules files, .mcp.json)
|
|
102
|
+
if (ALWAYS_SKIP_EXACT.has(norm)) return true;
|
|
103
|
+
|
|
104
|
+
// 3. Directory prefix matches
|
|
105
|
+
for (const prefix of ALWAYS_SKIP_DIR_PREFIXES) {
|
|
106
|
+
if (norm === prefix.slice(0, -1) || norm.startsWith(prefix)) return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4. Conditionally-skipped files (only if they carry the auto-gen marker)
|
|
110
|
+
if (CONDITIONAL_SKIP_IF_AUTOGEN.has(norm)) {
|
|
111
|
+
try {
|
|
112
|
+
const fullPath = path.join(root, file);
|
|
113
|
+
if (fs.existsSync(fullPath)) {
|
|
114
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
115
|
+
if (content.includes(SPECLOCK_AUTOGEN_MARKER)) return true;
|
|
116
|
+
}
|
|
117
|
+
} catch { /* ignore read errors */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
30
123
|
/**
|
|
31
124
|
* Parse a unified diff into per-file change blocks.
|
|
32
125
|
* Returns array of { file, addedLines, removedLines, hunks }.
|
|
@@ -191,8 +284,14 @@ export function semanticAudit(root) {
|
|
|
191
284
|
};
|
|
192
285
|
}
|
|
193
286
|
|
|
194
|
-
// Parse diff into per-file changes
|
|
195
|
-
|
|
287
|
+
// Parse diff into per-file changes, then drop SpecLock-internal / generated
|
|
288
|
+
// files so we don't flood the user with false positives from files that
|
|
289
|
+
// SpecLock itself creates or manages.
|
|
290
|
+
const allFileChanges = parseDiff(diff);
|
|
291
|
+
const fileChanges = allFileChanges.filter(
|
|
292
|
+
(fc) => !shouldSkipForSemanticAudit(fc.file, root)
|
|
293
|
+
);
|
|
294
|
+
const skippedCount = allFileChanges.length - fileChanges.length;
|
|
196
295
|
const violations = [];
|
|
197
296
|
|
|
198
297
|
for (const fc of fileChanges) {
|
|
@@ -276,6 +375,7 @@ export function semanticAudit(root) {
|
|
|
276
375
|
blocked,
|
|
277
376
|
violations: uniqueViolations,
|
|
278
377
|
filesChecked: fileChanges.length,
|
|
378
|
+
filesSkipped: skippedCount,
|
|
279
379
|
activeLocks: activeLocks.length,
|
|
280
380
|
mode: config.mode,
|
|
281
381
|
threshold: config.blockThreshold,
|