@mailwoman/geographic-model 9.4.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.
Files changed (65) hide show
  1. package/README.md +32 -32
  2. package/data/geographic-model.json +5 -5
  3. package/data/model/concepts.json +5 -5
  4. package/lib/artifact.ts +34 -62
  5. package/lib/compile.ts +52 -67
  6. package/lib/index.ts +21 -35
  7. package/lib/load.ts +55 -64
  8. package/lib/lookup.ts +12 -28
  9. package/lib/packaged.ts +49 -0
  10. package/lib/schema.ts +104 -97
  11. package/lib/validate.ts +24 -393
  12. package/lib/{validation-issues.ts → validation/issues.ts} +13 -17
  13. package/lib/validation/references.ts +361 -0
  14. package/out/artifact.d.ts +28 -45
  15. package/out/artifact.d.ts.map +1 -1
  16. package/out/artifact.js +23 -50
  17. package/out/artifact.js.map +1 -1
  18. package/out/compile.d.ts +27 -41
  19. package/out/compile.d.ts.map +1 -1
  20. package/out/compile.js +41 -63
  21. package/out/compile.js.map +1 -1
  22. package/out/index.d.ts +21 -35
  23. package/out/index.d.ts.map +1 -1
  24. package/out/index.js +21 -35
  25. package/out/index.js.map +1 -1
  26. package/out/load.d.ts +32 -41
  27. package/out/load.d.ts.map +1 -1
  28. package/out/load.js +41 -53
  29. package/out/load.js.map +1 -1
  30. package/out/lookup.d.ts +12 -28
  31. package/out/lookup.d.ts.map +1 -1
  32. package/out/lookup.js +4 -16
  33. package/out/lookup.js.map +1 -1
  34. package/out/packaged.d.ts +24 -0
  35. package/out/packaged.d.ts.map +1 -0
  36. package/out/packaged.js +41 -0
  37. package/out/packaged.js.map +1 -0
  38. package/out/schema.d.ts +104 -97
  39. package/out/schema.d.ts.map +1 -1
  40. package/out/schema.js +37 -52
  41. package/out/schema.js.map +1 -1
  42. package/out/tools/build-artifact.d.ts +27 -0
  43. package/out/tools/build-artifact.d.ts.map +1 -0
  44. package/out/tools/build-artifact.js +44 -0
  45. package/out/tools/build-artifact.js.map +1 -0
  46. package/out/validate.d.ts +69 -32
  47. package/out/validate.d.ts.map +1 -1
  48. package/out/validate.js +15 -236
  49. package/out/validate.js.map +1 -1
  50. package/out/{validation-issues.d.ts → validation/issues.d.ts} +12 -15
  51. package/out/validation/issues.d.ts.map +1 -0
  52. package/out/{validation-issues.js → validation/issues.js} +12 -16
  53. package/out/validation/issues.js.map +1 -0
  54. package/out/validation/references.d.ts +10 -0
  55. package/out/validation/references.d.ts.map +1 -0
  56. package/out/validation/references.js +205 -0
  57. package/out/validation/references.js.map +1 -0
  58. package/package.json +34 -75
  59. package/tools/build-artifact.ts +56 -0
  60. package/out/scripts/build-artifact.d.ts +0 -51
  61. package/out/scripts/build-artifact.d.ts.map +0 -1
  62. package/out/scripts/build-artifact.js +0 -85
  63. package/out/scripts/build-artifact.js.map +0 -1
  64. package/out/validation-issues.d.ts.map +0 -1
  65. package/out/validation-issues.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
- * Deterministic compilation of a validated {@link GeographicModelDocument} into the runtime artifact.
6
+ * Compiles a validated {@link GeographicModelDocument} into the runtime artifact deterministically.
7
7
  *
8
- * The compiler validates by DELEGATION: `parseGeographicModelDocument` is the only thing that
9
- * decides whether a document is well formed, and it throws with every violation before a single byte
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
- * **`isA` alone defines semantic inheritance.** Two things follow from it, and they are the whole
14
- * derivation surface:
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
- * 1. The transitive `isA` closure is materialized per concept, so a consumer asks "what is this a
17
- * kind of" with one lookup rather than by walking the graph at query time.
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 name a fact derived by `isA` inheritance carries in its `derivation` field. A consumer branches on this rather
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
- * Every way compilation can refuse a document the validator accepted.
41
+ * Reasons that compilation rejects a document that passed validation.
58
42
  *
59
- * Both are discovered while writing derived records, which is why the validator cannot report them: they are properties
60
- * of what the compiler is about to write, not of what the author wrote.
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 land on a concept whose kind the relation does not accept on the asserting side.
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 claim one identifier. Reachable when an authored derived fact takes an identifier a derivation
70
- * also produces, or when authored identifiers carry the separators the derived form is built from.
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 that validates does not compile.
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
- * Thrown by {@link compileGeographicModel}. Carries every reason at once, and states them all in its message, so a
87
- * caller that only prints `error.message` still sees the whole list.
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
- * The order derivation inputs are listed in. Grouping by table first keeps a long input list readable; the identifier
104
- * breaks ties inside a table.
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
- * The separator for the compound keys this module groups by. `U+0000` cannot appear in a readable identifier without
117
- * being visible in it, so two different key tuples cannot collapse onto one string. The derived IDENTIFIERS written
118
- * into the artifact use readable separators instead, and are checked for collisions once they are all built.
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
- * The separator between country codes inside a derived identifier.
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
- * Walk `isA` upward from one concept and return every concept reachable, in code-point order.
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
- // An array iterator reads entries appended during the walk, which is what makes this breadth-first rather than a
146
- // pass over the direct parents.
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. Drafts are keyed by the proposition they state, so two ancestors asserting the
161
- * same thing produce ONE fact naming both of them as inputs, while two ancestors asserting the same triple under
162
- * different modality produce two facts — a contradiction a consumer can see, rather than a silent choice between them.
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
- * The identifier a derived fact carries. Built from the proposition it states, so it is stable across edits elsewhere
187
- * in the document, and readable, so a reader meeting one in a diff can tell what it says.
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
- * The relation and target one assertion is about — the pair a descendant's own assertion speaks for.
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
- * Materialize every ancestor's assertions onto their descendants.
200
+ * Copies each ancestor's assertions onto its descendants as derived facts.
211
201
  *
212
- * A concept that authors its own assertion for the same relation and target inherits nothing for that pair. The
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 refuses an `isA` naming an undeclared concept, and an assertion naming an undeclared relation,
237
- // so both resolve for any document that reached the compiler. The guards keep the walk total; they do not
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
- * Compile an authored geographic-model document into its runtime artifact.
295
+ * Compiles an authored geographic-model document into its runtime artifact.
309
296
  *
310
- * Throws `GeographicModelValidationError` with every violation if the input is not a valid document, and
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
- * The document is read, never rewritten: the artifact's tables are new arrays holding the authored records themselves,
315
- * ordered by identifier, and the derived tables are new records built beside them.
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
- * `@mailwoman/geographic-model` — the world-semantic layer: stable concepts beyond the POI
7
- * vocabulary, relation definitions, activities and affordances, rule modality, source
8
- * observations, derived facts, derivation provenance, deterministic compilation and validation,
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
- * The public surface is the authored-record schema (`./schema.ts`), its deterministic validator
12
- * (`./validate.ts`), the compiler that turns a validated document into the runtime artifact
13
- * (`./compile.ts`), the artifact's shape and canonical bytes (`./artifact.ts`), and the lookups a
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
- * The authoring loader (`./load.ts`, the `./load` subpath) is deliberately NOT re-exported here. It
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
- * Four things this package must never hold, each owned elsewhere and each a rule the review
23
- * applies rather than a preference:
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
- * 1. **Ranking policy.** No weights, boosts, penalties, or candidate-ordering API — not as a
26
- * function, not as a type. Candidate ordering belongs to `@mailwoman/resolver`, and the decode
27
- * objective to `@mailwoman/neural` plus `@mailwoman/core/decoder`. Knowledge here creates
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,33 +3,24 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * The authoring loader: a directory of JSON files in, one {@link GeographicModelDocument} out.
6
+ * Loads a directory of JSON files into one {@link GeographicModelDocument}.
7
7
  *
8
- * **The filesystem layout is authoring convenience and carries no meaning.** A concept means the
9
- * same thing whichever file it was written in, and a file may hold any subset of the tables. What a
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
- * Two properties make the loader safe to build an artifact from:
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
- * 1. **Enumeration order cannot reach the output.** {@link mergeGeographicModelFiles} sorts the
16
- * files it was handed before reading any of them, so the merged tables are a function of the file
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
- import { readDirectoryEntries, readLocalTextFile } from "@mailwoman/core/fs/readers"
19
+ import { readLocalTextFile } from "@mailwoman/core/fs/readers"
30
20
  import { isPlainObject } from "@mailwoman/core/objects"
31
21
  import { compareByCodePoint as compareIdentifiers } from "@mailwoman/core/strings/compare"
32
22
  import { resolvePath } from "path-ts"
23
+ import { Globerator } from "spliterator/node/fs"
33
24
 
34
25
  import type { GeographicModelDocument } from "#schema"
35
26
  import { validateGeographicModelDocument } from "#validate"
@@ -40,14 +31,15 @@ import {
40
31
  readString,
41
32
  type ValidationIssue,
42
33
  ValidationIssueCode,
43
- } from "#validation-issues"
34
+ } from "#validation/issues"
44
35
  /**
45
- * The manifest every model directory carries: the document's `version`, and nothing else.
36
+ * The manifest file that every model directory contains.
37
+ * It holds only the document's `version`.
46
38
  */
47
39
  export const MODEL_MANIFEST_FILENAME = "model.json"
48
40
 
49
41
  /**
50
- * The keys a source file may use. They are the document's tables, minus the manifest's `version`.
42
+ * The table keys that a non-manifest source file may use.
51
43
  */
52
44
  const TABLE_FIELDS = ["relations", "concepts", "mappings", "observations", "derivedFacts"] as const
53
45
 
@@ -56,49 +48,49 @@ type TableField = (typeof TABLE_FIELDS)[number]
56
48
  const MANIFEST_FIELDS = ["version"] as const
57
49
 
58
50
  /**
59
- * The document path a record occupies, e.g. `$.concepts[7]` or `$.concepts[7].assertions[1]`. Group 3 is present only
60
- * for an assertion, which is the one record that nests.
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.
61
53
  */
62
54
  const RECORD_PATH_PATTERN = /^\$\.([A-Za-z]+)\[(\d+)\](?:\.assertions\[(\d+)\])?/u
63
55
 
64
56
  /**
65
- * Every way loading can fail. The document validator's whole vocabulary, plus the one failure only a loader meets: a
66
- * file that is not JSON at all.
57
+ * Load issue codes: every validation code plus `MalformedJSON`.
67
58
  */
68
59
  export const LoadIssueCode = {
69
60
  ...ValidationIssueCode,
70
61
  /**
71
- * A source file could not be parsed as JSON. Emitted by the loader alone; the document validator is handed values,
72
- * never text.
62
+ * A source file is not valid JSON.
73
63
  */
74
64
  MalformedJSON: "malformed_json",
75
65
  } as const
76
66
 
67
+ /**
68
+ * A {@link LoadIssueCode} value.
69
+ */
77
70
  export type LoadIssueCode = (typeof LoadIssueCode)[keyof typeof LoadIssueCode]
78
71
 
79
72
  /**
80
- * One violation, addressed to the file an author can open.
73
+ * One load issue, attributed to its source file.
81
74
  */
82
75
  export interface SourcedIssue {
83
76
  /**
84
- * The source file, relative to the model directory, with `/` separators on every platform.
77
+ * The source file path relative to the model directory, with `/` separators on every platform.
85
78
  */
86
79
  file: string
87
80
  /**
88
- * The JSONPath-style address into the MERGED document, kept so a reader can find the record in the table the
89
- * validator saw.
81
+ * The JSONPath-style location in the merged document.
90
82
  */
91
83
  path: string
92
84
  code: LoadIssueCode
93
85
  message: string
94
86
  /**
95
- * For a duplicate identifier: the file that claimed it first.
87
+ * For a duplicate identifier, the file that used the identifier first.
96
88
  */
97
89
  otherFile?: string
98
90
  }
99
91
 
100
92
  /**
101
- * One authoring file: its path relative to the model directory, and its text.
93
+ * A source file's path relative to the model directory, plus its text.
102
94
  */
103
95
  export interface GeographicModelSourceFile {
104
96
  path: string
@@ -106,7 +98,7 @@ export interface GeographicModelSourceFile {
106
98
  }
107
99
 
108
100
  /**
109
- * Render every issue as one line, `file:path: message [code]`, in the order the loader produced them.
101
+ * Formats each issue as one `file:path: message [code]` line, in input order.
110
102
  */
111
103
  export function formatSourcedIssues(issues: readonly SourcedIssue[]): string {
112
104
  return issues
@@ -119,8 +111,8 @@ export function formatSourcedIssues(issues: readonly SourcedIssue[]): string {
119
111
  }
120
112
 
121
113
  /**
122
- * Thrown when a model directory does not load. Carries every issue, and states them all in its message, so a caller
123
- * that only prints `error.message` still sees the whole list.
114
+ * The error thrown when a model directory does not load.
115
+ * Its message lists every issue.
124
116
  */
125
117
  export class GeographicModelLoadError extends Error {
126
118
  readonly issues: readonly SourcedIssue[]
@@ -134,13 +126,13 @@ export class GeographicModelLoadError extends Error {
134
126
  }
135
127
 
136
128
  /**
137
- * Where one record came from, kept so a validation issue addressed to the merged table can be re-addressed to a file.
129
+ * The source of one record in the merged document.
138
130
  */
139
131
  interface RecordOrigin {
140
132
  file: string
141
133
  /**
142
- * The table the record was appended to, or `assertions` for one nested inside a concept — the namespace its
143
- * identifier is unique within.
134
+ * The record's table, or `assertions` for an assertion nested in a concept.
135
+ * Identifiers are unique within a table.
144
136
  */
145
137
  table: string
146
138
  id?: string
@@ -151,8 +143,8 @@ interface MergeState {
151
143
  tables: Record<TableField, unknown[]>
152
144
  origins: Map<string, RecordOrigin>
153
145
  /**
154
- * Table → identifier → the file that used it first. The validator reports the SECOND claimant, so this is what names
155
- * the other half of the pair.
146
+ * Maps each table to its identifiers and the file that used each identifier first.
147
+ * The validator reports only the second use.
156
148
  */
157
149
  firstClaims: Map<string, Map<string, string>>
158
150
  version?: string
@@ -163,11 +155,9 @@ function sourced(file: string, issues: readonly ValidationIssue[]): SourcedIssue
163
155
  }
164
156
 
165
157
  /**
166
- * Parse one source file, or report why it could not be parsed.
158
+ * Parses one source file, or records the parse failure and returns `undefined`.
167
159
  *
168
- * The house wrapper lives in `@mailwoman/core/objects`, and this package takes no dependency on `@mailwoman/core` — the
169
- * boundary record keeps world semantics out of core, and a build-time loader is not the reason to reverse it. The
170
- * 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.
171
161
  */
172
162
  function readSourceJSON(file: GeographicModelSourceFile, issues: SourcedIssue[]): unknown {
173
163
  try {
@@ -218,7 +208,7 @@ function readManifestFile(state: MergeState, file: GeographicModelSourceFile, va
218
208
  }
219
209
 
220
210
  /**
221
- * Append one file's tables to the merged document, recording where every record came from.
211
+ * Appends one file's tables to the merged document and records each record's origin.
222
212
  */
223
213
  function readTableFile(state: MergeState, file: GeographicModelSourceFile, value: unknown): void {
224
214
  const issues: ValidationIssue[] = []
@@ -231,8 +221,7 @@ function readTableFile(state: MergeState, file: GeographicModelSourceFile, value
231
221
  return
232
222
  }
233
223
 
234
- // `version` is admitted to the field check and then refused on its own, so the report names where a version belongs
235
- // 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.
236
225
  checkFieldNames(issues, "$", value, [...TABLE_FIELDS, ...MANIFEST_FIELDS])
237
226
 
238
227
  if ("version" in value) {
@@ -276,7 +265,7 @@ function readTableFile(state: MergeState, file: GeographicModelSourceFile, value
276
265
  }
277
266
 
278
267
  /**
279
- * Re-address one validation issue from its position in the merged document to the file the record was authored in.
268
+ * Attributes one validation issue to the file that contains the record.
280
269
  */
281
270
  function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
282
271
  const match = RECORD_PATH_PATTERN.exec(issue.path)
@@ -284,8 +273,7 @@ function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
284
273
  const key = match ? `${match[1]}[${match[2]}]${nested ? `.assertions[${nested}]` : ""}` : undefined
285
274
  const origin = key ? state.origins.get(key) : undefined
286
275
 
287
- // A document-level issue — `$.version`, or the root itself — is about the manifest, which is the only file that
288
- // contributes anything outside a table.
276
+ // Only the manifest contributes fields outside the tables, so it owns document-level issues.
289
277
  const file = origin?.file ?? MODEL_MANIFEST_FILENAME
290
278
 
291
279
  const claimant =
@@ -303,11 +291,11 @@ function attribute(state: MergeState, issue: ValidationIssue): SourcedIssue {
303
291
  }
304
292
 
305
293
  /**
306
- * Merge authoring files into one document, and validate the result.
294
+ * Merges source files into one document and validates it.
307
295
  *
308
- * The files are sorted by path before anything is read, so any enumeration order produces the same tables in the same
309
- * order. Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file; returns nothing
310
- * partial.
296
+ * The function sorts files by path first, so the input order never affects the result.
297
+ *
298
+ * @throws {GeographicModelLoadError} With every issue, each attributed to its source file.
311
299
  */
312
300
  export function mergeGeographicModelFiles(files: readonly GeographicModelSourceFile[]): GeographicModelDocument {
313
301
  const state: MergeState = {
@@ -343,7 +331,7 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
343
331
  readTableFile(state, file, value)
344
332
  }
345
333
 
346
- // A record issue cannot be addressed to a file that failed to parse, so the structural pass reports alone.
334
+ // Validation issues cannot be attributed reliably after a structural failure, so those failures stop here.
347
335
  if (state.issues.length) throw new GeographicModelLoadError(state.issues)
348
336
 
349
337
  const result = validateGeographicModelDocument({ version: state.version, ...state.tables })
@@ -356,14 +344,17 @@ export function mergeGeographicModelFiles(files: readonly GeographicModelSourceF
356
344
  }
357
345
 
358
346
  /**
359
- * Every `*.json` file under `root`, relative to it, in code-point order.
347
+ * Lists every `*.json` file under `root` as a relative path, sorted by code point at each level.
360
348
  *
361
- * Directory entries are sorted at each level rather than taken as `readdir` returns them, so the list is a property of
362
- * the tree and not of the filesystem that stored it. Symbolic links are not followed: a model directory is source, and
363
- * 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.
364
350
  */
365
351
  async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
366
- const entries = await readDirectoryEntries(resolvePath(root, prefix))
352
+ const entries = await Globerator.from("*", {
353
+ cwd: resolvePath(root, prefix),
354
+ withFileTypes: true,
355
+ onlyFiles: false,
356
+ }).toArray()
357
+
367
358
  const found: string[] = []
368
359
 
369
360
  for (const entry of entries.toSorted((left, right) => compareIdentifiers(left.name, right.name))) {
@@ -384,9 +375,9 @@ async function listSourceFiles(root: string, prefix = ""): Promise<string[]> {
384
375
  }
385
376
 
386
377
  /**
387
- * Load a model directory: read every `*.json` file under it, merge them, and validate the result.
378
+ * Reads every `*.json` file under `root`, merges the files and validates the merged document.
388
379
  *
389
- * Throws {@link GeographicModelLoadError} with every issue, each addressed to its source file.
380
+ * @throws {GeographicModelLoadError} With every issue, each attributed to its source file.
390
381
  */
391
382
  export async function loadGeographicModelDirectory(root: string): Promise<GeographicModelDocument> {
392
383
  const paths = await listSourceFiles(root)