@claudinho/core 0.9.2 → 0.9.4

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/dist/index.d.ts CHANGED
@@ -167,7 +167,11 @@ declare function formatDate(iso: string, opts?: FormatOpts): string;
167
167
  declare function formatTime(iso: string, opts?: FormatOpts): string;
168
168
  /** Compact human countdown until kickoff: "3d4h", "2h10m", "45m", or "now". */
169
169
  declare function countdown(iso: string, from?: Date): string;
170
- /** The calendar date (YYYY-MM-DD) of a kickoff in the target timezone. */
170
+ /**
171
+ * The calendar date (YYYY-MM-DD) of a kickoff in the target timezone, or ''
172
+ * when the input can't be parsed — an unfileable fixture matches no date rather
173
+ * than throwing out of whatever was grouping by day.
174
+ */
171
175
  declare function localDate(iso: string, tz?: string): string;
172
176
 
173
177
  /**
@@ -183,36 +187,6 @@ declare function isValidTimeZone(tz: string | undefined | null): boolean;
183
187
  */
184
188
  declare function isValidDate(s: string | undefined | null): boolean;
185
189
 
186
- /**
187
- * Feed-string sanitizer — the chokepoint between untrusted provider data and
188
- * every output surface (terminal, statusline, share cards, and the Claude Code
189
- * hook, whose stdout lands in the model's context). Strips control characters
190
- * (C0 incl. ESC, DEL, C1) so a compromised feed can't inject ANSI escapes or
191
- * multi-line text, and caps length so one field can't flood a surface.
192
- *
193
- * Applied at the ESPN adapter boundary (toTeam / mapEspnEvent) and mirrored on
194
- * the statusline's cache reads (defense against a poisoned cache file).
195
- */
196
-
197
- /** Default per-field cap — generous for any real team/venue name. */
198
- declare const FEED_TEXT_MAX = 100;
199
- /**
200
- * Strip C0/C1 control characters (including ESC) and cap at `max` code points.
201
- * Whitespace controls (tab/newline/CR) become a single space so words a hostile
202
- * feed split across lines don't fuse together. Total: never throws.
203
- */
204
- declare function sanitizeFeedText(value: string, max?: number): string;
205
- /**
206
- * Sanitized, display-safe copy of a Match. Used on cache reads (the
207
- * statusline/hook render straight from the cache file), so it must be total:
208
- * a malformed entry yields empty strings, never a throw. Beyond the string
209
- * fields, the RENDERED numeric fields (score, shootout, minute) are dropped
210
- * unless they are real finite numbers — poisoned values degrade to "vs" /
211
- * "LIVE", never to injected text. Shootout never survives without its score
212
- * (the adapter-level invariant, re-enforced here).
213
- */
214
- declare function sanitizeMatchStrings(m: Match): Match;
215
-
216
190
  /**
217
191
  * Display-width helpers for monospace/plain-text alignment.
218
192
  *
@@ -225,6 +199,15 @@ declare function sanitizeMatchStrings(m: Match): Match;
225
199
  */
226
200
  /** Terminal display width of a string (grapheme clusters; emoji count as 2). */
227
201
  declare function displayWidth(s: string): number;
202
+ /**
203
+ * Truncate to `maxColumns` display columns, appending `marker` when anything
204
+ * was dropped. Never splits a grapheme cluster.
205
+ *
206
+ * `padVisible` deliberately never truncates, so a single over-wide value pushed
207
+ * every other column out of line for the whole table — and on the statusline,
208
+ * whose entire contract is one short line, nothing bounded the result at all.
209
+ */
210
+ declare function truncateVisible(s: string, maxColumns: number, marker?: string): string;
228
211
  /**
229
212
  * Pad with trailing spaces to `width` DISPLAY columns (never truncates — a
230
213
  * too-long value overflows its column rather than being cut mid-name).
@@ -401,6 +384,31 @@ declare const LIVE_WINDOW_MS: number;
401
384
  declare const KNOCKOUT_EXTRA_TIME_MS: number;
402
385
  /** Live-window length for a fixture — longer for knockout (extra time + penalties). */
403
386
  declare function liveWindowMsFor(m: Match): number;
387
+ /**
388
+ * True once EVERY bundled fixture's (stage-aware) live window has ELAPSED —
389
+ * derived from the bundled schedule rather than a hardcoded date (same reasoning
390
+ * as the derived knockout window: the `CLAUDINHO_COMPETITION` seam must not carry
391
+ * a dead calendar).
392
+ *
393
+ * Named for exactly what it measures. This is a TIME predicate, not proof that
394
+ * every match was played: the bundled schedule is a **resultless skeleton** (every
395
+ * fixture ships `SCHEDULED` by design), so status can't inform it — a POSTPONED or
396
+ * abandoned fixture still reads as elapsed once its window passes. That is
397
+ * acceptable for the only use it has (turning a spent schedule into a sign-off),
398
+ * and it's why this is not called `isTournamentComplete`. **Make it status-aware
399
+ * against the live overlay before reusing it for anything that asserts results.**
400
+ *
401
+ * Deliberately a GLOBAL condition, independent of any team filter: an eliminated
402
+ * team mid-tournament has no next fixture either, and must NOT read as "over".
403
+ * Every other empty case still fails closed to the neutral `⚽ —`.
404
+ *
405
+ * Callers must additionally confirm the bundled schedule even applies — it
406
+ * describes the DEFAULT competition, so `CLAUDINHO_COMPETITION` pointing elsewhere
407
+ * makes this answer meaningless (see the `DEFAULT_COMPETITION` gates in the CLI).
408
+ *
409
+ * Empty fixture list → false: "we know nothing" is not "it's over".
410
+ */
411
+ declare function isTournamentWindowOver(now?: number, fixtures?: Match[]): boolean;
404
412
  /** Fixtures whose (stage-aware) live window contains `now` (cheap, static — no network). */
405
413
  declare function fixturesInLiveWindow(now?: number, fixtures?: Match[]): Match[];
406
414
  /**
@@ -484,6 +492,17 @@ interface ProviderCapabilities {
484
492
  interface ProviderAdapter {
485
493
  readonly name: string;
486
494
  readonly capabilities: ProviderCapabilities;
495
+ /**
496
+ * Expected group-table scope for omission checks. Omit when the competition's
497
+ * full group set is not known in advance.
498
+ */
499
+ readonly expectedStandingsGroups?: readonly string[];
500
+ /**
501
+ * Groups whose degraded roster may be derived from the bundled schedule.
502
+ * This is deliberately separate from expected scope: a custom competition
503
+ * can have known groups without sharing the bundled World Cup teams.
504
+ */
505
+ readonly standingsFallbackGroups?: readonly string[];
487
506
  /** All fixtures/results for a single calendar date (provider's timezone semantics). */
488
507
  fetchByDate(dateISO: string): Promise<Match[]>;
489
508
  /** Currently in-progress matches (poll path). */
@@ -493,8 +512,9 @@ interface ProviderAdapter {
493
512
  /**
494
513
  * Optional authoritative group tables (cumulative across the group stage).
495
514
  * Returned in standings order per group. Providers that can't supply a real
496
- * table omit this; callers then fall back (degraded) to a roster at zero
497
- * never a wrong, partial table computed from a narrow live window.
515
+ * table omit this; callers then fail closed (degraded), using a roster at zero
516
+ * only when `standingsFallbackGroups` declares bundled-schedule compatibility
517
+ * — never a wrong, partial table computed from a narrow live window.
498
518
  */
499
519
  fetchStandings?(): Promise<GroupStandings[]>;
500
520
  /** Optional push subscription (websocket/SSE providers). Returns an unsubscribe fn. */
@@ -513,6 +533,118 @@ interface ProviderAdapter {
513
533
  };
514
534
  }
515
535
 
536
+ /**
537
+ * Bounded collections — the type that carries its own honesty.
538
+ *
539
+ * Every round of review found a list that was capped without saying so, or that
540
+ * reported a post-cap count as the total, or that said "none found" when it had
541
+ * simply run out of budget. Those are the same bug three ways: the shape of the
542
+ * data did not carry what the reader needed to interpret it. A `BoundedList`
543
+ * cannot be constructed without stating all four facts.
544
+ */
545
+ interface BoundedList<T> {
546
+ /** What survived the cap. */
547
+ readonly items: readonly T[];
548
+ /**
549
+ * How many were established before capping. Exact when `complete` is true;
550
+ * otherwise it may only be a lower bound over the records examined.
551
+ */
552
+ readonly total: number;
553
+ /** `items.length` — kept explicit so a serialized payload is self-describing. */
554
+ readonly shown: number;
555
+ /** Did the cap drop anything? */
556
+ readonly truncated: boolean;
557
+ /**
558
+ * Did we finish looking? False when a deadline, error, or partial fetch cut
559
+ * the work short — so a caller can tell "there are none" from "we don't know".
560
+ * An empty list with `complete: false` must NEVER render as "nothing found".
561
+ */
562
+ readonly complete: boolean;
563
+ }
564
+ /**
565
+ * Bound a list, recording what that cost.
566
+ *
567
+ * SLICE BEFORE MAP is the caller's job — this records the outcome, it does not
568
+ * make an unbounded traversal safe. `takeBounded` is the one that bounds work.
569
+ */
570
+ declare function bounded<T>(items: readonly T[], max: number, complete?: boolean): BoundedList<T>;
571
+
572
+ /**
573
+ * The vocabulary for "we could not use this".
574
+ *
575
+ * `undefined` was doing five jobs at once — absent, malformed, ambiguous, timed
576
+ * out, and definitively none — and the caller could not tell them apart. That
577
+ * ambiguity is what let a schema failure get negative-cached as the FACT "this
578
+ * fixture has no market", and what made `checked` mean two different things on
579
+ * two paths. Every rejection now says which kind it is.
580
+ */
581
+ /** A value we produced, or the reason we did not. */
582
+ type ParseResult<T> = {
583
+ readonly kind: 'valid';
584
+ readonly value: T;
585
+ }
586
+ /** We read the payload fine, and the answer is genuinely "nothing here". Cacheable. */
587
+ | {
588
+ readonly kind: 'definitive-none';
589
+ readonly reason: string;
590
+ }
591
+ /** We could not read the payload. A fact about US, not about the fixture. NOT cacheable. */
592
+ | {
593
+ readonly kind: 'malformed';
594
+ readonly reason: string;
595
+ }
596
+ /**
597
+ * The payload admits more than one reading. Never guess between them.
598
+ *
599
+ * CACHEABLE, unlike the two below. This is a fact about a payload we read
600
+ * successfully — two legs claiming the same team, an incoherent 1X2 — and it
601
+ * is STABLE: fetching again returns the same bytes and the same ambiguity.
602
+ * Treating it as non-cacheable meant re-fetching on every single command,
603
+ * forever, and under the default-on enrichment deadline those doomed fixtures
604
+ * consumed the whole budget and starved the resolvable ones behind them.
605
+ * A negative TTL is a bounded delay if the provider later fixes their data;
606
+ * a permanent refetch loop is not bounded by anything.
607
+ */
608
+ | {
609
+ readonly kind: 'ambiguous';
610
+ readonly reason: string;
611
+ }
612
+ /**
613
+ * We never reached a verdict — the deadline expired, the budget ran out.
614
+ * A fact about the CLOCK, not about the fixture. NOT cacheable.
615
+ *
616
+ * This kind exists because the alternative was folding a timeout into
617
+ * `malformed`, which is the same conflation this type was written to end.
618
+ */
619
+ | {
620
+ readonly kind: 'unresolved';
621
+ readonly reason: string;
622
+ };
623
+ declare const valid: <T>(value: T) => ParseResult<T>;
624
+ declare const definitiveNone: <T>(reason: string) => ParseResult<T>;
625
+ declare const malformed: <T>(reason: string) => ParseResult<T>;
626
+ declare const ambiguous: <T>(reason: string) => ParseResult<T>;
627
+ declare const unresolved: <T>(reason: string) => ParseResult<T>;
628
+ /** The value, or undefined — for callers that genuinely do not care why. */
629
+ declare function parsedValue<T>(r: ParseResult<T>): T | undefined;
630
+ /**
631
+ * May this rejection be remembered for the length of a TTL?
632
+ *
633
+ * The line is whether we READ the payload, not whether we liked it. A
634
+ * definitive none and an ambiguity are both conclusions drawn from bytes we
635
+ * understood, and both are stable across a refetch — so remembering them is
636
+ * correct, and re-asking immediately just burns the request.
637
+ *
638
+ * `malformed` and `unresolved` are facts about US: a shape we could not read
639
+ * (which may be a provider mid-deploy) and a clock that ran out. Remembering
640
+ * either would suppress the retry that recovers.
641
+ */
642
+ declare function isCacheable<T>(r: ParseResult<T>): boolean;
643
+
644
+ interface MapContext {
645
+ groupByTeam?: Record<string, string>;
646
+ }
647
+
516
648
  /**
517
649
  * ESPN adapter — the free, keyless default/fallback source.
518
650
  *
@@ -542,70 +674,21 @@ declare class ProviderError extends Error {
542
674
  /** 429/403 — the upstream is refusing us; retrying at the live cadence makes it worse. */
543
675
  get throttled(): boolean;
544
676
  }
545
- interface EspnStatusType {
546
- name?: string;
547
- state?: string;
548
- completed?: boolean;
549
- shortDetail?: string;
550
- }
551
- interface EspnStatus {
552
- type?: EspnStatusType;
553
- clock?: number;
554
- displayClock?: string;
555
- period?: number;
556
- }
557
- interface EspnTeam {
558
- abbreviation?: string;
559
- displayName?: string;
560
- shortDisplayName?: string;
561
- name?: string;
562
- location?: string;
563
- }
564
- interface EspnCompetitor {
565
- homeAway?: 'home' | 'away';
566
- score?: string;
567
- /** Penalty-shootout tally, present only on shootout matches (ESPN sends a number). */
568
- shootoutScore?: number | string;
569
- winner?: boolean;
570
- team?: EspnTeam;
571
- }
572
- interface EspnCompetition {
573
- id?: string;
574
- date?: string;
575
- competitors?: EspnCompetitor[];
576
- venue?: {
577
- fullName?: string;
578
- address?: {
579
- city?: string;
580
- country?: string;
581
- };
582
- };
583
- status?: EspnStatus;
584
- }
585
- interface EspnSeason {
586
- year?: number;
587
- slug?: string;
588
- }
589
- interface EspnEvent {
590
- id: string;
591
- date: string;
592
- name?: string;
593
- shortName?: string;
594
- season?: EspnSeason;
595
- status?: EspnStatus;
596
- competitions?: EspnCompetition[];
597
- }
598
- /** Optional context to enrich mapping (authoritative team->group letter). */
599
- interface MapContext {
600
- /** Map of UPPERCASE team code -> group letter ("A".."L"), from standings. */
601
- groupByTeam?: Record<string, string>;
602
- }
603
677
  /** Map a single ESPN event into the canonical Match model. Exported for tests. */
604
- declare function mapEspnEvent(ev: EspnEvent, ctx?: MapContext): Match;
678
+ declare function mapEspnEvent(ev: unknown, ctx?: MapContext): Match | undefined;
679
+ /** Project an ESPN standings payload onto group tables. Exported for tests. */
680
+ declare function parseStandings(data: unknown): GroupStandings[];
605
681
  interface EspnAdapterOptions {
606
682
  baseUrl?: string;
607
683
  fetchImpl?: typeof fetch;
608
684
  timeoutMs?: number;
685
+ /**
686
+ * Expected standings groups for completeness checks and definitive group
687
+ * validation. Defaults to the bundled groups for the default World Cup base;
688
+ * custom bases leave the scope open unless supplied. Declaring custom groups
689
+ * does not authorize use of the bundled World Cup roster on failure.
690
+ */
691
+ expectedStandingsGroups?: readonly string[];
609
692
  /**
610
693
  * Enrich group-stage matches with their group letter via the standings
611
694
  * endpoint (one extra request). Default true. Set false on the hot live-poll
@@ -613,39 +696,13 @@ interface EspnAdapterOptions {
613
696
  */
614
697
  enrichGroups?: boolean;
615
698
  }
616
- interface EspnStandingsStat {
617
- name?: string;
618
- value?: number;
619
- }
620
- interface EspnStandingsEntry {
621
- team?: EspnTeam;
622
- stats?: EspnStandingsStat[];
623
- }
624
- interface EspnStandingsChild {
625
- name?: string;
626
- abbreviation?: string;
627
- standings?: {
628
- entries?: EspnStandingsEntry[];
629
- };
630
- }
631
- interface EspnStandings {
632
- children?: EspnStandingsChild[];
633
- }
634
- /**
635
- * Parse the standings payload into group tables. Pure (exported for tests).
636
- *
637
- * Two non-obvious robustness points, both verified against the live response:
638
- * - ESPN's `entries` array is NOT in rank order, so we sort by the `rank` stat
639
- * (falling back to points → GD → GF → code when rank is absent).
640
- * - Non-group `children` (knockout brackets) are skipped by the "Group X" name
641
- * test, so this stays correct once the bracket phase begins.
642
- */
643
- declare function parseStandings(data: EspnStandings): GroupStandings[];
644
699
  declare class EspnAdapter implements ProviderAdapter {
645
700
  private readonly opts;
646
701
  readonly name = "espn";
647
702
  readonly capabilities: ProviderCapabilities;
648
- /** Cached team-code -> group-letter map (built lazily from standings). */
703
+ readonly expectedStandingsGroups?: readonly string[];
704
+ readonly standingsFallbackGroups?: readonly string[];
705
+ /** Short-lived team-code -> group-letter map (built lazily from standings). */
649
706
  private groupMap?;
650
707
  /**
651
708
  * One in-flight/recent standings fetch shared by fetchStandings and
@@ -677,15 +734,17 @@ declare class EspnAdapter implements ProviderAdapter {
677
734
  private sharedStandings;
678
735
  /**
679
736
  * Authoritative, cumulative group tables from the standings endpoint. Throws
680
- * on fetch/parse failure (the caller decides the fallback). Group-stage only:
681
- * non-group `children` are filtered out by {@link parseStandings}.
737
+ * on fetch failure. Group-stage only: non-group `children` are filtered out
738
+ * by {@link parseStandings}; malformed rows are omitted without hiding their
739
+ * readable siblings.
682
740
  */
683
741
  fetchStandings(): Promise<GroupStandings[]>;
684
742
  /**
685
- * Build (and cache) a team-code -> group-letter map from the standings
743
+ * Build (and briefly cache) a team-code -> group-letter map from the standings
686
744
  * endpoint. Best-effort: returns {} if standings are unavailable — but a
687
- * transient failure is NOT cached (only a successful parse pins the map), so
688
- * one blip can't silently drop group letters for the adapter's lifetime.
745
+ * transient failure is NOT cached, and a partial successful parse expires at
746
+ * the standings TTL, so neither can silently drop group letters for the
747
+ * adapter's lifetime.
689
748
  * Reuses the same parse/fetch as {@link fetchStandings}, so the two never
690
749
  * drift and one command never fetches standings twice.
691
750
  */
@@ -737,22 +796,33 @@ declare function getMatchesForDate(adapter: ProviderAdapter, dateISO: string): P
737
796
  interface StandingsResult {
738
797
  /** Group tables in group-letter order; each table's rows in standings order. */
739
798
  tables: GroupStandings[];
740
- /** True when no authoritative table was available and rows are a static roster. */
799
+ /** True when no authoritative table was available; tables may be a static roster or empty. */
741
800
  degraded: boolean;
742
- /** The provider that served a real table (absent when degraded). */
801
+ /** Provider that served the authoritative result, including an empty one (absent when degraded). */
743
802
  source?: string;
744
803
  }
745
804
  /**
746
805
  * Authoritative group tables, preferring the provider's cumulative standings and
747
- * FAILING CLOSED to a roster-at-zero (degraded) when none is available.
806
+ * FAILING CLOSED when none is available. An adapter with explicit bundled-
807
+ * roster compatibility may use the roster-at-zero; otherwise it returns an
808
+ * empty degraded result because expected group letters alone do not prove the
809
+ * bundle belongs to the same competition.
748
810
  *
749
811
  * Deliberately does NOT compute a table from a live-match window: that silently
750
812
  * drops earlier matchdays and reports a wrong, partial table (e.g. all-zeros for
751
813
  * a group not playing today) — the bug this replaced. A degraded roster is
752
- * honestly empty; a confidently-wrong table is the failure mode we refuse.
814
+ * explicitly zeroed; a confidently-wrong table is the failure mode we refuse.
753
815
  *
754
- * An empty `tables` with `degraded: false` means the fetch succeeded but the
755
- * asked-for group isn't in it (caller renders "no such group").
816
+ * An empty `tables` with `degraded: false` means either the fetch succeeded but
817
+ * the asked-for group wasn't in it, or the group is definitively outside the
818
+ * adapter's declared scope (caller renders "no such group"). A group declared
819
+ * in that expected scope but omitted from a partial
820
+ * provider result takes the degraded fallback instead of rendering as an
821
+ * authoritative empty table. An aggregate read also falls back when any group
822
+ * in that scope is absent; one result-level verdict cannot honestly describe a
823
+ * mix of live tables and static roster tables. Transport failure without
824
+ * explicit bundled-roster compatibility stays empty and degraded; it must
825
+ * never borrow the bundled World Cup roster.
756
826
  */
757
827
  declare function getStandings(adapter: ProviderAdapter, group?: string): Promise<StandingsResult>;
758
828
  declare function knockoutWindow(): {
@@ -856,15 +926,65 @@ declare function getMatchById(adapter: ProviderAdapter, id: string): Promise<Mat
856
926
  declare function getLiveMatches(adapter: ProviderAdapter, now?: Date): Promise<LiveResult>;
857
927
 
858
928
  /**
859
- * Prediction-market "signal" model a *sidecar* to Match, deliberately never
860
- * embedded in it. Market data has different freshness, reliability, failure,
861
- * and legal semantics than tournament facts, so it lives in its own folder and
862
- * is keyed back to a match by id. This keeps prediction-market context off the
863
- * hot paths (statusline, hook) by construction rather than by remembering a flag.
929
+ * Choosing one of several, and reporting on a batch.
930
+ *
931
+ * Two shapes, both replacing a boolean that was carrying more meaning than a
932
+ * boolean can hold.
933
+ *
934
+ * `Selection` replaces "the selector returned undefined". Finding no market for
935
+ * a team and finding TWO markets for that team both produced `undefined`, and
936
+ * the caller — having no way to tell them apart — recorded the second as the
937
+ * definitive fact "this fixture has no market" and negative-cached it for the
938
+ * whole TTL. An ambiguity is the one thing we must never resolve by guessing,
939
+ * and it was the case being guessed at.
940
+ *
941
+ * `BatchResolution` replaces `checked: Set<string>`. That set was maintained
942
+ * alongside the results rather than derived from them, so "did we reach a
943
+ * verdict?" and "what was the verdict?" could disagree — and did, on two paths.
944
+ * Here the verdict IS the record, and cacheability is read off it.
945
+ */
946
+
947
+ /** Exactly one, none, or more than one — never "one of the several". */
948
+ type Selection<T> = {
949
+ readonly kind: 'one';
950
+ readonly value: T;
951
+ } | {
952
+ readonly kind: 'none';
953
+ } | {
954
+ readonly kind: 'ambiguous';
955
+ readonly count: number;
956
+ };
957
+ /**
958
+ * The single candidate, or an honest account of why there isn't one.
864
959
  *
865
- * Read-only by design: providers fetch public market data only no wallet,
866
- * no auth, no order placement. Nothing here models trading.
960
+ * Deliberately NOT `candidates[0]`: which of two legs claiming the same team is
961
+ * the real one is not a question the payload answers, so it is not a question
962
+ * this function answers either.
867
963
  */
964
+ declare function selectOne<T>(candidates: readonly T[]): Selection<T>;
965
+ /**
966
+ * Every input's verdict, plus whether every input GOT one.
967
+ *
968
+ * `complete: false` means the batch was cut short (deadline, provider error) —
969
+ * the missing entries are unasked questions, not negative answers.
970
+ */
971
+ interface BatchResolution<T> {
972
+ readonly results: ReadonlyMap<string, ParseResult<T>>;
973
+ readonly complete: boolean;
974
+ }
975
+ /** The values that resolved, keyed as they went in. */
976
+ declare function resolvedValues<T>(batch: BatchResolution<T>): Map<string, T>;
977
+ /**
978
+ * The keys whose verdict may be remembered — see {@link isCacheable}.
979
+ *
980
+ * This is the old `checked` set, now DERIVED from each verdict instead of
981
+ * tracked beside it. A malformed or unresolved key is absent, so a shape we
982
+ * could not read and a deadline that expired are both retried next time rather
983
+ * than cached as the fact that this fixture has no market.
984
+ */
985
+ declare function cacheableKeys<T>(batch: BatchResolution<T>): Set<string>;
986
+ /** An empty batch that reached nobody — what a total provider failure returns. */
987
+ declare function emptyBatch<T>(): BatchResolution<T>;
868
988
 
869
989
  /** Which match result a priced line refers to (home win / draw / away win). */
870
990
  type MarketOutcomeKind = 'home' | 'draw' | 'away' | 'other';
@@ -937,16 +1057,16 @@ interface MarketSignalOptions {
937
1057
  timeoutMs?: number;
938
1058
  }
939
1059
  /**
940
- * Result of a batch lookup. `checked` is the set of match ids the provider
941
- * DEFINITIVELY resolved (reached the source and found no usable market, or the
942
- * fixture is unmappable) — distinct from matches that errored or were skipped by
943
- * the deadline. Callers negative-cache only `checked` ids, so a transient
944
- * provider/network failure never suppresses a valid signal.
1060
+ * Result of a batch lookup: every match id's VERDICT, plus whether the batch
1061
+ * finished. Read the signals with `resolvedValues` and the negative-cacheable
1062
+ * ids with `cacheableKeys`.
1063
+ *
1064
+ * This replaces a `checked: Set<string>` that was maintained beside the results
1065
+ * instead of derived from them — so an ambiguous or unreadable payload could be
1066
+ * recorded as the definitive fact "this fixture has no market", suppressing the
1067
+ * refetch for the whole TTL.
945
1068
  */
946
- interface MarketSignalsResult {
947
- signals: Map<string, MarketSignal>;
948
- checked: Set<string>;
949
- }
1069
+ type MarketSignalsResult = BatchResolution<MarketSignal>;
950
1070
  /**
951
1071
  * A prediction-market provider. A *separate* swap-point from ProviderAdapter
952
1072
  * (which supplies match data): different cadence, reliability, and legal
@@ -956,8 +1076,8 @@ interface MarketProvider {
956
1076
  readonly name: string;
957
1077
  /** Signal for one match, or undefined when nothing maps cleanly. */
958
1078
  findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
959
- /** Batch form; signals plus the set of definitively-checked ids. */
960
- findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1079
+ /** Batch form; a verdict per match id (see MarketSignalsResult). */
1080
+ findSignals(matches: readonly Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
961
1081
  }
962
1082
 
963
1083
  /** Default freshness window: a signal older than this is stale (15 minutes). */
@@ -1001,7 +1121,13 @@ declare function mapsCleanly(match: Match, outcomes: MarketOutcome[]): boolean;
1001
1121
  declare function marketSignalRendersFor(match: Match, signal: MarketSignal): boolean;
1002
1122
  /** Sanity check: ≥2 priced outcomes whose probabilities sum to ~1. */
1003
1123
  declare function hasSaneDistribution(outcomes: MarketOutcome[]): boolean;
1004
- /** Is the signal older than the freshness window? Unparseable timestamps are stale. */
1124
+ /**
1125
+ * Is the signal outside the freshness window? Unparseable timestamps are stale.
1126
+ *
1127
+ * A FUTURE `asOf` is stale too. Freshness was a one-sided test (`now - asOf >
1128
+ * maxAge`), so a timestamp dated forward was never stale and never expired —
1129
+ * failing open permanently, in the direction that looks most trustworthy.
1130
+ */
1005
1131
  declare function isStaleSignal(signal: MarketSignal, options?: MarketSignalOptions): boolean;
1006
1132
  /**
1007
1133
  * The load-bearing gate. A signal is reliable only when it is unambiguous, has
@@ -1077,8 +1203,8 @@ declare function resolveMarketSource(explicit?: string): string;
1077
1203
  declare function makeMarketProvider(source?: string): MarketProvider;
1078
1204
  /** Fetch one match's signal; never throws — undefined on any error. */
1079
1205
  declare function getMarketSignal(provider: MarketProvider, match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
1080
- /** Batch fetch; never throws — empty result (nothing checked) on any error. */
1081
- declare function getMarketSignals(provider: MarketProvider, matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1206
+ /** Batch fetch; never throws — an empty, INCOMPLETE batch on any error. */
1207
+ declare function getMarketSignals(provider: MarketProvider, matches: readonly Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1082
1208
 
1083
1209
  /**
1084
1210
  * A network-free MarketProvider for tests and local UX validation. Returns
@@ -1100,10 +1226,130 @@ declare class FakeMarketProvider implements MarketProvider {
1100
1226
  readonly name = "fake";
1101
1227
  constructor(opts?: FakeMarketProviderOptions);
1102
1228
  findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
1103
- findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1229
+ findSignals(matches: readonly Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1104
1230
  private synthesize;
1105
1231
  }
1106
1232
 
1233
+ /** Display columns a label may occupy. Generous for any real team or venue name. */
1234
+ declare const MAX_LABEL_COLUMNS = 100;
1235
+ /**
1236
+ * Prose meant for a human: a team name, a venue, a city, a market outcome label.
1237
+ *
1238
+ * NFC-normalized so the same name has one representation, bounded on input
1239
+ * length (work), display columns (layout) and code points (bytes), and free of
1240
+ * anything invisible. Whitespace controls fold to a space so words a hostile
1241
+ * feed split across lines cannot fuse.
1242
+ *
1243
+ * Total: never throws, whatever the input.
1244
+ */
1245
+ declare function humanLabel(value: unknown, maxColumns?: number): string;
1246
+ /**
1247
+ * A flag, GENERATED from the bundled map — never accepted from anywhere.
1248
+ *
1249
+ * This is the load-bearing line of the whole module. Because a flag is produced
1250
+ * here rather than passed through, no untrusted input ever needs to carry an
1251
+ * emoji, so {@link humanLabel} can refuse every invisible code point without
1252
+ * exception. The three P1s that each rode the old emoji exemption (TAG
1253
+ * sequences, variation selectors, ZWJ chains) are not defended against — they
1254
+ * have nowhere to enter.
1255
+ */
1256
+ declare function productFlag(nameOrCode: string | undefined): string;
1257
+
1258
+ /**
1259
+ * The single place a `Match` is sealed — whichever path it arrived by.
1260
+ *
1261
+ * This closes the PATH asymmetry. A fixture reaches a renderer two ways: live
1262
+ * from the ESPN adapter, or read back from the local cache file that the
1263
+ * statusline and hook render on every prompt. Those two paths had SEPARATE
1264
+ * rules, so every fix landed on one of them:
1265
+ *
1266
+ * - the live path DERIVED each team's flag from the nation; the cache path
1267
+ * accepted whatever string sat in `flag`, which is why the emoji exemption
1268
+ * existed at all and why TAG, variation selectors and ZWJ each produced a P1
1269
+ * - the live path required `status !== 'SCHEDULED'` before keeping a score;
1270
+ * the cache path kept any numeric pair, so an edited cache file could show
1271
+ * a scoreline on a fixture that has not kicked off
1272
+ * - the live path set `winnerCode` to one of the two competitors by
1273
+ * construction; the cache path passed any string through, and `winnerCode`
1274
+ * is what advances a team through the bracket
1275
+ *
1276
+ * Both paths now end HERE, so a rule cannot be added to one and forgotten on the
1277
+ * other. `parseEspnEvent` assembles candidate parts from the feed and seals
1278
+ * them; `parseCachedMatch` seals the record it read. The parity property in
1279
+ * trust-parity.test.ts asserts the two agree, and idempotence — sealing an
1280
+ * already-sealed Match returns it unchanged — is what makes the round trip safe.
1281
+ */
1282
+
1283
+ /** Loosely-typed candidate fields — whatever the feed or the cache file held. */
1284
+ interface MatchParts {
1285
+ id?: unknown;
1286
+ stage?: unknown;
1287
+ group?: unknown;
1288
+ kickoff?: unknown;
1289
+ venue?: unknown;
1290
+ city?: unknown;
1291
+ country?: unknown;
1292
+ home?: unknown;
1293
+ away?: unknown;
1294
+ score?: unknown;
1295
+ shootout?: unknown;
1296
+ minute?: unknown;
1297
+ status?: unknown;
1298
+ winnerCode?: unknown;
1299
+ updatedAt?: unknown;
1300
+ events?: unknown;
1301
+ }
1302
+ /**
1303
+ * Validate candidate parts into a Match, or say why not.
1304
+ *
1305
+ * `id`, `kickoff`, `stage` and `status` DROP the whole fixture rather than
1306
+ * falling back to a default. Each decides what the reader is told — status picks
1307
+ * between "FT" and a live scoreline, kickoff decides which calendar day the
1308
+ * fixture is filed under — so substituting a plausible value would invent the
1309
+ * very fact the bad field destroyed.
1310
+ */
1311
+ interface SealOptions {
1312
+ /**
1313
+ * Seal in-match events too. Default true.
1314
+ *
1315
+ * The statusline and hook render a scoreline, not a timeline — they never
1316
+ * read `events` — and sealing them is the DOMINANT cost on a 150ms path: a
1317
+ * poisoned cache of 64 live matches carrying 128 events each measured
1318
+ * 11.9 s, because each event's `player` is a label and a label is segmented
1319
+ * grapheme by grapheme. Bounding the COUNT was not enough when the surface
1320
+ * needs ZERO. Cheapest work is work not done.
1321
+ */
1322
+ readonly events?: boolean;
1323
+ }
1324
+ declare function sealMatch(parts: MatchParts, opts?: SealOptions): ParseResult<Match>;
1325
+ /**
1326
+ * A Match read back from our own cache file.
1327
+ *
1328
+ * The cache is a local file the statusline renders on every prompt, so it is
1329
+ * untrusted input in exactly the way a feed response is — with the extra twist
1330
+ * that it holds values we ourselves wrote, which is what made it tempting to
1331
+ * trust. It goes through the same seal.
1332
+ */
1333
+ declare function parseCachedMatch(raw: unknown, opts?: SealOptions): ParseResult<Match>;
1334
+ /** Cached fixtures, bounded BEFORE the per-record work. */
1335
+ declare function parseCachedMatches(raw: unknown, max: number, opts?: SealOptions): BoundedList<Match>;
1336
+
1337
+ /**
1338
+ * Validate a market signal, or say why not.
1339
+ *
1340
+ * `matchId` drops the whole signal: it is the key everything else is bound to,
1341
+ * and a signal that cannot name its fixture cannot be checked against one.
1342
+ */
1343
+ declare function sealMarketSignal(raw: unknown, options?: {
1344
+ now?: Date;
1345
+ maxAgeMs?: number;
1346
+ }): ParseResult<MarketSignal>;
1347
+ /** A signal read back from our own cache file — same seal as the live one. */
1348
+ declare function parseCachedMarketSignal(raw: unknown, options?: {
1349
+ now?: Date;
1350
+ maxAgeMs?: number;
1351
+ }): ParseResult<MarketSignal>;
1352
+
1107
1353
  /**
1108
1354
  * Optional override of the derived event slug for a fixture whose Polymarket
1109
1355
  * slug doesn't follow `fifwc-{home}-{away}-{date}` (e.g. an abbreviation that
@@ -1131,12 +1377,18 @@ declare class PolymarketProvider implements MarketProvider {
1131
1377
  readonly name = "polymarket";
1132
1378
  constructor(opts?: PolymarketProviderOptions);
1133
1379
  findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
1134
- findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1380
+ findSignals(matches: readonly Match[], options?: MarketSignalOptions): Promise<BatchResolution<MarketSignal>>;
1135
1381
  /**
1136
- * Resolve one match. `checked` distinguishes a DEFINITIVE result (reached the
1137
- * source and found no usable market, or the fixture is unmappable) from a
1138
- * provider/network error so transient failures are retried, not
1139
- * negative-cached.
1382
+ * Resolve one match into a verdict.
1383
+ *
1384
+ * Every exit says which KIND of non-answer it is, because that decides
1385
+ * whether it may be remembered — see `isCacheable`: a conclusion we drew from
1386
+ * a payload we READ is cacheable (including an ambiguity, which is stable),
1387
+ * while a shape we could not read is not. Previously a single
1388
+ * `checked: boolean` collapsed five distinct situations into two, and the
1389
+ * ones that landed on the wrong side of it — an ambiguous payload, a
1390
+ * two-legged market, an incoherent 1X2 — were negative-cached as the fact
1391
+ * that this fixture has no market.
1140
1392
  */
1141
1393
  private resolveOne;
1142
1394
  private fetchEvent;
@@ -1166,12 +1418,15 @@ interface ShareSnippetInput {
1166
1418
  /** Pre-resolved, English title line, e.g. "Next up for Mexico". */
1167
1419
  title: string;
1168
1420
  /** Matches to render (0..n). An empty set still yields a valid titled card. */
1169
- matches: Match[];
1421
+ /** Read-only: callers pass a bounded view, which must not be mutated. */
1422
+ matches: readonly Match[];
1170
1423
  /**
1171
1424
  * Reliable, display-ready market signals keyed by match id (sidecar — never
1172
1425
  * embedded in Match). Callers gate these; the formatter only renders.
1173
1426
  */
1174
1427
  marketSignals?: Map<string, MarketSignal>;
1428
+ /** False when market enrichment stopped before every relevant match was checked. */
1429
+ marketComplete?: boolean;
1175
1430
  /** Live-data provider name (e.g. "espn") for attribution; omit when static/degraded. */
1176
1431
  source?: string;
1177
1432
  /**
@@ -1202,9 +1457,9 @@ interface ShareSnippetInput {
1202
1457
  declare function formatShareSnippet(input: ShareSnippetInput, options?: ShareSnippetOptions): string;
1203
1458
  interface ShareTableInput {
1204
1459
  /** Group tables to render (1..n); each in standings order. */
1205
- tables: {
1460
+ tables: readonly {
1206
1461
  group: string;
1207
- rows: StandingRow[];
1462
+ rows: readonly StandingRow[];
1208
1463
  }[];
1209
1464
  /** Live-data provider name for attribution; omit when degraded/static. */
1210
1465
  source?: string;
@@ -1213,10 +1468,10 @@ interface ShareTableInput {
1213
1468
  /** Body line when there are no tables (e.g. "No group Z."). */
1214
1469
  emptyNote?: string;
1215
1470
  /**
1216
- * True when the rows are a static roster (no live results), not an
1217
- * authoritative table. A shared card is pasted into public/social, so this
1218
- * MUST be surfaced otherwise a roster-at-zero reads as a real "nobody has
1219
- * played yet" table. The card then carries an explicit not-live notice.
1471
+ * True when no authoritative table was available. Non-empty rows are a
1472
+ * static roster, not live results; an empty open-scope outage is described by
1473
+ * `emptyNote`. A shared card is pasted into public/social, so degraded state
1474
+ * MUST be surfaced rather than reading as an authoritative table.
1220
1475
  */
1221
1476
  degraded?: boolean;
1222
1477
  }
@@ -1293,4 +1548,4 @@ declare function formatShareBracket(input: ShareBracketInput, options?: ShareBra
1293
1548
  /** Compact one-line-per-match bracket for narrow share contexts. */
1294
1549
  declare function formatBracketCompactLine(mv: BracketMatchView, opts?: BracketFormatOpts): string;
1295
1550
 
1296
- export { BRACKET_STAGE_ORDER, type BracketFormatOpts, type BracketMatchNode, type BracketMatchView, type BracketResult, type BracketTopology, type BracketView, type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FEED_TEXT_MAX, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type GroupStandings, KNOCKOUT_EXTRA_TIME_MS, KNOWN_SOURCES, type KnockoutFixturesResult, LIVE_WINDOW_MS, type Lang, type LedgerRow, type LiveResult, type MapContext, type MarketFavorite, type MarketMapping, type MarketMappingTable, type MarketOutcome, type MarketOutcomeKind, type MarketProvider, type MarketSignal, type MarketSignalOptions, type MarketSignalsResult, type Match, type MatchByIdResult, type MatchEvent, type NextFixtureResult, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, ProviderError, type ProviderErrorKind, type PunditPick, type ResolvedParticipant, SHARE_DISCLAIMER, SHARE_HASHTAG, type ShareBracketInput, type ShareBracketOptions, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type ShareTableInput, type SlotRef, type Stage, type StandingRow, type StandingsResult, type Status, type Team, type TeamInfo, type TeamLookup, allFixtures, allTeams, asFlavorLevel, buildBracketTopology, buildBracketView, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, currentOrNextFixtureForTeam, deriveFavorite, displayWidth, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, fixturesInLiveWindow, flagEmoji, formatBracketCompactLine, formatBracketList, formatBracketMatchLine, formatBracketTree, formatDate, formatKickoff, formatShareBracket, formatShareSnippet, formatShareTable, formatTime, getBracket, getKnockoutFixtures, getLiveMatches, getMarketSignal, getMarketSignals, getMatchById, getMatchesForDate, getNextFixtureForTeam, getStandings, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isResolvedNation, isStaleSignal, isValidDate, isValidTimeZone, knockoutWindow, liveSourceLabel, liveWindowMsFor, loadBracketTopology, localDate, lookupTeam, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketFixtureForTeam, marketLine, marketProbabilityText, marketRelevant, marketSignalRendersFor, marketSourceLabel, matchFlavor, matchKey, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeLang, normalizeOutcomes, outcomeFromScore, padVisible, parseStandings, parseTeamSlot, resolveCompetition, resolveMarketSource, resolveTz, sanitizeBundledFixture, sanitizeFeedText, sanitizeMatchStrings, scoreline, stageLabel, stageLabelI18n, t };
1551
+ export { BRACKET_STAGE_ORDER, type BatchResolution, type BoundedList, type BracketFormatOpts, type BracketMatchNode, type BracketMatchView, type BracketResult, type BracketTopology, type BracketView, type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type GroupStandings, KNOCKOUT_EXTRA_TIME_MS, KNOWN_SOURCES, type KnockoutFixturesResult, LIVE_WINDOW_MS, type Lang, type LedgerRow, type LiveResult, MAX_LABEL_COLUMNS, type MapContext, type MarketFavorite, type MarketMapping, type MarketMappingTable, type MarketOutcome, type MarketOutcomeKind, type MarketProvider, type MarketSignal, type MarketSignalOptions, type MarketSignalsResult, type Match, type MatchByIdResult, type MatchEvent, type NextFixtureResult, type Outcome, type ParseResult, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, ProviderError, type ProviderErrorKind, type PunditPick, type ResolvedParticipant, SHARE_DISCLAIMER, SHARE_HASHTAG, type Selection, type ShareBracketInput, type ShareBracketOptions, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type ShareTableInput, type SlotRef, type Stage, type StandingRow, type StandingsResult, type Status, type Team, type TeamInfo, type TeamLookup, allFixtures, allTeams, ambiguous, asFlavorLevel, bounded, buildBracketTopology, buildBracketView, buildMarketSignal, byKickoff, cacheableKeys, competitionBase, computeStandings, countdown, currentOrNextFixtureForTeam, definitiveNone, deriveFavorite, displayWidth, emptyBatch, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, fixturesInLiveWindow, flagEmoji, formatBracketCompactLine, formatBracketList, formatBracketMatchLine, formatBracketTree, formatDate, formatKickoff, formatShareBracket, formatShareSnippet, formatShareTable, formatTime, getBracket, getKnockoutFixtures, getLiveMatches, getMarketSignal, getMarketSignals, getMatchById, getMatchesForDate, getNextFixtureForTeam, getStandings, groups, hasSaneDistribution, humanLabel, isCacheable, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isResolvedNation, isStaleSignal, isTournamentWindowOver, isValidDate, isValidTimeZone, knockoutWindow, liveSourceLabel, liveWindowMsFor, loadBracketTopology, localDate, lookupTeam, makeAdapter, makeMarketProvider, malformed, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketFixtureForTeam, marketLine, marketProbabilityText, marketRelevant, marketSignalRendersFor, marketSourceLabel, matchFlavor, matchKey, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeLang, normalizeOutcomes, outcomeFromScore, padVisible, parseCachedMarketSignal, parseCachedMatch, parseCachedMatches, parseStandings, parseTeamSlot, parsedValue, productFlag, resolveCompetition, resolveMarketSource, resolveTz, resolvedValues, sanitizeBundledFixture, scoreline, sealMarketSignal, sealMatch, selectOne, stageLabel, stageLabelI18n, t, truncateVisible, unresolved, valid };