speclock 3.5.3 → 4.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.
@@ -1,11 +1,11 @@
1
1
  // ===================================================================
2
- // SpecLock Semantic Analysis Engine v2
3
- // Replaces keyword matching with real semantic conflict detection.
2
+ // SpecLock Semantic Analysis Engine v3
3
+ // Subject-aware conflict detection with scope matching.
4
4
  // Zero external dependencies — pure JavaScript.
5
5
  // ===================================================================
6
6
 
7
7
  // ===================================================================
8
- // SYNONYM GROUPS (55 groups)
8
+ // SYNONYM GROUPS (75+ groups)
9
9
  // Each group contains words/phrases that are semantically equivalent.
10
10
  // ===================================================================
11
11
 
@@ -55,7 +55,7 @@ export const SYNONYM_GROUPS = [
55
55
  "interface", "service"],
56
56
  ["request", "call", "invoke", "query", "fetch"],
57
57
  ["response", "reply", "result", "output", "payload"],
58
- ["network", "connectivity", "connection", "socket", "port", "protocol"],
58
+ ["network", "connectivity", "connection", "socket", "websocket", "port", "protocol"],
59
59
 
60
60
  // --- Testing ---
61
61
  ["test", "testing", "spec", "coverage", "assertion", "unit test",
@@ -66,13 +66,15 @@ export const SYNONYM_GROUPS = [
66
66
  "production", "go live", "launch", "push to prod"],
67
67
 
68
68
  // --- Security & auth ---
69
- ["security", "auth", "authentication", "authorization", "token",
70
- "credential", "permission", "access control", "rbac", "acl"],
69
+ ["security", "auth", "authentication", "authorization", "login",
70
+ "token", "credential", "permission", "access control", "rbac", "acl"],
71
71
  ["encrypt", "encryption", "cipher", "hash", "cryptographic",
72
72
  "tls", "ssl", "https"],
73
73
  ["certificate", "cert", "signing", "signature", "verification", "verify"],
74
74
  ["firewall", "waf", "rate limit", "throttle", "ip block",
75
75
  "deny list", "allow list"],
76
+ ["mfa", "multi-factor authentication", "multi-factor", "2fa",
77
+ "two-factor authentication", "two-factor"],
76
78
  ["audit", "audit log", "audit trail", "logging", "log",
77
79
  "monitoring", "observability", "telemetry", "tracking"],
78
80
 
@@ -144,6 +146,40 @@ export const SYNONYM_GROUPS = [
144
146
  "archival", "preservation", "lifecycle"],
145
147
  ["consent", "user consent", "opt-in", "opt-out",
146
148
  "data subject", "right to erasure"],
149
+
150
+ // --- Logistics / Supply Chain ---
151
+ ["shipment", "shipping", "consignment", "delivery", "dispatch",
152
+ "freight", "cargo", "package", "parcel"],
153
+ ["manifest", "bill of lading", "shipping document", "waybill",
154
+ "consignment note", "packing list"],
155
+ ["warehouse", "fulfillment center", "distribution center",
156
+ "storage facility", "depot"],
157
+ ["carrier", "shipping provider", "logistics provider",
158
+ "transport company", "freight forwarder"],
159
+ ["eta", "estimated arrival", "delivery time", "arrival time",
160
+ "expected delivery"],
161
+ ["customs", "customs clearance", "import", "export",
162
+ "tariff", "duty", "declaration"],
163
+ ["tracking number", "tracking id", "shipment tracking",
164
+ "delivery tracking", "consignment tracking"],
165
+
166
+ // --- Travel / Booking ---
167
+ ["reservation", "booking", "appointment", "ticket",
168
+ "confirmation", "itinerary"],
169
+ ["passenger", "traveler", "guest", "visitor", "tourist"],
170
+ ["passport", "passport data", "travel document",
171
+ "identity document", "visa"],
172
+ ["flight", "airline", "aviation", "air travel"],
173
+ ["hotel", "accommodation", "lodging", "stay", "room"],
174
+
175
+ // --- E-commerce ---
176
+ ["checkout", "cart", "shopping cart", "basket",
177
+ "purchase flow", "buy flow"],
178
+ ["order", "purchase", "buy", "acquisition"],
179
+ ["product", "item", "sku", "merchandise", "product listing", "catalog"],
180
+ ["price", "pricing", "cost", "rate", "amount", "charge"],
181
+ ["coupon", "discount", "promo", "promotion", "voucher", "deal"],
182
+ ["inventory", "stock", "supply", "availability"],
147
183
  ];
148
184
 
149
185
  // ===================================================================
@@ -202,6 +238,37 @@ export const EUPHEMISM_MAP = {
202
238
  "work around": ["bypass", "circumvent"],
203
239
  "shortcut": ["bypass", "skip"],
204
240
 
241
+ // Financial / accounting euphemisms
242
+ "reconcile": ["modify", "adjust", "change", "alter"],
243
+ "reverse": ["undo", "revert", "modify", "change"],
244
+ "recalculate": ["modify", "change", "update", "alter"],
245
+ "backdate": ["modify", "tamper", "falsify", "change"],
246
+ "rebalance": ["modify", "adjust", "change", "redistribute"],
247
+ "reclassify": ["modify", "change", "recategorize"],
248
+ "redistribute": ["modify", "change", "move", "reallocate"],
249
+ "reallocate": ["modify", "change", "move"],
250
+ "write off": ["delete", "remove", "eliminate"],
251
+ "write down": ["modify", "reduce", "change"],
252
+ "void": ["delete", "cancel", "remove", "nullify"],
253
+ "post": ["modify", "change", "write", "record"],
254
+ "unpost": ["revert", "undo", "modify", "delete"],
255
+ "journal": ["modify", "record", "change"],
256
+ "accrue": ["modify", "add", "change"],
257
+ "amortize": ["modify", "reduce", "change"],
258
+ "depreciate": ["modify", "reduce", "change"],
259
+
260
+ // Logistics euphemisms
261
+ "reroute": ["modify", "change", "redirect"],
262
+ "divert": ["modify", "change", "redirect", "reroute"],
263
+ "reassign": ["modify", "change", "move", "transfer"],
264
+ "remanifest": ["modify", "change", "update"],
265
+ "deconsolidate": ["split", "separate", "modify"],
266
+
267
+ // Travel / booking euphemisms
268
+ "rebook": ["modify", "change", "replace", "cancel"],
269
+ "no-show": ["cancel", "void", "remove"],
270
+ "waitlist": ["modify", "change", "queue"],
271
+
205
272
  // Database euphemisms
206
273
  "truncate": ["delete", "remove", "wipe", "empty"],
207
274
  "vacuum": ["delete", "remove", "clean"],
@@ -285,7 +352,7 @@ export const CONCEPT_MAP = {
285
352
  "hipaa": ["phi", "patient data", "health information",
286
353
  "medical records", "compliance"],
287
354
 
288
- // Financial
355
+ // Financial / Fintech
289
356
  "pci": ["cardholder data", "payment data", "card data",
290
357
  "pci dss", "payment security"],
291
358
  "cardholder data": ["pci", "payment data", "card data", "credit card", "pan"],
@@ -293,6 +360,77 @@ export const CONCEPT_MAP = {
293
360
  "trade": ["executed trade", "trade record", "order", "position"],
294
361
  "executed trade": ["trade", "trade record", "order"],
295
362
  "trade record": ["trade", "executed trade", "transaction record"],
363
+ "transaction": ["payment", "transfer", "ledger entry", "posting",
364
+ "settlement", "billing", "charge", "balance"],
365
+ "ledger": ["transaction", "financial records", "accounting",
366
+ "general ledger", "journal", "balance sheet", "posting"],
367
+ "balance": ["account balance", "ledger", "transaction", "funds",
368
+ "financial records", "settlement"],
369
+ "account": ["balance", "ledger", "financial records", "customer account",
370
+ "account balance"],
371
+ "credit card": ["payment data", "cardholder data", "card data", "pci",
372
+ "card number", "pan"],
373
+ "card number": ["credit card", "cardholder data", "payment data", "pan"],
374
+ "settlement": ["transaction", "payment", "clearing", "reconciliation",
375
+ "transfer"],
376
+ "fraud detection": ["fraud", "fraud prevention", "anti-fraud", "fraud monitoring",
377
+ "suspicious activity", "transaction monitoring"],
378
+ "fraud": ["fraud detection", "fraud prevention", "suspicious activity",
379
+ "anti-fraud"],
380
+ "posting": ["transaction", "ledger entry", "journal entry", "record"],
381
+ "reconciliation": ["balance", "ledger", "account", "transaction", "audit"],
382
+ "checkout": ["payment", "cart", "purchase", "transaction", "billing",
383
+ "payment processing", "order"],
384
+ "revenue": ["payment", "billing", "income", "sales", "earnings",
385
+ "transaction"],
386
+ "invoice": ["billing", "payment", "charge", "transaction", "accounts receivable"],
387
+
388
+ // Logistics / Supply Chain
389
+ "shipment": ["cargo", "freight", "consignment", "delivery", "package",
390
+ "manifest", "tracking", "shipping"],
391
+ "manifest": ["shipment", "cargo", "freight", "bill of lading",
392
+ "shipping document", "consignment"],
393
+ "cargo": ["shipment", "freight", "manifest", "consignment", "goods"],
394
+ "freight": ["shipment", "cargo", "manifest", "logistics"],
395
+ "delivery": ["shipment", "shipping", "tracking", "eta", "transit"],
396
+ "eta": ["delivery time", "estimated arrival", "timestamp",
397
+ "delivery", "tracking", "schedule"],
398
+ "warehouse": ["inventory", "stock", "storage", "fulfillment"],
399
+ "inventory": ["warehouse", "stock", "supply", "goods"],
400
+ "customs": ["import", "export", "tariff", "duty", "clearance",
401
+ "border", "declaration"],
402
+ "carrier": ["shipping provider", "logistics provider", "trucker",
403
+ "transport", "shipping company"],
404
+ "tracking": ["shipment tracking", "delivery tracking", "status",
405
+ "location", "transit"],
406
+ "bill of lading": ["manifest", "shipping document", "consignment note"],
407
+
408
+ // Travel / Booking
409
+ "reservation": ["booking", "appointment", "ticket", "confirmation",
410
+ "itinerary", "payment record"],
411
+ "booking": ["reservation", "appointment", "ticket", "itinerary",
412
+ "confirmation"],
413
+ "passenger": ["traveler", "guest", "customer", "pii", "personal data",
414
+ "user data", "passenger data"],
415
+ "itinerary": ["booking", "reservation", "travel plan", "route",
416
+ "schedule", "flight"],
417
+ "passport": ["pii", "personal data", "identity document", "travel document",
418
+ "passenger data"],
419
+ "passport data": ["pii", "personal data", "identity", "passenger",
420
+ "travel document"],
421
+ "flight": ["booking", "reservation", "itinerary", "travel"],
422
+ "hotel": ["booking", "reservation", "accommodation", "lodging"],
423
+ "rate limiting": ["throttle", "request limit", "api limit", "rate limit",
424
+ "quota", "access control"],
425
+
426
+ // E-commerce
427
+ "cart": ["checkout", "purchase", "shopping cart"],
428
+ "payment processing":["payment", "checkout", "billing", "transaction",
429
+ "stripe", "payment gateway"],
430
+ "payment gateway": ["payment processing", "stripe", "paypal", "checkout",
431
+ "billing", "transaction"],
432
+ "product": ["item", "sku", "catalog", "merchandise", "product listing"],
433
+ "price": ["pricing", "cost", "amount", "rate", "charge"],
296
434
 
297
435
  // Audit/logging
298
436
  "audit logging": ["audit log", "audit trail", "logging", "monitoring"],
@@ -315,6 +453,12 @@ export const CONCEPT_MAP = {
315
453
  "pii": ["personal data", "user data", "personally identifiable information",
316
454
  "user information", "gdpr"],
317
455
  "personal data": ["pii", "user data", "user information", "gdpr", "data protection"],
456
+ "gdpr": ["data protection", "consent", "privacy", "personal data", "pii",
457
+ "data subject", "right to erasure", "user data"],
458
+ "data protection": ["gdpr", "privacy", "consent", "personal data", "pii",
459
+ "data subject", "compliance"],
460
+ "consent": ["gdpr", "data protection", "opt-in", "opt-out", "user consent",
461
+ "privacy", "data subject"],
318
462
  "user data": ["pii", "personal data", "user information", "user records"],
319
463
 
320
464
  // Encryption
@@ -326,6 +470,13 @@ export const CONCEPT_MAP = {
326
470
  "ban records": ["ban record", "banned users", "suspension records",
327
471
  "blocked users", "moderation records"],
328
472
 
473
+ // Approval / moderation concepts
474
+ "approve": ["moderation", "content review", "content moderation",
475
+ "review queue"],
476
+ "batch approve": ["bypass moderation", "skip review", "auto-approve",
477
+ "content moderation", "moderation"],
478
+ "approval queue": ["moderation", "review queue", "content review"],
479
+
329
480
  // Authentication/2FA
330
481
  "2fa": ["two-factor", "two factor authentication", "mfa",
331
482
  "multi-factor", "authentication", "auth"],
@@ -333,6 +484,20 @@ export const CONCEPT_MAP = {
333
484
  "credential", "session", "token"],
334
485
  "auth": ["authentication", "login", "sign in", "2fa",
335
486
  "credential", "access control"],
487
+ "mfa": ["multi-factor authentication", "multi-factor", "2fa",
488
+ "two-factor", "authentication"],
489
+ "multi-factor": ["mfa", "2fa", "two-factor", "authentication",
490
+ "multi-factor authentication"],
491
+ "sso": ["saml", "oidc", "single sign-on", "oauth",
492
+ "authentication", "identity provider"],
493
+ "saml": ["sso", "oidc", "single sign-on", "authentication",
494
+ "identity provider"],
495
+
496
+ // Networking
497
+ "websocket": ["socket", "real-time connection", "ws", "wss",
498
+ "socket connection"],
499
+ "socket": ["websocket", "connection", "real-time connection",
500
+ "socket connection"],
336
501
 
337
502
  // Encryption
338
503
  "encryption": ["encrypt", "tls", "ssl", "https", "cryptographic",
@@ -420,7 +585,7 @@ const STOPWORDS = new Set([
420
585
 
421
586
  const POSITIVE_INTENT_MARKERS = [
422
587
  "enable", "activate", "turn on", "switch on", "start",
423
- "add", "create", "implement", "introduce", "set up",
588
+ "add", "create", "implement", "introduce", "set up", "build",
424
589
  "install", "deploy", "launch", "initialize",
425
590
  "enforce", "strengthen", "harden", "improve", "enhance",
426
591
  "increase", "expand", "extend", "upgrade", "boost",
@@ -568,13 +733,39 @@ export function tokenize(text) {
568
733
  }
569
734
 
570
735
  // Extract single words (>= 2 chars)
571
- const words = lower
736
+ const rawWords = lower
572
737
  .replace(/[^a-z0-9\-\/&]+/g, " ")
573
738
  .split(/\s+/)
574
739
  .filter(w => w.length >= 2);
575
740
 
576
- const all = [...new Set([...phrases, ...words])];
577
- return { words, phrases, all };
741
+ // Split slash-separated tokens into parts — "sso/saml" also adds "sso", "saml"
742
+ for (const w of [...rawWords]) {
743
+ if (w.includes('/')) {
744
+ for (const part of w.split('/')) {
745
+ if (part.length >= 2 && !rawWords.includes(part)) rawWords.push(part);
746
+ }
747
+ }
748
+ }
749
+
750
+ // Basic plural normalization — add both singular and plural forms
751
+ // so "databases" matches "database" and vice versa
752
+ const words = [...rawWords];
753
+ for (const w of rawWords) {
754
+ if (w.endsWith("ses") && w.length > 4) {
755
+ // "databases" → "database"
756
+ words.push(w.slice(0, -1));
757
+ } else if (w.endsWith("ies") && w.length > 4) {
758
+ // "entries" → "entry"
759
+ words.push(w.slice(0, -3) + "y");
760
+ } else if (w.endsWith("s") && !w.endsWith("ss") && !w.endsWith("us") && w.length > 3) {
761
+ // "records" → "record", "logs" → "log"
762
+ words.push(w.slice(0, -1));
763
+ }
764
+ }
765
+ const uniqueWords = [...new Set(words)];
766
+
767
+ const all = [...new Set([...phrases, ...uniqueWords])];
768
+ return { words: uniqueWords, phrases, all };
578
769
  }
579
770
 
580
771
  // ===================================================================
@@ -803,10 +994,20 @@ function extractProhibitedVerb(lockText) {
803
994
  return null;
804
995
  }
805
996
 
997
+ // Neutral modification/replacement verbs — not inherently positive or negative,
998
+ // but important for extractPrimaryVerb to detect as action verbs.
999
+ const NEUTRAL_ACTION_VERBS = [
1000
+ "modify", "change", "alter", "reconfigure", "rework",
1001
+ "overhaul", "restructure", "refactor", "redesign",
1002
+ "replace", "swap", "switch", "migrate", "substitute",
1003
+ "touch", "mess", "configure", "optimize", "tweak",
1004
+ "extend", "shorten", "adjust", "customize", "personalize",
1005
+ ];
1006
+
806
1007
  function extractPrimaryVerb(actionText) {
807
1008
  const lower = actionText.toLowerCase();
808
- // Find first matching marker in text
809
- const allMarkers = [...POSITIVE_INTENT_MARKERS, ...NEGATIVE_INTENT_MARKERS]
1009
+ // Find first matching marker in text (includes neutral action verbs)
1010
+ const allMarkers = [...POSITIVE_INTENT_MARKERS, ...NEGATIVE_INTENT_MARKERS, ...NEUTRAL_ACTION_VERBS]
810
1011
  .sort((a, b) => b.length - a.length);
811
1012
 
812
1013
  let earliest = null;
@@ -867,7 +1068,322 @@ function checkOpposites(verb1, verb2) {
867
1068
 
868
1069
  function isProhibitiveLock(lockText) {
869
1070
  return /\b(never|must not|do not|don't|cannot|can't|forbidden|prohibited|disallowed)\b/i.test(lockText)
870
- || /\bno\s+\w/i.test(lockText);
1071
+ || /\bno\s+\w/i.test(lockText)
1072
+ // Normalized lock patterns from lock-author.js rewriting
1073
+ || /\bis\s+frozen\b/i.test(lockText)
1074
+ || /\bmust\s+(remain|be\s+preserved|stay|always)\b/i.test(lockText);
1075
+ }
1076
+
1077
+ // ===================================================================
1078
+ // INLINE SUBJECT EXTRACTION (avoids circular dependency with lock-author.js)
1079
+ // Extracts noun-phrase subjects from action/lock text for scope matching.
1080
+ // ===================================================================
1081
+
1082
+ const _CONTAMINATING_VERBS = new Set([
1083
+ "add", "create", "introduce", "insert", "implement", "build", "make",
1084
+ "include", "put", "set", "use", "install", "deploy", "attach", "connect",
1085
+ "remove", "delete", "drop", "destroy", "kill", "purge", "wipe", "erase",
1086
+ "eliminate", "clear", "empty", "nuke",
1087
+ "change", "modify", "alter", "update", "mutate", "transform", "rewrite",
1088
+ "edit", "adjust", "tweak", "revise", "amend", "touch", "rework",
1089
+ "move", "migrate", "transfer", "shift", "relocate", "switch", "swap",
1090
+ "replace", "substitute", "exchange",
1091
+ "enable", "disable", "activate", "deactivate", "start", "stop",
1092
+ "turn", "pause", "suspend", "halt",
1093
+ "push", "pull", "send", "expose", "leak", "reveal", "show",
1094
+ "allow", "permit", "let", "give", "grant", "open",
1095
+ "bypass", "skip", "ignore", "circumvent",
1096
+ "refactor", "restructure", "simplify", "consolidate",
1097
+ "mess",
1098
+ "fix", "repair", "restore", "recover", "break", "revert", "rollback",
1099
+ "reconcile", "reverse", "recalculate", "backdate", "rebalance",
1100
+ "reroute", "divert", "reassign", "rebook", "cancel",
1101
+ "upgrade", "downgrade", "patch", "bump",
1102
+ ]);
1103
+
1104
+ const _FILLER_WORDS = new Set([
1105
+ // Articles & determiners
1106
+ "the", "a", "an", "any", "another", "other", "new", "additional",
1107
+ "more", "extra", "further", "existing", "current", "old", "all",
1108
+ "our", "their", "user", "users",
1109
+ // Prepositions
1110
+ "to", "of", "in", "on", "for", "from", "with", "by", "at", "up",
1111
+ "as", "into", "through", "between", "about", "before", "after",
1112
+ "without", "within", "during", "under", "over", "above", "below",
1113
+ // Conjunctions — CRITICAL: "and"/"or" must not create false bigram overlaps
1114
+ "and", "or", "nor", "but", "yet", "so",
1115
+ // Auxiliary/modal verbs — these are grammar, not subjects
1116
+ "is", "be", "are", "was", "were", "been", "being",
1117
+ "must", "never", "should", "shall", "can", "could", "would",
1118
+ "will", "may", "might", "do", "does", "did", "has", "have", "had",
1119
+ // Negation
1120
+ "not", "no", "none",
1121
+ // Common adverbs
1122
+ "just", "only", "also", "always", "every",
1123
+ ]);
1124
+
1125
+ const _PROHIBITION_PATTERNS = [
1126
+ /^never\s+/i, /^must\s+not\s+/i, /^do\s+not\s+/i, /^don'?t\s+/i,
1127
+ /^cannot\s+/i, /^can'?t\s+/i, /^should\s+not\s+/i, /^shouldn'?t\s+/i,
1128
+ /^no\s+(?:one\s+(?:should|may|can)\s+)?/i,
1129
+ /^(?:it\s+is\s+)?(?:forbidden|prohibited|not\s+allowed)\s+to\s+/i,
1130
+ /^avoid\s+/i, /^prevent\s+/i, /^refrain\s+from\s+/i, /^stop\s+/i,
1131
+ ];
1132
+
1133
+ // Generic words that are too vague to establish subject overlap on their own
1134
+ const _GENERIC_SUBJECT_WORDS = new Set([
1135
+ "system", "service", "module", "component", "feature", "function",
1136
+ "method", "class", "model", "handler", "controller", "manager",
1137
+ "process", "workflow", "flow", "logic", "config", "configuration",
1138
+ "settings", "options", "parameters", "data", "information", "record",
1139
+ "records", "file", "files", "page", "section", "area", "zone",
1140
+ "layer", "level", "tier", "part", "piece", "item", "items",
1141
+ "thing", "stuff", "code", "app", "application", "project",
1142
+ // Rewritten lock qualifiers (from lock-author.js output)
1143
+ "frozen", "prohibited", "preserved", "active", "enabled",
1144
+ "disabled", "allowed", "introduced", "added", "modifications",
1145
+ "deletion", "removal", "migration", "replacement",
1146
+ ]);
1147
+
1148
+ // Check if a word is a verb form (past participle / gerund) of a contaminating verb.
1149
+ // e.g., "exposed" → "expose", "logged" → "log", "modified" → "modif" → "modify"
1150
+ function _isVerbForm(word) {
1151
+ // -ed suffix: "exposed" → "expos" → check "expose"; "logged" → "logg" → check "log"
1152
+ if (word.endsWith("ed")) {
1153
+ const stem1 = word.slice(0, -1); // "exposed" → "expose"
1154
+ if (_CONTAMINATING_VERBS.has(stem1)) return true;
1155
+ const stem2 = word.slice(0, -2); // "logged" → "logg" — nope
1156
+ if (_CONTAMINATING_VERBS.has(stem2)) return true;
1157
+ const stem3 = word.slice(0, -3); // "logged" → "log" (double consonant)
1158
+ if (_CONTAMINATING_VERBS.has(stem3)) return true;
1159
+ // "modified" → "modifi" → "modify" (ied → y)
1160
+ if (word.endsWith("ied")) {
1161
+ const stem4 = word.slice(0, -3) + "y"; // "modified" → "modify"
1162
+ if (_CONTAMINATING_VERBS.has(stem4)) return true;
1163
+ }
1164
+ }
1165
+ // -ing suffix: "exposing" → "expos" → check "expose"; "logging" → "logg" → "log"
1166
+ if (word.endsWith("ing")) {
1167
+ const stem1 = word.slice(0, -3); // "building" → "build"
1168
+ if (_CONTAMINATING_VERBS.has(stem1)) return true;
1169
+ const stem2 = word.slice(0, -3) + "e"; // "exposing" → "expose"
1170
+ if (_CONTAMINATING_VERBS.has(stem2)) return true;
1171
+ const stem3 = word.slice(0, -4); // "logging" → "log" (double consonant)
1172
+ if (_CONTAMINATING_VERBS.has(stem3)) return true;
1173
+ }
1174
+ // -s suffix: "exposes" → "expose"
1175
+ if (word.endsWith("s") && !word.endsWith("ss")) {
1176
+ const stem1 = word.slice(0, -1);
1177
+ if (_CONTAMINATING_VERBS.has(stem1)) return true;
1178
+ // "pushes" → "push"
1179
+ if (word.endsWith("es")) {
1180
+ const stem2 = word.slice(0, -2);
1181
+ if (_CONTAMINATING_VERBS.has(stem2)) return true;
1182
+ }
1183
+ }
1184
+ return false;
1185
+ }
1186
+
1187
+ function _extractSubjectsInline(text) {
1188
+ const lower = text.toLowerCase().trim();
1189
+ const subjects = [];
1190
+
1191
+ // Strip prohibition prefix
1192
+ let content = lower;
1193
+ for (const pattern of _PROHIBITION_PATTERNS) {
1194
+ const match = content.match(pattern);
1195
+ if (match) {
1196
+ content = content.slice(match[0].length).trim();
1197
+ break;
1198
+ }
1199
+ }
1200
+
1201
+ // Handle rewritten lock format — truncate at qualifier phrases
1202
+ // "Authentication system is frozen — no modifications allowed."
1203
+ // "Patient records must be preserved — deletion and removal are prohibited."
1204
+ // "Audit logging must remain active and enabled at all times."
1205
+ content = content.replace(/\s+is\s+frozen\b.*$/i, "").trim();
1206
+ content = content.replace(/\s+must\s+(?:be\s+)?(?:preserved|remain)\b.*$/i, "").trim();
1207
+ content = content.replace(/\s*[—–]\s+(?:prohibited|no\s+|must\s+not|deletion|do\s+not|migration)\b.*$/i, "").trim();
1208
+
1209
+ // Strip leading verb
1210
+ const words = content.split(/\s+/);
1211
+ let startIdx = 0;
1212
+ for (let i = 0; i < Math.min(2, words.length); i++) {
1213
+ const w = words[i].replace(/[^a-z]/g, "");
1214
+ if (_CONTAMINATING_VERBS.has(w)) {
1215
+ startIdx = i + 1;
1216
+ break;
1217
+ }
1218
+ }
1219
+
1220
+ // Skip fillers
1221
+ while (startIdx < words.length - 1) {
1222
+ const w = words[startIdx].replace(/[^a-z]/g, "");
1223
+ if (_FILLER_WORDS.has(w)) {
1224
+ startIdx++;
1225
+ } else {
1226
+ break;
1227
+ }
1228
+ }
1229
+
1230
+ const subjectWords = words.slice(startIdx);
1231
+ if (subjectWords.length === 0) return subjects;
1232
+
1233
+ // Full noun phrase
1234
+ const fullPhrase = subjectWords.join(" ").replace(/[^a-z0-9\s\-\/]/g, "").trim();
1235
+ if (fullPhrase.length > 1) subjects.push(fullPhrase);
1236
+
1237
+ // Split on conjunctions
1238
+ const conjSplit = fullPhrase.split(/\s+(?:and|or|,)\s+/).map(s => s.trim()).filter(s => s.length > 1);
1239
+ if (conjSplit.length > 1) {
1240
+ for (const s of conjSplit) subjects.push(s);
1241
+ }
1242
+
1243
+ // Individual significant words (excluding generic words)
1244
+ const significantWords = subjectWords
1245
+ .map(w => w.replace(/[^a-z0-9\-\/]/g, ""))
1246
+ .filter(w => w.length > 2 && !_FILLER_WORDS.has(w));
1247
+
1248
+ for (const w of significantWords) {
1249
+ if (_GENERIC_SUBJECT_WORDS.has(w) || w.length <= 3) continue;
1250
+ if (_CONTAMINATING_VERBS.has(w)) continue;
1251
+ // Also skip past participles/gerunds of contaminating verbs
1252
+ // "exposed" → "expose", "logged" → "log", "modified" → "modify"
1253
+ if (_isVerbForm(w)) continue;
1254
+ subjects.push(w);
1255
+ }
1256
+
1257
+ // Adjacent bigrams from significant words
1258
+ for (let i = 0; i < significantWords.length - 1; i++) {
1259
+ const bigram = `${significantWords[i]} ${significantWords[i + 1]}`;
1260
+ if (!subjects.includes(bigram)) {
1261
+ subjects.push(bigram);
1262
+ }
1263
+ }
1264
+
1265
+ return [...new Set(subjects)];
1266
+ }
1267
+
1268
+ function _compareSubjectsInline(actionText, lockText) {
1269
+ const lockSubjects = _extractSubjectsInline(lockText);
1270
+ const actionSubjects = _extractSubjectsInline(actionText);
1271
+
1272
+ if (lockSubjects.length === 0 || actionSubjects.length === 0) {
1273
+ return { overlaps: false, overlapScore: 0, matchedSubjects: [], lockSubjects, actionSubjects };
1274
+ }
1275
+
1276
+ const matched = [];
1277
+
1278
+ // Expand subjects through concept map for cross-domain matching.
1279
+ // IMPORTANT: Do NOT seed with originals — only concept-derived terms go here.
1280
+ // Including originals causes self-matches: if "device" is in both sides,
1281
+ // actionExpanded.has("device") fires for EVERY (ls, as) pair, creating
1282
+ // spurious "concept: dashboard ~ device" matches that inflate strongMatchCount.
1283
+ const lockExpanded = new Set();
1284
+ const actionExpanded = new Set();
1285
+
1286
+ for (const ls of lockSubjects) {
1287
+ // Check concept map — if a lock subject maps to a concept that
1288
+ // contains an action subject (or vice versa), it's a scope match
1289
+ if (CONCEPT_MAP[ls]) {
1290
+ for (const related of CONCEPT_MAP[ls]) {
1291
+ lockExpanded.add(related);
1292
+ }
1293
+ }
1294
+ // Also check individual words within the subject
1295
+ for (const word of ls.split(/\s+/)) {
1296
+ if (CONCEPT_MAP[word]) {
1297
+ for (const related of CONCEPT_MAP[word]) {
1298
+ lockExpanded.add(related);
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ for (const as of actionSubjects) {
1305
+ if (CONCEPT_MAP[as]) {
1306
+ for (const related of CONCEPT_MAP[as]) {
1307
+ actionExpanded.add(related);
1308
+ }
1309
+ }
1310
+ for (const word of as.split(/\s+/)) {
1311
+ if (CONCEPT_MAP[word]) {
1312
+ for (const related of CONCEPT_MAP[word]) {
1313
+ actionExpanded.add(related);
1314
+ }
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ let strongMatchCount = 0; // multi-word phrase or containment matches
1320
+
1321
+ for (const ls of lockSubjects) {
1322
+ for (const as of actionSubjects) {
1323
+ // Exact match
1324
+ if (ls === as) {
1325
+ matched.push(ls);
1326
+ // Multi-word exact = STRONG. Single word exact = WEAK (one shared entity word).
1327
+ if (ls.includes(" ")) strongMatchCount++;
1328
+ continue;
1329
+ }
1330
+ // Word-level containment — "patient records" is inside "old patient records archive"
1331
+ // Multi-word containment is always a STRONG match.
1332
+ const asRegex = new RegExp(`\\b${as.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
1333
+ const lsRegex = new RegExp(`\\b${ls.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
1334
+ if (asRegex.test(ls) || lsRegex.test(as)) {
1335
+ matched.push(`${as} ⊂ ${ls}`);
1336
+ // STRONG only if the contained phrase is multi-word (a compound concept inside a bigger one)
1337
+ // "patient records" ⊂ "old patient records" = STRONG (multi-word phrase match)
1338
+ // "device" ⊂ "device api" = WEAK (single word found in a bigram — just one shared word)
1339
+ if (as.includes(" ") && ls.includes(" ")) strongMatchCount++;
1340
+ continue;
1341
+ }
1342
+ // Concept-expanded match — only STRONG if BOTH sides are multi-word phrases
1343
+ // Single-word concept matches (account~ledger, device~iot) are too ambiguous
1344
+ // to be considered strong scope overlap.
1345
+ if (actionExpanded.has(ls) || lockExpanded.has(as)) {
1346
+ matched.push(`concept: ${as} ~ ${ls}`);
1347
+ if (as.includes(" ") && ls.includes(" ") && !_GENERIC_SUBJECT_WORDS.has(as) && !_GENERIC_SUBJECT_WORDS.has(ls)) {
1348
+ strongMatchCount++;
1349
+ }
1350
+ continue;
1351
+ }
1352
+ // Word-level overlap for multi-word subjects — skip generic words
1353
+ if (ls.includes(" ") && as.includes(" ")) {
1354
+ const lsWords = new Set(ls.split(/\s+/));
1355
+ const asWords = new Set(as.split(/\s+/));
1356
+ const intersection = [...lsWords].filter(w =>
1357
+ asWords.has(w) && w.length > 2 &&
1358
+ !_FILLER_WORDS.has(w) && !_CONTAMINATING_VERBS.has(w) &&
1359
+ !_GENERIC_SUBJECT_WORDS.has(w));
1360
+ if (intersection.length >= 2 && intersection.length >= Math.min(lsWords.size, asWords.size) * 0.4) {
1361
+ // 2+ shared words in a multi-word subject = STRONG
1362
+ matched.push(`word overlap: ${intersection.join(", ")}`);
1363
+ strongMatchCount++;
1364
+ } else if (intersection.length >= 1 && intersection.length >= Math.min(lsWords.size, asWords.size) * 0.4) {
1365
+ // 1 shared word in multi-word subjects = WEAK (different components of same entity)
1366
+ matched.push(`weak overlap: ${intersection.join(", ")}`);
1367
+ }
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ // Count unique single-word exact matches — 2+ different single words = STRONG
1373
+ const singleWordMatches = new Set(
1374
+ matched.filter(m => !m.includes(" ") && !m.includes("⊂") && !m.includes("~") && !m.includes(":"))
1375
+ );
1376
+ if (singleWordMatches.size >= 2) strongMatchCount++;
1377
+
1378
+ const uniqueMatched = [...new Set(matched)];
1379
+ return {
1380
+ overlaps: uniqueMatched.length > 0,
1381
+ strongOverlap: strongMatchCount > 0,
1382
+ overlapScore: uniqueMatched.length > 0 ? Math.min(uniqueMatched.length / Math.max(lockSubjects.length, 1), 1.0) : 0,
1383
+ matchedSubjects: uniqueMatched,
1384
+ lockSubjects,
1385
+ actionSubjects,
1386
+ };
871
1387
  }
872
1388
 
873
1389
  export function scoreConflict({ actionText, lockText }) {
@@ -934,6 +1450,31 @@ export function scoreConflict({ actionText, lockText }) {
934
1450
  euphemismMatches.push(`"${info.via}" (euphemism for ${term})`);
935
1451
  }
936
1452
  }
1453
+
1454
+ // 4b. Destructive method verbs — "by replacing", "through overwriting", "via deleting"
1455
+ // When an action uses a positive primary verb but employs a destructive method,
1456
+ // the method verb is the real operation. "Optimize X by replacing Y" = replacement.
1457
+ const DESTRUCTIVE_METHODS = new Set([
1458
+ "replace", "remove", "delete", "destroy", "rewrite", "overwrite",
1459
+ "restructure", "reconfigure", "migrate", "swap", "switch",
1460
+ "override", "bypass", "eliminate", "strip", "gut", "scrap",
1461
+ "discard", "drop", "disable", "break", "wipe", "erase",
1462
+ ]);
1463
+ const methodVerbMatch = actionText.toLowerCase().match(
1464
+ /\b(?:by|through|via)\s+(\w+ing)\b/i);
1465
+ if (methodVerbMatch) {
1466
+ const gerund = methodVerbMatch[1];
1467
+ const stem1 = gerund.slice(0, -3); // "switching" → "switch"
1468
+ const stem2 = gerund.slice(0, -3) + "e"; // "replacing" → "replace"
1469
+ const stem3 = gerund.slice(0, -4); // "dropping" → "drop"
1470
+ for (const verb of DESTRUCTIVE_METHODS) {
1471
+ if (verb === stem1 || verb === stem2 || verb === stem3) {
1472
+ euphemismMatches.push(`"${gerund}" (destructive method: ${verb})`);
1473
+ break;
1474
+ }
1475
+ }
1476
+ }
1477
+
937
1478
  if (euphemismMatches.length > 0) {
938
1479
  const pts = Math.min(euphemismMatches.length, 3) * SCORING.euphemismMatch;
939
1480
  score += pts;
@@ -961,40 +1502,102 @@ export function scoreConflict({ actionText, lockText }) {
961
1502
  reasons.push(`concept match: ${conceptMatches.slice(0, 2).join("; ")}`);
962
1503
  }
963
1504
 
964
- // 6. Subject relevance gateprevent false positives where only verb-level
965
- // matches exist (euphemism/synonym on verbs) but the subjects are different.
966
- // "Optimize images" should NOT conflict with "Do not modify calculateShipping"
967
- // because the subjects (images vs shipping function) don't overlap.
1505
+ // 6. SUBJECT RELEVANCE GATE (v3 scope-aware)
1506
+ // The core innovation: extract noun-phrase subjects from BOTH the lock
1507
+ // and the action, then check if they target the same component.
1508
+ //
1509
+ // "Update WhatsApp message formatting" vs "Never modify WhatsApp session handler"
1510
+ // → Lock subject: "whatsapp session handler"
1511
+ // → Action subject: "whatsapp message formatting"
1512
+ // → Subjects DON'T match (different components) → reduce score
968
1513
  //
969
- // However, subject-level synonyms like "content safety" "CSAM detection"
970
- // should still count as subject relevance (same concept, different words).
1514
+ // "Delete patient records" vs "Never delete patient records"
1515
+ // Lock subject: "patient records"
1516
+ // → Action subject: "patient records"
1517
+ // → Subjects MATCH → keep score
1518
+ //
1519
+ // This replaces the old verb-only check with proper scope awareness.
1520
+
971
1521
  const ACTION_VERBS_SET = new Set([
972
- "modify", "change", "alter", "update", "delete", "remove", "add", "create",
973
- "disable", "enable", "replace", "swap", "switch", "move", "migrate",
974
- "install", "uninstall", "deploy", "rewrite", "revise", "restructure",
975
- "refactor", "clean", "purge", "wipe", "drop", "kill", "destroy",
976
- "reduce", "simplify", "fix", "repair", "restore", "recover", "break",
977
- "expose", "hide", "connect", "disconnect", "merge", "split", "truncate",
978
- "bypass", "skip", "ignore", "override", "adjust", "tweak", "tune",
1522
+ // Modification verbs
1523
+ "modify", "change", "alter", "update", "mutate", "transform", "rewrite",
1524
+ "revise", "amend", "adjust", "tweak", "tune", "rework", "overhaul",
1525
+ // Destructive verbs
1526
+ "delete", "remove", "drop", "kill", "destroy", "purge", "wipe", "erase",
1527
+ "eliminate", "obliterate", "expunge", "nuke", "truncate", "clear", "empty",
1528
+ "flush", "reset", "void",
1529
+ // Creation verbs
1530
+ "add", "create", "introduce", "insert", "generate", "produce", "spawn",
1531
+ // Toggle verbs
1532
+ "disable", "enable", "activate", "deactivate", "start", "stop", "halt",
1533
+ "pause", "suspend", "freeze",
1534
+ // Replacement verbs
1535
+ "replace", "swap", "substitute", "switch", "exchange", "override", "overwrite",
1536
+ // Movement verbs
1537
+ "move", "relocate", "migrate", "transfer", "shift", "rearrange", "reorganize",
1538
+ "merge", "split", "separate", "partition", "divide", "fork",
1539
+ // Installation verbs
1540
+ "install", "uninstall", "deploy", "connect", "disconnect", "detach",
1541
+ // Structural verbs
1542
+ "refactor", "restructure", "simplify", "reduce", "consolidate",
1543
+ "clean", "normalize", "flatten",
1544
+ // Recovery verbs
1545
+ "fix", "repair", "restore", "recover", "break", "revert", "rollback",
1546
+ // Visibility verbs
1547
+ "expose", "hide", "reveal", "leak",
1548
+ // Bypass verbs
1549
+ "bypass", "skip", "ignore", "circumvent",
1550
+ // Financial verbs
1551
+ "reconcile", "reverse", "recalculate", "backdate", "rebalance",
1552
+ "post", "unpost", "accrue", "amortize", "depreciate", "journal",
1553
+ // Logistics verbs
1554
+ "reroute", "divert", "reassign", "deconsolidate",
1555
+ // Booking verbs
1556
+ "rebook", "cancel",
1557
+ // Upgrade/downgrade
1558
+ "upgrade", "downgrade", "patch", "bump", "advance",
979
1559
  ]);
980
1560
 
981
1561
  // Check if any synonym/concept match involves a non-verb term (= subject match)
982
1562
  const hasSynonymSubjectMatch = synonymMatches.some(m => {
983
- // Format: "term → expansion" — check if expansion is not a common verb
984
1563
  const parts = m.split(" → ");
985
1564
  const expansion = (parts[1] || "").trim();
986
1565
  return !ACTION_VERBS_SET.has(expansion);
987
1566
  });
988
1567
 
989
- const hasSubjectMatch = directOverlap.length > 0 || phraseOverlap.length > 0 ||
1568
+ // OLD check (vocabulary overlap) kept as one input
1569
+ const hasVocabSubjectMatch = directOverlap.length > 0 || phraseOverlap.length > 0 ||
990
1570
  conceptMatches.length > 0 || hasSynonymSubjectMatch;
1571
+
1572
+ // NEW check (scope-aware subject extraction)
1573
+ // Extract actual noun-phrase subjects and compare scopes
1574
+ const subjectComparison = _compareSubjectsInline(actionText, lockText);
1575
+ const hasScopeMatch = subjectComparison.overlaps;
1576
+ // strongOverlap = multi-word phrase match, containment, or 2+ individual word matches
1577
+ // Single-word overlaps like "product" alone are WEAK — different components of same entity
1578
+ const hasStrongScopeMatch = subjectComparison.strongOverlap;
1579
+
1580
+ // Combined subject match: EITHER vocabulary overlap on non-verbs
1581
+ // OR proper subject/scope overlap
1582
+ const hasSubjectMatch = hasVocabSubjectMatch || hasScopeMatch;
991
1583
  const hasAnyMatch = hasSubjectMatch || synonymMatches.length > 0 ||
992
1584
  euphemismMatches.length > 0;
993
1585
 
994
- // If the ONLY matches are verb-level (euphemism/synonym) with no subject
995
- // overlap, drastically reduce the score these are likely false positives
1586
+ // A "strong" vocabulary match means 2+ direct keyword overlap or a multi-word
1587
+ // phrase match. A single shared word + synonym/concept expansion = WEAK.
1588
+ // Synonym expansion inflates score ("device" → "iot, sensor, actuator") but
1589
+ // doesn't prove scope overlap — the action could be about a different "device".
1590
+ const hasStrongVocabMatch = directOverlap.length >= 2 || phraseOverlap.length > 0;
1591
+
1592
+ // Apply the subject relevance gate based on match quality
996
1593
  if (!hasSubjectMatch && (synonymMatches.length > 0 || euphemismMatches.length > 0)) {
1594
+ // NO subject match at all — verb-only match → heavy reduction
997
1595
  score = Math.floor(score * 0.15);
1596
+ reasons.push("subject gate: no subject overlap — verb-only match, likely false positive");
1597
+ } else if (hasVocabSubjectMatch && !hasScopeMatch && subjectComparison.lockSubjects.length > 0 && subjectComparison.actionSubjects.length > 0) {
1598
+ // Vocabulary overlap exists but subjects point to DIFFERENT scopes
1599
+ score = Math.floor(score * 0.35);
1600
+ reasons.push(`scope gate: shared vocabulary but different scope — lock targets "${subjectComparison.lockSubjects[0]}", action targets "${subjectComparison.actionSubjects[0]}"`);
998
1601
  }
999
1602
 
1000
1603
  const prohibitedVerb = extractProhibitedVerb(lockText);
@@ -1018,8 +1621,39 @@ export function scoreConflict({ actionText, lockText }) {
1018
1621
  if (!intentAligned && lockIsProhibitive && actionIntent.intent === "positive" && prohibitedVerb) {
1019
1622
  const prohibitedIsNegative = NEGATIVE_INTENT_MARKERS.some(m =>
1020
1623
  prohibitedVerb === m || prohibitedVerb.startsWith(m));
1021
- const hasEuphemismOrSynonymMatch = euphemismMatches.length > 0 || synonymMatches.length > 0;
1022
- if (prohibitedIsNegative && !actionIntent.negated && !hasEuphemismOrSynonymMatch) {
1624
+ // Only block alignment if the action actually performs the prohibited operation.
1625
+ // Noun synonyms ("price pricing") are incidental and should NOT block.
1626
+ // But if the action contains the prohibited verb or its synonym ("shows" ≈ "expose"),
1627
+ // that's a real violation signal.
1628
+ const hasDestructiveLanguageMatch = euphemismMatches.length > 0;
1629
+
1630
+ // Check if action text contains the prohibited verb or its synonyms
1631
+ let actionPerformsProhibitedOp = false;
1632
+ if (prohibitedVerb) {
1633
+ const actionWordsLower = actionText.toLowerCase().split(/\s+/)
1634
+ .map(w => w.replace(/[^a-z]/g, ""));
1635
+ for (const w of actionWordsLower) {
1636
+ if (!w) continue;
1637
+ // Direct match (including conjugations: show/shows/showing/showed)
1638
+ if (w === prohibitedVerb || w.startsWith(prohibitedVerb)) {
1639
+ actionPerformsProhibitedOp = true;
1640
+ break;
1641
+ }
1642
+ // Synonym match: check if word is in the same synonym group as prohibited verb
1643
+ for (const group of SYNONYM_GROUPS) {
1644
+ if (group.includes(prohibitedVerb)) {
1645
+ if (group.some(syn => w === syn || w.startsWith(syn) && w.length <= syn.length + 3)) {
1646
+ actionPerformsProhibitedOp = true;
1647
+ }
1648
+ break;
1649
+ }
1650
+ }
1651
+ if (actionPerformsProhibitedOp) break;
1652
+ }
1653
+ }
1654
+
1655
+ if (prohibitedIsNegative && !actionIntent.negated &&
1656
+ !hasDestructiveLanguageMatch && !actionPerformsProhibitedOp) {
1023
1657
  intentAligned = true;
1024
1658
  reasons.push(
1025
1659
  `intent alignment: positive action "${actionPrimaryVerb}" against ` +
@@ -1047,6 +1681,26 @@ export function scoreConflict({ actionText, lockText }) {
1047
1681
  // IS a modification. Only truly read-only and activation verbs are safe.
1048
1682
  ]);
1049
1683
 
1684
+ // OBSERVABILITY ACTIONS: "add logging", "add monitoring", "add tracking"
1685
+ // are constructive observability actions, NOT modifications to the locked system.
1686
+ // If the action verb is "add/create/implement" AND the object is an
1687
+ // observability concept, treat it as safe.
1688
+ const OBSERVABILITY_KEYWORDS = new Set([
1689
+ "logging", "log", "logs", "monitoring", "monitor", "tracking",
1690
+ "tracing", "trace", "metrics", "alerting", "alerts", "alert",
1691
+ "observability", "telemetry", "analytics", "reporting", "auditing",
1692
+ "profiling", "instrumentation", "dashboard",
1693
+ ]);
1694
+ const actionLower = actionText.toLowerCase();
1695
+ const actionWords = actionLower.split(/\s+/);
1696
+ const hasObservabilityObject = actionWords.some(w => OBSERVABILITY_KEYWORDS.has(w));
1697
+ const CONSTRUCTIVE_VERBS = new Set(["add", "create", "implement", "introduce", "set up", "enable"]);
1698
+ if (CONSTRUCTIVE_VERBS.has(actionPrimaryVerb) && hasObservabilityObject) {
1699
+ intentAligned = true;
1700
+ reasons.push(
1701
+ `intent alignment: observability action "${actionPrimaryVerb} ... ${actionWords.find(w => OBSERVABILITY_KEYWORDS.has(w))}" is non-destructive`);
1702
+ }
1703
+
1050
1704
  const PROHIBITED_ACTION_VERBS = new Set([
1051
1705
  "modify", "change", "alter", "delete", "remove", "disable",
1052
1706
  "drop", "break", "weaken", "expose", "install", "push",
@@ -1063,6 +1717,80 @@ export function scoreConflict({ actionText, lockText }) {
1063
1717
  }
1064
1718
  }
1065
1719
 
1720
+ // Check 4: Enhancement/constructive actions against preservation/maintenance locks
1721
+ // "Increase the rate limit" is COMPLIANT with "rate limiting must remain active"
1722
+ // "Add better rate limit error messages" is COMPLIANT (doesn't disable rate limiting)
1723
+ // But "Add a way to bypass rate limiting" is NOT safe (contains negative op "bypass")
1724
+ if (!intentAligned && actionPrimaryVerb) {
1725
+ const ENHANCEMENT_VERBS = new Set([
1726
+ "increase", "improve", "enhance", "boost", "strengthen",
1727
+ "upgrade", "raise", "expand", "extend", "grow",
1728
+ ]);
1729
+ const CONSTRUCTIVE_FOR_PRESERVATION = new Set([
1730
+ "build", "add", "create", "implement", "make", "design",
1731
+ "develop", "introduce",
1732
+ ]);
1733
+ const lockIsPreservation = /must remain|must be preserved|must always|at all times|must stay/i.test(lockText);
1734
+ if (lockIsPreservation) {
1735
+ if (ENHANCEMENT_VERBS.has(actionPrimaryVerb)) {
1736
+ // Enhancement verbs always align with preservation locks
1737
+ intentAligned = true;
1738
+ reasons.push(
1739
+ `intent alignment: enhancement action "${actionPrimaryVerb}" is ` +
1740
+ `compliant with preservation lock`);
1741
+ } else if (CONSTRUCTIVE_FOR_PRESERVATION.has(actionPrimaryVerb)) {
1742
+ // Constructive verbs align ONLY if the action doesn't contain negative ops
1743
+ const actionLower = actionText.toLowerCase();
1744
+ const hasNegativeOp = NEGATIVE_INTENT_MARKERS.some(m =>
1745
+ new RegExp(`\\b${escapeRegex(m)}\\b`, "i").test(actionLower));
1746
+ if (!hasNegativeOp) {
1747
+ intentAligned = true;
1748
+ reasons.push(
1749
+ `intent alignment: constructive "${actionPrimaryVerb}" is ` +
1750
+ `compliant with preservation lock (no negative operations)`);
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+
1756
+ // Check 5: Constructive actions with weak/no scope overlap
1757
+ // "Build a device dashboard" shares "device" with "Device API keys" lock,
1758
+ // but building a dashboard doesn't modify API key handling.
1759
+ {
1760
+ const FEATURE_BUILDING_VERBS = new Set([
1761
+ "build", "add", "create", "implement", "make", "design",
1762
+ "develop", "introduce", "setup", "establish", "launch",
1763
+ "integrate", "include", "support",
1764
+ ]);
1765
+ // 5a: Weak scope overlap (single shared word, no strong vocab match)
1766
+ if (!intentAligned && hasScopeMatch && !hasStrongScopeMatch && !hasStrongVocabMatch) {
1767
+ if (FEATURE_BUILDING_VERBS.has(actionPrimaryVerb)) {
1768
+ // Guard: if the shared word is a long, specific entity name (10+ chars),
1769
+ // it strongly identifies the target system — not an incidental overlap.
1770
+ // "authentication" (14 chars) clearly points to the auth system.
1771
+ // "product" (7 chars) or "device" (6 chars) are generic modifiers.
1772
+ const hasSpecificOverlap = directOverlap.some(w => w.length >= 10);
1773
+ if (!hasSpecificOverlap) {
1774
+ intentAligned = true;
1775
+ reasons.push(
1776
+ `intent alignment: constructive "${actionPrimaryVerb}" with ` +
1777
+ `weak scope overlap — new feature, not modification of locked component`);
1778
+ }
1779
+ }
1780
+ }
1781
+ // 5b: Vocab overlap but NO scope overlap (subjects point to different things)
1782
+ // Even weaker signal — shared vocabulary but different actual components.
1783
+ if (!intentAligned && hasVocabSubjectMatch && !hasScopeMatch &&
1784
+ subjectComparison.lockSubjects.length > 0 && subjectComparison.actionSubjects.length > 0) {
1785
+ if (FEATURE_BUILDING_VERBS.has(actionPrimaryVerb)) {
1786
+ intentAligned = true;
1787
+ reasons.push(
1788
+ `intent alignment: constructive "${actionPrimaryVerb}" with ` +
1789
+ `no scope overlap — different components despite shared vocabulary`);
1790
+ }
1791
+ }
1792
+ }
1793
+
1066
1794
  // If intent is ALIGNED, the action is COMPLIANT — slash the score to near zero
1067
1795
  // Shared keywords are expected (both discuss the same subject) but the action
1068
1796
  // is doing the right thing.
@@ -1072,27 +1800,37 @@ export function scoreConflict({ actionText, lockText }) {
1072
1800
  } else {
1073
1801
  // NOT aligned — apply standard conflict bonuses
1074
1802
 
1075
- // 7. Negation conflict bonus — requires subject match, not just verb-level matches
1076
- if (lockIsProhibitive && hasSubjectMatch) {
1803
+ // 7. Negation conflict bonus — requires STRONG subject match
1804
+ // Either: scope overlap (subject extraction confirms same target)
1805
+ // Or: 2+ direct word overlaps (not just a single shared word)
1806
+ // Or: phrase overlap (multi-word match is strong signal)
1807
+ // Or: concept match (domain-level relevance)
1808
+ // Concept matches contribute to base score but should NOT gate the negation
1809
+ // bonus — they're too indirect ("account" → "ledger" via concept map shouldn't
1810
+ // trigger the +35 negation bonus). Only direct evidence counts.
1811
+ const hasStrongSubjectMatch = hasStrongScopeMatch ||
1812
+ directOverlap.length >= 2 ||
1813
+ phraseOverlap.length > 0;
1814
+ if (lockIsProhibitive && hasStrongSubjectMatch) {
1077
1815
  score += SCORING.negationConflict;
1078
1816
  reasons.push("lock prohibits this action (negation detected)");
1079
1817
  }
1080
1818
 
1081
- // 8. Intent conflict bonus — requires subject match
1082
- if (lockIsProhibitive && actionIntent.intent === "negative" && hasSubjectMatch) {
1819
+ // 8. Intent conflict bonus — requires strong subject match
1820
+ if (lockIsProhibitive && actionIntent.intent === "negative" && hasStrongSubjectMatch) {
1083
1821
  score += SCORING.intentConflict;
1084
1822
  reasons.push(
1085
1823
  `intent conflict: action "${actionIntent.actionVerb}" ` +
1086
1824
  `conflicts with lock prohibition`);
1087
1825
  }
1088
1826
 
1089
- // 9. Destructive action bonus — requires subject match
1827
+ // 9. Destructive action bonus — requires strong subject match
1090
1828
  const DESTRUCTIVE = new Set(["remove", "delete", "drop", "destroy",
1091
1829
  "kill", "purge", "wipe", "break", "disable", "truncate",
1092
1830
  "erase", "nuke", "obliterate"]);
1093
1831
  const actionIsDestructive = actionTokens.all.some(t => DESTRUCTIVE.has(t)) ||
1094
1832
  actionIntent.intent === "negative";
1095
- if (actionIsDestructive && hasSubjectMatch) {
1833
+ if (actionIsDestructive && hasStrongSubjectMatch) {
1096
1834
  score += SCORING.destructiveAction;
1097
1835
  reasons.push("destructive action against locked constraint");
1098
1836
  }