speclock 3.5.4 → 4.1.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.
@@ -0,0 +1,478 @@
1
+ // ===================================================================
2
+ // SpecLock Smart Lock Authoring Engine
3
+ // Auto-rewrites user locks to prevent verb contamination.
4
+ //
5
+ // Problem: "Never add authentication" causes false positives because
6
+ // the word "add" gets extracted as a prohibited signal and fires on
7
+ // every action containing "add" (like "Add dark mode").
8
+ //
9
+ // Solution: Extract the SUBJECT from the lock, rewrite to state
10
+ // what IS fixed/prohibited rather than what ACTION is prohibited.
11
+ // "Never add authentication" → "Authentication and login are prohibited"
12
+ //
13
+ // Developed by Sandeep Roy (https://github.com/sgroy10)
14
+ // ===================================================================
15
+
16
+ // Common action verbs that contaminate lock matching when present in lock text.
17
+ // These are the verbs that users naturally write in locks ("never ADD X",
18
+ // "don't CHANGE Y") but that the semantic engine then matches against
19
+ // ALL actions containing those same verbs.
20
+ const CONTAMINATING_VERBS = new Set([
21
+ // Constructive
22
+ "add", "create", "introduce", "insert", "implement", "build", "make",
23
+ "include", "put", "set", "use", "install", "deploy", "attach", "connect",
24
+ // Destructive
25
+ "remove", "delete", "drop", "destroy", "kill", "purge", "wipe", "erase",
26
+ "eliminate", "clear", "empty", "nuke",
27
+ // Modification
28
+ "change", "modify", "alter", "update", "mutate", "transform", "rewrite",
29
+ "edit", "adjust", "tweak", "revise", "amend", "touch", "rework",
30
+ // Movement
31
+ "move", "migrate", "transfer", "shift", "relocate", "switch", "swap",
32
+ "replace", "substitute", "exchange",
33
+ // Toggle
34
+ "enable", "disable", "activate", "deactivate", "start", "stop",
35
+ "turn", "pause", "suspend", "halt",
36
+ // General
37
+ "push", "pull", "send", "expose", "leak", "reveal", "show",
38
+ "allow", "permit", "let", "give", "grant", "open",
39
+ // Informal
40
+ "mess",
41
+ ]);
42
+
43
+ // Prohibition patterns — these phrases introduce the verb that follows
44
+ const PROHIBITION_PATTERNS = [
45
+ /^never\s+/i,
46
+ /^must\s+not\s+/i,
47
+ /^do\s+not\s+/i,
48
+ /^don'?t\s+/i,
49
+ /^cannot\s+/i,
50
+ /^can'?t\s+/i,
51
+ /^should\s+not\s+/i,
52
+ /^shouldn'?t\s+/i,
53
+ /^no\s+(?:one\s+(?:should|may|can)\s+)?/i,
54
+ /^(?:it\s+is\s+)?(?:forbidden|prohibited|not\s+allowed)\s+to\s+/i,
55
+ /^avoid\s+/i,
56
+ /^prevent\s+/i,
57
+ /^refrain\s+from\s+/i,
58
+ /^stop\s+/i,
59
+ ];
60
+
61
+ // Filler words between verb and subject
62
+ const FILLER_WORDS = new Set([
63
+ "the", "a", "an", "any", "another", "other", "new", "additional",
64
+ "more", "extra", "further", "existing", "current", "old", "all",
65
+ "our", "their", "user", "users", "to",
66
+ ]);
67
+
68
+ // Domain subject → canonical prohibition phrasing
69
+ const SUBJECT_TEMPLATES = {
70
+ // Auth/Security
71
+ "authentication": "{subject} and login functionality are prohibited",
72
+ "auth": "Authentication and login functionality are prohibited",
73
+ "login": "Login and authentication functionality are prohibited",
74
+ "signup": "Sign-up and registration functionality are prohibited",
75
+ "user accounts": "User account creation and management are prohibited",
76
+ "2fa": "Two-factor authentication changes are prohibited",
77
+ "mfa": "Multi-factor authentication changes are prohibited",
78
+
79
+ // Database
80
+ "database": "External database services are prohibited — use {alternative} only",
81
+ "supabase": "Supabase integration is prohibited",
82
+ "firebase": "Firebase integration is prohibited",
83
+ "mongodb": "MongoDB integration is prohibited",
84
+
85
+ // Payment
86
+ "payment": "Additional payment providers are prohibited — use {alternative} exclusively",
87
+ "stripe": "Stripe modifications are prohibited",
88
+ "razorpay": "Razorpay integration is prohibited",
89
+ "paypal": "PayPal integration is prohibited",
90
+ };
91
+
92
+ /**
93
+ * Extract the subject (noun phrase) from a lock text.
94
+ * Given: "Never add user authentication or login functionality"
95
+ * Returns: "user authentication or login functionality"
96
+ */
97
+ export function extractLockSubject(lockText) {
98
+ let remaining = lockText.trim();
99
+
100
+ // Step 1: Strip prohibition prefix
101
+ for (const pattern of PROHIBITION_PATTERNS) {
102
+ const match = remaining.match(pattern);
103
+ if (match) {
104
+ remaining = remaining.slice(match[0].length).trim();
105
+ break;
106
+ }
107
+ }
108
+
109
+ // Step 2: Strip the first contaminating verb (and optional "to")
110
+ const words = remaining.split(/\s+/);
111
+ let verbIndex = -1;
112
+
113
+ // Check first 3 words for a contaminating verb
114
+ for (let i = 0; i < Math.min(3, words.length); i++) {
115
+ const w = words[i].toLowerCase().replace(/[^a-z]/g, "");
116
+ if (CONTAMINATING_VERBS.has(w)) {
117
+ verbIndex = i;
118
+ break;
119
+ }
120
+ }
121
+
122
+ if (verbIndex >= 0) {
123
+ let endIdx = verbIndex + 1;
124
+ // Handle compound verbs: "touch or modify", "change and update"
125
+ while (endIdx < words.length - 1) {
126
+ const connector = words[endIdx].toLowerCase();
127
+ if (connector === "or" || connector === "and") {
128
+ const nextWord = (words[endIdx + 1] || "").toLowerCase().replace(/[^a-z]/g, "");
129
+ if (CONTAMINATING_VERBS.has(nextWord)) {
130
+ endIdx += 2; // skip connector + verb
131
+ } else {
132
+ break;
133
+ }
134
+ } else {
135
+ break;
136
+ }
137
+ }
138
+ remaining = words.slice(endIdx).join(" ").trim();
139
+ }
140
+
141
+ // Step 3: Strip leading filler words and prepositions like "from"
142
+ const remainingWords = remaining.split(/\s+/);
143
+ let startIdx = 0;
144
+ const STRIP_LEADING = new Set([...FILLER_WORDS, "from", "on", "in", "at", "with"]);
145
+ while (startIdx < remainingWords.length - 1) {
146
+ if (STRIP_LEADING.has(remainingWords[startIdx].toLowerCase())) {
147
+ startIdx++;
148
+ } else {
149
+ break;
150
+ }
151
+ }
152
+ remaining = remainingWords.slice(startIdx).join(" ").trim();
153
+
154
+ // Step 4: Truncate at em dash, semicolon, or qualifier phrases
155
+ // "authentication system — NextAuth config must not be changed" → "authentication system"
156
+ remaining = remaining.split(/\s*[—–]\s*/)[0].trim();
157
+ remaining = remaining.split(/\s*;\s*/)[0].trim();
158
+ // Truncate at comma + pronoun/qualifier clause
159
+ // "KYC verification flow, it's SEC-compliant" → "KYC verification flow"
160
+ // But preserve comma-separated lists: "auth, authorization, and login"
161
+ remaining = remaining.replace(/,\s+(?:it'?s?|they|this|that|which|we|since|because|as)\b.*$/i, "").trim();
162
+ // Truncate at "must not", "should not" etc. — they start a qualifier
163
+ remaining = remaining.replace(/\s+(?:must|should|cannot|can't|will)\s+(?:not\s+)?(?:be\s+)?.*$/i, "").trim();
164
+ // Truncate at "to/with any/another/other" — directional qualifier
165
+ remaining = remaining.replace(/\s+(?:to|with)\s+(?:any|another|other|a\s+different)\s+.*$/i, "").trim();
166
+
167
+ return remaining || lockText;
168
+ }
169
+
170
+ /**
171
+ * Detect if a lock text contains verb contamination risk.
172
+ * Returns: { hasRisk, verb, subject, suggestion }
173
+ */
174
+ export function detectVerbContamination(lockText) {
175
+ const lower = lockText.toLowerCase().trim();
176
+
177
+ // Check if it starts with a prohibition pattern
178
+ let matchedProhibition = false;
179
+ let afterProhibition = lower;
180
+
181
+ for (const pattern of PROHIBITION_PATTERNS) {
182
+ const match = lower.match(pattern);
183
+ if (match) {
184
+ matchedProhibition = true;
185
+ afterProhibition = lower.slice(match[0].length).trim();
186
+ break;
187
+ }
188
+ }
189
+
190
+ if (!matchedProhibition) {
191
+ // No prohibition pattern — could be a declarative lock like
192
+ // "Use Stripe exclusively" — these are already safe
193
+ return { hasRisk: false, verb: null, subject: null, suggestion: null };
194
+ }
195
+
196
+ // Check if the next word(s) are a contaminating verb
197
+ const words = afterProhibition.split(/\s+/);
198
+ const firstWord = (words[0] || "").replace(/[^a-z]/g, "");
199
+
200
+ if (!CONTAMINATING_VERBS.has(firstWord)) {
201
+ // Prohibition but no contaminating verb — already safe
202
+ // e.g., "Never expose PHI" — "expose" is domain-specific enough
203
+ // Actually, let's still check — "expose" is in the set
204
+ // But we only flag if it's a COMMON verb that will match broadly
205
+ return { hasRisk: false, verb: null, subject: null, suggestion: null };
206
+ }
207
+
208
+ const verb = firstWord;
209
+ const subject = extractLockSubject(lockText);
210
+
211
+ return {
212
+ hasRisk: true,
213
+ verb,
214
+ subject,
215
+ suggestion: rewriteLock(lockText, verb, subject),
216
+ original: lockText,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Rewrite a lock to eliminate verb contamination.
222
+ * Transforms "Never add X" → "X is/are prohibited"
223
+ * Preserves the semantic meaning but removes the contaminating verb.
224
+ */
225
+ export function rewriteLock(lockText, verb, subject) {
226
+ if (!subject || subject === lockText) return lockText;
227
+
228
+ // Determine the appropriate rewrite based on verb category
229
+ const isDestructive = ["remove", "delete", "drop", "destroy", "kill",
230
+ "purge", "wipe", "erase", "eliminate", "clear", "empty", "nuke"].includes(verb);
231
+ const isConstructive = ["add", "create", "introduce", "insert", "implement",
232
+ "build", "make", "include", "install", "deploy", "attach", "connect",
233
+ "put", "set", "use"].includes(verb);
234
+ const isModification = ["change", "modify", "alter", "update", "mutate",
235
+ "transform", "rewrite", "edit", "adjust", "tweak", "revise", "amend",
236
+ "rework", "touch"].includes(verb);
237
+ const isMovement = ["move", "migrate", "transfer", "shift", "relocate",
238
+ "switch", "swap", "replace", "substitute", "exchange"].includes(verb);
239
+ const isToggle = ["enable", "disable", "activate", "deactivate", "start",
240
+ "stop", "turn", "pause", "suspend", "halt"].includes(verb);
241
+
242
+ // Clean subject — capitalize first letter
243
+ const cleanSubject = subject.charAt(0).toUpperCase() + subject.slice(1);
244
+
245
+ if (isConstructive) {
246
+ // "Never add X" → "X is prohibited — do not introduce it"
247
+ return `${cleanSubject} — prohibited. Must not be introduced or added.`;
248
+ }
249
+
250
+ if (isDestructive) {
251
+ // "Never delete X" → "X must be preserved — delete and remove operations are prohibited"
252
+ // CRITICAL: include the original verb so euphemism matching can find it
253
+ // ("phase out" → "remove" needs "remove" in the lock text)
254
+ return `${cleanSubject} must be preserved — ${verb} and remove operations are prohibited.`;
255
+ }
256
+
257
+ if (isModification) {
258
+ // "Never modify X" → "X is frozen — modify and change operations are prohibited"
259
+ return `${cleanSubject} is frozen — ${verb} and change operations are prohibited.`;
260
+ }
261
+
262
+ if (isMovement) {
263
+ // "Never migrate X" → "X must remain unchanged — migrate and replace operations are prohibited"
264
+ return `${cleanSubject} must remain unchanged — ${verb} and replace operations are prohibited.`;
265
+ }
266
+
267
+ if (isToggle) {
268
+ if (verb === "disable" || verb === "deactivate" || verb === "stop" ||
269
+ verb === "pause" || verb === "suspend" || verb === "halt" || verb === "turn") {
270
+ // "Never disable X" → "X must remain active — disable is prohibited"
271
+ return `${cleanSubject} must remain active and enabled — ${verb} is prohibited.`;
272
+ } else {
273
+ // "Never enable X" → "X must remain disabled"
274
+ return `${cleanSubject} must remain disabled — do not activate.`;
275
+ }
276
+ }
277
+
278
+ // Fallback: generic rewrite
279
+ return `${cleanSubject} — no ${verb} operations allowed.`;
280
+ }
281
+
282
+ /**
283
+ * Smart lock normalizer. Takes raw user lock text and returns
284
+ * the best version for the semantic engine.
285
+ *
286
+ * Returns: {
287
+ * normalized: string, // The rewritten lock (or original if safe)
288
+ * wasRewritten: boolean, // Whether the lock was rewritten
289
+ * original: string, // The original lock text
290
+ * reason: string|null, // Why it was rewritten (or null)
291
+ * }
292
+ */
293
+ export function normalizeLock(lockText) {
294
+ const contamination = detectVerbContamination(lockText);
295
+
296
+ if (!contamination.hasRisk) {
297
+ return {
298
+ normalized: lockText,
299
+ wasRewritten: false,
300
+ original: lockText,
301
+ reason: null,
302
+ };
303
+ }
304
+
305
+ return {
306
+ normalized: contamination.suggestion,
307
+ wasRewritten: true,
308
+ original: lockText,
309
+ reason: `Verb "${contamination.verb}" in lock text causes false positives — ` +
310
+ `rewritten to focus on the subject "${contamination.subject}"`,
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Extract subject noun phrases from any text (lock or action).
316
+ * This is the foundation for scope-aware matching.
317
+ *
318
+ * Given: "Update the WhatsApp message formatting logic"
319
+ * Returns: ["whatsapp message formatting logic", "whatsapp", "message formatting", "formatting logic"]
320
+ *
321
+ * Given: "Never modify the WhatsApp session handler"
322
+ * Returns: ["whatsapp session handler", "whatsapp", "session handler"]
323
+ */
324
+ export function extractSubjects(text) {
325
+ const lower = text.toLowerCase().trim();
326
+ const subjects = [];
327
+
328
+ // Step 1: Strip prohibition prefix
329
+ let content = lower;
330
+ for (const pattern of PROHIBITION_PATTERNS) {
331
+ const match = content.match(pattern);
332
+ if (match) {
333
+ content = content.slice(match[0].length).trim();
334
+ break;
335
+ }
336
+ }
337
+
338
+ // Step 2: Strip leading verb
339
+ const words = content.split(/\s+/);
340
+ let startIdx = 0;
341
+
342
+ // Skip action verbs at the beginning
343
+ for (let i = 0; i < Math.min(2, words.length); i++) {
344
+ const w = words[i].replace(/[^a-z]/g, "");
345
+ if (CONTAMINATING_VERBS.has(w)) {
346
+ startIdx = i + 1;
347
+ break;
348
+ }
349
+ }
350
+
351
+ // Step 3: Skip fillers
352
+ while (startIdx < words.length - 1) {
353
+ const w = words[startIdx].replace(/[^a-z]/g, "");
354
+ if (FILLER_WORDS.has(w)) {
355
+ startIdx++;
356
+ } else {
357
+ break;
358
+ }
359
+ }
360
+
361
+ // Step 4: The remaining text is the subject noun phrase
362
+ const subjectWords = words.slice(startIdx);
363
+ if (subjectWords.length === 0) return subjects;
364
+
365
+ // Full noun phrase
366
+ const fullPhrase = subjectWords.join(" ").replace(/[^a-z0-9\s\-]/g, "").trim();
367
+ if (fullPhrase.length > 1) subjects.push(fullPhrase);
368
+
369
+ // Split on conjunctions for sub-phrases
370
+ const conjSplit = fullPhrase.split(/\s+(?:and|or|,)\s+/).map(s => s.trim()).filter(s => s.length > 1);
371
+ if (conjSplit.length > 1) {
372
+ for (const s of conjSplit) subjects.push(s);
373
+ }
374
+
375
+ // Bigrams and individual significant words
376
+ const significantWords = subjectWords
377
+ .map(w => w.replace(/[^a-z0-9\-]/g, ""))
378
+ .filter(w => w.length > 2 && !FILLER_WORDS.has(w));
379
+
380
+ // Generic words too vague to establish subject identity
381
+ const GENERIC_WORDS = new Set([
382
+ "system", "service", "module", "component", "feature", "function",
383
+ "method", "class", "model", "handler", "controller", "manager",
384
+ "process", "workflow", "flow", "logic", "config", "configuration",
385
+ "settings", "data", "information", "record", "records", "file",
386
+ "files", "page", "section", "layer", "level", "part", "item",
387
+ "code", "app", "application", "project",
388
+ ]);
389
+
390
+ // Add individual significant words (proper nouns, domain terms) — skip generic
391
+ for (const w of significantWords) {
392
+ if (!CONTAMINATING_VERBS.has(w) && !GENERIC_WORDS.has(w) && w.length > 3) {
393
+ subjects.push(w);
394
+ }
395
+ }
396
+
397
+ // Adjacent bigrams from significant words
398
+ for (let i = 0; i < significantWords.length - 1; i++) {
399
+ const bigram = `${significantWords[i]} ${significantWords[i + 1]}`;
400
+ if (!subjects.includes(bigram)) {
401
+ subjects.push(bigram);
402
+ }
403
+ }
404
+
405
+ return [...new Set(subjects)];
406
+ }
407
+
408
+ /**
409
+ * Compare subjects from action and lock to determine if they target
410
+ * the same component. This is the scope-awareness engine.
411
+ *
412
+ * Returns: {
413
+ * overlaps: boolean,
414
+ * overlapScore: 0-1,
415
+ * matchedSubjects: string[],
416
+ * lockSubjects: string[],
417
+ * actionSubjects: string[],
418
+ * }
419
+ */
420
+ export function compareSubjects(actionText, lockText) {
421
+ const lockSubjects = extractSubjects(lockText);
422
+ const actionSubjects = extractSubjects(actionText);
423
+
424
+ if (lockSubjects.length === 0 || actionSubjects.length === 0) {
425
+ return {
426
+ overlaps: false,
427
+ overlapScore: 0,
428
+ matchedSubjects: [],
429
+ lockSubjects,
430
+ actionSubjects,
431
+ };
432
+ }
433
+
434
+ const matched = [];
435
+
436
+ // Check for direct subject overlap
437
+ for (const ls of lockSubjects) {
438
+ for (const as of actionSubjects) {
439
+ // Exact match
440
+ if (ls === as) {
441
+ matched.push(ls);
442
+ continue;
443
+ }
444
+ // Word-level containment — "patient records" inside "old patient records"
445
+ // NOT substring: "shipping" should NOT match "calculateshipping"
446
+ const asRe = new RegExp(`\\b${as.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
447
+ const lsRe = new RegExp(`\\b${ls.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
448
+ if (asRe.test(ls) || lsRe.test(as)) {
449
+ matched.push(`${as} ⊂ ${ls}`);
450
+ continue;
451
+ }
452
+ // Word-level overlap for multi-word phrases
453
+ if (ls.includes(" ") && as.includes(" ")) {
454
+ const lsWords = new Set(ls.split(/\s+/));
455
+ const asWords = new Set(as.split(/\s+/));
456
+ const intersection = [...lsWords].filter(w => asWords.has(w) && w.length > 2);
457
+ // Need significant overlap — more than just shared filler
458
+ const significantIntersection = intersection.filter(w => !FILLER_WORDS.has(w));
459
+ if (significantIntersection.length >= 1 && significantIntersection.length >= Math.min(lsWords.size, asWords.size) * 0.4) {
460
+ matched.push(`word overlap: ${significantIntersection.join(", ")}`);
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ const uniqueMatched = [...new Set(matched)];
467
+ const overlapScore = uniqueMatched.length > 0
468
+ ? Math.min(uniqueMatched.length / Math.max(lockSubjects.length, 1), 1.0)
469
+ : 0;
470
+
471
+ return {
472
+ overlaps: uniqueMatched.length > 0,
473
+ overlapScore,
474
+ matchedSubjects: uniqueMatched,
475
+ lockSubjects,
476
+ actionSubjects,
477
+ };
478
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from "./storage.js";
22
22
  import { hasGit, getHead, getDefaultBranch } from "./git.js";
23
23
  import { ensureAuditKeyGitignored } from "./audit.js";
24
+ import { normalizeLock } from "./lock-author.js";
24
25
 
25
26
  // --- Internal helpers ---
26
27
 
@@ -82,25 +83,33 @@ export function setGoal(root, text) {
82
83
  export function addLock(root, text, tags, source) {
83
84
  const brain = ensureInit(root);
84
85
  const lockId = newId("lock");
86
+
87
+ // Smart Lock Authoring — auto-normalize to prevent verb contamination
88
+ const normResult = normalizeLock(text);
89
+
85
90
  brain.specLock.items.unshift({
86
91
  id: lockId,
87
- text,
92
+ text: normResult.normalized,
93
+ originalText: normResult.wasRewritten ? normResult.original : undefined,
88
94
  createdAt: nowIso(),
89
95
  source: source || "user",
90
96
  tags: tags || [],
91
97
  active: true,
92
98
  });
93
99
  const eventId = newId("evt");
100
+ const rewriteNote = normResult.wasRewritten
101
+ ? ` (auto-rewritten from: "${normResult.original.substring(0, 60)}")`
102
+ : "";
94
103
  const event = {
95
104
  eventId,
96
105
  type: "lock_added",
97
106
  at: nowIso(),
98
107
  files: [],
99
- summary: `Lock added: ${text.substring(0, 80)}`,
108
+ summary: `Lock added: ${normResult.normalized.substring(0, 80)}${rewriteNote}`,
100
109
  patchPath: "",
101
110
  };
102
111
  recordEvent(root, brain, event);
103
- return { brain, lockId };
112
+ return { brain, lockId, rewritten: normResult.wasRewritten, rewriteReason: normResult.reason };
104
113
  }
105
114
 
106
115
  export function removeLock(root, lockId) {