speclock 5.5.4 → 5.5.6
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 +263 -335
- package/package.json +1 -1
- package/src/cli/index.js +552 -41
- package/src/core/compliance.js +1 -1
- package/src/core/hooks.js +5 -2
- package/src/core/pre-commit-semantic.js +102 -2
- package/src/core/telemetry.js +685 -114
- package/src/dashboard/index.html +2 -2
- package/src/mcp/http-server.js +1 -1
- package/src/mcp/server.js +1 -1
- package/src/templates/rule-packs/fastapi.md +22 -0
- package/src/templates/rule-packs/nextjs.md +22 -0
- package/src/templates/rule-packs/node.md +22 -0
- package/src/templates/rule-packs/python.md +22 -0
- package/src/templates/rule-packs/rails.md +22 -0
- package/src/templates/rule-packs/react.md +22 -0
package/src/cli/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { getStagedDiff, parseDiff, shouldSkipForSemanticAudit } from "../core/pre-commit-semantic.js";
|
|
2
5
|
import {
|
|
3
6
|
ensureInit,
|
|
4
7
|
setGoal,
|
|
@@ -56,6 +59,15 @@ import {
|
|
|
56
59
|
import {
|
|
57
60
|
isTelemetryEnabled,
|
|
58
61
|
getTelemetrySummary,
|
|
62
|
+
isTelemetryOptedIn,
|
|
63
|
+
hasTelemetryDecision,
|
|
64
|
+
enableTelemetry,
|
|
65
|
+
disableTelemetry,
|
|
66
|
+
clearTelemetryLog,
|
|
67
|
+
getOptInTelemetryStatus,
|
|
68
|
+
ensureTelemetryDecision,
|
|
69
|
+
recordCommand,
|
|
70
|
+
TELEMETRY_DEFAULT_ENDPOINT,
|
|
59
71
|
} from "../core/telemetry.js";
|
|
60
72
|
import {
|
|
61
73
|
isSSOEnabled,
|
|
@@ -128,11 +140,153 @@ function refreshContext(root) {
|
|
|
128
140
|
}
|
|
129
141
|
}
|
|
130
142
|
|
|
143
|
+
// --- Rule packs (speclock init --from <framework>) ---
|
|
144
|
+
|
|
145
|
+
const RULE_PACKS = {
|
|
146
|
+
nextjs: {
|
|
147
|
+
name: "nextjs",
|
|
148
|
+
displayName: "Next.js",
|
|
149
|
+
description: "Next.js (App Router, Server Components, TypeScript)",
|
|
150
|
+
},
|
|
151
|
+
fastapi: {
|
|
152
|
+
name: "fastapi",
|
|
153
|
+
displayName: "FastAPI",
|
|
154
|
+
description: "FastAPI + Python (async, Pydantic, JWT)",
|
|
155
|
+
},
|
|
156
|
+
rails: {
|
|
157
|
+
name: "rails",
|
|
158
|
+
displayName: "Ruby on Rails",
|
|
159
|
+
description: "Ruby on Rails (Strong Params, ActiveRecord)",
|
|
160
|
+
},
|
|
161
|
+
react: {
|
|
162
|
+
name: "react",
|
|
163
|
+
displayName: "React",
|
|
164
|
+
description: "Generic React (hooks, state management)",
|
|
165
|
+
},
|
|
166
|
+
python: {
|
|
167
|
+
name: "python",
|
|
168
|
+
displayName: "Python",
|
|
169
|
+
description: "Generic Python (security, type hints)",
|
|
170
|
+
},
|
|
171
|
+
node: {
|
|
172
|
+
name: "node",
|
|
173
|
+
displayName: "Node.js/Express",
|
|
174
|
+
description: "Node.js/Express (async, security)",
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Locate the rule-packs directory relative to this module.
|
|
180
|
+
* Works for local dev, npm global installs, and npx cache.
|
|
181
|
+
*/
|
|
182
|
+
function getRulePacksDir() {
|
|
183
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
184
|
+
// src/cli/index.js -> src/templates/rule-packs
|
|
185
|
+
return path.resolve(here, "..", "templates", "rule-packs");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read a rule pack file. Returns the raw markdown and an estimated rule count
|
|
190
|
+
* (number of list items under any "## Rules" section).
|
|
191
|
+
*/
|
|
192
|
+
export function loadRulePack(framework) {
|
|
193
|
+
const pack = RULE_PACKS[framework];
|
|
194
|
+
if (!pack) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
error:
|
|
198
|
+
`Unknown framework "${framework}". ` +
|
|
199
|
+
`Run "speclock init --from list" to see available rule packs.`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const dir = getRulePacksDir();
|
|
203
|
+
const filePath = path.join(dir, `${pack.name}.md`);
|
|
204
|
+
if (!fs.existsSync(filePath)) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
error: `Rule pack file missing: ${filePath}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
211
|
+
if (!content.trim()) {
|
|
212
|
+
return { ok: false, error: `Rule pack "${framework}" is empty.` };
|
|
213
|
+
}
|
|
214
|
+
// Count rules: lines under any "## Rules" heading that begin with "- " or "* ".
|
|
215
|
+
const lines = content.split("\n");
|
|
216
|
+
let inRules = false;
|
|
217
|
+
let ruleCount = 0;
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
const trimmed = line.trim();
|
|
220
|
+
if (/^##\s+Rules\s*$/i.test(trimmed)) {
|
|
221
|
+
inRules = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (inRules && /^##\s+/.test(trimmed)) {
|
|
225
|
+
inRules = false;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (inRules && /^[-*]\s+/.test(trimmed)) ruleCount++;
|
|
229
|
+
}
|
|
230
|
+
return { ok: true, pack, content, ruleCount, filePath };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function printRulePackList() {
|
|
234
|
+
console.log("\nAvailable rule packs:");
|
|
235
|
+
const order = ["nextjs", "fastapi", "rails", "react", "python", "node"];
|
|
236
|
+
const pad = Math.max(...order.map((k) => k.length));
|
|
237
|
+
for (const key of order) {
|
|
238
|
+
const p = RULE_PACKS[key];
|
|
239
|
+
console.log(` ${p.name.padEnd(pad)} — ${p.description}`);
|
|
240
|
+
}
|
|
241
|
+
console.log("\nUsage: speclock init --from <framework>");
|
|
242
|
+
console.log("Example: speclock init --from nextjs\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Write (or append) a rule pack to CLAUDE.md in `root`.
|
|
247
|
+
* Returns { ok, displayName, ruleCount, appended, listed, error }.
|
|
248
|
+
*/
|
|
249
|
+
export function initFromRulePack(root, framework) {
|
|
250
|
+
if (framework === "list" || framework === "--list" || framework === "help") {
|
|
251
|
+
printRulePackList();
|
|
252
|
+
return { listed: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const loaded = loadRulePack(framework);
|
|
256
|
+
if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
257
|
+
|
|
258
|
+
const claudePath = path.join(root, "CLAUDE.md");
|
|
259
|
+
let appended = false;
|
|
260
|
+
if (fs.existsSync(claudePath)) {
|
|
261
|
+
appended = true;
|
|
262
|
+
const existing = fs.readFileSync(claudePath, "utf-8");
|
|
263
|
+
const sep =
|
|
264
|
+
existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
265
|
+
const banner =
|
|
266
|
+
`\n<!-- Appended by speclock init --from ${framework} -->\n\n`;
|
|
267
|
+
fs.writeFileSync(claudePath, existing + sep + banner + loaded.content);
|
|
268
|
+
console.log(
|
|
269
|
+
`⚠ CLAUDE.md already exists — appending ${loaded.pack.displayName} rule pack ` +
|
|
270
|
+
`instead of overwriting.`
|
|
271
|
+
);
|
|
272
|
+
} else {
|
|
273
|
+
fs.writeFileSync(claudePath, loaded.content);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
displayName: loaded.pack.displayName,
|
|
279
|
+
ruleCount: loaded.ruleCount,
|
|
280
|
+
appended,
|
|
281
|
+
path: claudePath,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
131
285
|
// --- Help text ---
|
|
132
286
|
|
|
133
287
|
function printHelp() {
|
|
134
288
|
console.log(`
|
|
135
|
-
SpecLock v5.5.
|
|
289
|
+
SpecLock v5.5.6 — Your AI has rules. SpecLock makes them unbreakable.
|
|
136
290
|
Developed by Sandeep Roy (github.com/sgroy10)
|
|
137
291
|
|
|
138
292
|
Usage: speclock <command> [options]
|
|
@@ -140,6 +294,8 @@ Usage: speclock <command> [options]
|
|
|
140
294
|
Commands:
|
|
141
295
|
setup [--goal <text>] [--template <name>] Full setup: init + SPECLOCK.md + context
|
|
142
296
|
init Initialize SpecLock in current directory
|
|
297
|
+
init --from <framework> Bootstrap CLAUDE.md from a curated rule pack
|
|
298
|
+
(nextjs, fastapi, rails, react, python, node, list)
|
|
143
299
|
goal <text> Set or update the project goal
|
|
144
300
|
lock <text> [--tags a,b] Add a non-negotiable constraint
|
|
145
301
|
lock remove <id> Remove a lock by ID
|
|
@@ -208,7 +364,10 @@ Policy-as-Code (v3.5):
|
|
|
208
364
|
policy remove <ruleId> Remove a policy rule
|
|
209
365
|
policy evaluate <action> Evaluate action against policy rules
|
|
210
366
|
policy export Export policy as YAML
|
|
211
|
-
telemetry
|
|
367
|
+
telemetry on Opt in to anonymous usage telemetry
|
|
368
|
+
telemetry off Opt out of telemetry (revokes immediately)
|
|
369
|
+
telemetry status Show opt-in state + last 10 recorded events
|
|
370
|
+
telemetry clear Clear the local telemetry event log
|
|
212
371
|
sso status Show SSO configuration
|
|
213
372
|
sso configure --issuer <url> Configure SSO (--client-id, --client-secret)
|
|
214
373
|
|
|
@@ -286,14 +445,32 @@ function showStatus(root) {
|
|
|
286
445
|
// --- Main ---
|
|
287
446
|
|
|
288
447
|
async function main() {
|
|
289
|
-
|
|
448
|
+
let { cmd, args } = parseArgs(process.argv);
|
|
290
449
|
const root = rootDir();
|
|
291
450
|
|
|
451
|
+
// Fire-and-forget telemetry: hook process exit so we record every
|
|
452
|
+
// invocation exactly once with its final exit code. Wrapped in try/catch
|
|
453
|
+
// so telemetry failures can never block or break the CLI.
|
|
454
|
+
try {
|
|
455
|
+
process.once("exit", (code) => {
|
|
456
|
+
try {
|
|
457
|
+
recordCommand(cmd || "unknown", typeof code === "number" ? code : 0, {
|
|
458
|
+
projectRoot: root,
|
|
459
|
+
});
|
|
460
|
+
} catch (_) { /* swallow */ }
|
|
461
|
+
});
|
|
462
|
+
} catch (_) { /* swallow */ }
|
|
463
|
+
|
|
292
464
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
293
465
|
printHelp();
|
|
294
466
|
process.exit(0);
|
|
295
467
|
}
|
|
296
468
|
|
|
469
|
+
// Dispatch loop — allows certain commands to rewrite `cmd`/`args` and
|
|
470
|
+
// `continue dispatch` to re-enter the command switch (used by
|
|
471
|
+
// `audit --pre-commit` to delegate to `audit-semantic`).
|
|
472
|
+
// eslint-disable-next-line no-labels
|
|
473
|
+
dispatch: while (true) {
|
|
297
474
|
// --- SETUP (new: one-shot full setup) ---
|
|
298
475
|
if (cmd === "setup") {
|
|
299
476
|
const flags = parseFlags(args);
|
|
@@ -363,6 +540,39 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
363
540
|
|
|
364
541
|
// --- INIT ---
|
|
365
542
|
if (cmd === "init") {
|
|
543
|
+
const flags = parseFlags(args);
|
|
544
|
+
|
|
545
|
+
// init --from <framework> : bootstrap CLAUDE.md from a curated rule pack
|
|
546
|
+
if (flags.from) {
|
|
547
|
+
const framework = String(flags.from).trim().toLowerCase();
|
|
548
|
+
const result = initFromRulePack(root, framework);
|
|
549
|
+
if (result.listed) {
|
|
550
|
+
// Already printed list, nothing else to do.
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (!result.ok) {
|
|
554
|
+
console.error(result.error);
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
// Fall through into the rest of the protect flow so the rule pack is
|
|
558
|
+
// actually activated (locks extracted, hook installed, context refreshed).
|
|
559
|
+
const report = protect(root, { strict: false });
|
|
560
|
+
console.log(formatProtectReport(report));
|
|
561
|
+
try {
|
|
562
|
+
setEnforcementMode(root, "advisory");
|
|
563
|
+
} catch (_) { /* ignore */ }
|
|
564
|
+
console.log(
|
|
565
|
+
`✓ Initialized SpecLock with ${result.displayName} rule pack ` +
|
|
566
|
+
`(${result.ruleCount} rules). Edit CLAUDE.md to customize.`
|
|
567
|
+
);
|
|
568
|
+
if (result.appended) {
|
|
569
|
+
console.log(
|
|
570
|
+
" Note: CLAUDE.md already existed — rule pack was APPENDED, not overwritten."
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
366
576
|
ensureInit(root);
|
|
367
577
|
createSpecLockMd(root);
|
|
368
578
|
injectPackageJsonMarker(root);
|
|
@@ -566,6 +776,13 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
566
776
|
|
|
567
777
|
// --- PROTECT (zero-config guardian mode) ---
|
|
568
778
|
if (cmd === "protect") {
|
|
779
|
+
// First-run opt-in prompt (only on the first 'protect' ever, only on TTY).
|
|
780
|
+
try {
|
|
781
|
+
if (!hasTelemetryDecision()) {
|
|
782
|
+
await ensureTelemetryDecision();
|
|
783
|
+
}
|
|
784
|
+
} catch (_) { /* swallow — telemetry must never block protect */ }
|
|
785
|
+
|
|
569
786
|
const flags = parseFlags(args);
|
|
570
787
|
const strict = flags.strict === true || flags.block === true;
|
|
571
788
|
const opts = {
|
|
@@ -837,6 +1054,19 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
837
1054
|
// --- AUDIT ---
|
|
838
1055
|
if (cmd === "audit") {
|
|
839
1056
|
const flags = parseFlags(args);
|
|
1057
|
+
// When invoked from the pre-commit hook (either new or legacy), always
|
|
1058
|
+
// route to the semantic audit path so the commit message + diff content
|
|
1059
|
+
// are actually fed through the semantic conflict engine. We do this by
|
|
1060
|
+
// rewriting cmd/args so the audit-semantic branch below picks it up on
|
|
1061
|
+
// the next iteration of the outer dispatch loop.
|
|
1062
|
+
if (flags["pre-commit"] === true) {
|
|
1063
|
+
cmd = "audit-semantic";
|
|
1064
|
+
args = args.filter((a) => a !== "--pre-commit");
|
|
1065
|
+
// Skip the rest of this audit block; dispatch loop will re-enter.
|
|
1066
|
+
// (See `dispatch:` label wrapping the command switch.)
|
|
1067
|
+
// eslint-disable-next-line no-labels
|
|
1068
|
+
continue dispatch;
|
|
1069
|
+
}
|
|
840
1070
|
// Warn mode is the default (investor audit: hard-block had too many false positives).
|
|
841
1071
|
// Users opt in to hard blocking with --strict, SPECLOCK_STRICT=1, or by running
|
|
842
1072
|
// `speclock enforce hard` (which sets the persistent brain enforcement mode).
|
|
@@ -1013,8 +1243,118 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1013
1243
|
// --- AUDIT-SEMANTIC (v2.5) ---
|
|
1014
1244
|
if (cmd === "audit-semantic") {
|
|
1015
1245
|
const flags = parseFlags(args);
|
|
1246
|
+
const verbose =
|
|
1247
|
+
flags.verbose === true ||
|
|
1248
|
+
process.env.SPECLOCK_VERBOSE === "1" ||
|
|
1249
|
+
process.env.SPECLOCK_VERBOSE === "true";
|
|
1016
1250
|
const result = semanticAudit(root);
|
|
1017
1251
|
|
|
1252
|
+
// --- Commit-message + diff-content semantic check ---
|
|
1253
|
+
// The diff-level semanticAudit() above only inspects per-file change
|
|
1254
|
+
// summaries. It does NOT see the commit message, and its summaries can
|
|
1255
|
+
// miss short fragments like "delete user data" that collide with locks
|
|
1256
|
+
// such as "NEVER delete user data". Here we explicitly build a combined
|
|
1257
|
+
// "action description" from the commit message + every added line and
|
|
1258
|
+
// run it through enforceConflictCheck — the same engine used by
|
|
1259
|
+
// `speclock check`.
|
|
1260
|
+
const extraViolations = [];
|
|
1261
|
+
try {
|
|
1262
|
+
// 1. Read commit message (.git/COMMIT_EDITMSG is written by git BEFORE
|
|
1263
|
+
// pre-commit hooks run).
|
|
1264
|
+
let commitMsg = "";
|
|
1265
|
+
const editMsgPath = path.join(root, ".git", "COMMIT_EDITMSG");
|
|
1266
|
+
if (fs.existsSync(editMsgPath)) {
|
|
1267
|
+
try {
|
|
1268
|
+
commitMsg = fs.readFileSync(editMsgPath, "utf-8")
|
|
1269
|
+
.split("\n")
|
|
1270
|
+
.filter((l) => !l.trim().startsWith("#"))
|
|
1271
|
+
.join("\n")
|
|
1272
|
+
.trim();
|
|
1273
|
+
} catch { /* ignore */ }
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// 2. Collect added lines from the staged diff, skipping SpecLock-internal
|
|
1277
|
+
// files (see shouldSkipForSemanticAudit). Those files' contents literally
|
|
1278
|
+
// restate the locks, so scanning their added lines produces 100%
|
|
1279
|
+
// false-positive matches for every lock in the brain.
|
|
1280
|
+
const diffText = getStagedDiff(root);
|
|
1281
|
+
const allParsedChanges = diffText ? parseDiff(diffText) : [];
|
|
1282
|
+
const fileChanges = allParsedChanges.filter(
|
|
1283
|
+
(fc) => !shouldSkipForSemanticAudit(fc.file, root)
|
|
1284
|
+
);
|
|
1285
|
+
const addedSnippets = [];
|
|
1286
|
+
for (const fc of fileChanges) {
|
|
1287
|
+
for (const line of fc.addedLines) {
|
|
1288
|
+
// Strip common comment markers so "// delete user data" becomes
|
|
1289
|
+
// "delete user data" for the semantic engine.
|
|
1290
|
+
const cleaned = line
|
|
1291
|
+
.replace(/^\s*(\/\/|#|\/\*+|\*+\/?|--|<!--|-->|;)\s*/, "")
|
|
1292
|
+
.replace(/\*\/\s*$/, "")
|
|
1293
|
+
.replace(/-->\s*$/, "")
|
|
1294
|
+
.trim();
|
|
1295
|
+
if (cleaned) addedSnippets.push(cleaned);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// 3. Build a combined action description. We check the commit message
|
|
1300
|
+
// as one unit (highest priority), then individual added snippets.
|
|
1301
|
+
const actionsToCheck = [];
|
|
1302
|
+
if (commitMsg) {
|
|
1303
|
+
actionsToCheck.push({ kind: "commit message", text: commitMsg });
|
|
1304
|
+
}
|
|
1305
|
+
// Cap added snippets to avoid blowing up the check loop on huge diffs.
|
|
1306
|
+
for (const snip of addedSnippets.slice(0, 100)) {
|
|
1307
|
+
actionsToCheck.push({ kind: "added code", text: snip });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (actionsToCheck.length > 0) {
|
|
1311
|
+
for (const item of actionsToCheck) {
|
|
1312
|
+
const check = enforceConflictCheck(root, item.text);
|
|
1313
|
+
if (check.hasConflict && check.conflictingLocks.length > 0) {
|
|
1314
|
+
for (const lock of check.conflictingLocks) {
|
|
1315
|
+
extraViolations.push({
|
|
1316
|
+
file: item.kind === "commit message" ? "(commit message)" : "(staged diff)",
|
|
1317
|
+
lockId: lock.id,
|
|
1318
|
+
lockText: lock.text,
|
|
1319
|
+
confidence: lock.confidence,
|
|
1320
|
+
level: lock.level,
|
|
1321
|
+
reason: `${item.kind}: "${item.text.substring(0, 80)}" — ${(lock.reasons || []).join("; ")}`,
|
|
1322
|
+
source: "commit-msg-semantic",
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
// Never fail the hook on internal errors — just note it.
|
|
1330
|
+
console.log(`(speclock: commit-message semantic check skipped: ${err.message})`);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Merge + dedupe by lockId+source-ish key, keeping highest confidence.
|
|
1334
|
+
if (extraViolations.length > 0) {
|
|
1335
|
+
const merged = [...result.violations, ...extraViolations];
|
|
1336
|
+
const bestByKey = new Map();
|
|
1337
|
+
for (const v of merged) {
|
|
1338
|
+
const key = `${v.file}::${v.lockId || v.lockText}`;
|
|
1339
|
+
const existing = bestByKey.get(key);
|
|
1340
|
+
if (!existing || (v.confidence || 0) > (existing.confidence || 0)) {
|
|
1341
|
+
bestByKey.set(key, v);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
result.violations = [...bestByKey.values()].sort(
|
|
1345
|
+
(a, b) => (b.confidence || 0) - (a.confidence || 0)
|
|
1346
|
+
);
|
|
1347
|
+
// Recompute blocked state against the configured threshold.
|
|
1348
|
+
const threshold = result.threshold || 70;
|
|
1349
|
+
if (result.mode === "hard") {
|
|
1350
|
+
result.blocked = result.violations.some((v) => (v.confidence || 0) >= threshold);
|
|
1351
|
+
}
|
|
1352
|
+
result.passed = result.violations.length === 0;
|
|
1353
|
+
result.message = result.blocked
|
|
1354
|
+
? `BLOCKED: ${result.violations.length} violation(s) detected. Hard enforcement active — commit rejected.`
|
|
1355
|
+
: `WARNING: ${result.violations.length} violation(s) detected. Review before proceeding.`;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1018
1358
|
// Warn mode default: only exit 1 if --strict, SPECLOCK_STRICT=1, or brain is in "hard" mode
|
|
1019
1359
|
// (result.blocked already reflects "hard" mode from brain config).
|
|
1020
1360
|
const strict =
|
|
@@ -1024,15 +1364,40 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1024
1364
|
process.env.SPECLOCK_STRICT === "true" ||
|
|
1025
1365
|
result.blocked;
|
|
1026
1366
|
|
|
1367
|
+
// --- Three-tier output filter (v5.5.6) ---
|
|
1368
|
+
// Investor audit: walls of LOW-confidence matches are user-hostile.
|
|
1369
|
+
// Only HIGH and MEDIUM print by default. LOW rolls up into a one-liner.
|
|
1370
|
+
// --verbose / SPECLOCK_VERBOSE=1 shows everything.
|
|
1371
|
+
const OUTPUT_MIN_CONFIDENCE = 40; // below this = "LOW", hidden by default
|
|
1372
|
+
const MAX_VISIBLE_VIOLATIONS = 10; // hard cap on printed items
|
|
1373
|
+
|
|
1374
|
+
const allViolations = result.violations || [];
|
|
1375
|
+
const highViolations = allViolations.filter((v) => (v.confidence || 0) >= 70);
|
|
1376
|
+
const mediumViolations = allViolations.filter(
|
|
1377
|
+
(v) => (v.confidence || 0) >= OUTPUT_MIN_CONFIDENCE && (v.confidence || 0) < 70
|
|
1378
|
+
);
|
|
1379
|
+
const lowViolations = allViolations.filter(
|
|
1380
|
+
(v) => (v.confidence || 0) < OUTPUT_MIN_CONFIDENCE
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
// What actually gets printed
|
|
1384
|
+
const visibleViolations = verbose
|
|
1385
|
+
? allViolations
|
|
1386
|
+
: [...highViolations, ...mediumViolations];
|
|
1387
|
+
|
|
1027
1388
|
console.log(`\nSemantic Pre-Commit Audit`);
|
|
1028
1389
|
console.log("=".repeat(50));
|
|
1029
1390
|
console.log(`Mode: ${result.mode} | Threshold: ${result.threshold}%`);
|
|
1030
|
-
|
|
1391
|
+
const filesLine = result.filesSkipped
|
|
1392
|
+
? `Files analyzed: ${result.filesChecked} (${result.filesSkipped} skipped)`
|
|
1393
|
+
: `Files analyzed: ${result.filesChecked}`;
|
|
1394
|
+
console.log(filesLine);
|
|
1031
1395
|
console.log(`Active locks: ${result.activeLocks}`);
|
|
1032
|
-
|
|
1033
|
-
if (
|
|
1396
|
+
|
|
1397
|
+
if (visibleViolations.length > 0) {
|
|
1034
1398
|
console.log("");
|
|
1035
|
-
|
|
1399
|
+
const toPrint = visibleViolations.slice(0, MAX_VISIBLE_VIOLATIONS);
|
|
1400
|
+
for (const v of toPrint) {
|
|
1036
1401
|
console.log(` [${v.level}] ${v.file} (confidence: ${v.confidence}%)`);
|
|
1037
1402
|
console.log(` Lock: "${v.lockText}"`);
|
|
1038
1403
|
console.log(` Reason: ${v.reason}`);
|
|
@@ -1040,17 +1405,48 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1040
1405
|
console.log(` Changes: +${v.addedLines} / -${v.removedLines} lines`);
|
|
1041
1406
|
}
|
|
1042
1407
|
}
|
|
1408
|
+
const hiddenByCap = visibleViolations.length - toPrint.length;
|
|
1409
|
+
if (hiddenByCap > 0) {
|
|
1410
|
+
console.log(` ... + ${hiddenByCap} more (output capped at ${MAX_VISIBLE_VIOLATIONS})`);
|
|
1411
|
+
}
|
|
1043
1412
|
}
|
|
1044
|
-
console.log(`\n${result.message}`);
|
|
1045
1413
|
|
|
1046
|
-
|
|
1414
|
+
// LOW-confidence rollup
|
|
1415
|
+
if (!verbose && lowViolations.length > 0) {
|
|
1416
|
+
console.log(
|
|
1417
|
+
` + ${lowViolations.length} low-confidence match(es) hidden (use --verbose or SPECLOCK_VERBOSE=1 to see)`
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// --- New summary line ---
|
|
1422
|
+
console.log("");
|
|
1423
|
+
let summaryLine;
|
|
1424
|
+
if (highViolations.length === 0 && mediumViolations.length === 0) {
|
|
1425
|
+
summaryLine = `[OK] ${result.filesChecked} file(s) checked, no concerns.`;
|
|
1426
|
+
} else if (highViolations.length > 0) {
|
|
1427
|
+
summaryLine = `[!] ${highViolations.length} HIGH-confidence concern(s) — review before merging.`;
|
|
1428
|
+
} else {
|
|
1429
|
+
summaryLine = `[i] ${mediumViolations.length} medium-confidence note(s) (informational).`;
|
|
1430
|
+
}
|
|
1431
|
+
console.log(summaryLine);
|
|
1432
|
+
|
|
1433
|
+
// Preserve the machine-readable status message for any callers that grep for it
|
|
1434
|
+
if (result.blocked) {
|
|
1435
|
+
console.log(
|
|
1436
|
+
`BLOCKED: ${highViolations.length} high-confidence violation(s) — hard enforcement active.`
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (highViolations.length > 0 && !strict) {
|
|
1047
1441
|
console.log("\nWarning mode active — commit allowed. To enforce hard blocks, run:");
|
|
1048
1442
|
console.log(" speclock audit-semantic --strict");
|
|
1049
1443
|
console.log(" SPECLOCK_STRICT=1 git commit ...");
|
|
1050
1444
|
console.log(" speclock enforce hard (persistent, project-wide)");
|
|
1051
1445
|
}
|
|
1052
1446
|
|
|
1053
|
-
|
|
1447
|
+
// Blocking decision is still driven by the engine's 70% threshold, so
|
|
1448
|
+
// only HIGH-confidence matches can ever cause a non-zero exit.
|
|
1449
|
+
process.exit(strict && highViolations.length > 0 ? 1 : 0);
|
|
1054
1450
|
}
|
|
1055
1451
|
|
|
1056
1452
|
// --- AUTH (v3.0) ---
|
|
@@ -1233,30 +1629,102 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1233
1629
|
process.exit(1);
|
|
1234
1630
|
}
|
|
1235
1631
|
|
|
1236
|
-
// --- TELEMETRY (
|
|
1632
|
+
// --- TELEMETRY (opt-in, v5.5) ---
|
|
1237
1633
|
if (cmd === "telemetry") {
|
|
1238
1634
|
const sub = args[0];
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
console.log("
|
|
1244
|
-
|
|
1635
|
+
|
|
1636
|
+
if (sub === "on" || sub === "enable") {
|
|
1637
|
+
try {
|
|
1638
|
+
enableTelemetry();
|
|
1639
|
+
console.log("Telemetry: ENABLED.");
|
|
1640
|
+
console.log("We collect anonymous usage data only. See: speclock telemetry status");
|
|
1641
|
+
console.log("To opt out at any time: speclock telemetry off");
|
|
1642
|
+
} catch (_) {
|
|
1643
|
+
console.error("Failed to enable telemetry (ignored).");
|
|
1644
|
+
}
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (sub === "off" || sub === "disable") {
|
|
1649
|
+
try {
|
|
1650
|
+
disableTelemetry();
|
|
1651
|
+
console.log("Telemetry: DISABLED. No further events will be recorded or sent.");
|
|
1652
|
+
} catch (_) {
|
|
1653
|
+
console.error("Failed to disable telemetry (ignored).");
|
|
1654
|
+
}
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
if (sub === "clear") {
|
|
1659
|
+
try {
|
|
1660
|
+
const r = clearTelemetryLog();
|
|
1661
|
+
console.log(r.cleared ? "Telemetry log cleared." : "No telemetry log to clear.");
|
|
1662
|
+
} catch (_) {
|
|
1663
|
+
console.error("Failed to clear telemetry log (ignored).");
|
|
1245
1664
|
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
console.log(`\
|
|
1253
|
-
|
|
1254
|
-
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
if (sub === "status" || !sub) {
|
|
1669
|
+
try {
|
|
1670
|
+
const st = getOptInTelemetryStatus({ eventLimit: 10 });
|
|
1671
|
+
console.log(`\nSpecLock Telemetry (opt-in)`);
|
|
1672
|
+
console.log("=".repeat(50));
|
|
1673
|
+
console.log(`State: ${st.enabled ? "ENABLED" : "DISABLED"}`);
|
|
1674
|
+
console.log(`Decided: ${st.decided ? "yes" : "no (will prompt on next 'speclock protect')"}`);
|
|
1675
|
+
if (st.decidedAt) console.log(`Decided at: ${st.decidedAt}`);
|
|
1676
|
+
if (st.installedAt) console.log(`First run: ${st.installedAt}`);
|
|
1677
|
+
console.log(`Install id: ${st.installId}`);
|
|
1678
|
+
console.log(`Endpoint: ${st.endpoint || "(disabled)"}`);
|
|
1679
|
+
console.log(`Config file: ${st.configPath}`);
|
|
1680
|
+
console.log(`Events file: ${st.eventsPath}`);
|
|
1681
|
+
if (st.envOverride) console.log(`Env override: SPECLOCK_TELEMETRY=${st.envOverride}`);
|
|
1682
|
+
console.log(`Total events: ${st.eventCount}`);
|
|
1683
|
+
console.log("");
|
|
1684
|
+
console.log("What we collect (anonymous, no PII):");
|
|
1685
|
+
console.log(" installId, version, os, nodeVersion, command, exitCode,");
|
|
1686
|
+
console.log(" enforcementMode, lockCount, ruleFilesFound,");
|
|
1687
|
+
console.log(" mcpClientsConfigured, daysSinceInstall, timestamp");
|
|
1688
|
+
console.log("");
|
|
1689
|
+
console.log("What we NEVER collect:");
|
|
1690
|
+
console.log(" file contents, commit messages, lock content, user names,");
|
|
1691
|
+
console.log(" file paths, IP addresses, project names");
|
|
1692
|
+
console.log("");
|
|
1693
|
+
if (st.sampleEvent) {
|
|
1694
|
+
console.log("Sample event payload:");
|
|
1695
|
+
console.log(JSON.stringify(st.sampleEvent, null, 2));
|
|
1696
|
+
console.log("");
|
|
1697
|
+
}
|
|
1698
|
+
if (st.recentEvents.length > 0) {
|
|
1699
|
+
console.log(`Last ${st.recentEvents.length} event(s):`);
|
|
1700
|
+
for (const e of st.recentEvents) {
|
|
1701
|
+
console.log(` ${e.timestamp} ${e.command} exit=${e.exitCode} mode=${e.enforcementMode} locks=${e.lockCount}`);
|
|
1702
|
+
}
|
|
1703
|
+
console.log("");
|
|
1704
|
+
} else {
|
|
1705
|
+
console.log("No events recorded yet.");
|
|
1706
|
+
console.log("");
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Also surface the legacy per-project analytics if that layer is enabled.
|
|
1710
|
+
if (isTelemetryEnabled()) {
|
|
1711
|
+
const legacy = getTelemetrySummary(root);
|
|
1712
|
+
if (legacy.enabled) {
|
|
1713
|
+
console.log("Per-project analytics (SPECLOCK_TELEMETRY):");
|
|
1714
|
+
console.log(` Total MCP tool calls: ${legacy.totalCalls}`);
|
|
1715
|
+
console.log(` Avg response: ${legacy.avgResponseMs}ms`);
|
|
1716
|
+
console.log(` Sessions: ${legacy.sessions.total}`);
|
|
1717
|
+
console.log(` Conflicts: ${legacy.conflicts.total} (blocked: ${legacy.conflicts.blocked})`);
|
|
1718
|
+
console.log("");
|
|
1719
|
+
}
|
|
1255
1720
|
}
|
|
1721
|
+
} catch (_) {
|
|
1722
|
+
console.error("Failed to read telemetry status (ignored).");
|
|
1256
1723
|
}
|
|
1257
1724
|
return;
|
|
1258
1725
|
}
|
|
1259
|
-
|
|
1726
|
+
|
|
1727
|
+
console.error("Usage: speclock telemetry <on|off|status|clear>");
|
|
1260
1728
|
process.exit(1);
|
|
1261
1729
|
}
|
|
1262
1730
|
|
|
@@ -1477,16 +1945,31 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1477
1945
|
lines.push("Installation");
|
|
1478
1946
|
let pkgVersion = "unknown";
|
|
1479
1947
|
try {
|
|
1480
|
-
//
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1948
|
+
// Walk up from this module's directory to find speclock's package.json
|
|
1949
|
+
// (works for: local install, npm global, npx cache)
|
|
1950
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
1951
|
+
for (let i = 0; i < 5; i++) {
|
|
1952
|
+
const candidate = path.join(dir, "package.json");
|
|
1953
|
+
if (fs.existsSync(candidate)) {
|
|
1954
|
+
const p = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
|
1955
|
+
if (p.name === "speclock") {
|
|
1956
|
+
pkgVersion = p.version;
|
|
1957
|
+
break;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
dir = path.dirname(dir);
|
|
1961
|
+
}
|
|
1962
|
+
// Fallbacks
|
|
1963
|
+
if (pkgVersion === "unknown") {
|
|
1964
|
+
const selfPkgPath = path.join(root, "node_modules", "speclock", "package.json");
|
|
1965
|
+
if (fs.existsSync(selfPkgPath)) {
|
|
1966
|
+
pkgVersion = JSON.parse(fs.readFileSync(selfPkgPath, "utf-8")).version;
|
|
1967
|
+
} else {
|
|
1968
|
+
const localPkg = path.join(root, "package.json");
|
|
1969
|
+
if (fs.existsSync(localPkg)) {
|
|
1970
|
+
const p = JSON.parse(fs.readFileSync(localPkg, "utf-8"));
|
|
1971
|
+
if (p.name === "speclock") pkgVersion = p.version;
|
|
1972
|
+
}
|
|
1490
1973
|
}
|
|
1491
1974
|
}
|
|
1492
1975
|
lines.push(` ✓ SpecLock v${pkgVersion} installed`);
|
|
@@ -1790,12 +2273,40 @@ Tip: Run "speclock sync --all" to push constraints to Cursor, Claude, Copilot, W
|
|
|
1790
2273
|
return;
|
|
1791
2274
|
}
|
|
1792
2275
|
|
|
2276
|
+
// End of dispatch loop. If no handler returned/exited, fall through.
|
|
2277
|
+
break;
|
|
2278
|
+
} // end dispatch: while
|
|
2279
|
+
|
|
1793
2280
|
console.error(`Unknown command: ${cmd}`);
|
|
1794
2281
|
console.error("Run 'speclock --help' for usage.");
|
|
1795
2282
|
process.exit(1);
|
|
1796
2283
|
}
|
|
1797
2284
|
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2285
|
+
// Only run the CLI when this file is invoked as the entry point — either
|
|
2286
|
+
// directly (`node src/cli/index.js`) or through the bin wrapper
|
|
2287
|
+
// (`bin/speclock.js` does `import "../src/cli/index.js"`). Skip autorun when
|
|
2288
|
+
// this module is imported by tests or other tooling that only needs the
|
|
2289
|
+
// exported helpers (`loadRulePack`, `initFromRulePack`).
|
|
2290
|
+
const shouldAutoRun = (() => {
|
|
2291
|
+
if (process.env.SPECLOCK_CLI_NO_AUTORUN === "1") return false;
|
|
2292
|
+
try {
|
|
2293
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
2294
|
+
const entryFile = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
2295
|
+
if (!entryFile) return true;
|
|
2296
|
+
if (thisFile === entryFile) return true;
|
|
2297
|
+
// bin wrapper: bin/speclock.js (or any file under a /bin/ directory
|
|
2298
|
+
// whose basename starts with "speclock").
|
|
2299
|
+
const base = path.basename(entryFile).toLowerCase();
|
|
2300
|
+
if (base.startsWith("speclock")) return true;
|
|
2301
|
+
return false;
|
|
2302
|
+
} catch (_) {
|
|
2303
|
+
return true;
|
|
2304
|
+
}
|
|
2305
|
+
})();
|
|
2306
|
+
|
|
2307
|
+
if (shouldAutoRun) {
|
|
2308
|
+
main().catch((err) => {
|
|
2309
|
+
console.error("SpecLock error:", err.message);
|
|
2310
|
+
process.exit(1);
|
|
2311
|
+
});
|
|
2312
|
+
}
|