@crewhaus/tool-text 0.7.0 → 0.8.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +14 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +94 -35
- package/dist/lib/entities.d.ts +2 -0
- package/dist/lib/entities.js +71 -9
- package/dist/lib/markdown.d.ts +18 -0
- package/dist/lib/markdown.js +35 -4
- package/dist/lib/normalize.d.ts +20 -1
- package/dist/lib/normalize.js +181 -17
- package/dist/lib/regex.d.ts +54 -7
- package/dist/lib/regex.js +116 -27
- package/dist/lib/similarity.d.ts +20 -1
- package/dist/lib/similarity.js +67 -15
- package/dist/lib/template.d.ts +23 -2
- package/dist/lib/template.js +36 -7
- package/package.json +11 -6
package/dist/lib/normalize.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/** Strip ANSI escapes so a captured terminal log compares as plain text. */
|
|
2
2
|
export declare function stripAnsi(text: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* `line` without its trailing spaces and tabs: what
|
|
5
|
+
* `line.replace(/[ \t]+$/, "")` returned, in one pass from the end. That
|
|
6
|
+
* pattern tries every start inside a run of blanks that is not at the end of
|
|
7
|
+
* the line and rescans the rest of the run from each, so it was quadratic in
|
|
8
|
+
* the run's length.
|
|
9
|
+
*/
|
|
10
|
+
export declare function withoutTrailingBlanks(line: string): string;
|
|
3
11
|
export type NormalizeOptions = {
|
|
4
12
|
readonly eol?: "lf" | "crlf";
|
|
5
13
|
readonly trimTrailingWhitespace?: boolean;
|
|
@@ -32,8 +40,19 @@ export declare function sortLines(text: string, opts: SortOptions): string[];
|
|
|
32
40
|
* Replace terms using operator-supplied mappings, longest term first so a
|
|
33
41
|
* multi-word entry wins over a substring of itself. Whole-word by default,
|
|
34
42
|
* which is what stops "AI" rewriting the middle of "CHAIN".
|
|
43
|
+
*
|
|
44
|
+
* Each term's pass also runs over what earlier terms put in, so a
|
|
45
|
+
* replacement that holds the next term many times multiplied the text once
|
|
46
|
+
* per term: a 382-byte call grew it to 639,999 characters, and a fifth term
|
|
47
|
+
* to 12.8 million. The text may grow to `limit`, and the passes together may
|
|
48
|
+
* read {@link GLOSSARY_PASSES_PER_LIMIT} times it; past either this throws
|
|
49
|
+
* a sentence that says which, never a cut text.
|
|
50
|
+
*
|
|
51
|
+
* Each pass finds its term the way `new RegExp(<escaped term>, "g" | "gi")`
|
|
52
|
+
* did, with `(?<![\w-])` and `(?![\w-])` around it for whole words — but
|
|
53
|
+
* reads each unit of the text once, whatever the term ({@link replaceTerm}).
|
|
35
54
|
*/
|
|
36
|
-
export declare function glossaryReplace(text: string, mapping: Readonly<Record<string, string>>, wholeWord: boolean, caseSensitive: boolean): {
|
|
55
|
+
export declare function glossaryReplace(text: string, mapping: Readonly<Record<string, string>>, wholeWord: boolean, caseSensitive: boolean, limit?: number): {
|
|
37
56
|
text: string;
|
|
38
57
|
replacements: Record<string, number>;
|
|
39
58
|
};
|
package/dist/lib/normalize.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { escapeFor } from "./format";
|
|
2
1
|
/** CSI/SGR escapes emitted by terminals into captured output. */
|
|
3
2
|
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
|
|
4
3
|
/** Strip ANSI escapes so a captured terminal log compares as plain text. */
|
|
@@ -30,6 +29,22 @@ const INVISIBLE_CODE_POINTS = [
|
|
|
30
29
|
0x2069,
|
|
31
30
|
];
|
|
32
31
|
const INVISIBLE_PATTERN = new RegExp(INVISIBLE_CODE_POINTS.map((cp) => `\\u${cp.toString(16).padStart(4, "0")}`).join("|"), "g");
|
|
32
|
+
/**
|
|
33
|
+
* `line` without its trailing spaces and tabs: what
|
|
34
|
+
* `line.replace(/[ \t]+$/, "")` returned, in one pass from the end. That
|
|
35
|
+
* pattern tries every start inside a run of blanks that is not at the end of
|
|
36
|
+
* the line and rescans the rest of the run from each, so it was quadratic in
|
|
37
|
+
* the run's length.
|
|
38
|
+
*/
|
|
39
|
+
export function withoutTrailingBlanks(line) {
|
|
40
|
+
let end = line.length;
|
|
41
|
+
for (; end > 0; end--) {
|
|
42
|
+
const c = line.charCodeAt(end - 1);
|
|
43
|
+
if (c !== 0x20 && c !== 0x09)
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
return end === line.length ? line : line.slice(0, end);
|
|
47
|
+
}
|
|
33
48
|
/**
|
|
34
49
|
* Canonicalize text so two copies differing only in invisible ways compare
|
|
35
50
|
* equal. Run this before hashing, diffing, or de-duplicating; it is why a
|
|
@@ -48,10 +63,7 @@ export function normalizeText(text, opts) {
|
|
|
48
63
|
// Fold to LF first so later rules see one shape, then convert at the end.
|
|
49
64
|
out = out.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
50
65
|
if (opts.trimTrailingWhitespace === true) {
|
|
51
|
-
out = out
|
|
52
|
-
.split("\n")
|
|
53
|
-
.map((l) => l.replace(/[ \t]+$/, ""))
|
|
54
|
-
.join("\n");
|
|
66
|
+
out = out.split("\n").map(withoutTrailingBlanks).join("\n");
|
|
55
67
|
}
|
|
56
68
|
if (opts.collapseBlankLines === true)
|
|
57
69
|
out = out.replace(/\n{3,}/g, "\n\n");
|
|
@@ -98,25 +110,177 @@ export function sortLines(text, opts) {
|
|
|
98
110
|
});
|
|
99
111
|
return opts.order === "desc" ? sorted.reverse() : sorted;
|
|
100
112
|
}
|
|
113
|
+
/** GlossaryReplace's limit for its text: the default for what the mapping may grow it to. */
|
|
114
|
+
const GLOSSARY_LIMIT = 2_000_000;
|
|
115
|
+
/**
|
|
116
|
+
* Each term is one pass over the whole text, so the passes together may
|
|
117
|
+
* read this many times the limit: thousands of terms over a long document,
|
|
118
|
+
* or a hundred over the longest text.
|
|
119
|
+
*/
|
|
120
|
+
const GLOSSARY_PASSES_PER_LIMIT = 100;
|
|
101
121
|
/**
|
|
102
122
|
* Replace terms using operator-supplied mappings, longest term first so a
|
|
103
123
|
* multi-word entry wins over a substring of itself. Whole-word by default,
|
|
104
124
|
* which is what stops "AI" rewriting the middle of "CHAIN".
|
|
125
|
+
*
|
|
126
|
+
* Each term's pass also runs over what earlier terms put in, so a
|
|
127
|
+
* replacement that holds the next term many times multiplied the text once
|
|
128
|
+
* per term: a 382-byte call grew it to 639,999 characters, and a fifth term
|
|
129
|
+
* to 12.8 million. The text may grow to `limit`, and the passes together may
|
|
130
|
+
* read {@link GLOSSARY_PASSES_PER_LIMIT} times it; past either this throws
|
|
131
|
+
* a sentence that says which, never a cut text.
|
|
132
|
+
*
|
|
133
|
+
* Each pass finds its term the way `new RegExp(<escaped term>, "g" | "gi")`
|
|
134
|
+
* did, with `(?<![\w-])` and `(?![\w-])` around it for whole words — but
|
|
135
|
+
* reads each unit of the text once, whatever the term ({@link replaceTerm}).
|
|
105
136
|
*/
|
|
106
|
-
export function glossaryReplace(text, mapping, wholeWord, caseSensitive) {
|
|
137
|
+
export function glossaryReplace(text, mapping, wholeWord, caseSensitive, limit = GLOSSARY_LIMIT) {
|
|
107
138
|
const replacements = {};
|
|
139
|
+
const terms = Object.keys(mapping).sort((a, b) => b.length - a.length);
|
|
140
|
+
const budget = GLOSSARY_PASSES_PER_LIMIT * limit;
|
|
108
141
|
let out = text;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (count > 0)
|
|
119
|
-
replacements[term] = count;
|
|
142
|
+
let read = 0;
|
|
143
|
+
for (const term of terms) {
|
|
144
|
+
if (read + out.length > budget) {
|
|
145
|
+
throw new Error(`${terms.length} glossary terms over ${out.length} characters of text would read more than ${budget} characters, since each term is a pass over the whole text; send fewer terms or a shorter text`);
|
|
146
|
+
}
|
|
147
|
+
read += out.length;
|
|
148
|
+
// What the replacer returned before: a missing value reads "undefined".
|
|
149
|
+
const value = String(mapping[term]);
|
|
150
|
+
const replaced = replaceTerm(out, term, value, wholeWord, caseSensitive, limit);
|
|
151
|
+
if (replaced.count > 0)
|
|
152
|
+
replacements[term] = replaced.count;
|
|
153
|
+
out = replaced.text;
|
|
120
154
|
}
|
|
121
155
|
return { text: out, replacements };
|
|
122
156
|
}
|
|
157
|
+
/** `[\w-]`: what a whole word may not touch. The regex `i` flag (without `u`) folds no other unit into it. */
|
|
158
|
+
function isWordUnit(c) {
|
|
159
|
+
return ((c >= 0x30 && c <= 0x39) ||
|
|
160
|
+
(c >= 0x41 && c <= 0x5a) ||
|
|
161
|
+
(c >= 0x61 && c <= 0x7a) ||
|
|
162
|
+
c === 0x5f ||
|
|
163
|
+
c === 0x2d);
|
|
164
|
+
}
|
|
165
|
+
/** Canonical code unit per code unit, filled on first use; -1 is "not yet". */
|
|
166
|
+
const CANONICAL = new Int32Array(0x10000).fill(-1);
|
|
167
|
+
/**
|
|
168
|
+
* The code unit the regex `i` flag (without `u`) compares `c` as: the
|
|
169
|
+
* ECMAScript Canonicalize — its upper case when that is one code unit, and not
|
|
170
|
+
* ASCII for a non-ASCII `c` — as this engine's own tables have it. The
|
|
171
|
+
* engine's case tables can be older than `toUpperCase`'s, so a pairing the
|
|
172
|
+
* regex does not make is not made here either. The same function is in
|
|
173
|
+
* tool-secure's `lib/literal.ts`; keep the two in step.
|
|
174
|
+
*/
|
|
175
|
+
function canonicalUnit(c) {
|
|
176
|
+
const known = CANONICAL[c];
|
|
177
|
+
if (known !== -1)
|
|
178
|
+
return known;
|
|
179
|
+
let out = c;
|
|
180
|
+
if (c < 0x80) {
|
|
181
|
+
if (c >= 0x61 && c <= 0x7a)
|
|
182
|
+
out = c - 0x20;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const upper = String.fromCharCode(c).toUpperCase();
|
|
186
|
+
if (upper.length === 1) {
|
|
187
|
+
const u = upper.charCodeAt(0);
|
|
188
|
+
if (u >= 0x80 && u !== c && regexFolds(c, u))
|
|
189
|
+
out = u;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
CANONICAL[c] = out;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
function regexFolds(a, b) {
|
|
196
|
+
return new RegExp(`^\\u${a.toString(16).padStart(4, "0")}$`, "i").test(String.fromCharCode(b));
|
|
197
|
+
}
|
|
198
|
+
const sameUnit = (c) => c;
|
|
199
|
+
/**
|
|
200
|
+
* `text` with `term` replaced by `value` wherever `new RegExp(<escaped
|
|
201
|
+
* term>, caseSensitive ? "g" : "gi")` matched it — inside
|
|
202
|
+
* `(?<![\w-])…(?![\w-])` when `wholeWord` — and how many times.
|
|
203
|
+
*
|
|
204
|
+
* That regex tried the whole term from every position of the text, so one
|
|
205
|
+
* pass read the text's length times the term's. Here the term is found with
|
|
206
|
+
* Knuth–Morris–Pratt, comparing code units folded as the `i` flag folds
|
|
207
|
+
* them: each unit of the text is read once, and the comparisons that fail
|
|
208
|
+
* are paid for by ones that matched. Every place the term occurs is found,
|
|
209
|
+
* overlapping ones too, left to right; one is a match when it starts where
|
|
210
|
+
* the last match ended or later and, for whole words, the units on either
|
|
211
|
+
* side of it (in the text as it was, as the lookarounds read it) are not
|
|
212
|
+
* `[\w-]`. That is the leftmost match from each place the regex resumed. The
|
|
213
|
+
* empty term occurs before each unit and at the end, as an empty regex
|
|
214
|
+
* matches.
|
|
215
|
+
*
|
|
216
|
+
* Case folds as the term on its own folded. JavaScriptCore folded some units
|
|
217
|
+
* further when the pattern had the lookbehind, so with whole words the
|
|
218
|
+
* Kelvin sign matched "k" and the long s "S", where the term on its own
|
|
219
|
+
* never did; with whole words on or off, they now fold alike.
|
|
220
|
+
*
|
|
221
|
+
* Every match is as long as the term, so the text's growth only rises or
|
|
222
|
+
* only falls: once past `limit` the whole pass ends past it, and this throws
|
|
223
|
+
* at that match rather than build the text.
|
|
224
|
+
*/
|
|
225
|
+
function replaceTerm(text, term, value, wholeWord, caseSensitive, limit) {
|
|
226
|
+
const m = term.length;
|
|
227
|
+
const n = text.length;
|
|
228
|
+
const fold = caseSensitive ? sameUnit : canonicalUnit;
|
|
229
|
+
const parts = [];
|
|
230
|
+
let copied = 0;
|
|
231
|
+
let count = 0;
|
|
232
|
+
let grown = 0;
|
|
233
|
+
const take = (start) => {
|
|
234
|
+
count++;
|
|
235
|
+
grown += value.length - m;
|
|
236
|
+
if (grown > 0 && n + grown > limit) {
|
|
237
|
+
throw new Error(`the glossary would make the text longer than ${limit} characters, the limit for text; terms apply longest first, each to the text the ones before produced, so a replacement that holds a later term is replaced again`);
|
|
238
|
+
}
|
|
239
|
+
parts.push(text.slice(copied, start), value);
|
|
240
|
+
copied = start + m;
|
|
241
|
+
};
|
|
242
|
+
const clearOfWords = (start, end) => !wholeWord ||
|
|
243
|
+
((start === 0 || !isWordUnit(text.charCodeAt(start - 1))) &&
|
|
244
|
+
(end === n || !isWordUnit(text.charCodeAt(end))));
|
|
245
|
+
if (m === 0) {
|
|
246
|
+
for (let i = 0; i <= n; i++)
|
|
247
|
+
if (clearOfWords(i, i))
|
|
248
|
+
take(i);
|
|
249
|
+
}
|
|
250
|
+
else if (m <= n) {
|
|
251
|
+
// For each prefix of the folded term, its longest proper border.
|
|
252
|
+
const border = new Int32Array(m);
|
|
253
|
+
for (let i = 1, k = 0; i < m; i++) {
|
|
254
|
+
const c = fold(term.charCodeAt(i));
|
|
255
|
+
while (k > 0 && fold(term.charCodeAt(k)) !== c)
|
|
256
|
+
k = border[k - 1];
|
|
257
|
+
if (fold(term.charCodeAt(k)) === c)
|
|
258
|
+
k += 1;
|
|
259
|
+
border[i] = k;
|
|
260
|
+
}
|
|
261
|
+
let k = 0;
|
|
262
|
+
for (let i = 0; i < n; i++) {
|
|
263
|
+
const c = fold(text.charCodeAt(i));
|
|
264
|
+
while (k > 0 && fold(term.charCodeAt(k)) !== c)
|
|
265
|
+
k = border[k - 1];
|
|
266
|
+
if (fold(term.charCodeAt(k)) === c)
|
|
267
|
+
k += 1;
|
|
268
|
+
if (k === m) {
|
|
269
|
+
const start = i + 1 - m;
|
|
270
|
+
if (start >= copied && clearOfWords(start, i + 1)) {
|
|
271
|
+
take(start);
|
|
272
|
+
// The next match starts where this one ends.
|
|
273
|
+
k = 0;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Not a match here; one may still start inside it.
|
|
277
|
+
k = border[m - 1];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (count === 0)
|
|
283
|
+
return { text, count };
|
|
284
|
+
parts.push(text.slice(copied));
|
|
285
|
+
return { text: parts.join(""), count };
|
|
286
|
+
}
|
package/dist/lib/regex.d.ts
CHANGED
|
@@ -7,14 +7,61 @@ export type RegexMatch = {
|
|
|
7
7
|
readonly groups: Readonly<Record<string, string>>;
|
|
8
8
|
readonly captures: ReadonlyArray<string | undefined>;
|
|
9
9
|
};
|
|
10
|
+
/** Where a tool call's patterns run: its abort signal, and whose runaway workers they count as. */
|
|
11
|
+
export type RegexRunContext = {
|
|
12
|
+
readonly signal?: AbortSignal;
|
|
13
|
+
readonly runawayKey?: string;
|
|
14
|
+
};
|
|
15
|
+
/** The ctx fields a tool's `execute` receives that a regex run uses. */
|
|
16
|
+
export declare function regexRunContext(ctx: unknown): RegexRunContext;
|
|
17
|
+
/** Wall-clock budget for one call's caller patterns. */
|
|
18
|
+
export declare const PATTERN_DEADLINE_MS = 5000;
|
|
19
|
+
/** The screen's limits for a caller pattern. */
|
|
20
|
+
export declare const PATTERN_LIMITS: {
|
|
21
|
+
readonly maxPatternChars: 10000;
|
|
22
|
+
};
|
|
23
|
+
export type RegexExtractResult = {
|
|
24
|
+
readonly ok: true;
|
|
25
|
+
readonly matches: RegexMatch[];
|
|
26
|
+
/** More matches may exist than were returned. */
|
|
27
|
+
readonly truncated: boolean;
|
|
28
|
+
readonly truncatedBy?: "maxMatches" | "maxOutputChars";
|
|
29
|
+
/**
|
|
30
|
+
* Set when the pattern could not be run to the end of the text (the
|
|
31
|
+
* deadline, the engine giving up): `matches` are the ones found before
|
|
32
|
+
* it stopped, and there may be more. Never read as "no more matches".
|
|
33
|
+
*/
|
|
34
|
+
readonly undetermined?: string;
|
|
35
|
+
} | {
|
|
36
|
+
readonly ok: false;
|
|
37
|
+
readonly invalid: string;
|
|
38
|
+
};
|
|
10
39
|
/**
|
|
11
|
-
* Run a regex over text and return located matches.
|
|
40
|
+
* Run a caller's regex over text and return located matches.
|
|
12
41
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
42
|
+
* The match runs in `@crewhaus/tool-safety`'s regex worker under a deadline,
|
|
43
|
+
* never on this thread: a synchronous `RegExp` cannot be interrupted, and
|
|
44
|
+
* `a*a*a*a*b` over 400 characters held the whole process for 108 s. The
|
|
45
|
+
* pattern is screened first, so a shape that backtracks exponentially is
|
|
46
|
+
* refused with the reason. `g` is always applied, and a zero-width match
|
|
47
|
+
* advances by one character, so a pattern like `a*` terminates.
|
|
16
48
|
*/
|
|
17
|
-
export declare function regexExtractAll(text: string, pattern: string, flags: string, maxMatches: number):
|
|
18
|
-
|
|
19
|
-
|
|
49
|
+
export declare function regexExtractAll(text: string, pattern: string, flags: string, maxMatches: number, ctx?: RegexRunContext): Promise<RegexExtractResult>;
|
|
50
|
+
/** One pattern's answer on one text: a verdict, or why there is none. */
|
|
51
|
+
export type PatternAnswer = boolean | {
|
|
52
|
+
readonly undetermined: string;
|
|
20
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Test every pattern against `text` (case-insensitively, as RuleClassify
|
|
56
|
+
* reads its regex rules) in one worker run, so a classifier can score
|
|
57
|
+
* synchronously from the answers. A pattern the screen refuses is reported
|
|
58
|
+
* as `invalid`, with its reason; one the worker could not finish is
|
|
59
|
+
* undetermined, never `false`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function answerPatterns(text: string, patterns: ReadonlyArray<string>, flags: string, ctx?: RegexRunContext): Promise<{
|
|
62
|
+
readonly ok: true;
|
|
63
|
+
readonly answers: ReadonlyMap<string, PatternAnswer>;
|
|
64
|
+
} | {
|
|
65
|
+
readonly ok: false;
|
|
66
|
+
readonly invalid: string;
|
|
67
|
+
}>;
|
package/dist/lib/regex.js
CHANGED
|
@@ -1,36 +1,125 @@
|
|
|
1
|
+
import { describeRegexOutcome, openRegexSession, runRegex, } from "@crewhaus/tool-safety/regex";
|
|
1
2
|
import { lineStarts, offsetToLineCol } from "./locate";
|
|
3
|
+
/** The ctx fields a tool's `execute` receives that a regex run uses. */
|
|
4
|
+
export function regexRunContext(ctx) {
|
|
5
|
+
const c = ctx;
|
|
6
|
+
return {
|
|
7
|
+
...(c?.signal === undefined ? {} : { signal: c.signal }),
|
|
8
|
+
...(typeof c?.runContext?.sessionId === "string" ? { runawayKey: c.runContext.sessionId } : {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Wall-clock budget for one call's caller patterns. */
|
|
12
|
+
export const PATTERN_DEADLINE_MS = 5_000;
|
|
13
|
+
/** The screen's limits for a caller pattern. */
|
|
14
|
+
export const PATTERN_LIMITS = { maxPatternChars: 10_000 };
|
|
2
15
|
/**
|
|
3
|
-
* Run a regex over text and return located matches.
|
|
16
|
+
* Run a caller's regex over text and return located matches.
|
|
4
17
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
18
|
+
* The match runs in `@crewhaus/tool-safety`'s regex worker under a deadline,
|
|
19
|
+
* never on this thread: a synchronous `RegExp` cannot be interrupted, and
|
|
20
|
+
* `a*a*a*a*b` over 400 characters held the whole process for 108 s. The
|
|
21
|
+
* pattern is screened first, so a shape that backtracks exponentially is
|
|
22
|
+
* refused with the reason. `g` is always applied, and a zero-width match
|
|
23
|
+
* advances by one character, so a pattern like `a*` terminates.
|
|
8
24
|
*/
|
|
9
|
-
export function regexExtractAll(text, pattern, flags, maxMatches) {
|
|
10
|
-
const
|
|
11
|
-
|
|
25
|
+
export async function regexExtractAll(text, pattern, flags, maxMatches, ctx = {}) {
|
|
26
|
+
const outcome = await runRegex({
|
|
27
|
+
op: "matchAll",
|
|
28
|
+
pattern,
|
|
29
|
+
flags,
|
|
30
|
+
input: text,
|
|
31
|
+
maxMatches,
|
|
32
|
+
deadlineMs: PATTERN_DEADLINE_MS,
|
|
33
|
+
maxInputChars: text.length,
|
|
34
|
+
limits: PATTERN_LIMITS,
|
|
35
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
36
|
+
...(ctx.runawayKey === undefined ? {} : { runawayKey: ctx.runawayKey }),
|
|
37
|
+
});
|
|
38
|
+
if (outcome.status === "rejected")
|
|
39
|
+
return { ok: false, invalid: outcome.reason };
|
|
40
|
+
const found = outcome.status === "ok"
|
|
41
|
+
? outcome.result.matches
|
|
42
|
+
: outcome.status === "gave-up"
|
|
43
|
+
? (outcome.partial?.matches ?? [])
|
|
44
|
+
: [];
|
|
12
45
|
const starts = lineStarts(text);
|
|
13
|
-
const matches =
|
|
14
|
-
let truncated = false;
|
|
15
|
-
for (;;) {
|
|
16
|
-
const m = re.exec(text);
|
|
17
|
-
if (m === null)
|
|
18
|
-
break;
|
|
19
|
-
if (matches.length >= maxMatches) {
|
|
20
|
-
truncated = true;
|
|
21
|
-
break;
|
|
22
|
-
}
|
|
46
|
+
const matches = found.map((m) => {
|
|
23
47
|
const { line, column } = offsetToLineCol(starts, m.index);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
const groups = {};
|
|
49
|
+
for (const [name, value] of Object.entries(m.groups ?? {})) {
|
|
50
|
+
if (value !== undefined)
|
|
51
|
+
groups[name] = value;
|
|
52
|
+
}
|
|
53
|
+
return { match: m.match, index: m.index, line, column, groups, captures: m.captures };
|
|
54
|
+
});
|
|
55
|
+
if (outcome.status !== "ok") {
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
matches,
|
|
59
|
+
truncated: true,
|
|
60
|
+
undetermined: describeRegexOutcome(outcome),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
matches,
|
|
66
|
+
truncated: outcome.result.truncated,
|
|
67
|
+
...(outcome.result.truncatedBy === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { truncatedBy: outcome.result.truncatedBy }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Test every pattern against `text` (case-insensitively, as RuleClassify
|
|
74
|
+
* reads its regex rules) in one worker run, so a classifier can score
|
|
75
|
+
* synchronously from the answers. A pattern the screen refuses is reported
|
|
76
|
+
* as `invalid`, with its reason; one the worker could not finish is
|
|
77
|
+
* undetermined, never `false`.
|
|
78
|
+
*/
|
|
79
|
+
export async function answerPatterns(text, patterns, flags, ctx = {}) {
|
|
80
|
+
const distinct = [...new Set(patterns)];
|
|
81
|
+
const answers = new Map();
|
|
82
|
+
if (distinct.length === 0)
|
|
83
|
+
return { ok: true, answers };
|
|
84
|
+
let total = 0;
|
|
85
|
+
for (const p of distinct)
|
|
86
|
+
total += p.length;
|
|
87
|
+
const session = openRegexSession();
|
|
88
|
+
try {
|
|
89
|
+
const outcome = await session.run({
|
|
90
|
+
op: "testMatrix",
|
|
91
|
+
patterns: distinct.map((pattern) => ({ pattern, flags })),
|
|
92
|
+
inputs: [text],
|
|
93
|
+
onGiveUp: "skip",
|
|
94
|
+
deadlineMs: PATTERN_DEADLINE_MS,
|
|
95
|
+
maxInputChars: text.length + 1,
|
|
96
|
+
maxItemChars: text.length,
|
|
97
|
+
maxRules: distinct.length,
|
|
98
|
+
maxTotalPatternChars: total,
|
|
99
|
+
limits: PATTERN_LIMITS,
|
|
100
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
101
|
+
...(ctx.runawayKey === undefined ? {} : { runawayKey: ctx.runawayKey }),
|
|
31
102
|
});
|
|
32
|
-
if (
|
|
33
|
-
|
|
103
|
+
if (outcome.status === "rejected") {
|
|
104
|
+
const which = outcome.ruleIndex === undefined ? "" : `/${distinct[outcome.ruleIndex]}/: `;
|
|
105
|
+
return { ok: false, invalid: `${which}${outcome.reason}` };
|
|
106
|
+
}
|
|
107
|
+
for (const [i, pattern] of distinct.entries()) {
|
|
108
|
+
if (outcome.status !== "ok") {
|
|
109
|
+
answers.set(pattern, { undetermined: describeRegexOutcome(outcome) });
|
|
110
|
+
}
|
|
111
|
+
else if ((outcome.result.undetermined[i] ?? []).length > 0) {
|
|
112
|
+
answers.set(pattern, {
|
|
113
|
+
undetermined: "the regex engine gave up on the text, which it reports as no match",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
answers.set(pattern, (outcome.result.matched[i] ?? []).length > 0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, answers };
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
session.close();
|
|
34
124
|
}
|
|
35
|
-
return { matches, truncated };
|
|
36
125
|
}
|
package/dist/lib/similarity.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Classic Levenshtein edit distance, two-row variant, over UTF-16 code
|
|
3
|
+
* units. The rows are typed arrays reused across the table, not a fresh
|
|
4
|
+
* array per row: the same answers at a fraction of the time per cell.
|
|
5
|
+
*/
|
|
2
6
|
export declare function levenshtein(a: string, b: string): number;
|
|
3
7
|
/** Edit distance expressed as a 0..1 similarity. */
|
|
4
8
|
export declare function levenshteinRatio(a: string, b: string): number;
|
|
@@ -8,6 +12,21 @@ export declare function jaroWinkler(a: string, b: string): number;
|
|
|
8
12
|
export declare function nGrams(text: string, n: number): Set<string>;
|
|
9
13
|
export declare function jaccard(a: ReadonlySet<string>, b: ReadonlySet<string>): number;
|
|
10
14
|
export type SimilarityMethod = "levenshtein" | "jaro" | "trigram" | "tokenJaccard";
|
|
15
|
+
/**
|
|
16
|
+
* The most work one Levenshtein or Jaro comparison may do, in Levenshtein
|
|
17
|
+
* cells: about a second of one thread. Both methods are O(n x m) and run
|
|
18
|
+
* synchronously on the harness thread, so two 20,000-character strings
|
|
19
|
+
* stalled every session in the process; this refuses those and still
|
|
20
|
+
* answers the sizes 0.7.0 answered in a fraction of a second.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_SIMILARITY_WORK = 200000000;
|
|
23
|
+
/**
|
|
24
|
+
* The work one comparison does, in Levenshtein cells: the DP table for
|
|
25
|
+
* Levenshtein, the matching window for Jaro at its measured discount.
|
|
26
|
+
* Trigram and token overlap are linear, so they cost nothing against the
|
|
27
|
+
* quadratic budget.
|
|
28
|
+
*/
|
|
29
|
+
export declare function similarityCost(a: string, b: string, method: SimilarityMethod): number;
|
|
11
30
|
export declare function similarity(a: string, b: string, method: SimilarityMethod): number;
|
|
12
31
|
export type FuzzyHit = {
|
|
13
32
|
readonly candidate: string;
|
package/dist/lib/similarity.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { tokenize } from "./locate";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Classic Levenshtein edit distance, two-row variant, over UTF-16 code
|
|
4
|
+
* units. The rows are typed arrays reused across the table, not a fresh
|
|
5
|
+
* array per row: the same answers at a fraction of the time per cell.
|
|
6
|
+
*/
|
|
3
7
|
export function levenshtein(a, b) {
|
|
4
8
|
if (a === b)
|
|
5
9
|
return 0;
|
|
@@ -7,17 +11,31 @@ export function levenshtein(a, b) {
|
|
|
7
11
|
return b.length;
|
|
8
12
|
if (b.length === 0)
|
|
9
13
|
return a.length;
|
|
10
|
-
|
|
14
|
+
const m = b.length;
|
|
15
|
+
// Int32, not Uint32: an unsigned element can exceed 2^31, so the engine
|
|
16
|
+
// reads Uint32 cells as doubles and the table ran four times slower.
|
|
17
|
+
let prev = new Int32Array(m + 1);
|
|
18
|
+
let cur = new Int32Array(m + 1);
|
|
19
|
+
for (let j = 0; j <= m; j++)
|
|
20
|
+
prev[j] = j;
|
|
11
21
|
for (let i = 1; i <= a.length; i++) {
|
|
12
|
-
const cur = new Array(b.length + 1);
|
|
13
22
|
cur[0] = i;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
const ai = a.charCodeAt(i - 1);
|
|
24
|
+
for (let j = 1; j <= m; j++) {
|
|
25
|
+
let best = prev[j - 1] + (ai === b.charCodeAt(j - 1) ? 0 : 1);
|
|
26
|
+
const insert = cur[j - 1] + 1;
|
|
27
|
+
if (insert < best)
|
|
28
|
+
best = insert;
|
|
29
|
+
const remove = prev[j] + 1;
|
|
30
|
+
if (remove < best)
|
|
31
|
+
best = remove;
|
|
32
|
+
cur[j] = best;
|
|
17
33
|
}
|
|
34
|
+
const done = prev;
|
|
18
35
|
prev = cur;
|
|
36
|
+
cur = done;
|
|
19
37
|
}
|
|
20
|
-
return prev[
|
|
38
|
+
return prev[m];
|
|
21
39
|
}
|
|
22
40
|
/** Edit distance expressed as a 0..1 similarity. */
|
|
23
41
|
export function levenshteinRatio(a, b) {
|
|
@@ -33,17 +51,20 @@ export function jaroWinkler(a, b) {
|
|
|
33
51
|
if (a.length === 0 || b.length === 0)
|
|
34
52
|
return 0;
|
|
35
53
|
const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
// Typed flags and code units: the same answers, at a fraction of the time
|
|
55
|
+
// per window step.
|
|
56
|
+
const aFlags = new Uint8Array(a.length);
|
|
57
|
+
const bFlags = new Uint8Array(b.length);
|
|
38
58
|
let matches = 0;
|
|
39
59
|
for (let i = 0; i < a.length; i++) {
|
|
40
60
|
const lo = Math.max(0, i - window);
|
|
41
61
|
const hi = Math.min(b.length - 1, i + window);
|
|
62
|
+
const ai = a.charCodeAt(i);
|
|
42
63
|
for (let j = lo; j <= hi; j++) {
|
|
43
|
-
if (bFlags[j] ===
|
|
64
|
+
if (bFlags[j] === 1 || ai !== b.charCodeAt(j))
|
|
44
65
|
continue;
|
|
45
|
-
aFlags[i] =
|
|
46
|
-
bFlags[j] =
|
|
66
|
+
aFlags[i] = 1;
|
|
67
|
+
bFlags[j] = 1;
|
|
47
68
|
matches++;
|
|
48
69
|
break;
|
|
49
70
|
}
|
|
@@ -53,11 +74,11 @@ export function jaroWinkler(a, b) {
|
|
|
53
74
|
let transpositions = 0;
|
|
54
75
|
let k = 0;
|
|
55
76
|
for (let i = 0; i < a.length; i++) {
|
|
56
|
-
if (aFlags[i] !==
|
|
77
|
+
if (aFlags[i] !== 1)
|
|
57
78
|
continue;
|
|
58
|
-
while (bFlags[k] !==
|
|
79
|
+
while (bFlags[k] !== 1)
|
|
59
80
|
k++;
|
|
60
|
-
if (a
|
|
81
|
+
if (a.charCodeAt(i) !== b.charCodeAt(k))
|
|
61
82
|
transpositions++;
|
|
62
83
|
k++;
|
|
63
84
|
}
|
|
@@ -86,6 +107,37 @@ export function jaccard(a, b) {
|
|
|
86
107
|
const union = a.size + b.size - intersection;
|
|
87
108
|
return union === 0 ? 0 : intersection / union;
|
|
88
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The most work one Levenshtein or Jaro comparison may do, in Levenshtein
|
|
112
|
+
* cells: about a second of one thread. Both methods are O(n x m) and run
|
|
113
|
+
* synchronously on the harness thread, so two 20,000-character strings
|
|
114
|
+
* stalled every session in the process; this refuses those and still
|
|
115
|
+
* answers the sizes 0.7.0 answered in a fraction of a second.
|
|
116
|
+
*/
|
|
117
|
+
export const MAX_SIMILARITY_WORK = 200_000_000;
|
|
118
|
+
/**
|
|
119
|
+
* A Jaro cell (one step of the matching window) costs about a quarter of a
|
|
120
|
+
* Levenshtein cell, measured: 25 M of them take 30-40 ms where 25 M
|
|
121
|
+
* Levenshtein cells take over 100 ms.
|
|
122
|
+
*/
|
|
123
|
+
const JARO_CELLS_PER_UNIT = 4;
|
|
124
|
+
/**
|
|
125
|
+
* The work one comparison does, in Levenshtein cells: the DP table for
|
|
126
|
+
* Levenshtein, the matching window for Jaro at its measured discount.
|
|
127
|
+
* Trigram and token overlap are linear, so they cost nothing against the
|
|
128
|
+
* quadratic budget.
|
|
129
|
+
*/
|
|
130
|
+
export function similarityCost(a, b, method) {
|
|
131
|
+
if (a === b || a.length === 0 || b.length === 0)
|
|
132
|
+
return 0;
|
|
133
|
+
if (method === "levenshtein")
|
|
134
|
+
return a.length * b.length;
|
|
135
|
+
if (method === "jaro") {
|
|
136
|
+
const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
|
|
137
|
+
return Math.ceil((a.length * Math.min(b.length, 2 * window + 1)) / JARO_CELLS_PER_UNIT);
|
|
138
|
+
}
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
89
141
|
export function similarity(a, b, method) {
|
|
90
142
|
if (method === "levenshtein")
|
|
91
143
|
return levenshteinRatio(a, b);
|