@company-semantics/contracts 83.1.0 → 84.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "83.1.0",
3
+ "version": "84.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'e485ddbb7682' as const;
3
- export const SPEC_HASH_FULL = 'e485ddbb7682e98908329d4b788ce63dd32e00c23a6360e344d953ae9a42fc0e' as const;
2
+ export const SPEC_HASH = '53f462b4c90b' as const;
3
+ export const SPEC_HASH_FULL = '53f462b4c90ba419e71d55d82e19bb6a9836d95aeaaae6299b8124ac340320d8' as const;
@@ -3367,7 +3367,7 @@ export interface paths {
3367
3367
  };
3368
3368
  /**
3369
3369
  * What I owe and what happened, merged, newest first
3370
- * @description Actionable rows first, then informational rows newest-first — grouped, not chronologically interleaved. Only the informational half paginates.
3370
+ * @description Actionable rows first (the same in every view), then the informational rows of the requested view (view: all | unread) — unread-and-unresolved first, each by latest activity. Grouped, not chronologically interleaved. Only the informational half paginates.
3371
3371
  */
3372
3372
  get: operations["listMyFeed"];
3373
3373
  put?: never;
@@ -3387,7 +3387,10 @@ export interface paths {
3387
3387
  };
3388
3388
  get?: never;
3389
3389
  put?: never;
3390
- /** Mark specific notifications read */
3390
+ /**
3391
+ * Mark selected notifications read
3392
+ * @description Selected by ids or by view snapshot (asOf). Only the actor’s own rows.
3393
+ */
3391
3394
  post: operations["markMyFeedRead"];
3392
3395
  delete?: never;
3393
3396
  options?: never;
@@ -3412,6 +3415,83 @@ export interface paths {
3412
3415
  patch?: never;
3413
3416
  trace?: never;
3414
3417
  };
3418
+ "/api/me/feed/unread": {
3419
+ parameters: {
3420
+ query?: never;
3421
+ header?: never;
3422
+ path?: never;
3423
+ cookie?: never;
3424
+ };
3425
+ get?: never;
3426
+ put?: never;
3427
+ /**
3428
+ * Mark selected notifications unread
3429
+ * @description Selected by ids or by view snapshot (asOf). Resolved and deleted rows are never marked unread.
3430
+ */
3431
+ post: operations["markMyFeedUnread"];
3432
+ delete?: never;
3433
+ options?: never;
3434
+ head?: never;
3435
+ patch?: never;
3436
+ trace?: never;
3437
+ };
3438
+ "/api/me/feed/delete": {
3439
+ parameters: {
3440
+ query?: never;
3441
+ header?: never;
3442
+ path?: never;
3443
+ cookie?: never;
3444
+ };
3445
+ get?: never;
3446
+ put?: never;
3447
+ /**
3448
+ * Delete selected notifications, undoable by restore
3449
+ * @description Soft-deletes the actor’s own selected rows under one opaque restoreKey. The entity a row is about is never touched.
3450
+ */
3451
+ post: operations["deleteMyFeedItems"];
3452
+ delete?: never;
3453
+ options?: never;
3454
+ head?: never;
3455
+ patch?: never;
3456
+ trace?: never;
3457
+ };
3458
+ "/api/me/feed/restore": {
3459
+ parameters: {
3460
+ query?: never;
3461
+ header?: never;
3462
+ path?: never;
3463
+ cookie?: never;
3464
+ };
3465
+ get?: never;
3466
+ put?: never;
3467
+ /**
3468
+ * Undo a delete by its restoreKey
3469
+ * @description Restores the rows one delete removed. A row whose condition already has a newer active row stays deleted and is counted as superseded.
3470
+ */
3471
+ post: operations["restoreMyFeedItems"];
3472
+ delete?: never;
3473
+ options?: never;
3474
+ head?: never;
3475
+ patch?: never;
3476
+ trace?: never;
3477
+ };
3478
+ "/api/me/feed/clear-resolved": {
3479
+ parameters: {
3480
+ query?: never;
3481
+ header?: never;
3482
+ path?: never;
3483
+ cookie?: never;
3484
+ };
3485
+ get?: never;
3486
+ put?: never;
3487
+ /** Delete every resolved notification, undoable by restore */
3488
+ post: operations["clearMyFeedResolved"];
3489
+ delete?: never;
3490
+ options?: never;
3491
+ head?: never;
3492
+ patch?: never;
3493
+ trace?: never;
3494
+ };
3415
3495
  "/api/me/meetings/recordings/start": {
3416
3496
  parameters: {
3417
3497
  query?: never;
@@ -7715,6 +7795,62 @@ export interface components {
7715
7795
  unreadCount: number;
7716
7796
  truncated: boolean;
7717
7797
  };
7798
+ FeedMutationResponse: {
7799
+ affected: number;
7800
+ };
7801
+ FeedMarkReadRequest: {
7802
+ selector: {
7803
+ /** @constant */
7804
+ kind: "ids";
7805
+ notificationIds: string[];
7806
+ } | {
7807
+ /** @constant */
7808
+ kind: "view";
7809
+ /** @enum {string} */
7810
+ view: "all" | "unread";
7811
+ /** Format: date-time */
7812
+ asOf: string;
7813
+ };
7814
+ };
7815
+ FeedMarkUnreadRequest: {
7816
+ selector: {
7817
+ /** @constant */
7818
+ kind: "ids";
7819
+ notificationIds: string[];
7820
+ } | {
7821
+ /** @constant */
7822
+ kind: "view";
7823
+ /** @enum {string} */
7824
+ view: "all" | "unread";
7825
+ /** Format: date-time */
7826
+ asOf: string;
7827
+ };
7828
+ };
7829
+ FeedDeleteResponse: {
7830
+ affected: number;
7831
+ restoreKey: string;
7832
+ };
7833
+ FeedDeleteRequest: {
7834
+ selector: {
7835
+ /** @constant */
7836
+ kind: "ids";
7837
+ notificationIds: string[];
7838
+ } | {
7839
+ /** @constant */
7840
+ kind: "view";
7841
+ /** @enum {string} */
7842
+ view: "all" | "unread";
7843
+ /** Format: date-time */
7844
+ asOf: string;
7845
+ };
7846
+ };
7847
+ FeedRestoreResponse: {
7848
+ restored: number;
7849
+ superseded: number;
7850
+ };
7851
+ FeedRestoreRequest: {
7852
+ restoreKey: string;
7853
+ };
7718
7854
  /** @description Recording + work_item ids and the transcription session grant. */
7719
7855
  StartMeetingRecordingResponse: {
7720
7856
  /** @description ULID; unified trace key for the recording (INV-MTG-7). */
@@ -8740,7 +8876,10 @@ export interface components {
8740
8876
  targetDocId: string;
8741
8877
  targetDocTitle: string;
8742
8878
  /** @enum {string} */
8743
- sourceClass: "board_deck" | "board_minutes" | "investor_update" | "financial_brief" | "budget_forecast" | "okr_plan" | "annual_plan" | "all_hands" | "unit_strategy" | "roadmap" | "qbr" | "other" | "decoy";
8879
+ visibility: "hidden" | "shown";
8880
+ /** @enum {string} */
8881
+ classificationState: "unclassified" | "classified";
8882
+ sourceClass: ("board_deck" | "board_minutes" | "investor_update" | "financial_brief" | "budget_forecast" | "okr_plan" | "annual_plan" | "all_hands" | "unit_strategy" | "roadmap" | "qbr" | "other" | "decoy") | null;
8744
8883
  periodLabel: string | null;
8745
8884
  seriesRole: ("current" | "history") | null;
8746
8885
  relevance: number;
@@ -8750,9 +8889,42 @@ export interface components {
8750
8889
  sharingBreadth: "restricted_few" | "group" | "domain" | "anyone" | "unknown";
8751
8890
  sharedWithCount: number | null;
8752
8891
  audienceFit: ("fits" | "widens") | null;
8753
- /** @enum {string} */
8754
- recommendedUse: "attached" | "direction_only" | "excluded";
8892
+ recommendedUse: ("attached" | "direction_only" | "excluded") | null;
8755
8893
  use: ("attached" | "direction_only" | "excluded") | null;
8894
+ selectionSource: ("person" | "approval_default") | null;
8895
+ personHoldReasons: ("relevance_override" | "treatment_selection" | "target_override" | "manual_add")[];
8896
+ /** @enum {string} */
8897
+ machineState: "fresh" | "refreshed" | "filtered_out" | "not_rediscovered" | "pending_classification" | "retired";
8898
+ relevanceReasons: ({
8899
+ /** @constant */
8900
+ kind: "lexicon";
8901
+ /** @enum {string} */
8902
+ code: "title_term" | "folder_term" | "negative_title_term" | "negative_folder_term";
8903
+ term: string;
8904
+ count: number;
8905
+ } | {
8906
+ /** @constant */
8907
+ kind: "signal";
8908
+ /** @enum {string} */
8909
+ code: "folder_probe_hit" | "title_probe_hit" | "version_churn" | "mime_class" | "size_band" | "dated_series" | "recent_edit" | "owner_unit_head" | "owner_executive" | "sharing_breadth" | "duplicate_title" | "personal_mime";
8910
+ value: number | null;
8911
+ } | {
8912
+ /** @constant */
8913
+ kind: "evaluation";
8914
+ /** @enum {string} */
8915
+ code: "not_evaluated_t1" | "not_evaluated_t2" | "folder_unresolved" | "folder_truncated" | "explored_t1" | "explored_t2";
8916
+ } | {
8917
+ /** @constant */
8918
+ kind: "model";
8919
+ /** @enum {string} */
8920
+ code: "title_path_score" | "peek_confirms" | "peek_contradicts";
8921
+ score: number | null;
8922
+ })[];
8923
+ targetAudience: ("restricted" | "unit" | "org_wide") | null;
8924
+ attention: {
8925
+ t1: ("exploit" | "explore") | null;
8926
+ t2: ("exploit" | "explore") | null;
8927
+ };
8756
8928
  included: boolean;
8757
8929
  treatmentUnresolved: boolean;
8758
8930
  matchReason: string;
@@ -8795,6 +8967,9 @@ export interface components {
8795
8967
  files: number;
8796
8968
  filesIncluded: number;
8797
8969
  filesNeedingChoice: number;
8970
+ filesHidden: number;
8971
+ filesNotRediscovered: number;
8972
+ filesPendingClassification: number;
8798
8973
  };
8799
8974
  scopes: {
8800
8975
  scopeKey: string;
@@ -8817,6 +8992,16 @@ export interface components {
8817
8992
  highMicroUsd: string;
8818
8993
  capMicroUsd: string;
8819
8994
  } | null;
8995
+ discovery: {
8996
+ foundThisRun: number;
8997
+ retainedDecisions: number;
8998
+ notRediscovered: number;
8999
+ pendingClassification: number;
9000
+ filesDropped: number;
9001
+ coveragePercent: number;
9002
+ /** @enum {string} */
9003
+ scanOutcome: "complete" | "budget_exhausted" | "file_ceiling" | "stopped";
9004
+ } | null;
8820
9005
  };
8821
9006
  SourceSyncApproveResponse: {
8822
9007
  /** Format: uuid */
@@ -15352,7 +15537,8 @@ export interface operations {
15352
15537
  };
15353
15538
  listMyFeed: {
15354
15539
  parameters: {
15355
- query?: {
15540
+ query: {
15541
+ view: "all" | "unread";
15356
15542
  cursor?: string;
15357
15543
  };
15358
15544
  header?: never;
@@ -15379,14 +15565,20 @@ export interface operations {
15379
15565
  path?: never;
15380
15566
  cookie?: never;
15381
15567
  };
15382
- requestBody?: never;
15568
+ requestBody: {
15569
+ content: {
15570
+ "application/json": components["schemas"]["FeedMarkReadRequest"];
15571
+ };
15572
+ };
15383
15573
  responses: {
15384
- /** @description Read state applied to the actor’s own rows */
15574
+ /** @description How many of the actor’s own rows were marked read */
15385
15575
  200: {
15386
15576
  headers: {
15387
15577
  [name: string]: unknown;
15388
15578
  };
15389
- content?: never;
15579
+ content: {
15580
+ "application/json": components["schemas"]["FeedMutationResponse"];
15581
+ };
15390
15582
  };
15391
15583
  };
15392
15584
  };
@@ -15399,12 +15591,106 @@ export interface operations {
15399
15591
  };
15400
15592
  requestBody?: never;
15401
15593
  responses: {
15402
- /** @description Read state applied to the actor’s own rows */
15594
+ /** @description How many of the actor’s own rows were marked read */
15403
15595
  200: {
15404
15596
  headers: {
15405
15597
  [name: string]: unknown;
15406
15598
  };
15407
- content?: never;
15599
+ content: {
15600
+ "application/json": components["schemas"]["FeedMutationResponse"];
15601
+ };
15602
+ };
15603
+ };
15604
+ };
15605
+ markMyFeedUnread: {
15606
+ parameters: {
15607
+ query?: never;
15608
+ header?: never;
15609
+ path?: never;
15610
+ cookie?: never;
15611
+ };
15612
+ requestBody: {
15613
+ content: {
15614
+ "application/json": components["schemas"]["FeedMarkUnreadRequest"];
15615
+ };
15616
+ };
15617
+ responses: {
15618
+ /** @description How many of the actor’s own rows were marked unread */
15619
+ 200: {
15620
+ headers: {
15621
+ [name: string]: unknown;
15622
+ };
15623
+ content: {
15624
+ "application/json": components["schemas"]["FeedMutationResponse"];
15625
+ };
15626
+ };
15627
+ };
15628
+ };
15629
+ deleteMyFeedItems: {
15630
+ parameters: {
15631
+ query?: never;
15632
+ header?: never;
15633
+ path?: never;
15634
+ cookie?: never;
15635
+ };
15636
+ requestBody: {
15637
+ content: {
15638
+ "application/json": components["schemas"]["FeedDeleteRequest"];
15639
+ };
15640
+ };
15641
+ responses: {
15642
+ /** @description How many rows were deleted, and the key that restores exactly them */
15643
+ 200: {
15644
+ headers: {
15645
+ [name: string]: unknown;
15646
+ };
15647
+ content: {
15648
+ "application/json": components["schemas"]["FeedDeleteResponse"];
15649
+ };
15650
+ };
15651
+ };
15652
+ };
15653
+ restoreMyFeedItems: {
15654
+ parameters: {
15655
+ query?: never;
15656
+ header?: never;
15657
+ path?: never;
15658
+ cookie?: never;
15659
+ };
15660
+ requestBody: {
15661
+ content: {
15662
+ "application/json": components["schemas"]["FeedRestoreRequest"];
15663
+ };
15664
+ };
15665
+ responses: {
15666
+ /** @description How many rows were restored, and how many a newer row superseded */
15667
+ 200: {
15668
+ headers: {
15669
+ [name: string]: unknown;
15670
+ };
15671
+ content: {
15672
+ "application/json": components["schemas"]["FeedRestoreResponse"];
15673
+ };
15674
+ };
15675
+ };
15676
+ };
15677
+ clearMyFeedResolved: {
15678
+ parameters: {
15679
+ query?: never;
15680
+ header?: never;
15681
+ path?: never;
15682
+ cookie?: never;
15683
+ };
15684
+ requestBody?: never;
15685
+ responses: {
15686
+ /** @description How many resolved rows were deleted, and the key that restores them */
15687
+ 200: {
15688
+ headers: {
15689
+ [name: string]: unknown;
15690
+ };
15691
+ content: {
15692
+ "application/json": components["schemas"]["FeedDeleteResponse"];
15693
+ };
15408
15694
  };
15409
15695
  };
15410
15696
  };
@@ -17647,6 +17933,8 @@ export interface operations {
17647
17933
  requestBody: {
17648
17934
  content: {
17649
17935
  "application/json": {
17936
+ /** @constant */
17937
+ visibility?: "shown";
17650
17938
  /** @enum {string} */
17651
17939
  use?: "attached" | "direction_only" | "excluded";
17652
17940
  /** @enum {string} */
@@ -103,8 +103,12 @@ export const openApiRoutes = {
103
103
  '/api/me/action-items': ['GET'],
104
104
  '/api/me/events': ['GET'],
105
105
  '/api/me/feed': ['GET'],
106
+ '/api/me/feed/clear-resolved': ['POST'],
107
+ '/api/me/feed/delete': ['POST'],
106
108
  '/api/me/feed/read': ['POST'],
107
109
  '/api/me/feed/read-all': ['POST'],
110
+ '/api/me/feed/restore': ['POST'],
111
+ '/api/me/feed/unread': ['POST'],
108
112
  '/api/me/md': ['GET'],
109
113
  '/api/me/meetings/recordings/start': ['POST'],
110
114
  '/api/me/meetings/recordings/{id}': ['DELETE'],
@@ -107,6 +107,54 @@ way. This directory holds that shared spelling.
107
107
  domain is the largest trust change in the wave, and an empty body is
108
108
  indistinguishable from a mistaken or replayed body-less POST.
109
109
 
110
+ ## Relevance
111
+
112
+ Doctrine: control ADR `source-discovery-relevance-funnel`. Vocabulary: ADR
113
+ `source-discovery-relevance-vocabulary`. The sets live in `relevance.ts`.
114
+
115
+ - **Two axes on the wire.**
116
+ - **Relevance** is the funnel's verdict: `visibility` is `shown` or
117
+ `hidden`.
118
+ - **Treatment** is a person's decision: `recommendedUse` (machine state,
119
+ null until classified), `use` (the committed treatment, null until
120
+ chosen) and `selectionSource` (`person` or `approval_default`, present
121
+ exactly when `use` is).
122
+
123
+ Neither axis changes the other. The disclosure question is asked only of
124
+ lines that cleared relevance.
125
+
126
+ - **Hidden is not dropped.** `SOURCE_RELEVANCE_TIERS` includes `dropped` so the
127
+ backend can mirror it in persistence, but the item schema excludes it: a
128
+ dropped sighting never reaches the wire. A hidden line sits behind the
129
+ drawer, is never `included`, and a person can bring it into review with the
130
+ patch `{ visibility: "shown" }`. The patch changes exactly one field, so
131
+ promoting a line never also commits a treatment, and no client can hide a
132
+ line.
133
+ - **Classification is separate from visibility.** `classificationState` is
134
+ `unclassified` (so `sourceClass` and `recommendedUse` are both null) or
135
+ `classified` (both non-null). A shown line can still be
136
+ `pending_classification` in its `machineState`.
137
+ - **Reasons are a discriminated union.** `relevanceReasons` (≤ 8) is
138
+ `RelevanceReason`: `lexicon` (a product-owned `term` and a `count`),
139
+ `signal` (a `value` or null), `evaluation` (a code only) or `model` (a
140
+ `score` or null). Each kind has its own disjoint code set, and the app
141
+ switches over `kind`, then `code`, exhaustively. `relevance` is the fused
142
+ score, and `matchReason` is server copy rendered from the reasons
143
+ (deprecated).
144
+ - **Holds and lifecycle travel on the item.** `personHoldReasons` are the
145
+ field-level barriers a person's actions placed on the line. `machineState`
146
+ is its lifecycle (`fresh`, `refreshed`, `filtered_out`, `not_rediscovered`,
147
+ `pending_classification`, `retired`). `attention` says which lane
148
+ (`exploit` or `explore`) earned each model tier's look.
149
+ - **Attach shows its audience.** `targetAudience` is the audience Attach would
150
+ disclose to. Null means unknown and reads as "may widen".
151
+ - **No single ambiguous total.** `discovery` on the set response carries the
152
+ last completed run's counts (found, retained decisions, not rediscovered,
153
+ pending classification, dropped, coverage and how the scan ended). It is
154
+ null before any run completes. `totals` adds `filesHidden`,
155
+ `filesNotRediscovered` and `filesPendingClassification`, counted by
156
+ `candidateTotalsOf` over distinct files.
157
+
110
158
  ## Public API
111
159
 
112
160
  - `SOURCE_CLASSES` / `SourceClass` — what kind of document a source is.
@@ -135,7 +183,16 @@ way. This directory holds that shared spelling.
135
183
  `candidateTotalsOf(items)` — THE file count: distinct `sourceId`, never lines
136
184
  (ADR-CONTRACTS-195).
137
185
  - `SourceSyncCandidateItemPatchSchema` / `SourceSyncCandidateItemPatch` — one
138
- edit to one candidate item (use, class or target); never empty.
186
+ edit to one candidate item: exactly one of visibility (`shown` only), use,
187
+ class or target.
188
+ - `SOURCE_RELEVANCE_TIERS`, `CLASSIFICATION_STATES`, `RELEVANCE_REASON_KINDS`,
189
+ `LEXICON_REASON_CODES`, `SIGNAL_REASON_CODES`, `EVALUATION_REASON_CODES`,
190
+ `MODEL_REASON_CODES`, `TARGET_AUDIENCES`, `ATTENTION_LANES`,
191
+ `CANDIDATE_MACHINE_STATES`, `SELECTION_SOURCES`, `PERSON_HOLD_REASONS`,
192
+ `SCAN_OUTCOMES` and their derived unions — the relevance vocabulary (see
193
+ Relevance).
194
+ - `RelevanceReasonSchema` / `RelevanceReason` — why a candidate scored as it
195
+ did: a discriminated union on `kind`.
139
196
  - `SourceSyncAddCandidateRequestSchema` / `SourceSyncAddCandidateRequest` —
140
197
  "add one I missed": a provider file id picked in the provider's own search.
141
198
  - `SourceSyncApproveRequestSchema` / `SourceSyncApproveRequest` — approve the
@@ -210,4 +267,5 @@ way. This directory holds that shared spelling.
210
267
 
211
268
  `rules.ts` imports only `zod`, `../api/primitives` and `./vocabulary`.
212
269
  `upkeep.ts` and `reach.ts` import only `zod` and `../api/primitives`.
270
+ `relevance.ts` imports only `zod`.
213
271
  `vocabulary.ts` itself has no imports.
@@ -5,8 +5,8 @@
5
5
  * backend answers with.
6
6
  *
7
7
  * INVARIANTS TESTED:
8
- * - A candidate patch changes something: an empty patch is refused, and an
9
- * unknown key is an error, not surplus.
8
+ * - A candidate patch changes exactly one thing: an empty patch is refused, and
9
+ * an unknown key is an error, not surplus.
10
10
  * - An approval echoes the revision the person reviewed and carries the spend
11
11
  * cap as integer micro-USD; either one missing is refused.
12
12
  * - An approval answers PER SCOPE: one row per scope approved, each bound to its
@@ -16,6 +16,8 @@
16
16
  * below, which fails typecheck if one is missing at the root).
17
17
  * - The same two checks hold for the Release D names: staleness findings,
18
18
  * standing orders and the Drive-reach shapes.
19
+ * - Every runtime name `relevance.ts` declares is reachable from the root as
20
+ * the same binding.
19
21
  * - `ACTION_ITEM_KINDS` at the root carries `sourceSync.approval_pending`.
20
22
  * - The root re-exports the domain barrel explicitly.
21
23
  *
@@ -66,6 +68,7 @@ import type {
66
68
  SourceSyncStandingOrdersResponse,
67
69
  } from "../../index";
68
70
  import * as reach from "../reach";
71
+ import * as relevance from "../relevance";
69
72
  import * as rules from "../rules";
70
73
  import * as schemas from "../schemas";
71
74
  import * as upkeep from "../upkeep";
@@ -236,6 +239,17 @@ describe("source-sync Release C package surface", () => {
236
239
  }
237
240
  });
238
241
 
242
+ it("every discovery relevance set is exported from the package root", () => {
243
+ const root = rootBarrel as Record<string, unknown>;
244
+ const names = Object.keys(relevance);
245
+ expect(names.length).toBeGreaterThan(0);
246
+ for (const name of names) {
247
+ expect(root[name], `${name} at the root`).toBe(
248
+ (relevance as Record<string, unknown>)[name],
249
+ );
250
+ }
251
+ });
252
+
239
253
  it("exposes sourceSync.approval_pending in ACTION_ITEM_KINDS at the root", () => {
240
254
  expect(rootBarrel.ACTION_ITEM_KINDS).toContain(
241
255
  "sourceSync.approval_pending",
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The one file count for a candidate review.
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - Every total counts DISTINCT files (`sourceId`), never lines: a file routed to
6
+ * two documents is two lines and one file in every total.
7
+ * - A file is hidden only when EVERY line of it is hidden; one shown line shows it.
8
+ * - A file is not rediscovered / pending classification when ANY line of it is.
9
+ */
10
+
11
+ import { describe, expect, it } from "vitest";
12
+ import { candidateTotalsOf, type CandidateTotalsInput } from "../index";
13
+
14
+ function line(
15
+ sourceId: string,
16
+ overrides: Partial<CandidateTotalsInput> = {},
17
+ ): CandidateTotalsInput {
18
+ return {
19
+ sourceId,
20
+ included: true,
21
+ treatmentUnresolved: false,
22
+ visibility: "shown",
23
+ machineState: "fresh",
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("candidateTotalsOf", () => {
29
+ it("counts distinct files, never lines", () => {
30
+ expect(
31
+ candidateTotalsOf([
32
+ line("a", { treatmentUnresolved: true }),
33
+ line("a"),
34
+ line("b", { included: false, treatmentUnresolved: true }),
35
+ line("c"),
36
+ ]),
37
+ ).toEqual({
38
+ files: 3,
39
+ filesIncluded: 2,
40
+ filesNeedingChoice: 1,
41
+ filesHidden: 0,
42
+ filesNotRediscovered: 0,
43
+ filesPendingClassification: 0,
44
+ });
45
+ });
46
+
47
+ it("a file is hidden only when every line of it is hidden", () => {
48
+ const hidden = { visibility: "hidden", included: false } as const;
49
+ const totals = candidateTotalsOf([
50
+ line("partly", hidden),
51
+ line("partly"),
52
+ line("wholly", hidden),
53
+ line("wholly", hidden),
54
+ line("shown"),
55
+ ]);
56
+ expect(totals.files).toBe(3);
57
+ expect(totals.filesHidden).toBe(1);
58
+ expect(totals.filesIncluded).toBe(2);
59
+ });
60
+
61
+ it("a file is not rediscovered or pending classification when any line of it is", () => {
62
+ const totals = candidateTotalsOf([
63
+ line("a", { machineState: "not_rediscovered" }),
64
+ line("a", { machineState: "refreshed" }),
65
+ line("b", { machineState: "pending_classification" }),
66
+ line("b"),
67
+ line("c", { machineState: "retired" }),
68
+ ]);
69
+ expect(totals.filesNotRediscovered).toBe(1);
70
+ expect(totals.filesPendingClassification).toBe(1);
71
+ });
72
+
73
+ it("a file routed to two documents counts once in every total", () => {
74
+ const facts = {
75
+ treatmentUnresolved: true,
76
+ visibility: "hidden",
77
+ machineState: "not_rediscovered",
78
+ included: false,
79
+ } as const;
80
+ const hiddenTwice = candidateTotalsOf([line("a", facts), line("a", facts)]);
81
+ expect(hiddenTwice).toEqual({
82
+ files: 1,
83
+ filesIncluded: 0,
84
+ filesNeedingChoice: 0,
85
+ filesHidden: 1,
86
+ filesNotRediscovered: 1,
87
+ filesPendingClassification: 0,
88
+ });
89
+ const shownTwice = candidateTotalsOf([
90
+ line("a", {
91
+ treatmentUnresolved: true,
92
+ machineState: "pending_classification",
93
+ }),
94
+ line("a", {
95
+ treatmentUnresolved: true,
96
+ machineState: "pending_classification",
97
+ }),
98
+ ]);
99
+ expect(shownTwice).toEqual({
100
+ files: 1,
101
+ filesIncluded: 1,
102
+ filesNeedingChoice: 1,
103
+ filesHidden: 0,
104
+ filesNotRediscovered: 0,
105
+ filesPendingClassification: 1,
106
+ });
107
+ });
108
+
109
+ it("counts nothing for an empty review", () => {
110
+ expect(candidateTotalsOf([])).toEqual({
111
+ files: 0,
112
+ filesIncluded: 0,
113
+ filesNeedingChoice: 0,
114
+ filesHidden: 0,
115
+ filesNotRediscovered: 0,
116
+ filesPendingClassification: 0,
117
+ });
118
+ });
119
+ });