@firedrill-tools/hubspot 0.1.1

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.
Files changed (36) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +263 -0
  3. package/firedrill/agent.target.json +17 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/conformance.suite.json +20 -0
  6. package/firedrill/hubspot-deactivated-user.drill.json +53 -0
  7. package/firedrill/hubspot-denied.drill.json +74 -0
  8. package/firedrill/hubspot-fresh-actor.drill.json +128 -0
  9. package/firedrill/hubspot-invalid-auth.drill.json +433 -0
  10. package/firedrill/hubspot-mcp-aliases.drill.json +265 -0
  11. package/firedrill/hubspot-no-scopes.drill.json +433 -0
  12. package/firedrill/hubspot-owned-visibility.drill.json +113 -0
  13. package/firedrill/hubspot-rate-limited.drill.json +446 -0
  14. package/firedrill/hubspot-rest-flow.drill.json +963 -0
  15. package/firedrill/hubspot-scopes.drill.json +166 -0
  16. package/firedrill/hubspot-size-bounds.drill.json +156 -0
  17. package/firedrill/hubspot-write-committed-lost.drill.json +166 -0
  18. package/firedrill/hubspot-write-unavailable.drill.json +292 -0
  19. package/firedrill/rate-limited.scenario.json +11 -0
  20. package/firedrill/tools/hubspot/behavior.mjs +1318 -0
  21. package/firedrill/tools/hubspot/hubspot.tool.json +5524 -0
  22. package/firedrill/tools/hubspot/lib/bytes.mjs +32 -0
  23. package/firedrill/tools/hubspot/lib/json-depth.mjs +17 -0
  24. package/firedrill/tools/hubspot/lib/object-types.mjs +47 -0
  25. package/firedrill/tools/hubspot/lib/properties.mjs +275 -0
  26. package/firedrill/tools/hubspot/lib/search-matchers.mjs +85 -0
  27. package/firedrill/tools/hubspot/lib/search.mjs +301 -0
  28. package/firedrill/tools/hubspot/lib/state.mjs +248 -0
  29. package/firedrill/tools/hubspot/lib/wire.mjs +125 -0
  30. package/firedrill/world.json +3128 -0
  31. package/firedrill/write-committed-lost.scenario.json +11 -0
  32. package/firedrill/write-unavailable.scenario.json +11 -0
  33. package/firedrill.json +5 -0
  34. package/package.json +64 -0
  35. package/starter.json +2540 -0
  36. package/test/conformance.mjs +1007 -0
@@ -0,0 +1,32 @@
1
+ // Response byte budgets. The framework refuses HTTP responses above 1 MiB, so pages are filled by the UTF-8 size of
2
+ // their encoded JSON (counted from code points, never from UTF-16 string length) and single or batch responses that
3
+ // would not fit fail with a declared error before anything commits.
4
+
5
+ /** Pages stop filling before the encoded body would pass this many UTF-8 bytes (the framework cap is 1,048,576). */
6
+ export const RESPONSE_BYTE_BUDGET = 900_000;
7
+
8
+ /** Bytes reserved on a page for the envelope (`{"results":[…]}`, `total`, `paging.next` with its link). */
9
+ export const PAGE_ENVELOPE_BYTES = 512;
10
+
11
+ /** UTF-8 byte length of a string: 1–3 bytes per BMP code unit, 4 per surrogate pair, 3 for a lone surrogate. */
12
+ export function utf8Length(text) {
13
+ let bytes = 0;
14
+ for (let index = 0; index < text.length; index += 1) {
15
+ const unit = text.charCodeAt(index);
16
+ if (unit < 0x80) bytes += 1;
17
+ else if (unit < 0x800) bytes += 2;
18
+ else if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < text.length) {
19
+ const next = text.charCodeAt(index + 1);
20
+ if (next >= 0xdc00 && next <= 0xdfff) {
21
+ bytes += 4;
22
+ index += 1;
23
+ } else bytes += 3;
24
+ } else bytes += 3;
25
+ }
26
+ return bytes;
27
+ }
28
+
29
+ /** UTF-8 size of a value's JSON encoding (the shape the HTTP route sends). */
30
+ export function jsonBytes(value) {
31
+ return utf8Length(JSON.stringify(value));
32
+ }
@@ -0,0 +1,17 @@
1
+ // Iterative JSON nesting-depth guard for HTTP codecs. A body nested past the framework's recursive argument
2
+ // validation would otherwise answer an opaque 500, so every JSON route's decode rejects bodies deeper than
3
+ // MAX_JSON_DEPTH before the request reaches validation. An explicit stack, never recursion.
4
+
5
+ export const MAX_JSON_DEPTH = 512;
6
+
7
+ /** Throws a TypeError when `value` nests objects or arrays more than `max` levels deep. */
8
+ export function assertJsonDepth(value, max = MAX_JSON_DEPTH) {
9
+ if (value === null || typeof value !== "object") return;
10
+ const stack = [[value, 1]];
11
+ while (stack.length > 0) {
12
+ const [node, depth] = stack.pop();
13
+ if (depth > max) throw new TypeError(`request body is nested more than ${String(max)} levels deep`);
14
+ const children = Array.isArray(node) ? node : Object.values(node);
15
+ for (const child of children) if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
16
+ }
17
+ }
@@ -0,0 +1,47 @@
1
+ // CRM object types in scope and their HubSpot object-type ids. Pure constants and lookups only.
2
+
3
+ export const OBJECT_TYPES = Object.freeze([
4
+ Object.freeze({ name: "contacts", singular: "contact", typeId: "0-1", scope: "contacts", schemaScope: "contacts" }),
5
+ Object.freeze({ name: "companies", singular: "company", typeId: "0-2", scope: "companies", schemaScope: "companies" }),
6
+ Object.freeze({ name: "deals", singular: "deal", typeId: "0-3", scope: "deals", schemaScope: "deals" }),
7
+ Object.freeze({ name: "notes", singular: "note", typeId: "0-46", scope: "contacts", schemaScope: "contacts" }),
8
+ Object.freeze({ name: "tasks", singular: "task", typeId: "0-27", scope: "contacts", schemaScope: "contacts" }),
9
+ ]);
10
+
11
+ /** `contacts` | `contact` | `0-1` (case-insensitive) → the type definition, or undefined. */
12
+ export function resolveObjectType(value) {
13
+ if (typeof value !== "string") return undefined;
14
+ const needle = value.trim().toLowerCase();
15
+ return OBJECT_TYPES.find((type) => type.name === needle || type.singular === needle || type.typeId === needle);
16
+ }
17
+
18
+ export function objectTypeById(typeId) {
19
+ return OBJECT_TYPES.find((type) => type.typeId === typeId);
20
+ }
21
+
22
+ export function readScope(type) {
23
+ return `crm.objects.${type.scope}.read`;
24
+ }
25
+
26
+ export function writeScope(type) {
27
+ return `crm.objects.${type.scope}.write`;
28
+ }
29
+
30
+ export function schemaReadScope(type) {
31
+ return `crm.schemas.${type.schemaScope}.read`;
32
+ }
33
+
34
+ /** Every scope this Tool knows; an actor without a `scopes` attribute holds all of them. */
35
+ export const ALL_SCOPES = Object.freeze([
36
+ "oauth",
37
+ "crm.objects.contacts.read",
38
+ "crm.objects.contacts.write",
39
+ "crm.objects.companies.read",
40
+ "crm.objects.companies.write",
41
+ "crm.objects.deals.read",
42
+ "crm.objects.deals.write",
43
+ "crm.objects.owners.read",
44
+ "crm.schemas.contacts.read",
45
+ "crm.schemas.companies.read",
46
+ "crm.schemas.deals.read",
47
+ ]);
@@ -0,0 +1,275 @@
1
+ // HubSpot-defined property definitions (the subset agents actually use), default property sets, the
2
+ // searchable properties behind free-text `query`, and value coercion. Custom properties live in the
3
+ // `properties` state namespace and are merged in by the behavior module.
4
+ import { clip, definedAt, normalizeDateTime } from "./state.mjs";
5
+
6
+ function option(value, label) {
7
+ return { label: label ?? value, value, displayOrder: -1, hidden: false };
8
+ }
9
+
10
+ function def(name, label, type, fieldType, groupName, extra = {}) {
11
+ const { options = [], readOnly = false, unique = false, hidden = false, calculated = false, description = "" } = extra;
12
+ return Object.freeze({
13
+ name,
14
+ label,
15
+ type,
16
+ fieldType,
17
+ groupName,
18
+ description,
19
+ options: options.map((entry, index) => ({ ...option(entry.value ?? entry, entry.label), displayOrder: index })),
20
+ displayOrder: -1,
21
+ hasUniqueValue: unique,
22
+ hidden,
23
+ formField: !readOnly && !hidden,
24
+ calculated,
25
+ externalOptions: false,
26
+ archived: false,
27
+ hubspotDefined: true,
28
+ modificationMetadata: { archivable: false, readOnlyDefinition: true, readOnlyValue: readOnly },
29
+ createdAt: definedAt(),
30
+ updatedAt: definedAt(),
31
+ });
32
+ }
33
+
34
+ const LIFECYCLE_STAGES = [
35
+ { value: "subscriber", label: "Subscriber" },
36
+ { value: "lead", label: "Lead" },
37
+ { value: "marketingqualifiedlead", label: "Marketing Qualified Lead" },
38
+ { value: "salesqualifiedlead", label: "Sales Qualified Lead" },
39
+ { value: "opportunity", label: "Opportunity" },
40
+ { value: "customer", label: "Customer" },
41
+ { value: "evangelist", label: "Evangelist" },
42
+ { value: "other", label: "Other" },
43
+ ];
44
+
45
+ const LEAD_STATUSES = [
46
+ { value: "NEW", label: "New" },
47
+ { value: "OPEN", label: "Open" },
48
+ { value: "IN_PROGRESS", label: "In progress" },
49
+ { value: "OPEN_DEAL", label: "Open deal" },
50
+ { value: "UNQUALIFIED", label: "Unqualified" },
51
+ { value: "ATTEMPTED_TO_CONTACT", label: "Attempted to contact" },
52
+ { value: "CONNECTED", label: "Connected" },
53
+ { value: "BAD_TIMING", label: "Bad timing" },
54
+ ];
55
+
56
+ const CONTACT = "contactinformation";
57
+ const COMPANY = "companyinformation";
58
+ const DEAL = "dealinformation";
59
+
60
+ const CONTACT_PROPERTIES = [
61
+ def("email", "Email", "string", "text", CONTACT, { unique: true, description: "A contact's email address" }),
62
+ def("firstname", "First Name", "string", "text", CONTACT),
63
+ def("lastname", "Last Name", "string", "text", CONTACT),
64
+ def("phone", "Phone Number", "string", "phonenumber", CONTACT),
65
+ def("mobilephone", "Mobile Phone Number", "string", "phonenumber", CONTACT),
66
+ def("company", "Company Name", "string", "text", CONTACT),
67
+ def("jobtitle", "Job Title", "string", "text", CONTACT),
68
+ def("website", "Website URL", "string", "text", CONTACT),
69
+ def("city", "City", "string", "text", CONTACT),
70
+ def("state", "State/Region", "string", "text", CONTACT),
71
+ def("country", "Country/Region", "string", "text", CONTACT),
72
+ def("lifecyclestage", "Lifecycle Stage", "enumeration", "radio", CONTACT, { options: LIFECYCLE_STAGES }),
73
+ def("hs_lead_status", "Lead Status", "enumeration", "radio", CONTACT, { options: LEAD_STATUSES }),
74
+ def("hubspot_owner_id", "Contact owner", "enumeration", "select", CONTACT, { description: "The owner of the contact" }),
75
+ def("hubspot_owner_assigneddate", "Owner Assigned Date", "datetime", "date", CONTACT, { readOnly: true, calculated: true }),
76
+ def("hs_object_id", "Record ID", "number", "number", CONTACT, { readOnly: true, calculated: true }),
77
+ def("createdate", "Create Date", "datetime", "date", CONTACT, { readOnly: true }),
78
+ def("lastmodifieddate", "Last Modified Date", "datetime", "date", CONTACT, { readOnly: true, calculated: true }),
79
+ ];
80
+
81
+ const COMPANY_PROPERTIES = [
82
+ def("name", "Name", "string", "text", COMPANY),
83
+ def("domain", "Company Domain Name", "string", "text", COMPANY),
84
+ def("industry", "Industry", "string", "text", COMPANY),
85
+ def("description", "Description", "string", "textarea", COMPANY),
86
+ def("phone", "Phone Number", "string", "phonenumber", COMPANY),
87
+ def("website", "Website URL", "string", "text", COMPANY),
88
+ def("city", "City", "string", "text", COMPANY),
89
+ def("state", "State/Region", "string", "text", COMPANY),
90
+ def("country", "Country/Region", "string", "text", COMPANY),
91
+ def("numberofemployees", "Number of Employees", "number", "number", COMPANY),
92
+ def("annualrevenue", "Annual Revenue", "number", "number", COMPANY),
93
+ def("lifecyclestage", "Lifecycle Stage", "enumeration", "radio", COMPANY, { options: LIFECYCLE_STAGES }),
94
+ def("type", "Type", "enumeration", "select", COMPANY, {
95
+ options: [
96
+ { value: "PROSPECT", label: "Prospect" },
97
+ { value: "PARTNER", label: "Partner" },
98
+ { value: "RESELLER", label: "Reseller" },
99
+ { value: "VENDOR", label: "Vendor" },
100
+ { value: "OTHER", label: "Other" },
101
+ ],
102
+ }),
103
+ def("hubspot_owner_id", "Company owner", "enumeration", "select", COMPANY),
104
+ def("hs_object_id", "Record ID", "number", "number", COMPANY, { readOnly: true, calculated: true }),
105
+ def("createdate", "Create Date", "datetime", "date", COMPANY, { readOnly: true }),
106
+ def("hs_lastmodifieddate", "Last Modified Date", "datetime", "date", COMPANY, { readOnly: true, calculated: true }),
107
+ ];
108
+
109
+ const DEAL_PROPERTIES = [
110
+ def("dealname", "Deal Name", "string", "text", DEAL),
111
+ def("amount", "Amount", "number", "number", DEAL),
112
+ def("dealstage", "Deal Stage", "enumeration", "radio", DEAL),
113
+ def("pipeline", "Pipeline", "enumeration", "radio", DEAL),
114
+ def("closedate", "Close Date", "datetime", "date", DEAL),
115
+ def("dealtype", "Deal Type", "enumeration", "radio", DEAL, {
116
+ options: [
117
+ { value: "newbusiness", label: "New Business" },
118
+ { value: "existingbusiness", label: "Existing Business" },
119
+ ],
120
+ }),
121
+ def("description", "Deal Description", "string", "textarea", DEAL),
122
+ def("hubspot_owner_id", "Deal owner", "enumeration", "select", DEAL),
123
+ def("hs_object_id", "Record ID", "number", "number", DEAL, { readOnly: true, calculated: true }),
124
+ def("createdate", "Create Date", "datetime", "date", DEAL, { readOnly: true }),
125
+ def("hs_lastmodifieddate", "Last Modified Date", "datetime", "date", DEAL, { readOnly: true, calculated: true }),
126
+ def("hs_is_closed", "Is Deal Closed?", "bool", "booleancheckbox", DEAL, { readOnly: true, calculated: true }),
127
+ def("hs_is_closed_won", "Is Closed Won", "bool", "booleancheckbox", DEAL, { readOnly: true, calculated: true }),
128
+ def("hs_deal_stage_probability", "Deal probability", "number", "number", DEAL, { readOnly: true, calculated: true }),
129
+ ];
130
+
131
+ const NOTE_PROPERTIES = [
132
+ def("hs_note_body", "Note body", "string", "html", "note"),
133
+ def("hs_timestamp", "Activity date", "datetime", "date", "note"),
134
+ def("hubspot_owner_id", "Activity assigned to", "enumeration", "select", "note"),
135
+ def("hs_attachment_ids", "Attached file IDs", "enumeration", "checkbox", "note"),
136
+ def("hs_object_id", "Record ID", "number", "number", "note", { readOnly: true, calculated: true }),
137
+ def("hs_createdate", "Create date", "datetime", "date", "note", { readOnly: true }),
138
+ def("hs_lastmodifieddate", "Last modified date", "datetime", "date", "note", { readOnly: true, calculated: true }),
139
+ ];
140
+
141
+ const TASK_PROPERTIES = [
142
+ def("hs_task_subject", "Task Title", "string", "text", "task"),
143
+ def("hs_task_body", "Notes", "string", "html", "task"),
144
+ def("hs_task_status", "Task Status", "enumeration", "select", "task", {
145
+ options: [
146
+ { value: "NOT_STARTED", label: "Not started" },
147
+ { value: "IN_PROGRESS", label: "In progress" },
148
+ { value: "WAITING", label: "Waiting on contact" },
149
+ { value: "COMPLETED", label: "Completed" },
150
+ { value: "DEFERRED", label: "Deferred" },
151
+ ],
152
+ }),
153
+ def("hs_task_priority", "Priority", "enumeration", "select", "task", {
154
+ options: [
155
+ { value: "LOW", label: "Low" },
156
+ { value: "MEDIUM", label: "Medium" },
157
+ { value: "HIGH", label: "High" },
158
+ ],
159
+ }),
160
+ def("hs_task_type", "Task Type", "enumeration", "select", "task", {
161
+ options: [
162
+ { value: "TODO", label: "To-do" },
163
+ { value: "EMAIL", label: "Email" },
164
+ { value: "CALL", label: "Call" },
165
+ ],
166
+ }),
167
+ def("hs_timestamp", "Due date", "datetime", "date", "task"),
168
+ def("hubspot_owner_id", "Assigned to", "enumeration", "select", "task"),
169
+ def("hs_object_id", "Record ID", "number", "number", "task", { readOnly: true, calculated: true }),
170
+ def("hs_createdate", "Create date", "datetime", "date", "task", { readOnly: true }),
171
+ def("hs_lastmodifieddate", "Last modified date", "datetime", "date", "task", { readOnly: true, calculated: true }),
172
+ ];
173
+
174
+ function indexed(list) {
175
+ const map = new Map();
176
+ list.forEach((definition, index) => map.set(definition.name, Object.freeze({ ...definition, displayOrder: index })));
177
+ return map;
178
+ }
179
+
180
+ /** HubSpot-defined property definitions per object type (`Map<name, definition>`). */
181
+ export const DEFINED_PROPERTIES = Object.freeze({
182
+ contacts: indexed(CONTACT_PROPERTIES),
183
+ companies: indexed(COMPANY_PROPERTIES),
184
+ deals: indexed(DEAL_PROPERTIES),
185
+ notes: indexed(NOTE_PROPERTIES),
186
+ tasks: indexed(TASK_PROPERTIES),
187
+ });
188
+
189
+ /** Properties returned when a request names none. */
190
+ export const DEFAULT_PROPERTIES = Object.freeze({
191
+ contacts: ["createdate", "email", "firstname", "hs_object_id", "lastmodifieddate", "lastname"],
192
+ companies: ["createdate", "domain", "hs_lastmodifieddate", "hs_object_id", "name"],
193
+ deals: ["amount", "closedate", "createdate", "dealname", "dealstage", "hs_lastmodifieddate", "hs_object_id", "pipeline"],
194
+ notes: ["hs_createdate", "hs_lastmodifieddate", "hs_object_id"],
195
+ tasks: ["hs_createdate", "hs_lastmodifieddate", "hs_object_id"],
196
+ });
197
+
198
+ /** Properties matched by the free-text `query` of a search. */
199
+ export const SEARCHABLE_PROPERTIES = Object.freeze({
200
+ contacts: ["firstname", "lastname", "email", "phone", "company", "hs_object_id"],
201
+ companies: ["name", "domain", "website", "phone"],
202
+ deals: ["dealname"],
203
+ notes: ["hs_note_body"],
204
+ tasks: ["hs_task_subject", "hs_task_body"],
205
+ });
206
+
207
+ /** Timestamp property names per object type. */
208
+ export const TIMESTAMPS = Object.freeze({
209
+ contacts: { created: "createdate", modified: "lastmodifieddate" },
210
+ companies: { created: "createdate", modified: "hs_lastmodifieddate" },
211
+ deals: { created: "createdate", modified: "hs_lastmodifieddate" },
212
+ notes: { created: "hs_createdate", modified: "hs_lastmodifieddate" },
213
+ tasks: { created: "hs_createdate", modified: "hs_lastmodifieddate" },
214
+ });
215
+
216
+ /** Properties a create must carry. */
217
+ export const REQUIRED_ON_CREATE = Object.freeze({
218
+ contacts: [],
219
+ companies: [],
220
+ deals: ["dealstage"],
221
+ notes: ["hs_timestamp"],
222
+ tasks: ["hs_timestamp"],
223
+ });
224
+
225
+ // Unambiguous (no nested or adjacent overlapping quantifiers), so a long non-numeric digit run fails in linear time.
226
+ const NUMBER = /^-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?$/;
227
+
228
+ /** HubSpot's maximum length of a single property value (characters); longer values fail with INVALID_LENGTH. */
229
+ export const MAX_PROPERTY_VALUE = 65_536;
230
+
231
+ /**
232
+ * Coerce one incoming property value to its stored string form. Returns `{ value }` or `{ error }`
233
+ * (the error names HubSpot's validation reason). `null` always clears the property.
234
+ */
235
+ export function coerceValue(definition, raw) {
236
+ if (raw === null) return { value: null };
237
+ if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "boolean") {
238
+ return { error: `Property "${definition.name}" must be a string, number, boolean or null`, code: "INVALID_TYPE" };
239
+ }
240
+ const text = String(raw);
241
+ if (text.length > MAX_PROPERTY_VALUE) {
242
+ return { error: `Property "${definition.name}" value has ${text.length} characters, above the maximum of ${MAX_PROPERTY_VALUE}`, code: "INVALID_LENGTH" };
243
+ }
244
+ switch (definition.type) {
245
+ case "number": {
246
+ if (!NUMBER.test(text.trim()) || !Number.isFinite(Number(text))) {
247
+ return { error: `${clip(JSON.stringify(text))} was not a valid number for property "${definition.name}"`, code: "INVALID_NUMBER" };
248
+ }
249
+ return { value: text.trim() };
250
+ }
251
+ case "datetime":
252
+ case "date": {
253
+ const iso = normalizeDateTime(text.trim());
254
+ if (iso === undefined) {
255
+ return { error: `${clip(JSON.stringify(text))} was not a valid date or datetime for property "${definition.name}"`, code: "INVALID_DATE" };
256
+ }
257
+ return { value: iso };
258
+ }
259
+ case "bool": {
260
+ const lowered = text.trim().toLowerCase();
261
+ if (lowered !== "true" && lowered !== "false") {
262
+ return { error: `${clip(JSON.stringify(text))} was not a valid boolean for property "${definition.name}"`, code: "INVALID_OPTION" };
263
+ }
264
+ return { value: lowered };
265
+ }
266
+ case "enumeration": {
267
+ if (definition.options.length > 0 && !definition.options.some((entry) => entry.value === text)) {
268
+ return { error: `${clip(JSON.stringify(text))} was not one of the allowed options for property "${definition.name}"`, code: "INVALID_OPTION" };
269
+ }
270
+ return { value: text };
271
+ }
272
+ default:
273
+ return { value: text };
274
+ }
275
+ }
@@ -0,0 +1,85 @@
1
+ // Linear-time matching primitives for CRM search: KMP substring search for wildcard segments and a bounded
2
+ // work budget shared by one search request. Pure functions; no regular expression is built from caller text.
3
+
4
+ /**
5
+ * Upper bound on matching work for one search request, in character units: every property value folded or
6
+ * tokenised, every token scan of a CONTAINS_TOKEN filter and every free-text lookup is charged. A search that would
7
+ * exceed it fails with VALIDATION_ERROR instead of blocking the server.
8
+ */
9
+ export const MAX_MATCH_WORK = 40_000_000;
10
+
11
+ export function createWorkBudget(limit = MAX_MATCH_WORK) {
12
+ return { limit, remaining: limit, exceeded: false };
13
+ }
14
+
15
+ /** Charge `units` of work; returns false (and marks the budget) once it is exhausted. */
16
+ export function charge(budget, units) {
17
+ budget.remaining -= units;
18
+ if (budget.remaining < 0) budget.exceeded = true;
19
+ return !budget.exceeded;
20
+ }
21
+
22
+ function failureTable(pattern) {
23
+ const table = new Int32Array(pattern.length);
24
+ let k = 0;
25
+ for (let index = 1; index < pattern.length; index += 1) {
26
+ const unit = pattern.charCodeAt(index);
27
+ while (k > 0 && unit !== pattern.charCodeAt(k)) k = table[k - 1];
28
+ if (unit === pattern.charCodeAt(k)) k += 1;
29
+ table[index] = k;
30
+ }
31
+ return table;
32
+ }
33
+
34
+ /** First index ≥ `from` where `pattern` occurs entirely before `end`, or -1. Never re-reads text (O(end - from)). */
35
+ function kmpIndexOf(text, pattern, table, from, end) {
36
+ let k = 0;
37
+ for (let index = from; index < end; index += 1) {
38
+ const unit = text.charCodeAt(index);
39
+ while (k > 0 && unit !== pattern.charCodeAt(k)) k = table[k - 1];
40
+ if (unit === pattern.charCodeAt(k)) k += 1;
41
+ if (k === pattern.length) return index - pattern.length + 1;
42
+ }
43
+ return -1;
44
+ }
45
+
46
+ /**
47
+ * Compile a CONTAINS_TOKEN value (`*` = any run of characters) into a whole-token matcher over lower-cased tokens.
48
+ * The first and last literal segments are anchored; the middle segments are located left to right with KMP (the
49
+ * leftmost occurrence is always a valid choice for a glob). Each segment search resumes where the previous one
50
+ * ended and KMP never moves backwards in the token, so one call costs O(token length + pattern length).
51
+ */
52
+ export function globMatcher(value) {
53
+ const segments = String(value).toLowerCase().split("*");
54
+ if (segments.length === 1) return (token) => token === segments[0];
55
+ const first = segments[0];
56
+ const last = segments[segments.length - 1];
57
+ const middle = segments
58
+ .slice(1, -1)
59
+ .filter((segment) => segment.length > 0)
60
+ .map((segment) => ({ segment, table: failureTable(segment) }));
61
+ const anchored = first.length + last.length + middle.reduce((sum, entry) => sum + entry.segment.length, 0);
62
+ return (token) => {
63
+ if (token.length < anchored || !token.startsWith(first) || !token.endsWith(last)) return false;
64
+ let position = first.length;
65
+ const end = token.length - last.length;
66
+ for (const { segment, table } of middle) {
67
+ const found = kmpIndexOf(token, segment, table, position, end);
68
+ if (found === -1) return false;
69
+ position = found + segment.length;
70
+ }
71
+ return true;
72
+ };
73
+ }
74
+
75
+ /** Index of the first entry of a sorted string array that is ≥ `needle` (code-unit order). */
76
+ export function lowerBound(sorted, needle) {
77
+ let low = 0;
78
+ let high = sorted.length;
79
+ while (low < high) {
80
+ const middle = (low + high) >>> 1;
81
+ if (sorted[middle] < needle) low = middle + 1;
82
+ else high = middle;
83
+ }
84
+ return low;
85
+ }