unknown-knowledge 2.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/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +167 -0
- package/cli/.gitkeep +0 -0
- package/cli/commands/init-copy.js +90 -0
- package/cli/commands/init.js +386 -0
- package/cli/init-copy.js +24 -0
- package/cli/init.js +24 -0
- package/cli/kit.manifest.yaml +256 -0
- package/cli/lib/copy-payload.js +334 -0
- package/cli/lib/generate-wrappers.js +162 -0
- package/package.json +36 -0
- package/payload/adapter-fixtures/README.md +53 -0
- package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
- package/payload/adapter-fixtures/html/sample.html +38 -0
- package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
- package/payload/adapter-fixtures/md/sample.md +32 -0
- package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
- package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
- package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
- package/payload/adapter-fixtures/txt/sample.txt +18 -0
- package/payload/docs/README.md +102 -0
- package/payload/docs/boundaries.md +60 -0
- package/payload/docs/ci-wiring.md +109 -0
- package/payload/docs/steward-guide.md +238 -0
- package/payload/engine/audit.js +34 -0
- package/payload/engine/commands/audit.js +289 -0
- package/payload/engine/commands/derive.js +334 -0
- package/payload/engine/commands/ingest.js +124 -0
- package/payload/engine/commands/log-entry.js +85 -0
- package/payload/engine/commands/phoenix.js +206 -0
- package/payload/engine/commands/preflight.js +530 -0
- package/payload/engine/commands/resolve.js +1678 -0
- package/payload/engine/commands/survey-map.js +351 -0
- package/payload/engine/commands/validate-values.js +315 -0
- package/payload/engine/commands/validate.js +1426 -0
- package/payload/engine/derive.js +34 -0
- package/payload/engine/ingest.js +34 -0
- package/payload/engine/lib/anchor-signatures.js +126 -0
- package/payload/engine/lib/boot.js +39 -0
- package/payload/engine/lib/call-numbers.js +133 -0
- package/payload/engine/lib/cli.js +147 -0
- package/payload/engine/lib/coverage.js +849 -0
- package/payload/engine/lib/decomposition.js +225 -0
- package/payload/engine/lib/derived.js +494 -0
- package/payload/engine/lib/engine-refusal.js +40 -0
- package/payload/engine/lib/exit-codes.js +14 -0
- package/payload/engine/lib/extractor-kinds.js +955 -0
- package/payload/engine/lib/format-adapters.js +802 -0
- package/payload/engine/lib/id-grammars.js +178 -0
- package/payload/engine/lib/iso-date.js +55 -0
- package/payload/engine/lib/kit-root.js +101 -0
- package/payload/engine/lib/load-stores.js +1624 -0
- package/payload/engine/lib/log-entry.js +196 -0
- package/payload/engine/lib/phoenix.js +628 -0
- package/payload/engine/lib/scoring.js +150 -0
- package/payload/engine/lib/suppressions.js +172 -0
- package/payload/engine/lib/time-verdicts.js +282 -0
- package/payload/engine/lib/usage-error.js +14 -0
- package/payload/engine/lib/validate-record.js +504 -0
- package/payload/engine/log-entry.js +34 -0
- package/payload/engine/phoenix.js +39 -0
- package/payload/engine/preflight.js +34 -0
- package/payload/engine/resolve.js +34 -0
- package/payload/engine/survey-map.js +34 -0
- package/payload/engine/validate-values.js +34 -0
- package/payload/engine/validate.js +34 -0
- package/payload/extractor-fixtures/.gitkeep +0 -0
- package/payload/extractor-fixtures/README.md +29 -0
- package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
- package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
- package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
- package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
- package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
- package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
- package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
- package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
- package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
- package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
- package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
- package/payload/hooks/pre-commit +37 -0
- package/payload/hooks/reverse-lookup +66 -0
- package/payload/package.json +3 -0
- package/payload/protocol/.gitkeep +0 -0
- package/payload/protocol/AGENTS.md +239 -0
- package/payload/protocol/derived-layer.md +174 -0
- package/payload/protocol/new-kind-pipeline.md +179 -0
- package/payload/protocol/registry-warrant.md +162 -0
- package/payload/protocol/skills/kb-build.md +303 -0
- package/payload/protocol/skills/knowledge-audit.md +183 -0
- package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
- package/payload/protocol/skills/knowledge-reflect.md +397 -0
- package/payload/schemas/catalog.schema.json +32 -0
- package/payload/schemas/decision-entry.schema.json +122 -0
- package/payload/schemas/finding.schema.json +77 -0
- package/payload/schemas/gap.schema.json +52 -0
- package/payload/schemas/graduation-categories.schema.json +64 -0
- package/payload/schemas/knowledge-leaf.schema.json +194 -0
- package/payload/schemas/miss.schema.json +45 -0
- package/payload/schemas/ontology-concept.schema.json +115 -0
- package/payload/schemas/phoenix-event.schema.json +76 -0
- package/payload/schemas/registry.schema.json +57 -0
- package/payload/schemas/rules.schema.json +14 -0
- package/payload/schemas/survey-scope.schema.json +23 -0
- package/payload/templates/decisions/_catalog.yaml +7 -0
- package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
- package/payload/templates/decisions/phoenix-event.yaml +74 -0
- package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
- package/payload/templates/decisions/registry-minting.yaml +58 -0
- package/payload/templates/decisions/trust-graduation.yaml +120 -0
- package/payload/templates/decisions/trust-revocation.yaml +106 -0
- package/payload/templates/knowledge/_catalog.yaml +9 -0
- package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
- package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
- package/payload/templates/knowledge/_registries/domains.yaml +43 -0
- package/payload/templates/knowledge/_registries/form.yaml +38 -0
- package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
- package/payload/templates/knowledge/_registries/operations.yaml +18 -0
- package/payload/templates/knowledge/_registries/stage.yaml +53 -0
- package/payload/templates/knowledge/_rules.yaml +6 -0
- package/payload/templates/new-kind/README.md +107 -0
- package/payload/templates/new-kind/descriptor.example.yaml +18 -0
- package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
- package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
- package/payload/templates/new-kind/fixture/sample.list +7 -0
- package/payload/templates/new-kind/parser.example.js +98 -0
- package/payload/templates/ontology/_catalog.yaml +6 -0
- package/payload/templates/ontology/_rules.yaml +6 -0
- package/payload/wrappers/cursor.mdc +15 -0
- package/payload/wrappers/pointer.md +10 -0
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store loader (KK-04) — the engine's single shared substrate (PRD §4).
|
|
3
|
+
*
|
|
4
|
+
* Parses all three stores (ontology, knowledge, decisions) from a kit root
|
|
5
|
+
* directory ONCE into an indexed in-memory model, with diagnostics on one
|
|
6
|
+
* error/warning scale. Every downstream surface — structural validator
|
|
7
|
+
* (KK-05), value validator (KK-07), audit (KK-12), resolver (KK-06),
|
|
8
|
+
* preflight (KK-26) — consumes these same diagnostics: the single-health-model
|
|
9
|
+
* guarantee that validator, audit, and preflight can never disagree.
|
|
10
|
+
*
|
|
11
|
+
* Layout loaded (PRD §9.1):
|
|
12
|
+
* ontology/ _catalog.yaml _rules.yaml classes/*.yaml (concept records)
|
|
13
|
+
* knowledge/ _catalog.yaml _rules.yaml **\/*.md (leaf = YAML front
|
|
14
|
+
* matter + markdown body)
|
|
15
|
+
* decisions/ _catalog.yaml entries/*.yaml (decision records)
|
|
16
|
+
* <store>/ _registries/*.yaml (governed vocabularies,
|
|
17
|
+
* UCS-1148 — optional;
|
|
18
|
+
* also the trust
|
|
19
|
+
* graduation category
|
|
20
|
+
* table, UCS-1155)
|
|
21
|
+
*
|
|
22
|
+
* Which files a store carries is DATA, not control flow: STORE_DESCRIPTORS
|
|
23
|
+
* below is the table, so a registry is a declared file class rather than a
|
|
24
|
+
* fourth bespoke reader.
|
|
25
|
+
*
|
|
26
|
+
* Model shape (all collections deterministically sorted — PRD §5 diffability):
|
|
27
|
+
* {
|
|
28
|
+
* root, // absolute root the stores were loaded from
|
|
29
|
+
* stores: { ontology|knowledge|decisions:
|
|
30
|
+
* { present, catalog, rules, files } }, // parsed docs (null if absent),
|
|
31
|
+
* // record files root-relative
|
|
32
|
+
* concepts: Map id -> { id, file, record },
|
|
33
|
+
* leaves: Map accession -> { identity, id, notation, file, record, body },
|
|
34
|
+
* // `identity` is the neutral id key consumers
|
|
35
|
+
* // read (UCS-1142); since UCS-1147 it is the
|
|
36
|
+
* // leaf's accession and nothing else. `id` and
|
|
37
|
+
* // `notation` are the two public wire names —
|
|
38
|
+
* // `id` the accession, `notation` the OPTIONAL
|
|
39
|
+
* // LEGACY display label, null when absent and
|
|
40
|
+
* // never an identity anything resolves through
|
|
41
|
+
* decisions: Map id -> { id, file, record },
|
|
42
|
+
* registries: Map "<store>/<name>" ->
|
|
43
|
+
* { name, store, file, hierarchical, minted:Set, suppressed:Set },
|
|
44
|
+
* // governed vocabularies (UCS-1148): the closed
|
|
45
|
+
* // value sets facets draw from. Membership is a
|
|
46
|
+
* // structural-validator check (KK-05), never a
|
|
47
|
+
* // schema enum — the vocabulary grows by steward
|
|
48
|
+
* // review, not by an engine release
|
|
49
|
+
* graduations: Map "<store>/<table>" ->
|
|
50
|
+
* { table, store, file, categories:Map name->row },
|
|
51
|
+
* // trust graduation category tables (UCS-1155):
|
|
52
|
+
* // which change categories may graduate from
|
|
53
|
+
* // full inspection to sampling and which are
|
|
54
|
+
* // permanently gated. Autonomy is per CATEGORY,
|
|
55
|
+
* // never per leaf, so this table is what a
|
|
56
|
+
* // graduation or revocation entry is held against
|
|
57
|
+
* phoenix: Map "<store>/<event>" ->
|
|
58
|
+
* { event, store, file, decision, scope, rows:Map id->row },
|
|
59
|
+
* // retained phoenix event mappings (UCS-1154):
|
|
60
|
+
* // the record of a bulk re-taxonomy that already
|
|
61
|
+
* // happened, kept so the validator can hold every
|
|
62
|
+
* // non-1 edition against a sanctioning event
|
|
63
|
+
* // without reading git history
|
|
64
|
+
* pointers: Map source-of-truth path -> [concept ids], // KK-06 --paths
|
|
65
|
+
* leavesByConcept: Map concept id -> [leaf identities],
|
|
66
|
+
* // the leaf→concept edge derived in REVERSE at
|
|
67
|
+
* // load (UCS-1151). Declared leaf-side, walked
|
|
68
|
+
* // from either end: resolving a concept surfaces
|
|
69
|
+
* // its declaring leaves structurally, with no
|
|
70
|
+
* // dependence on whether any term text matches
|
|
71
|
+
* refs: [{ from, type, to, file, path, resolved }], // cross-ref graph
|
|
72
|
+
* diagnostics: [{ severity, code, file, path, message }],
|
|
73
|
+
* ok, // true iff no error-severity diagnostic
|
|
74
|
+
* }
|
|
75
|
+
*
|
|
76
|
+
* Diagnostics (severity 'error' | 'warning'), stable-sorted by file/path/code:
|
|
77
|
+
* parse-error error unparseable YAML, multi-doc file, or a leaf
|
|
78
|
+
* without front matter
|
|
79
|
+
* <validate-record> error every schema/convention code from KK-02
|
|
80
|
+
* (missing-required, non-string-enumerates-value,
|
|
81
|
+
* …) carried through unchanged — including the
|
|
82
|
+
* §3.5 YAML-coercion hard error: files are parsed
|
|
83
|
+
* with scalar types intact (js-yaml YAML 1.2 core
|
|
84
|
+
* schema), so true/1.0/null enumerates values are
|
|
85
|
+
* detected as non-strings, never stringified
|
|
86
|
+
* duplicate-id error same id minted twice across (or within) files
|
|
87
|
+
* unresolved-ref error typed ref to an id that neither loaded as an
|
|
88
|
+
* entry nor is declared in the target store's
|
|
89
|
+
* catalog (catalog-declared ids with pending
|
|
90
|
+
* files resolve — the catalog never implies a
|
|
91
|
+
* recorded id is absent; file-pointer resolution
|
|
92
|
+
* is KK-05's check)
|
|
93
|
+
* missing-store warning store directory absent (pre-init / partial
|
|
94
|
+
* repos load well-defined; post-init repos always
|
|
95
|
+
* have all three)
|
|
96
|
+
* missing-catalog error store directory present without _catalog.yaml
|
|
97
|
+
* (the navigational entry point, PRD §3)
|
|
98
|
+
* registry-name-mismatch
|
|
99
|
+
* error a registry's declared name disagrees with its
|
|
100
|
+
* filename, so a finding could not name both the
|
|
101
|
+
* registry and the file a steward opens (UCS-1148)
|
|
102
|
+
* registry-store-mismatch
|
|
103
|
+
* error a registry's declared store disagrees with the
|
|
104
|
+
* directory it sits in (UCS-1148)
|
|
105
|
+
* duplicate-registry-value
|
|
106
|
+
* error one value declared twice in a registry —
|
|
107
|
+
* redundantly, or as both minted AND suppressed,
|
|
108
|
+
* which the engine must never settle by file
|
|
109
|
+
* order (UCS-1148)
|
|
110
|
+
*
|
|
111
|
+
* A nonexistent/unreadable root THROWS — an engine failure (exit-code 2
|
|
112
|
+
* territory, PRD §5), never a silent diagnostic.
|
|
113
|
+
*/
|
|
114
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
115
|
+
import { join, resolve } from 'node:path';
|
|
116
|
+
import { load, YAMLException } from 'js-yaml';
|
|
117
|
+
import { validateStoreFile, ERROR_CODES, compare } from './validate-record.js';
|
|
118
|
+
// The accession grammar, from the one module that owns it (UCS-1142) — the same
|
|
119
|
+
// source the schemas bind to, so what the loader will INDEX and what the
|
|
120
|
+
// validator will ACCEPT can never be two different notions of a leaf id.
|
|
121
|
+
import { idPattern } from './id-grammars.js';
|
|
122
|
+
import { UsageError } from './usage-error.js';
|
|
123
|
+
|
|
124
|
+
export const SEVERITIES = Object.freeze(['error', 'warning']);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The field holding a leaf's LEGACY dotted notation (UCS-1142, UCS-1147).
|
|
128
|
+
*
|
|
129
|
+
* Named ONCE, here. It was the leaf's id field; since the contract phase it is
|
|
130
|
+
* an optional display label that nothing treats as identity and no citation
|
|
131
|
+
* resolves through. It is still read in one place — the loader copies it onto
|
|
132
|
+
* the indexed entry — because it remains a PUBLISHED resolver field (§4), and
|
|
133
|
+
* a published field needs a single spelling as much as an id space does.
|
|
134
|
+
*/
|
|
135
|
+
export const LEAF_ID_FIELD = 'notation';
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The field a knowledge leaf mints its ACCESSION id in (UCS-1144).
|
|
139
|
+
*
|
|
140
|
+
* An accession is opaque, minted at PR time, never reused, never positional —
|
|
141
|
+
* everything a dotted notation is not. Since UCS-1147 every leaf carries one
|
|
142
|
+
* and it IS the leaf's identity: the only key the store indexes by, and the
|
|
143
|
+
* only spelling any record may cite it as.
|
|
144
|
+
*/
|
|
145
|
+
export const LEAF_ACCESSION_FIELD = 'id';
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A leaf's identity: its accession, and nothing else (UCS-1147).
|
|
149
|
+
*
|
|
150
|
+
* The contract phase's whole shape, in one function. A leaf IS its accession —
|
|
151
|
+
* that is what every consumer reads, reports, indexes and serializes, and it is
|
|
152
|
+
* the only spelling a citation may reach it by. The expand phase (UCS-1144) let
|
|
153
|
+
* this fall back to the notation for a leaf that had not been minted one yet,
|
|
154
|
+
* and returned a second `keys` list of alternate spellings so notation-form
|
|
155
|
+
* citations kept resolving while the migrate batches ran. Both are gone: the
|
|
156
|
+
* migrations finished, the accession is required, and a leaf answers to exactly
|
|
157
|
+
* one name again.
|
|
158
|
+
*
|
|
159
|
+
* A leaf with NO accession therefore has no identity, and this says so by
|
|
160
|
+
* returning `undefined` rather than reaching for the notation. That is the
|
|
161
|
+
* honest answer and it is also the useful one: indexRecord declines a
|
|
162
|
+
* non-string id, so an unaccessioned leaf simply does not enter the index, and
|
|
163
|
+
* the schema's `missing-required` on `id` is the finding the author acts on.
|
|
164
|
+
* Falling back to the notation would instead index the leaf under a spelling
|
|
165
|
+
* nothing may cite — present in every enumeration, reachable by no reference.
|
|
166
|
+
*
|
|
167
|
+
* The id is checked against the ACCESSION GRAMMAR, not merely for being a
|
|
168
|
+
* string, and that is load-bearing rather than defensive. The index is what ref
|
|
169
|
+
* resolution consults, so whatever key a leaf lands under becomes a spelling
|
|
170
|
+
* that RESOLVES. A leaf carrying `id: "700.2"` would otherwise take identity
|
|
171
|
+
* under its own notation and quietly restore the dual-shape contract this
|
|
172
|
+
* ticket retired: the schema would report both records, and the citation would
|
|
173
|
+
* resolve anyway — the two mechanisms disagreeing about whether a notation is a
|
|
174
|
+
* citation. Refusing the malformed id here keeps one answer. The defect is
|
|
175
|
+
* already reported (a `pattern-mismatch` on the leaf's `id`, and `id-shape` on
|
|
176
|
+
* any catalog row naming it), so this adds no finding; it only declines to
|
|
177
|
+
* build an index entry on top of a value no check approved.
|
|
178
|
+
*
|
|
179
|
+
* Non-string and malformed ids are dropped rather than coerced — KK-02 already
|
|
180
|
+
* diagnoses both, and a coerced key would index a leaf under a spelling no
|
|
181
|
+
* author ever wrote.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} record a parsed leaf front matter
|
|
184
|
+
* @returns {string|undefined} the leaf's identity, or undefined when it mints none
|
|
185
|
+
*/
|
|
186
|
+
export function leafIdentity(record) {
|
|
187
|
+
const accession = record[LEAF_ACCESSION_FIELD];
|
|
188
|
+
return typeof accession === 'string' && idPattern('accessions').test(accession)
|
|
189
|
+
? accession
|
|
190
|
+
: undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The id of one indexed record, whatever store it came from.
|
|
195
|
+
*
|
|
196
|
+
* Concepts and decisions carry `id`, leaves carry `identity` — one accessor,
|
|
197
|
+
* so a consumer that walks all three spaces (catalog checks, orphan checks)
|
|
198
|
+
* never names a store-specific field. The Map key is the same value; this is
|
|
199
|
+
* for the code paths that hold the entry rather than the pair.
|
|
200
|
+
*
|
|
201
|
+
* @param {{ id?: string, identity?: string }} entry an indexed record
|
|
202
|
+
* @returns {string|undefined} the record's id
|
|
203
|
+
*/
|
|
204
|
+
export const recordId = (entry) => entry.identity ?? entry.id;
|
|
205
|
+
|
|
206
|
+
export const DIAGNOSTIC_CODES = Object.freeze([
|
|
207
|
+
...ERROR_CODES,
|
|
208
|
+
'parse-error',
|
|
209
|
+
'read-error',
|
|
210
|
+
'duplicate-id',
|
|
211
|
+
'unresolved-ref',
|
|
212
|
+
'skipped-file',
|
|
213
|
+
'missing-store',
|
|
214
|
+
'missing-catalog',
|
|
215
|
+
'missing-rules',
|
|
216
|
+
'registry-name-mismatch',
|
|
217
|
+
'registry-store-mismatch',
|
|
218
|
+
'duplicate-registry-value',
|
|
219
|
+
'phoenix-name-mismatch',
|
|
220
|
+
'duplicate-phoenix-row',
|
|
221
|
+
'graduation-table-name-mismatch',
|
|
222
|
+
'graduation-table-store-mismatch',
|
|
223
|
+
'duplicate-graduation-category',
|
|
224
|
+
'graduation-threshold-shape',
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Freeze a ref-field table through every level it has: the table, each kind's
|
|
229
|
+
* row array, each row, and an array-form field path. `Object.freeze` is
|
|
230
|
+
* shallow, so freezing only the outer object would leave every row writable —
|
|
231
|
+
* and a mutated row is a silently rewritten cross-reference graph.
|
|
232
|
+
*
|
|
233
|
+
* @template {Record<string, Array<{ field: string|string[], space: string }>>} T
|
|
234
|
+
* @param {T} table the declaration table
|
|
235
|
+
* @returns {Readonly<T>} the same table, frozen all the way down
|
|
236
|
+
*/
|
|
237
|
+
function deepFreezeTable(table) {
|
|
238
|
+
for (const rows of Object.values(table)) {
|
|
239
|
+
for (const row of rows) {
|
|
240
|
+
if (Array.isArray(row.field)) Object.freeze(row.field);
|
|
241
|
+
Object.freeze(row);
|
|
242
|
+
}
|
|
243
|
+
Object.freeze(rows);
|
|
244
|
+
}
|
|
245
|
+
return Object.freeze(table);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Typed cross-references per store record shape (§3.1–3.3): field path → id space.
|
|
250
|
+
*
|
|
251
|
+
* This table IS the cross-reference graph. Every typed edge the engine knows
|
|
252
|
+
* about is a row here, and `collectRefs`/`resolveRefs` are generic over it —
|
|
253
|
+
* so a new edge is a new declaration, never a bespoke check bolted onto the
|
|
254
|
+
* walker. That property is what the rest of the frontmatter-v2 work leans on:
|
|
255
|
+
* `relates.depends-on`, `relates.contradicts` and friends arrive as rows.
|
|
256
|
+
*
|
|
257
|
+
* A `field` is a path of object keys ending at an ARRAY of id strings, spelled
|
|
258
|
+
* either as a dotted string ('relates.depends-on') or as an array of segments
|
|
259
|
+
* (['relates', 'depends-on']). Depth is arbitrary — one level, two, or the
|
|
260
|
+
* three that v2's nested `relates` map needs — because the walker descends the
|
|
261
|
+
* segments rather than destructuring a fixed `[head, tail]` pair, which is all
|
|
262
|
+
* it used to handle. Segments containing a literal dot must use the array form.
|
|
263
|
+
*
|
|
264
|
+
* The declared path doubles as the edge's `type` (a published field on
|
|
265
|
+
* `model.refs`, quoted in the unresolved-ref message), so it always reads as
|
|
266
|
+
* the dotted path an author would find in their own file.
|
|
267
|
+
*
|
|
268
|
+
* A row may set `scalar: true` for a field holding ONE id rather than a list
|
|
269
|
+
* (UCS-1155's `graduation.revokes`). It is declared rather than inferred from
|
|
270
|
+
* the value's runtime type: sniffing would silently accept `supersedes: D-001`
|
|
271
|
+
* — a field whose contract is a list — and index it as a working edge, turning
|
|
272
|
+
* a shape error into a reference that appears to resolve.
|
|
273
|
+
*
|
|
274
|
+
* Frozen all the way down (rows, and any array-form path): the table is a
|
|
275
|
+
* declaration every surface reads, so a consumer that could mutate a row would
|
|
276
|
+
* be rewriting the cross-reference graph out from under the loader.
|
|
277
|
+
*
|
|
278
|
+
* @type {Readonly<Record<string, ReadonlyArray<{ field: string|string[], space: string, scalar?: boolean }>>>}
|
|
279
|
+
*/
|
|
280
|
+
export const REF_FIELDS = deepFreezeTable({
|
|
281
|
+
'ontology-concept': [
|
|
282
|
+
{ field: 'used-by', space: 'concepts' },
|
|
283
|
+
{ field: 'confusable-with', space: 'concepts' },
|
|
284
|
+
{ field: 'rationale', space: 'decisions' },
|
|
285
|
+
],
|
|
286
|
+
'knowledge-leaf': [
|
|
287
|
+
{ field: 'cross-references.class-elsewhere', space: 'leaves' },
|
|
288
|
+
{ field: 'cross-references.see-also', space: 'leaves' },
|
|
289
|
+
// Typed edges (UCS-1151). `concepts` is the leaf→ontology edge, declared
|
|
290
|
+
// leaf-side because deciding what a leaf is ABOUT is curatorial and the
|
|
291
|
+
// leaf is what sits under the human write gate; the loader derives the
|
|
292
|
+
// reverse direction at load (leavesByConcept). The four `relates` rows are
|
|
293
|
+
// the leaf→leaf edge kinds, each a distinct claim, each reaching the leaf
|
|
294
|
+
// space — which is what the prefactor's arbitrary-depth walker was built
|
|
295
|
+
// for. Note what is NOT here: `paths` names the working tree rather than an
|
|
296
|
+
// id space, so it cannot be a ref row (see checkLeafPaths in validate.js).
|
|
297
|
+
{ field: 'concepts', space: 'concepts' },
|
|
298
|
+
{ field: 'relates.depends-on', space: 'leaves' },
|
|
299
|
+
{ field: 'relates.see-also', space: 'leaves' },
|
|
300
|
+
{ field: 'relates.contradicts', space: 'leaves' },
|
|
301
|
+
{ field: 'relates.supersedes', space: 'leaves' },
|
|
302
|
+
],
|
|
303
|
+
'decision-entry': [
|
|
304
|
+
{ field: 'supersedes', space: 'decisions' },
|
|
305
|
+
{ field: 'superseded-by', space: 'decisions' },
|
|
306
|
+
{ field: 'relates-to.concepts', space: 'concepts' },
|
|
307
|
+
{ field: 'relates-to.leaves', space: 'leaves' },
|
|
308
|
+
{ field: 'relates-to.decisions', space: 'decisions' },
|
|
309
|
+
// A revocation names the graduation it withdraws (UCS-1155) — ONE id, so
|
|
310
|
+
// the row is `scalar`. Declared here rather than checked bespokely, so a
|
|
311
|
+
// revocation citing no real graduation is the same `unresolved-ref` error
|
|
312
|
+
// it would be anywhere else, and the withdrawal stays connected in the
|
|
313
|
+
// record to the thing it withdrew.
|
|
314
|
+
{ field: 'graduation.revokes', space: 'decisions', scalar: true },
|
|
315
|
+
],
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The leaf→leaf edge KINDS, in the order a neighborhood presents them
|
|
320
|
+
* (UCS-1151), and the field the map itself lives under.
|
|
321
|
+
*
|
|
322
|
+
* Read from REF_FIELDS rather than restated, so the kinds a resolver expands
|
|
323
|
+
* over and the kinds the ref graph resolves are the same list by construction.
|
|
324
|
+
* Declaring a fifth kind is one more row in the table above; nothing here, and
|
|
325
|
+
* nothing in the resolver, has to learn about it.
|
|
326
|
+
*
|
|
327
|
+
* Order is the DECLARATION order, deliberately not alphabetical: `depends-on`
|
|
328
|
+
* before `see-also` before `contradicts` before `supersedes` is a rough reading
|
|
329
|
+
* of how load-bearing each edge is, and the table is where that judgement is
|
|
330
|
+
* recorded. Output within each kind is sorted; the kinds themselves keep this
|
|
331
|
+
* order, so a neighborhood is stable without being arbitrary.
|
|
332
|
+
*/
|
|
333
|
+
export const RELATES_FIELD = 'relates';
|
|
334
|
+
export const RELATES_KINDS = Object.freeze(
|
|
335
|
+
REF_FIELDS['knowledge-leaf']
|
|
336
|
+
.map(({ field }) => (Array.isArray(field) ? field : field.split('.')))
|
|
337
|
+
.filter((segments) => segments.length === 2 && segments[0] === RELATES_FIELD)
|
|
338
|
+
.map((segments) => segments[1]),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The leaf field holding repo-tree paths (UCS-1151) — named once, here.
|
|
343
|
+
*
|
|
344
|
+
* Two surfaces read it and they must never disagree about the spelling: the
|
|
345
|
+
* validator checks each path EXISTS, and the resolver joins reverse lookups
|
|
346
|
+
* over the same list. A rename that reached one and not the other would leave
|
|
347
|
+
* paths reverse-looked-up but unchecked, or checked but unreachable — either
|
|
348
|
+
* way a seam that silently half-works.
|
|
349
|
+
*/
|
|
350
|
+
export const LEAF_PATHS_FIELD = 'paths';
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The concepts one leaf declares (UCS-1151) — the single reader of the leaf's
|
|
354
|
+
* `concepts` spelling, for the same reason `leafStage` is for `facets.stage`.
|
|
355
|
+
*
|
|
356
|
+
* Non-strings are dropped rather than coerced: KK-02 already diagnoses the
|
|
357
|
+
* wrong type, and a coerced id would join a leaf to a concept nobody named.
|
|
358
|
+
*
|
|
359
|
+
* @param {object} record a leaf's front-matter record
|
|
360
|
+
* @returns {string[]} the declared concept ids, in authored order
|
|
361
|
+
*/
|
|
362
|
+
export function leafConcepts(record) {
|
|
363
|
+
const declared = record?.concepts;
|
|
364
|
+
return Array.isArray(declared) ? declared.filter((id) => typeof id === 'string') : [];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The id spaces a ref row may target, and the store each one is declared in. */
|
|
368
|
+
const SPACE_TO_STORE = Object.freeze({
|
|
369
|
+
concepts: 'ontology',
|
|
370
|
+
leaves: 'knowledge',
|
|
371
|
+
decisions: 'decisions',
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The subdirectory a store keeps its governed vocabulary REGISTRIES in
|
|
376
|
+
* (UCS-1148), and the extension those files carry.
|
|
377
|
+
*
|
|
378
|
+
* Underscore-prefixed like `_catalog.yaml` and `_rules.yaml`, and for the same
|
|
379
|
+
* reason: it is governed store META, not a record. `listFiles` already skips
|
|
380
|
+
* every `_`-prefixed entry, so the record walks cannot see registries and a
|
|
381
|
+
* registry can never be mistaken for a leaf — the naming grammar does the
|
|
382
|
+
* separating, with no exception list to keep in sync.
|
|
383
|
+
*/
|
|
384
|
+
/**
|
|
385
|
+
* The derived layer's directory name (UCS-1158) — declared HERE, where the
|
|
386
|
+
* loader that must ignore it lives.
|
|
387
|
+
*
|
|
388
|
+
* The derived layer is engine output: plural browse trees, synthesized call
|
|
389
|
+
* numbers, a resolution index. It is regenerable and disposable, and the
|
|
390
|
+
* property that makes those words true is that nothing reads it back. The
|
|
391
|
+
* loader is the surface that would break that first — browse trees are markdown
|
|
392
|
+
* files under `knowledge/`, exactly like leaves — so the name lives beside the
|
|
393
|
+
* walk that skips it rather than in lib/derived.js, which would make the loader
|
|
394
|
+
* import the layer it is supposed to be independent of.
|
|
395
|
+
*
|
|
396
|
+
* lib/derived.js re-exports this so the generating side and the ignoring side
|
|
397
|
+
* name one string.
|
|
398
|
+
*/
|
|
399
|
+
export const DERIVED_DIR = 'derived';
|
|
400
|
+
|
|
401
|
+
export const REGISTRY_DIR = '_registries';
|
|
402
|
+
const REGISTRY_EXTENSION = '.yaml';
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Where a store keeps its phoenix event mappings (UCS-1154).
|
|
406
|
+
*
|
|
407
|
+
* `_`-prefixed for the same reason `_registries` is: the record walks skip
|
|
408
|
+
* every underscore entry, so an event mapping can never be mistaken for a
|
|
409
|
+
* leaf. The mappings are RETAINED in the store after the event applies,
|
|
410
|
+
* because they are what lets the structural validator confirm — from the
|
|
411
|
+
* working tree alone, with no git history — that every non-1 edition was
|
|
412
|
+
* sanctioned by a phoenix event rather than typed by hand.
|
|
413
|
+
*/
|
|
414
|
+
export const PHOENIX_DIR = '_phoenix';
|
|
415
|
+
const PHOENIX_EXTENSION = '.yaml';
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The trust graduation category table's file class (UCS-1155).
|
|
419
|
+
*
|
|
420
|
+
* It lives in `_registries/` rather than a directory of its own, and that is a
|
|
421
|
+
* deliberate reading of what it IS: a governed table declaring a closed
|
|
422
|
+
* vocabulary — the change categories autonomy may ever be scoped to — with a
|
|
423
|
+
* warrant and a Decisions entry per row, which is precisely a registry's shape.
|
|
424
|
+
* A second directory would have split one governance idea across two file
|
|
425
|
+
* classes for no gain a steward can see.
|
|
426
|
+
*
|
|
427
|
+
* It is a distinct KIND inside that directory because it answers a different
|
|
428
|
+
* question. A vocabulary registry says which values a facet may take; this
|
|
429
|
+
* table says how much INSPECTION a class of change gets, and carries an
|
|
430
|
+
* eligibility and a threshold no registry row has. Loading it as a registry
|
|
431
|
+
* would have meant either bending the registry schema to hold graduation
|
|
432
|
+
* fields, or letting the table pass unvalidated — so the file class is shared
|
|
433
|
+
* and the schema is not, keyed off the document's own `table` key.
|
|
434
|
+
*
|
|
435
|
+
* The table is filed under DECISIONS because graduation governs the change
|
|
436
|
+
* process rather than the knowledge: the truth anchor is the team (D-003).
|
|
437
|
+
*/
|
|
438
|
+
export const GRADUATION_TABLE_KEY = 'table';
|
|
439
|
+
const GRADUATION_EXTENSION = '.yaml';
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Per-store file-class descriptors (UCS-1148) — what a store IS, as data.
|
|
443
|
+
*
|
|
444
|
+
* The loader used to carry each store's shape in its control flow: which
|
|
445
|
+
* directory the records sit in, which extension they wear, whether the walk
|
|
446
|
+
* recurses, whether a `_rules.yaml` is expected, and a `store !== 'decisions'`
|
|
447
|
+
* ternary at the call site. Adding registries as a fourth bespoke code path
|
|
448
|
+
* would have been the fifth place a store's shape is spelled, so the shape
|
|
449
|
+
* moved here instead: a store is a row, a registry is a declared file class,
|
|
450
|
+
* and `loadStores` reads the table rather than knowing the stores.
|
|
451
|
+
*
|
|
452
|
+
* dir the store directory, root-relative (also the store's name)
|
|
453
|
+
* records how the record files are found and read, or null for a store
|
|
454
|
+
* whose records are loaded by a bespoke reader (knowledge leaves
|
|
455
|
+
* are front matter + body, which is a parser, not a descriptor)
|
|
456
|
+
* reader names that bespoke reader in BESPOKE_READERS, for a store with
|
|
457
|
+
* no `records` shape — so "how is this store read" stays a fact
|
|
458
|
+
* in the table rather than a branch in the load loop
|
|
459
|
+
* subdir where under the store the record files live
|
|
460
|
+
* kind the KK-02 record kind each file validates as
|
|
461
|
+
* space the id space the records index into
|
|
462
|
+
* extension the file extension the walk reads
|
|
463
|
+
* recursive whether the walk descends into subdirectories
|
|
464
|
+
* rules whether the store declares a `_rules.yaml` (decisions does
|
|
465
|
+
* not, by design — §9.1)
|
|
466
|
+
* registries whether the store may carry `_registries/*.yaml`
|
|
467
|
+
* phoenix whether the store may carry `_phoenix/*.yaml` — the retained
|
|
468
|
+
* event mappings a bulk re-taxonomy leaves behind (UCS-1154)
|
|
469
|
+
*
|
|
470
|
+
* Frozen: every surface reads this table, so a consumer able to mutate a row
|
|
471
|
+
* would be redefining a store's shape out from under the loader.
|
|
472
|
+
*
|
|
473
|
+
* @type {Readonly<Record<string, Readonly<object>>>}
|
|
474
|
+
*/
|
|
475
|
+
export const STORE_DESCRIPTORS = Object.freeze({
|
|
476
|
+
ontology: Object.freeze({
|
|
477
|
+
dir: 'ontology',
|
|
478
|
+
records: Object.freeze({
|
|
479
|
+
subdir: 'classes', kind: 'ontology-concept', space: 'concepts',
|
|
480
|
+
extension: '.yaml', recursive: false,
|
|
481
|
+
}),
|
|
482
|
+
rules: true,
|
|
483
|
+
registries: true,
|
|
484
|
+
}),
|
|
485
|
+
knowledge: Object.freeze({
|
|
486
|
+
dir: 'knowledge',
|
|
487
|
+
// Leaves are YAML front matter plus a markdown body — a parse shape, not a
|
|
488
|
+
// walk shape, so no `records` descriptor can express them. `reader` names
|
|
489
|
+
// the bespoke loader instead, which keeps the branch in the TABLE rather
|
|
490
|
+
// than in the load loop: the loop asks each store how it is read and never
|
|
491
|
+
// learns that one store is special.
|
|
492
|
+
records: null,
|
|
493
|
+
reader: 'loadLeafFiles',
|
|
494
|
+
rules: true,
|
|
495
|
+
registries: true,
|
|
496
|
+
// Only knowledge carries phoenix events: an event re-taxonomizes LEAVES,
|
|
497
|
+
// and the leaves live here.
|
|
498
|
+
phoenix: true,
|
|
499
|
+
}),
|
|
500
|
+
decisions: Object.freeze({
|
|
501
|
+
dir: 'decisions',
|
|
502
|
+
records: Object.freeze({
|
|
503
|
+
subdir: 'entries', kind: 'decision-entry', space: 'decisions',
|
|
504
|
+
extension: '.yaml', recursive: false,
|
|
505
|
+
}),
|
|
506
|
+
rules: false, // §9.1: decisions has no _rules.yaml
|
|
507
|
+
registries: true,
|
|
508
|
+
}),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
/** The store directories the loader walks, in load order. */
|
|
512
|
+
export const STORES = Object.freeze(Object.keys(STORE_DESCRIPTORS));
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The stores that may carry phoenix events, derived from the descriptors
|
|
516
|
+
* (UCS-1154).
|
|
517
|
+
*
|
|
518
|
+
* DERIVED, never restated. The loader reads `descriptor.phoenix` to decide
|
|
519
|
+
* where to look for mappings; a consumer that hardcoded "knowledge" instead
|
|
520
|
+
* would keep working right up until a second store gained the flag, at which
|
|
521
|
+
* point that store's events would load into `model.phoenix` and be unreachable
|
|
522
|
+
* from the command line — present in the model, absent from every lookup. One
|
|
523
|
+
* table decides, and everything asks it.
|
|
524
|
+
*/
|
|
525
|
+
export const PHOENIX_STORES = Object.freeze(
|
|
526
|
+
STORES.filter((store) => STORE_DESCRIPTORS[store].phoenix),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
530
|
+
|
|
531
|
+
function sortedMap(map) {
|
|
532
|
+
return new Map([...map.entries()].sort((a, b) => compare(a[0], b[0])));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Parse one YAML document with scalar types intact (§3.5 coercion trap). */
|
|
536
|
+
function parseYaml(ctx, file, text) {
|
|
537
|
+
try {
|
|
538
|
+
return { doc: load(text, { filename: file }) };
|
|
539
|
+
} catch (error) {
|
|
540
|
+
const reason = error instanceof YAMLException ? error.reason ?? error.message : error.message;
|
|
541
|
+
ctx.diagnostics.push({
|
|
542
|
+
severity: 'error', code: 'parse-error', file, path: '',
|
|
543
|
+
message: `unparseable YAML: ${reason}`,
|
|
544
|
+
});
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Validate a store file via KK-02 and map its errors onto the one scale. */
|
|
550
|
+
function validateInto(ctx, kind, file, doc) {
|
|
551
|
+
const { ok, errors } = validateStoreFile(kind, doc);
|
|
552
|
+
for (const { path, code, message } of errors) {
|
|
553
|
+
ctx.diagnostics.push({ severity: 'error', code, file, path, message });
|
|
554
|
+
}
|
|
555
|
+
return ok;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Index one record by id; the second mint of an id is a duplicate-id error.
|
|
560
|
+
*
|
|
561
|
+
* @returns {boolean} whether the record took the id. A caller with further
|
|
562
|
+
* indexing to do for the same record must check: a record that LOST a
|
|
563
|
+
* collision owns nothing, and anything else indexed in its name would be
|
|
564
|
+
* filed under the winner (see loadLeafFiles).
|
|
565
|
+
*/
|
|
566
|
+
function indexRecord(ctx, space, id, file, path, entry) {
|
|
567
|
+
if (typeof id !== 'string') return false; // shape defects already diagnosed by KK-02
|
|
568
|
+
const existing = ctx[space].get(id);
|
|
569
|
+
if (existing) {
|
|
570
|
+
ctx.diagnostics.push({
|
|
571
|
+
severity: 'error', code: 'duplicate-id', file, path,
|
|
572
|
+
message: `id "${id}" is already minted in ${existing.file} — published ids are immutable; the later PR renumbers its own entry (§3.5)`,
|
|
573
|
+
});
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
ctx[space].set(id, entry);
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The segments of a declared ref field path. Dotted strings are the ordinary
|
|
582
|
+
* spelling; the array form exists for a segment that contains a literal dot.
|
|
583
|
+
*
|
|
584
|
+
* @param {string|string[]} field as declared in REF_FIELDS
|
|
585
|
+
* @returns {string[]} the object keys to descend, outermost first
|
|
586
|
+
*/
|
|
587
|
+
const fieldSegments = (field) => (Array.isArray(field) ? field : field.split('.'));
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The author-facing spelling of a declared path — the edge's `type`, and the
|
|
591
|
+
* stem of the `path` a finding quotes.
|
|
592
|
+
*
|
|
593
|
+
* Deliberately a plain dotted join, with NO escaping. This string's whole job
|
|
594
|
+
* is to be findable: an author reading `cross-references.see-also[0]` searches
|
|
595
|
+
* their own file for exactly that. An escaped rendering (`v1\.2.refs[0]`)
|
|
596
|
+
* would match nothing they wrote, so escaping would trade a real, everyday
|
|
597
|
+
* cost against a collision that only two DECLARATIONS can create.
|
|
598
|
+
*
|
|
599
|
+
* That collision is instead refused at the source (assertDistinctPaths): two
|
|
600
|
+
* rows like ['a.b','c'] and ['a','b.c'] would render identically, and the fix
|
|
601
|
+
* is to reject the ambiguous TABLE, not to disfigure every finding message.
|
|
602
|
+
*
|
|
603
|
+
* @param {string[]} segments the declared segments
|
|
604
|
+
* @returns {string} the dotted path as an author would find it in their file
|
|
605
|
+
*/
|
|
606
|
+
const pathLabel = (segments) => segments.join('.');
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Refuse a ref-field table in which two rows render the same author-facing
|
|
610
|
+
* path — an engine failure at load, never a silent diagnostic.
|
|
611
|
+
*
|
|
612
|
+
* Only the array form can cause this (a segment carrying a literal dot), and
|
|
613
|
+
* only against another row of the same record kind. Two indistinguishable
|
|
614
|
+
* edges would make a finding ambiguous about which declaration it came from,
|
|
615
|
+
* so the table is refused rather than believed: shipping it would be a check
|
|
616
|
+
* whose output cannot be acted on (PRD §5).
|
|
617
|
+
*
|
|
618
|
+
* @param {Record<string, ReadonlyArray<{ field: string|string[] }>>} table
|
|
619
|
+
* @throws {Error} if any record kind declares two rows with the same rendering
|
|
620
|
+
*/
|
|
621
|
+
export function assertDistinctPaths(table) {
|
|
622
|
+
for (const [kind, rows] of Object.entries(table)) {
|
|
623
|
+
const seen = new Map();
|
|
624
|
+
for (const { field } of rows) {
|
|
625
|
+
const label = pathLabel(fieldSegments(field));
|
|
626
|
+
const previous = seen.get(label);
|
|
627
|
+
if (previous !== undefined) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`ref-field table for "${kind}" declares two edges that render the same path "${label}" `
|
|
630
|
+
+ `(${JSON.stringify(previous)} and ${JSON.stringify(field)}) — findings could not say which edge they came from; `
|
|
631
|
+
+ 'spell one of them so the rendered paths differ',
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
seen.set(label, field);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// The shipped table is checked as this module loads: an ambiguous declaration
|
|
640
|
+
// is a defect in the kit itself, and must surface the moment it is introduced
|
|
641
|
+
// rather than as a confusing finding in somebody's repo.
|
|
642
|
+
assertDistinctPaths(REF_FIELDS);
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Follow a declared field path into one record, at any depth.
|
|
646
|
+
*
|
|
647
|
+
* Returns the value at the end of the path, or undefined if any intermediate
|
|
648
|
+
* segment is missing or is not an object — a record that simply does not carry
|
|
649
|
+
* the edge is the common case, not a defect, and shape defects at the leaf are
|
|
650
|
+
* already diagnosed by KK-02. The caller decides what a non-array end means.
|
|
651
|
+
*
|
|
652
|
+
* @param {object} record the parsed record
|
|
653
|
+
* @param {string[]} segments object keys to descend, outermost first
|
|
654
|
+
* @returns {unknown} the value at the path's end, or undefined
|
|
655
|
+
*/
|
|
656
|
+
function valueAtPath(record, segments) {
|
|
657
|
+
let node = record;
|
|
658
|
+
for (const segment of segments) {
|
|
659
|
+
if (!isObject(node)) return undefined;
|
|
660
|
+
node = node[segment];
|
|
661
|
+
}
|
|
662
|
+
return node;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The typed ref edges one record declares — the whole ref-graph walker.
|
|
667
|
+
*
|
|
668
|
+
* Generic over the DECLARATION at any depth: it descends the declared segments,
|
|
669
|
+
* so a nested map of typed arrays (frontmatter v2's `relates`) walks the same
|
|
670
|
+
* code path as a top-level array. Both the edge's `type` and its `path` are
|
|
671
|
+
* built from the declaration, which is why introducing an edge is adding a row
|
|
672
|
+
* to REF_FIELDS and nothing else — there is no per-edge branch to extend.
|
|
673
|
+
*
|
|
674
|
+
* Pure: it takes the rows rather than reaching for REF_FIELDS, and returns
|
|
675
|
+
* edges rather than pushing into the loader's context. That is what lets a
|
|
676
|
+
* test drive a synthetic record kind through the real walker without a fake
|
|
677
|
+
* edge being added to the shipped table (tests/load-stores.test.js).
|
|
678
|
+
*
|
|
679
|
+
* Non-string members are skipped: KK-02 already diagnoses the wrong type, and
|
|
680
|
+
* a second complaint from the ref graph would double-report one defect.
|
|
681
|
+
*
|
|
682
|
+
* @param {ReadonlyArray<{ field: string|string[], space: string }>} rows the
|
|
683
|
+
* declared edges for this record kind
|
|
684
|
+
* @param {object} record the parsed record to read edges out of
|
|
685
|
+
* @param {{ from: string, file: string, basePath?: string }} origin what the
|
|
686
|
+
* edges are attributed to — the record's id, its file, and the path prefix
|
|
687
|
+
* the record sits at within that file ('' for a one-record file)
|
|
688
|
+
* @returns {Array<{ from, type, to, file, path, space }>} edges in declaration
|
|
689
|
+
* order, then array order
|
|
690
|
+
*/
|
|
691
|
+
export function refEdges(rows, record, { from, file, basePath = '' }) {
|
|
692
|
+
const edges = [];
|
|
693
|
+
for (const { field, space, scalar } of rows) {
|
|
694
|
+
const segments = fieldSegments(field);
|
|
695
|
+
const value = valueAtPath(record, segments);
|
|
696
|
+
const type = pathLabel(segments);
|
|
697
|
+
// A `scalar` row holds ONE id rather than a list. Declared on the row, not
|
|
698
|
+
// sniffed from the value: inferring "array means list, string means scalar"
|
|
699
|
+
// would silently accept `supersedes: D-001` — a field whose whole contract
|
|
700
|
+
// is a list — and index it as a resolvable edge, so a shape error would
|
|
701
|
+
// read as a working reference (KK-02 diagnoses the wrong type instead).
|
|
702
|
+
if (scalar) {
|
|
703
|
+
if (typeof value !== 'string') continue; // absent, or a wrong-type KK-02 diagnosed
|
|
704
|
+
edges.push({
|
|
705
|
+
from, type, to: value, file,
|
|
706
|
+
path: basePath ? `${basePath}.${type}` : type,
|
|
707
|
+
space,
|
|
708
|
+
});
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (!Array.isArray(value)) continue;
|
|
712
|
+
value.forEach((to, i) => {
|
|
713
|
+
if (typeof to !== 'string') return; // wrong-type already diagnosed
|
|
714
|
+
const path = basePath ? `${basePath}.${type}[${i}]` : `${type}[${i}]`;
|
|
715
|
+
edges.push({ from, type, to, file, path, space });
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return edges;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Collect one record's declared edges into the loader's graph. */
|
|
722
|
+
function collectRefs(ctx, kind, from, file, basePath, record) {
|
|
723
|
+
ctx.refs.push(...refEdges(REF_FIELDS[kind], record, { from, file, basePath }));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Read one store file. Only a genuinely absent file returns null; any other
|
|
728
|
+
* failure (permissions, I/O) is an engine-visible read-error — an unread
|
|
729
|
+
* file must never masquerade as a missing or malformed one (PRD §5).
|
|
730
|
+
*/
|
|
731
|
+
function readText(ctx, file) {
|
|
732
|
+
try {
|
|
733
|
+
return readFileSync(join(ctx.root, file), 'utf8');
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (error.code !== 'ENOENT') {
|
|
736
|
+
ctx.diagnostics.push({
|
|
737
|
+
severity: 'error', code: 'read-error', file, path: '',
|
|
738
|
+
message: `cannot read file: ${error.message}`,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* One pipeline for every store meta file: read → parse → validate → assign.
|
|
747
|
+
*
|
|
748
|
+
* `kind` may be a function of the PARSED document rather than a fixed string,
|
|
749
|
+
* for a directory holding more than one kind (`_registries/` carries both the
|
|
750
|
+
* vocabulary registries and the trust graduation category table, UCS-1155).
|
|
751
|
+
* Dispatching inside the pipeline keeps one read and one parse: a caller that
|
|
752
|
+
* peeked at the file first to choose a kind would read every registry twice and
|
|
753
|
+
* could diagnose a parse error twice with it.
|
|
754
|
+
*/
|
|
755
|
+
function loadMetaFile(ctx, file, kind, { onMissing, onParsed } = {}) {
|
|
756
|
+
const text = readText(ctx, file);
|
|
757
|
+
if (text === null) {
|
|
758
|
+
onMissing?.();
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
const parsed = parseYaml(ctx, file, text);
|
|
762
|
+
if (!parsed) return null;
|
|
763
|
+
const resolvedKind = typeof kind === 'function' ? kind(parsed.doc) : kind;
|
|
764
|
+
const valid = validateInto(ctx, resolvedKind, file, parsed.doc);
|
|
765
|
+
onParsed?.(parsed.doc, valid, resolvedKind);
|
|
766
|
+
return valid ? parsed.doc : null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function loadCatalogAndRules(ctx, store, hasRules) {
|
|
770
|
+
const meta = ctx.stores[store];
|
|
771
|
+
meta.catalog = loadMetaFile(ctx, `${store}/_catalog.yaml`, 'catalog', {
|
|
772
|
+
onMissing: () => ctx.diagnostics.push({
|
|
773
|
+
severity: 'error', code: 'missing-catalog', file: `${store}/_catalog.yaml`, path: '',
|
|
774
|
+
message: `store "${store}" has no _catalog.yaml — the navigational entry point every store shares (PRD §3)`,
|
|
775
|
+
}),
|
|
776
|
+
// Harvest declared ids from every id-bearing row even when the catalog
|
|
777
|
+
// has schema defects elsewhere: one bad row must not turn every ref to a
|
|
778
|
+
// validly declared pending id into a spurious unresolved-ref cascade.
|
|
779
|
+
onParsed: (doc) => {
|
|
780
|
+
if (!isObject(doc) || !Array.isArray(doc.entries)) return;
|
|
781
|
+
for (const entry of doc.entries) {
|
|
782
|
+
if (isObject(entry) && typeof entry.id === 'string') ctx.declared[store].add(entry.id);
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
if (hasRules) {
|
|
787
|
+
meta.rules = loadMetaFile(ctx, `${store}/_rules.yaml`, 'rules', {
|
|
788
|
+
onMissing: () => ctx.diagnostics.push({
|
|
789
|
+
severity: 'warning', code: 'missing-rules', file: `${store}/_rules.yaml`, path: '',
|
|
790
|
+
message: `store "${store}" has no _rules.yaml (§9.1) — rules-dependent surfaces have no input`,
|
|
791
|
+
}),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Load one store's governed vocabulary registries (UCS-1148).
|
|
798
|
+
*
|
|
799
|
+
* A registry is a DECLARED FILE CLASS, reached through the store descriptor,
|
|
800
|
+
* not a fourth bespoke reader: the same read → parse → validate pipeline every
|
|
801
|
+
* store meta file rides, pointed at `<store>/_registries/*.yaml`.
|
|
802
|
+
*
|
|
803
|
+
* Registry ABSENCE is deliberately not diagnosed here, and that is the ticket's
|
|
804
|
+
* central conduct choice. A store with no registries is the whole installed
|
|
805
|
+
* base (and every fixture written before this ticket), so demanding registries
|
|
806
|
+
* unconditionally would fail every existing store for a governance layer it
|
|
807
|
+
* never opted into. Absence surfaces where it can actually mean something
|
|
808
|
+
* instead: at the point a record CITES a governed facet. A leaf naming
|
|
809
|
+
* `facets.domain` in a store with no domains registry is an explicit
|
|
810
|
+
* `missing-registry` finding naming both the value and the registry it wanted —
|
|
811
|
+
* never a silent pass, and never a demand on a store that governs nothing.
|
|
812
|
+
*
|
|
813
|
+
* A MALFORMED registry, by contrast, is a hard error the moment it is read:
|
|
814
|
+
* unparseable YAML is `parse-error` and a schema defect carries its KK-02 code,
|
|
815
|
+
* both error-severity, both gating the validator to exit 2. A vocabulary the
|
|
816
|
+
* engine could not read is a check that never ran, so membership must never be
|
|
817
|
+
* judged against half a registry.
|
|
818
|
+
*
|
|
819
|
+
* @param {object} ctx the loader context
|
|
820
|
+
* @param {string} store the store whose registries to load
|
|
821
|
+
*/
|
|
822
|
+
function loadRegistryFiles(ctx, store) {
|
|
823
|
+
const dir = `${store}/${REGISTRY_DIR}`;
|
|
824
|
+
for (const file of listFiles(ctx, dir, REGISTRY_EXTENSION, false, { skipUnderscore: false })) {
|
|
825
|
+
const name = file.slice(dir.length + 1, -REGISTRY_EXTENSION.length);
|
|
826
|
+
ctx.stores[store].files.push(file);
|
|
827
|
+
// The graduation category table shares this directory and is a different
|
|
828
|
+
// KIND (see GRADUATION_TABLE_KEY). Dispatch on the document's own `table:`
|
|
829
|
+
// key rather than on the filename, so the table is recognized by what it
|
|
830
|
+
// says it is: keying off a reserved basename would mean a steward who
|
|
831
|
+
// renamed the file got it silently validated as a registry, and every
|
|
832
|
+
// graduation row would then read as an unknown-property defect pointing at
|
|
833
|
+
// the wrong schema entirely.
|
|
834
|
+
let isTable = false;
|
|
835
|
+
const doc = loadMetaFile(ctx, file, (parsed) => {
|
|
836
|
+
isTable = isObject(parsed) && typeof parsed[GRADUATION_TABLE_KEY] === 'string';
|
|
837
|
+
return isTable ? 'graduation-categories' : 'registry';
|
|
838
|
+
});
|
|
839
|
+
if (doc === null) continue; // parse-error or schema defect already diagnosed
|
|
840
|
+
if (isTable) {
|
|
841
|
+
loadGraduationTable(ctx, store, file, name, doc);
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
// A registry whose declared name disagrees with its filename would make
|
|
845
|
+
// every membership finding cite a file that does not answer to the name it
|
|
846
|
+
// quotes. Refusing here keeps "the registry a finding names" and "the file
|
|
847
|
+
// a steward opens" the same thing.
|
|
848
|
+
if (doc.registry !== name) {
|
|
849
|
+
ctx.diagnostics.push({
|
|
850
|
+
severity: 'error', code: 'registry-name-mismatch', file, path: 'registry',
|
|
851
|
+
message: `registry declares name "${doc.registry}" but lives at ${file} — a finding that names a registry must name the file a steward opens`,
|
|
852
|
+
});
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
// The same argument one level up: a registry filed under the wrong store
|
|
856
|
+
// governs facets in a store it does not sit in. Registries are keyed
|
|
857
|
+
// "<store>/<name>", so believing the declaration would index this file
|
|
858
|
+
// under a key its own path contradicts — and a steward following the key
|
|
859
|
+
// would open a different store's directory.
|
|
860
|
+
if (doc.store !== store) {
|
|
861
|
+
ctx.diagnostics.push({
|
|
862
|
+
severity: 'error', code: 'registry-store-mismatch', file, path: 'store',
|
|
863
|
+
message: `registry declares store "${doc.store}" but lives under ${store}/ — a registry governs the store it sits in, and the two spellings must agree`,
|
|
864
|
+
});
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const registry = {
|
|
868
|
+
name,
|
|
869
|
+
store,
|
|
870
|
+
file,
|
|
871
|
+
hierarchical: doc.hierarchical === true,
|
|
872
|
+
minted: new Set(),
|
|
873
|
+
suppressed: new Set(),
|
|
874
|
+
};
|
|
875
|
+
// A value is declared ONCE. Two rows claiming one value is a defect
|
|
876
|
+
// whichever statuses they carry, and the two shapes fail differently:
|
|
877
|
+
//
|
|
878
|
+
// same status twice — a redundant row. Harmless to the sets, but one of
|
|
879
|
+
// the two warrants is the live one and a reader
|
|
880
|
+
// cannot tell which, so the vocabulary's own record
|
|
881
|
+
// of why a term exists has become ambiguous.
|
|
882
|
+
// minted AND suppressed — the value lands in both sets, and `judgeValue`
|
|
883
|
+
// tests suppression first, so a MINTED value silently
|
|
884
|
+
// reads as refused. The registry contradicts itself
|
|
885
|
+
// and the engine resolves it by evaluation order,
|
|
886
|
+
// which is not a governance decision anyone made.
|
|
887
|
+
//
|
|
888
|
+
// Both are refused rather than reconciled: "minted or suppressed" is the
|
|
889
|
+
// one question a registry exists to answer, and a file that answers it
|
|
890
|
+
// twice must be fixed by a steward, never guessed at here.
|
|
891
|
+
const declared = new Map(); // value -> the status its first row carried
|
|
892
|
+
for (const [i, entry] of doc.values.entries()) {
|
|
893
|
+
if (!isObject(entry) || typeof entry.value !== 'string') continue; // KK-02 diagnosed the shape
|
|
894
|
+
const status = entry.status === 'suppressed' ? 'suppressed' : 'minted';
|
|
895
|
+
const first = declared.get(entry.value);
|
|
896
|
+
if (first !== undefined) {
|
|
897
|
+
ctx.diagnostics.push({
|
|
898
|
+
severity: 'error', code: 'duplicate-registry-value', file, path: `values[${i}].value`,
|
|
899
|
+
message: first === status
|
|
900
|
+
? `value "${entry.value}" is declared twice, both times as ${status} — a value is declared once, and a duplicate row leaves two warrants with no way to tell which one governs`
|
|
901
|
+
: `value "${entry.value}" is declared as both ${first} and ${status} — a registry cannot mint and refuse the same value, and resolving the contradiction by file order would be a governance decision nobody made`,
|
|
902
|
+
});
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
declared.set(entry.value, status);
|
|
906
|
+
// "Each minting a Decisions entry" (UCS-1148) enforced rather than
|
|
907
|
+
// merely documented: the citation rides the ordinary ref graph, so an
|
|
908
|
+
// id naming no decision is the same `unresolved-ref` error it would be
|
|
909
|
+
// anywhere else, and the registry's governance is checked by the same
|
|
910
|
+
// machinery as every other cross-store citation.
|
|
911
|
+
if (typeof entry.decision === 'string') {
|
|
912
|
+
ctx.refs.push({
|
|
913
|
+
// Store-qualified, matching the `<store>/<name>` registry key: `refs`
|
|
914
|
+
// is a published, `from`-sorted model field, so two identically named
|
|
915
|
+
// registries in different stores must not share an edge origin.
|
|
916
|
+
from: `${store}/${name}/${entry.value}`,
|
|
917
|
+
type: 'registry.decision',
|
|
918
|
+
to: entry.decision,
|
|
919
|
+
file,
|
|
920
|
+
path: `values[${i}].decision`,
|
|
921
|
+
space: 'decisions',
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
// Absent status means minted; only an explicit suppression withholds a
|
|
925
|
+
// value. Both sets are kept because they answer different questions: a
|
|
926
|
+
// suppressed value is not usable, but it IS accounted for, and a finding
|
|
927
|
+
// that can say so tells an author "this was refused" rather than the far
|
|
928
|
+
// less useful "this does not exist".
|
|
929
|
+
registry[status].add(entry.value);
|
|
930
|
+
}
|
|
931
|
+
ctx.registries.set(`${store}/${name}`, registry);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Index one trust graduation category table (UCS-1155).
|
|
937
|
+
*
|
|
938
|
+
* The table declares which change CATEGORIES may graduate from full inspection
|
|
939
|
+
* to sampling and which are permanently gated. Autonomy is per category and
|
|
940
|
+
* never per leaf, so the table is the only thing that can answer "may this
|
|
941
|
+
* class of change graduate at all" — and a graduation Decisions entry naming a
|
|
942
|
+
* category absent from it is a validator finding rather than a silent pass.
|
|
943
|
+
*
|
|
944
|
+
* Three defects are refused HERE rather than downstream, because each one makes
|
|
945
|
+
* the table unable to answer the question it exists for:
|
|
946
|
+
*
|
|
947
|
+
* graduation-table-name-mismatch
|
|
948
|
+
* the declared `table` disagrees with the filename, so a finding
|
|
949
|
+
* naming the table would cite a file no steward can open under
|
|
950
|
+
* that name — the same rule registries and phoenix events keep.
|
|
951
|
+
* graduation-table-store-mismatch
|
|
952
|
+
* the declared `store` disagrees with the directory the file sits
|
|
953
|
+
* in. Every store loads `_registries/`, so a table can physically
|
|
954
|
+
* land under knowledge/ while declaring `decisions` and pass its
|
|
955
|
+
* schema; the key it indexes under would then contradict its own
|
|
956
|
+
* path.
|
|
957
|
+
* duplicate-graduation-category
|
|
958
|
+
* one category declared twice. Which row governs would then be
|
|
959
|
+
* decided by file order: two rows can carry different
|
|
960
|
+
* eligibilities, so a category could be both graduation-eligible
|
|
961
|
+
* and permanently gated, and the engine would pick one by
|
|
962
|
+
* accident. That is a governance decision nobody made.
|
|
963
|
+
* graduation-threshold-shape
|
|
964
|
+
* an `eligible` row with no threshold, or a `gated` row carrying
|
|
965
|
+
* one. The schema cannot state this — the engine's keyword subset
|
|
966
|
+
* has no conditional — and a rule stated in a keyword nothing
|
|
967
|
+
* enforces is contract drift wearing the appearance of a check.
|
|
968
|
+
* An eligible category with no threshold is eligible against no
|
|
969
|
+
* bar at all; a gated one with a threshold advertises a bar that
|
|
970
|
+
* can never be met, which reads as an oversight either way.
|
|
971
|
+
*
|
|
972
|
+
* Each row's `decision` rides the ordinary ref graph exactly as a registry
|
|
973
|
+
* minting's does, so a row citing no real decision is the same `unresolved-ref`
|
|
974
|
+
* error it would be anywhere else: autonomy nobody signed is the ungoverned
|
|
975
|
+
* drift this whole mechanism exists to prevent.
|
|
976
|
+
*
|
|
977
|
+
* @param {object} ctx the in-flight load context
|
|
978
|
+
* @param {string} store the store directory carrying the table
|
|
979
|
+
* @param {string} file the table's root-relative path
|
|
980
|
+
* @param {string} name the table's basename
|
|
981
|
+
* @param {object} doc the parsed, schema-valid table
|
|
982
|
+
*/
|
|
983
|
+
function loadGraduationTable(ctx, store, file, name, doc) {
|
|
984
|
+
if (doc[GRADUATION_TABLE_KEY] !== name) {
|
|
985
|
+
ctx.diagnostics.push({
|
|
986
|
+
severity: 'error', code: 'graduation-table-name-mismatch', file, path: GRADUATION_TABLE_KEY,
|
|
987
|
+
message: `graduation table declares name "${doc[GRADUATION_TABLE_KEY]}" but lives at ${file} — a finding that names a table must name the file a steward opens`,
|
|
988
|
+
});
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
// The same argument one level up, and the same check a registry already
|
|
992
|
+
// keeps: a table filed under the wrong store governs a trust boundary in a
|
|
993
|
+
// store it does not sit in. Tables are keyed "<store>/<name>", so believing
|
|
994
|
+
// the declaration would index this file under a key its own path
|
|
995
|
+
// contradicts, and a steward following the key would open a different
|
|
996
|
+
// store's directory. The schema requires the field; the loader is what makes
|
|
997
|
+
// it mean something.
|
|
998
|
+
if (doc.store !== store) {
|
|
999
|
+
ctx.diagnostics.push({
|
|
1000
|
+
severity: 'error', code: 'graduation-table-store-mismatch', file, path: 'store',
|
|
1001
|
+
message: `graduation table declares store "${doc.store}" but lives under ${store}/ — a table governs the store it sits in, and the two spellings must agree`,
|
|
1002
|
+
});
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const categories = new Map();
|
|
1006
|
+
for (const [i, row] of doc.categories.entries()) {
|
|
1007
|
+
if (!isObject(row) || typeof row.category !== 'string') continue; // KK-02 diagnosed the shape
|
|
1008
|
+
if (categories.has(row.category)) {
|
|
1009
|
+
ctx.diagnostics.push({
|
|
1010
|
+
severity: 'error', code: 'duplicate-graduation-category', file, path: `categories[${i}].category`,
|
|
1011
|
+
message: `category "${row.category}" is declared twice — one row per category, or the table states two eligibilities for one class of change and the engine would pick by file order`,
|
|
1012
|
+
});
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
// The conditional the schema subset cannot express, checked where it can
|
|
1016
|
+
// name the row an author must edit.
|
|
1017
|
+
const hasThreshold = Number.isInteger(row.threshold);
|
|
1018
|
+
if (row.eligibility === 'eligible' && !hasThreshold) {
|
|
1019
|
+
ctx.diagnostics.push({
|
|
1020
|
+
severity: 'error', code: 'graduation-threshold-shape', file, path: `categories[${i}].threshold`,
|
|
1021
|
+
message: `category "${row.category}" is graduation-eligible but declares no threshold — N is the bar a steward judges the recorded approved-unmodified count against, and an eligible category without one is eligible against nothing`,
|
|
1022
|
+
});
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
if (row.eligibility === 'gated' && hasThreshold) {
|
|
1026
|
+
ctx.diagnostics.push({
|
|
1027
|
+
severity: 'error', code: 'graduation-threshold-shape', file, path: `categories[${i}].threshold`,
|
|
1028
|
+
message: `category "${row.category}" is permanently gated but declares a threshold of ${row.threshold} — a gated category can never graduate, so a bar that can never be met reads as an eligibility somebody forgot to set`,
|
|
1029
|
+
});
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
categories.set(row.category, {
|
|
1033
|
+
category: row.category,
|
|
1034
|
+
eligibility: row.eligibility,
|
|
1035
|
+
threshold: hasThreshold ? row.threshold : null,
|
|
1036
|
+
warrant: row.warrant,
|
|
1037
|
+
decision: row.decision,
|
|
1038
|
+
file,
|
|
1039
|
+
index: i,
|
|
1040
|
+
});
|
|
1041
|
+
// The row's warrant rides the ordinary ref graph, exactly as a registry
|
|
1042
|
+
// minting's does.
|
|
1043
|
+
if (typeof row.decision === 'string') {
|
|
1044
|
+
ctx.refs.push({
|
|
1045
|
+
from: `${store}/${name}/${row.category}`,
|
|
1046
|
+
type: 'graduation.decision',
|
|
1047
|
+
to: row.decision,
|
|
1048
|
+
file,
|
|
1049
|
+
path: `categories[${i}].decision`,
|
|
1050
|
+
space: 'decisions',
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
ctx.graduations.set(`${store}/${name}`, { table: name, store, file, categories });
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Load a store's retained phoenix event mappings (UCS-1154).
|
|
1059
|
+
*
|
|
1060
|
+
* Shaped after `loadRegistryFiles`, and for the same reason: the file's
|
|
1061
|
+
* declared id must agree with its filename, or a finding that names an event
|
|
1062
|
+
* would cite a file a steward cannot open under that name.
|
|
1063
|
+
*
|
|
1064
|
+
* The mapping is indexed but never APPLIED here. Applying it is the phoenix
|
|
1065
|
+
* command's job, and it happens once; what the loader publishes is the record
|
|
1066
|
+
* of an event that already happened, so the validator can hold every non-1
|
|
1067
|
+
* edition against it.
|
|
1068
|
+
*
|
|
1069
|
+
* @param {object} ctx the in-flight load context
|
|
1070
|
+
* @param {string} store the store directory carrying `_phoenix/`
|
|
1071
|
+
*/
|
|
1072
|
+
function loadPhoenixFiles(ctx, store) {
|
|
1073
|
+
const dir = `${store}/${PHOENIX_DIR}`;
|
|
1074
|
+
for (const file of listFiles(ctx, dir, PHOENIX_EXTENSION, false, { skipUnderscore: false })) {
|
|
1075
|
+
const name = file.slice(dir.length + 1, -PHOENIX_EXTENSION.length);
|
|
1076
|
+
ctx.stores[store].files.push(file);
|
|
1077
|
+
const doc = loadMetaFile(ctx, file, 'phoenix-event');
|
|
1078
|
+
if (doc === null) continue; // parse-error or schema defect already diagnosed
|
|
1079
|
+
if (doc.event !== name) {
|
|
1080
|
+
ctx.diagnostics.push({
|
|
1081
|
+
severity: 'error', code: 'phoenix-name-mismatch', file, path: 'event',
|
|
1082
|
+
message: `phoenix event declares id "${doc.event}" but lives at ${file} — a finding that names an event must name the file a steward opens`,
|
|
1083
|
+
});
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
// The event's warrant rides the ordinary ref graph, exactly as a registry
|
|
1087
|
+
// minting's does: a decision id naming no decision is the same
|
|
1088
|
+
// `unresolved-ref` error it would be anywhere else. A bulk re-taxonomy
|
|
1089
|
+
// nobody signed is the ungoverned drift this mechanism exists to replace.
|
|
1090
|
+
ctx.refs.push({
|
|
1091
|
+
from: `${store}/${PHOENIX_DIR}/${doc.event}`,
|
|
1092
|
+
type: 'phoenix.decision',
|
|
1093
|
+
to: doc.decision,
|
|
1094
|
+
file,
|
|
1095
|
+
path: 'decision',
|
|
1096
|
+
space: 'decisions',
|
|
1097
|
+
});
|
|
1098
|
+
const rows = new Map();
|
|
1099
|
+
for (const [i, row] of doc.leaves.entries()) {
|
|
1100
|
+
if (!isObject(row) || typeof row.id !== 'string') continue; // KK-02 diagnosed the shape
|
|
1101
|
+
// One row per leaf. Two rows for one accession would make "what did this
|
|
1102
|
+
// event do to L-x" a question the file answers twice, and choosing by
|
|
1103
|
+
// file order would be a governance decision nobody made.
|
|
1104
|
+
if (rows.has(row.id)) {
|
|
1105
|
+
ctx.diagnostics.push({
|
|
1106
|
+
severity: 'error', code: 'duplicate-phoenix-row', file, path: `leaves[${i}].id`,
|
|
1107
|
+
message: `leaf "${row.id}" is mapped twice by event ${doc.event} — one row per leaf, or the event states two fates for one leaf and the engine would pick by file order`,
|
|
1108
|
+
});
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
rows.set(row.id, { ...row, index: i });
|
|
1112
|
+
}
|
|
1113
|
+
ctx.phoenix.set(`${store}/${name}`, {
|
|
1114
|
+
event: doc.event, store, file, decision: doc.decision, scope: doc.scope, rows,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function listFiles(ctx, dir, extension, recursive, { skipUnderscore = true, skipDirs = [] } = {}) {
|
|
1120
|
+
const out = [];
|
|
1121
|
+
const skipped = new Set(skipDirs);
|
|
1122
|
+
const walk = (rel) => {
|
|
1123
|
+
let entries;
|
|
1124
|
+
try {
|
|
1125
|
+
entries = readdirSync(join(ctx.root, rel), { withFileTypes: true });
|
|
1126
|
+
} catch {
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
for (const entry of entries) {
|
|
1130
|
+
// Underscore-prefixed names are governed store META (`_catalog.yaml`,
|
|
1131
|
+
// `_rules.yaml`, `_registries/`), never records — so a record walk skips
|
|
1132
|
+
// them, and the registry walk (which IS a meta walk, already pointed at
|
|
1133
|
+
// the `_registries` directory) does not.
|
|
1134
|
+
if (skipUnderscore && entry.name.startsWith('_')) continue;
|
|
1135
|
+
if (entry.name.startsWith('.')) continue;
|
|
1136
|
+
// A named directory the walk must not descend into, and must not warn
|
|
1137
|
+
// about either (UCS-1158). `derived/` holds engine OUTPUT in the same
|
|
1138
|
+
// extension the records use — browse trees are markdown, and so are
|
|
1139
|
+
// leaves — so neither the underscore rule nor the extension check can
|
|
1140
|
+
// tell them apart. Skipping by name is what keeps a generated artifact
|
|
1141
|
+
// from being loaded as a leaf, which would make the derived layer
|
|
1142
|
+
// load-bearing: the trees would enter the model, fail the leaf schema,
|
|
1143
|
+
// and a store's health would depend on a directory whose whole contract
|
|
1144
|
+
// is that deleting it loses nothing.
|
|
1145
|
+
if (entry.isDirectory() && skipped.has(entry.name)) continue;
|
|
1146
|
+
const relPath = `${rel}/${entry.name}`;
|
|
1147
|
+
if (entry.isDirectory()) {
|
|
1148
|
+
if (recursive) walk(relPath);
|
|
1149
|
+
} else if (entry.name.endsWith(extension)) {
|
|
1150
|
+
// Symlinked record files load like regular ones (isFile() is false
|
|
1151
|
+
// for symlinks; the readFileSync that follows resolves them).
|
|
1152
|
+
out.push(relPath);
|
|
1153
|
+
} else {
|
|
1154
|
+
// A file the loader will not read must never be a silent pass
|
|
1155
|
+
// (PRD §5): its ids simply wouldn't exist, with nothing recorded.
|
|
1156
|
+
ctx.diagnostics.push({
|
|
1157
|
+
severity: 'warning', code: 'skipped-file', file: relPath, path: '',
|
|
1158
|
+
message: `not a ${extension} file — the loader only reads ${extension} records here; rename it or move it out of the store`,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
walk(dir);
|
|
1164
|
+
return out.sort(compare);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Ontology class files / decision entries files: the storeFile envelope.
|
|
1169
|
+
* Driven by the store's `records` descriptor, so the two stores that share
|
|
1170
|
+
* this shape share it as data rather than as two call sites spelling it out.
|
|
1171
|
+
*/
|
|
1172
|
+
function loadEntriesFiles(ctx, store, { subdir, kind, space, extension, recursive }) {
|
|
1173
|
+
for (const file of listFiles(ctx, `${store}/${subdir}`, extension, recursive)) {
|
|
1174
|
+
ctx.stores[store].files.push(file);
|
|
1175
|
+
const text = readText(ctx, file);
|
|
1176
|
+
if (text === null) continue; // read-error already diagnosed
|
|
1177
|
+
const parsed = parseYaml(ctx, file, text);
|
|
1178
|
+
if (!parsed) continue;
|
|
1179
|
+
validateInto(ctx, kind, file, parsed.doc);
|
|
1180
|
+
if (!isObject(parsed.doc) || !Array.isArray(parsed.doc.entries)) continue;
|
|
1181
|
+
parsed.doc.entries.forEach((record, i) => {
|
|
1182
|
+
if (!isObject(record)) return;
|
|
1183
|
+
const basePath = `entries[${i}]`;
|
|
1184
|
+
indexRecord(ctx, space, record.id, file, `${basePath}.id`, { id: record.id, file, record });
|
|
1185
|
+
if (typeof record.id === 'string') collectRefs(ctx, kind, record.id, file, basePath, record);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Knowledge leaves: YAML front matter + markdown body, one leaf per file. */
|
|
1191
|
+
function loadLeafFiles(ctx) {
|
|
1192
|
+
for (const file of listFiles(ctx, 'knowledge', '.md', true, { skipDirs: [DERIVED_DIR] })) {
|
|
1193
|
+
ctx.stores.knowledge.files.push(file);
|
|
1194
|
+
const raw = readText(ctx, file);
|
|
1195
|
+
if (raw === null) continue; // read-error already diagnosed
|
|
1196
|
+
// Editors and autocrlf produce BOMs and CRLF; both are well-formed input.
|
|
1197
|
+
const text = raw.replace(/^/, '').replace(/\r\n/g, '\n');
|
|
1198
|
+
const match = /^---\n([^]*?\n)?---(?:\n|$)([^]*)$/.exec(text);
|
|
1199
|
+
if (!match) {
|
|
1200
|
+
ctx.diagnostics.push({
|
|
1201
|
+
severity: 'error', code: 'parse-error', file, path: '',
|
|
1202
|
+
message: 'knowledge leaf must open with YAML front matter fenced by "---" lines (§3.2)',
|
|
1203
|
+
});
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const parsed = parseYaml(ctx, file, match[1] ?? '');
|
|
1207
|
+
if (!parsed) continue;
|
|
1208
|
+
validateInto(ctx, 'knowledge-leaf', file, parsed.doc);
|
|
1209
|
+
const record = parsed.doc;
|
|
1210
|
+
if (!isObject(record)) continue;
|
|
1211
|
+
// The leaf's identity is read through leafIdentity(), never by naming a
|
|
1212
|
+
// field here: that function is the whole seam an id-space change moves
|
|
1213
|
+
// through (UCS-1142, UCS-1144, UCS-1147). `identity` is the neutral key
|
|
1214
|
+
// consumers read; `notation` stays alongside it because it is a PUBLIC
|
|
1215
|
+
// resolver field — the optional legacy display label — and `id` because it
|
|
1216
|
+
// is what the resolver publishes as the leaf's identity.
|
|
1217
|
+
const identity = leafIdentity(record);
|
|
1218
|
+
const entry = {
|
|
1219
|
+
identity,
|
|
1220
|
+
[LEAF_ACCESSION_FIELD]: record[LEAF_ACCESSION_FIELD] ?? null,
|
|
1221
|
+
[LEAF_ID_FIELD]: record[LEAF_ID_FIELD],
|
|
1222
|
+
file,
|
|
1223
|
+
record,
|
|
1224
|
+
body: match[2],
|
|
1225
|
+
};
|
|
1226
|
+
// `leaves` is keyed by the accession alone — one entry per leaf, under the
|
|
1227
|
+
// one name anything may cite it by. A leaf that mints no accession has no
|
|
1228
|
+
// identity to be keyed under, so indexRecord declines it (a non-string id)
|
|
1229
|
+
// and the schema's missing-required on `id` is the finding the author acts
|
|
1230
|
+
// on. Indexing it under its notation instead would put a leaf in every
|
|
1231
|
+
// enumeration that no citation could reach.
|
|
1232
|
+
//
|
|
1233
|
+
// Everything downstream is indexed under this leaf's identity, so it all
|
|
1234
|
+
// hangs on the leaf actually TAKING that identity. A leaf that lost the id
|
|
1235
|
+
// to an earlier file owns nothing, and its cross-references would otherwise
|
|
1236
|
+
// enter the graph as edges the WINNER never declared — worse than the leaf
|
|
1237
|
+
// simply not being there, which is what a losing mint means.
|
|
1238
|
+
if (indexRecord(ctx, 'leaves', identity, file, LEAF_ACCESSION_FIELD, entry)) {
|
|
1239
|
+
collectRefs(ctx, 'knowledge-leaf', identity, file, '', record);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* The bespoke record readers a descriptor may name, by name (UCS-1148).
|
|
1246
|
+
*
|
|
1247
|
+
* A store whose records are not a walk-and-parse shape names its reader in the
|
|
1248
|
+
* descriptor rather than being special-cased in the load loop. The indirection
|
|
1249
|
+
* through a name exists because the descriptor table is declared above these
|
|
1250
|
+
* functions: holding the function itself would be a forward reference into a
|
|
1251
|
+
* frozen const, and reordering the module to avoid it would put the store shape
|
|
1252
|
+
* table below the machinery that reads it.
|
|
1253
|
+
*
|
|
1254
|
+
* @type {Readonly<Record<string, (ctx: object, store: string) => void>>}
|
|
1255
|
+
*/
|
|
1256
|
+
const BESPOKE_READERS = Object.freeze({
|
|
1257
|
+
loadLeafFiles: (ctx) => loadLeafFiles(ctx),
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Refuse a descriptor naming a reader that does not exist — an engine failure
|
|
1262
|
+
* at load, never a silent pass. A store whose reader never resolves would load
|
|
1263
|
+
* ZERO records and report nothing, which reads exactly like an empty store.
|
|
1264
|
+
*
|
|
1265
|
+
* @param {Record<string, { reader?: string }>} descriptors
|
|
1266
|
+
* @throws {Error} if a named reader is not in BESPOKE_READERS
|
|
1267
|
+
*/
|
|
1268
|
+
export function assertReadersResolve(descriptors) {
|
|
1269
|
+
for (const [store, descriptor] of Object.entries(descriptors)) {
|
|
1270
|
+
if (descriptor.reader && !BESPOKE_READERS[descriptor.reader]) {
|
|
1271
|
+
throw new Error(
|
|
1272
|
+
`store "${store}" names reader "${descriptor.reader}", which does not exist — `
|
|
1273
|
+
+ 'its records would silently fail to load and the store would read as empty',
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
assertReadersResolve(STORE_DESCRIPTORS);
|
|
1280
|
+
|
|
1281
|
+
/** Pointer index: source-of-truth path → concept ids (KK-06 reverse lookup). */
|
|
1282
|
+
function buildPointers(ctx) {
|
|
1283
|
+
const pointers = new Map();
|
|
1284
|
+
for (const { id, record } of ctx.concepts.values()) {
|
|
1285
|
+
if (!Array.isArray(record['source-of-truth'])) continue;
|
|
1286
|
+
for (const path of record['source-of-truth']) {
|
|
1287
|
+
if (typeof path !== 'string') continue;
|
|
1288
|
+
if (!pointers.has(path)) pointers.set(path, []);
|
|
1289
|
+
const ids = pointers.get(path);
|
|
1290
|
+
if (!ids.includes(id)) ids.push(id);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
for (const ids of pointers.values()) ids.sort(compare);
|
|
1294
|
+
return sortedMap(pointers);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* The leaf↔concept edge, derived in BOTH directions at load (UCS-1151).
|
|
1299
|
+
*
|
|
1300
|
+
* The edge is authored once, leaf-side, because deciding what a leaf is about
|
|
1301
|
+
* is curatorial work under the human write gate. But it has to be traversable
|
|
1302
|
+
* from either end: an agent that resolves a concept needs the leaves that claim
|
|
1303
|
+
* it, and an agent holding a leaf needs the concepts it answers to. Deriving
|
|
1304
|
+
* the reverse here is what makes the concept→leaves direction STRUCTURAL — the
|
|
1305
|
+
* join is over declared ids, so it holds whether or not the leaf's `terms`
|
|
1306
|
+
* happen to spell the concept's term or one of its aliases. Text matching was
|
|
1307
|
+
* the only join before this ticket, which meant a leaf reached its concept by
|
|
1308
|
+
* term LUCK: rename the concept, or write the leaf with a different vocabulary,
|
|
1309
|
+
* and the two silently stopped seeing each other at exit 0.
|
|
1310
|
+
*
|
|
1311
|
+
* Keyed by concept id, valued by leaf IDENTITY (never the entry) so the index
|
|
1312
|
+
* cannot become a second copy of a leaf that the leaves map disagrees with.
|
|
1313
|
+
* Every list is de-duplicated and sorted, and the map itself is sorted, because
|
|
1314
|
+
* this is a published model field that resolver output is built from.
|
|
1315
|
+
*
|
|
1316
|
+
* Unresolvable concept ids are left in: this index says what the leaf CLAIMS,
|
|
1317
|
+
* and the ref graph is what judges whether the claim resolves. Filtering here
|
|
1318
|
+
* would silently drop the very edge the unresolved-ref finding is about.
|
|
1319
|
+
*
|
|
1320
|
+
* @param {object} ctx the loader context
|
|
1321
|
+
* @returns {Map<string, string[]>} concept id -> declaring leaf identities
|
|
1322
|
+
*/
|
|
1323
|
+
function buildLeavesByConcept(ctx) {
|
|
1324
|
+
const index = new Map();
|
|
1325
|
+
for (const entry of ctx.leaves.values()) {
|
|
1326
|
+
for (const id of leafConcepts(entry.record)) {
|
|
1327
|
+
if (!index.has(id)) index.set(id, []);
|
|
1328
|
+
const identities = index.get(id);
|
|
1329
|
+
if (!identities.includes(entry.identity)) identities.push(entry.identity);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
for (const identities of index.values()) identities.sort(compare);
|
|
1333
|
+
return sortedMap(index);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** Resolve every collected edge; a miss is an unresolved-ref error. */
|
|
1337
|
+
function resolveRefs(ctx) {
|
|
1338
|
+
for (const ref of ctx.refs) {
|
|
1339
|
+
const store = SPACE_TO_STORE[ref.space];
|
|
1340
|
+
// One index, one spelling. The alternate-spelling lookup the expand phase
|
|
1341
|
+
// added (UCS-1144) left with the alias table itself: a leaf answers to its
|
|
1342
|
+
// accession, and a notation-form citation resolves to nothing (UCS-1147).
|
|
1343
|
+
ref.resolved = ctx[ref.space].has(ref.to) || ctx.declared[store].has(ref.to);
|
|
1344
|
+
if (!ref.resolved) {
|
|
1345
|
+
ctx.diagnostics.push({
|
|
1346
|
+
severity: 'error', code: 'unresolved-ref', file: ref.file, path: ref.path,
|
|
1347
|
+
message: `${ref.type} ref "${ref.to}" does not resolve to any ${store} entry or catalog-declared id`,
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
delete ref.space;
|
|
1351
|
+
}
|
|
1352
|
+
ctx.refs.sort((a, b) =>
|
|
1353
|
+
compare(a.from, b.from) || compare(a.type, b.type) || compare(a.to, b.to) || compare(a.path, b.path));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Load the three stores under `root` into the indexed model described above.
|
|
1358
|
+
* @param {string} root kit root directory (the dir containing ontology/,
|
|
1359
|
+
* knowledge/, decisions/ — absent stores yield missing-store warnings)
|
|
1360
|
+
* @returns the model; `model.ok` is the single health verdict every
|
|
1361
|
+
* downstream surface shares
|
|
1362
|
+
*/
|
|
1363
|
+
export function loadStores(root) {
|
|
1364
|
+
const absRoot = resolve(root);
|
|
1365
|
+
if (!statSync(absRoot, { throwIfNoEntry: false })?.isDirectory()) {
|
|
1366
|
+
throw new Error(`store root is not a readable directory: ${absRoot}`);
|
|
1367
|
+
}
|
|
1368
|
+
const ctx = {
|
|
1369
|
+
root: absRoot,
|
|
1370
|
+
stores: Object.fromEntries(STORES.map((store) =>
|
|
1371
|
+
[store, { present: false, catalog: null, rules: null, files: [] }])),
|
|
1372
|
+
declared: Object.fromEntries(STORES.map((store) => [store, new Set()])),
|
|
1373
|
+
concepts: new Map(),
|
|
1374
|
+
leaves: new Map(),
|
|
1375
|
+
decisions: new Map(),
|
|
1376
|
+
registries: new Map(),
|
|
1377
|
+
graduations: new Map(),
|
|
1378
|
+
phoenix: new Map(),
|
|
1379
|
+
refs: [],
|
|
1380
|
+
diagnostics: [],
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
for (const store of STORES) {
|
|
1384
|
+
const descriptor = STORE_DESCRIPTORS[store];
|
|
1385
|
+
const meta = ctx.stores[store];
|
|
1386
|
+
meta.present = !!statSync(join(absRoot, store), { throwIfNoEntry: false })?.isDirectory();
|
|
1387
|
+
if (!meta.present) {
|
|
1388
|
+
ctx.diagnostics.push({
|
|
1389
|
+
severity: 'warning', code: 'missing-store', file: store, path: '',
|
|
1390
|
+
message: `store directory "${store}/" is absent — loading proceeds; refs into it cannot resolve`,
|
|
1391
|
+
});
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
loadCatalogAndRules(ctx, store, descriptor.rules);
|
|
1395
|
+
if (descriptor.registries) loadRegistryFiles(ctx, store);
|
|
1396
|
+
if (descriptor.phoenix) loadPhoenixFiles(ctx, store);
|
|
1397
|
+
}
|
|
1398
|
+
// Records load after every registry, in every store: membership is judged
|
|
1399
|
+
// against the whole governed vocabulary, so no record may be read before the
|
|
1400
|
+
// vocabulary it draws from is complete.
|
|
1401
|
+
for (const store of STORES) {
|
|
1402
|
+
if (!ctx.stores[store].present) continue;
|
|
1403
|
+
const { records, reader } = STORE_DESCRIPTORS[store];
|
|
1404
|
+
if (records) loadEntriesFiles(ctx, store, records);
|
|
1405
|
+
else if (reader) BESPOKE_READERS[reader](ctx, store);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const pointers = buildPointers(ctx);
|
|
1409
|
+
const leavesByConcept = buildLeavesByConcept(ctx);
|
|
1410
|
+
resolveRefs(ctx);
|
|
1411
|
+
ctx.diagnostics.sort((a, b) =>
|
|
1412
|
+
compare(a.file, b.file) || compare(a.path, b.path) || compare(a.code, b.code));
|
|
1413
|
+
|
|
1414
|
+
return {
|
|
1415
|
+
root: absRoot,
|
|
1416
|
+
stores: ctx.stores,
|
|
1417
|
+
concepts: sortedMap(ctx.concepts),
|
|
1418
|
+
leaves: sortedMap(ctx.leaves),
|
|
1419
|
+
decisions: sortedMap(ctx.decisions),
|
|
1420
|
+
registries: sortedMap(ctx.registries),
|
|
1421
|
+
graduations: sortedMap(ctx.graduations),
|
|
1422
|
+
phoenix: sortedMap(ctx.phoenix),
|
|
1423
|
+
pointers,
|
|
1424
|
+
leavesByConcept,
|
|
1425
|
+
refs: ctx.refs,
|
|
1426
|
+
diagnostics: ctx.diagnostics,
|
|
1427
|
+
ok: ctx.diagnostics.every((d) => d.severity !== 'error'),
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Loader diagnostics summarized on the one scale every downstream surface
|
|
1433
|
+
* shares — validator, value check, and preflight report store health from
|
|
1434
|
+
* this single shape, so they can never disagree about it (PRD §4).
|
|
1435
|
+
*/
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Store health — the ONE authority (UCS-939/945/951).
|
|
1440
|
+
*
|
|
1441
|
+
* The validator, the reverse audit and preflight can never disagree about a
|
|
1442
|
+
* Store, because they all ask this function rather than each filtering
|
|
1443
|
+
* `model.diagnostics` themselves. It once reported only counts, which is not
|
|
1444
|
+
* what any caller needed, so seven sites reached past it and the resolver
|
|
1445
|
+
* re-declared its own copy. A seam too narrow to be used is a seam that gets
|
|
1446
|
+
* walked around.
|
|
1447
|
+
*
|
|
1448
|
+
* Derived from `model.ok` and `model.diagnostics` — the single health model —
|
|
1449
|
+
* and from nothing else. `ok` is the loader's verdict, never recomputed from
|
|
1450
|
+
* the counts: a surface that recomputed it would BE a second health model.
|
|
1451
|
+
*
|
|
1452
|
+
* @param {object} model a loaded store model
|
|
1453
|
+
* @returns {{ ok: boolean, errors: object[], warnings: object[], errorCount: number, warningCount: number }}
|
|
1454
|
+
*/
|
|
1455
|
+
export function storeHealth(model) {
|
|
1456
|
+
const errors = model.diagnostics.filter((d) => d.severity === 'error');
|
|
1457
|
+
const warnings = model.diagnostics.filter((d) => d.severity === 'warning');
|
|
1458
|
+
return {
|
|
1459
|
+
ok: model.ok,
|
|
1460
|
+
errors,
|
|
1461
|
+
warnings,
|
|
1462
|
+
errorCount: errors.length,
|
|
1463
|
+
warningCount: warnings.length,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* The wire shape of store health: counts, not Diagnostics. Every surface's
|
|
1469
|
+
* `store-health` JSON key is this, and it has looked like this since KK-04.
|
|
1470
|
+
*
|
|
1471
|
+
* It takes the HEALTH, never the model — so it is a projection of the one
|
|
1472
|
+
* authority and cannot become a second derivation of it. That is the whole
|
|
1473
|
+
* point: `healthSummary(storeHealth(model))` reads as what it is.
|
|
1474
|
+
*
|
|
1475
|
+
* @param {{ ok: boolean, errorCount: number, warningCount: number }} health
|
|
1476
|
+
* @returns {{ ok: boolean, errors: number, warnings: number }}
|
|
1477
|
+
*/
|
|
1478
|
+
export function healthSummary(health) {
|
|
1479
|
+
return { ok: health.ok, errors: health.errorCount, warnings: health.warningCount };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* A --concepts id the ontology does not carry — a check that never ran.
|
|
1484
|
+
* A usage error: the caller named something that does not exist, so the command
|
|
1485
|
+
* refused its arguments rather than failing mid-run. Exit 2, never 1.
|
|
1486
|
+
*/
|
|
1487
|
+
export class UnknownConceptsError extends UsageError {
|
|
1488
|
+
name = 'UnknownConceptsError';
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* The `--leaves` counterpart (UCS-1149): an id the knowledge store does not
|
|
1493
|
+
* carry, under either of a leaf's two legal spellings. A usage error for the
|
|
1494
|
+
* same reason — the caller named something that does not exist, so the command
|
|
1495
|
+
* refused its arguments rather than reporting on nothing. Exit 2, never 1.
|
|
1496
|
+
*/
|
|
1497
|
+
export class UnknownLeavesError extends UsageError {
|
|
1498
|
+
name = 'UnknownLeavesError';
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* The `--concepts` grammar, settled in one place (UCS-935).
|
|
1503
|
+
*
|
|
1504
|
+
* Ids are trimmed, empties dropped, duplicates collapsed, order stable. Every
|
|
1505
|
+
* surface taking the flag reads it the same way, because a filter that means
|
|
1506
|
+
* two different things is worse than no filter: the structural validator used
|
|
1507
|
+
* to accept `" K-101 "` while the value validator rejected the same argument
|
|
1508
|
+
* as a check that never ran, so a CI pipeline that padded its arguments got a
|
|
1509
|
+
* clean pass from one gate and a blocking defect from the other.
|
|
1510
|
+
*
|
|
1511
|
+
* Returns the normalized ids. Whether an EMPTY result is an error is the
|
|
1512
|
+
* caller's policy, not the grammar's: the validators refuse it (a filter that
|
|
1513
|
+
* names nothing never ran), while preflight reads it as store-health-only.
|
|
1514
|
+
*
|
|
1515
|
+
* @param {string[]} raw ids as spelled on the command line, pre-split on commas
|
|
1516
|
+
* @returns {string[]} trimmed, de-duplicated, stably sorted ids
|
|
1517
|
+
*/
|
|
1518
|
+
export function normalizeConceptIds(raw) {
|
|
1519
|
+
return [...new Set(raw.map((s) => s.trim()).filter(Boolean))].sort(compare);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Select concepts by id (null/undefined = every concept). An unknown id
|
|
1524
|
+
* throws UnknownConceptsError: a verdict or finding set "filtered" to a typo
|
|
1525
|
+
* would be a check that never ran reading as a silent pass (PRD §5). Every
|
|
1526
|
+
* --concepts consumer shares this contract from here.
|
|
1527
|
+
*/
|
|
1528
|
+
export function selectConcepts(model, ids) {
|
|
1529
|
+
if (!ids) return [...model.concepts.values()];
|
|
1530
|
+
const unknown = ids.filter((id) => !model.concepts.has(id));
|
|
1531
|
+
if (unknown.length) {
|
|
1532
|
+
throw new UnknownConceptsError(`--concepts names id(s) not in the ontology: ${unknown.join(', ')} — a check that never ran is a blocking defect, never a silent pass (PRD §5)`);
|
|
1533
|
+
}
|
|
1534
|
+
return ids.map((id) => model.concepts.get(id));
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Select leaves by id (null/undefined = every leaf) — the `--leaves` analogue
|
|
1539
|
+
* of selectConcepts (UCS-1149).
|
|
1540
|
+
*
|
|
1541
|
+
* Same contract, for the same reason: an id the store does not carry throws
|
|
1542
|
+
* rather than filtering to nothing, because a verdict set "filtered" to a typo
|
|
1543
|
+
* is a check that never ran wearing a clean exit (PRD §5).
|
|
1544
|
+
*
|
|
1545
|
+
* Leaves are named by accession and nothing else (UCS-1147), the same spelling
|
|
1546
|
+
* a citation uses. The de-duplication below survives that narrowing: naming one
|
|
1547
|
+
* leaf twice is still one leaf, not two verdicts that would then disagree about
|
|
1548
|
+
* nothing.
|
|
1549
|
+
*
|
|
1550
|
+
* @param {object} model the loaded store model
|
|
1551
|
+
* @param {string[]|null} ids leaf ids as the caller spelled them
|
|
1552
|
+
* @returns {object[]} the indexed leaf entries, in the order named
|
|
1553
|
+
*/
|
|
1554
|
+
export function selectLeaves(model, ids) {
|
|
1555
|
+
if (!ids) return [...model.leaves.values()];
|
|
1556
|
+
const unknown = ids.filter((id) => leafIdentityOf(model, id) === undefined);
|
|
1557
|
+
if (unknown.length) {
|
|
1558
|
+
throw new UnknownLeavesError(`--leaves names id(s) not in the knowledge store: ${unknown.join(', ')} — a check that never ran is a blocking defect, never a silent pass (PRD §5)`);
|
|
1559
|
+
}
|
|
1560
|
+
const seen = new Set();
|
|
1561
|
+
const out = [];
|
|
1562
|
+
for (const id of ids) {
|
|
1563
|
+
const identity = leafIdentityOf(model, id);
|
|
1564
|
+
if (seen.has(identity)) continue;
|
|
1565
|
+
seen.add(identity);
|
|
1566
|
+
out.push(model.leaves.get(identity));
|
|
1567
|
+
}
|
|
1568
|
+
return out;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* The IDENTITY a leaf id names, or undefined when the store has no such leaf.
|
|
1573
|
+
*
|
|
1574
|
+
* Since UCS-1147 a leaf answers to exactly one spelling, so this no longer
|
|
1575
|
+
* TRANSLATES anything — it answers "is this a leaf of this store, and what is
|
|
1576
|
+
* its identity". It stays a named function, and exported, because that question
|
|
1577
|
+
* is still asked from two places that must answer it identically: preflight's
|
|
1578
|
+
* healthy path resolves ids through selectLeaves, and its store-wide-failure
|
|
1579
|
+
* path cannot (the verdicts are degraded without selecting anything). Inlining
|
|
1580
|
+
* `model.leaves.has(id)` at both would be the same code twice, which is how the
|
|
1581
|
+
* two drifted apart the first time.
|
|
1582
|
+
*
|
|
1583
|
+
* @param {object} model the loaded store model
|
|
1584
|
+
* @param {string} id a leaf id as the caller spelled it
|
|
1585
|
+
* @returns {string|undefined} the leaf's identity, or undefined if unknown
|
|
1586
|
+
*/
|
|
1587
|
+
export function leafIdentityOf(model, id) {
|
|
1588
|
+
return model.leaves.has(id) ? id : undefined;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* §3.5: draft/proposed concepts get structural checks only — the value check
|
|
1594
|
+
* skips them and preflight verdicts them unknown. One predicate, so the two
|
|
1595
|
+
* surfaces can never diverge on which statuses that means.
|
|
1596
|
+
*
|
|
1597
|
+
* Frontmatter v2 puts LEAVES through this same predicate (UCS-1149). A leaf has
|
|
1598
|
+
* no `status`; it has `facets.stage`, and `leafStage` below is the one place
|
|
1599
|
+
* that spelling is read. The predicate itself is unchanged and untyped as to
|
|
1600
|
+
* what it is judging, which is what lets a leaf's `draft` and a concept's
|
|
1601
|
+
* `draft` mean the same thing to every surface that asks.
|
|
1602
|
+
*/
|
|
1603
|
+
export const isPrePromotionStatus = (status) => status === 'draft' || status === 'proposed';
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* A leaf's promotion stage, or null when it declares none (UCS-1149).
|
|
1607
|
+
*
|
|
1608
|
+
* The single reader of the `facets.stage` spelling. Two surfaces ask this
|
|
1609
|
+
* question — the resolver, to downrank a provisional leaf, and preflight, to
|
|
1610
|
+
* verdict one unknown — and if either reached into the record itself, a later
|
|
1611
|
+
* move of the field would leave one of them silently reading `undefined`:
|
|
1612
|
+
* a leaf that stopped being downranked, at exit 0, with nothing said.
|
|
1613
|
+
*
|
|
1614
|
+
* A stage that is not a string is null rather than the raw value: the schema
|
|
1615
|
+
* has already reported it, and passing a number into the predicate would just
|
|
1616
|
+
* return false, quietly promoting the leaf the defect was meant to hold back.
|
|
1617
|
+
*
|
|
1618
|
+
* @param {object} record a leaf's front-matter record
|
|
1619
|
+
* @returns {string|null}
|
|
1620
|
+
*/
|
|
1621
|
+
export function leafStage(record) {
|
|
1622
|
+
const stage = record?.facets?.stage;
|
|
1623
|
+
return typeof stage === 'string' ? stage : null;
|
|
1624
|
+
}
|