@mailwoman/geographic-model 9.3.0 → 10.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.
Files changed (39) hide show
  1. package/lib/artifact.ts +0 -1
  2. package/lib/compile.ts +3 -7
  3. package/lib/load.ts +10 -5
  4. package/lib/lookup.ts +2 -1
  5. package/lib/validate.ts +12 -359
  6. package/lib/validation/references.ts +364 -0
  7. package/out/artifact.d.ts +0 -7
  8. package/out/artifact.d.ts.map +1 -1
  9. package/out/artifact.js +0 -1
  10. package/out/artifact.js.map +1 -1
  11. package/out/compile.d.ts.map +1 -1
  12. package/out/compile.js +2 -1
  13. package/out/compile.js.map +1 -1
  14. package/out/load.d.ts +5 -5
  15. package/out/load.d.ts.map +1 -1
  16. package/out/load.js +9 -4
  17. package/out/load.js.map +1 -1
  18. package/out/lookup.d.ts +1 -1
  19. package/out/lookup.d.ts.map +1 -1
  20. package/out/lookup.js +1 -1
  21. package/out/lookup.js.map +1 -1
  22. package/out/schema.d.ts.map +1 -1
  23. package/out/scripts/build-artifact.js.map +1 -1
  24. package/out/validate.d.ts +60 -3
  25. package/out/validate.d.ts.map +1 -1
  26. package/out/validate.js +3 -202
  27. package/out/validate.js.map +1 -1
  28. package/out/{validation-issues.d.ts → validation/issues.d.ts} +1 -1
  29. package/out/validation/issues.d.ts.map +1 -0
  30. package/out/{validation-issues.js → validation/issues.js} +1 -1
  31. package/out/validation/issues.js.map +1 -0
  32. package/out/validation/references.d.ts +10 -0
  33. package/out/validation/references.d.ts.map +1 -0
  34. package/out/validation/references.js +208 -0
  35. package/out/validation/references.js.map +1 -0
  36. package/package.json +3 -3
  37. package/out/validation-issues.d.ts.map +0 -1
  38. package/out/validation-issues.js.map +0 -1
  39. /package/lib/{validation-issues.ts → validation/issues.ts} +0 -0
package/lib/artifact.ts CHANGED
@@ -93,7 +93,6 @@ export interface CompiledGeographicModel {
93
93
  * `String.prototype.localeCompare` is the trap this exists to avoid — its answer depends on the machine's collation, so
94
94
  * an artifact ordered with it is reproducible only on the machine that built it.
95
95
  */
96
- export { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
97
96
 
98
97
  /**
99
98
  * Rebuild `value` with every object's keys in code-point order, at every depth. Arrays keep their order — ordering
package/lib/compile.ts CHANGED
@@ -31,12 +31,9 @@
31
31
  * it total for any graph rather than for the graphs the validator happens to admit.
32
32
  */
33
33
 
34
- import {
35
- ARTIFACT_SCHEMA_VERSION,
36
- type CompiledGeographicModel,
37
- compareIdentifiers,
38
- type InheritanceClosureEntry,
39
- } from "#artifact"
34
+ import { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
35
+
36
+ import { ARTIFACT_SCHEMA_VERSION, type CompiledGeographicModel, type InheritanceClosureEntry } from "#artifact"
40
37
  import {
41
38
  type ConceptID,
42
39
  type ConceptRecord,
@@ -50,7 +47,6 @@ import {
50
47
  toDerivedFactID,
51
48
  } from "#schema"
52
49
  import { parseGeographicModelDocument } from "#validate"
53
-
54
50
  /**
55
51
  * The name a fact derived by `isA` inheritance carries in its `derivation` field. A consumer branches on this rather
56
52
  * than on where the record sits.
package/lib/load.ts CHANGED
@@ -26,11 +26,12 @@
26
26
  * keys it uses are tables.
27
27
  */
28
28
 
29
- import { readDirectoryEntries, readLocalTextFile } from "@mailwoman/core/fs/readers"
29
+ import { readLocalTextFile } from "@mailwoman/core/fs/readers"
30
30
  import { isPlainObject } from "@mailwoman/core/objects"
31
+ import { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
31
32
  import { resolvePath } from "path-ts"
33
+ import { Globerator } from "spliterator/node/fs"
32
34
 
33
- import { compareIdentifiers } from "#artifact"
34
35
  import type { GeographicModelDocument } from "#schema"
35
36
  import { validateGeographicModelDocument } from "#validate"
36
37
  import {
@@ -40,8 +41,7 @@ import {
40
41
  readString,
41
42
  type ValidationIssue,
42
43
  ValidationIssueCode,
43
- } from "#validation-issues"
44
-
44
+ } from "#validation/issues"
45
45
  /**
46
46
  * The manifest every model directory carries: the document's `version`, and nothing else.
47
47
  */
@@ -364,7 +364,12 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
364
364
  * a link out of it is a record whose home nobody can state.
365
365
  */
366
366
  async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
367
- const entries = await readDirectoryEntries(resolvePath(root, prefix))
367
+ const entries = await Globerator.from("*", {
368
+ cwd: resolvePath(root, prefix),
369
+ withFileTypes: true,
370
+ onlyFiles: false,
371
+ }).toArray()
372
+
368
373
  const found: string[] = []
369
374
 
370
375
  for (const entry of entries.toSorted((left, right) => compareIdentifiers(left.name, right.name))) {
package/lib/lookup.ts CHANGED
@@ -18,9 +18,10 @@
18
18
  * concept it had never heard of.
19
19
  */
20
20
 
21
+ import { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
21
22
  import type { POICategoryID } from "@mailwoman/poi-taxonomy/types"
22
23
 
23
- import { type CompiledGeographicModel, compareIdentifiers } from "#artifact"
24
+ import type { CompiledGeographicModel } from "#artifact"
24
25
  import type {
25
26
  ConceptID,
26
27
  ConceptRecord,
package/lib/validate.ts CHANGED
@@ -41,7 +41,6 @@ import {
41
41
  import {
42
42
  add,
43
43
  checkFieldNames,
44
- listVocabulary,
45
44
  readArray,
46
45
  readBoolean,
47
46
  readString,
@@ -50,9 +49,10 @@ import {
50
49
  readVocabularyValue,
51
50
  type ValidationIssue,
52
51
  ValidationIssueCode,
53
- } from "#validation-issues"
52
+ } from "#validation/issues"
53
+ import { checkReferences } from "#validation/references"
54
54
 
55
- export { type ValidationIssue, ValidationIssueCode } from "#validation-issues"
55
+ export { type ValidationIssue, ValidationIssueCode } from "#validation/issues"
56
56
 
57
57
  /**
58
58
  * The whole document, or every reason it is not one.
@@ -101,14 +101,14 @@ const DERIVATION_INPUT_FIELDS = ["kind", "id"] as const
101
101
  */
102
102
  const COUNTRY_PATTERN = /^[A-Z]{2}$/
103
103
 
104
- interface AssertionView {
104
+ export interface AssertionView {
105
105
  path: string
106
106
  id?: string
107
107
  relation?: string
108
108
  target?: string
109
109
  }
110
110
 
111
- interface ConceptView {
111
+ export interface ConceptView {
112
112
  path: string
113
113
  id?: string
114
114
  kind?: ConceptKind
@@ -116,7 +116,7 @@ interface ConceptView {
116
116
  assertions: AssertionView[]
117
117
  }
118
118
 
119
- interface RelationView {
119
+ export interface RelationView {
120
120
  path: string
121
121
  id?: string
122
122
  domainKinds?: ConceptKind[]
@@ -126,13 +126,13 @@ interface RelationView {
126
126
  inverse?: string
127
127
  }
128
128
 
129
- interface MappingView {
129
+ export interface MappingView {
130
130
  path: string
131
131
  id?: string
132
132
  concept?: string
133
133
  }
134
134
 
135
- interface TripleView {
135
+ export interface TripleView {
136
136
  path: string
137
137
  id?: string
138
138
  subject?: string
@@ -140,17 +140,17 @@ interface TripleView {
140
140
  object?: string
141
141
  }
142
142
 
143
- interface DerivationInputView {
143
+ export interface DerivationInputView {
144
144
  path: string
145
145
  kind?: DerivationInputKind
146
146
  id?: string
147
147
  }
148
148
 
149
- interface DerivedFactView extends TripleView {
149
+ export interface DerivedFactView extends TripleView {
150
150
  inputs: DerivationInputView[]
151
151
  }
152
152
 
153
- interface DocumentView {
153
+ export interface DocumentView {
154
154
  relations: RelationView[]
155
155
  concepts: ConceptView[]
156
156
  mappings: MappingView[]
@@ -158,7 +158,7 @@ interface DocumentView {
158
158
  derivedFacts: DerivedFactView[]
159
159
  }
160
160
 
161
- interface ReferenceTables {
161
+ export interface ReferenceTables {
162
162
  concepts: Map<string, ConceptView>
163
163
  relations: Map<string, RelationView>
164
164
  assertions: ReadonlyMap<string, unknown>
@@ -424,353 +424,6 @@ function readTable<T>(
424
424
  return records
425
425
  }
426
426
 
427
- /**
428
- * Index a table by identifier, reporting every record after the first that claims an identifier already taken. The
429
- * first claimant keeps the identifier, so a duplicate never silently displaces the record other rows resolve against.
430
- */
431
- function indexByID<T extends { path: string; id?: string }>(
432
- issues: ValidationIssue[],
433
- records: readonly T[],
434
- label: string
435
- ): Map<string, T> {
436
- const index = new Map<string, T>()
437
-
438
- for (const record of records) {
439
- if (record.id === undefined) continue
440
-
441
- if (index.has(record.id)) {
442
- add(
443
- issues,
444
- `${record.path}.id`,
445
- ValidationIssueCode.DuplicateID,
446
- `\`${record.id}\` is already used by another ${label}`
447
- )
448
-
449
- continue
450
- }
451
-
452
- index.set(record.id, record)
453
- }
454
-
455
- return index
456
- }
457
-
458
- interface EdgeCheck {
459
- subjectKind?: ConceptKind
460
- subjectPath: string
461
- relationID?: string
462
- relationPath: string
463
- objectID?: string
464
- objectPath: string
465
- }
466
-
467
- /**
468
- * Resolve one subject–relation–object edge and check it against the relation's declared domain and range kinds.
469
- *
470
- * Shared by authored assertions, source observations, and derived facts. The three differ in who stands behind them and
471
- * in what provenance they carry, and the structural question asked of them is the same one.
472
- */
473
- function checkEdge(issues: ValidationIssue[], edge: EdgeCheck, tables: ReferenceTables): void {
474
- const relation = edge.relationID === undefined ? undefined : tables.relations.get(edge.relationID)
475
-
476
- if (edge.relationID !== undefined && !relation) {
477
- add(
478
- issues,
479
- edge.relationPath,
480
- ValidationIssueCode.UnknownRelation,
481
- `\`${edge.relationID}\` is not a relation declared in this document`
482
- )
483
- }
484
-
485
- const object = edge.objectID === undefined ? undefined : tables.concepts.get(edge.objectID)
486
-
487
- if (edge.objectID !== undefined && !object) {
488
- add(
489
- issues,
490
- edge.objectPath,
491
- ValidationIssueCode.UnknownConcept,
492
- `\`${edge.objectID}\` is not a concept declared in this document`
493
- )
494
- }
495
-
496
- if (!relation) return
497
-
498
- if (edge.subjectKind && relation.domainKinds && !relation.domainKinds.includes(edge.subjectKind)) {
499
- add(
500
- issues,
501
- edge.subjectPath,
502
- ValidationIssueCode.DomainKindMismatch,
503
- `relation \`${relation.id}\` accepts ${listVocabulary(relation.domainKinds)} on the asserting side, not \`${edge.subjectKind}\``
504
- )
505
- }
506
-
507
- if (object?.kind && relation.rangeKinds && !relation.rangeKinds.includes(object.kind)) {
508
- add(
509
- issues,
510
- edge.objectPath,
511
- ValidationIssueCode.RangeKindMismatch,
512
- `relation \`${relation.id}\` accepts ${listVocabulary(relation.rangeKinds)} on the target side, not \`${object.kind}\``
513
- )
514
- }
515
- }
516
-
517
- function sameKinds(left: readonly ConceptKind[], right: readonly ConceptKind[]): boolean {
518
- return left.length === right.length && left.every((kind) => right.includes(kind))
519
- }
520
-
521
- /**
522
- * Check one relation's inverse and its transitivity against the kinds it declares.
523
- */
524
- function checkRelation(
525
- issues: ValidationIssue[],
526
- relation: RelationView,
527
- index: ReadonlyMap<string, RelationView>
528
- ): void {
529
- const inversePath = `${relation.path}.inverse`
530
-
531
- if (relation.transitive === true && relation.domainKinds && relation.rangeKinds) {
532
- const chainable = relation.rangeKinds.some((kind) => relation.domainKinds?.includes(kind))
533
-
534
- if (!chainable) {
535
- add(
536
- issues,
537
- `${relation.path}.transitive`,
538
- ValidationIssueCode.TransitiveKindsDisjoint,
539
- "a transitive relation has to be able to chain, so its range kinds and its domain kinds must overlap"
540
- )
541
- }
542
- }
543
-
544
- if (relation.inverse === undefined) return
545
-
546
- const inverse = index.get(relation.inverse)
547
-
548
- if (!inverse) {
549
- add(
550
- issues,
551
- inversePath,
552
- ValidationIssueCode.UnknownRelation,
553
- `\`${relation.inverse}\` is not a relation declared in this document`
554
- )
555
-
556
- return
557
- }
558
-
559
- if (relation.symmetric === true && inverse.id !== relation.id) {
560
- add(
561
- issues,
562
- inversePath,
563
- ValidationIssueCode.InverseNotReciprocal,
564
- `\`${relation.id}\` is symmetric, so it is its own inverse; it names \`${relation.inverse}\``
565
- )
566
-
567
- return
568
- }
569
-
570
- if (inverse.inverse !== relation.id) {
571
- add(
572
- issues,
573
- inversePath,
574
- ValidationIssueCode.InverseNotReciprocal,
575
- `\`${relation.inverse}\` does not name \`${relation.id}\` as its own inverse`
576
- )
577
- }
578
-
579
- if (
580
- inverse.id !== relation.id &&
581
- relation.domainKinds &&
582
- relation.rangeKinds &&
583
- inverse.domainKinds &&
584
- inverse.rangeKinds &&
585
- !(sameKinds(relation.domainKinds, inverse.rangeKinds) && sameKinds(relation.rangeKinds, inverse.domainKinds))
586
- ) {
587
- add(
588
- issues,
589
- inversePath,
590
- ValidationIssueCode.InverseKindsMismatch,
591
- `an inverse reads the same edge backwards, so \`${relation.inverse}\` has to declare this relation's range kinds as its domain kinds, and the reverse`
592
- )
593
- }
594
- }
595
-
596
- /**
597
- * Follow `isA` upward from one concept and report the trail if it returns to where it started.
598
- *
599
- * The direct self-edge is left out of the walk: `checkIsA` already reports that as a self-reference, at the entry that
600
- * carries it, and a second report saying the same concept cycles through itself tells its author nothing new.
601
- */
602
- function findIsACycle(start: ConceptView, concepts: ReadonlyMap<string, ConceptView>): string[] | undefined {
603
- if (start.id === undefined) return undefined
604
-
605
- const startID = start.id
606
- const visited = new Set<string>()
607
-
608
- const frontier: Array<{ id: string; trail: string[] }> = (start.isA ?? [])
609
- .filter((parent) => parent !== startID)
610
- .map((parent) => ({ id: parent, trail: [startID, parent] }))
611
-
612
- while (frontier.length) {
613
- const step = frontier.pop()
614
-
615
- if (!step) break
616
-
617
- if (step.id === startID) return step.trail
618
-
619
- if (visited.has(step.id)) continue
620
-
621
- visited.add(step.id)
622
-
623
- for (const parent of concepts.get(step.id)?.isA ?? []) {
624
- frontier.push({ id: parent, trail: [...step.trail, parent] })
625
- }
626
- }
627
-
628
- return undefined
629
- }
630
-
631
- function checkIsA(issues: ValidationIssue[], concept: ConceptView, concepts: ReadonlyMap<string, ConceptView>): void {
632
- if (!concept.isA) return
633
-
634
- for (const [index, parent] of concept.isA.entries()) {
635
- const parentPath = `${concept.path}.isA[${index}]`
636
-
637
- if (parent === concept.id) {
638
- add(issues, parentPath, ValidationIssueCode.SelfReference, "a concept is not a kind of itself")
639
-
640
- continue
641
- }
642
-
643
- if (!concepts.has(parent)) {
644
- add(
645
- issues,
646
- parentPath,
647
- ValidationIssueCode.UnknownConcept,
648
- `\`${parent}\` is not a concept declared in this document`
649
- )
650
- }
651
- }
652
-
653
- const cycle = findIsACycle(concept, concepts)
654
-
655
- if (cycle) {
656
- add(issues, `${concept.path}.isA`, ValidationIssueCode.CyclicIsA, `\`isA\` cycles through ${cycle.join(" → ")}`)
657
- }
658
- }
659
-
660
- function checkDerivationInputs(issues: ValidationIssue[], fact: DerivedFactView, tables: ReferenceTables): void {
661
- const byKind: Record<DerivationInputKind, ReadonlyMap<string, unknown>> = {
662
- [DerivationInputKind.Concept]: tables.concepts,
663
- [DerivationInputKind.Relation]: tables.relations,
664
- [DerivationInputKind.Assertion]: tables.assertions,
665
- [DerivationInputKind.Mapping]: tables.mappings,
666
- [DerivationInputKind.Observation]: tables.observations,
667
- [DerivationInputKind.DerivedFact]: tables.derivedFacts,
668
- }
669
-
670
- for (const input of fact.inputs) {
671
- if (input.kind === undefined || input.id === undefined) continue
672
-
673
- if (input.kind === DerivationInputKind.DerivedFact && input.id === fact.id) {
674
- add(issues, `${input.path}.id`, ValidationIssueCode.SelfReference, "a derived fact is not one of its own inputs")
675
-
676
- continue
677
- }
678
-
679
- if (!byKind[input.kind].has(input.id)) {
680
- add(
681
- issues,
682
- `${input.path}.id`,
683
- ValidationIssueCode.UnknownDerivationInput,
684
- `no \`${input.kind}\` record in this document is identified by \`${input.id}\``
685
- )
686
- }
687
- }
688
- }
689
-
690
- function checkReferences(issues: ValidationIssue[], view: DocumentView): void {
691
- const assertions: AssertionView[] = []
692
-
693
- for (const concept of view.concepts) {
694
- assertions.push(...concept.assertions)
695
- }
696
-
697
- const relations = indexByID(issues, view.relations, "relation")
698
- const concepts = indexByID(issues, view.concepts, "concept")
699
-
700
- const tables: ReferenceTables = {
701
- relations,
702
- concepts,
703
- assertions: indexByID(issues, assertions, "assertion"),
704
- mappings: indexByID(issues, view.mappings, "mapping"),
705
- observations: indexByID(issues, view.observations, "observation"),
706
- derivedFacts: indexByID(issues, view.derivedFacts, "derived fact"),
707
- }
708
-
709
- for (const relation of view.relations) {
710
- checkRelation(issues, relation, relations)
711
- }
712
-
713
- for (const concept of view.concepts) {
714
- checkIsA(issues, concept, concepts)
715
-
716
- for (const assertion of concept.assertions) {
717
- checkEdge(
718
- issues,
719
- {
720
- subjectKind: concept.kind,
721
- subjectPath: `${assertion.path}.relation`,
722
- relationID: assertion.relation,
723
- relationPath: `${assertion.path}.relation`,
724
- objectID: assertion.target,
725
- objectPath: `${assertion.path}.target`,
726
- },
727
- tables
728
- )
729
- }
730
- }
731
-
732
- for (const mapping of view.mappings) {
733
- if (mapping.concept !== undefined && !concepts.has(mapping.concept)) {
734
- add(
735
- issues,
736
- `${mapping.path}.concept`,
737
- ValidationIssueCode.UnknownConcept,
738
- `\`${mapping.concept}\` is not a concept declared in this document`
739
- )
740
- }
741
- }
742
-
743
- for (const triple of [...view.observations, ...view.derivedFacts]) {
744
- const subject = triple.subject === undefined ? undefined : concepts.get(triple.subject)
745
-
746
- if (triple.subject !== undefined && !subject) {
747
- add(
748
- issues,
749
- `${triple.path}.subject`,
750
- ValidationIssueCode.UnknownConcept,
751
- `\`${triple.subject}\` is not a concept declared in this document`
752
- )
753
- }
754
-
755
- checkEdge(
756
- issues,
757
- {
758
- subjectKind: subject?.kind,
759
- subjectPath: `${triple.path}.subject`,
760
- relationID: triple.relation,
761
- relationPath: `${triple.path}.relation`,
762
- objectID: triple.object,
763
- objectPath: `${triple.path}.object`,
764
- },
765
- tables
766
- )
767
- }
768
-
769
- for (const fact of view.derivedFacts) {
770
- checkDerivationInputs(issues, fact, tables)
771
- }
772
- }
773
-
774
427
  function collectIssues(input: unknown): ValidationIssue[] {
775
428
  const issues: ValidationIssue[] = []
776
429