@bli-cockpit/cli 0.2.112 → 0.2.113

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,10 +1,8 @@
1
- import { createReadStream, existsSync } from "node:fs";
2
- import crypto from "node:crypto";
1
+ import { existsSync } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import path from "node:path";
5
- import { StringDecoder } from "node:string_decoder";
6
- import { normalizeGitOrigin } from "../repo-identity.js";
7
4
  import { describeError } from "../health-detail.js";
5
+ import { extractCodexSessionSignals, readCodexMetadataSignals, } from "./codex-session-signals.js";
8
6
  import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
9
7
  /**
10
8
  * Deterministic Codex session JSONL -> repo/worktree attribution.
@@ -17,8 +15,10 @@ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName,
17
15
  * attribute identically.
18
16
  */
19
17
  // Re-exported for callers (and tests) that historically imported these from
20
- // the codex adapter; the single implementation lives in attribution-core.
18
+ // the codex adapter; the single implementation lives in attribution-core, and
19
+ // the signal reading in codex-session-signals.
21
20
  export { sanitizeSessionId, sessionIdFromFileName };
21
+ export { extractCodexSessionSignals };
22
22
  export const CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
23
23
  export const CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
24
24
  export const CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES = 14 * 24 * 60;
@@ -27,7 +27,6 @@ export const CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT = 500;
27
27
  // and no longer rejects sessions by file size; raw evidence collection enforces
28
28
  // upload budgets and content guards later.
29
29
  export const CODEX_SESSION_MAX_FILE_BYTES = 10 * 1024 * 1024;
30
- const CODEX_ATTRIBUTION_MAX_LINE_BYTES = 2 * 1024 * 1024;
31
30
  export function defaultCodexSessionDirs(homeDir) {
32
31
  return [
33
32
  path.join(homeDir, ".codex", "sessions"),
@@ -223,139 +222,6 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
223
222
  }, worktrees, { collectionRoots, pathExists: existsSync });
224
223
  return { ...base, ...outcome };
225
224
  }
226
- async function readCodexMetadataSignals(filePath) {
227
- const hash = crypto.createHash("sha256");
228
- const decoder = new StringDecoder("utf8");
229
- const state = makeSignalExtractionState();
230
- const stream = createReadStream(filePath);
231
- let byteSize = 0;
232
- let lineBuffer = "";
233
- let discardingOversizedLine = false;
234
- for await (const chunk of stream) {
235
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
236
- byteSize += buffer.byteLength;
237
- hash.update(buffer);
238
- const text = decoder.write(buffer);
239
- const segments = text.split("\n");
240
- for (let index = 0; index < segments.length; index += 1) {
241
- const segment = segments[index] ?? "";
242
- const lineEnded = index < segments.length - 1;
243
- if (discardingOversizedLine) {
244
- if (lineEnded)
245
- discardingOversizedLine = false;
246
- continue;
247
- }
248
- lineBuffer += segment;
249
- if (Buffer.byteLength(lineBuffer, "utf8") > CODEX_ATTRIBUTION_MAX_LINE_BYTES) {
250
- state.lineCount += 1;
251
- state.parseErrorCount += 1;
252
- lineBuffer = "";
253
- discardingOversizedLine = !lineEnded;
254
- continue;
255
- }
256
- if (lineEnded) {
257
- absorbCodexSessionLine(state, lineBuffer);
258
- lineBuffer = "";
259
- }
260
- }
261
- }
262
- const rest = decoder.end();
263
- if (rest)
264
- lineBuffer += rest;
265
- if (lineBuffer.trim())
266
- absorbCodexSessionLine(state, lineBuffer);
267
- return {
268
- signals: codexSignalsFromState(state),
269
- contentHashSha256: hash.digest("hex"),
270
- byteSize,
271
- };
272
- }
273
- export function extractCodexSessionSignals(content) {
274
- const state = makeSignalExtractionState();
275
- for (const line of content.split("\n")) {
276
- absorbCodexSessionLine(state, line);
277
- }
278
- return codexSignalsFromState(state);
279
- }
280
- function makeSignalExtractionState() {
281
- return {
282
- sessionIds: new Set(),
283
- cwds: new Set(),
284
- workspaceRoots: new Set(),
285
- branches: new Set(),
286
- commitHashes: new Set(),
287
- repositoryUrls: new Set(),
288
- lineCount: 0,
289
- parseErrorCount: 0,
290
- };
291
- }
292
- function absorbCodexSessionLine(state, line) {
293
- if (!line.trim())
294
- return;
295
- state.lineCount += 1;
296
- let record;
297
- try {
298
- record = JSON.parse(line);
299
- }
300
- catch {
301
- // Deliberately silent (BLI-3238). Per LINE, in files with hundreds of
302
- // thousands of them, and the last line of a live session is routinely
303
- // half-written — this is expected, not a failure. The count travels in
304
- // `parseErrorCount` on the scan result, which is the right grain, and the
305
- // error object would carry a fragment of the transcript.
306
- state.parseErrorCount += 1;
307
- return;
308
- }
309
- if (!record || typeof record !== "object")
310
- return;
311
- const type = record.type;
312
- if (type !== "session_meta" && type !== "turn_context")
313
- return;
314
- const payload = record.payload;
315
- if (!payload || typeof payload !== "object")
316
- return;
317
- const payloadRecord = payload;
318
- if (type === "session_meta") {
319
- addString(state.sessionIds, payloadRecord["id"]);
320
- addString(state.cwds, payloadRecord["cwd"]);
321
- const git = payloadRecord["git"];
322
- if (git && typeof git === "object") {
323
- const gitRecord = git;
324
- addString(state.branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
325
- addString(state.commitHashes, gitRecord["commit_hash"]);
326
- const repositoryUrl = gitRecord["repository_url"];
327
- if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
328
- state.repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
329
- }
330
- }
331
- }
332
- else if (type === "turn_context") {
333
- addString(state.cwds, payloadRecord["cwd"]);
334
- const roots = payloadRecord["workspace_roots"];
335
- if (Array.isArray(roots)) {
336
- for (const root of roots) {
337
- if (typeof root === "string") {
338
- addString(state.workspaceRoots, root);
339
- }
340
- else if (root && typeof root === "object") {
341
- addString(state.workspaceRoots, root["path"]);
342
- }
343
- }
344
- }
345
- }
346
- }
347
- function codexSignalsFromState(state) {
348
- return {
349
- session_ids: [...state.sessionIds],
350
- cwds: [...state.cwds],
351
- workspace_roots: [...state.workspaceRoots],
352
- branches: [...state.branches],
353
- commit_hashes: [...state.commitHashes],
354
- repository_urls: [...state.repositoryUrls],
355
- line_count: state.lineCount,
356
- parse_error_count: state.parseErrorCount,
357
- };
358
- }
359
225
  function skippedResult(base, reason) {
360
226
  return {
361
227
  ...base,
@@ -366,8 +232,4 @@ function skippedResult(base, reason) {
366
232
  path_score: 0,
367
233
  worktree: null,
368
234
  };
369
- }
370
- function addString(target, value) {
371
- if (typeof value === "string" && value.trim())
372
- target.add(value.trim());
373
235
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * What a Codex session file says about WHERE it ran, and nothing else.
3
+ *
4
+ * Only metadata-bearing records are inspected (session_meta, turn_context):
5
+ * cwd, workspace roots, and git branch/commit/origin. Prompt, response,
6
+ * reasoning and tool payload fields are never extracted, printed or
7
+ * summarized. Split out of codex-attribution.ts so the file that DECIDES a
8
+ * session's repo reads as the decision, and this one as the reading.
9
+ *
10
+ * Two doors, same extraction: `readCodexMetadataSignals` streams a file off
11
+ * disk (sessions run to hundreds of megabytes, so the file is never held in
12
+ * memory and its sha256 is taken on the way past), and
13
+ * `extractCodexSessionSignals` takes content already in hand.
14
+ */
15
+ import { createReadStream } from "node:fs";
16
+ import crypto from "node:crypto";
17
+ import { StringDecoder } from "node:string_decoder";
18
+ import { normalizeGitOrigin } from "../repo-identity.js";
19
+ /** A line this long is not a session record; it is counted as a parse error and dropped. */
20
+ const CODEX_ATTRIBUTION_MAX_LINE_BYTES = 2 * 1024 * 1024;
21
+ /**
22
+ * One session file, streamed: the metadata signals, the sha256 of every byte
23
+ * that went past, and the size the walk should believe.
24
+ */
25
+ export async function readCodexMetadataSignals(filePath) {
26
+ const hash = crypto.createHash("sha256");
27
+ const decoder = new StringDecoder("utf8");
28
+ const state = makeSignalExtractionState();
29
+ const stream = createReadStream(filePath);
30
+ let byteSize = 0;
31
+ let lineBuffer = "";
32
+ let discardingOversizedLine = false;
33
+ for await (const chunk of stream) {
34
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
35
+ byteSize += buffer.byteLength;
36
+ hash.update(buffer);
37
+ const text = decoder.write(buffer);
38
+ const segments = text.split("\n");
39
+ for (let index = 0; index < segments.length; index += 1) {
40
+ const segment = segments[index] ?? "";
41
+ const lineEnded = index < segments.length - 1;
42
+ if (discardingOversizedLine) {
43
+ if (lineEnded)
44
+ discardingOversizedLine = false;
45
+ continue;
46
+ }
47
+ lineBuffer += segment;
48
+ if (Buffer.byteLength(lineBuffer, "utf8") > CODEX_ATTRIBUTION_MAX_LINE_BYTES) {
49
+ state.lineCount += 1;
50
+ state.parseErrorCount += 1;
51
+ lineBuffer = "";
52
+ discardingOversizedLine = !lineEnded;
53
+ continue;
54
+ }
55
+ if (lineEnded) {
56
+ absorbCodexSessionLine(state, lineBuffer);
57
+ lineBuffer = "";
58
+ }
59
+ }
60
+ }
61
+ const rest = decoder.end();
62
+ if (rest)
63
+ lineBuffer += rest;
64
+ if (lineBuffer.trim())
65
+ absorbCodexSessionLine(state, lineBuffer);
66
+ return {
67
+ signals: codexSignalsFromState(state),
68
+ contentHashSha256: hash.digest("hex"),
69
+ byteSize,
70
+ };
71
+ }
72
+ export function extractCodexSessionSignals(content) {
73
+ const state = makeSignalExtractionState();
74
+ for (const line of content.split("\n")) {
75
+ absorbCodexSessionLine(state, line);
76
+ }
77
+ return codexSignalsFromState(state);
78
+ }
79
+ function makeSignalExtractionState() {
80
+ return {
81
+ sessionIds: new Set(),
82
+ cwds: new Set(),
83
+ workspaceRoots: new Set(),
84
+ branches: new Set(),
85
+ commitHashes: new Set(),
86
+ repositoryUrls: new Set(),
87
+ lineCount: 0,
88
+ parseErrorCount: 0,
89
+ };
90
+ }
91
+ function absorbCodexSessionLine(state, line) {
92
+ if (!line.trim())
93
+ return;
94
+ state.lineCount += 1;
95
+ let record;
96
+ try {
97
+ record = JSON.parse(line);
98
+ }
99
+ catch {
100
+ // Deliberately silent (BLI-3238). Per LINE, in files with hundreds of
101
+ // thousands of them, and the last line of a live session is routinely
102
+ // half-written — this is expected, not a failure. The count travels in
103
+ // `parseErrorCount` on the scan result, which is the right grain, and the
104
+ // error object would carry a fragment of the transcript.
105
+ state.parseErrorCount += 1;
106
+ return;
107
+ }
108
+ if (!record || typeof record !== "object")
109
+ return;
110
+ const type = record.type;
111
+ if (type !== "session_meta" && type !== "turn_context")
112
+ return;
113
+ const payload = record.payload;
114
+ if (!payload || typeof payload !== "object")
115
+ return;
116
+ const payloadRecord = payload;
117
+ if (type === "session_meta") {
118
+ addString(state.sessionIds, payloadRecord["id"]);
119
+ addString(state.cwds, payloadRecord["cwd"]);
120
+ const git = payloadRecord["git"];
121
+ if (git && typeof git === "object") {
122
+ const gitRecord = git;
123
+ addString(state.branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
124
+ addString(state.commitHashes, gitRecord["commit_hash"]);
125
+ const repositoryUrl = gitRecord["repository_url"];
126
+ if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
127
+ state.repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
128
+ }
129
+ }
130
+ }
131
+ else if (type === "turn_context") {
132
+ addString(state.cwds, payloadRecord["cwd"]);
133
+ const roots = payloadRecord["workspace_roots"];
134
+ if (Array.isArray(roots)) {
135
+ for (const root of roots) {
136
+ if (typeof root === "string") {
137
+ addString(state.workspaceRoots, root);
138
+ }
139
+ else if (root && typeof root === "object") {
140
+ addString(state.workspaceRoots, root["path"]);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ function codexSignalsFromState(state) {
147
+ return {
148
+ session_ids: [...state.sessionIds],
149
+ cwds: [...state.cwds],
150
+ workspace_roots: [...state.workspaceRoots],
151
+ branches: [...state.branches],
152
+ commit_hashes: [...state.commitHashes],
153
+ repository_urls: [...state.repositoryUrls],
154
+ line_count: state.lineCount,
155
+ parse_error_count: state.parseErrorCount,
156
+ };
157
+ }
158
+ function addString(target, value) {
159
+ if (typeof value === "string" && value.trim())
160
+ target.add(value.trim());
161
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A ticket-binding section somebody wrote by hand, and whether it already says
3
+ * what our managed block says.
4
+ *
5
+ * Split out of agent-rules.ts (BLI-10108), which writes the block; this file
6
+ * only READS what is already in a person's rules file and answers two
7
+ * questions: is this good enough to leave alone (`equivalent`), or is it the
8
+ * older vocabulary we should replace (`stale`)?
9
+ *
10
+ * The matching is deliberately loose. These sections were typed by people and
11
+ * by other agents over months, so the test is a handful of cues plus a score,
12
+ * not equality, and every cue is lowercased, unquoted and whitespace-flattened
13
+ * first so formatting never decides the answer.
14
+ */
15
+ import path from "node:path";
16
+ export function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
17
+ const text = normalizeRuleText(contents);
18
+ if (!hasTicketBindingCues(text))
19
+ return false;
20
+ if (!hasRepoScopeGuard(text))
21
+ return false;
22
+ if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
23
+ return false;
24
+ if (!hasTicketLookupOrCreationCue(text))
25
+ return false;
26
+ // Contents without the QA-receipts rule are the older vocabulary and must
27
+ // read as stale — otherwise machines with a hand-written ticket-binding
28
+ // section never receive the definition-of-done rule.
29
+ if (!hasQaReceiptsCue(text))
30
+ return false;
31
+ const signals = [
32
+ /cockpit\s+start\s+--ticket\b/u,
33
+ /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
34
+ /general\s+ambient/u,
35
+ /cockpit\s+sync\s+--repo|cockpit\s+sync\s+--workspace|fresh\s+ticket\/session\s+binding\s+metadata/u,
36
+ /use\s+--ticket|the\s+flag\s+is\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
37
+ ];
38
+ const score = signals.filter((signal) => signal.test(text)).length;
39
+ return score >= 4;
40
+ }
41
+ function hasQaReceiptsCue(text) {
42
+ return /computer-use\s+qa\s+pass/u.test(text)
43
+ && /receipts/u.test(text);
44
+ }
45
+ function hasTicketLookupOrCreationCue(text) {
46
+ return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
47
+ }
48
+ function hasScopePath(text, scopePath) {
49
+ return text.includes(normalizeRuleText(path.resolve(scopePath)));
50
+ }
51
+ function hasRepoScopeGuard(text) {
52
+ return (/only\s+applies\s+when\s+the\s+current\s+working\s+directory\s+is\s+inside/u.test(text) ||
53
+ /outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
54
+ /private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
55
+ }
56
+ export function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
57
+ const lines = contents.split("\n");
58
+ for (let index = 0; index < lines.length; index += 1) {
59
+ if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
60
+ continue;
61
+ }
62
+ let endLine = lines.length;
63
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
64
+ if (/^#{1,6}\s+\S/u.test(lines[cursor] ?? "")) {
65
+ endLine = cursor;
66
+ break;
67
+ }
68
+ }
69
+ const candidate = lines.slice(index, endLine).join("\n");
70
+ const normalized = normalizeRuleText(candidate);
71
+ if (hasTicketBindingCues(normalized) &&
72
+ !hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
73
+ return { startLine: index, endLine };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ export function replaceLineSpan(contents, startLine, endLine, replacement) {
79
+ const lines = contents.split("\n");
80
+ const before = lines.slice(0, startLine).join("\n").trimEnd();
81
+ const after = lines.slice(endLine).join("\n").trimStart();
82
+ return [before, replacement, after]
83
+ .filter((part) => part.trim().length > 0)
84
+ .join("\n\n")
85
+ .replace(/\n{3,}/gu, "\n\n")
86
+ .trimEnd() + "\n";
87
+ }
88
+ function hasTicketBindingCues(text) {
89
+ return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
90
+ }
91
+ function normalizeRuleText(contents) {
92
+ return contents
93
+ .toLowerCase()
94
+ .replace(/[`"'<>]/gu, "")
95
+ .replace(/\s+/gu, " ")
96
+ .trim();
97
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { findStaleUnmanagedTicketBindingBlock, hasEquivalentUnmanagedTicketBinding, replaceLineSpan, } from "./agent-rules-unmanaged-block.js";
4
5
  import { describeError, isMissingFileFailure } from "./health-detail.js";
5
6
  const MANAGED_BLOCK_START = "<!-- BLI_COCKPIT_AGENT_RULES:START -->";
6
7
  const MANAGED_BLOCK_END = "<!-- BLI_COCKPIT_AGENT_RULES:END -->";
@@ -260,88 +261,6 @@ function managedBlockPattern() {
260
261
  function extractManagedBlock(contents) {
261
262
  return contents.match(managedBlockPattern())?.[0] ?? null;
262
263
  }
263
- function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
264
- const text = normalizeRuleText(contents);
265
- if (!hasTicketBindingCues(text))
266
- return false;
267
- if (!hasRepoScopeGuard(text))
268
- return false;
269
- if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
270
- return false;
271
- if (!hasTicketLookupOrCreationCue(text))
272
- return false;
273
- // Contents without the QA-receipts rule are the older vocabulary and must
274
- // read as stale — otherwise machines with a hand-written ticket-binding
275
- // section never receive the definition-of-done rule.
276
- if (!hasQaReceiptsCue(text))
277
- return false;
278
- const signals = [
279
- /cockpit\s+start\s+--ticket\b/u,
280
- /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
281
- /general\s+ambient/u,
282
- /cockpit\s+sync\s+--repo|cockpit\s+sync\s+--workspace|fresh\s+ticket\/session\s+binding\s+metadata/u,
283
- /use\s+--ticket|the\s+flag\s+is\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
284
- ];
285
- const score = signals.filter((signal) => signal.test(text)).length;
286
- return score >= 4;
287
- }
288
- function hasQaReceiptsCue(text) {
289
- return /computer-use\s+qa\s+pass/u.test(text)
290
- && /receipts/u.test(text);
291
- }
292
- function hasTicketLookupOrCreationCue(text) {
293
- return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
294
- }
295
- function hasScopePath(text, scopePath) {
296
- return text.includes(normalizeRuleText(path.resolve(scopePath)));
297
- }
298
- function hasRepoScopeGuard(text) {
299
- return (/only\s+applies\s+when\s+the\s+current\s+working\s+directory\s+is\s+inside/u.test(text) ||
300
- /outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
301
- /private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
302
- }
303
- function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
304
- const lines = contents.split("\n");
305
- for (let index = 0; index < lines.length; index += 1) {
306
- if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
307
- continue;
308
- }
309
- let endLine = lines.length;
310
- for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
311
- if (/^#{1,6}\s+\S/u.test(lines[cursor] ?? "")) {
312
- endLine = cursor;
313
- break;
314
- }
315
- }
316
- const candidate = lines.slice(index, endLine).join("\n");
317
- const normalized = normalizeRuleText(candidate);
318
- if (hasTicketBindingCues(normalized) &&
319
- !hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
320
- return { startLine: index, endLine };
321
- }
322
- }
323
- return null;
324
- }
325
- function replaceLineSpan(contents, startLine, endLine, replacement) {
326
- const lines = contents.split("\n");
327
- const before = lines.slice(0, startLine).join("\n").trimEnd();
328
- const after = lines.slice(endLine).join("\n").trimStart();
329
- return [before, replacement, after]
330
- .filter((part) => part.trim().length > 0)
331
- .join("\n\n")
332
- .replace(/\n{3,}/gu, "\n\n")
333
- .trimEnd() + "\n";
334
- }
335
- function hasTicketBindingCues(text) {
336
- return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
337
- }
338
- function normalizeRuleText(contents) {
339
- return contents
340
- .toLowerCase()
341
- .replace(/[`"'<>]/gu, "")
342
- .replace(/\s+/gu, " ")
343
- .trim();
344
- }
345
264
  function escapeRegExp(value) {
346
265
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
347
266
  }