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.
- package/package.json +1 -1
- package/src/core/conflict.js +74 -3
- package/src/core/engine.js +10 -0
- package/src/core/llm-checker.js +60 -6
- package/src/core/lock-author.js +478 -0
- package/src/core/memory.js +12 -3
- package/src/core/semantics.js +562 -33
- package/src/mcp/server.js +26 -16
package/src/core/semantics.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ===================================================================
|
|
2
|
-
// SpecLock Semantic Analysis Engine
|
|
3
|
-
//
|
|
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", "
|
|
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.
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
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
|
-
//
|
|
1127
|
-
|
|
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
|
|
1550
|
+
// Financial verbs
|
|
1158
1551
|
"reconcile", "reverse", "recalculate", "backdate", "rebalance",
|
|
1159
1552
|
"post", "unpost", "accrue", "amortize", "depreciate", "journal",
|
|
1160
|
-
// Logistics verbs
|
|
1553
|
+
// Logistics verbs
|
|
1161
1554
|
"reroute", "divert", "reassign", "deconsolidate",
|
|
1162
|
-
// Booking verbs
|
|
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
|
-
|
|
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
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
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
|
|
1285
|
-
|
|
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" &&
|
|
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 &&
|
|
1833
|
+
if (actionIsDestructive && hasStrongSubjectMatch) {
|
|
1305
1834
|
score += SCORING.destructiveAction;
|
|
1306
1835
|
reasons.push("destructive action against locked constraint");
|
|
1307
1836
|
}
|