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.
- package/CHANGELOG.md +18 -0
- package/README.md +16 -7
- package/docs/configuration.md +28 -5
- package/docs/recipes/socrata.md +38 -0
- package/docs/release.md +1 -1
- package/docs/report-formats.md +6 -2
- package/docs/report-schema.json +32 -6
- package/docs/report-schema.md +6 -4
- package/docs/scenario-report-schema.json +2 -2
- package/lib/adapters/dist/jsonpath.d.ts +2 -2
- package/lib/adapters/dist/jsonpath.js +14 -6
- package/lib/adapters/dist/rest.js +8 -2
- package/lib/cli/dist/version.d.ts +1 -1
- package/lib/cli/dist/version.js +1 -1
- package/lib/core/dist/aggregates.d.ts +8 -3
- package/lib/core/dist/aggregates.js +30 -3
- package/lib/core/dist/calendar.d.ts +3 -0
- package/lib/core/dist/calendar.js +30 -0
- package/lib/core/dist/config.d.ts +33 -6
- package/lib/core/dist/config.js +41 -2
- package/lib/core/dist/engine.js +13 -6
- package/lib/core/dist/groups.d.ts +1 -1
- package/lib/core/dist/groups.js +33 -11
- package/lib/core/dist/html-patterns.d.ts +26 -0
- package/lib/core/dist/html-patterns.js +11 -3
- package/lib/core/dist/html.js +23 -16
- package/lib/core/dist/human.js +30 -3
- package/lib/core/dist/labels.d.ts +12 -3
- package/lib/core/dist/money-display.js +12 -7
- package/lib/core/dist/normalize.js +10 -2
- package/lib/core/dist/reconciliation-display.js +8 -5
- package/lib/core/dist/reconciliation.js +29 -23
- package/lib/core/dist/source-state.d.ts +1 -0
- package/lib/core/dist/source-state.js +2 -1
- package/lib/core/dist/types.d.ts +3 -2
- package/lib/scenarios/dist/config.d.ts +14 -0
- 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";
|
package/lib/core/dist/config.js
CHANGED
|
@@ -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',
|
package/lib/core/dist/engine.js
CHANGED
|
@@ -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 &&
|
|
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[][];
|
package/lib/core/dist/groups.js
CHANGED
|
@@ -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: '
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
:
|
|
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.
|
|
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()];
|