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.
@@ -1,6 +1,6 @@
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
 
@@ -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
 
@@ -366,6 +368,9 @@ export const CONCEPT_MAP = {
366
368
  "financial records", "settlement"],
367
369
  "account": ["balance", "ledger", "financial records", "customer account",
368
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"],
369
374
  "settlement": ["transaction", "payment", "clearing", "reconciliation",
370
375
  "transfer"],
371
376
  "fraud detection": ["fraud", "fraud prevention", "anti-fraud", "fraud monitoring",
@@ -448,6 +453,12 @@ export const CONCEPT_MAP = {
448
453
  "pii": ["personal data", "user data", "personally identifiable information",
449
454
  "user information", "gdpr"],
450
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"],
451
462
  "user data": ["pii", "personal data", "user information", "user records"],
452
463
 
453
464
  // Encryption
@@ -473,6 +484,20 @@ export const CONCEPT_MAP = {
473
484
  "credential", "session", "token"],
474
485
  "auth": ["authentication", "login", "sign in", "2fa",
475
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"],
476
501
 
477
502
  // Encryption
478
503
  "encryption": ["encrypt", "tls", "ssl", "https", "cryptographic",
@@ -560,7 +585,7 @@ const STOPWORDS = new Set([
560
585
 
561
586
  const POSITIVE_INTENT_MARKERS = [
562
587
  "enable", "activate", "turn on", "switch on", "start",
563
- "add", "create", "implement", "introduce", "set up",
588
+ "add", "create", "implement", "introduce", "set up", "build",
564
589
  "install", "deploy", "launch", "initialize",
565
590
  "enforce", "strengthen", "harden", "improve", "enhance",
566
591
  "increase", "expand", "extend", "upgrade", "boost",
@@ -713,6 +738,15 @@ export function tokenize(text) {
713
738
  .split(/\s+/)
714
739
  .filter(w => w.length >= 2);
715
740
 
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
+
716
750
  // Basic plural normalization — add both singular and plural forms
717
751
  // so "databases" matches "database" and vice versa
718
752
  const words = [...rawWords];
@@ -960,10 +994,20 @@ function extractProhibitedVerb(lockText) {
960
994
  return null;
961
995
  }
962
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
+
963
1007
  function extractPrimaryVerb(actionText) {
964
1008
  const lower = actionText.toLowerCase();
965
- // Find first matching marker in text
966
- 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]
967
1011
  .sort((a, b) => b.length - a.length);
968
1012
 
969
1013
  let earliest = null;
@@ -1024,7 +1068,322 @@ function checkOpposites(verb1, verb2) {
1024
1068
 
1025
1069
  function isProhibitiveLock(lockText) {
1026
1070
  return /\b(never|must not|do not|don't|cannot|can't|forbidden|prohibited|disallowed)\b/i.test(lockText)
1027
- || /\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
+ };
1028
1387
  }
1029
1388
 
1030
1389
  export function scoreConflict({ actionText, lockText }) {
@@ -1091,6 +1450,31 @@ export function scoreConflict({ actionText, lockText }) {
1091
1450
  euphemismMatches.push(`"${info.via}" (euphemism for ${term})`);
1092
1451
  }
1093
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
+
1094
1478
  if (euphemismMatches.length > 0) {
1095
1479
  const pts = Math.min(euphemismMatches.length, 3) * SCORING.euphemismMatch;
1096
1480
  score += pts;
@@ -1118,13 +1502,22 @@ export function scoreConflict({ actionText, lockText }) {
1118
1502
  reasons.push(`concept match: ${conceptMatches.slice(0, 2).join("; ")}`);
1119
1503
  }
1120
1504
 
1121
- // 6. Subject relevance gateprevent false positives where only verb-level
1122
- // matches exist (euphemism/synonym on verbs) but the subjects are different.
1123
- // "Optimize images" should NOT conflict with "Do not modify calculateShipping"
1124
- // 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
1513
+ //
1514
+ // "Delete patient records" vs "Never delete patient records"
1515
+ // → Lock subject: "patient records"
1516
+ // → Action subject: "patient records"
1517
+ // → Subjects MATCH → keep score
1125
1518
  //
1126
- // However, subject-level synonyms like "content safety" "CSAM detection"
1127
- // should still count as subject relevance (same concept, different words).
1519
+ // This replaces the old verb-only check with proper scope awareness.
1520
+
1128
1521
  const ACTION_VERBS_SET = new Set([
1129
1522
  // Modification verbs
1130
1523
  "modify", "change", "alter", "update", "mutate", "transform", "rewrite",
@@ -1154,12 +1547,12 @@ export function scoreConflict({ actionText, lockText }) {
1154
1547
  "expose", "hide", "reveal", "leak",
1155
1548
  // Bypass verbs
1156
1549
  "bypass", "skip", "ignore", "circumvent",
1157
- // Financial verbs (new)
1550
+ // Financial verbs
1158
1551
  "reconcile", "reverse", "recalculate", "backdate", "rebalance",
1159
1552
  "post", "unpost", "accrue", "amortize", "depreciate", "journal",
1160
- // Logistics verbs (new)
1553
+ // Logistics verbs
1161
1554
  "reroute", "divert", "reassign", "deconsolidate",
1162
- // Booking verbs (new)
1555
+ // Booking verbs
1163
1556
  "rebook", "cancel",
1164
1557
  // Upgrade/downgrade
1165
1558
  "upgrade", "downgrade", "patch", "bump", "advance",
@@ -1167,23 +1560,44 @@ export function scoreConflict({ actionText, lockText }) {
1167
1560
 
1168
1561
  // Check if any synonym/concept match involves a non-verb term (= subject match)
1169
1562
  const hasSynonymSubjectMatch = synonymMatches.some(m => {
1170
- // Format: "term → expansion" — check if expansion is not a common verb
1171
1563
  const parts = m.split(" → ");
1172
1564
  const expansion = (parts[1] || "").trim();
1173
1565
  return !ACTION_VERBS_SET.has(expansion);
1174
1566
  });
1175
1567
 
1176
- 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 ||
1177
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;
1178
1583
  const hasAnyMatch = hasSubjectMatch || synonymMatches.length > 0 ||
1179
1584
  euphemismMatches.length > 0;
1180
1585
 
1181
- // If the ONLY matches are verb-level (euphemism/synonym) with no subject
1182
- // overlap, reduce the score these are likely false positives.
1183
- // Use 0.25 (not 0.15) to avoid killing legitimate cross-domain detections
1184
- // where concept links are present but subject wording differs.
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
1185
1593
  if (!hasSubjectMatch && (synonymMatches.length > 0 || euphemismMatches.length > 0)) {
1186
- score = Math.floor(score * 0.25);
1594
+ // NO subject match at all — verb-only match → heavy reduction
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]}"`);
1187
1601
  }
1188
1602
 
1189
1603
  const prohibitedVerb = extractProhibitedVerb(lockText);
@@ -1207,8 +1621,39 @@ export function scoreConflict({ actionText, lockText }) {
1207
1621
  if (!intentAligned && lockIsProhibitive && actionIntent.intent === "positive" && prohibitedVerb) {
1208
1622
  const prohibitedIsNegative = NEGATIVE_INTENT_MARKERS.some(m =>
1209
1623
  prohibitedVerb === m || prohibitedVerb.startsWith(m));
1210
- const hasEuphemismOrSynonymMatch = euphemismMatches.length > 0 || synonymMatches.length > 0;
1211
- 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) {
1212
1657
  intentAligned = true;
1213
1658
  reasons.push(
1214
1659
  `intent alignment: positive action "${actionPrimaryVerb}" against ` +
@@ -1272,6 +1717,80 @@ export function scoreConflict({ actionText, lockText }) {
1272
1717
  }
1273
1718
  }
1274
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
+
1275
1794
  // If intent is ALIGNED, the action is COMPLIANT — slash the score to near zero
1276
1795
  // Shared keywords are expected (both discuss the same subject) but the action
1277
1796
  // is doing the right thing.
@@ -1281,27 +1800,37 @@ export function scoreConflict({ actionText, lockText }) {
1281
1800
  } else {
1282
1801
  // NOT aligned — apply standard conflict bonuses
1283
1802
 
1284
- // 7. Negation conflict bonus — requires subject match, not just verb-level matches
1285
- 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) {
1286
1815
  score += SCORING.negationConflict;
1287
1816
  reasons.push("lock prohibits this action (negation detected)");
1288
1817
  }
1289
1818
 
1290
- // 8. Intent conflict bonus — requires subject match
1291
- if (lockIsProhibitive && actionIntent.intent === "negative" && hasSubjectMatch) {
1819
+ // 8. Intent conflict bonus — requires strong subject match
1820
+ if (lockIsProhibitive && actionIntent.intent === "negative" && hasStrongSubjectMatch) {
1292
1821
  score += SCORING.intentConflict;
1293
1822
  reasons.push(
1294
1823
  `intent conflict: action "${actionIntent.actionVerb}" ` +
1295
1824
  `conflicts with lock prohibition`);
1296
1825
  }
1297
1826
 
1298
- // 9. Destructive action bonus — requires subject match
1827
+ // 9. Destructive action bonus — requires strong subject match
1299
1828
  const DESTRUCTIVE = new Set(["remove", "delete", "drop", "destroy",
1300
1829
  "kill", "purge", "wipe", "break", "disable", "truncate",
1301
1830
  "erase", "nuke", "obliterate"]);
1302
1831
  const actionIsDestructive = actionTokens.all.some(t => DESTRUCTIVE.has(t)) ||
1303
1832
  actionIntent.intent === "negative";
1304
- if (actionIsDestructive && hasSubjectMatch) {
1833
+ if (actionIsDestructive && hasStrongSubjectMatch) {
1305
1834
  score += SCORING.destructiveAction;
1306
1835
  reasons.push("destructive action against locked constraint");
1307
1836
  }