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.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +167 -0
  4. package/cli/.gitkeep +0 -0
  5. package/cli/commands/init-copy.js +90 -0
  6. package/cli/commands/init.js +386 -0
  7. package/cli/init-copy.js +24 -0
  8. package/cli/init.js +24 -0
  9. package/cli/kit.manifest.yaml +256 -0
  10. package/cli/lib/copy-payload.js +334 -0
  11. package/cli/lib/generate-wrappers.js +162 -0
  12. package/package.json +36 -0
  13. package/payload/adapter-fixtures/README.md +53 -0
  14. package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
  15. package/payload/adapter-fixtures/html/sample.html +38 -0
  16. package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
  17. package/payload/adapter-fixtures/md/sample.md +32 -0
  18. package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
  19. package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
  20. package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
  21. package/payload/adapter-fixtures/txt/sample.txt +18 -0
  22. package/payload/docs/README.md +102 -0
  23. package/payload/docs/boundaries.md +60 -0
  24. package/payload/docs/ci-wiring.md +109 -0
  25. package/payload/docs/steward-guide.md +238 -0
  26. package/payload/engine/audit.js +34 -0
  27. package/payload/engine/commands/audit.js +289 -0
  28. package/payload/engine/commands/derive.js +334 -0
  29. package/payload/engine/commands/ingest.js +124 -0
  30. package/payload/engine/commands/log-entry.js +85 -0
  31. package/payload/engine/commands/phoenix.js +206 -0
  32. package/payload/engine/commands/preflight.js +530 -0
  33. package/payload/engine/commands/resolve.js +1678 -0
  34. package/payload/engine/commands/survey-map.js +351 -0
  35. package/payload/engine/commands/validate-values.js +315 -0
  36. package/payload/engine/commands/validate.js +1426 -0
  37. package/payload/engine/derive.js +34 -0
  38. package/payload/engine/ingest.js +34 -0
  39. package/payload/engine/lib/anchor-signatures.js +126 -0
  40. package/payload/engine/lib/boot.js +39 -0
  41. package/payload/engine/lib/call-numbers.js +133 -0
  42. package/payload/engine/lib/cli.js +147 -0
  43. package/payload/engine/lib/coverage.js +849 -0
  44. package/payload/engine/lib/decomposition.js +225 -0
  45. package/payload/engine/lib/derived.js +494 -0
  46. package/payload/engine/lib/engine-refusal.js +40 -0
  47. package/payload/engine/lib/exit-codes.js +14 -0
  48. package/payload/engine/lib/extractor-kinds.js +955 -0
  49. package/payload/engine/lib/format-adapters.js +802 -0
  50. package/payload/engine/lib/id-grammars.js +178 -0
  51. package/payload/engine/lib/iso-date.js +55 -0
  52. package/payload/engine/lib/kit-root.js +101 -0
  53. package/payload/engine/lib/load-stores.js +1624 -0
  54. package/payload/engine/lib/log-entry.js +196 -0
  55. package/payload/engine/lib/phoenix.js +628 -0
  56. package/payload/engine/lib/scoring.js +150 -0
  57. package/payload/engine/lib/suppressions.js +172 -0
  58. package/payload/engine/lib/time-verdicts.js +282 -0
  59. package/payload/engine/lib/usage-error.js +14 -0
  60. package/payload/engine/lib/validate-record.js +504 -0
  61. package/payload/engine/log-entry.js +34 -0
  62. package/payload/engine/phoenix.js +39 -0
  63. package/payload/engine/preflight.js +34 -0
  64. package/payload/engine/resolve.js +34 -0
  65. package/payload/engine/survey-map.js +34 -0
  66. package/payload/engine/validate-values.js +34 -0
  67. package/payload/engine/validate.js +34 -0
  68. package/payload/extractor-fixtures/.gitkeep +0 -0
  69. package/payload/extractor-fixtures/README.md +29 -0
  70. package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
  71. package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
  72. package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
  73. package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
  74. package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
  75. package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
  76. package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
  77. package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
  78. package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
  79. package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
  80. package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
  81. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
  82. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
  83. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
  84. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
  85. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
  86. package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
  87. package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
  88. package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
  89. package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
  90. package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
  91. package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
  92. package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
  93. package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
  94. package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
  95. package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
  96. package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
  97. package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
  98. package/payload/hooks/pre-commit +37 -0
  99. package/payload/hooks/reverse-lookup +66 -0
  100. package/payload/package.json +3 -0
  101. package/payload/protocol/.gitkeep +0 -0
  102. package/payload/protocol/AGENTS.md +239 -0
  103. package/payload/protocol/derived-layer.md +174 -0
  104. package/payload/protocol/new-kind-pipeline.md +179 -0
  105. package/payload/protocol/registry-warrant.md +162 -0
  106. package/payload/protocol/skills/kb-build.md +303 -0
  107. package/payload/protocol/skills/knowledge-audit.md +183 -0
  108. package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
  109. package/payload/protocol/skills/knowledge-reflect.md +397 -0
  110. package/payload/schemas/catalog.schema.json +32 -0
  111. package/payload/schemas/decision-entry.schema.json +122 -0
  112. package/payload/schemas/finding.schema.json +77 -0
  113. package/payload/schemas/gap.schema.json +52 -0
  114. package/payload/schemas/graduation-categories.schema.json +64 -0
  115. package/payload/schemas/knowledge-leaf.schema.json +194 -0
  116. package/payload/schemas/miss.schema.json +45 -0
  117. package/payload/schemas/ontology-concept.schema.json +115 -0
  118. package/payload/schemas/phoenix-event.schema.json +76 -0
  119. package/payload/schemas/registry.schema.json +57 -0
  120. package/payload/schemas/rules.schema.json +14 -0
  121. package/payload/schemas/survey-scope.schema.json +23 -0
  122. package/payload/templates/decisions/_catalog.yaml +7 -0
  123. package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
  124. package/payload/templates/decisions/phoenix-event.yaml +74 -0
  125. package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
  126. package/payload/templates/decisions/registry-minting.yaml +58 -0
  127. package/payload/templates/decisions/trust-graduation.yaml +120 -0
  128. package/payload/templates/decisions/trust-revocation.yaml +106 -0
  129. package/payload/templates/knowledge/_catalog.yaml +9 -0
  130. package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
  131. package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
  132. package/payload/templates/knowledge/_registries/domains.yaml +43 -0
  133. package/payload/templates/knowledge/_registries/form.yaml +38 -0
  134. package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
  135. package/payload/templates/knowledge/_registries/operations.yaml +18 -0
  136. package/payload/templates/knowledge/_registries/stage.yaml +53 -0
  137. package/payload/templates/knowledge/_rules.yaml +6 -0
  138. package/payload/templates/new-kind/README.md +107 -0
  139. package/payload/templates/new-kind/descriptor.example.yaml +18 -0
  140. package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
  141. package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
  142. package/payload/templates/new-kind/fixture/sample.list +7 -0
  143. package/payload/templates/new-kind/parser.example.js +98 -0
  144. package/payload/templates/ontology/_catalog.yaml +6 -0
  145. package/payload/templates/ontology/_rules.yaml +6 -0
  146. package/payload/wrappers/cursor.mdc +15 -0
  147. package/payload/wrappers/pointer.md +10 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Schema validation for store records (KK-02) — the layer the store loader
3
+ * (KK-04) calls before indexing anything.
4
+ *
5
+ * The formal contract lives in ../../schemas/*.schema.json (JSON Schema
6
+ * draft 2020-12, consumable by any external tool). This module interprets
7
+ * those documents directly — no schema library (D-002: js-yaml and little
8
+ * else) — supporting exactly the subset the schemas use: type, required,
9
+ * properties, additionalProperties:false, items, enum, pattern, minItems,
10
+ * minimum, and $ref into #/$defs.
11
+ *
12
+ * On top of the schemas it enforces the PRD §3.5 conventions JSON Schema
13
+ * cannot express record-locally:
14
+ * - enumerates values are strings, byte-exact, case-sensitive, as sets;
15
+ * non-string scalars hard-error (`non-string-enumerates-value`) — the
16
+ * YAML 1.1 coercion trap. Callers must parse YAML with types intact
17
+ * (js-yaml default schema) and pass the parsed document unchanged.
18
+ * - multi-entry source-of-truth: the first entry is the primary owner,
19
+ * additional entries are secondary references; every enumerates
20
+ * descriptor's source must name a listed entry
21
+ * (`enumerates-source-not-listed`).
22
+ * - every store file carries an integer schema-version >= 1; value defects
23
+ * there are normalized to `invalid-schema-version` (a stray record-level
24
+ * key stays `unknown-property` — the fix is to remove it, not retype it).
25
+ * Evolution is additive-only (D-013 generalized).
26
+ *
27
+ * Diagnostics carry a closed set of typed codes (PRD §5: protocol conduct
28
+ * keys on codes, not prose) and are stable-sorted by path then code so
29
+ * run-over-run diffs mean something.
30
+ */
31
+ import { readFileSync } from 'node:fs';
32
+ import { ID_GRAMMARS, SCHEMA_DEFS } from './id-grammars.js';
33
+
34
+ /** Record kind → schema document shipped in payload/schemas/. */
35
+ const KIND_SCHEMA_FILES = Object.freeze({
36
+ 'ontology-concept': 'ontology-concept.schema.json',
37
+ 'knowledge-leaf': 'knowledge-leaf.schema.json',
38
+ 'decision-entry': 'decision-entry.schema.json',
39
+ 'finding': 'finding.schema.json',
40
+ 'miss': 'miss.schema.json',
41
+ 'gap': 'gap.schema.json',
42
+ 'survey-scope': 'survey-scope.schema.json',
43
+ 'catalog': 'catalog.schema.json',
44
+ 'rules': 'rules.schema.json',
45
+ 'registry': 'registry.schema.json',
46
+ 'phoenix-event': 'phoenix-event.schema.json',
47
+ 'graduation-categories': 'graduation-categories.schema.json',
48
+ });
49
+
50
+ export const KINDS = Object.freeze(Object.keys(KIND_SCHEMA_FILES));
51
+
52
+ export const ERROR_CODES = Object.freeze([
53
+ 'wrong-type',
54
+ 'missing-required',
55
+ 'unknown-property',
56
+ 'invalid-enum-value',
57
+ 'pattern-mismatch',
58
+ 'too-few-items',
59
+ 'out-of-range',
60
+ 'invalid-schema-version',
61
+ 'non-string-enumerates-value',
62
+ 'duplicate-enumerates-value',
63
+ 'enumerates-source-not-listed',
64
+ 'lifecycle-field-mismatch',
65
+ 'locator-shape',
66
+ ]);
67
+
68
+ /**
69
+ * The JSON Schema subset this module interprets. Schemas must not use
70
+ * keywords outside this set — an unenforced keyword is silent contract
71
+ * drift (tests/store-schemas.test.js walks every schema against it).
72
+ */
73
+ export const SUPPORTED_KEYWORDS = Object.freeze([
74
+ '$schema', '$id', '$defs', '$ref',
75
+ 'title', 'description',
76
+ 'type', 'required', 'properties', 'additionalProperties',
77
+ 'items', 'enum', 'pattern', 'minItems', 'minimum',
78
+ ]);
79
+
80
+ /**
81
+ * Lexicographic comparator shared by every engine surface that emits
82
+ * stable-sorted, diffable output (PRD §5) — one ordering, one copy.
83
+ */
84
+ export const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
85
+
86
+ const schemaCache = new Map();
87
+
88
+ /**
89
+ * Bind the shared id grammars onto a freshly parsed schema (UCS-1142).
90
+ *
91
+ * The schema files keep their own `$defs` copies so each stays self-contained
92
+ * for external consumers, but the ENGINE validates against lib/id-grammars.js
93
+ * — the one module that owns each id space's pattern. Overwriting rather than
94
+ * asserting is deliberate: a drifted copy is corrected here, so the engine can
95
+ * only ever enforce the live grammar. (tests/id-grammars.test.js pins that the
96
+ * shipped copies agree, which is what keeps the published documents honest.)
97
+ *
98
+ * The grammar's HINT is bound alongside its pattern, under a key the schema
99
+ * keyword set does not interpret, so a pattern-mismatch on an id can say what
100
+ * shape was expected instead of quoting a regex at an author. That matters most
101
+ * where the shape CHANGED: since UCS-1147 a leaf citation must be an accession,
102
+ * and `"700.2" does not match ^L-[0-9]{6}$` tells an author what failed while
103
+ * naming neither the remedy nor the migration that moved it. The hint travels
104
+ * from the same frozen entry as the pattern, for the reason id-grammars.js
105
+ * exists — a hint that drifted from its pattern would describe the wrong shape.
106
+ */
107
+ function bindIdGrammars(schema) {
108
+ for (const [space, def] of Object.entries(SCHEMA_DEFS)) {
109
+ const node = schema.$defs?.[def];
110
+ if (node) {
111
+ node.pattern = ID_GRAMMARS[space].pattern;
112
+ node.hint = ID_GRAMMARS[space].hint;
113
+ }
114
+ }
115
+ return schema;
116
+ }
117
+
118
+ function schemaFor(kind) {
119
+ const file = KIND_SCHEMA_FILES[kind];
120
+ if (!file) {
121
+ throw new TypeError(`unknown record kind "${kind}" (expected one of: ${KINDS.join(', ')})`);
122
+ }
123
+ if (!schemaCache.has(kind)) {
124
+ const url = new URL(`../../schemas/${file}`, import.meta.url);
125
+ schemaCache.set(kind, bindIdGrammars(JSON.parse(readFileSync(url, 'utf8'))));
126
+ }
127
+ return schemaCache.get(kind);
128
+ }
129
+
130
+ function isPlainObject(value) {
131
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
132
+ }
133
+
134
+ function describe(value) {
135
+ if (value === null) return 'null';
136
+ if (Array.isArray(value)) return 'array';
137
+ if (value instanceof Date) return 'date (quote it)';
138
+ return typeof value;
139
+ }
140
+
141
+ function typeMatches(type, value) {
142
+ if (Array.isArray(type)) return type.some((t) => typeMatches(t, value));
143
+ switch (type) {
144
+ case 'string': return typeof value === 'string';
145
+ case 'integer': return Number.isInteger(value);
146
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
147
+ case 'boolean': return typeof value === 'boolean';
148
+ case 'array': return Array.isArray(value);
149
+ case 'object': return isPlainObject(value);
150
+ case 'null': return value === null;
151
+ default: throw new Error(`schema uses unsupported type "${type}"`);
152
+ }
153
+ }
154
+
155
+ function joinPath(base, key) {
156
+ return base ? `${base}.${key}` : key;
157
+ }
158
+
159
+ const regexCache = new Map();
160
+
161
+ function regexFor(pattern) {
162
+ let re = regexCache.get(pattern);
163
+ if (!re) {
164
+ re = new RegExp(pattern);
165
+ regexCache.set(pattern, re);
166
+ }
167
+ return re;
168
+ }
169
+
170
+ const refCache = new WeakMap();
171
+
172
+ function resolveRef(root, ref) {
173
+ if (ref === '#') return root;
174
+ let refs = refCache.get(root);
175
+ if (!refs) {
176
+ refs = new Map();
177
+ refCache.set(root, refs);
178
+ }
179
+ let target = refs.get(ref);
180
+ if (target === undefined) {
181
+ const match = /^#\/\$defs\/([^/]+)$/.exec(ref);
182
+ target = (match && root.$defs?.[match[1]]) ?? null;
183
+ refs.set(ref, target);
184
+ }
185
+ if (!target) throw new Error(`schema $ref "${ref}" does not resolve`);
186
+ return target;
187
+ }
188
+
189
+ /**
190
+ * The id-grammar hint a required property carries, if it declares one.
191
+ *
192
+ * Only reaches a hint bound by bindIdGrammars — a property whose shape is an id
193
+ * space. Everything else returns null and the plain message stands, which is
194
+ * the right default: "required property "heading" is missing" needs no gloss,
195
+ * and a hint invented per field would be prose with nothing keeping it true.
196
+ *
197
+ * A `$ref` that does not resolve is swallowed rather than thrown: this runs
198
+ * only to DECORATE a defect the validator has already found, and a schema
199
+ * authoring error must not turn a legitimate finding into a crash.
200
+ *
201
+ * @param {object} root the schema document, for $defs resolution
202
+ * @param {object} schema the object schema declaring the required property
203
+ * @param {string} required the missing property's name
204
+ * @returns {string|null} the hint, or null when the property declares none
205
+ */
206
+ function requiredHint(root, schema, required) {
207
+ const property = schema.properties?.[required];
208
+ if (!property) return null;
209
+ try {
210
+ const target = property.$ref ? resolveRef(root, property.$ref) : property;
211
+ return typeof target.hint === 'string' ? target.hint : null;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /** Interpret the supported JSON Schema subset against a value. */
218
+ function check(root, schema, value, path, errors) {
219
+ if (schema.$ref) {
220
+ check(root, resolveRef(root, schema.$ref), value, path, errors);
221
+ return;
222
+ }
223
+ if (schema.type && !typeMatches(schema.type, value)) {
224
+ errors.push({
225
+ path,
226
+ code: 'wrong-type',
227
+ message: `expected ${schema.type}, got ${describe(value)}`,
228
+ });
229
+ return; // deeper checks are meaningless on the wrong shape
230
+ }
231
+ if (schema.enum && !schema.enum.includes(value)) {
232
+ errors.push({
233
+ path,
234
+ code: 'invalid-enum-value',
235
+ message: `${JSON.stringify(value)} is not one of: ${schema.enum.join(' | ')}`,
236
+ });
237
+ }
238
+ if (schema.pattern && typeof value === 'string' && !regexFor(schema.pattern).test(value)) {
239
+ errors.push({
240
+ path,
241
+ code: 'pattern-mismatch',
242
+ // An id-space pattern arrives with the hint bound by bindIdGrammars, and
243
+ // the hint REPLACES the regex rather than trailing it: it says everything
244
+ // the pattern says and says it to a human, so printing both would just
245
+ // make the author read the regex first. Patterns from the schema files
246
+ // themselves carry no hint and keep quoting the regex — there is nothing
247
+ // better to say about them, and inventing prose per pattern here would be
248
+ // the drift id-grammars.js exists to prevent.
249
+ message: schema.hint
250
+ ? `${JSON.stringify(value)} is not a valid id here — expected ${schema.hint}`
251
+ : `${JSON.stringify(value)} does not match ${schema.pattern}`,
252
+ });
253
+ }
254
+ if (schema.minimum !== undefined && typeof value === 'number' && value < schema.minimum) {
255
+ errors.push({
256
+ path,
257
+ code: 'out-of-range',
258
+ message: `${value} is below the minimum of ${schema.minimum}`,
259
+ });
260
+ }
261
+ if (Array.isArray(value)) {
262
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
263
+ errors.push({
264
+ path,
265
+ code: 'too-few-items',
266
+ message: `expected at least ${schema.minItems} item(s), got ${value.length}`,
267
+ });
268
+ }
269
+ if (schema.items) {
270
+ value.forEach((item, i) => check(root, schema.items, item, `${path}[${i}]`, errors));
271
+ }
272
+ }
273
+ if (isPlainObject(value)) {
274
+ for (const required of schema.required ?? []) {
275
+ if (!Object.hasOwn(value, required)) {
276
+ // A missing property has no value to test, so the pattern branch above
277
+ // can never speak for it — yet "required property "id" is missing" is
278
+ // exactly the finding an author gets for a leaf that mints no accession,
279
+ // and on its own it names neither the shape to write nor why the field
280
+ // became required. So the hint is read from the property's OWN grammar
281
+ // here, through the same `$defs` binding: one fact, quoted wherever the
282
+ // author meets it.
283
+ const hint = requiredHint(root, schema, required);
284
+ errors.push({
285
+ path: joinPath(path, required),
286
+ code: 'missing-required',
287
+ message: hint
288
+ ? `required property "${required}" is missing — expected ${hint}`
289
+ : `required property "${required}" is missing`,
290
+ });
291
+ }
292
+ }
293
+ const properties = schema.properties ?? {};
294
+ for (const [key, propertyValue] of Object.entries(value)) {
295
+ if (Object.hasOwn(properties, key)) {
296
+ check(root, properties[key], propertyValue, joinPath(path, key), errors);
297
+ } else if (schema.additionalProperties === false) {
298
+ errors.push({
299
+ path: joinPath(path, key),
300
+ code: 'unknown-property',
301
+ message: `unknown property "${key}" (additive schema evolution edits the schema; unknown keys are typos)`,
302
+ });
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * PRD §3.5 conventions on one ontology concept — the checks JSON Schema
310
+ * cannot express record-locally.
311
+ */
312
+ function conceptConventions(record, basePath, errors) {
313
+ if (!isPlainObject(record) || !Array.isArray(record.enumerates)) return;
314
+ const sourceOfTruth = new Set(Array.isArray(record['source-of-truth'])
315
+ ? record['source-of-truth'].filter((entry) => typeof entry === 'string')
316
+ : []);
317
+ record.enumerates.forEach((descriptor, i) => {
318
+ if (!isPlainObject(descriptor)) return;
319
+ const descriptorPath = joinPath(basePath, `enumerates[${i}]`);
320
+ if (Array.isArray(descriptor.values)) {
321
+ const seen = new Set();
322
+ descriptor.values.forEach((value, j) => {
323
+ if (typeof value !== 'string') {
324
+ errors.push({
325
+ path: `${descriptorPath}.values[${j}]`,
326
+ code: 'non-string-enumerates-value',
327
+ message: `enumerates values are strings compared byte-exact and case-sensitive (§3.5); got ${describe(value)} (${JSON.stringify(value)}) — YAML coerces bare scalars like true/1.0/null, so quote the value in the store file`,
328
+ });
329
+ } else if (seen.has(value)) {
330
+ errors.push({
331
+ path: `${descriptorPath}.values[${j}]`,
332
+ code: 'duplicate-enumerates-value',
333
+ message: `duplicate enumerates value ${JSON.stringify(value)} — values are compared as sets (§3.5), so a duplicate is a malformed descriptor, never a bigger set`,
334
+ });
335
+ } else {
336
+ seen.add(value);
337
+ }
338
+ });
339
+ }
340
+ if (typeof descriptor.source === 'string' && !sourceOfTruth.has(descriptor.source)) {
341
+ errors.push({
342
+ path: `${descriptorPath}.source`,
343
+ code: 'enumerates-source-not-listed',
344
+ message: `enumerates source ${JSON.stringify(descriptor.source)} must name a listed source-of-truth entry (§3.5: first entry is the primary owner, the rest are secondary references)`,
345
+ });
346
+ }
347
+ });
348
+ }
349
+
350
+ /**
351
+ * §3.4 lifecycle invariants on one log fragment (finding/miss/gap) — the
352
+ * checks JSON Schema cannot express record-locally. The schema gate matches
353
+ * what the transition helper (log-entry.js) enforces on its write path, so a
354
+ * hand-edited fragment can't carry an inconsistent lifecycle:
355
+ * - verified ⇔ status resolved (both directions)
356
+ * - rejected ⇒ non-empty reason; reason travels only with rejected
357
+ */
358
+ function lifecycleConventions(record, basePath, errors) {
359
+ if (!isPlainObject(record) || typeof record.status !== 'string') return;
360
+ const { status } = record;
361
+ if (Object.hasOwn(record, 'verified') !== (status === 'resolved')) {
362
+ errors.push({
363
+ path: joinPath(basePath, 'verified'),
364
+ code: 'lifecycle-field-mismatch',
365
+ message: status === 'resolved'
366
+ ? 'a resolved entry carries the verified date the validator re-run stamped (§8)'
367
+ : `verified travels only with status "resolved", not ${JSON.stringify(status)} — re-opening drops it (§8)`,
368
+ });
369
+ }
370
+ const hasReason = typeof record.reason === 'string' && record.reason !== '';
371
+ if (status === 'rejected' ? !hasReason : Object.hasOwn(record, 'reason')) {
372
+ errors.push({
373
+ path: joinPath(basePath, 'reason'),
374
+ code: 'lifecycle-field-mismatch',
375
+ message: status === 'rejected'
376
+ ? 'rejecting requires a non-empty reason — rejections record the reason (§8)'
377
+ : `reason travels only with status "rejected", not ${JSON.stringify(status)} — re-opening drops it (§8)`,
378
+ });
379
+ }
380
+ }
381
+
382
+ /**
383
+ * A document candidate's `section` locator addresses ONE coordinate system
384
+ * (UCS-1160). `lib/coverage.js` emits `{line, endLine}` for line-addressed
385
+ * sources and `{page, object}` for page-addressed ones (pdf) — never both,
386
+ * because a section does not have a line number AND a page number in the same
387
+ * document. So the finding fragment must carry exactly one:
388
+ * - NEITHER is an underspecified locator — "somewhere in this document" is
389
+ * the coordinate-free claim the locator exists to replace, and reflect
390
+ * would have to re-read the document it was meant to avoid opening.
391
+ * - BOTH is contradictory — two coordinate systems disagreeing about where
392
+ * the section is, with nothing to say which one a reader should trust.
393
+ *
394
+ * This is a CONVENTION rather than a schema keyword because the JSON Schema
395
+ * subset this module interprets (SUPPORTED_KEYWORDS) has no conditionals —
396
+ * no `oneOf`, `anyOf`, `allOf`, or `not`. Writing one into the schema would
397
+ * add a keyword nothing enforces, which is silent contract drift: the schema
398
+ * would promise a rule no validator checks. The rule lives here, where finding
399
+ * records are already judged, and the schema's description says so.
400
+ */
401
+ function locatorConventions(record, basePath, errors) {
402
+ if (!isPlainObject(record) || !isPlainObject(record.section)) return;
403
+ const { section } = record;
404
+ const hasLine = Object.hasOwn(section, 'line');
405
+ const hasPage = Object.hasOwn(section, 'page');
406
+ if (hasLine === hasPage) {
407
+ errors.push({
408
+ path: joinPath(basePath, 'section'),
409
+ code: 'locator-shape',
410
+ message: hasLine
411
+ ? 'a section locator addresses one coordinate system: line (line-addressed sources) or page (pdf), never both — coverage emits exactly one (UCS-1160)'
412
+ : 'a section locator needs a coordinate: line for line-addressed sources, page for pdf — a locator with neither cannot open the section it addresses (UCS-1160)',
413
+ });
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Findings carry the lifecycle every log fragment carries, plus the locator
419
+ * shape only they can have (a miss/gap has no `section`).
420
+ */
421
+ function findingConventions(record, basePath, errors) {
422
+ lifecycleConventions(record, basePath, errors);
423
+ locatorConventions(record, basePath, errors);
424
+ }
425
+
426
+ /** Record-local convention checks, keyed by kind (one lookup, both entry points). */
427
+ const CONVENTIONS = Object.freeze({
428
+ 'ontology-concept': conceptConventions,
429
+ finding: findingConventions,
430
+ miss: lifecycleConventions,
431
+ gap: lifecycleConventions,
432
+ });
433
+
434
+ /**
435
+ * schema-version defects where the envelope legitimately carries the key are
436
+ * normalized to `invalid-schema-version`. Only value defects qualify — an
437
+ * `unknown-property` at that path means the key is misplaced (it belongs on
438
+ * the store-file envelope), which is the opposite remediation.
439
+ */
440
+ const SCHEMA_VERSION_VALUE_DEFECTS = new Set(['wrong-type', 'missing-required', 'out-of-range']);
441
+
442
+ /**
443
+ * Dedupe (convention codes own their paths over generic schema codes),
444
+ * normalize schema-version value defects to one code, and stable-sort.
445
+ */
446
+ function finish(errors, { fileEnvelope }) {
447
+ const conventionPaths = new Set(
448
+ errors
449
+ .filter((e) => e.code === 'non-string-enumerates-value')
450
+ .map((e) => e.path),
451
+ );
452
+ const cleaned = errors
453
+ .filter((e) => e.code === 'non-string-enumerates-value' || !conventionPaths.has(e.path))
454
+ .map((e) => (fileEnvelope && e.path === 'schema-version' && SCHEMA_VERSION_VALUE_DEFECTS.has(e.code)
455
+ ? { ...e, code: 'invalid-schema-version', message: `${e.message} — every store file carries an integer schema-version >= 1 (§3.5)` }
456
+ : e));
457
+ cleaned.sort((a, b) => compare(a.path, b.path) || compare(a.code, b.code));
458
+ return { ok: cleaned.length === 0, errors: cleaned };
459
+ }
460
+
461
+ function runConventions(kind, record, basePath, errors) {
462
+ CONVENTIONS[kind]?.(record, basePath, errors);
463
+ }
464
+
465
+ /**
466
+ * Validate a single record of the given kind against its shipped schema.
467
+ * For one-record-per-file kinds (knowledge-leaf, finding, catalog, rules)
468
+ * the record is the whole store file, schema-version included.
469
+ *
470
+ * @param {string} kind one of KINDS
471
+ * @param {unknown} record the YAML document (or entry), parsed with types intact
472
+ * @returns {{ ok: boolean, errors: Array<{path: string, code: string, message: string}> }}
473
+ */
474
+ export function validateRecord(kind, record) {
475
+ const schema = schemaFor(kind);
476
+ const errors = [];
477
+ check(schema, schema, record, '', errors);
478
+ runConventions(kind, record, '', errors);
479
+ // One-record-per-file kinds ARE the store file (schema-version at the root);
480
+ // multi-entry kinds carry it on the envelope, not the record.
481
+ return finish(errors, { fileEnvelope: !schema.$defs?.storeFile });
482
+ }
483
+
484
+ /**
485
+ * Validate a whole store file of the given kind. Multi-entry kinds
486
+ * (ontology-concept, decision-entry) are wrapped in the $defs/storeFile
487
+ * envelope ({ schema-version, entries: [...] }); one-record-per-file kinds
488
+ * validate as the record itself.
489
+ *
490
+ * @param {string} kind one of KINDS
491
+ * @param {unknown} doc the parsed store file
492
+ * @returns {{ ok: boolean, errors: Array<{path: string, code: string, message: string}> }}
493
+ */
494
+ export function validateStoreFile(kind, doc) {
495
+ const schema = schemaFor(kind);
496
+ const fileSchema = schema.$defs?.storeFile;
497
+ if (!fileSchema) return validateRecord(kind, doc);
498
+ const errors = [];
499
+ check(schema, fileSchema, doc, '', errors);
500
+ if (isPlainObject(doc) && Array.isArray(doc.entries)) {
501
+ doc.entries.forEach((entry, i) => runConventions(kind, entry, `entries[${i}]`, errors));
502
+ }
503
+ return finish(errors, { fileEnvelope: true });
504
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/log-entry.js — the entry shim (UCS-956).
4
+ *
5
+ * This file statically imports NOTHING. That is its entire job.
6
+ *
7
+ * Node exits 1 on an unhandled ES module load error, and exit 1 means FINDINGS
8
+ * (PRD §5). A SyntaxError in a lib/ module, or a missing `js-yaml`, must not
9
+ * tell an agent that the check ran and found problems. So the engine is reached
10
+ * only through `import()`, where a load failure is an ordinary catchable
11
+ * rejection rather than a process-level crash.
12
+ *
13
+ * Both specifiers are string literals naming the engine's own files. D-014
14
+ * forbids importing REPO CONTENT — the client's code — and nothing here can
15
+ * name it: there is no variable to point somewhere else.
16
+ *
17
+ * The command lives in commands/log-entry.js. Its invocation path is unchanged.
18
+ */
19
+ try {
20
+ const [{ boot }, command] = await Promise.all([
21
+ import('./lib/boot.js'),
22
+ import('./commands/log-entry.js'),
23
+ ]);
24
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
25
+ // piped --json output would truncate at the pipe buffer — corrupt output
26
+ // wearing a clean exit code. Node exits on its own once stdout drains.
27
+ process.exitCode = await boot('log-entry', command);
28
+ } catch (error) {
29
+ // The engine could not be loaded, so no check ran. Exit 2 — never 1.
30
+ // Hardcoded, because reading it from lib/exit-codes.js is the very thing
31
+ // that may have just failed.
32
+ process.stderr.write(`log-entry: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
33
+ process.exitCode = 2;
34
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/phoenix.js — the entry shim (UCS-956).
4
+ *
5
+ * This file statically imports NOTHING. That is its entire job.
6
+ *
7
+ * Node exits 1 on an unhandled ES module load error, and exit 1 means FINDINGS
8
+ * (PRD §5). A SyntaxError in a lib/ module, or a missing `js-yaml`, must not
9
+ * tell an agent that the check ran and found problems. So the engine is reached
10
+ * only through `import()`, where a load failure is an ordinary catchable
11
+ * rejection rather than a process-level crash.
12
+ *
13
+ * The stakes are highest here: this is the one command that WRITES to a store
14
+ * (UCS-1154). A load failure that wore exit 1 would read as "the event was
15
+ * refused with findings" — which is the one outcome that also means "nothing
16
+ * was written", and would be indistinguishable from an event that half-applied.
17
+ *
18
+ * Both specifiers are string literals naming the engine's own files. D-014
19
+ * forbids importing REPO CONTENT — the client's code — and nothing here can
20
+ * name it: there is no variable to point somewhere else.
21
+ *
22
+ * The command lives in commands/phoenix.js. Its invocation path is unchanged.
23
+ */
24
+ try {
25
+ const [{ boot }, command] = await Promise.all([
26
+ import('./lib/boot.js'),
27
+ import('./commands/phoenix.js'),
28
+ ]);
29
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
30
+ // piped --json output would truncate at the pipe buffer — corrupt output
31
+ // wearing a clean exit code. Node exits on its own once stdout drains.
32
+ process.exitCode = await boot('phoenix', command);
33
+ } catch (error) {
34
+ // The engine could not be loaded, so no event ran. Exit 2 — never 1.
35
+ // Hardcoded, because reading it from lib/exit-codes.js is the very thing
36
+ // that may have just failed.
37
+ process.stderr.write(`phoenix: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
38
+ process.exitCode = 2;
39
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/preflight.js — the entry shim (UCS-956).
4
+ *
5
+ * This file statically imports NOTHING. That is its entire job.
6
+ *
7
+ * Node exits 1 on an unhandled ES module load error, and exit 1 means FINDINGS
8
+ * (PRD §5). A SyntaxError in a lib/ module, or a missing `js-yaml`, must not
9
+ * tell an agent that the check ran and found problems. So the engine is reached
10
+ * only through `import()`, where a load failure is an ordinary catchable
11
+ * rejection rather than a process-level crash.
12
+ *
13
+ * Both specifiers are string literals naming the engine's own files. D-014
14
+ * forbids importing REPO CONTENT — the client's code — and nothing here can
15
+ * name it: there is no variable to point somewhere else.
16
+ *
17
+ * The command lives in commands/preflight.js. Its invocation path is unchanged.
18
+ */
19
+ try {
20
+ const [{ boot }, command] = await Promise.all([
21
+ import('./lib/boot.js'),
22
+ import('./commands/preflight.js'),
23
+ ]);
24
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
25
+ // piped --json output would truncate at the pipe buffer — corrupt output
26
+ // wearing a clean exit code. Node exits on its own once stdout drains.
27
+ process.exitCode = await boot('preflight', command);
28
+ } catch (error) {
29
+ // The engine could not be loaded, so no check ran. Exit 2 — never 1.
30
+ // Hardcoded, because reading it from lib/exit-codes.js is the very thing
31
+ // that may have just failed.
32
+ process.stderr.write(`preflight: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
33
+ process.exitCode = 2;
34
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/resolve.js — the entry shim (UCS-956).
4
+ *
5
+ * This file statically imports NOTHING. That is its entire job.
6
+ *
7
+ * Node exits 1 on an unhandled ES module load error, and exit 1 means FINDINGS
8
+ * (PRD §5). A SyntaxError in a lib/ module, or a missing `js-yaml`, must not
9
+ * tell an agent that the check ran and found problems. So the engine is reached
10
+ * only through `import()`, where a load failure is an ordinary catchable
11
+ * rejection rather than a process-level crash.
12
+ *
13
+ * Both specifiers are string literals naming the engine's own files. D-014
14
+ * forbids importing REPO CONTENT — the client's code — and nothing here can
15
+ * name it: there is no variable to point somewhere else.
16
+ *
17
+ * The command lives in commands/resolve.js. Its invocation path is unchanged.
18
+ */
19
+ try {
20
+ const [{ boot }, command] = await Promise.all([
21
+ import('./lib/boot.js'),
22
+ import('./commands/resolve.js'),
23
+ ]);
24
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
25
+ // piped --json output would truncate at the pipe buffer — corrupt output
26
+ // wearing a clean exit code. Node exits on its own once stdout drains.
27
+ process.exitCode = await boot('resolve', command);
28
+ } catch (error) {
29
+ // The engine could not be loaded, so no check ran. Exit 2 — never 1.
30
+ // Hardcoded, because reading it from lib/exit-codes.js is the very thing
31
+ // that may have just failed.
32
+ process.stderr.write(`resolve: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
33
+ process.exitCode = 2;
34
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/survey-map.js — the entry shim (UCS-956).
4
+ *
5
+ * This file statically imports NOTHING. That is its entire job.
6
+ *
7
+ * Node exits 1 on an unhandled ES module load error, and exit 1 means FINDINGS
8
+ * (PRD §5). A SyntaxError in a lib/ module, or a missing `js-yaml`, must not
9
+ * tell an agent that the check ran and found problems. So the engine is reached
10
+ * only through `import()`, where a load failure is an ordinary catchable
11
+ * rejection rather than a process-level crash.
12
+ *
13
+ * Both specifiers are string literals naming the engine's own files. D-014
14
+ * forbids importing REPO CONTENT — the client's code — and nothing here can
15
+ * name it: there is no variable to point somewhere else.
16
+ *
17
+ * The command lives in commands/survey-map.js. Its invocation path is unchanged.
18
+ */
19
+ try {
20
+ const [{ boot }, command] = await Promise.all([
21
+ import('./lib/boot.js'),
22
+ import('./commands/survey-map.js'),
23
+ ]);
24
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
25
+ // piped --json output would truncate at the pipe buffer — corrupt output
26
+ // wearing a clean exit code. Node exits on its own once stdout drains.
27
+ process.exitCode = await boot('survey-map', command);
28
+ } catch (error) {
29
+ // The engine could not be loaded, so no check ran. Exit 2 — never 1.
30
+ // Hardcoded, because reading it from lib/exit-codes.js is the very thing
31
+ // that may have just failed.
32
+ process.stderr.write(`survey-map: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
33
+ process.exitCode = 2;
34
+ }