@mailwoman/geographic-model 10.0.0 → 10.1.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/README.md +32 -32
- package/data/geographic-model.json +5 -5
- package/data/model/concepts.json +5 -5
- package/lib/artifact.ts +34 -62
- package/lib/compile.ts +52 -67
- package/lib/index.ts +21 -35
- package/lib/load.ts +46 -61
- package/lib/lookup.ts +12 -28
- package/lib/packaged.ts +49 -0
- package/lib/schema.ts +104 -97
- package/lib/validate.ts +12 -34
- package/lib/validation/issues.ts +13 -17
- package/lib/validation/references.ts +6 -9
- package/out/artifact.d.ts +28 -45
- package/out/artifact.d.ts.map +1 -1
- package/out/artifact.js +23 -50
- package/out/artifact.js.map +1 -1
- package/out/compile.d.ts +27 -41
- package/out/compile.d.ts.map +1 -1
- package/out/compile.js +41 -63
- package/out/compile.js.map +1 -1
- package/out/index.d.ts +21 -35
- package/out/index.d.ts.map +1 -1
- package/out/index.js +21 -35
- package/out/index.js.map +1 -1
- package/out/load.d.ts +29 -38
- package/out/load.d.ts.map +1 -1
- package/out/load.js +33 -50
- package/out/load.js.map +1 -1
- package/out/lookup.d.ts +12 -28
- package/out/lookup.d.ts.map +1 -1
- package/out/lookup.js +4 -16
- package/out/lookup.js.map +1 -1
- package/out/packaged.d.ts +24 -0
- package/out/packaged.d.ts.map +1 -0
- package/out/packaged.js +41 -0
- package/out/packaged.js.map +1 -0
- package/out/schema.d.ts +104 -97
- package/out/schema.d.ts.map +1 -1
- package/out/schema.js +37 -52
- package/out/schema.js.map +1 -1
- package/out/tools/build-artifact.d.ts +27 -0
- package/out/tools/build-artifact.d.ts.map +1 -0
- package/out/tools/build-artifact.js +44 -0
- package/out/tools/build-artifact.js.map +1 -0
- package/out/validate.d.ts +9 -29
- package/out/validate.d.ts.map +1 -1
- package/out/validate.js +12 -34
- package/out/validate.js.map +1 -1
- package/out/validation/issues.d.ts +11 -14
- package/out/validation/issues.d.ts.map +1 -1
- package/out/validation/issues.js +11 -15
- package/out/validation/issues.js.map +1 -1
- package/out/validation/references.d.ts.map +1 -1
- package/out/validation/references.js +6 -9
- package/out/validation/references.js.map +1 -1
- package/package.json +34 -75
- package/tools/build-artifact.ts +56 -0
- package/out/scripts/build-artifact.d.ts +0 -51
- package/out/scripts/build-artifact.d.ts.map +0 -1
- package/out/scripts/build-artifact.js +0 -85
- package/out/scripts/build-artifact.js.map +0 -1
package/lib/compile.ts
CHANGED
|
@@ -3,32 +3,17 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Compiles a validated {@link GeographicModelDocument} into the runtime artifact deterministically.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* is computed. There is no second validator here, and no partial artifact on failure — a compile
|
|
11
|
-
* either produces the whole artifact or produces nothing.
|
|
8
|
+
* `parseGeographicModelDocument` performs all document validation and throws before compilation
|
|
9
|
+
* starts. A compile either returns the whole artifact or throws.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Only `isA` defines inheritance. The compiler materializes the transitive `isA` closure for each
|
|
12
|
+
* concept. It copies each ancestor's assertions onto descendants as {@link DerivedFactRecord}
|
|
13
|
+
* entries. The compiler does not close over relations marked `transitive` or `inverse`.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* 2. Every assertion an ancestor carries is materialized onto its descendants as a
|
|
19
|
-
* {@link DerivedFactRecord}, naming {@link DERIVATION_ISA_INHERITANCE} and every record the
|
|
20
|
-
* derivation read. Without this a consumer would still be traversing — the closure alone tells it
|
|
21
|
-
* which concepts to go and read, which is the traversal it was supposed to be spared.
|
|
22
|
-
*
|
|
23
|
-
* A relation declaring `transitive` or `inverse` is NOT closed over. Those fields are vocabulary
|
|
24
|
-
* describing what the relation means; materializing them is a reasoning step no executable need has
|
|
25
|
-
* asked for, and the boundary record excludes general reasoning from this package. The day one is
|
|
26
|
-
* needed it arrives as its own named derivation beside this one.
|
|
27
|
-
*
|
|
28
|
-
* Cycles never reach the derivations: `parseGeographicModelDocument` refuses a direct or indirect
|
|
29
|
-
* `isA` cycle and names the trail it followed, so a cyclic document fails as a validation error
|
|
30
|
-
* rather than as a hang. The walk below is breadth-first over a visited set regardless, which makes
|
|
31
|
-
* it total for any graph rather than for the graphs the validator happens to admit.
|
|
15
|
+
* The validator rejects `isA` cycles, but the ancestor walk also tracks visited concepts, so it
|
|
16
|
+
* terminates on any graph.
|
|
32
17
|
*/
|
|
33
18
|
|
|
34
19
|
import { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
|
|
@@ -48,34 +33,38 @@ import {
|
|
|
48
33
|
} from "#schema"
|
|
49
34
|
import { parseGeographicModelDocument } from "#validate"
|
|
50
35
|
/**
|
|
51
|
-
* The
|
|
52
|
-
* than on where the record sits.
|
|
36
|
+
* The `derivation` value of a fact derived by `isA` inheritance.
|
|
53
37
|
*/
|
|
54
38
|
export const DERIVATION_ISA_INHERITANCE = "isa-assertion-inheritance"
|
|
55
39
|
|
|
56
40
|
/**
|
|
57
|
-
*
|
|
41
|
+
* Reasons that compilation rejects a document that passed validation.
|
|
58
42
|
*
|
|
59
|
-
* Both
|
|
60
|
-
*
|
|
43
|
+
* Both problems concern the derived records.
|
|
44
|
+
* The validator never sees those records.
|
|
61
45
|
*/
|
|
62
46
|
export const CompileIssueCode = {
|
|
63
47
|
/**
|
|
64
|
-
* An inherited assertion would
|
|
65
|
-
* Emitting it would put a record in the artifact that the document validator would reject if it were authored.
|
|
48
|
+
* An inherited assertion would apply to a concept whose kind the relation's `domainKinds` excludes.
|
|
66
49
|
*/
|
|
67
50
|
InheritedDomainKindMismatch: "inherited_domain_kind_mismatch",
|
|
68
51
|
/**
|
|
69
|
-
* Two derived facts
|
|
70
|
-
*
|
|
52
|
+
* Two derived facts share one identifier.
|
|
53
|
+
*
|
|
54
|
+
* This happens when an authored derived fact uses an identifier that a derivation also
|
|
55
|
+
* produces, or when authored identifiers contain the separators of the derived form.
|
|
71
56
|
*/
|
|
72
57
|
DuplicateDerivedFactID: "duplicate_derived_fact_id",
|
|
73
58
|
} as const
|
|
74
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A {@link CompileIssueCode} value.
|
|
62
|
+
*/
|
|
63
|
+
|
|
75
64
|
export type CompileIssueCode = (typeof CompileIssueCode)[keyof typeof CompileIssueCode]
|
|
76
65
|
|
|
77
66
|
/**
|
|
78
|
-
* One reason a document
|
|
67
|
+
* One reason that a valid document does not compile.
|
|
79
68
|
*/
|
|
80
69
|
export interface CompileIssue {
|
|
81
70
|
code: CompileIssueCode
|
|
@@ -83,8 +72,8 @@ export interface CompileIssue {
|
|
|
83
72
|
}
|
|
84
73
|
|
|
85
74
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
75
|
+
* The error that {@link compileGeographicModel} throws.
|
|
76
|
+
* Its message lists every issue.
|
|
88
77
|
*/
|
|
89
78
|
export class GeographicModelCompileError extends Error {
|
|
90
79
|
readonly issues: readonly CompileIssue[]
|
|
@@ -100,8 +89,8 @@ export class GeographicModelCompileError extends Error {
|
|
|
100
89
|
}
|
|
101
90
|
|
|
102
91
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
92
|
+
* Sort order of derivation inputs by kind.
|
|
93
|
+
* Identifiers break ties within a kind.
|
|
105
94
|
*/
|
|
106
95
|
const DERIVATION_INPUT_ORDER: readonly DerivationInputKind[] = [
|
|
107
96
|
DerivationInputKind.Concept,
|
|
@@ -113,14 +102,14 @@ const DERIVATION_INPUT_ORDER: readonly DerivationInputKind[] = [
|
|
|
113
102
|
]
|
|
114
103
|
|
|
115
104
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
105
|
+
* Separator for internal grouping keys.
|
|
106
|
+
*
|
|
107
|
+
* Identifiers never contain `U+0000`, so distinct key tuples cannot produce the same string.
|
|
119
108
|
*/
|
|
120
109
|
const KEY_SEPARATOR = "\u0000"
|
|
121
110
|
|
|
122
111
|
/**
|
|
123
|
-
*
|
|
112
|
+
* Separator between country codes in a derived identifier.
|
|
124
113
|
*/
|
|
125
114
|
const COUNTRY_SEPARATOR = "+"
|
|
126
115
|
|
|
@@ -129,11 +118,8 @@ function compareByID(left: { id: string }, right: { id: string }): number {
|
|
|
129
118
|
}
|
|
130
119
|
|
|
131
120
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* Breadth-first over a visited set: each concept is expanded once, so the walk terminates on any graph and the answer
|
|
135
|
-
* does not depend on how the parents were authored. The concept itself is never in its own list — it could only get
|
|
136
|
-
* there around a cycle, and the validator refuses those before the compiler runs.
|
|
121
|
+
* Returns every concept reachable from `conceptID` through `isA`, sorted by code point.
|
|
122
|
+
* The result excludes the concept itself.
|
|
137
123
|
*/
|
|
138
124
|
function ancestorsOfConcept(
|
|
139
125
|
conceptID: ConceptID,
|
|
@@ -142,8 +128,8 @@ function ancestorsOfConcept(
|
|
|
142
128
|
const visited = new Set<string>()
|
|
143
129
|
const frontier: ConceptID[] = [...(parents.get(conceptID) ?? [])]
|
|
144
130
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
131
|
+
// The array iterator also visits entries pushed during the loop.
|
|
132
|
+
// This makes the walk breadth-first.
|
|
147
133
|
for (const next of frontier) {
|
|
148
134
|
if (next === conceptID || visited.has(next)) continue
|
|
149
135
|
|
|
@@ -157,9 +143,12 @@ function ancestorsOfConcept(
|
|
|
157
143
|
}
|
|
158
144
|
|
|
159
145
|
/**
|
|
160
|
-
* One derived fact under construction.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
146
|
+
* One derived fact under construction.
|
|
147
|
+
*
|
|
148
|
+
* Drafts are keyed by subject, relation, target, modality and countries.
|
|
149
|
+
* Two ancestors with the same assertion produce one fact that lists both as inputs.
|
|
150
|
+
*
|
|
151
|
+
* Assertions that differ only in modality produce separate facts, so a contradiction stays visible.
|
|
163
152
|
*/
|
|
164
153
|
interface DerivedDraft {
|
|
165
154
|
subject: ConceptID
|
|
@@ -183,8 +172,9 @@ function draftInputs(draft: DerivedDraft): DerivationInput[] {
|
|
|
183
172
|
}
|
|
184
173
|
|
|
185
174
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
175
|
+
* Builds a readable derived-fact identifier from the fact's proposition.
|
|
176
|
+
*
|
|
177
|
+
* The identifier stays stable when unrelated parts of the document change.
|
|
188
178
|
*/
|
|
189
179
|
function derivedFactID(draft: DerivedDraft): string {
|
|
190
180
|
const scope = draft.countries?.length ? `:${draft.countries.join(COUNTRY_SEPARATOR)}` : ""
|
|
@@ -194,7 +184,7 @@ function derivedFactID(draft: DerivedDraft): string {
|
|
|
194
184
|
}
|
|
195
185
|
|
|
196
186
|
/**
|
|
197
|
-
*
|
|
187
|
+
* Returns a key for an assertion's relation and target.
|
|
198
188
|
*/
|
|
199
189
|
function edgeKey(assertion: RelationAssertion): string {
|
|
200
190
|
return `${assertion.relation}${KEY_SEPARATOR}${assertion.target}`
|
|
@@ -207,11 +197,9 @@ function draftKey(subject: ConceptID, assertion: RelationAssertion, countries: r
|
|
|
207
197
|
}
|
|
208
198
|
|
|
209
199
|
/**
|
|
210
|
-
*
|
|
200
|
+
* Copies each ancestor's assertions onto its descendants as derived facts.
|
|
211
201
|
*
|
|
212
|
-
* A concept
|
|
213
|
-
* authored record is the more specific one, which is what `isA` means, and re-stating the pair would put two modalities
|
|
214
|
-
* for one proposition into the artifact with no rule saying which of them holds.
|
|
202
|
+
* A concept's own assertion for a relation and target overrides every inherited assertion for that pair.
|
|
215
203
|
*/
|
|
216
204
|
function deriveInheritedFacts(
|
|
217
205
|
concepts: readonly ConceptRecord[],
|
|
@@ -233,9 +221,8 @@ function deriveInheritedFacts(
|
|
|
233
221
|
for (const ancestorID of entry.ancestors) {
|
|
234
222
|
const ancestor = conceptByID.get(String(ancestorID))
|
|
235
223
|
|
|
236
|
-
// Validation
|
|
237
|
-
//
|
|
238
|
-
// describe a state the artifact can hold.
|
|
224
|
+
// Validation guarantees that every referenced concept and relation exists.
|
|
225
|
+
// These guards only satisfy the type checker.
|
|
239
226
|
if (!ancestor) continue
|
|
240
227
|
|
|
241
228
|
for (const assertion of ancestor.assertions) {
|
|
@@ -305,14 +292,12 @@ function checkDerivedIdentifiers(facts: readonly DerivedFactRecord[], issues: Co
|
|
|
305
292
|
}
|
|
306
293
|
|
|
307
294
|
/**
|
|
308
|
-
*
|
|
295
|
+
* Compiles an authored geographic-model document into its runtime artifact.
|
|
309
296
|
*
|
|
310
|
-
*
|
|
311
|
-
* {@link GeographicModelCompileError} with every reason if it validates but its derivations cannot be written. Nothing
|
|
312
|
-
* partial is ever returned.
|
|
297
|
+
* The artifact's tables are new sorted arrays that hold the authored records unchanged.
|
|
313
298
|
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
299
|
+
* @throws {GeographicModelValidationError} When the input is not a valid document.
|
|
300
|
+
* @throws {GeographicModelCompileError} When the document is valid but its derived facts are invalid.
|
|
316
301
|
*/
|
|
317
302
|
export function compileGeographicModel(input: unknown): CompiledGeographicModel {
|
|
318
303
|
const document: GeographicModelDocument = parseGeographicModelDocument(input)
|
package/lib/index.ts
CHANGED
|
@@ -3,45 +3,31 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* and mappings from external vocabularies into world concepts.
|
|
6
|
+
* `@mailwoman/geographic-model` — the world-semantic layer: stable concepts beyond the POI
|
|
7
|
+
* vocabulary, relation definitions, activities, affordances, rule modality, source observations,
|
|
8
|
+
* derived facts, derivation provenance, deterministic compilation, plus validation.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* runtime consumer reads it through (`./lookup.ts`). All five are re-exported here and all five are
|
|
15
|
-
* reachable as curated subpaths.
|
|
10
|
+
* The authoring loader (`./load.ts`, the `./load` subpath) is deliberately not re-exported here: it is
|
|
11
|
+
* the only module in the package that touches a filesystem and belongs to the build step rather than
|
|
12
|
+
* to the consumers that read an artifact.
|
|
16
13
|
*
|
|
17
|
-
*
|
|
18
|
-
* is the only module in the package that touches a filesystem, and it belongs to the build step that
|
|
19
|
-
* produces an artifact rather than to the consumers that read one. The first authored document
|
|
20
|
-
* arrives with #1927; this entry point carries no data.
|
|
14
|
+
* Four things this package must never hold, each owned elsewhere:
|
|
21
15
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
16
|
+
* 1. The resolver owns ranking policy. It defines weights, boosts, penalties and candidate order.
|
|
17
|
+
* The resolver package also owns rank order. The neural package and `@mailwoman/core/decoder` own
|
|
18
|
+
* the decode objective. This package creates observations and leaves candidate order to the resolver.
|
|
19
|
+
* 2. A second POI vocabulary — external and curated POI categories, their containment hierarchy, the
|
|
20
|
+
* Overture-leaf translation, the query-phrase lexicon and the brand table belong to
|
|
21
|
+
* `@mailwoman/poi-taxonomy`; this package maps into those identifiers.
|
|
22
|
+
* 3. A second coverage register — dataset identity and coverage epistemics belong to
|
|
23
|
+
* `@mailwoman/core/layers`, and an expected-but-absent observation becomes negative evidence only
|
|
24
|
+
* where `supportsExclusion` permits it there.
|
|
25
|
+
* 4. Empirical affordance statistics — this package owns the stable activity and affordance identifiers.
|
|
26
|
+
* It does not own numeric values fitted against those identifiers.
|
|
24
27
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* observations; it never overrides learned interpretation.
|
|
29
|
-
* 2. **A second POI vocabulary.** External and curated POI categories, their containment
|
|
30
|
-
* hierarchy, the Overture-leaf translation, the query-phrase lexicon and the brand table all
|
|
31
|
-
* belong to `@mailwoman/poi-taxonomy`. This package maps INTO those identifiers.
|
|
32
|
-
* 3. **A second coverage register.** Dataset identity and coverage epistemics belong to
|
|
33
|
-
* `@mailwoman/core/layers`. An expected-but-absent observation becomes negative evidence only
|
|
34
|
-
* where `supportsExclusion` permits it there.
|
|
35
|
-
* 4. **Empirical affordance statistics.** #1683 fits those. This package owns the stable
|
|
36
|
-
* activity and affordance identifiers they are fitted against, and nothing numeric about them.
|
|
37
|
-
*
|
|
38
|
-
* `@mailwoman/core` must not depend on this package. Core ships the pipeline contract and roughly
|
|
39
|
-
* 9 MB of reference data to every consumer, so a world-semantics dependency there is one every
|
|
40
|
-
* drop-in API inherits without asking for it. Reversing that direction is an explicit amendment to
|
|
41
|
-
* the boundary record, not a convenience during implementation.
|
|
42
|
-
*
|
|
43
|
-
* Boundary record: `docs/superpowers/specs/2026-08-26-geographic-model-boundaries.md` (#1917).
|
|
44
|
-
* Program parent: #1916.
|
|
28
|
+
* `@mailwoman/core` must not depend on this package: core ships the pipeline interface and roughly
|
|
29
|
+
* 9 MB of reference data to every consumer, so a world-semantics dependency there is one every
|
|
30
|
+
* drop-in API inherits without asking.
|
|
45
31
|
*/
|
|
46
32
|
|
|
47
33
|
export * from "#artifact"
|
package/lib/load.ts
CHANGED
|
@@ -3,27 +3,17 @@
|
|
|
3
3
|
* @license AGPL-3.0
|
|
4
4
|
* @author Teffen Ellis, et al.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Loads a directory of JSON files into one {@link GeographicModelDocument}.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* directory does carry is one manifest — `model.json`, holding the document's `version` — because a
|
|
11
|
-
* version assembled from whichever fragment happened to declare one is a version nobody chose.
|
|
8
|
+
* File layout does not assign meaning to a file. Any file may hold any subset of the tables.
|
|
9
|
+
* Only `model.json` may declare the document's `version`.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
11
|
+
* The loader sorts files by path before reading them, so directory enumeration order never affects
|
|
12
|
+
* the output. It records the source file of every record and maps each validation issue back to that
|
|
13
|
+
* file. A duplicate-identifier issue also reports the file that used the identifier first.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* NAMES and their contents. `readdir` order, and therefore the filesystem, is out of the answer.
|
|
18
|
-
* 2. **Every issue names the file it came from.** The document validator addresses a record by its
|
|
19
|
-
* position in the merged table (`$.concepts[7].kind`), which is the one thing an author cannot
|
|
20
|
-
* see; the loader keeps a per-record origin and re-addresses each issue to its source file. A
|
|
21
|
-
* duplicate identifier names both files — the one that claimed it and the one that claimed it
|
|
22
|
-
* first — because "already used" is unactionable without the other half.
|
|
23
|
-
*
|
|
24
|
-
* Validation itself is delegated whole to `./validate.ts`. What the loader checks on its own is only
|
|
25
|
-
* what the validator cannot see: whether a file parses, whether it is an object, and whether the
|
|
26
|
-
* keys it uses are tables.
|
|
15
|
+
* `./validate.ts` performs document validation. The loader checks only that each file parses, holds
|
|
16
|
+
* an object and uses known table keys.
|
|
27
17
|
*/
|
|
28
18
|
|
|
29
19
|
import { readLocalTextFile } from "@mailwoman/core/fs/readers"
|
|
@@ -43,12 +33,13 @@ import {
|
|
|
43
33
|
ValidationIssueCode,
|
|
44
34
|
} from "#validation/issues"
|
|
45
35
|
/**
|
|
46
|
-
* The manifest every model directory
|
|
36
|
+
* The manifest file that every model directory contains.
|
|
37
|
+
* It holds only the document's `version`.
|
|
47
38
|
*/
|
|
48
39
|
export const MODEL_MANIFEST_FILENAME = "model.json"
|
|
49
40
|
|
|
50
41
|
/**
|
|
51
|
-
* The keys a source file may use.
|
|
42
|
+
* The table keys that a non-manifest source file may use.
|
|
52
43
|
*/
|
|
53
44
|
const TABLE_FIELDS = ["relations", "concepts", "mappings", "observations", "derivedFacts"] as const
|
|
54
45
|
|
|
@@ -57,49 +48,49 @@ type TableField = (typeof TABLE_FIELDS)[number]
|
|
|
57
48
|
const MANIFEST_FIELDS = ["version"] as const
|
|
58
49
|
|
|
59
50
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
51
|
+
* Matches a record's path in the merged document, such as `$.concepts[7]` or `$.concepts[7].assertions[1]`.
|
|
52
|
+
* Group 3 matches only for a nested assertion.
|
|
62
53
|
*/
|
|
63
54
|
const RECORD_PATH_PATTERN = /^\$\.([A-Za-z]+)\[(\d+)\](?:\.assertions\[(\d+)\])?/u
|
|
64
55
|
|
|
65
56
|
/**
|
|
66
|
-
*
|
|
67
|
-
* file that is not JSON at all.
|
|
57
|
+
* Load issue codes: every validation code plus `MalformedJSON`.
|
|
68
58
|
*/
|
|
69
59
|
export const LoadIssueCode = {
|
|
70
60
|
...ValidationIssueCode,
|
|
71
61
|
/**
|
|
72
|
-
* A source file
|
|
73
|
-
* never text.
|
|
62
|
+
* A source file is not valid JSON.
|
|
74
63
|
*/
|
|
75
64
|
MalformedJSON: "malformed_json",
|
|
76
65
|
} as const
|
|
77
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A {@link LoadIssueCode} value.
|
|
69
|
+
*/
|
|
78
70
|
export type LoadIssueCode = (typeof LoadIssueCode)[keyof typeof LoadIssueCode]
|
|
79
71
|
|
|
80
72
|
/**
|
|
81
|
-
* One
|
|
73
|
+
* One load issue, attributed to its source file.
|
|
82
74
|
*/
|
|
83
75
|
export interface SourcedIssue {
|
|
84
76
|
/**
|
|
85
|
-
* The source file
|
|
77
|
+
* The source file path relative to the model directory, with `/` separators on every platform.
|
|
86
78
|
*/
|
|
87
79
|
file: string
|
|
88
80
|
/**
|
|
89
|
-
* The JSONPath-style
|
|
90
|
-
* validator saw.
|
|
81
|
+
* The JSONPath-style location in the merged document.
|
|
91
82
|
*/
|
|
92
83
|
path: string
|
|
93
84
|
code: LoadIssueCode
|
|
94
85
|
message: string
|
|
95
86
|
/**
|
|
96
|
-
* For a duplicate identifier
|
|
87
|
+
* For a duplicate identifier, the file that used the identifier first.
|
|
97
88
|
*/
|
|
98
89
|
otherFile?: string
|
|
99
90
|
}
|
|
100
91
|
|
|
101
92
|
/**
|
|
102
|
-
*
|
|
93
|
+
* A source file's path relative to the model directory, plus its text.
|
|
103
94
|
*/
|
|
104
95
|
export interface GeographicModelSourceFile {
|
|
105
96
|
path: string
|
|
@@ -107,7 +98,7 @@ export interface GeographicModelSourceFile {
|
|
|
107
98
|
}
|
|
108
99
|
|
|
109
100
|
/**
|
|
110
|
-
*
|
|
101
|
+
* Formats each issue as one `file:path: message [code]` line, in input order.
|
|
111
102
|
*/
|
|
112
103
|
export function formatSourcedIssues(issues: readonly SourcedIssue[]): string {
|
|
113
104
|
return issues
|
|
@@ -120,8 +111,8 @@ export function formatSourcedIssues(issues: readonly SourcedIssue[]): string {
|
|
|
120
111
|
}
|
|
121
112
|
|
|
122
113
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
114
|
+
* The error thrown when a model directory does not load.
|
|
115
|
+
* Its message lists every issue.
|
|
125
116
|
*/
|
|
126
117
|
export class GeographicModelLoadError extends Error {
|
|
127
118
|
readonly issues: readonly SourcedIssue[]
|
|
@@ -135,13 +126,13 @@ export class GeographicModelLoadError extends Error {
|
|
|
135
126
|
}
|
|
136
127
|
|
|
137
128
|
/**
|
|
138
|
-
*
|
|
129
|
+
* The source of one record in the merged document.
|
|
139
130
|
*/
|
|
140
131
|
interface RecordOrigin {
|
|
141
132
|
file: string
|
|
142
133
|
/**
|
|
143
|
-
* The
|
|
144
|
-
*
|
|
134
|
+
* The record's table, or `assertions` for an assertion nested in a concept.
|
|
135
|
+
* Identifiers are unique within a table.
|
|
145
136
|
*/
|
|
146
137
|
table: string
|
|
147
138
|
id?: string
|
|
@@ -152,8 +143,8 @@ interface MergeState {
|
|
|
152
143
|
tables: Record<TableField, unknown[]>
|
|
153
144
|
origins: Map<string, RecordOrigin>
|
|
154
145
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
146
|
+
* Maps each table to its identifiers and the file that used each identifier first.
|
|
147
|
+
* The validator reports only the second use.
|
|
157
148
|
*/
|
|
158
149
|
firstClaims: Map<string, Map<string, string>>
|
|
159
150
|
version?: string
|
|
@@ -164,11 +155,9 @@ function sourced(file: string, issues: readonly ValidationIssue[]): SourcedIssue
|
|
|
164
155
|
}
|
|
165
156
|
|
|
166
157
|
/**
|
|
167
|
-
*
|
|
158
|
+
* Parses one source file, or records the parse failure and returns `undefined`.
|
|
168
159
|
*
|
|
169
|
-
* The
|
|
170
|
-
* boundary record keeps world semantics out of core, and a build-time loader is not the reason to reverse it. The
|
|
171
|
-
* parser's own message is also the useful half of the report here, which a wrapper returning a fallback discards.
|
|
160
|
+
* The loader calls `JSON.parse` directly because the report needs the parser's error message.
|
|
172
161
|
*/
|
|
173
162
|
function readSourceJSON(file: GeographicModelSourceFile, issues: SourcedIssue[]): unknown {
|
|
174
163
|
try {
|
|
@@ -219,7 +208,7 @@ function readManifestFile(state: MergeState, file: GeographicModelSourceFile, va
|
|
|
219
208
|
}
|
|
220
209
|
|
|
221
210
|
/**
|
|
222
|
-
*
|
|
211
|
+
* Appends one file's tables to the merged document and records each record's origin.
|
|
223
212
|
*/
|
|
224
213
|
function readTableFile(state: MergeState, file: GeographicModelSourceFile, value: unknown): void {
|
|
225
214
|
const issues: ValidationIssue[] = []
|
|
@@ -232,8 +221,7 @@ function readTableFile(state: MergeState, file: GeographicModelSourceFile, value
|
|
|
232
221
|
return
|
|
233
222
|
}
|
|
234
223
|
|
|
235
|
-
//
|
|
236
|
-
// instead of only saying the field is unknown here.
|
|
224
|
+
// The field check allows `version` so that the specific error below can say where it belongs.
|
|
237
225
|
checkFieldNames(issues, "$", value, [...TABLE_FIELDS, ...MANIFEST_FIELDS])
|
|
238
226
|
|
|
239
227
|
if ("version" in value) {
|
|
@@ -277,7 +265,7 @@ function readTableFile(state: MergeState, file: GeographicModelSourceFile, value
|
|
|
277
265
|
}
|
|
278
266
|
|
|
279
267
|
/**
|
|
280
|
-
*
|
|
268
|
+
* Attributes one validation issue to the file that contains the record.
|
|
281
269
|
*/
|
|
282
270
|
function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
|
|
283
271
|
const match = RECORD_PATH_PATTERN.exec(issue.path)
|
|
@@ -285,8 +273,7 @@ function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
|
|
|
285
273
|
const key = match ? `${match[1]}[${match[2]}]${nested ? `.assertions[${nested}]` : ""}` : undefined
|
|
286
274
|
const origin = key ? state.origins.get(key) : undefined
|
|
287
275
|
|
|
288
|
-
//
|
|
289
|
-
// contributes anything outside a table.
|
|
276
|
+
// Only the manifest contributes fields outside the tables, so it owns document-level issues.
|
|
290
277
|
const file = origin?.file ?? MODEL_MANIFEST_FILENAME
|
|
291
278
|
|
|
292
279
|
const claimant =
|
|
@@ -304,11 +291,11 @@ function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
|
|
|
304
291
|
}
|
|
305
292
|
|
|
306
293
|
/**
|
|
307
|
-
*
|
|
294
|
+
* Merges source files into one document and validates it.
|
|
295
|
+
*
|
|
296
|
+
* The function sorts files by path first, so the input order never affects the result.
|
|
308
297
|
*
|
|
309
|
-
*
|
|
310
|
-
* order. Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file; returns nothing
|
|
311
|
-
* partial.
|
|
298
|
+
* @throws {GeographicModelLoadError} With every issue, each attributed to its source file.
|
|
312
299
|
*/
|
|
313
300
|
export function mergeGeographicModelFiles(files: readonly GeographicModelSourceFile[]): GeographicModelDocument {
|
|
314
301
|
const state: MergeState = {
|
|
@@ -344,7 +331,7 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
|
|
|
344
331
|
readTableFile(state, file, value)
|
|
345
332
|
}
|
|
346
333
|
|
|
347
|
-
//
|
|
334
|
+
// Validation issues cannot be attributed reliably after a structural failure, so those failures stop here.
|
|
348
335
|
if (state.issues.length) throw new GeographicModelLoadError(state.issues)
|
|
349
336
|
|
|
350
337
|
const result = validateGeographicModelDocument({ version: state.version, ...state.tables })
|
|
@@ -357,11 +344,9 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
|
|
|
357
344
|
}
|
|
358
345
|
|
|
359
346
|
/**
|
|
360
|
-
*
|
|
347
|
+
* Lists every `*.json` file under `root` as a relative path, sorted by code point at each level.
|
|
361
348
|
*
|
|
362
|
-
*
|
|
363
|
-
* the tree and not of the filesystem that stored it. Symbolic links are not followed: a model directory is source, and
|
|
364
|
-
* a link out of it is a record whose home nobody can state.
|
|
349
|
+
* Symbolic links are skipped because only regular files and directories match.
|
|
365
350
|
*/
|
|
366
351
|
async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
|
|
367
352
|
const entries = await Globerator.from("*", {
|
|
@@ -390,9 +375,9 @@ async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
|
|
|
390
375
|
}
|
|
391
376
|
|
|
392
377
|
/**
|
|
393
|
-
*
|
|
378
|
+
* Reads every `*.json` file under `root`, merges the files and validates the merged document.
|
|
394
379
|
*
|
|
395
|
-
*
|
|
380
|
+
* @throws {GeographicModelLoadError} With every issue, each attributed to its source file.
|
|
396
381
|
*/
|
|
397
382
|
export async function loadGeographicModelDirectory(root: string): Promise<GeographicModelDocument> {
|
|
398
383
|
const paths = await listSourceFiles(root)
|