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,628 @@
1
+ /**
2
+ * Phoenix events — governed bulk re-taxonomy (UCS-1154).
3
+ *
4
+ * A subtree's classification drifts. The material did not change; the shape we
5
+ * file it under did. A phoenix event rewrites that subtree's facets in bulk,
6
+ * bumps the `edition` of every leaf it touches, and lands as an ordinary PR:
7
+ * a reviewable diff, not a migration project.
8
+ *
9
+ * Three properties make that claim true rather than aspirational, and this
10
+ * module exists to enforce all three.
11
+ *
12
+ * CITATIONS ARE UNTOUCHED BY CONSTRUCTION. Identity is the accession id, and a
13
+ * phoenix event never changes one. Nothing that cites a leaf has to chase its
14
+ * reclassification, so a decisions entry written against edition 1 is still
15
+ * valid at edition 2 — it names the leaf, not the shelf. The rewriter below
16
+ * makes this structural rather than careful: it replaces individual frontmatter
17
+ * LINES and copies every other byte of the file through untouched, so the
18
+ * citations block cannot change even if this module has a bug.
19
+ *
20
+ * THE MAPPING IS LEAF-GRANULAR, because a class-level rule cannot express a
21
+ * SPLIT. When `design-system/components` divides into `design-system/primitives`
22
+ * and `design-system/patterns`, no rename rule says which leaf went where — only
23
+ * a per-accession table can, and the `why` on each row is the whole substance of
24
+ * the review.
25
+ *
26
+ * IT IS ALL-OR-NOTHING. Every check runs against the whole mapping BEFORE a
27
+ * single byte is written, and any finding refuses the entire event. A partial
28
+ * apply would leave the store in a state no mapping describes — half
29
+ * re-taxonomized, with editions that agree with nothing — and that state is
30
+ * strictly worse than not having started, because the next run cannot tell
31
+ * which leaves already moved.
32
+ *
33
+ * Findings, not exceptions: a mapping that misses a leaf is a defect its author
34
+ * fixes, which is exit 1 under the engine's contract (PRD §5). Exit 2 stays
35
+ * what it has always been — the run never happened.
36
+ */
37
+ import { readFileSync, writeFileSync } from 'node:fs';
38
+ import { join } from 'node:path';
39
+ import { load } from 'js-yaml';
40
+ import { compare } from './validate-record.js';
41
+ import { PHOENIX_DIR } from './load-stores.js';
42
+
43
+ /** The frontmatter field a phoenix event bumps — the ONLY thing that bumps it. */
44
+ export const EDITION_FIELD = 'edition';
45
+
46
+ /**
47
+ * The edition every leaf starts at.
48
+ *
49
+ * A leaf is born at edition 1 and stays there until an event moves it. So
50
+ * "edition > 1" and "a phoenix event touched this leaf" are the same claim,
51
+ * which is what lets the validator check the second by reading the first.
52
+ */
53
+ export const FIRST_EDITION = 1;
54
+
55
+ /**
56
+ * Every check class the phoenix gate runs, sorted — reported on every run, so
57
+ * a clean result names what it checked rather than only what it found.
58
+ */
59
+ export const CHECKS = Object.freeze([
60
+ 'edition-conflict', 'facet-unminted', 'noop-row', 'out-of-scope-row',
61
+ 'scope-unaccounted', 'unexplained-move', 'unknown-leaf', 'unreadable-leaf',
62
+ 'unrewritable-leaf',
63
+ ]);
64
+
65
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
66
+
67
+ /**
68
+ * Read a dotted field path out of a record, without inventing intermediates.
69
+ *
70
+ * @param {object} record
71
+ * @param {string} path a dotted path, e.g. `facets.domain`
72
+ * @returns {unknown} the value, or undefined if any segment is missing
73
+ */
74
+ export function valueAt(record, path) {
75
+ let node = record;
76
+ for (const segment of path.split('.')) {
77
+ if (!isObject(node)) return undefined;
78
+ node = node[segment];
79
+ }
80
+ return node;
81
+ }
82
+
83
+ /**
84
+ * Does `value` fall inside a scope value?
85
+ *
86
+ * For a hierarchical facet a scope value claims its whole subtree, so
87
+ * `design-system` claims `design-system/components`. Segment-wise rather than by
88
+ * prefix string: `design-system-legacy` starts with `design-system` and is a
89
+ * different class, and claiming it would silently widen the event's blast
90
+ * radius past what the steward wrote down.
91
+ *
92
+ * @param {string} value the leaf's current facet value
93
+ * @param {string} claimed a value listed in the event's scope
94
+ * @returns {boolean}
95
+ */
96
+ export function withinScope(value, claimed) {
97
+ return value === claimed || value.startsWith(`${claimed}/`);
98
+ }
99
+
100
+ /**
101
+ * Which leaves does this event's declared scope contain?
102
+ *
103
+ * The scope is stated as facet values, never as a list of ids: an event that
104
+ * enumerated its own membership could not MISS a leaf, and missing a leaf is
105
+ * exactly the defect the completeness gate exists to catch. So the engine
106
+ * derives the membership from the store and holds the mapping against it.
107
+ *
108
+ * @param {object} model a loaded store model
109
+ * @param {{ facet: string, values: string[] }} scope
110
+ * @returns {Array<{ id: string, entry: object, value: string }>} sorted by id
111
+ */
112
+ export function leavesInScope(model, scope) {
113
+ const found = [];
114
+ for (const [id, entry] of model.leaves) {
115
+ const value = valueAt(entry.record, scope.facet);
116
+ if (typeof value !== 'string') continue;
117
+ if (scope.values.some((claimed) => withinScope(value, claimed))) {
118
+ found.push({ id, entry, value });
119
+ }
120
+ }
121
+ return found.sort((a, b) => compare(a.id, b.id));
122
+ }
123
+
124
+ /**
125
+ * Split a scalar line's value from its trailing comment (UCS-1154).
126
+ *
127
+ * A steward's ` # ∈ registry (Personality)` note is authored content, and
128
+ * replacing the text after the colon wholesale deletes it — a silent loss in a
129
+ * diff that otherwise advertises itself as two lines. So the comment is carried
130
+ * across the rewrite.
131
+ *
132
+ * The split is refused, not guessed, when the old value contains a quote
133
+ * character. A `#` inside a quoted string is not a comment, and telling the two
134
+ * apart needs a YAML scanner rather than an index search; a wrong guess would
135
+ * either truncate a value at a legitimate `#` or paste half a string into a
136
+ * comment. Every value this engine writes is a plain registry term, so refusing
137
+ * here costs nothing real and keeps the rewriter honest about what it can read.
138
+ *
139
+ * @param {string} rest the text after `key:`, including leading space
140
+ * @returns {{ comment: string }|null} the trailing comment (with its spacing),
141
+ * or null if the line cannot be split safely
142
+ */
143
+ export function trailingComment(rest) {
144
+ const hash = rest.indexOf('#');
145
+ if (hash === -1) return { comment: '' };
146
+ // A quote anywhere before the hash means the hash may be inside a string.
147
+ if (/["']/.test(rest.slice(0, hash))) return null;
148
+ return { comment: rest.slice(hash).replace(/\s+$/, '') };
149
+ }
150
+
151
+ /**
152
+ * Spell a facet value so YAML reads it back as the same string (UCS-1154).
153
+ *
154
+ * Registry values are strings, and the facet grammar permits segments that YAML
155
+ * does not read as strings: `010` reloads as 10 (octal under YAML 1.1), `2024`
156
+ * as a number, `1e5` as 100000. Written bare, such a value would come back a
157
+ * number, stop equalling the minted string it was checked against, and turn a
158
+ * validated move into a membership finding on the next run — a corruption the
159
+ * engine itself introduced.
160
+ *
161
+ * Decided by ROUND-TRIPPING rather than by a character rule: the question is
162
+ * exactly "does the loader give this back unchanged", so the loader is what
163
+ * answers it, and no hand-written list of YAML's coercion traps can fall behind.
164
+ * Values that survive bare are written bare, which keeps the ordinary diff
165
+ * unquoted and unremarkable.
166
+ *
167
+ * @param {string} value the facet value to write
168
+ * @returns {string} the value, quoted only if it would not survive unquoted
169
+ */
170
+ export function yamlScalar(value) {
171
+ let roundTripped;
172
+ try {
173
+ roundTripped = load(`x: ${value}`)?.x;
174
+ } catch {
175
+ roundTripped = undefined; // unparseable bare: quoting is the answer
176
+ }
177
+ if (roundTripped === value) return value;
178
+ return JSON.stringify(value); // JSON strings are valid YAML double-quoted scalars
179
+ }
180
+
181
+ /**
182
+ * Rewrite one scalar frontmatter line, in place, byte-for-byte otherwise.
183
+ *
184
+ * This is the whole write strategy, and it is deliberately not "parse the YAML
185
+ * and re-serialize it". Round-tripping through a YAML emitter would rewrite the
186
+ * entire document: it reorders nothing but reformats everything — quoting
187
+ * style, flow vs block sequences, blank lines, comments — so a one-field change
188
+ * would land as a whole-file diff that no reviewer can read, and the claim
189
+ * "citations are byte-identical" would become a thing to verify rather than a
190
+ * thing that is true by construction.
191
+ *
192
+ * Instead: find the one line that declares this field at this indent, replace
193
+ * the text after the colon, and leave every other byte of the file alone.
194
+ * Field order, comments, quoting, spacing and the entire body survive because
195
+ * they are never touched. In exchange this handles only scalar values on their
196
+ * own line, which is what `edition` and every facet value are — anything else
197
+ * is refused by `rewriteFailure` rather than guessed at.
198
+ *
199
+ * INDENTATION IS PART OF THE MATCH, at every level. A key is the field being
200
+ * looked for only when it sits at exactly the depth the path says it does:
201
+ * top-level keys at column 0, and each nested segment at the indent its own
202
+ * parent block established. Matching on the name alone would let a key nested
203
+ * somewhere else stand in for the real one — `citations:` containing an
204
+ * `edition:` would shadow the leaf's own edition, and the rewriter would edit a
205
+ * CITATION while the field it was asked to change kept its old value. That is
206
+ * the one corruption this module claims cannot happen, so the guard belongs
207
+ * here rather than in a caller.
208
+ *
209
+ * @param {string} text the file's full text
210
+ * @param {string[]} path the field's path, e.g. ['facets','domain'] or ['edition']
211
+ * @param {string} value the new scalar, already in its wire spelling
212
+ * @returns {string|null} the rewritten text, or null if the line was not found
213
+ */
214
+ export function rewriteScalarLine(text, path, value) {
215
+ const lines = text.split('\n');
216
+ // Frontmatter only: the body may legally contain a line that looks like a
217
+ // field, and rewriting prose would be a corruption wearing a clean diff.
218
+ let end = lines.length;
219
+ for (let i = 1; i < lines.length; i += 1) {
220
+ if (lines[i] === '---') { end = i; break; }
221
+ }
222
+
223
+ let depth = 0;
224
+ // The indent the current level's keys sit at. Top-level fields are at column
225
+ // 0 by definition; each nested level's indent is learned from the first key
226
+ // line inside its parent's block, because YAML lets a document choose its own
227
+ // step and this rewriter must read the file's convention rather than impose
228
+ // one.
229
+ let expected = 0;
230
+ let parentIndent = -1;
231
+ for (let i = 1; i < end; i += 1) {
232
+ const match = /^(\s*)([A-Za-z0-9_-]+):(.*)$/.exec(lines[i]);
233
+ if (!match) continue;
234
+ const [, indent, key, rest] = match;
235
+ if (depth > 0) {
236
+ // Dedented to or past the parent: the block that would have held this
237
+ // field has ended without it.
238
+ if (indent.length <= parentIndent) return null;
239
+ // The first key inside the block fixes the level's indent.
240
+ if (expected <= parentIndent) expected = indent.length;
241
+ // Deeper than this level is a grandchild — a key belonging to some nested
242
+ // block, not the child being looked for. Skip it rather than match it.
243
+ if (indent.length > expected) continue;
244
+ } else if (indent.length !== 0) {
245
+ // A top-level field sits at column 0. Anything indented is nested inside
246
+ // some other block and is not this field, whatever it is called.
247
+ continue;
248
+ }
249
+ if (key !== path[depth]) continue;
250
+ if (depth === path.length - 1) {
251
+ // A scalar sits on its own line. A nested block or a value this function
252
+ // cannot see the end of is refused, not overwritten.
253
+ if (rest.trim() === '') return null;
254
+ // A steward's trailing note is authored content; carry it across rather
255
+ // than deleting it. A line whose comment cannot be told from its value is
256
+ // refused, not guessed at.
257
+ const split = trailingComment(rest);
258
+ if (!split) return null;
259
+ const spacing = split.comment ? rest.slice(0, rest.indexOf('#')).match(/\s*$/)[0] : '';
260
+ lines[i] = `${indent}${key}: ${value}${spacing}${split.comment}`;
261
+ return lines.join('\n');
262
+ }
263
+ // Descend: the next segment must be nested under this key.
264
+ if (rest.trim() !== '') return null; // a scalar where a block was expected
265
+ parentIndent = indent.length;
266
+ expected = parentIndent; // unset until the block's first key line fixes it
267
+ depth += 1;
268
+ }
269
+ return null;
270
+ }
271
+
272
+ /**
273
+ * Why can this leaf not be rewritten? Null when it can.
274
+ *
275
+ * Runs during the gate, on every leaf the event would touch, so an unwritable
276
+ * file is a finding BEFORE anything is written rather than a half-applied event
277
+ * discovered partway through.
278
+ *
279
+ * @param {string} text the leaf file's text
280
+ * @param {string} facet the scope facet, e.g. `facets.domain`
281
+ * @returns {string|null} the field path that could not be rewritten
282
+ */
283
+ export function rewriteFailure(text, facet) {
284
+ const probe = (path, value) => rewriteScalarLine(text, path, value) === null;
285
+ if (probe([EDITION_FIELD], '1')) return EDITION_FIELD;
286
+ if (probe(facet.split('.'), 'x')) return facet;
287
+ return null;
288
+ }
289
+
290
+ /**
291
+ * Apply one event's rewrites to one leaf's text.
292
+ *
293
+ * The facet value is spelled through `yamlScalar` so it reloads as the string
294
+ * it was checked against; `edition` is written bare, being an integer by design.
295
+ *
296
+ * @param {string} text the leaf file's text
297
+ * @param {string} facet the scope facet
298
+ * @param {string} to the new facet value
299
+ * @param {number} edition the new edition
300
+ * @returns {string|null} rewritten text, or null if either line was not found
301
+ */
302
+ export function rewriteLeaf(text, facet, to, edition) {
303
+ const withFacet = rewriteScalarLine(text, facet.split('.'), yamlScalar(to));
304
+ if (withFacet === null) return null;
305
+ return rewriteScalarLine(withFacet, [EDITION_FIELD], String(edition));
306
+ }
307
+
308
+ /**
309
+ * Plan a phoenix event: decide everything, write nothing.
310
+ *
311
+ * The whole all-or-nothing property lives here. This function answers "what
312
+ * would this event do, and is it legal" in one pass over the mapping, and the
313
+ * caller either writes every rewrite it returned or none of them. Nothing below
314
+ * this line touches the filesystem except to READ.
315
+ *
316
+ * @param {object} model a loaded store model
317
+ * @param {object} event a loaded phoenix mapping from `model.phoenix`
318
+ * @returns {{ findings: object[], rewrites: object[], carried: string[] }}
319
+ */
320
+ export function planEvent(model, event) {
321
+ const findings = [];
322
+ const rewrites = [];
323
+ const carried = [];
324
+ const { scope, rows, file } = event;
325
+ const push = (f) => findings.push({ ...f, event: event.event });
326
+
327
+ const inScope = leavesInScope(model, scope);
328
+ const scoped = new Set(inScope.map((l) => l.id));
329
+
330
+ // A row naming a leaf the store does not carry is a typo or a stale mapping,
331
+ // and either way the event cannot be what its author believed it was.
332
+ for (const [id, row] of rows) {
333
+ if (model.leaves.has(id)) continue;
334
+ push({
335
+ severity: 'error', code: 'unknown-leaf', id, file, path: `leaves[${row.index}].id`,
336
+ message: `event ${event.event} maps "${id}", which no leaf in this store carries — a mapping that names a leaf nobody can open describes an event that cannot be reviewed`,
337
+ });
338
+ }
339
+
340
+ // A row naming a real leaf that is OUTSIDE the declared scope reaches past
341
+ // what the event said it would touch. The scope is the reviewable claim; a
342
+ // row beyond it makes the diff larger than the declaration.
343
+ for (const [id, row] of rows) {
344
+ const entry = model.leaves.get(id);
345
+ if (!entry || scoped.has(id)) continue;
346
+ const value = valueAt(entry.record, scope.facet);
347
+ // The commonest way to land here is re-running an event that already
348
+ // applied: the leaf has moved OUT of the scope, to exactly where this row
349
+ // sent it. Saying so beats the generic out-of-scope text, which reads like
350
+ // a broken mapping when the truth is the work is already done. Still exit
351
+ // 1, and still no write — re-applying would bump the edition a second time
352
+ // for a move that happened once.
353
+ const applied = value === row.to;
354
+ // A DIFFERENT defect from `unknown-leaf`, and so a different code: that one
355
+ // is a row naming a leaf nobody can open (a typo, or a mapping written
356
+ // against a store that no longer exists); this one is a row reaching past
357
+ // the scope the event declared. A consumer filtering on the code should be
358
+ // able to tell a typo from an overreach without reading the prose.
359
+ push({
360
+ severity: 'error', code: 'out-of-scope-row', id, file, path: `leaves[${row.index}].id`,
361
+ message: applied
362
+ ? `event ${event.event} maps "${id}" to ${scope.facet}: ${row.to}, where it already sits — this event appears to have been applied already, and re-applying it would bump the edition a second time for a move that happened once`
363
+ : `event ${event.event} maps "${id}", whose ${scope.facet} is ${JSON.stringify(value ?? null)} — outside the declared scope (${scope.values.join(', ')}), so the event would touch more than it declares`,
364
+ });
365
+ }
366
+
367
+ // THE COMPLETENESS GATE. Every leaf in the declared scope must be accounted
368
+ // for — moved, split, or explicitly carried forward. A leaf the mapping never
369
+ // mentions is the rejection case the ticket names: it would keep a facet
370
+ // value the event has just retired, leaving the store describing its own
371
+ // taxonomy in two incompatible ways with nothing recording the disagreement.
372
+ for (const { id, entry, value } of inScope) {
373
+ if (rows.has(id)) continue;
374
+ push({
375
+ severity: 'error', code: 'scope-unaccounted', id, file: entry.file, path: scope.facet,
376
+ message: `leaf "${id}" is in event ${event.event}'s declared scope (${scope.facet}: ${value}) and the mapping never mentions it — every leaf in scope must be mapped, split, or explicitly carried forward, and a phoenix event that leaves one behind is a partial re-taxonomy nobody reviewed`,
377
+ });
378
+ }
379
+
380
+ for (const { id, entry, value } of inScope) {
381
+ const row = rows.get(id);
382
+ if (!row) continue;
383
+
384
+ // Carried forward: considered, deliberately unmoved. No rewrite, and so no
385
+ // edition bump — the edition records that a leaf CHANGED, and this one did
386
+ // not. Bumping it anyway would make the edition count reviews, not moves.
387
+ if (row.to === undefined) {
388
+ carried.push(id);
389
+ continue;
390
+ }
391
+
392
+ // A row whose target is where the leaf already sits is REFUSED, not quietly
393
+ // treated as a carry-forward. The mapping is the store's only record of what
394
+ // an event did, and the rows carry no `from`, so after the fact "to: X" on a
395
+ // leaf sitting at X is indistinguishable from a move that already applied.
396
+ // Reading it as a carry would leave the validator expecting an edition bump
397
+ // that never happens; reading it as a move would bump on every re-run. The
398
+ // honest fix is upstream: `to` present must MEAN moved, so the steward
399
+ // writes the intent they meant.
400
+ if (row.to === value) {
401
+ push({
402
+ severity: 'error', code: 'noop-row', id, file, path: `leaves[${row.index}].to`,
403
+ message: `event ${event.event} maps "${id}" to ${scope.facet}: ${row.to}, which is where it already sits — omit "to" to carry the leaf forward, because a row that states a move records one, and the store cannot later tell a no-op row from a move that already applied`,
404
+ });
405
+ continue;
406
+ }
407
+
408
+ // A move must say why. On a split this is the whole substance of the review:
409
+ // the class-level rule cannot explain why one sibling went to primitives and
410
+ // the other to patterns, so the row has to, and a row that does not is a bulk
411
+ // rewrite with no rationale attached to the leaf it moved.
412
+ if (typeof row.why !== 'string' || row.why.trim() === '') {
413
+ push({
414
+ severity: 'error', code: 'unexplained-move', id, file, path: `leaves[${row.index}].why`,
415
+ message: `event ${event.event} moves "${id}" to ${scope.facet}: ${row.to} with no "why" — a moved leaf carries the reason it moved, because that sentence is what a reviewer reads and what a later reader finds when they ask why this leaf is filed here`,
416
+ });
417
+ continue;
418
+ }
419
+
420
+ // The new value must already be minted. The registry is the governed
421
+ // vocabulary (UCS-1148) and a phoenix event is not a way around it: an
422
+ // event that could mint by writing would let a bulk rewrite invent
423
+ // vocabulary that no decisions entry ever justified.
424
+ const registryKey = FACET_REGISTRY[scope.facet];
425
+ const registry = registryKey ? model.registries.get(registryKey) : null;
426
+ const verdict = mintingVerdict(registry, registryKey, row.to);
427
+ if (verdict) {
428
+ push({
429
+ severity: 'error', code: 'facet-unminted', id, file, path: `leaves[${row.index}].to`,
430
+ message: `event ${event.event} moves "${id}" to ${scope.facet}: ${row.to}, but ${verdict}`,
431
+ });
432
+ continue;
433
+ }
434
+
435
+ const current = entry.record[EDITION_FIELD];
436
+ if (current !== undefined && (!Number.isInteger(current) || current < FIRST_EDITION)) {
437
+ push({
438
+ severity: 'error', code: 'edition-conflict', id, file: entry.file, path: EDITION_FIELD,
439
+ message: `leaf "${id}" carries ${EDITION_FIELD}: ${JSON.stringify(current)}, which is not an edition — a bump has nothing to count from`,
440
+ });
441
+ continue;
442
+ }
443
+
444
+ let text;
445
+ try {
446
+ text = readFileSync(join(model.root, entry.file), 'utf8');
447
+ } catch (error) {
448
+ push({
449
+ severity: 'error', code: 'unreadable-leaf', id, file: entry.file, path: '',
450
+ message: `leaf "${id}" could not be read, so the event cannot be applied to it: ${error.message}`,
451
+ });
452
+ continue;
453
+ }
454
+
455
+ // Probe the rewrite before committing to it. A leaf whose frontmatter this
456
+ // rewriter cannot edit surgically must fail the GATE — discovering it
457
+ // during the write loop is exactly the partial apply this design refuses.
458
+ const unwritable = rewriteFailure(text, scope.facet);
459
+ if (unwritable !== null) {
460
+ push({
461
+ severity: 'error', code: 'unrewritable-leaf', id, file: entry.file, path: unwritable,
462
+ message: `leaf "${id}" has no plain "${unwritable}:" line in its front matter to rewrite — phoenix edits single scalar lines so every other byte, citations included, is carried through untouched, and it will not reformat a file to make room`,
463
+ });
464
+ continue;
465
+ }
466
+
467
+ const edition = (current ?? FIRST_EDITION) + 1;
468
+ const next = rewriteLeaf(text, scope.facet, row.to, edition);
469
+ if (next === null) {
470
+ push({
471
+ severity: 'error', code: 'unrewritable-leaf', id, file: entry.file, path: scope.facet,
472
+ message: `leaf "${id}" could not be rewritten cleanly, so the event is refused rather than half-applied`,
473
+ });
474
+ continue;
475
+ }
476
+ rewrites.push({
477
+ id, file: entry.file, from: value, to: row.to, edition, before: text, after: next,
478
+ });
479
+ }
480
+
481
+ findings.sort((a, b) =>
482
+ compare(a.file, b.file) || compare(a.path, b.path) || compare(a.code, b.code) || compare(a.id, b.id));
483
+ rewrites.sort((a, b) => compare(a.id, b.id));
484
+ return { findings, rewrites, carried: carried.sort(compare) };
485
+ }
486
+
487
+ /**
488
+ * Which registry governs each facet a phoenix event may re-taxonomize.
489
+ *
490
+ * Restated from the validator's FACET_REGISTRIES rather than imported, because
491
+ * a command must not import another command (the engine's modules are the
492
+ * shared layer). `tests/phoenix.test.js` pins the two against each other, so a
493
+ * facet that gains a registry there cannot silently keep an old one here.
494
+ */
495
+ export const FACET_REGISTRY = Object.freeze({
496
+ 'facets.domain': 'knowledge/domains',
497
+ 'facets.form': 'knowledge/form',
498
+ 'facets.anchor': 'knowledge/anchor',
499
+ 'facets.stage': 'knowledge/stage',
500
+ });
501
+
502
+ /**
503
+ * Why can this value not be a phoenix target? Null when it can.
504
+ *
505
+ * Hierarchical registries mint each segment, so a target under an unminted
506
+ * parent is refused segment-wise — the same judgment the structural validator
507
+ * makes, so an applied event can never leave a store the validator rejects.
508
+ *
509
+ * @param {object|null} registry the loaded registry, or null if absent
510
+ * @param {string} key the registry's `<store>/<name>` key
511
+ * @param {string} value the proposed new facet value
512
+ * @returns {string|null} the reason, phrased to complete "…but <reason>"
513
+ */
514
+ function mintingVerdict(registry, key, value) {
515
+ if (!registry) {
516
+ return `the "${key}" registry is not in this store — a phoenix event moves leaves between governed values, it does not invent the vocabulary`;
517
+ }
518
+ const segments = registry.hierarchical ? value.split('/') : [value];
519
+ for (let i = 0; i < segments.length; i += 1) {
520
+ const path = registry.hierarchical ? segments.slice(0, i + 1).join('/') : value;
521
+ if (registry.suppressed.has(path)) {
522
+ return `"${path}" is SUPPRESSED in ${registry.file} — a refused value is not a destination`;
523
+ }
524
+ if (!registry.minted.has(path)) {
525
+ return `"${path}" is not minted in ${registry.file} — mint it with its warrant and decisions entry in the same PR as the event`;
526
+ }
527
+ }
528
+ return null;
529
+ }
530
+
531
+ /**
532
+ * Write a planned event's rewrites. Called only after the gate found nothing.
533
+ *
534
+ * Separate from `planEvent` on purpose: the two halves of "validate everything,
535
+ * then write" are two functions, so no future edit can interleave a write into
536
+ * the checking loop without deleting this seam first.
537
+ *
538
+ * `touched` accumulates the files this call may have changed on disk, IN ORDER,
539
+ * and it is the caller's — not this function's. The gate makes a mid-loop
540
+ * failure improbable (every leaf was read and probed moments earlier), not
541
+ * impossible: a disk can fill and a file can lose its permissions between the
542
+ * probe and the write. If that happens the store is left partially rewritten,
543
+ * which is the one state this design refuses to produce silently, so the throw
544
+ * propagates and the caller can name exactly which files to revert. An array
545
+ * the caller owns is what makes that list survive the exception.
546
+ *
547
+ * The file is recorded BEFORE it is written, not after. `writeFileSync` is not
548
+ * atomic — it can truncate a file and then fail partway through the contents —
549
+ * so a file whose write threw may still be damaged. Recording it only on
550
+ * success would leave the one file most likely to need reverting off the list
551
+ * the caller prints. Over-reporting a file that turned out untouched costs a
552
+ * reviewer one `git checkout`; under-reporting a truncated leaf costs them the
553
+ * leaf.
554
+ *
555
+ * @param {string} root the store root the rewrites are relative to
556
+ * @param {Array<{ file: string, after: string }>} rewrites
557
+ * @param {string[]} [touched] collects the files that may have changed, for the caller
558
+ * @throws {Error} whatever the filesystem threw, after recording what it touched
559
+ */
560
+ export function applyRewrites(root, rewrites, touched = []) {
561
+ for (const rewrite of rewrites) {
562
+ touched.push(rewrite.file);
563
+ writeFileSync(join(root, rewrite.file), rewrite.after);
564
+ }
565
+ return touched;
566
+ }
567
+
568
+ /**
569
+ * Every edition in the store that the retained phoenix mappings do not account
570
+ * for (UCS-1154) — the check the structural validator runs.
571
+ *
572
+ * A leaf's edition is not a number an author may pick. It COUNTS the phoenix
573
+ * events that moved this leaf, starting at 1, and a phoenix event is the only
574
+ * thing that bumps it. So the store states the same fact twice — once in each
575
+ * leaf's `edition`, once in the retained mappings — and this holds the two
576
+ * against each other:
577
+ *
578
+ * edition === 1 + (retained events that moved this leaf)
579
+ *
580
+ * An equality rather than a floor, deliberately. "At least one event mentions
581
+ * this leaf" would accept `edition: 4` on a leaf one event moved once, which is
582
+ * precisely the hand-typed number the rule exists to refuse — the check would
583
+ * pass while the field it checks means nothing.
584
+ *
585
+ * Checkable from the WORKING TREE ALONE, which is why the mappings are retained
586
+ * in `knowledge/_phoenix/` rather than living only in the PRs that applied them.
587
+ * The validator sees a fresh clone with no history, and a governance rule it
588
+ * could only verify by reading commits is a rule it does not verify.
589
+ *
590
+ * A carried-forward row does not count: it records that a leaf was considered
591
+ * and deliberately left alone, so it cannot also be the warrant for an edition
592
+ * that says the leaf changed.
593
+ *
594
+ * @param {object} model a loaded store model
595
+ * @returns {Array<{ id: string, file: string, edition: number, events: string[], expected: number }>}
596
+ * sorted by id
597
+ */
598
+ export function unaccountedEditions(model) {
599
+ const movedBy = new Map();
600
+ for (const [, event] of [...model.phoenix].sort((a, b) => compare(a[0], b[0]))) {
601
+ for (const [id, row] of event.rows) {
602
+ if (row.to === undefined) continue;
603
+ if (!movedBy.has(id)) movedBy.set(id, []);
604
+ movedBy.get(id).push(event.event);
605
+ }
606
+ }
607
+ const unaccounted = [];
608
+ for (const [id, entry] of model.leaves) {
609
+ const edition = entry.record[EDITION_FIELD];
610
+ // A missing or malformed edition is the schema's finding (KK-02), not this
611
+ // check's — do not double-report.
612
+ if (!Number.isInteger(edition)) continue;
613
+ const events = movedBy.get(id) ?? [];
614
+ const expected = FIRST_EDITION + events.length;
615
+ if (edition === expected) continue;
616
+ unaccounted.push({ id, file: entry.file, edition, events, expected });
617
+ }
618
+ return unaccounted.sort((a, b) => compare(a.id, b.id));
619
+ }
620
+
621
+ /**
622
+ * The store-relative path an event's mapping lives at.
623
+ *
624
+ * @param {string} store the store carrying the event
625
+ * @param {string} event the event id
626
+ * @returns {string}
627
+ */
628
+ export const phoenixPath = (store, event) => `${store}/${PHOENIX_DIR}/${event}.yaml`;