@claudinho/core 0.9.3 → 0.10.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/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).
@@ -509,6 +492,17 @@ interface ProviderCapabilities {
509
492
  interface ProviderAdapter {
510
493
  readonly name: string;
511
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[];
512
506
  /** All fixtures/results for a single calendar date (provider's timezone semantics). */
513
507
  fetchByDate(dateISO: string): Promise<Match[]>;
514
508
  /** Currently in-progress matches (poll path). */
@@ -518,8 +512,9 @@ interface ProviderAdapter {
518
512
  /**
519
513
  * Optional authoritative group tables (cumulative across the group stage).
520
514
  * Returned in standings order per group. Providers that can't supply a real
521
- * table omit this; callers then fall back (degraded) to a roster at zero
522
- * 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.
523
518
  */
524
519
  fetchStandings?(): Promise<GroupStandings[]>;
525
520
  /** Optional push subscription (websocket/SSE providers). Returns an unsubscribe fn. */
@@ -538,6 +533,118 @@ interface ProviderAdapter {
538
533
  };
539
534
  }
540
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
+
541
648
  /**
542
649
  * ESPN adapter — the free, keyless default/fallback source.
543
650
  *
@@ -567,70 +674,21 @@ declare class ProviderError extends Error {
567
674
  /** 429/403 — the upstream is refusing us; retrying at the live cadence makes it worse. */
568
675
  get throttled(): boolean;
569
676
  }
570
- interface EspnStatusType {
571
- name?: string;
572
- state?: string;
573
- completed?: boolean;
574
- shortDetail?: string;
575
- }
576
- interface EspnStatus {
577
- type?: EspnStatusType;
578
- clock?: number;
579
- displayClock?: string;
580
- period?: number;
581
- }
582
- interface EspnTeam {
583
- abbreviation?: string;
584
- displayName?: string;
585
- shortDisplayName?: string;
586
- name?: string;
587
- location?: string;
588
- }
589
- interface EspnCompetitor {
590
- homeAway?: 'home' | 'away';
591
- score?: string;
592
- /** Penalty-shootout tally, present only on shootout matches (ESPN sends a number). */
593
- shootoutScore?: number | string;
594
- winner?: boolean;
595
- team?: EspnTeam;
596
- }
597
- interface EspnCompetition {
598
- id?: string;
599
- date?: string;
600
- competitors?: EspnCompetitor[];
601
- venue?: {
602
- fullName?: string;
603
- address?: {
604
- city?: string;
605
- country?: string;
606
- };
607
- };
608
- status?: EspnStatus;
609
- }
610
- interface EspnSeason {
611
- year?: number;
612
- slug?: string;
613
- }
614
- interface EspnEvent {
615
- id: string;
616
- date: string;
617
- name?: string;
618
- shortName?: string;
619
- season?: EspnSeason;
620
- status?: EspnStatus;
621
- competitions?: EspnCompetition[];
622
- }
623
- /** Optional context to enrich mapping (authoritative team->group letter). */
624
- interface MapContext {
625
- /** Map of UPPERCASE team code -> group letter ("A".."L"), from standings. */
626
- groupByTeam?: Record<string, string>;
627
- }
628
677
  /** Map a single ESPN event into the canonical Match model. Exported for tests. */
629
- 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[];
630
681
  interface EspnAdapterOptions {
631
682
  baseUrl?: string;
632
683
  fetchImpl?: typeof fetch;
633
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[];
634
692
  /**
635
693
  * Enrich group-stage matches with their group letter via the standings
636
694
  * endpoint (one extra request). Default true. Set false on the hot live-poll
@@ -638,39 +696,13 @@ interface EspnAdapterOptions {
638
696
  */
639
697
  enrichGroups?: boolean;
640
698
  }
641
- interface EspnStandingsStat {
642
- name?: string;
643
- value?: number;
644
- }
645
- interface EspnStandingsEntry {
646
- team?: EspnTeam;
647
- stats?: EspnStandingsStat[];
648
- }
649
- interface EspnStandingsChild {
650
- name?: string;
651
- abbreviation?: string;
652
- standings?: {
653
- entries?: EspnStandingsEntry[];
654
- };
655
- }
656
- interface EspnStandings {
657
- children?: EspnStandingsChild[];
658
- }
659
- /**
660
- * Parse the standings payload into group tables. Pure (exported for tests).
661
- *
662
- * Two non-obvious robustness points, both verified against the live response:
663
- * - ESPN's `entries` array is NOT in rank order, so we sort by the `rank` stat
664
- * (falling back to points → GD → GF → code when rank is absent).
665
- * - Non-group `children` (knockout brackets) are skipped by the "Group X" name
666
- * test, so this stays correct once the bracket phase begins.
667
- */
668
- declare function parseStandings(data: EspnStandings): GroupStandings[];
669
699
  declare class EspnAdapter implements ProviderAdapter {
670
700
  private readonly opts;
671
701
  readonly name = "espn";
672
702
  readonly capabilities: ProviderCapabilities;
673
- /** 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). */
674
706
  private groupMap?;
675
707
  /**
676
708
  * One in-flight/recent standings fetch shared by fetchStandings and
@@ -702,15 +734,17 @@ declare class EspnAdapter implements ProviderAdapter {
702
734
  private sharedStandings;
703
735
  /**
704
736
  * Authoritative, cumulative group tables from the standings endpoint. Throws
705
- * on fetch/parse failure (the caller decides the fallback). Group-stage only:
706
- * 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.
707
740
  */
708
741
  fetchStandings(): Promise<GroupStandings[]>;
709
742
  /**
710
- * 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
711
744
  * endpoint. Best-effort: returns {} if standings are unavailable — but a
712
- * transient failure is NOT cached (only a successful parse pins the map), so
713
- * 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.
714
748
  * Reuses the same parse/fetch as {@link fetchStandings}, so the two never
715
749
  * drift and one command never fetches standings twice.
716
750
  */
@@ -722,20 +756,31 @@ declare class EspnAdapter implements ProviderAdapter {
722
756
  /**
723
757
  * The ESPN competition slug to fetch live state from. Defaults to the 2026
724
758
  * World Cup (`fifa.world`); override with CLAUDINHO_COMPETITION (e.g.
725
- * `fifa.friendly` to follow international friendlies during pre-tournament
726
- * testing). Only affects the *live* fetch the bundled static schedule is
727
- * always the World Cup.
759
+ * `eng.1`, `uefa.champions`, `fifa.friendly`). Only affects the *live* fetch
760
+ * the bundled static schedule is always the World Cup.
761
+ *
762
+ * Lives in its own module so that both the live layer and the market sidecar
763
+ * read the ONE resolver without importing each other.
728
764
  */
729
765
  declare function resolveCompetition(explicit?: string): string;
766
+
730
767
  /** Provider names {@link makeAdapter} can construct (the CLI validates against this). */
731
768
  declare const KNOWN_SOURCES: readonly ["espn"];
769
+ interface AdapterOptions {
770
+ /**
771
+ * Enrich group-stage fixtures with their group letter (one extra standings
772
+ * request per poll). Default on; the statusline refresher turns it off because
773
+ * it never renders group letters.
774
+ */
775
+ enrichGroups?: boolean;
776
+ }
732
777
  /**
733
778
  * Construct a provider adapter for a `--source` name (default: espn). An
734
779
  * unknown source FAILS LOUD instead of silently running ESPN — `--source foo`
735
780
  * previously no-op'd, which lied about what the flag did (attribution stayed
736
781
  * honest, but the advertised knob did nothing).
737
782
  */
738
- declare function makeAdapter(source?: string): ProviderAdapter;
783
+ declare function makeAdapter(source?: string, opts?: AdapterOptions): ProviderAdapter;
739
784
  /**
740
785
  * Merge live matches over a base set by id. Live entries replace base entries
741
786
  * with the same id; unknown ids are appended.
@@ -762,22 +807,33 @@ declare function getMatchesForDate(adapter: ProviderAdapter, dateISO: string): P
762
807
  interface StandingsResult {
763
808
  /** Group tables in group-letter order; each table's rows in standings order. */
764
809
  tables: GroupStandings[];
765
- /** True when no authoritative table was available and rows are a static roster. */
810
+ /** True when no authoritative table was available; tables may be a static roster or empty. */
766
811
  degraded: boolean;
767
- /** The provider that served a real table (absent when degraded). */
812
+ /** Provider that served the authoritative result, including an empty one (absent when degraded). */
768
813
  source?: string;
769
814
  }
770
815
  /**
771
816
  * Authoritative group tables, preferring the provider's cumulative standings and
772
- * FAILING CLOSED to a roster-at-zero (degraded) when none is available.
817
+ * FAILING CLOSED when none is available. An adapter with explicit bundled-
818
+ * roster compatibility may use the roster-at-zero; otherwise it returns an
819
+ * empty degraded result because expected group letters alone do not prove the
820
+ * bundle belongs to the same competition.
773
821
  *
774
822
  * Deliberately does NOT compute a table from a live-match window: that silently
775
823
  * drops earlier matchdays and reports a wrong, partial table (e.g. all-zeros for
776
824
  * a group not playing today) — the bug this replaced. A degraded roster is
777
- * honestly empty; a confidently-wrong table is the failure mode we refuse.
825
+ * explicitly zeroed; a confidently-wrong table is the failure mode we refuse.
778
826
  *
779
- * An empty `tables` with `degraded: false` means the fetch succeeded but the
780
- * asked-for group isn't in it (caller renders "no such group").
827
+ * An empty `tables` with `degraded: false` means either the fetch succeeded but
828
+ * the asked-for group wasn't in it, or the group is definitively outside the
829
+ * adapter's declared scope (caller renders "no such group"). A group declared
830
+ * in that expected scope but omitted from a partial
831
+ * provider result takes the degraded fallback instead of rendering as an
832
+ * authoritative empty table. An aggregate read also falls back when any group
833
+ * in that scope is absent; one result-level verdict cannot honestly describe a
834
+ * mix of live tables and static roster tables. Transport failure without
835
+ * explicit bundled-roster compatibility stays empty and degraded; it must
836
+ * never borrow the bundled World Cup roster.
781
837
  */
782
838
  declare function getStandings(adapter: ProviderAdapter, group?: string): Promise<StandingsResult>;
783
839
  declare function knockoutWindow(): {
@@ -881,15 +937,65 @@ declare function getMatchById(adapter: ProviderAdapter, id: string): Promise<Mat
881
937
  declare function getLiveMatches(adapter: ProviderAdapter, now?: Date): Promise<LiveResult>;
882
938
 
883
939
  /**
884
- * Prediction-market "signal" model a *sidecar* to Match, deliberately never
885
- * embedded in it. Market data has different freshness, reliability, failure,
886
- * and legal semantics than tournament facts, so it lives in its own folder and
887
- * is keyed back to a match by id. This keeps prediction-market context off the
888
- * hot paths (statusline, hook) by construction rather than by remembering a flag.
940
+ * Choosing one of several, and reporting on a batch.
941
+ *
942
+ * Two shapes, both replacing a boolean that was carrying more meaning than a
943
+ * boolean can hold.
944
+ *
945
+ * `Selection` replaces "the selector returned undefined". Finding no market for
946
+ * a team and finding TWO markets for that team both produced `undefined`, and
947
+ * the caller — having no way to tell them apart — recorded the second as the
948
+ * definitive fact "this fixture has no market" and negative-cached it for the
949
+ * whole TTL. An ambiguity is the one thing we must never resolve by guessing,
950
+ * and it was the case being guessed at.
951
+ *
952
+ * `BatchResolution` replaces `checked: Set<string>`. That set was maintained
953
+ * alongside the results rather than derived from them, so "did we reach a
954
+ * verdict?" and "what was the verdict?" could disagree — and did, on two paths.
955
+ * Here the verdict IS the record, and cacheability is read off it.
956
+ */
957
+
958
+ /** Exactly one, none, or more than one — never "one of the several". */
959
+ type Selection<T> = {
960
+ readonly kind: 'one';
961
+ readonly value: T;
962
+ } | {
963
+ readonly kind: 'none';
964
+ } | {
965
+ readonly kind: 'ambiguous';
966
+ readonly count: number;
967
+ };
968
+ /**
969
+ * The single candidate, or an honest account of why there isn't one.
970
+ *
971
+ * Deliberately NOT `candidates[0]`: which of two legs claiming the same team is
972
+ * the real one is not a question the payload answers, so it is not a question
973
+ * this function answers either.
974
+ */
975
+ declare function selectOne<T>(candidates: readonly T[]): Selection<T>;
976
+ /**
977
+ * Every input's verdict, plus whether every input GOT one.
889
978
  *
890
- * Read-only by design: providers fetch public market data only no wallet,
891
- * no auth, no order placement. Nothing here models trading.
979
+ * `complete: false` means the batch was cut short (deadline, provider error)
980
+ * the missing entries are unasked questions, not negative answers.
892
981
  */
982
+ interface BatchResolution<T> {
983
+ readonly results: ReadonlyMap<string, ParseResult<T>>;
984
+ readonly complete: boolean;
985
+ }
986
+ /** The values that resolved, keyed as they went in. */
987
+ declare function resolvedValues<T>(batch: BatchResolution<T>): Map<string, T>;
988
+ /**
989
+ * The keys whose verdict may be remembered — see {@link isCacheable}.
990
+ *
991
+ * This is the old `checked` set, now DERIVED from each verdict instead of
992
+ * tracked beside it. A malformed or unresolved key is absent, so a shape we
993
+ * could not read and a deadline that expired are both retried next time rather
994
+ * than cached as the fact that this fixture has no market.
995
+ */
996
+ declare function cacheableKeys<T>(batch: BatchResolution<T>): Set<string>;
997
+ /** An empty batch that reached nobody — what a total provider failure returns. */
998
+ declare function emptyBatch<T>(): BatchResolution<T>;
893
999
 
894
1000
  /** Which match result a priced line refers to (home win / draw / away win). */
895
1001
  type MarketOutcomeKind = 'home' | 'draw' | 'away' | 'other';
@@ -962,16 +1068,16 @@ interface MarketSignalOptions {
962
1068
  timeoutMs?: number;
963
1069
  }
964
1070
  /**
965
- * Result of a batch lookup. `checked` is the set of match ids the provider
966
- * DEFINITIVELY resolved (reached the source and found no usable market, or the
967
- * fixture is unmappable) — distinct from matches that errored or were skipped by
968
- * the deadline. Callers negative-cache only `checked` ids, so a transient
969
- * provider/network failure never suppresses a valid signal.
1071
+ * Result of a batch lookup: every match id's VERDICT, plus whether the batch
1072
+ * finished. Read the signals with `resolvedValues` and the negative-cacheable
1073
+ * ids with `cacheableKeys`.
1074
+ *
1075
+ * This replaces a `checked: Set<string>` that was maintained beside the results
1076
+ * instead of derived from them — so an ambiguous or unreadable payload could be
1077
+ * recorded as the definitive fact "this fixture has no market", suppressing the
1078
+ * refetch for the whole TTL.
970
1079
  */
971
- interface MarketSignalsResult {
972
- signals: Map<string, MarketSignal>;
973
- checked: Set<string>;
974
- }
1080
+ type MarketSignalsResult = BatchResolution<MarketSignal>;
975
1081
  /**
976
1082
  * A prediction-market provider. A *separate* swap-point from ProviderAdapter
977
1083
  * (which supplies match data): different cadence, reliability, and legal
@@ -981,8 +1087,8 @@ interface MarketProvider {
981
1087
  readonly name: string;
982
1088
  /** Signal for one match, or undefined when nothing maps cleanly. */
983
1089
  findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
984
- /** Batch form; signals plus the set of definitively-checked ids. */
985
- findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1090
+ /** Batch form; a verdict per match id (see MarketSignalsResult). */
1091
+ findSignals(matches: readonly Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
986
1092
  }
987
1093
 
988
1094
  /** Default freshness window: a signal older than this is stale (15 minutes). */
@@ -1026,7 +1132,13 @@ declare function mapsCleanly(match: Match, outcomes: MarketOutcome[]): boolean;
1026
1132
  declare function marketSignalRendersFor(match: Match, signal: MarketSignal): boolean;
1027
1133
  /** Sanity check: ≥2 priced outcomes whose probabilities sum to ~1. */
1028
1134
  declare function hasSaneDistribution(outcomes: MarketOutcome[]): boolean;
1029
- /** Is the signal older than the freshness window? Unparseable timestamps are stale. */
1135
+ /**
1136
+ * Is the signal outside the freshness window? Unparseable timestamps are stale.
1137
+ *
1138
+ * A FUTURE `asOf` is stale too. Freshness was a one-sided test (`now - asOf >
1139
+ * maxAge`), so a timestamp dated forward was never stale and never expired —
1140
+ * failing open permanently, in the direction that looks most trustworthy.
1141
+ */
1030
1142
  declare function isStaleSignal(signal: MarketSignal, options?: MarketSignalOptions): boolean;
1031
1143
  /**
1032
1144
  * The load-bearing gate. A signal is reliable only when it is unambiguous, has
@@ -1083,11 +1195,19 @@ declare function marketLine(signal: MarketSignal, match: Match): string;
1083
1195
  declare function marketBlock(signal: MarketSignal, match: Match): string[];
1084
1196
 
1085
1197
  /**
1086
- * Provider factory + graceful-degradation wrappers, mirroring `live.ts`'s
1087
- * getMatchesForDate contract: a market signal is optional enrichment, so any
1088
- * provider/network/parse error degrades to "no signal" and never throws.
1198
+ * Competitions the market sidecar covers CLAUDINHO'S implementation scope, not
1199
+ * Polymarket's. Polymarket does carry per-match moneylines for leagues (the
1200
+ * event `epl-lee-new-2026-09-14` has Leeds / draw / Newcastle legs), but this
1201
+ * sidecar derives its event slugs from the World Cup series only (`fifwc-…`,
1202
+ * series `soccer-fifwc` — see `deriveEventSlugs`) and validates fixture↔market
1203
+ * identity with nation tokens. Outside this set every fixture would derive a
1204
+ * slug that cannot exist, so the sidecar is switched off by construction
1205
+ * instead of issuing doomed requests. Supporting a league is slug-derivation,
1206
+ * mapping and validation work per competition — not widening this set.
1089
1207
  */
1090
-
1208
+ declare const MARKET_COMPETITIONS: ReadonlySet<string>;
1209
+ /** Whether the market sidecar can say anything about the active competition. */
1210
+ declare function marketsCoverCompetition(competition?: string): boolean;
1091
1211
  /**
1092
1212
  * Resolve the market-data source: explicit arg > CLAUDINHO_MARKETS_SOURCE env >
1093
1213
  * 'polymarket' (mirrors resolveCompetition). Set CLAUDINHO_MARKETS_SOURCE=fake
@@ -1102,8 +1222,8 @@ declare function resolveMarketSource(explicit?: string): string;
1102
1222
  declare function makeMarketProvider(source?: string): MarketProvider;
1103
1223
  /** Fetch one match's signal; never throws — undefined on any error. */
1104
1224
  declare function getMarketSignal(provider: MarketProvider, match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
1105
- /** Batch fetch; never throws — empty result (nothing checked) on any error. */
1106
- declare function getMarketSignals(provider: MarketProvider, matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1225
+ /** Batch fetch; never throws — an empty, INCOMPLETE batch on any error. */
1226
+ declare function getMarketSignals(provider: MarketProvider, matches: readonly Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1107
1227
 
1108
1228
  /**
1109
1229
  * A network-free MarketProvider for tests and local UX validation. Returns
@@ -1125,10 +1245,130 @@ declare class FakeMarketProvider implements MarketProvider {
1125
1245
  readonly name = "fake";
1126
1246
  constructor(opts?: FakeMarketProviderOptions);
1127
1247
  findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
1128
- findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1248
+ findSignals(matches: readonly Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1129
1249
  private synthesize;
1130
1250
  }
1131
1251
 
1252
+ /** Display columns a label may occupy. Generous for any real team or venue name. */
1253
+ declare const MAX_LABEL_COLUMNS = 100;
1254
+ /**
1255
+ * Prose meant for a human: a team name, a venue, a city, a market outcome label.
1256
+ *
1257
+ * NFC-normalized so the same name has one representation, bounded on input
1258
+ * length (work), display columns (layout) and code points (bytes), and free of
1259
+ * anything invisible. Whitespace controls fold to a space so words a hostile
1260
+ * feed split across lines cannot fuse.
1261
+ *
1262
+ * Total: never throws, whatever the input.
1263
+ */
1264
+ declare function humanLabel(value: unknown, maxColumns?: number): string;
1265
+ /**
1266
+ * A flag, GENERATED from the bundled map — never accepted from anywhere.
1267
+ *
1268
+ * This is the load-bearing line of the whole module. Because a flag is produced
1269
+ * here rather than passed through, no untrusted input ever needs to carry an
1270
+ * emoji, so {@link humanLabel} can refuse every invisible code point without
1271
+ * exception. The three P1s that each rode the old emoji exemption (TAG
1272
+ * sequences, variation selectors, ZWJ chains) are not defended against — they
1273
+ * have nowhere to enter.
1274
+ */
1275
+ declare function productFlag(nameOrCode: string | undefined): string;
1276
+
1277
+ /**
1278
+ * The single place a `Match` is sealed — whichever path it arrived by.
1279
+ *
1280
+ * This closes the PATH asymmetry. A fixture reaches a renderer two ways: live
1281
+ * from the ESPN adapter, or read back from the local cache file that the
1282
+ * statusline and hook render on every prompt. Those two paths had SEPARATE
1283
+ * rules, so every fix landed on one of them:
1284
+ *
1285
+ * - the live path DERIVED each team's flag from the nation; the cache path
1286
+ * accepted whatever string sat in `flag`, which is why the emoji exemption
1287
+ * existed at all and why TAG, variation selectors and ZWJ each produced a P1
1288
+ * - the live path required `status !== 'SCHEDULED'` before keeping a score;
1289
+ * the cache path kept any numeric pair, so an edited cache file could show
1290
+ * a scoreline on a fixture that has not kicked off
1291
+ * - the live path set `winnerCode` to one of the two competitors by
1292
+ * construction; the cache path passed any string through, and `winnerCode`
1293
+ * is what advances a team through the bracket
1294
+ *
1295
+ * Both paths now end HERE, so a rule cannot be added to one and forgotten on the
1296
+ * other. `parseEspnEvent` assembles candidate parts from the feed and seals
1297
+ * them; `parseCachedMatch` seals the record it read. The parity property in
1298
+ * trust-parity.test.ts asserts the two agree, and idempotence — sealing an
1299
+ * already-sealed Match returns it unchanged — is what makes the round trip safe.
1300
+ */
1301
+
1302
+ /** Loosely-typed candidate fields — whatever the feed or the cache file held. */
1303
+ interface MatchParts {
1304
+ id?: unknown;
1305
+ stage?: unknown;
1306
+ group?: unknown;
1307
+ kickoff?: unknown;
1308
+ venue?: unknown;
1309
+ city?: unknown;
1310
+ country?: unknown;
1311
+ home?: unknown;
1312
+ away?: unknown;
1313
+ score?: unknown;
1314
+ shootout?: unknown;
1315
+ minute?: unknown;
1316
+ status?: unknown;
1317
+ winnerCode?: unknown;
1318
+ updatedAt?: unknown;
1319
+ events?: unknown;
1320
+ }
1321
+ /**
1322
+ * Validate candidate parts into a Match, or say why not.
1323
+ *
1324
+ * `id`, `kickoff`, `stage` and `status` DROP the whole fixture rather than
1325
+ * falling back to a default. Each decides what the reader is told — status picks
1326
+ * between "FT" and a live scoreline, kickoff decides which calendar day the
1327
+ * fixture is filed under — so substituting a plausible value would invent the
1328
+ * very fact the bad field destroyed.
1329
+ */
1330
+ interface SealOptions {
1331
+ /**
1332
+ * Seal in-match events too. Default true.
1333
+ *
1334
+ * The statusline and hook render a scoreline, not a timeline — they never
1335
+ * read `events` — and sealing them is the DOMINANT cost on a 150ms path: a
1336
+ * poisoned cache of 64 live matches carrying 128 events each measured
1337
+ * 11.9 s, because each event's `player` is a label and a label is segmented
1338
+ * grapheme by grapheme. Bounding the COUNT was not enough when the surface
1339
+ * needs ZERO. Cheapest work is work not done.
1340
+ */
1341
+ readonly events?: boolean;
1342
+ }
1343
+ declare function sealMatch(parts: MatchParts, opts?: SealOptions): ParseResult<Match>;
1344
+ /**
1345
+ * A Match read back from our own cache file.
1346
+ *
1347
+ * The cache is a local file the statusline renders on every prompt, so it is
1348
+ * untrusted input in exactly the way a feed response is — with the extra twist
1349
+ * that it holds values we ourselves wrote, which is what made it tempting to
1350
+ * trust. It goes through the same seal.
1351
+ */
1352
+ declare function parseCachedMatch(raw: unknown, opts?: SealOptions): ParseResult<Match>;
1353
+ /** Cached fixtures, bounded BEFORE the per-record work. */
1354
+ declare function parseCachedMatches(raw: unknown, max: number, opts?: SealOptions): BoundedList<Match>;
1355
+
1356
+ /**
1357
+ * Validate a market signal, or say why not.
1358
+ *
1359
+ * `matchId` drops the whole signal: it is the key everything else is bound to,
1360
+ * and a signal that cannot name its fixture cannot be checked against one.
1361
+ */
1362
+ declare function sealMarketSignal(raw: unknown, options?: {
1363
+ now?: Date;
1364
+ maxAgeMs?: number;
1365
+ }): ParseResult<MarketSignal>;
1366
+ /** A signal read back from our own cache file — same seal as the live one. */
1367
+ declare function parseCachedMarketSignal(raw: unknown, options?: {
1368
+ now?: Date;
1369
+ maxAgeMs?: number;
1370
+ }): ParseResult<MarketSignal>;
1371
+
1132
1372
  /**
1133
1373
  * Optional override of the derived event slug for a fixture whose Polymarket
1134
1374
  * slug doesn't follow `fifwc-{home}-{away}-{date}` (e.g. an abbreviation that
@@ -1156,12 +1396,18 @@ declare class PolymarketProvider implements MarketProvider {
1156
1396
  readonly name = "polymarket";
1157
1397
  constructor(opts?: PolymarketProviderOptions);
1158
1398
  findSignal(match: Match, options?: MarketSignalOptions): Promise<MarketSignal | undefined>;
1159
- findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
1399
+ findSignals(matches: readonly Match[], options?: MarketSignalOptions): Promise<BatchResolution<MarketSignal>>;
1160
1400
  /**
1161
- * Resolve one match. `checked` distinguishes a DEFINITIVE result (reached the
1162
- * source and found no usable market, or the fixture is unmappable) from a
1163
- * provider/network error so transient failures are retried, not
1164
- * negative-cached.
1401
+ * Resolve one match into a verdict.
1402
+ *
1403
+ * Every exit says which KIND of non-answer it is, because that decides
1404
+ * whether it may be remembered — see `isCacheable`: a conclusion we drew from
1405
+ * a payload we READ is cacheable (including an ambiguity, which is stable),
1406
+ * while a shape we could not read is not. Previously a single
1407
+ * `checked: boolean` collapsed five distinct situations into two, and the
1408
+ * ones that landed on the wrong side of it — an ambiguous payload, a
1409
+ * two-legged market, an incoherent 1X2 — were negative-cached as the fact
1410
+ * that this fixture has no market.
1165
1411
  */
1166
1412
  private resolveOne;
1167
1413
  private fetchEvent;
@@ -1191,12 +1437,15 @@ interface ShareSnippetInput {
1191
1437
  /** Pre-resolved, English title line, e.g. "Next up for Mexico". */
1192
1438
  title: string;
1193
1439
  /** Matches to render (0..n). An empty set still yields a valid titled card. */
1194
- matches: Match[];
1440
+ /** Read-only: callers pass a bounded view, which must not be mutated. */
1441
+ matches: readonly Match[];
1195
1442
  /**
1196
1443
  * Reliable, display-ready market signals keyed by match id (sidecar — never
1197
1444
  * embedded in Match). Callers gate these; the formatter only renders.
1198
1445
  */
1199
1446
  marketSignals?: Map<string, MarketSignal>;
1447
+ /** False when market enrichment stopped before every relevant match was checked. */
1448
+ marketComplete?: boolean;
1200
1449
  /** Live-data provider name (e.g. "espn") for attribution; omit when static/degraded. */
1201
1450
  source?: string;
1202
1451
  /**
@@ -1227,9 +1476,9 @@ interface ShareSnippetInput {
1227
1476
  declare function formatShareSnippet(input: ShareSnippetInput, options?: ShareSnippetOptions): string;
1228
1477
  interface ShareTableInput {
1229
1478
  /** Group tables to render (1..n); each in standings order. */
1230
- tables: {
1479
+ tables: readonly {
1231
1480
  group: string;
1232
- rows: StandingRow[];
1481
+ rows: readonly StandingRow[];
1233
1482
  }[];
1234
1483
  /** Live-data provider name for attribution; omit when degraded/static. */
1235
1484
  source?: string;
@@ -1238,10 +1487,10 @@ interface ShareTableInput {
1238
1487
  /** Body line when there are no tables (e.g. "No group Z."). */
1239
1488
  emptyNote?: string;
1240
1489
  /**
1241
- * True when the rows are a static roster (no live results), not an
1242
- * authoritative table. A shared card is pasted into public/social, so this
1243
- * MUST be surfaced otherwise a roster-at-zero reads as a real "nobody has
1244
- * played yet" table. The card then carries an explicit not-live notice.
1490
+ * True when no authoritative table was available. Non-empty rows are a
1491
+ * static roster, not live results; an empty open-scope outage is described by
1492
+ * `emptyNote`. A shared card is pasted into public/social, so degraded state
1493
+ * MUST be surfaced rather than reading as an authoritative table.
1245
1494
  */
1246
1495
  degraded?: boolean;
1247
1496
  }
@@ -1318,4 +1567,4 @@ declare function formatShareBracket(input: ShareBracketInput, options?: ShareBra
1318
1567
  /** Compact one-line-per-match bracket for narrow share contexts. */
1319
1568
  declare function formatBracketCompactLine(mv: BracketMatchView, opts?: BracketFormatOpts): string;
1320
1569
 
1321
- 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, isTournamentWindowOver, 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 };
1570
+ 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, MARKET_COMPETITIONS, 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, marketsCoverCompetition, 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 };