logiparity 1.1.0 → 1.2.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +16 -7
  3. package/docs/configuration.md +28 -5
  4. package/docs/recipes/socrata.md +38 -0
  5. package/docs/release.md +1 -1
  6. package/docs/report-formats.md +6 -2
  7. package/docs/report-schema.json +32 -6
  8. package/docs/report-schema.md +6 -4
  9. package/docs/scenario-report-schema.json +2 -2
  10. package/lib/adapters/dist/jsonpath.d.ts +2 -2
  11. package/lib/adapters/dist/jsonpath.js +14 -6
  12. package/lib/adapters/dist/rest.js +8 -2
  13. package/lib/cli/dist/version.d.ts +1 -1
  14. package/lib/cli/dist/version.js +1 -1
  15. package/lib/core/dist/aggregates.d.ts +8 -3
  16. package/lib/core/dist/aggregates.js +30 -3
  17. package/lib/core/dist/calendar.d.ts +3 -0
  18. package/lib/core/dist/calendar.js +30 -0
  19. package/lib/core/dist/config.d.ts +33 -6
  20. package/lib/core/dist/config.js +41 -2
  21. package/lib/core/dist/engine.js +13 -6
  22. package/lib/core/dist/groups.d.ts +1 -1
  23. package/lib/core/dist/groups.js +33 -11
  24. package/lib/core/dist/html-patterns.d.ts +26 -0
  25. package/lib/core/dist/html-patterns.js +11 -3
  26. package/lib/core/dist/html.js +23 -16
  27. package/lib/core/dist/human.js +30 -3
  28. package/lib/core/dist/labels.d.ts +12 -3
  29. package/lib/core/dist/money-display.js +12 -7
  30. package/lib/core/dist/normalize.js +10 -2
  31. package/lib/core/dist/reconciliation-display.js +8 -5
  32. package/lib/core/dist/reconciliation.js +29 -23
  33. package/lib/core/dist/source-state.d.ts +1 -0
  34. package/lib/core/dist/source-state.js +2 -1
  35. package/lib/core/dist/types.d.ts +3 -2
  36. package/lib/scenarios/dist/config.d.ts +14 -0
  37. package/package.json +2 -1
@@ -0,0 +1,30 @@
1
+ import { DateTime } from 'luxon';
2
+ export function formatHasTime(format) {
3
+ return DateTime.fromFormatExplain('', format).tokens.some((token) => !token.literal && /^(?:[HhmsSu]+|[tTfF]{1,4}|[xX])$/.test(token.val));
4
+ }
5
+ export function isCalendarFormat(format) {
6
+ const tokens = DateTime.fromFormatExplain('', format).tokens.filter((token) => !token.literal);
7
+ return (!tokens.some((token) => /^(?:[HhmsSuZaXxz]+|[tTfF]{1,4})$/.test(token.val)) &&
8
+ tokens.some((token) => /^y{2,4}$/.test(token.val)) &&
9
+ tokens.some((token) => /^[ML]{1,4}$/.test(token.val)) &&
10
+ tokens.some((token) => /^d{1,2}$/.test(token.val)));
11
+ }
12
+ // Calendar dates have no instant or zone. The fixed 1969–2068 pivot does not depend on the host year or Luxon settings.
13
+ export function normalizeDate(text, format, locale) {
14
+ if (!format && !/^\d{4}-\d{2}-\d{2}$/.test(text))
15
+ throw new Error('Date requires an ISO calendar day (yyyy-MM-dd), or a date-only format; omit the time and zone.');
16
+ const selected = format ?? 'yyyy-MM-dd';
17
+ const explanation = DateTime.fromFormatExplain(text, selected, { locale });
18
+ if (!explanation.rawMatches || explanation.invalidReason)
19
+ throw new Error(`Date value does not match configured calendar format "${selected}".`);
20
+ const parts = { ...explanation.result };
21
+ if (explanation.tokens.some((token) => !token.literal && token.val === 'yy') &&
22
+ typeof explanation.matches?.y === 'number') {
23
+ const year = explanation.matches.y % 100;
24
+ parts.year = year <= 68 ? 2000 + year : 1900 + year;
25
+ }
26
+ const date = DateTime.fromObject(parts, { zone: 'UTC', locale });
27
+ if (!date.isValid || date.year < 1 || date.year > 9999)
28
+ throw new Error('Invalid calendar date; check the year, month, day and leap year.');
29
+ return date.toISODate();
30
+ }
@@ -19,7 +19,9 @@ export declare const sourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
19
19
  fields: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
20
20
  path: z.ZodString;
21
21
  label: z.ZodOptional<z.ZodString>;
22
+ missing: z.ZodOptional<z.ZodNull>;
22
23
  }, z.core.$strict>]>>;
24
+ absentAsNull: z.ZodDefault<z.ZodBoolean>;
23
25
  items: z.ZodDefault<z.ZodString>;
24
26
  headers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
25
27
  headersEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
@@ -75,6 +77,7 @@ export declare const sourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
75
77
  group: z.ZodOptional<z.ZodString>;
76
78
  }, z.core.$strict>>;
77
79
  datetimeFormat: z.ZodOptional<z.ZodString>;
80
+ dateFormat: z.ZodOptional<z.ZodString>;
78
81
  type: z.ZodLiteral<"rest">;
79
82
  label: z.ZodOptional<z.ZodString>;
80
83
  rollup: z.ZodOptional<z.ZodObject<{
@@ -115,6 +118,7 @@ export declare const sourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
115
118
  group: z.ZodOptional<z.ZodString>;
116
119
  }, z.core.$strict>>;
117
120
  datetimeFormat: z.ZodOptional<z.ZodString>;
121
+ dateFormat: z.ZodOptional<z.ZodString>;
118
122
  type: z.ZodLiteral<"csv">;
119
123
  label: z.ZodOptional<z.ZodString>;
120
124
  rollup: z.ZodOptional<z.ZodObject<{
@@ -149,6 +153,7 @@ export declare const sourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
149
153
  group: z.ZodOptional<z.ZodString>;
150
154
  }, z.core.$strict>>;
151
155
  datetimeFormat: z.ZodOptional<z.ZodString>;
156
+ dateFormat: z.ZodOptional<z.ZodString>;
152
157
  type: z.ZodLiteral<"xlsx">;
153
158
  label: z.ZodOptional<z.ZodString>;
154
159
  rollup: z.ZodOptional<z.ZodObject<{
@@ -189,6 +194,7 @@ export declare const sourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
189
194
  group: z.ZodOptional<z.ZodString>;
190
195
  }, z.core.$strict>>;
191
196
  datetimeFormat: z.ZodOptional<z.ZodString>;
197
+ dateFormat: z.ZodOptional<z.ZodString>;
192
198
  type: z.ZodLiteral<"ui">;
193
199
  label: z.ZodOptional<z.ZodString>;
194
200
  rollup: z.ZodOptional<z.ZodObject<{
@@ -225,6 +231,7 @@ export declare const sourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
225
231
  group: z.ZodOptional<z.ZodString>;
226
232
  }, z.core.$strict>>;
227
233
  datetimeFormat: z.ZodOptional<z.ZodString>;
234
+ dateFormat: z.ZodOptional<z.ZodString>;
228
235
  type: z.ZodLiteral<"sql">;
229
236
  label: z.ZodOptional<z.ZodString>;
230
237
  rollup: z.ZodOptional<z.ZodObject<{
@@ -247,6 +254,7 @@ export declare const ruleSchema: z.ZodObject<{
247
254
  type: z.ZodEnum<{
248
255
  string: "string";
249
256
  number: "number";
257
+ date: "date";
250
258
  enum: "enum";
251
259
  money: "money";
252
260
  datetime: "datetime";
@@ -256,6 +264,7 @@ export declare const ruleSchema: z.ZodObject<{
256
264
  format: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
257
265
  allowNumber: z.ZodDefault<z.ZodBoolean>;
258
266
  caseFold: z.ZodDefault<z.ZodBoolean>;
267
+ whitespaceFold: z.ZodDefault<z.ZodBoolean>;
259
268
  emptyAsNull: z.ZodDefault<z.ZodBoolean>;
260
269
  locale: z.ZodOptional<z.ZodString>;
261
270
  numberFormat: z.ZodOptional<z.ZodObject<{
@@ -293,15 +302,15 @@ export declare const aggregateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
293
302
  tolerance: z.ZodDefault<z.ZodNumber>;
294
303
  }, z.core.$strict>], "type">;
295
304
  export declare const failOnSchema: z.ZodDefault<z.ZodUnion<readonly [z.ZodArray<z.ZodEnum<{
296
- invalid: "invalid";
297
305
  missing: "missing";
306
+ invalid: "invalid";
298
307
  extra: "extra";
299
308
  mismatch: "mismatch";
300
309
  aggregate: "aggregate";
301
310
  duplicate: "duplicate";
302
311
  }>>, z.ZodRecord<z.ZodEnum<{
303
- invalid: "invalid";
304
312
  missing: "missing";
313
+ invalid: "invalid";
305
314
  extra: "extra";
306
315
  mismatch: "mismatch";
307
316
  aggregate: "aggregate";
@@ -319,7 +328,9 @@ export declare const entitySchema: z.ZodObject<{
319
328
  fields: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
320
329
  path: z.ZodString;
321
330
  label: z.ZodOptional<z.ZodString>;
331
+ missing: z.ZodOptional<z.ZodNull>;
322
332
  }, z.core.$strict>]>>;
333
+ absentAsNull: z.ZodDefault<z.ZodBoolean>;
323
334
  items: z.ZodDefault<z.ZodString>;
324
335
  headers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
325
336
  headersEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
@@ -375,6 +386,7 @@ export declare const entitySchema: z.ZodObject<{
375
386
  group: z.ZodOptional<z.ZodString>;
376
387
  }, z.core.$strict>>;
377
388
  datetimeFormat: z.ZodOptional<z.ZodString>;
389
+ dateFormat: z.ZodOptional<z.ZodString>;
378
390
  type: z.ZodLiteral<"rest">;
379
391
  label: z.ZodOptional<z.ZodString>;
380
392
  rollup: z.ZodOptional<z.ZodObject<{
@@ -415,6 +427,7 @@ export declare const entitySchema: z.ZodObject<{
415
427
  group: z.ZodOptional<z.ZodString>;
416
428
  }, z.core.$strict>>;
417
429
  datetimeFormat: z.ZodOptional<z.ZodString>;
430
+ dateFormat: z.ZodOptional<z.ZodString>;
418
431
  type: z.ZodLiteral<"csv">;
419
432
  label: z.ZodOptional<z.ZodString>;
420
433
  rollup: z.ZodOptional<z.ZodObject<{
@@ -449,6 +462,7 @@ export declare const entitySchema: z.ZodObject<{
449
462
  group: z.ZodOptional<z.ZodString>;
450
463
  }, z.core.$strict>>;
451
464
  datetimeFormat: z.ZodOptional<z.ZodString>;
465
+ dateFormat: z.ZodOptional<z.ZodString>;
452
466
  type: z.ZodLiteral<"xlsx">;
453
467
  label: z.ZodOptional<z.ZodString>;
454
468
  rollup: z.ZodOptional<z.ZodObject<{
@@ -489,6 +503,7 @@ export declare const entitySchema: z.ZodObject<{
489
503
  group: z.ZodOptional<z.ZodString>;
490
504
  }, z.core.$strict>>;
491
505
  datetimeFormat: z.ZodOptional<z.ZodString>;
506
+ dateFormat: z.ZodOptional<z.ZodString>;
492
507
  type: z.ZodLiteral<"ui">;
493
508
  label: z.ZodOptional<z.ZodString>;
494
509
  rollup: z.ZodOptional<z.ZodObject<{
@@ -525,6 +540,7 @@ export declare const entitySchema: z.ZodObject<{
525
540
  group: z.ZodOptional<z.ZodString>;
526
541
  }, z.core.$strict>>;
527
542
  datetimeFormat: z.ZodOptional<z.ZodString>;
543
+ dateFormat: z.ZodOptional<z.ZodString>;
528
544
  type: z.ZodLiteral<"sql">;
529
545
  label: z.ZodOptional<z.ZodString>;
530
546
  rollup: z.ZodOptional<z.ZodObject<{
@@ -547,6 +563,7 @@ export declare const entitySchema: z.ZodObject<{
547
563
  type: z.ZodEnum<{
548
564
  string: "string";
549
565
  number: "number";
566
+ date: "date";
550
567
  enum: "enum";
551
568
  money: "money";
552
569
  datetime: "datetime";
@@ -556,6 +573,7 @@ export declare const entitySchema: z.ZodObject<{
556
573
  format: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
557
574
  allowNumber: z.ZodDefault<z.ZodBoolean>;
558
575
  caseFold: z.ZodDefault<z.ZodBoolean>;
576
+ whitespaceFold: z.ZodDefault<z.ZodBoolean>;
559
577
  emptyAsNull: z.ZodDefault<z.ZodBoolean>;
560
578
  locale: z.ZodOptional<z.ZodString>;
561
579
  numberFormat: z.ZodOptional<z.ZodObject<{
@@ -593,15 +611,15 @@ export declare const entitySchema: z.ZodObject<{
593
611
  tolerance: z.ZodDefault<z.ZodNumber>;
594
612
  }, z.core.$strict>], "type">>>;
595
613
  failOn: z.ZodDefault<z.ZodUnion<readonly [z.ZodArray<z.ZodEnum<{
596
- invalid: "invalid";
597
614
  missing: "missing";
615
+ invalid: "invalid";
598
616
  extra: "extra";
599
617
  mismatch: "mismatch";
600
618
  aggregate: "aggregate";
601
619
  duplicate: "duplicate";
602
620
  }>>, z.ZodRecord<z.ZodEnum<{
603
- invalid: "invalid";
604
621
  missing: "missing";
622
+ invalid: "invalid";
605
623
  extra: "extra";
606
624
  mismatch: "mismatch";
607
625
  aggregate: "aggregate";
@@ -634,7 +652,9 @@ export declare const configSchema: z.ZodObject<{
634
652
  fields: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
635
653
  path: z.ZodString;
636
654
  label: z.ZodOptional<z.ZodString>;
655
+ missing: z.ZodOptional<z.ZodNull>;
637
656
  }, z.core.$strict>]>>;
657
+ absentAsNull: z.ZodDefault<z.ZodBoolean>;
638
658
  items: z.ZodDefault<z.ZodString>;
639
659
  headers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
640
660
  headersEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
@@ -690,6 +710,7 @@ export declare const configSchema: z.ZodObject<{
690
710
  group: z.ZodOptional<z.ZodString>;
691
711
  }, z.core.$strict>>;
692
712
  datetimeFormat: z.ZodOptional<z.ZodString>;
713
+ dateFormat: z.ZodOptional<z.ZodString>;
693
714
  type: z.ZodLiteral<"rest">;
694
715
  label: z.ZodOptional<z.ZodString>;
695
716
  rollup: z.ZodOptional<z.ZodObject<{
@@ -730,6 +751,7 @@ export declare const configSchema: z.ZodObject<{
730
751
  group: z.ZodOptional<z.ZodString>;
731
752
  }, z.core.$strict>>;
732
753
  datetimeFormat: z.ZodOptional<z.ZodString>;
754
+ dateFormat: z.ZodOptional<z.ZodString>;
733
755
  type: z.ZodLiteral<"csv">;
734
756
  label: z.ZodOptional<z.ZodString>;
735
757
  rollup: z.ZodOptional<z.ZodObject<{
@@ -764,6 +786,7 @@ export declare const configSchema: z.ZodObject<{
764
786
  group: z.ZodOptional<z.ZodString>;
765
787
  }, z.core.$strict>>;
766
788
  datetimeFormat: z.ZodOptional<z.ZodString>;
789
+ dateFormat: z.ZodOptional<z.ZodString>;
767
790
  type: z.ZodLiteral<"xlsx">;
768
791
  label: z.ZodOptional<z.ZodString>;
769
792
  rollup: z.ZodOptional<z.ZodObject<{
@@ -804,6 +827,7 @@ export declare const configSchema: z.ZodObject<{
804
827
  group: z.ZodOptional<z.ZodString>;
805
828
  }, z.core.$strict>>;
806
829
  datetimeFormat: z.ZodOptional<z.ZodString>;
830
+ dateFormat: z.ZodOptional<z.ZodString>;
807
831
  type: z.ZodLiteral<"ui">;
808
832
  label: z.ZodOptional<z.ZodString>;
809
833
  rollup: z.ZodOptional<z.ZodObject<{
@@ -840,6 +864,7 @@ export declare const configSchema: z.ZodObject<{
840
864
  group: z.ZodOptional<z.ZodString>;
841
865
  }, z.core.$strict>>;
842
866
  datetimeFormat: z.ZodOptional<z.ZodString>;
867
+ dateFormat: z.ZodOptional<z.ZodString>;
843
868
  type: z.ZodLiteral<"sql">;
844
869
  label: z.ZodOptional<z.ZodString>;
845
870
  rollup: z.ZodOptional<z.ZodObject<{
@@ -862,6 +887,7 @@ export declare const configSchema: z.ZodObject<{
862
887
  type: z.ZodEnum<{
863
888
  string: "string";
864
889
  number: "number";
890
+ date: "date";
865
891
  enum: "enum";
866
892
  money: "money";
867
893
  datetime: "datetime";
@@ -871,6 +897,7 @@ export declare const configSchema: z.ZodObject<{
871
897
  format: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
872
898
  allowNumber: z.ZodDefault<z.ZodBoolean>;
873
899
  caseFold: z.ZodDefault<z.ZodBoolean>;
900
+ whitespaceFold: z.ZodDefault<z.ZodBoolean>;
874
901
  emptyAsNull: z.ZodDefault<z.ZodBoolean>;
875
902
  locale: z.ZodOptional<z.ZodString>;
876
903
  numberFormat: z.ZodOptional<z.ZodObject<{
@@ -908,15 +935,15 @@ export declare const configSchema: z.ZodObject<{
908
935
  tolerance: z.ZodDefault<z.ZodNumber>;
909
936
  }, z.core.$strict>], "type">>>;
910
937
  failOn: z.ZodDefault<z.ZodUnion<readonly [z.ZodArray<z.ZodEnum<{
911
- invalid: "invalid";
912
938
  missing: "missing";
939
+ invalid: "invalid";
913
940
  extra: "extra";
914
941
  mismatch: "mismatch";
915
942
  aggregate: "aggregate";
916
943
  duplicate: "duplicate";
917
944
  }>>, z.ZodRecord<z.ZodEnum<{
918
- invalid: "invalid";
919
945
  missing: "missing";
946
+ invalid: "invalid";
920
947
  extra: "extra";
921
948
  mismatch: "mismatch";
922
949
  aggregate: "aggregate";
@@ -7,6 +7,7 @@ import { normalize, sourceRule } from './normalize.js';
7
7
  import { validAllowedHost } from './hosts.js';
8
8
  import { DateTime } from 'luxon';
9
9
  import { findingCategories } from './policy.js';
10
+ import { formatHasTime, isCalendarFormat } from './calendar.js';
10
11
  const name = z.string().min(1);
11
12
  const locale = name.refine((value) => {
12
13
  try {
@@ -42,6 +43,7 @@ const datetimeFormat = name.refine((format) => {
42
43
  return false;
43
44
  }
44
45
  }, 'Use supported datetime format tokens, e.g. yyyy-MM-dd HH:mm; quote literal text');
46
+ const dateFormat = datetimeFormat.refine(isCalendarFormat, 'Use a calendar date format with year, month and day, without time or time zone');
45
47
  export const reportFileMode = z
46
48
  .string({
47
49
  error: "Quote report.fileMode as an octal string, e.g. '0640' or '0o640'; plain integers are not accepted",
@@ -55,10 +57,14 @@ const sourceFormatting = {
55
57
  locale: locale.optional(),
56
58
  numberFormat: numberFormat.optional(),
57
59
  datetimeFormat: datetimeFormat.optional(),
60
+ dateFormat: dateFormat.optional(),
58
61
  };
59
62
  const envName = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/);
60
63
  export const fieldSchema = z.union([name, z.object({ path: name, label: name.optional() }).strict()]);
61
64
  const fields = z.record(name, fieldSchema).refine((v) => Object.keys(v).length > 0, 'Map at least one field');
65
+ const restFields = z
66
+ .record(name, z.union([name, z.object({ path: name, label: name.optional(), missing: z.null().optional() }).strict()]))
67
+ .refine((v) => Object.keys(v).length > 0, 'Map at least one field');
62
68
  const httpUrl = z.url().refine((v) => {
63
69
  try {
64
70
  const u = new URL(v);
@@ -156,7 +162,8 @@ export const sourceSchema = z.discriminatedUnion('type', [
156
162
  unique: z.array(name).default([]),
157
163
  baseUrl: httpUrl,
158
164
  path: name,
159
- fields,
165
+ fields: restFields,
166
+ absentAsNull: z.boolean().default(false),
160
167
  items: name.default('$.data[*]'),
161
168
  headers: z
162
169
  .record(name, z.string())
@@ -252,12 +259,13 @@ export const sourceSchema = z.discriminatedUnion('type', [
252
259
  ]);
253
260
  export const ruleSchema = z
254
261
  .object({
255
- type: z.enum(['string', 'number', 'money', 'datetime', 'enum']),
262
+ type: z.enum(['string', 'number', 'money', 'datetime', 'date', 'enum']),
256
263
  allowCurrencySymbol: z.boolean().default(false),
257
264
  negativeParentheses: z.boolean().default(false),
258
265
  format: z.union([datetimeFormat, z.record(name, datetimeFormat)]).optional(),
259
266
  allowNumber: z.boolean().default(false),
260
267
  caseFold: z.boolean().default(false),
268
+ whitespaceFold: z.boolean().default(false),
261
269
  emptyAsNull: z.boolean().default(true),
262
270
  locale: locale.optional(),
263
271
  numberFormat: numberFormat.optional(),
@@ -275,6 +283,26 @@ export const ruleSchema = z
275
283
  })
276
284
  .strict()
277
285
  .superRefine((v, ctx) => {
286
+ for (const [source, format] of Object.entries(typeof v.format === 'string' ? { '': v.format } : (v.format ?? {}))) {
287
+ if (v.type === 'datetime' && !formatHasTime(format))
288
+ ctx.addIssue({
289
+ code: 'custom',
290
+ path: ['format', ...(source ? [source] : [])],
291
+ message: 'Datetime date has no time in the configured format; use type: date for a calendar date',
292
+ });
293
+ if (v.type === 'date' && !isCalendarFormat(format))
294
+ ctx.addIssue({
295
+ code: 'custom',
296
+ path: ['format', ...(source ? [source] : [])],
297
+ message: 'Use a calendar date format without time or time zone',
298
+ });
299
+ }
300
+ if (v.type === 'date' &&
301
+ (Object.keys(v.sourceTimezones).length || v.tolerance || v.toleranceSeconds || v.displayTimezoneField))
302
+ ctx.addIssue({
303
+ code: 'custom',
304
+ message: 'Calendar dates have no time zone or time tolerance; compare the calendar day exactly',
305
+ });
278
306
  if (v.type === 'money' && !v.currency)
279
307
  ctx.addIssue({ code: 'custom', path: ['currency'], message: 'Money requires a currency (e.g. USD)' });
280
308
  if (v.type === 'money' && !Number.isSafeInteger(v.tolerance))
@@ -332,6 +360,17 @@ export const entitySchema = z
332
360
  message: 'Configure at least two sources; the first is the reference',
333
361
  });
334
362
  for (const [source, config] of Object.entries(v.sources)) {
363
+ for (const [field, rule] of Object.entries(v.compare))
364
+ if (rule.type === 'datetime' &&
365
+ Object.hasOwn(config.fields, field) &&
366
+ !(typeof rule.format === 'string' ? rule.format : rule.format?.[source]) &&
367
+ config.datetimeFormat &&
368
+ !formatHasTime(config.datetimeFormat))
369
+ ctx.addIssue({
370
+ code: 'custom',
371
+ path: ['sources', source, 'datetimeFormat'],
372
+ message: `Datetime format for ${field} has no time; use type: date for a calendar date`,
373
+ });
335
374
  if (keyFields(v.key).some((field) => !Object.hasOwn(config.fields, field)))
336
375
  ctx.addIssue({
337
376
  code: 'custom',
@@ -4,7 +4,7 @@ import { secretValues, redactSecrets, redactTree } from './diagnostics.js';
4
4
  import { keyFields } from './config.js';
5
5
  import { readSource } from './source-state.js';
6
6
  import { equalValues } from './normalize.js';
7
- import { aggregateEqual } from './aggregates.js';
7
+ import { aggregateEqual, sumDecimal } from './aggregates.js';
8
8
  import { applyFailurePolicies } from './policy.js';
9
9
  import { groupFindings, attachGroupMembership } from './groups.js';
10
10
  import { collectReconciliation, reconcileAggregates } from './reconciliation.js';
@@ -101,7 +101,7 @@ function compareSource(entity, source, reference, baseline, current, report) {
101
101
  const observations = new Map();
102
102
  for (const [key, row] of baseline.rows)
103
103
  for (const [field, value] of Object.entries(row)) {
104
- if (keys.has(field) || ['money', 'number', 'datetime'].includes(entity.compare[field]?.type ?? ''))
104
+ if (keys.has(field) || ['money', 'number', 'datetime', 'date'].includes(entity.compare[field]?.type ?? ''))
105
105
  continue;
106
106
  if (baseline.uncertain.get(key)?.has(field) || current.uncertain.get(key)?.has(field))
107
107
  continue;
@@ -228,6 +228,11 @@ function publishAggregates(entity, source, reference, state, summary, report) {
228
228
  aggregate.sourceStatus[source] = excluded.size ? 'uncertain' : 'certain';
229
229
  aggregate.uncertainKeys[source] = [...excluded].map((key) => state.parts.get(key) ?? [key]);
230
230
  aggregate.values[source] = actual;
231
+ const exactActual = state.exactMeasurements.get(definition.name);
232
+ if (exactActual !== undefined) {
233
+ aggregate.exactValues ??= {};
234
+ aggregate.exactValues[source] = exactActual;
235
+ }
231
236
  aggregate.rawValues ??= {};
232
237
  aggregate.rawValues[source] = state.rawMeasurements.get(definition.name) ?? actual;
233
238
  aggregate.excludedRows ??= {};
@@ -264,16 +269,18 @@ function publishAggregates(entity, source, reference, state, summary, report) {
264
269
  }
265
270
  continue;
266
271
  }
267
- const expected = aggregate.values[reference];
268
- if (source !== reference && expected !== undefined && !aggregateEqual(expected, actual, definition))
272
+ const expected = aggregate.values[reference], exactExpected = aggregate.exactValues?.[reference];
273
+ if (source !== reference &&
274
+ expected !== undefined &&
275
+ !aggregateEqual(exactExpected ?? expected, exactActual ?? actual, definition))
269
276
  appendFinding(report, {
270
277
  type: 'aggregate_mismatch',
271
278
  entity: entity.name,
272
279
  source,
273
280
  key: '',
274
281
  field: definition.name,
275
- expected,
276
- actual,
282
+ expected: exactExpected !== undefined && sumDecimal(expected) !== exactExpected ? exactExpected : expected,
283
+ actual: exactActual !== undefined && sumDecimal(actual) !== exactActual ? exactActual : actual,
277
284
  message: `${definition.name} differs from ${reference}.`,
278
285
  });
279
286
  }
@@ -6,7 +6,7 @@ export interface PatternOptions {
6
6
  }
7
7
  export declare function strideGroups(bucket: readonly Finding[], minCount: number, finalKey?: string): FindingGroup[];
8
8
  export declare function deltaGroups(bucket: readonly Finding[], entities: readonly EntityConfig[], minCount: number): FindingGroup[];
9
- export declare function mappingGroups(bucket: readonly Finding[], minCount: number): FindingGroup[];
9
+ export declare function mappingGroups(bucket: readonly Finding[], minCount: number, rule?: EntityConfig['compare'][string]): FindingGroup[];
10
10
  export declare function groupFindings(findings: readonly Finding[], entities?: readonly EntityConfig[], finalReferenceKeys?: Readonly<Record<string, string>>, options?: PatternOptions): FindingGroup[];
11
11
  export declare function reportGroups(report: Report, presentation?: ReportPresentation): FindingGroup[];
12
12
  export declare function groupMembership(report: Report, presentation?: ReportPresentation): number[][];
@@ -166,7 +166,7 @@ export function deltaGroups(bucket, entities, minCount) {
166
166
  ]
167
167
  : []);
168
168
  }
169
- export function mappingGroups(bucket, minCount) {
169
+ export function mappingGroups(bucket, minCount, rule) {
170
170
  const pairs = new Map();
171
171
  for (const finding of bucket) {
172
172
  if (finding.reason === 'currency_mismatch')
@@ -183,18 +183,38 @@ export function mappingGroups(bucket, minCount) {
183
183
  if (!first || entries.length < minCount || first.expected === undefined || first.actual === undefined)
184
184
  return [];
185
185
  const summary = `${entries.length.toLocaleString('en-US')} ${first.field} values in ${first.source} map ${quoted(first.expected)} to ${quoted(first.actual)}.`;
186
+ const fold = (value) => value.trim().replace(/\s+/gu, ' ');
187
+ const formatting = (!rule || rule.type === 'enum' || rule.type === 'string') &&
188
+ typeof first.expected === 'string' &&
189
+ typeof first.actual === 'string' &&
190
+ fold(first.expected).toLocaleLowerCase(rule?.locale ?? 'en-US') ===
191
+ fold(first.actual).toLocaleLowerCase(rule?.locale ?? 'en-US');
192
+ const formattingCause = formatting
193
+ ? fold(String(first.expected)) === fold(String(first.actual))
194
+ ? 'whitespace_only'
195
+ : String(first.expected).toLocaleLowerCase(rule?.locale ?? 'en-US') ===
196
+ String(first.actual).toLocaleLowerCase(rule?.locale ?? 'en-US')
197
+ ? 'case_only'
198
+ : 'case_whitespace'
199
+ : undefined;
186
200
  return [
187
201
  {
188
202
  ...identity(first),
189
203
  count: entries.length,
190
- kind: 'value_mapping',
191
- ...(entries.some((item) => item.evidence?.sourceContainsReference)
192
- ? {
193
- cause: 'stale_value',
194
- referenceCount: first.evidence?.referenceCount ?? entries.length,
195
- matchingCount: first.evidence?.matchingCount ?? 0,
196
- }
197
- : {}),
204
+ kind: rule?.type === 'number'
205
+ ? 'number_change'
206
+ : formatting
207
+ ? 'formatting_difference'
208
+ : 'value_mapping',
209
+ ...(formattingCause
210
+ ? { cause: formattingCause }
211
+ : rule?.type !== 'number' && entries.some((item) => item.evidence?.sourceContainsReference)
212
+ ? {
213
+ cause: 'stale_value',
214
+ referenceCount: first.evidence?.referenceCount ?? entries.length,
215
+ matchingCount: first.evidence?.matchingCount ?? 0,
216
+ }
217
+ : {}),
198
218
  expected: first.expected,
199
219
  actual: first.actual,
200
220
  summary,
@@ -226,7 +246,9 @@ export function groupFindings(findings, entities = [], finalReferenceKeys = {},
226
246
  const rule = entities.find((entity) => entity.name === first.entity)?.compare[first.field];
227
247
  const candidates = rule?.type === 'money' || rule?.type === 'datetime'
228
248
  ? deltaGroups(bucket, entities, minCount)
229
- : mappingGroups(bucket, minCount);
249
+ : rule?.type === 'date'
250
+ ? []
251
+ : mappingGroups(bucket, minCount, rule);
230
252
  for (const group of candidates)
231
253
  groups.push(group);
232
254
  }
@@ -262,7 +284,7 @@ export function groupMembership(report, presentation = {}) {
262
284
  : [];
263
285
  if (finding.type !== 'field_mismatch' || finding.field !== group.field)
264
286
  return [];
265
- if (group.kind === 'value_mapping')
287
+ if (['value_mapping', 'formatting_difference', 'number_change'].includes(group.kind))
266
288
  return finding.expected === group.expected && finding.actual === group.actual ? [index] : [];
267
289
  const expected = integerValue(finding.expected), actual = integerValue(finding.actual);
268
290
  return expected !== undefined && actual !== undefined && String(actual - expected) === group.delta ? [index] : [];
@@ -26,6 +26,16 @@ export declare const kindInfo: {
26
26
  readonly color: "tz";
27
27
  readonly path: "<path d=\"M4 7h16l-4-4M20 17H4l4 4\"/>";
28
28
  };
29
+ readonly formatting_difference: {
30
+ readonly label: "Formatting difference";
31
+ readonly color: "tz";
32
+ readonly path: "<path d=\"M4 7h16M4 12h10M4 17h16\"/>";
33
+ };
34
+ readonly number_change: {
35
+ readonly label: "Repeated numeric change";
36
+ readonly color: "round";
37
+ readonly path: "<path d=\"M4 7h16l-4-4M20 17H4l4 4\"/>";
38
+ };
29
39
  };
30
40
  export declare function patternKind(group: FindingGroup): {
31
41
  readonly label: "Page boundary";
@@ -47,6 +57,14 @@ export declare function patternKind(group: FindingGroup): {
47
57
  readonly label: "Value mapping";
48
58
  readonly color: "tz";
49
59
  readonly path: "<path d=\"M4 7h16l-4-4M20 17H4l4 4\"/>";
60
+ } | {
61
+ readonly label: "Formatting difference";
62
+ readonly color: "tz";
63
+ readonly path: "<path d=\"M4 7h16M4 12h10M4 17h16\"/>";
64
+ } | {
65
+ readonly label: "Repeated numeric change";
66
+ readonly color: "round";
67
+ readonly path: "<path d=\"M4 7h16l-4-4M20 17H4l4 4\"/>";
50
68
  } | {
51
69
  label: string;
52
70
  color: "page";
@@ -67,6 +85,14 @@ export declare function patternKind(group: FindingGroup): {
67
85
  label: string;
68
86
  color: "tz";
69
87
  path: "<path d=\"M4 7h16l-4-4M20 17H4l4 4\"/>";
88
+ } | {
89
+ label: string;
90
+ color: "tz";
91
+ path: "<path d=\"M4 7h16M4 12h10M4 17h16\"/>";
92
+ } | {
93
+ label: string;
94
+ color: "round";
95
+ path: "<path d=\"M4 7h16l-4-4M20 17H4l4 4\"/>";
70
96
  };
71
97
  export declare const icon: (path: string) => string;
72
98
  export interface PatternCard {
@@ -21,6 +21,8 @@ export const kindInfo = {
21
21
  path: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M3 9h18M7 14h4"/>',
22
22
  },
23
23
  value_mapping: { label: 'Value mapping', color: 'tz', path: '<path d="M4 7h16l-4-4M20 17H4l4 4"/>' },
24
+ formatting_difference: { label: 'Formatting difference', color: 'tz', path: '<path d="M4 7h16M4 12h10M4 17h16"/>' },
25
+ number_change: { label: 'Repeated numeric change', color: 'round', path: '<path d="M4 7h16l-4-4M20 17H4l4 4"/>' },
24
26
  };
25
27
  export function patternKind(group) {
26
28
  const info = kindInfo[group.kind];
@@ -53,7 +55,9 @@ export function patternCards(report, presentation) {
53
55
  group.stride,
54
56
  group.cause,
55
57
  group.currency ?? rule?.currency,
56
- group.kind === 'value_mapping' ? [group.expected, group.actual] : [],
58
+ ['value_mapping', 'formatting_difference', 'number_change'].includes(group.kind)
59
+ ? [group.expected, group.actual]
60
+ : [],
57
61
  ], (_, value) => (typeof value === 'bigint' ? String(value) : value));
58
62
  let card = cards.get(key);
59
63
  if (!card) {
@@ -72,7 +76,11 @@ export function patternCards(report, presentation) {
72
76
  ? `${delta > 0n ? '+' : ''}${formatMoneyMajor(delta, group.currency ?? rule.currency)}`.replace('-', '−')
73
77
  : group.cause === 'stale_value'
74
78
  ? 'Some values lag'
75
- : 'Consistent mapping',
79
+ : group.kind === 'formatting_difference'
80
+ ? 'Formatting differs'
81
+ : group.kind === 'number_change'
82
+ ? 'Numeric values differ'
83
+ : 'Consistent mapping',
76
84
  hint: patternWords(group, presentation).hint,
77
85
  nextStep: patternWords(group, presentation).nextStep,
78
86
  };
@@ -195,7 +203,7 @@ export function patternCards(report, presentation) {
195
203
  }
196
204
  else {
197
205
  card.chart =
198
- svg(210, 70, `${sources}: ${first.cause === 'stale_value' ? 'stale or lagging values' : 'consistent value mapping'}.`, `<text x="105" y="25" text-anchor="middle" fill="var(--ink)" font-size="13">${escape(presentationValue(first.expected, first.entity, first.field, false, presentation))} → ${escape(presentationValue(first.actual, first.entity, first.field, false, presentation))}</text><text x="105" y="53" text-anchor="middle" fill="var(--muted)" font-size="12">${members.length} values</text>`) + caption(`${sources}: ${labels.field(first.entity, first.field)}.`);
206
+ svg(210, 70, `${sources}: ${patternKind(first).label}.`, `<text x="105" y="25" text-anchor="middle" fill="var(--ink)" font-size="13" style="white-space:pre">${escape(first.kind === 'formatting_difference' ? JSON.stringify(first.expected) : presentationValue(first.expected, first.entity, first.field, false, presentation))} → ${escape(first.kind === 'formatting_difference' ? JSON.stringify(first.actual) : presentationValue(first.actual, first.entity, first.field, false, presentation))}</text><text x="105" y="53" text-anchor="middle" fill="var(--muted)" font-size="12">${members.length} values</text>`) + caption(`${sources}: ${labels.field(first.entity, first.field)}.`);
199
207
  }
200
208
  }
201
209
  return [...cards.values()];