@crewhaus/tool-flow 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 +39 -30
- package/dist/index.d.ts +6 -0
- package/dist/index.js +52 -16
- package/dist/lib/assign.d.ts +4 -2
- package/dist/lib/assign.js +15 -4
- package/dist/lib/branch.d.ts +11 -1
- package/dist/lib/branch.js +24 -4
- package/dist/lib/consensus.js +86 -9
- package/dist/lib/decision.d.ts +9 -2
- package/dist/lib/decision.js +26 -5
- package/dist/lib/errors.d.ts +25 -4
- package/dist/lib/errors.js +70 -16
- package/dist/lib/http-date.d.ts +30 -0
- package/dist/lib/http-date.js +175 -0
- package/dist/lib/score.d.ts +16 -14
- package/dist/lib/score.js +55 -11
- package/dist/lib/sequence.d.ts +10 -2
- package/dist/lib/sequence.js +29 -12
- package/package.json +12 -7
package/dist/lib/consensus.js
CHANGED
|
@@ -24,13 +24,17 @@ export function normalizeValue(value, options = {}) {
|
|
|
24
24
|
}
|
|
25
25
|
/** Jaccard similarity of two arrays treated as sets of stringified members. */
|
|
26
26
|
export function jaccard(a, b) {
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
return jaccardOfSets(new Set(a.map(canonicalize)), new Set(b.map(canonicalize)));
|
|
28
|
+
}
|
|
29
|
+
/** {@link jaccard} of two arrays already turned into sets of stringified members. */
|
|
30
|
+
function jaccardOfSets(left, right) {
|
|
29
31
|
if (left.size === 0 && right.size === 0)
|
|
30
32
|
return 1;
|
|
33
|
+
// The shared count is the same whichever side is walked; walk the smaller.
|
|
34
|
+
const [small, large] = left.size <= right.size ? [left, right] : [right, left];
|
|
31
35
|
let shared = 0;
|
|
32
|
-
for (const item of
|
|
33
|
-
if (
|
|
36
|
+
for (const item of small)
|
|
37
|
+
if (large.has(item))
|
|
34
38
|
shared += 1;
|
|
35
39
|
return shared / (left.size + right.size - shared);
|
|
36
40
|
}
|
|
@@ -56,6 +60,71 @@ export function tallyVotes(votes, options = {}) {
|
|
|
56
60
|
throw new Error("no votes were given");
|
|
57
61
|
const groups = [];
|
|
58
62
|
let total = 0;
|
|
63
|
+
const mode = options.mode ?? "exact";
|
|
64
|
+
const overlap = options.overlap ?? 1;
|
|
65
|
+
// Groups by canonical form, where "the same answer" is equality of that
|
|
66
|
+
// form: `exact`, and `set` at overlap 1, where a Jaccard of 1 means equal
|
|
67
|
+
// sets. Each vote then finds its group by one lookup, where comparing it
|
|
68
|
+
// with every group canonicalized both sides each time: 512 votes of a
|
|
69
|
+
// kilobyte made 270 million characters of JSON.
|
|
70
|
+
const byKey = new Map();
|
|
71
|
+
/** `set`: the first group whose value is an array, the only kind a set vote can join. */
|
|
72
|
+
const arrays = {};
|
|
73
|
+
const membersOf = (value) => new Set(value.map(canonicalize));
|
|
74
|
+
/** The canonical form of a set vote at overlap 1: its distinct members, sorted. */
|
|
75
|
+
const setKey = (members) => JSON.stringify([...members].sort());
|
|
76
|
+
/**
|
|
77
|
+
* The first group `value` counts as the same answer as, or undefined —
|
|
78
|
+
* what `groups.find((g) => same(g.value, value, options))` returns, with
|
|
79
|
+
* each value's canonical form or members worked out once, at the point the
|
|
80
|
+
* pairwise comparison first needed them, so anything that cannot be
|
|
81
|
+
* canonicalized throws exactly where it did.
|
|
82
|
+
*/
|
|
83
|
+
const firstSame = (value, created) => {
|
|
84
|
+
if (mode === "exact") {
|
|
85
|
+
const first = groups[0];
|
|
86
|
+
if (first === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (first.key === undefined) {
|
|
89
|
+
first.key = canonicalize(first.value);
|
|
90
|
+
byKey.set(first.key, first);
|
|
91
|
+
}
|
|
92
|
+
created.key = canonicalize(value);
|
|
93
|
+
return byKey.get(created.key);
|
|
94
|
+
}
|
|
95
|
+
if (mode === "set") {
|
|
96
|
+
if (!Array.isArray(value))
|
|
97
|
+
return undefined;
|
|
98
|
+
let mine;
|
|
99
|
+
if (overlap === 1) {
|
|
100
|
+
// Only array groups can match; the first of them may not have its
|
|
101
|
+
// members yet, every later one got them when it was created.
|
|
102
|
+
const first = arrays.first;
|
|
103
|
+
if (first === undefined)
|
|
104
|
+
return undefined;
|
|
105
|
+
if (first.members === undefined) {
|
|
106
|
+
first.members = membersOf(first.value);
|
|
107
|
+
first.key = setKey(first.members);
|
|
108
|
+
byKey.set(first.key, first);
|
|
109
|
+
}
|
|
110
|
+
mine = membersOf(value);
|
|
111
|
+
created.members = mine;
|
|
112
|
+
created.key = setKey(mine);
|
|
113
|
+
return byKey.get(created.key);
|
|
114
|
+
}
|
|
115
|
+
for (const g of groups) {
|
|
116
|
+
if (!Array.isArray(g.value))
|
|
117
|
+
continue;
|
|
118
|
+
g.members ??= membersOf(g.value);
|
|
119
|
+
mine ??= membersOf(value);
|
|
120
|
+
created.members = mine;
|
|
121
|
+
if (jaccardOfSets(g.members, mine) >= overlap)
|
|
122
|
+
return g;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return groups.find((g) => same(g.value, value, options));
|
|
127
|
+
};
|
|
59
128
|
for (const [index, vote] of votes.entries()) {
|
|
60
129
|
const weight = vote.weight ?? 1;
|
|
61
130
|
if (!Number.isFinite(weight) || weight < 0) {
|
|
@@ -65,16 +134,24 @@ export function tallyVotes(votes, options = {}) {
|
|
|
65
134
|
total += weight;
|
|
66
135
|
const voter = vote.voter ?? `#${index}`;
|
|
67
136
|
// Grouping is by first-match against existing groups, which is what makes
|
|
68
|
-
// `numeric` and `set` work at all: they are not
|
|
69
|
-
// canonical key to bucket by. Declared order
|
|
70
|
-
// group a borderline value joins, and that is why
|
|
71
|
-
|
|
137
|
+
// `numeric` and `set` below full overlap work at all: they are not
|
|
138
|
+
// transitive, so there is no canonical key to bucket by. Declared order
|
|
139
|
+
// therefore decides which group a borderline value joins, and that is why
|
|
140
|
+
// it is documented. (`exact` and full-overlap `set` are transitive, and
|
|
141
|
+
// `firstSame` buckets them.)
|
|
142
|
+
const created = {};
|
|
143
|
+
const existing = firstSame(value, created);
|
|
72
144
|
if (existing) {
|
|
73
145
|
existing.weight += weight;
|
|
74
146
|
existing.voters.push(voter);
|
|
75
147
|
}
|
|
76
148
|
else {
|
|
77
|
-
|
|
149
|
+
const group = { ...created, value, weight, voters: [voter], order: index };
|
|
150
|
+
groups.push(group);
|
|
151
|
+
if (group.key !== undefined)
|
|
152
|
+
byKey.set(group.key, group);
|
|
153
|
+
if (arrays.first === undefined && Array.isArray(value))
|
|
154
|
+
arrays.first = group;
|
|
78
155
|
}
|
|
79
156
|
}
|
|
80
157
|
const ranked = [...groups].sort((a, b) => b.weight - a.weight || a.order - b.order);
|
package/dist/lib/decision.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Check } from "@crewhaus/tool-schema";
|
|
1
|
+
import { type Check, type RegexAnswers } from "@crewhaus/tool-schema";
|
|
2
2
|
import type { MatchMode } from "./branch";
|
|
3
3
|
export declare const HIT_POLICIES: readonly ["first", "unique", "priority", "collect"];
|
|
4
4
|
export type HitPolicy = (typeof HIT_POLICIES)[number];
|
|
@@ -30,9 +30,16 @@ export type DecisionResult = {
|
|
|
30
30
|
readonly fallback: boolean;
|
|
31
31
|
/** Set when the table could not answer: ambiguity under `unique`/`priority`. */
|
|
32
32
|
readonly conflict: string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Set when a row could not be decided (a pattern had no answer) that could
|
|
35
|
+
* have changed the outcome: no outputs are given, not even `otherwise`'s.
|
|
36
|
+
*/
|
|
37
|
+
readonly undetermined?: string;
|
|
33
38
|
/** Stable digest of the table, so a changed policy is visible in a log. */
|
|
34
39
|
readonly tableHash: string;
|
|
35
40
|
};
|
|
36
41
|
/** Digest of the rows and policy — not the version string, which is a label. */
|
|
37
42
|
export declare function hashTable(table: DecisionTable): string;
|
|
38
|
-
export declare function evaluateTable(value: unknown, table: DecisionTable
|
|
43
|
+
export declare function evaluateTable(value: unknown, table: DecisionTable, options?: {
|
|
44
|
+
readonly regex?: RegexAnswers;
|
|
45
|
+
}): DecisionResult;
|
package/dist/lib/decision.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* policy bug that "first wins" hides.
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { runChecks } from "@crewhaus/tool-schema";
|
|
11
|
+
import { checksVerdict, runChecks } from "@crewhaus/tool-schema";
|
|
12
12
|
import { canonicalize } from "./canonical";
|
|
13
13
|
export const HIT_POLICIES = ["first", "unique", "priority", "collect"];
|
|
14
14
|
/** Digest of the rows and policy — not the version string, which is a label. */
|
|
@@ -16,7 +16,7 @@ export function hashTable(table) {
|
|
|
16
16
|
const material = canonicalize({ policy: table.policy, rows: table.rows });
|
|
17
17
|
return createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
18
18
|
}
|
|
19
|
-
export function evaluateTable(value, table) {
|
|
19
|
+
export function evaluateTable(value, table, options = {}) {
|
|
20
20
|
if (table.rows.length === 0)
|
|
21
21
|
throw new Error("the decision table has no rows");
|
|
22
22
|
const seen = new Set();
|
|
@@ -32,10 +32,19 @@ export function evaluateTable(value, table) {
|
|
|
32
32
|
const version = table.version ?? null;
|
|
33
33
|
const base = { policy: table.policy, version, tableHash };
|
|
34
34
|
const matches = [];
|
|
35
|
+
const undecided = [];
|
|
35
36
|
for (const row of table.rows) {
|
|
36
|
-
const report = runChecks(value, row.when);
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
37
|
+
const report = runChecks(value, row.when, options.regex === undefined ? {} : { regex: options.regex });
|
|
38
|
+
const verdict = checksVerdict(report, row.match ?? "all");
|
|
39
|
+
if (verdict === "undetermined") {
|
|
40
|
+
const why = report.failures.find((f) => f.undetermined === true)?.reason ?? "";
|
|
41
|
+
undecided.push(`row "${row.id}": ${why}`);
|
|
42
|
+
// Under `first`, a later row cannot be taken past one not decided.
|
|
43
|
+
if (table.policy === "first")
|
|
44
|
+
break;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (verdict === "pass") {
|
|
39
48
|
matches.push(row);
|
|
40
49
|
// `first` needs no further rows; the others need to see them all.
|
|
41
50
|
if (table.policy === "first")
|
|
@@ -43,6 +52,18 @@ export function evaluateTable(value, table) {
|
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
const matchedIds = matches.map((r) => r.id);
|
|
55
|
+
if (undecided.length > 0) {
|
|
56
|
+
return {
|
|
57
|
+
...base,
|
|
58
|
+
ok: false,
|
|
59
|
+
matched: false,
|
|
60
|
+
matchedIds,
|
|
61
|
+
outputs: null,
|
|
62
|
+
fallback: false,
|
|
63
|
+
conflict: null,
|
|
64
|
+
undetermined: `could not decide ${undecided.join("; ")}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
46
67
|
if (matches.length === 0) {
|
|
47
68
|
if (table.otherwise) {
|
|
48
69
|
return {
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* called `Date.now()` would return a different answer every second, which is
|
|
15
15
|
* exactly what this package promises not to do.
|
|
16
16
|
*/
|
|
17
|
+
import { type RegexAnswers } from "@crewhaus/tool-schema";
|
|
17
18
|
export declare const ERROR_CLASSES: readonly ["ok", "transient", "timeout", "unavailable", "rate_limited", "quota", "auth", "permission", "not_found", "bad_request", "conflict", "too_large", "unsupported", "capacity", "integrity", "cancelled", "redirect", "bug", "unknown"];
|
|
18
19
|
export type ErrorClass = (typeof ERROR_CLASSES)[number];
|
|
19
20
|
export declare const NEXT_ACTIONS: readonly ["continue", "retry", "retry_after", "backoff", "reauth", "reduce_input", "escalate", "skip", "fail"];
|
|
@@ -56,22 +57,42 @@ export type Classification = {
|
|
|
56
57
|
readonly retryAt: string | null;
|
|
57
58
|
/** Which rule decided this: a pack entry's id, or a caller rule's id. */
|
|
58
59
|
readonly matched: string;
|
|
59
|
-
readonly source: "custom" | "status" | "exit" | "signal" | "code" | "message" | "default";
|
|
60
|
+
readonly source: "custom" | "status" | "exit" | "signal" | "code" | "message" | "default" | "undetermined";
|
|
60
61
|
/** True when attempts ran out, which downgrades a retry to an escalation. */
|
|
61
62
|
readonly exhausted: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Set when a caller rule tried before any match could not be decided (its
|
|
65
|
+
* pattern had no answer): no class is given for it (`unknown`, source
|
|
66
|
+
* `undetermined`) and the action is `escalate`, never a later rule's or a
|
|
67
|
+
* builtin pack's answer, which that rule might have overridden.
|
|
68
|
+
*/
|
|
69
|
+
readonly undetermined?: string;
|
|
62
70
|
};
|
|
63
71
|
/**
|
|
64
|
-
* Parse `Retry-After`. Two forms are legal: delta-seconds, and an HTTP-date
|
|
65
|
-
*
|
|
66
|
-
*
|
|
72
|
+
* Parse `Retry-After`. Two forms are legal: delta-seconds, and an HTTP-date
|
|
73
|
+
* (RFC 9110: IMF-fixdate, or the obsolete rfc850 and asctime forms, all
|
|
74
|
+
* GMT). An RFC 5322 date-time with its zone written (`+0200`, `UTC`,
|
|
75
|
+
* `-0000`), and an ISO-8601 instant with an explicit offset, are read too,
|
|
76
|
+
* because each is unambiguous and 0.7.0 read them. Anything else — a date
|
|
77
|
+
* with no zone, an offset-less ISO string, prose — answers nulls rather than
|
|
78
|
+
* being read as the host's local time. The date form needs a reference point to become a duration, so
|
|
79
|
+
* without `nowMs` it is returned as a date and `waitMs` stays null.
|
|
67
80
|
*/
|
|
68
81
|
export declare function parseRetryAfter(raw: string, nowMs?: number): {
|
|
69
82
|
waitMs: number | null;
|
|
70
83
|
retryAt: string | null;
|
|
71
84
|
};
|
|
85
|
+
/**
|
|
86
|
+
* Ask `regex` about every caller rule's pattern for `signal`, so one
|
|
87
|
+
* resolve in the worker settles them before {@link classifyError} reads
|
|
88
|
+
* them. The fields are the ones {@link classifyError} tests.
|
|
89
|
+
*/
|
|
90
|
+
export declare function askErrorPatterns(signal: ErrorSignal, rules: ReadonlyArray<ErrorRule>, regex: RegexAnswers): void;
|
|
72
91
|
export type ClassifyOptions = {
|
|
73
92
|
readonly rules?: ReadonlyArray<ErrorRule>;
|
|
74
93
|
/** Epoch milliseconds, only used to turn an HTTP-date `Retry-After` into a wait. */
|
|
75
94
|
readonly nowMs?: number;
|
|
95
|
+
/** Answers for the caller rules' `matches` patterns; see {@link askErrorPatterns}. */
|
|
96
|
+
readonly regex?: RegexAnswers;
|
|
76
97
|
};
|
|
77
98
|
export declare function classifyError(signal: ErrorSignal, options?: ClassifyOptions): Classification;
|
package/dist/lib/errors.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* called `Date.now()` would return a different answer every second, which is
|
|
15
15
|
* exactly what this package promises not to do.
|
|
16
16
|
*/
|
|
17
|
+
import { testPatternSync } from "@crewhaus/tool-schema";
|
|
18
|
+
import { parseHttpDate, parseIsoInstantWithOffset, parseRfc5322WithZone } from "./http-date";
|
|
17
19
|
export const ERROR_CLASSES = [
|
|
18
20
|
"ok",
|
|
19
21
|
"transient",
|
|
@@ -247,9 +249,14 @@ const MESSAGE_TABLE = [
|
|
|
247
249
|
/** Guards the substring scan against a multi-megabyte error body. */
|
|
248
250
|
const MAX_MESSAGE_CHARS = 64_000;
|
|
249
251
|
/**
|
|
250
|
-
* Parse `Retry-After`. Two forms are legal: delta-seconds, and an HTTP-date
|
|
251
|
-
*
|
|
252
|
-
*
|
|
252
|
+
* Parse `Retry-After`. Two forms are legal: delta-seconds, and an HTTP-date
|
|
253
|
+
* (RFC 9110: IMF-fixdate, or the obsolete rfc850 and asctime forms, all
|
|
254
|
+
* GMT). An RFC 5322 date-time with its zone written (`+0200`, `UTC`,
|
|
255
|
+
* `-0000`), and an ISO-8601 instant with an explicit offset, are read too,
|
|
256
|
+
* because each is unambiguous and 0.7.0 read them. Anything else — a date
|
|
257
|
+
* with no zone, an offset-less ISO string, prose — answers nulls rather than
|
|
258
|
+
* being read as the host's local time. The date form needs a reference point to become a duration, so
|
|
259
|
+
* without `nowMs` it is returned as a date and `waitMs` stays null.
|
|
253
260
|
*/
|
|
254
261
|
export function parseRetryAfter(raw, nowMs) {
|
|
255
262
|
const text = raw.trim();
|
|
@@ -259,8 +266,8 @@ export function parseRetryAfter(raw, nowMs) {
|
|
|
259
266
|
const seconds = Number.parseInt(text, 10);
|
|
260
267
|
return { waitMs: seconds * 1000, retryAt: null };
|
|
261
268
|
}
|
|
262
|
-
const parsed =
|
|
263
|
-
if (
|
|
269
|
+
const parsed = parseHttpDate(text, nowMs) ?? parseRfc5322WithZone(text) ?? parseIsoInstantWithOffset(text);
|
|
270
|
+
if (parsed === undefined)
|
|
264
271
|
return { waitMs: null, retryAt: null };
|
|
265
272
|
const retryAt = new Date(parsed).toISOString();
|
|
266
273
|
if (nowMs === undefined)
|
|
@@ -281,7 +288,7 @@ export function parseRetryAfter(raw, nowMs) {
|
|
|
281
288
|
* matched immediately into a backtracking search over attacker-supplied
|
|
282
289
|
* text. Joining the fields manufactured the worst case for free.
|
|
283
290
|
*/
|
|
284
|
-
function matchCustom(signal, rules, fields) {
|
|
291
|
+
function matchCustom(signal, rules, fields, regex) {
|
|
285
292
|
for (const rule of rules) {
|
|
286
293
|
if (rule.status !== undefined && rule.status !== signal.status)
|
|
287
294
|
continue;
|
|
@@ -293,15 +300,29 @@ function matchCustom(signal, rules, fields) {
|
|
|
293
300
|
continue;
|
|
294
301
|
}
|
|
295
302
|
if (rule.matches !== undefined) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
303
|
+
// The caller's pattern never runs on this thread: the tool answered it
|
|
304
|
+
// in the worker (a library caller gets the bounded fallback). Either
|
|
305
|
+
// field matching decides it; otherwise one without an answer leaves
|
|
306
|
+
// the rule undecided, never "no match".
|
|
307
|
+
const pattern = rule.matches;
|
|
308
|
+
const answers = fields.map((f) => regex === undefined ? testPatternSync(pattern, "i", f) : regex.lookup(pattern, "i", f));
|
|
309
|
+
if (!answers.includes(true)) {
|
|
310
|
+
for (const answer of answers) {
|
|
311
|
+
if (typeof answer === "object" && "refused" in answer) {
|
|
312
|
+
throw new Error(`rule "${rule.id}" has an invalid pattern: ${answer.refused}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// `undefined` is a question not answered yet: as open as undetermined.
|
|
316
|
+
const open = answers.findIndex((a) => a !== false);
|
|
317
|
+
if (open !== -1) {
|
|
318
|
+
const answer = answers[open];
|
|
319
|
+
const why = typeof answer === "object" && "undetermined" in answer
|
|
320
|
+
? answer.undetermined
|
|
321
|
+
: "its answer has not been worked out yet";
|
|
322
|
+
return { undetermined: `rule "${rule.id}" could not be decided: ${why}`, rule };
|
|
323
|
+
}
|
|
304
324
|
continue;
|
|
325
|
+
}
|
|
305
326
|
}
|
|
306
327
|
// A rule with no conditions at all would match everything silently.
|
|
307
328
|
const hasCondition = rule.status !== undefined ||
|
|
@@ -314,16 +335,49 @@ function matchCustom(signal, rules, fields) {
|
|
|
314
335
|
}
|
|
315
336
|
return null;
|
|
316
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Ask `regex` about every caller rule's pattern for `signal`, so one
|
|
340
|
+
* resolve in the worker settles them before {@link classifyError} reads
|
|
341
|
+
* them. The fields are the ones {@link classifyError} tests.
|
|
342
|
+
*/
|
|
343
|
+
export function askErrorPatterns(signal, rules, regex) {
|
|
344
|
+
for (const field of classifiedFields(signal)) {
|
|
345
|
+
for (const rule of rules) {
|
|
346
|
+
if (rule.matches !== undefined)
|
|
347
|
+
regex.lookup(rule.matches, "i", field);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function classifiedFields(signal) {
|
|
352
|
+
const message = (signal.message ?? "").slice(0, MAX_MESSAGE_CHARS);
|
|
353
|
+
return [message.toLowerCase(), (signal.code ?? "").toLowerCase()];
|
|
354
|
+
}
|
|
317
355
|
export function classifyError(signal, options = {}) {
|
|
318
356
|
const message = (signal.message ?? "").slice(0, MAX_MESSAGE_CHARS);
|
|
319
|
-
const fields =
|
|
357
|
+
const fields = classifiedFields(signal);
|
|
320
358
|
const retry = signal.retryAfter === undefined
|
|
321
359
|
? { waitMs: null, retryAt: null }
|
|
322
360
|
: parseRetryAfter(signal.retryAfter, options.nowMs);
|
|
323
361
|
let verdict = null;
|
|
324
362
|
let matched = "default";
|
|
325
363
|
let source = "default";
|
|
326
|
-
const custom = matchCustom(signal, options.rules ?? [], fields);
|
|
364
|
+
const custom = matchCustom(signal, options.rules ?? [], fields, options.regex);
|
|
365
|
+
if (custom !== null && "undetermined" in custom) {
|
|
366
|
+
const exhausted = signal.attempt !== undefined &&
|
|
367
|
+
signal.maxAttempts !== undefined &&
|
|
368
|
+
signal.attempt >= signal.maxAttempts;
|
|
369
|
+
return {
|
|
370
|
+
class: "unknown",
|
|
371
|
+
action: "escalate",
|
|
372
|
+
retryable: false,
|
|
373
|
+
waitMs: null,
|
|
374
|
+
retryAt: retry.retryAt,
|
|
375
|
+
matched: custom.rule.id,
|
|
376
|
+
source: "undetermined",
|
|
377
|
+
exhausted,
|
|
378
|
+
undetermined: custom.undetermined,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
327
381
|
if (custom) {
|
|
328
382
|
verdict = { class: custom.class, action: custom.action, waitMs: custom.waitMs };
|
|
329
383
|
matched = custom.id;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-dates (RFC 9110 §5.6.7), read as the instants they name.
|
|
3
|
+
*
|
|
4
|
+
* `Date.parse` is not an HTTP-date reader. It accepts anything a browser
|
|
5
|
+
* ever did, and reads a string with no zone as the HOST's local time: an
|
|
6
|
+
* asctime date (which the RFC defines as GMT), a zone-less RFC 1123 date or
|
|
7
|
+
* an offset-less ISO-8601 string meant 10:00 UTC on one machine and 01:00
|
|
8
|
+
* UTC on another, so ErrorClassify's wait for the same header differed by
|
|
9
|
+
* the operator's timezone. This reads exactly the three forms the RFC
|
|
10
|
+
* defines, all of which are GMT by definition, builds the instant from
|
|
11
|
+
* its UTC fields, and answers undefined for anything else — never a guess.
|
|
12
|
+
* Two unambiguous non-HTTP forms that 0.7.0 read correctly are read too,
|
|
13
|
+
* each only with its zone written: an RFC 5322 date-time and an ISO-8601
|
|
14
|
+
* instant with an offset.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The epoch milliseconds an HTTP-date names, or undefined when `text` is not
|
|
18
|
+
* one of the RFC 9110 forms.
|
|
19
|
+
*
|
|
20
|
+
* An rfc850-date has a two-digit year; per the RFC it is read as the most
|
|
21
|
+
* recent year with those digits that is not more than 50 years after
|
|
22
|
+
* `nowMs`. Without `nowMs` its century is unknown, so it answers undefined
|
|
23
|
+
* rather than guessing one. Names are matched without regard to case; the
|
|
24
|
+
* weekday is checked to be a weekday but not against the date, as
|
|
25
|
+
* recipients commonly do.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseHttpDate(text: string, nowMs?: number): number | undefined;
|
|
28
|
+
/** The epoch milliseconds an RFC 5322 date-time with a written zone names, or undefined. */
|
|
29
|
+
export declare function parseRfc5322WithZone(text: string): number | undefined;
|
|
30
|
+
export declare function parseIsoInstantWithOffset(text: string): number | undefined;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-dates (RFC 9110 §5.6.7), read as the instants they name.
|
|
3
|
+
*
|
|
4
|
+
* `Date.parse` is not an HTTP-date reader. It accepts anything a browser
|
|
5
|
+
* ever did, and reads a string with no zone as the HOST's local time: an
|
|
6
|
+
* asctime date (which the RFC defines as GMT), a zone-less RFC 1123 date or
|
|
7
|
+
* an offset-less ISO-8601 string meant 10:00 UTC on one machine and 01:00
|
|
8
|
+
* UTC on another, so ErrorClassify's wait for the same header differed by
|
|
9
|
+
* the operator's timezone. This reads exactly the three forms the RFC
|
|
10
|
+
* defines, all of which are GMT by definition, builds the instant from
|
|
11
|
+
* its UTC fields, and answers undefined for anything else — never a guess.
|
|
12
|
+
* Two unambiguous non-HTTP forms that 0.7.0 read correctly are read too,
|
|
13
|
+
* each only with its zone written: an RFC 5322 date-time and an ISO-8601
|
|
14
|
+
* instant with an offset.
|
|
15
|
+
*/
|
|
16
|
+
const DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
17
|
+
const LONG_DAY_NAMES = [
|
|
18
|
+
"sunday",
|
|
19
|
+
"monday",
|
|
20
|
+
"tuesday",
|
|
21
|
+
"wednesday",
|
|
22
|
+
"thursday",
|
|
23
|
+
"friday",
|
|
24
|
+
"saturday",
|
|
25
|
+
];
|
|
26
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
27
|
+
// Bounded, anchored, and with no nested quantifiers: each is linear.
|
|
28
|
+
/** IMF-fixdate: `Sun, 06 Nov 1994 08:49:37 GMT`. */
|
|
29
|
+
const IMF_FIXDATE = /^([A-Za-z]{3}), (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
|
|
30
|
+
/** rfc850-date (obsolete): `Sunday, 06-Nov-94 08:49:37 GMT`. */
|
|
31
|
+
const RFC850_DATE = /^([A-Za-z]{6,9}), (\d{2})-([A-Za-z]{3})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
|
|
32
|
+
/** asctime-date (obsolete): `Sun Nov 6 08:49:37 1994`, GMT with no zone written. */
|
|
33
|
+
const ASCTIME_DATE = /^([A-Za-z]{3}) ([A-Za-z]{3}) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i;
|
|
34
|
+
/**
|
|
35
|
+
* The epoch milliseconds of a UTC calendar time, or undefined when any field
|
|
36
|
+
* is out of range. The year is used as written: `Date.UTC` maps 0-99 to
|
|
37
|
+
* 1900-1999, which read `Sat, 26 Sep 0026 …` as 1926.
|
|
38
|
+
*/
|
|
39
|
+
function utcInstant(year, month, day, hour, minute, second, ms = 0) {
|
|
40
|
+
if (month < 0 || month > 11 || day < 1 || hour > 23 || minute > 59 || second > 60) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const date = new Date(0);
|
|
44
|
+
date.setUTCFullYear(year, month, day);
|
|
45
|
+
// Refuse a day the month does not have (31 Nov, 30 Feb), rather than
|
|
46
|
+
// rolling over into the next month as Date does.
|
|
47
|
+
if (date.getUTCMonth() !== month || date.getUTCDate() !== day)
|
|
48
|
+
return undefined;
|
|
49
|
+
// A leap second is legal on the wire; it names the same instant as :59
|
|
50
|
+
// plus one second, which is what adding 60 seconds gives.
|
|
51
|
+
return date.getTime() + ((hour * 60 + minute) * 60 + second) * 1000 + ms;
|
|
52
|
+
}
|
|
53
|
+
function instant(year, monthName, day, hour, minute, second) {
|
|
54
|
+
return utcInstant(year, MONTHS.indexOf(monthName.toLowerCase()), day, hour, minute, second);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The epoch milliseconds an HTTP-date names, or undefined when `text` is not
|
|
58
|
+
* one of the RFC 9110 forms.
|
|
59
|
+
*
|
|
60
|
+
* An rfc850-date has a two-digit year; per the RFC it is read as the most
|
|
61
|
+
* recent year with those digits that is not more than 50 years after
|
|
62
|
+
* `nowMs`. Without `nowMs` its century is unknown, so it answers undefined
|
|
63
|
+
* rather than guessing one. Names are matched without regard to case; the
|
|
64
|
+
* weekday is checked to be a weekday but not against the date, as
|
|
65
|
+
* recipients commonly do.
|
|
66
|
+
*/
|
|
67
|
+
export function parseHttpDate(text, nowMs) {
|
|
68
|
+
const t = text.trim();
|
|
69
|
+
let m = IMF_FIXDATE.exec(t);
|
|
70
|
+
if (m !== null) {
|
|
71
|
+
if (!DAY_NAMES.includes(m[1].toLowerCase()))
|
|
72
|
+
return undefined;
|
|
73
|
+
return instant(Number(m[4]), m[3], Number(m[2]), Number(m[5]), Number(m[6]), Number(m[7]));
|
|
74
|
+
}
|
|
75
|
+
m = RFC850_DATE.exec(t);
|
|
76
|
+
if (m !== null) {
|
|
77
|
+
if (!LONG_DAY_NAMES.includes(m[1].toLowerCase()))
|
|
78
|
+
return undefined;
|
|
79
|
+
if (nowMs === undefined || !Number.isFinite(nowMs))
|
|
80
|
+
return undefined;
|
|
81
|
+
const nowYear = new Date(nowMs).getUTCFullYear();
|
|
82
|
+
const yy = Number(m[4]);
|
|
83
|
+
let year = nowYear - (nowYear % 100) + yy;
|
|
84
|
+
if (year > nowYear + 50)
|
|
85
|
+
year -= 100;
|
|
86
|
+
return instant(year, m[3], Number(m[2]), Number(m[5]), Number(m[6]), Number(m[7]));
|
|
87
|
+
}
|
|
88
|
+
m = ASCTIME_DATE.exec(t);
|
|
89
|
+
if (m !== null) {
|
|
90
|
+
if (!DAY_NAMES.includes(m[1].toLowerCase()))
|
|
91
|
+
return undefined;
|
|
92
|
+
return instant(Number(m[7]), m[2], Number(m[3].trim()), Number(m[4]), Number(m[5]), Number(m[6]));
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* RFC 5322 date-time: optional weekday, a one- or two-digit day, a
|
|
98
|
+
* four-digit year, optional seconds, and a zone that is WRITTEN — GMT, UT,
|
|
99
|
+
* UTC, Z, `±hhmm`, or one of the eight North American names RFC 5322 gives
|
|
100
|
+
* fixed offsets. Mail libraries write these (`… 07:28:00 -0000`, `… +0200`),
|
|
101
|
+
* and `Date.parse` read them correctly on 0.7.0 on every host, because the
|
|
102
|
+
* zone is in the text. Military one-letter zones are refused: RFC 5322 says
|
|
103
|
+
* their signs were botched in practice and to treat them as unknown.
|
|
104
|
+
*/
|
|
105
|
+
const RFC5322_DATE = /^(?:([A-Za-z]{3}),\s*)?(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+(GMT|UTC|UT|Z|[+-]\d{4}|[ECMP][SD]T)$/i;
|
|
106
|
+
/** RFC 5322's named zones, as offsets in minutes east of UTC. */
|
|
107
|
+
const NAMED_ZONES = new Map([
|
|
108
|
+
["gmt", 0],
|
|
109
|
+
["utc", 0],
|
|
110
|
+
["ut", 0],
|
|
111
|
+
["z", 0],
|
|
112
|
+
["est", -300],
|
|
113
|
+
["edt", -240],
|
|
114
|
+
["cst", -360],
|
|
115
|
+
["cdt", -300],
|
|
116
|
+
["mst", -420],
|
|
117
|
+
["mdt", -360],
|
|
118
|
+
["pst", -480],
|
|
119
|
+
["pdt", -420],
|
|
120
|
+
]);
|
|
121
|
+
/** The epoch milliseconds an RFC 5322 date-time with a written zone names, or undefined. */
|
|
122
|
+
export function parseRfc5322WithZone(text) {
|
|
123
|
+
const m = RFC5322_DATE.exec(text.trim());
|
|
124
|
+
if (m === null)
|
|
125
|
+
return undefined;
|
|
126
|
+
if (m[1] !== undefined && !DAY_NAMES.includes(m[1].toLowerCase()))
|
|
127
|
+
return undefined;
|
|
128
|
+
const zone = m[8].toLowerCase();
|
|
129
|
+
let offsetMinutes;
|
|
130
|
+
if (zone.startsWith("+") || zone.startsWith("-")) {
|
|
131
|
+
const hours = Number(zone.slice(1, 3));
|
|
132
|
+
const minutes = Number(zone.slice(3, 5));
|
|
133
|
+
if (hours > 23 || minutes > 59)
|
|
134
|
+
return undefined;
|
|
135
|
+
offsetMinutes = (zone.startsWith("-") ? -1 : 1) * (hours * 60 + minutes);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const named = NAMED_ZONES.get(zone);
|
|
139
|
+
if (named === undefined)
|
|
140
|
+
return undefined;
|
|
141
|
+
offsetMinutes = named;
|
|
142
|
+
}
|
|
143
|
+
const ms = instant(Number(m[4]), m[3], Number(m[2]), Number(m[5]), Number(m[6]), Number(m[7] ?? "0"));
|
|
144
|
+
return ms === undefined ? undefined : ms - offsetMinutes * 60_000;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* An ISO-8601 / RFC 3339 date-time that carries its own UTC offset (`Z`,
|
|
148
|
+
* `±hh:mm` or `±hhmm`), with `T` or a single space between date and time
|
|
149
|
+
* (RFC 3339 §5.6 allows the space; Python's `str(datetime)` writes it). Not
|
|
150
|
+
* an HTTP-date, but unambiguous, and `Date.parse` read it correctly on
|
|
151
|
+
* 0.7.0, so a server that sends one keeps working. Without the offset it is
|
|
152
|
+
* refused like any other zone-less time.
|
|
153
|
+
*
|
|
154
|
+
* The fields are checked here, not by `Date.parse`, which rolls an
|
|
155
|
+
* impossible day over (`2026-02-30` became 2 March). Fractional seconds past
|
|
156
|
+
* the millisecond are dropped, as `Date.parse` drops them.
|
|
157
|
+
*/
|
|
158
|
+
const ISO_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})$/i;
|
|
159
|
+
export function parseIsoInstantWithOffset(text) {
|
|
160
|
+
const m = ISO_WITH_OFFSET.exec(text.trim());
|
|
161
|
+
if (m === null)
|
|
162
|
+
return undefined;
|
|
163
|
+
const zone = m[8].toUpperCase();
|
|
164
|
+
let offsetMinutes = 0;
|
|
165
|
+
if (zone !== "Z") {
|
|
166
|
+
const digits = zone.slice(1).replace(":", "");
|
|
167
|
+
const hours = Number(digits.slice(0, 2));
|
|
168
|
+
const minutes = Number(digits.slice(2, 4));
|
|
169
|
+
if (hours > 23 || minutes > 59)
|
|
170
|
+
return undefined;
|
|
171
|
+
offsetMinutes = (zone.startsWith("-") ? -1 : 1) * (hours * 60 + minutes);
|
|
172
|
+
}
|
|
173
|
+
const ms = utcInstant(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6] ?? "0"), Number(`${m[7] ?? ""}000`.slice(0, 3)));
|
|
174
|
+
return ms === undefined ? undefined : ms - offsetMinutes * 60_000;
|
|
175
|
+
}
|
package/dist/lib/score.d.ts
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Additive rule scoring with an attributable breakdown.
|
|
3
|
-
*
|
|
4
|
-
* Any "score this thing and decide which band it lands in" job — lead
|
|
5
|
-
* qualification, risk, triage priority — is a table lookup plus addition.
|
|
6
|
-
* The reason to do it here rather than in a model is not only cost: a score
|
|
7
|
-
* a model produced cannot be explained to the person it affects, and cannot
|
|
8
|
-
* be re-run identically next quarter. This returns the contributors, and the
|
|
9
|
-
* version of the rules that produced them.
|
|
10
|
-
*/
|
|
11
|
-
import { type Check } from "@crewhaus/tool-schema";
|
|
1
|
+
import { type Check, type RegexAnswers } from "@crewhaus/tool-schema";
|
|
12
2
|
export type ScoreRule = {
|
|
13
3
|
readonly id: string;
|
|
14
4
|
/** All of these must hold for the points to be awarded. */
|
|
@@ -31,9 +21,10 @@ export type ScoreModel = {
|
|
|
31
21
|
readonly max?: number;
|
|
32
22
|
};
|
|
33
23
|
export type ScoreResult = {
|
|
34
|
-
|
|
24
|
+
/** Null when a rule could not be decided: its points may or may not count. */
|
|
25
|
+
readonly score: number | null;
|
|
35
26
|
/** The sum before clamping, which differs from `score` when it was clamped. */
|
|
36
|
-
readonly rawScore: number;
|
|
27
|
+
readonly rawScore: number | null;
|
|
37
28
|
readonly band: string | null;
|
|
38
29
|
readonly version: string | null;
|
|
39
30
|
readonly contributors: ReadonlyArray<{
|
|
@@ -46,5 +37,16 @@ export type ScoreResult = {
|
|
|
46
37
|
readonly id: string;
|
|
47
38
|
readonly reason: string;
|
|
48
39
|
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Rules that could not be decided (a pattern had no answer), with why. When
|
|
42
|
+
* any is listed, `score`, `rawScore` and `band` are null: a total that left
|
|
43
|
+
* their points out would be a guess.
|
|
44
|
+
*/
|
|
45
|
+
readonly undetermined?: ReadonlyArray<{
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly reason: string;
|
|
48
|
+
}>;
|
|
49
49
|
};
|
|
50
|
-
export declare function scoreValue(value: unknown, model: ScoreModel
|
|
50
|
+
export declare function scoreValue(value: unknown, model: ScoreModel, options?: {
|
|
51
|
+
readonly regex?: RegexAnswers;
|
|
52
|
+
}): ScoreResult;
|