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,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`;
|