@crewhaus/tool-flow 0.7.0 → 0.8.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.
@@ -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
- const left = new Set(a.map(canonicalize));
28
- const right = new Set(b.map(canonicalize));
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 left)
33
- if (right.has(item))
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 transitive, so there is no
69
- // canonical key to bucket by. Declared order therefore decides which
70
- // group a borderline value joins, and that is why it is documented.
71
- const existing = groups.find((g) => same(g.value, value, options));
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
- groups.push({ value, weight, voters: [voter], order: index });
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);
@@ -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): DecisionResult;
43
+ export declare function evaluateTable(value: unknown, table: DecisionTable, options?: {
44
+ readonly regex?: RegexAnswers;
45
+ }): DecisionResult;
@@ -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 ok = (row.match ?? "all") === "all" ? report.ok : report.passed > 0;
38
- if (ok) {
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 {
@@ -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
- * The date form needs a reference point to become a duration, so without
66
- * `nowMs` it is returned as a date and `waitMs` stays null.
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;
@@ -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
- * The date form needs a reference point to become a duration, so without
252
- * `nowMs` it is returned as a date and `waitMs` stays null.
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 = Date.parse(text);
263
- if (Number.isNaN(parsed))
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
- let re;
297
- try {
298
- re = new RegExp(rule.matches, "i");
299
- }
300
- catch (err) {
301
- throw new Error(`rule "${rule.id}" has an invalid pattern: ${err.message}`);
302
- }
303
- if (!fields.some((f) => re.test(f)))
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 = [message.toLowerCase(), (signal.code ?? "").toLowerCase()];
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
+ }
@@ -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
- readonly score: number;
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): ScoreResult;
50
+ export declare function scoreValue(value: unknown, model: ScoreModel, options?: {
51
+ readonly regex?: RegexAnswers;
52
+ }): ScoreResult;