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,1426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural validator (KK-05) — blocking-grade (PRD §4, D-011).
|
|
3
|
+
*
|
|
4
|
+
* node payload/engine/validate.js [--json] [--root <dir>] [--concepts <ids>]
|
|
5
|
+
*
|
|
6
|
+
* Runs the full §4 structural check list over the KK-04 loader model — the
|
|
7
|
+
* single health model; this module NEVER re-parses stores itself. The loader
|
|
8
|
+
* already carries schema validation (KK-02), duplicate-id detection, and
|
|
9
|
+
* typed-ref resolution on its one error/warning scale; this validator adds
|
|
10
|
+
* the checks that need the whole model and the working tree:
|
|
11
|
+
*
|
|
12
|
+
* id-shape catalog entry id violates the owning store's id grammar
|
|
13
|
+
* (K-NNN / dotted notation / D-NNN or provisional draft id)
|
|
14
|
+
* — record-level ids are schema-pattern-checked upstream,
|
|
15
|
+
* catalog ids are plain strings there by design
|
|
16
|
+
* id-range concept id outside the class file's declared range: the
|
|
17
|
+
* numeric filename prefix N declares [N, N+99] (§3.5 ids
|
|
18
|
+
* are minted within class ranges, leaving gaps)
|
|
19
|
+
* missing-path a declared pointer that does not name a real thing inside
|
|
20
|
+
* this repo — a concept source-of-truth path, or a
|
|
21
|
+
* knowledge leaf `paths` entry (UCS-1151). Three shapes,
|
|
22
|
+
* one code, because from the store's side they are one
|
|
23
|
+
* defect class: the pointer is absent, it ESCAPES the repo
|
|
24
|
+
* root (`../elsewhere`, an absolute path, or a symlink
|
|
25
|
+
* inside the repo whose target is outside it — containment
|
|
26
|
+
* is judged on CANONICAL paths, never lexically), or it
|
|
27
|
+
* names the repo ROOT itself — a pointer at everything
|
|
28
|
+
* attributes nothing. A dangling symlink reports as absent
|
|
29
|
+
* rather than escaping, which is what it is.
|
|
30
|
+
* Deprecated concepts demote an ABSENT pointer to
|
|
31
|
+
* warning (§3.5 — the source-deletion escape hatch) but
|
|
32
|
+
* never an escaping or root one: that hatch is for a path
|
|
33
|
+
* that used to exist, not for a claim the store was never
|
|
34
|
+
* entitled to make. Leaves have no demotion at all
|
|
35
|
+
* index-drift catalog/tree index inconsistency: a row naming a file
|
|
36
|
+
* that was not loaded, or naming a file that does not
|
|
37
|
+
* contain the row's id. The documented pending marker
|
|
38
|
+
* (file: pending-import, mid-import rows) is a warning —
|
|
39
|
+
* declared, not drifted
|
|
40
|
+
* orphan a loaded record its store's catalog never declares —
|
|
41
|
+
* unreachable through the navigational entry point (§3)
|
|
42
|
+
* missing-citation a knowledge-leaf citation whose source is empty — an
|
|
43
|
+
* unsourced claim is not promotable (§3.2); presence and
|
|
44
|
+
* minItems are schema checks upstream
|
|
45
|
+
* ref-cycle a decision supersedes chain that loops (§3.3 chains
|
|
46
|
+
* must be acyclic; supersedes/superseded-by mirror pairs
|
|
47
|
+
* are legitimate, so only supersedes edges are walked)
|
|
48
|
+
* unregistered-value a governed facet value absent from its registry
|
|
49
|
+
* (UCS-1148). Registry membership cannot be a schema enum:
|
|
50
|
+
* the vocabulary is minted by literary warrant under
|
|
51
|
+
* steward review, so it lives in a governed file that
|
|
52
|
+
* grows without an engine release
|
|
53
|
+
* unminted-segment a hierarchical domain path with an unminted segment —
|
|
54
|
+
* the finding names the SEGMENT, which is the edit the
|
|
55
|
+
* author can actually make
|
|
56
|
+
* suppressed-value a value the registry lists as REJECTED. Suppression is
|
|
57
|
+
* durable and visible: a refused term must not read as a
|
|
58
|
+
* typo, and must not be quietly re-minted
|
|
59
|
+
* missing-registry a record cites a governed facet whose registry the store
|
|
60
|
+
* does not carry. Registry absence surfaces HERE, at the
|
|
61
|
+
* point a value actually needs judging — a store that
|
|
62
|
+
* governs nothing is complete, but a value checked against
|
|
63
|
+
* a registry that never loaded is a check that never ran
|
|
64
|
+
* missing-graduation-table
|
|
65
|
+
* a graduation/revocation entry in a store carrying no
|
|
66
|
+
* category table (UCS-1155). Absence surfaces where it can
|
|
67
|
+
* mean something — at the entry that needed the table
|
|
68
|
+
* undeclared-category
|
|
69
|
+
* a graduation/revocation naming a category the table does
|
|
70
|
+
* not declare. Autonomy is per CATEGORY, so one nobody
|
|
71
|
+
* scoped has no written extent
|
|
72
|
+
* gated-category-graduation
|
|
73
|
+
* a graduation for a PERMANENTLY GATED category — the
|
|
74
|
+
* refusal the mechanism exists for. Revocation of a gated
|
|
75
|
+
* category is fine: it only ever narrows autonomy
|
|
76
|
+
* registry-shape-mismatch
|
|
77
|
+
* a registry's hierarchical flag disagrees with the shape
|
|
78
|
+
* the facet it governs requires. Reported once, against the
|
|
79
|
+
* registry file: a domains registry missing its
|
|
80
|
+
* `hierarchical: true` would judge whole paths as opaque
|
|
81
|
+
* strings, silently disabling the segment rule at exit 0
|
|
82
|
+
*
|
|
83
|
+
* Exit codes (PRD §5, lib/exit-codes.js): 0 clean, 1 findings (any
|
|
84
|
+
* error-severity finding), 2 engine failure. Loader error-severity
|
|
85
|
+
* diagnostics gate to exit 2 — structural checks over a store that failed to
|
|
86
|
+
* load never ran, and a check that never ran is a blocking defect, never a
|
|
87
|
+
* silent pass. Warnings alone (from findings or the loader) exit 0.
|
|
88
|
+
*
|
|
89
|
+
* --concepts <ids> (comma-separated) filters findings to the named concepts
|
|
90
|
+
* for mid-session ACT checks; an unknown id is a hard error (exit 2) —
|
|
91
|
+
* filtering on a typo must never read as a clean pass.
|
|
92
|
+
*
|
|
93
|
+
* JSON findings output is deterministic and stable-sorted by file/path/code/id
|
|
94
|
+
* (shared comparator), no timestamps — baseline-diffable (D-012).
|
|
95
|
+
*/
|
|
96
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
97
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
98
|
+
import { fileURLToPath } from 'node:url';
|
|
99
|
+
import process from 'node:process';
|
|
100
|
+
import {
|
|
101
|
+
LEAF_ACCESSION_FIELD, LEAF_PATHS_FIELD, healthSummary, loadStores,
|
|
102
|
+
normalizeConceptIds, recordId, storeHealth,
|
|
103
|
+
} from '../lib/load-stores.js';
|
|
104
|
+
import { locateKitRoot } from '../lib/kit-root.js';
|
|
105
|
+
import { EXIT_CODES } from '../lib/exit-codes.js';
|
|
106
|
+
import { UsageError, parseArgs as parseFlags, rethrowIfBug } from '../lib/cli.js';
|
|
107
|
+
import { compare } from '../lib/validate-record.js';
|
|
108
|
+
// Catalog id grammars come from the one module that owns them (UCS-1142), the
|
|
109
|
+
// same source the record-level schema patterns bind to — so a catalog row and
|
|
110
|
+
// the record it points at can never be judged by two different grammars.
|
|
111
|
+
import { ID_GRAMMARS, idPattern } from '../lib/id-grammars.js';
|
|
112
|
+
// The Time facet's field spellings and pinned thresholds (UCS-1150), read from
|
|
113
|
+
// the one module that owns them — the validator's presence check and every
|
|
114
|
+
// surface's staleness verdict must agree about which fields those are.
|
|
115
|
+
import { VERIFIED_FIELD, VOLATILITY_LIMITS, leafVolatility } from '../lib/time-verdicts.js';
|
|
116
|
+
import { isCalendarDate } from '../lib/iso-date.js';
|
|
117
|
+
// Phoenix events own the `edition` field: they are the only thing that bumps it
|
|
118
|
+
// (UCS-1154), so the check that every bump was sanctioned reads its rule from
|
|
119
|
+
// the same module that applies them.
|
|
120
|
+
import { EDITION_FIELD, FIRST_EDITION, unaccountedEditions } from '../lib/phoenix.js';
|
|
121
|
+
|
|
122
|
+
export const USAGE = 'usage: node payload/engine/validate.js [--json] [--root <dir>] [--concepts <ids>]';
|
|
123
|
+
|
|
124
|
+
/** Every check class this validator runs, sorted — reported on every run. */
|
|
125
|
+
export const CHECKS = Object.freeze([
|
|
126
|
+
'disconnected-revocation', 'gated-category-graduation',
|
|
127
|
+
'graduation-field-shape', 'graduation-not-trust-category',
|
|
128
|
+
'id-range', 'id-shape', 'index-drift',
|
|
129
|
+
'malformed-verified', 'missing-authority', 'missing-citation',
|
|
130
|
+
'missing-graduation-table', 'missing-path', 'missing-registry',
|
|
131
|
+
'missing-verified', 'orphan', 'ref-cycle', 'registry-shape-mismatch',
|
|
132
|
+
'suppressed-value', 'unaccounted-edition', 'undeclared-category',
|
|
133
|
+
'unminted-segment', 'unregistered-value',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/** The §3 documented mid-import marker a catalog row carries instead of a file. */
|
|
137
|
+
const PENDING_MARKER = 'pending-import';
|
|
138
|
+
|
|
139
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
140
|
+
const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Judge one declared pointer into the working tree — the single reader of
|
|
144
|
+
* "does this path exist, and is it even ours to ask about".
|
|
145
|
+
*
|
|
146
|
+
* Both pointer families ride this: concept `source-of-truth` and leaf `paths`.
|
|
147
|
+
* They had the same defect independently, which is the argument for one
|
|
148
|
+
* function rather than two call sites that happen to agree today.
|
|
149
|
+
*
|
|
150
|
+
* CONTAINMENT is checked before existence, and that order is the whole point.
|
|
151
|
+
* `join(repoRoot, p)` happily resolves `../sibling` to a directory OUTSIDE the
|
|
152
|
+
* repo, and `/etc/passwd` to `<repoRoot>/etc/passwd`. A pointer that escapes
|
|
153
|
+
* would then be judged against a file the store has no claim on: an escaping
|
|
154
|
+
* path that happens to exist on the author's machine passes silently, and the
|
|
155
|
+
* same store fails on a machine where it does not. A check whose verdict
|
|
156
|
+
* depends on what sits OUTSIDE the repo is not a check — the store is the unit
|
|
157
|
+
* that gets committed, reviewed and shipped, so a pointer that leaves it is
|
|
158
|
+
* refused on its shape rather than tested against the filesystem.
|
|
159
|
+
*
|
|
160
|
+
* The repo ROOT itself is refused too, for a reason the resolver already
|
|
161
|
+
* settled from the other side: `--paths .` is a usage error there ("name the
|
|
162
|
+
* files or directories the change touched"), because a pointer at everything
|
|
163
|
+
* attributes nothing. A leaf declaring `paths: ["."]` makes that same empty
|
|
164
|
+
* claim, and it is worse than useless — it would match every path ever queried,
|
|
165
|
+
* putting one leaf in front of every developer regardless of what they touched.
|
|
166
|
+
* Refusing it is the same judgement, applied where the claim is authored.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} repoRoot the repo root pointers resolve against
|
|
169
|
+
* @param {string} p the pointer as the record spells it
|
|
170
|
+
* @returns {'escapes'|'root'|'missing'|null} the defect, or null if it is fine
|
|
171
|
+
*/
|
|
172
|
+
/**
|
|
173
|
+
* What each pointer defect says, per family — one message per defect, taking
|
|
174
|
+
* the family's own noun so a concept and a leaf each read naturally.
|
|
175
|
+
*
|
|
176
|
+
* All three ride the `missing-path` code, because they are one defect class
|
|
177
|
+
* from the store's side: a declared pointer that does not name a real thing
|
|
178
|
+
* inside this repo. The MESSAGE is what distinguishes them, and it has to,
|
|
179
|
+
* since the three send an author to three different edits — restore the file,
|
|
180
|
+
* bring the path inside the repo, or name something narrower than everything.
|
|
181
|
+
*/
|
|
182
|
+
const POINTER_MESSAGES = Object.freeze({
|
|
183
|
+
missing: (p, noun) => `${noun} "${p}" does not exist in the working tree — the truth anchor is the artifact (§3.1)`,
|
|
184
|
+
escapes: (p, noun) => `${noun} "${p}" resolves outside the repo root — a store may only point at its own repo, and a pointer that escapes would be judged against a file this store has no claim on (passing or failing by what happens to sit outside it)`,
|
|
185
|
+
root: (p, noun) => `${noun} "${p}" names the repo root — a pointer at everything attributes nothing, and it would match every path ever queried; name the files or directories this actually governs`,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
function pointerDefect(repoRoot, p) {
|
|
189
|
+
const root = resolve(repoRoot);
|
|
190
|
+
const target = resolve(root, p);
|
|
191
|
+
// The repo root itself is its own defect — a pointer at everything — so it is
|
|
192
|
+
// separated from the escapes it otherwise shares a test with.
|
|
193
|
+
if (relative(root, target) === '') return 'root';
|
|
194
|
+
if (outside(root, target)) return 'escapes';
|
|
195
|
+
// Existence before canonicalization, because the two questions are ordered:
|
|
196
|
+
// a path that is not there has no canonical form to compare, and reporting
|
|
197
|
+
// it as an escape would send an author looking for a link that does not
|
|
198
|
+
// exist. A dangling symlink lands here too — statSync follows links, so a
|
|
199
|
+
// link whose target is gone is `missing`, which is what it is.
|
|
200
|
+
if (!statSync(target, { throwIfNoEntry: false })) return 'missing';
|
|
201
|
+
// Then AGAIN on the canonical paths. The lexical test above is necessary but
|
|
202
|
+
// not sufficient: `src/link.ts` is lexically inside the repo while resolving
|
|
203
|
+
// to anywhere at all, so a symlink would carry the whole escape back in
|
|
204
|
+
// through a path that looks contained. Both sides are canonicalized, because
|
|
205
|
+
// the ROOT may itself be reached through a link (a /tmp that is really
|
|
206
|
+
// /private/tmp, which is exactly what macOS hands a test) — comparing a
|
|
207
|
+
// canonical target against a lexical root would then read every ordinary
|
|
208
|
+
// path as an escape.
|
|
209
|
+
const realRoot = realpathOrNull(root);
|
|
210
|
+
const realTarget = realpathOrNull(target);
|
|
211
|
+
// If either cannot be canonicalized the filesystem has declined to answer.
|
|
212
|
+
// The lexical test already passed and the path exists, so the honest reading
|
|
213
|
+
// is to accept it rather than invent a defect from a failed syscall.
|
|
214
|
+
if (realRoot === null || realTarget === null) return null;
|
|
215
|
+
return outside(realRoot, realTarget) ? 'escapes' : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Is `target` outside `root`, or root itself? Both must already be absolute. */
|
|
219
|
+
function outside(root, target) {
|
|
220
|
+
const rel = relative(root, target);
|
|
221
|
+
// `''` is the root itself; a leading `..` climbed out; an absolute result
|
|
222
|
+
// means a different volume entirely.
|
|
223
|
+
return rel === '' || rel.startsWith('..') || isAbsolute(rel);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The canonical path, or null when the filesystem will not say.
|
|
228
|
+
*
|
|
229
|
+
* A pointer this engine cannot canonicalize (a permissions wall on a parent, a
|
|
230
|
+
* race with a concurrent delete) must not crash a validation run that can still
|
|
231
|
+
* answer for every other pointer — the same conduct the resolver's folder test
|
|
232
|
+
* already applies to an unreadable pointer.
|
|
233
|
+
*/
|
|
234
|
+
function realpathOrNull(path) {
|
|
235
|
+
try {
|
|
236
|
+
return realpathSync(path);
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// -------------------------------------------------------------- the checks
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The id grammar each store's CATALOG rows are judged by.
|
|
246
|
+
*
|
|
247
|
+
* Usually the store's own space — a catalog row names an id that store mints.
|
|
248
|
+
* Knowledge stays a distinct entry after UCS-1147 narrowed citations, because a
|
|
249
|
+
* knowledge row is still a POINTER rather than a mint: it is the store's own
|
|
250
|
+
* citation of its leaves, so it is judged by the citation grammar, and its
|
|
251
|
+
* finding carries the citation grammar's hint — the one that names the
|
|
252
|
+
* accession migration. The two grammars accept the same strings today; the
|
|
253
|
+
* table is what keeps the distinction available when they diverge.
|
|
254
|
+
*/
|
|
255
|
+
const CATALOG_ID_SPACE = Object.freeze({
|
|
256
|
+
decisions: 'decisions',
|
|
257
|
+
knowledge: 'leaf-ref',
|
|
258
|
+
ontology: 'ontology',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/** Catalog rows: id grammar (id-shape) + row↔file agreement (index-drift). */
|
|
262
|
+
function checkCatalogs(model, push) {
|
|
263
|
+
// What each loaded file actually contains — the fact the index must match.
|
|
264
|
+
// One id per record now that a leaf answers to one spelling (UCS-1147); the
|
|
265
|
+
// alternate-spelling pass the expand phase needed here left with the alias
|
|
266
|
+
// index, so a row naming a leaf by its retired notation reads as the
|
|
267
|
+
// id-shape finding it is rather than resolving to the file.
|
|
268
|
+
const idsByFile = new Map();
|
|
269
|
+
const addTo = (file, id) => {
|
|
270
|
+
if (typeof file !== 'string' || typeof id !== 'string') return;
|
|
271
|
+
if (!idsByFile.has(file)) idsByFile.set(file, new Set());
|
|
272
|
+
idsByFile.get(file).add(id);
|
|
273
|
+
};
|
|
274
|
+
for (const records of [model.concepts, model.decisions, model.leaves]) {
|
|
275
|
+
for (const entry of records.values()) addTo(entry.file, recordId(entry));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const store of ['decisions', 'knowledge', 'ontology']) {
|
|
279
|
+
const catalog = model.stores[store].catalog;
|
|
280
|
+
if (!isObject(catalog) || !Array.isArray(catalog.entries)) continue; // absent/invalid: loader diagnosed
|
|
281
|
+
// A catalog row is a POINTER at a leaf, not a leaf, so it is judged by the
|
|
282
|
+
// citation grammar rather than the minting one: a row names its leaf the
|
|
283
|
+
// way a cross-reference does, which since UCS-1147 means by accession.
|
|
284
|
+
// Agreement with the pointed-at file is checked below, and that check is
|
|
285
|
+
// what actually ties the row to a leaf — the grammar only decides whether
|
|
286
|
+
// the row names something of a shape a leaf could have.
|
|
287
|
+
const space = CATALOG_ID_SPACE[store];
|
|
288
|
+
const grammar = ID_GRAMMARS[space];
|
|
289
|
+
const pattern = idPattern(space);
|
|
290
|
+
catalog.entries.forEach((row, i) => {
|
|
291
|
+
if (!isObject(row) || typeof row.id !== 'string' || typeof row.file !== 'string') return;
|
|
292
|
+
if (!pattern.test(row.id)) {
|
|
293
|
+
push({
|
|
294
|
+
severity: 'error', code: 'id-shape', id: row.id,
|
|
295
|
+
file: `${store}/_catalog.yaml`, path: `entries[${i}].id`,
|
|
296
|
+
message: `id "${row.id}" violates the ${store} id grammar (${grammar.hint}, §3.5)`,
|
|
297
|
+
});
|
|
298
|
+
return; // a malformed id cannot be meaningfully matched against files
|
|
299
|
+
}
|
|
300
|
+
if (row.file === PENDING_MARKER) {
|
|
301
|
+
push({
|
|
302
|
+
severity: 'warning', code: 'index-drift', id: row.id,
|
|
303
|
+
file: `${store}/_catalog.yaml`, path: `entries[${i}].file`,
|
|
304
|
+
message: `"${row.id}" is declared pending import — resolve the marker to a real file once the record lands`,
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const target = `${store}/${row.file}`;
|
|
309
|
+
if (!model.stores[store].files.includes(target)) {
|
|
310
|
+
push({
|
|
311
|
+
severity: 'error', code: 'index-drift', id: row.id,
|
|
312
|
+
file: `${store}/_catalog.yaml`, path: `entries[${i}].file`,
|
|
313
|
+
message: `catalog names ${target}, which does not exist in the store`,
|
|
314
|
+
});
|
|
315
|
+
} else if (!idsByFile.get(target)?.has(row.id)) {
|
|
316
|
+
push({
|
|
317
|
+
severity: 'error', code: 'index-drift', id: row.id,
|
|
318
|
+
file: `${store}/_catalog.yaml`, path: `entries[${i}].file`,
|
|
319
|
+
message: `catalog id "${row.id}" not found in ${target} — the map must point at the fact it names`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Concepts: class-range membership (id-range) + SSOT existence (missing-path). */
|
|
327
|
+
function checkConcepts(model, push, repoRoot) {
|
|
328
|
+
for (const { id, file, record } of model.concepts.values()) {
|
|
329
|
+
// id-range: the class file's numeric prefix N declares [N, N+99] (§3.5).
|
|
330
|
+
const prefix = /^(\d+)-/.exec(file.split('/').pop());
|
|
331
|
+
const n = Number(id.slice(2));
|
|
332
|
+
if (prefix && Number.isInteger(n)) {
|
|
333
|
+
const lower = Number(prefix[1]);
|
|
334
|
+
if (n < lower || n > lower + 99) {
|
|
335
|
+
push({
|
|
336
|
+
severity: 'error', code: 'id-range', id, file, path: 'id',
|
|
337
|
+
message: `id "${id}" is outside the class range ${lower}..${lower + 99} declared by ${file} (§3.5: ids are minted within class ranges)`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// missing-path: §3.5 deprecated demotes pointer checks to warnings — the
|
|
342
|
+
// escape hatch that lets a source-deletion PR land without dead-ending.
|
|
343
|
+
const severity = record.status === 'deprecated' ? 'warning' : 'error';
|
|
344
|
+
strings(record['source-of-truth']).forEach((p, i) => {
|
|
345
|
+
const defect = pointerDefect(repoRoot, p);
|
|
346
|
+
if (!defect) return;
|
|
347
|
+
push({
|
|
348
|
+
// A pointer that ESCAPES the repo, or names the root, stays
|
|
349
|
+
// error-severity even for a deprecated concept. The §3.5 demotion is
|
|
350
|
+
// an escape hatch for a path that USED to exist and was deleted — it
|
|
351
|
+
// says nothing about a pointer that was never the store's to make, and
|
|
352
|
+
// demoting a malformed claim would let it ship under a warning.
|
|
353
|
+
severity: defect === 'missing' ? severity : 'error',
|
|
354
|
+
code: 'missing-path', id, file, path: `source-of-truth[${i}]`,
|
|
355
|
+
message: POINTER_MESSAGES[defect](p, 'source-of-truth path'),
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Leaf `paths` must exist in the working tree (UCS-1151) — the same check
|
|
363
|
+
* concept source-of-truth pointers already ride, deliberately reusing the same
|
|
364
|
+
* CODE.
|
|
365
|
+
*
|
|
366
|
+
* Three of frontmatter v2's typed edge families are declared in the ref-field
|
|
367
|
+
* table and get `unresolved-ref` for free. `paths` cannot join them, and the
|
|
368
|
+
* reason is worth stating plainly rather than papering over: the ref graph
|
|
369
|
+
* resolves IDS. Its whole question is whether a string is minted in some store's
|
|
370
|
+
* id space, and a repo path is not an id — it names the working tree, which is a
|
|
371
|
+
* different truth anchor (§3.1: the artifact) checked by a different means (the
|
|
372
|
+
* filesystem). Declaring `paths` as a ref row would have asked the loader
|
|
373
|
+
* whether `src/api/handler.ts` resolves to a knowledge entry, which it never
|
|
374
|
+
* could, and every leaf carrying a path would have failed for the wrong reason.
|
|
375
|
+
*
|
|
376
|
+
* So the family gets the HONEST mechanism for what it points at, and it is not a
|
|
377
|
+
* new one: `missing-path` already means exactly this — a declared pointer into
|
|
378
|
+
* the working tree that is not there. Reusing the code keeps one finding class
|
|
379
|
+
* for one defect class, so a steward who has fixed a concept's dead pointer
|
|
380
|
+
* needs nothing new to fix a leaf's.
|
|
381
|
+
*
|
|
382
|
+
* Unlike concepts there is no deprecation demotion. That escape hatch exists so
|
|
383
|
+
* a source-deletion PR can land while a DEPRECATED concept still points at what
|
|
384
|
+
* it deleted (§3.5); a leaf has no `status`, and its `facets.stage` is a
|
|
385
|
+
* promotion lifecycle rather than a retirement one — there is no stage that
|
|
386
|
+
* means "this leaf's pointers are allowed to dangle". Inventing one here would
|
|
387
|
+
* be a governance decision this ticket has no warrant to make.
|
|
388
|
+
*/
|
|
389
|
+
function checkLeafPaths(model, push, repoRoot) {
|
|
390
|
+
for (const entry of model.leaves.values()) {
|
|
391
|
+
const { file, record } = entry;
|
|
392
|
+
strings(record[LEAF_PATHS_FIELD]).forEach((p, i) => {
|
|
393
|
+
const defect = pointerDefect(repoRoot, p);
|
|
394
|
+
if (!defect) return;
|
|
395
|
+
push({
|
|
396
|
+
severity: 'error', code: 'missing-path', id: recordId(entry), file,
|
|
397
|
+
path: `${LEAF_PATHS_FIELD}[${i}]`,
|
|
398
|
+
message: POINTER_MESSAGES[defect](p, 'path'),
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Records the store catalog never declares are unreachable — orphans. */
|
|
405
|
+
function checkOrphans(model, push) {
|
|
406
|
+
const declared = { ontology: new Set(), knowledge: new Set(), decisions: new Set() };
|
|
407
|
+
for (const store of Object.keys(declared)) {
|
|
408
|
+
const catalog = model.stores[store].catalog;
|
|
409
|
+
if (!isObject(catalog) || !Array.isArray(catalog.entries)) continue;
|
|
410
|
+
for (const row of catalog.entries) {
|
|
411
|
+
if (isObject(row) && typeof row.id === 'string') declared[store].add(row.id);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// The third element derives the FINDING path — which field of the record the
|
|
415
|
+
// reported id was read from — not how this check GETS at the id: identity
|
|
416
|
+
// comes through recordId(), so a leaf id space change never reaches this
|
|
417
|
+
// loop (UCS-1142). A leaf's identity is its accession, so that is the field
|
|
418
|
+
// an orphan finding points the author at (UCS-1147).
|
|
419
|
+
const spaces = [
|
|
420
|
+
['ontology', model.concepts, () => 'id'],
|
|
421
|
+
['knowledge', model.leaves, () => LEAF_ACCESSION_FIELD],
|
|
422
|
+
['decisions', model.decisions, () => 'id'],
|
|
423
|
+
];
|
|
424
|
+
for (const [store, records, idPath] of spaces) {
|
|
425
|
+
if (!model.stores[store].catalog) continue; // no catalog loaded: loader diagnosed
|
|
426
|
+
for (const [id, entry] of records) {
|
|
427
|
+
if (!declared[store].has(id)) {
|
|
428
|
+
push({
|
|
429
|
+
severity: 'error', code: 'orphan', id, file: entry.file, path: idPath(entry),
|
|
430
|
+
message: `"${id}" is not declared in ${store}/_catalog.yaml — unreachable through the store's navigational entry point (§3)`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ------------------------------------------- registry membership (UCS-1148)
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Which governed vocabulary each facet field draws from (UCS-1148).
|
|
441
|
+
*
|
|
442
|
+
* A DECLARATION, in the same spirit as the loader's REF_FIELDS: "this field's
|
|
443
|
+
* values must be minted in that registry" is a fact about the store shape, and
|
|
444
|
+
* facts about the store shape belong in a table every surface reads. Adding a
|
|
445
|
+
* governed facet is adding a row — there is no per-facet branch in the checker
|
|
446
|
+
* to extend, which is what lets frontmatter v2 (UCS-1149) declare the rest of
|
|
447
|
+
* its facets here rather than growing a second membership code path.
|
|
448
|
+
*
|
|
449
|
+
* Each row:
|
|
450
|
+
* field a path of object keys into the record, dotted. The path may end
|
|
451
|
+
* at a single string or at an ARRAY of strings; `each` says which
|
|
452
|
+
* each true when the field holds an array, so every member is checked
|
|
453
|
+
* and the finding path carries the index the author can point at
|
|
454
|
+
* registry "<store>/<name>" — the registry key, matching the loader's index
|
|
455
|
+
* within an optional path prefix, so a field nested inside a repeated
|
|
456
|
+
* sub-record (a citation) is declared once rather than per index
|
|
457
|
+
* hierarchical
|
|
458
|
+
* the registry SHAPE this facet requires. Declared, and checked
|
|
459
|
+
* against the registry's own flag: see registryShapeMismatch
|
|
460
|
+
* blankOwnedBy
|
|
461
|
+
* the check that owns this field's ABSENCE, when one exists. A
|
|
462
|
+
* blank value is then that check's to report and membership defers,
|
|
463
|
+
* so one omission earns one finding. Absent means membership judges
|
|
464
|
+
* blanks itself — the safe default, since a field whose emptiness
|
|
465
|
+
* nobody checks would otherwise pass governed-but-unchecked
|
|
466
|
+
*
|
|
467
|
+
* Frozen all the way down: a mutated row would silently redirect a facet at a
|
|
468
|
+
* different vocabulary, which is a governed store quietly ungoverned.
|
|
469
|
+
*
|
|
470
|
+
* @type {Readonly<Record<string, ReadonlyArray<Readonly<object>>>>}
|
|
471
|
+
*/
|
|
472
|
+
export const FACET_REGISTRIES = Object.freeze({
|
|
473
|
+
'knowledge-leaf': Object.freeze([
|
|
474
|
+
Object.freeze({ field: 'facets.domain', registry: 'knowledge/domains', hierarchical: true }),
|
|
475
|
+
// Frontmatter v2's remaining classification facets (UCS-1149). Each is a
|
|
476
|
+
// ROW, which is the whole point of the table: three more governed fields
|
|
477
|
+
// cost three declarations and no second membership code path.
|
|
478
|
+
Object.freeze({ field: 'facets.form', registry: 'knowledge/form', hierarchical: false }),
|
|
479
|
+
Object.freeze({ field: 'facets.anchor', registry: 'knowledge/anchor', hierarchical: false }),
|
|
480
|
+
Object.freeze({ field: 'facets.stage', registry: 'knowledge/stage', hierarchical: false }),
|
|
481
|
+
Object.freeze({ field: 'operations', each: true, registry: 'knowledge/operations', hierarchical: false }),
|
|
482
|
+
Object.freeze({ field: 'applies.jurisdictions', each: true, registry: 'knowledge/jurisdictions', hierarchical: false }),
|
|
483
|
+
// `blankOwnedBy` names the check that OWNS this field's absence. Only
|
|
484
|
+
// `authority` has one (`missing-authority`), so only `authority` may leave
|
|
485
|
+
// a blank value to it; every other governed field judges a blank itself as
|
|
486
|
+
// an unregistered value, because otherwise `stage: ""` would pass silently.
|
|
487
|
+
Object.freeze({
|
|
488
|
+
within: 'citations', field: 'authority', registry: 'knowledge/authority-tiers',
|
|
489
|
+
hierarchical: false, blankOwnedBy: 'missing-authority',
|
|
490
|
+
}),
|
|
491
|
+
]),
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Which model collection each governed record kind is indexed in.
|
|
496
|
+
*
|
|
497
|
+
* The one place the checker learns that `knowledge-leaf` records live in
|
|
498
|
+
* `model.leaves`. Without it the kind and the collection were spelled at two
|
|
499
|
+
* call sites apiece, so a second governed kind — which UCS-1149 brings — would
|
|
500
|
+
* have meant editing the walker rather than the tables it reads.
|
|
501
|
+
*
|
|
502
|
+
* Keyed by the same kind strings FACET_REGISTRIES uses, so the two tables are
|
|
503
|
+
* read together and a kind declared in one but missing from the other is
|
|
504
|
+
* refused at load rather than silently unchecked (assertGovernedKinds).
|
|
505
|
+
*
|
|
506
|
+
* @type {Readonly<Record<string, string>>}
|
|
507
|
+
*/
|
|
508
|
+
export const GOVERNED_COLLECTIONS = Object.freeze({
|
|
509
|
+
'knowledge-leaf': 'leaves',
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Refuse a facet table naming a kind whose records the checker cannot reach —
|
|
514
|
+
* an engine failure at load, never a silent pass.
|
|
515
|
+
*
|
|
516
|
+
* A row declared for a kind with no collection would govern nothing: the
|
|
517
|
+
* facet would look governed in the table and be unchecked in every store,
|
|
518
|
+
* which is the failure class this engine exists to prevent (PRD §5).
|
|
519
|
+
*
|
|
520
|
+
* @param {Record<string, unknown>} table the facet declaration table
|
|
521
|
+
* @throws {Error} if a declared kind has no model collection
|
|
522
|
+
*/
|
|
523
|
+
export function assertGovernedKinds(table) {
|
|
524
|
+
for (const kind of Object.keys(table)) {
|
|
525
|
+
if (!GOVERNED_COLLECTIONS[kind]) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`facet table declares governed kind "${kind}", which maps to no model collection — `
|
|
528
|
+
+ 'its rows would look governed and be checked in no store; add it to GOVERNED_COLLECTIONS',
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Refuse a facet table whose rows disagree about one registry's SHAPE — an
|
|
536
|
+
* engine failure at load, never a silent pass.
|
|
537
|
+
*
|
|
538
|
+
* The shape check runs once per registry, because a mismatch is a fact about
|
|
539
|
+
* the registry rather than about any record that drew from it. That
|
|
540
|
+
* de-duplication is only sound while every row naming a registry agrees about
|
|
541
|
+
* its shape: if two rows disagreed, the first one seen would settle the
|
|
542
|
+
* question and the second would be checked against a shape it never asked for
|
|
543
|
+
* — a facet silently governed by the wrong rule, which is the exact failure
|
|
544
|
+
* `registry-shape-mismatch` exists to catch one level down.
|
|
545
|
+
*
|
|
546
|
+
* The contradiction cannot be resolved here either. One registry is either
|
|
547
|
+
* hierarchical or flat; two facets needing it both ways need two registries,
|
|
548
|
+
* and picking a winner by declaration order would be an arbitrary answer to a
|
|
549
|
+
* question the table asked wrongly.
|
|
550
|
+
*
|
|
551
|
+
* @param {Record<string, ReadonlyArray<{ registry: string, hierarchical?: boolean }>>} table
|
|
552
|
+
* @throws {Error} if two rows name one registry with different shapes
|
|
553
|
+
*/
|
|
554
|
+
export function assertConsistentRegistryShapes(table) {
|
|
555
|
+
const expected = new Map(); // registry key -> { hierarchical, kind, field }
|
|
556
|
+
for (const [kind, rows] of Object.entries(table)) {
|
|
557
|
+
for (const row of rows) {
|
|
558
|
+
const previous = expected.get(row.registry);
|
|
559
|
+
if (previous === undefined) {
|
|
560
|
+
expected.set(row.registry, { hierarchical: row.hierarchical, kind, field: row.field });
|
|
561
|
+
} else if (previous.hierarchical !== row.hierarchical) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`facet table declares registry "${row.registry}" as both hierarchical=${previous.hierarchical} `
|
|
564
|
+
+ `(${previous.kind}.${previous.field}) and hierarchical=${row.hierarchical} (${kind}.${row.field}) — `
|
|
565
|
+
+ 'one registry has one shape, so one of these facets would be governed by a rule it never asked for; '
|
|
566
|
+
+ 'a facet needing the other shape needs its own registry',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Both checked as this module loads: a facet governed by nothing, or governed
|
|
574
|
+
// against a shape another row settled, is a defect in the kit itself. Each must
|
|
575
|
+
// surface the moment it is introduced rather than as a store that quietly
|
|
576
|
+
// passes checks it never ran.
|
|
577
|
+
assertGovernedKinds(FACET_REGISTRIES);
|
|
578
|
+
assertConsistentRegistryShapes(FACET_REGISTRIES);
|
|
579
|
+
|
|
580
|
+
/** Follow a dotted path into a record; undefined if any segment is missing. */
|
|
581
|
+
function valueAtPath(record, field) {
|
|
582
|
+
let node = record;
|
|
583
|
+
for (const segment of field.split('.')) {
|
|
584
|
+
if (!isObject(node)) return undefined;
|
|
585
|
+
node = node[segment];
|
|
586
|
+
}
|
|
587
|
+
return node;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The registry SHAPE a facet requires, checked against the shape the registry
|
|
592
|
+
* declares — a silent disagreement here disables the rule it governs.
|
|
593
|
+
*
|
|
594
|
+
* `facets.domain` is hierarchical: its whole membership rule is that every
|
|
595
|
+
* segment of a path is minted. That rule lives behind `registry.hierarchical`,
|
|
596
|
+
* so a domains registry that lost its `hierarchical: true` line would judge a
|
|
597
|
+
* two-segment path as one opaque string and pass it the moment that exact
|
|
598
|
+
* string appeared in the file — the segment rule switched off by an omission,
|
|
599
|
+
* at exit 0, with nothing said. The converse is as bad: a flat registry
|
|
600
|
+
* declaring itself hierarchical would split values on '/' and demand parents
|
|
601
|
+
* nobody meant to mint.
|
|
602
|
+
*
|
|
603
|
+
* Neither direction is a defect the value-level checks can see, because both
|
|
604
|
+
* produce a coherent-looking verdict about the wrong question. So the shape is
|
|
605
|
+
* declared on both sides and the disagreement is the finding: reported ONCE per
|
|
606
|
+
* registry, against the registry file, because the registry is what must change
|
|
607
|
+
* — reporting it per value would bury one edit under a finding for every leaf.
|
|
608
|
+
*
|
|
609
|
+
* @param {object} registry the loaded registry
|
|
610
|
+
* @param {boolean|undefined} expected the shape the declaration requires
|
|
611
|
+
* @returns {{ code: string, message: string }|null}
|
|
612
|
+
*/
|
|
613
|
+
function registryShapeMismatch(registry, expected, registryKey) {
|
|
614
|
+
if (expected === undefined || registry.hierarchical === expected) return null;
|
|
615
|
+
return {
|
|
616
|
+
code: 'registry-shape-mismatch',
|
|
617
|
+
message: expected
|
|
618
|
+
? `the "${registryKey}" registry must be hierarchical — the facet it governs is a '/'-joined path whose every segment must be minted, and a flat registry would judge the whole path as one opaque string, silently disabling that rule. Add "hierarchical: true" to ${registry.file}`
|
|
619
|
+
: `the "${registryKey}" registry declares itself hierarchical, but the facet it governs is a flat value — path splitting would demand parent values nobody minted. Remove "hierarchical: true" from ${registry.file}`,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Judge one facet value against its registry — the whole membership rule.
|
|
625
|
+
*
|
|
626
|
+
* Returns the finding this value earns, or null when it is legitimately
|
|
627
|
+
* minted. Four outcomes, and each one names both the value and the registry,
|
|
628
|
+
* because a finding that says only "unknown value" leaves the author guessing
|
|
629
|
+
* which of four vocabularies to go read:
|
|
630
|
+
*
|
|
631
|
+
* - the registry did not load at all → `missing-registry`. This is where
|
|
632
|
+
* registry ABSENCE surfaces, and it surfaces HERE rather than at load
|
|
633
|
+
* because absence is only meaningful once a record actually claims a
|
|
634
|
+
* governed value. A store that governs nothing is complete; a store whose
|
|
635
|
+
* leaf cites a domain with no domains registry is a check that never ran.
|
|
636
|
+
* - the value is minted → clean.
|
|
637
|
+
* - the value is SUPPRESSED → `suppressed-value`, quoting the refusal. A
|
|
638
|
+
* term that was considered and rejected must not read as a typo: the
|
|
639
|
+
* author needs to know the vocabulary decision already went against them.
|
|
640
|
+
* - a hierarchical path with an unminted segment → `unminted-segment`,
|
|
641
|
+
* naming the SEGMENT rather than the whole path. "trading/derivatives/swaps
|
|
642
|
+
* is not in the registry" sends an author looking for the wrong edit; the
|
|
643
|
+
* finding they can act on is which parent is missing.
|
|
644
|
+
* - otherwise → `unregistered-value`.
|
|
645
|
+
*
|
|
646
|
+
* @param {object} registry the loaded registry, or undefined if absent
|
|
647
|
+
* @param {string} registryKey the "<store>/<name>" the row declared
|
|
648
|
+
* @param {string} value the facet value as the record spells it
|
|
649
|
+
* @returns {{ code: string, message: string }|null}
|
|
650
|
+
*/
|
|
651
|
+
function judgeValue(registry, registryKey, value) {
|
|
652
|
+
if (!registry) {
|
|
653
|
+
return {
|
|
654
|
+
code: 'missing-registry',
|
|
655
|
+
message: `value "${value}" is governed by the "${registryKey}" registry, which the store does not carry — the vocabulary this value must be minted in never loaded, so its membership was never checked (a check that never ran is a blocking defect, PRD §5)`,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
const where = `the "${registryKey}" registry (${registry.file})`;
|
|
659
|
+
if (registry.suppressed.has(value)) {
|
|
660
|
+
return {
|
|
661
|
+
code: 'suppressed-value',
|
|
662
|
+
message: `value "${value}" is SUPPRESSED in ${where} — this term was proposed and refused, and a suppression is durable: re-minting it is a registry edit with its own Decisions entry, never a quiet reuse`,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (registry.hierarchical) {
|
|
666
|
+
// Every segment above a child must itself be minted: a hierarchy where a
|
|
667
|
+
// child may hang off an unminted parent is not a hierarchy, it is a set of
|
|
668
|
+
// strings that happen to contain slashes.
|
|
669
|
+
const segments = value.split('/');
|
|
670
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
671
|
+
const path = segments.slice(0, i + 1).join('/');
|
|
672
|
+
if (registry.suppressed.has(path)) {
|
|
673
|
+
return {
|
|
674
|
+
code: 'suppressed-value',
|
|
675
|
+
message: `value "${value}" descends from "${path}", which is SUPPRESSED in ${where} — a refused class mints no children`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
if (!registry.minted.has(path)) {
|
|
679
|
+
return {
|
|
680
|
+
code: 'unminted-segment',
|
|
681
|
+
message: `value "${value}" is invalid: the segment "${path}" is not minted in ${where} — every segment of a hierarchical path must be minted before a child may hang off it, and each minting is a registry edit plus a Decisions entry (literary warrant: material must exist to fill it)`,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
if (registry.minted.has(value)) return null;
|
|
688
|
+
return {
|
|
689
|
+
code: 'unregistered-value',
|
|
690
|
+
message: `value "${value}" is not minted in ${where} — governed facets draw only from their registry; minting a new value is a registry edit plus a Decisions entry, never an ad-hoc string`,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Every governed facet value is minted in its registry (UCS-1148).
|
|
696
|
+
*
|
|
697
|
+
* Generic over FACET_REGISTRIES and GOVERNED_COLLECTIONS: this function knows
|
|
698
|
+
* how to walk a declared path and how to judge a value, and nothing about which
|
|
699
|
+
* kinds or facets exist. That is the seam UCS-1149 extends by declaration —
|
|
700
|
+
* a second governed record kind is two table entries, not an edit here.
|
|
701
|
+
*/
|
|
702
|
+
function checkRegistryMembership(model, push) {
|
|
703
|
+
// Shape first, once per registry rather than once per record: a registry
|
|
704
|
+
// whose shape disagrees with the facet it governs is judging the wrong
|
|
705
|
+
// question, so the disagreement is reported against the registry file rather
|
|
706
|
+
// than against every record that drew from it. De-duplicated across kinds,
|
|
707
|
+
// since two kinds may legitimately draw on one registry — sound because
|
|
708
|
+
// assertConsistentRegistryShapes refused at load any table whose rows
|
|
709
|
+
// disagree about a registry's shape, so whichever row is seen first here
|
|
710
|
+
// speaks for all of them.
|
|
711
|
+
const shapeChecked = new Set();
|
|
712
|
+
for (const rows of Object.values(FACET_REGISTRIES)) {
|
|
713
|
+
for (const { registry: registryKey, hierarchical } of rows) {
|
|
714
|
+
if (shapeChecked.has(registryKey)) continue;
|
|
715
|
+
shapeChecked.add(registryKey);
|
|
716
|
+
const registry = model.registries.get(registryKey);
|
|
717
|
+
if (!registry) continue; // absence is the per-value missing-registry finding
|
|
718
|
+
const mismatch = registryShapeMismatch(registry, hierarchical, registryKey);
|
|
719
|
+
if (mismatch) {
|
|
720
|
+
push({
|
|
721
|
+
severity: 'error', code: mismatch.code, id: registryKey,
|
|
722
|
+
file: registry.file, path: 'hierarchical', message: mismatch.message,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
for (const [kind, rows] of Object.entries(FACET_REGISTRIES)) {
|
|
728
|
+
// Non-null by construction: assertGovernedKinds refused the table at load
|
|
729
|
+
// if any declared kind lacked a collection.
|
|
730
|
+
for (const entry of model[GOVERNED_COLLECTIONS[kind]].values()) {
|
|
731
|
+
checkOneRecord(model, push, entry, rows);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Judge one record's governed facets against the rows its kind declares. */
|
|
737
|
+
function checkOneRecord(model, push, entry, rows) {
|
|
738
|
+
const { file, record } = entry;
|
|
739
|
+
const id = recordId(entry);
|
|
740
|
+
for (const { within, field, each, registry: registryKey, blankOwnedBy } of rows) {
|
|
741
|
+
const registry = model.registries.get(registryKey);
|
|
742
|
+
// A row may sit inside a repeated sub-record (citations[]); declaring the
|
|
743
|
+
// container once keeps the table free of per-index rows.
|
|
744
|
+
const container = within ? valueAtPath(record, within) : null;
|
|
745
|
+
const hosts = within
|
|
746
|
+
? (Array.isArray(container)
|
|
747
|
+
? container.map((host, i) => [host, `${within}[${i}]`])
|
|
748
|
+
: [])
|
|
749
|
+
: [[record, '']];
|
|
750
|
+
for (const [host, hostPath] of hosts) {
|
|
751
|
+
if (!isObject(host)) continue;
|
|
752
|
+
const raw = valueAtPath(host, field);
|
|
753
|
+
// Non-strings are already diagnosed by KK-02's schema check; a second
|
|
754
|
+
// complaint here would double-report one defect.
|
|
755
|
+
const values = each
|
|
756
|
+
? (Array.isArray(raw) ? raw.map((v, i) => [v, `${field}[${i}]`]) : [])
|
|
757
|
+
: [[raw, field]];
|
|
758
|
+
for (const [value, valuePath] of values) {
|
|
759
|
+
if (typeof value !== 'string') continue;
|
|
760
|
+
// A BLANK value is deferred ONLY when another check owns this field's
|
|
761
|
+
// absence — declared as `blankOwnedBy`, which today is just
|
|
762
|
+
// `citations[].authority` and its `missing-authority` check. There,
|
|
763
|
+
// reporting `unregistered-value ""` too would put a second finding on a
|
|
764
|
+
// path that already carries the one an author can act on.
|
|
765
|
+
//
|
|
766
|
+
// Everywhere else a blank is judged HERE, and that asymmetry is the
|
|
767
|
+
// point: no check owns `facets.stage: ""`, so skipping it would make an
|
|
768
|
+
// empty governed facet pass silently at exit 0 — a governed field
|
|
769
|
+
// ungoverned by an empty string, which is worse than double-reporting.
|
|
770
|
+
// The registry does not mint "", so it falls out as unregistered.
|
|
771
|
+
if (value.trim() === '' && blankOwnedBy) continue;
|
|
772
|
+
const verdict = judgeValue(registry, registryKey, value);
|
|
773
|
+
if (!verdict) continue;
|
|
774
|
+
push({
|
|
775
|
+
severity: 'error', code: verdict.code, id, file,
|
|
776
|
+
path: hostPath ? `${hostPath}.${valuePath}` : valuePath,
|
|
777
|
+
message: verdict.message,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Leaf citations must carry a non-empty source (§3.2) and an authority tier
|
|
786
|
+
* (UCS-1149).
|
|
787
|
+
*
|
|
788
|
+
* The two failures are separate codes because they are separate defects. An
|
|
789
|
+
* empty `source` is an unsourced claim. A missing `authority` is a sourced
|
|
790
|
+
* claim that records nothing about HOW FAR the source can be trusted — a
|
|
791
|
+
* regulator's text and a hallway conversation read identically once the tier
|
|
792
|
+
* is gone, and a reader comparing two leaves that disagree has nothing to go
|
|
793
|
+
* on but the source strings themselves.
|
|
794
|
+
*
|
|
795
|
+
* Note what this check does and does not claim. It governs the tier as
|
|
796
|
+
* VOCABULARY: present, and minted in the registry. Nothing in this engine
|
|
797
|
+
* compares two tiers or resolves a conflict between citations today —
|
|
798
|
+
* automatic conflict ranking arrives with the resolution pipeline (UCS-1152).
|
|
799
|
+
* Requiring the tier now is what makes that possible later: a tier nobody
|
|
800
|
+
* recorded cannot be ranked retroactively.
|
|
801
|
+
*
|
|
802
|
+
* Absence is checked here; a tier naming a value the registry does not carry is
|
|
803
|
+
* the ordinary `unregistered-value` finding the facet table already declares
|
|
804
|
+
* (`{ within: 'citations', field: 'authority' }`). Two codes, because "you left
|
|
805
|
+
* it out" and "that tier does not exist" send an author to two different edits
|
|
806
|
+
* — and neither is checked by the other: the membership walk skips a value that
|
|
807
|
+
* is not a string, which is exactly what an absent field is.
|
|
808
|
+
*
|
|
809
|
+
* The tier requirement is gated on the store CARRYING an authority-tiers
|
|
810
|
+
* registry, which is UCS-1148's opt-in conduct applied one level up rather than
|
|
811
|
+
* a softening of it. A store with no such registry has no vocabulary to draw a
|
|
812
|
+
* tier from, so demanding one would be demanding a value that could only be
|
|
813
|
+
* unregistered — every leaf failing twice for one thing the store never opted
|
|
814
|
+
* into. Once the registry exists the project HAS said tiers govern its
|
|
815
|
+
* citations, and an untiered citation is then a real omission. The escalation
|
|
816
|
+
* is the steward's, made by adding the file, and it is exactly the escalation
|
|
817
|
+
* `missing-registry` refuses to let happen silently in the other direction.
|
|
818
|
+
*/
|
|
819
|
+
function checkCitations(model, push) {
|
|
820
|
+
const tiersGoverned = model.registries.has('knowledge/authority-tiers');
|
|
821
|
+
for (const leaf of model.leaves.values()) {
|
|
822
|
+
const { file, record } = leaf;
|
|
823
|
+
if (!Array.isArray(record.citations)) continue; // presence is a schema check
|
|
824
|
+
record.citations.forEach((c, i) => {
|
|
825
|
+
if (!isObject(c)) return;
|
|
826
|
+
if (typeof c.source === 'string' && c.source.trim() === '') {
|
|
827
|
+
push({
|
|
828
|
+
severity: 'error', code: 'missing-citation', id: recordId(leaf), file,
|
|
829
|
+
path: `citations[${i}].source`,
|
|
830
|
+
message: 'citation source is empty — an unsourced claim is not promotable (§3.2)',
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
// A non-string authority is a schema defect KK-02 already reported;
|
|
834
|
+
// complaining again here would double-report one mistake.
|
|
835
|
+
if (tiersGoverned
|
|
836
|
+
&& (c.authority === undefined || (typeof c.authority === 'string' && c.authority.trim() === ''))) {
|
|
837
|
+
push({
|
|
838
|
+
severity: 'error', code: 'missing-authority', id: recordId(leaf), file,
|
|
839
|
+
path: `citations[${i}].authority`,
|
|
840
|
+
message: 'citation carries no authority tier — nothing records how far this source can be trusted, so a regulator\'s text and a hallway conversation read identically; name a tier minted in the "knowledge/authority-tiers" registry (UCS-1149)',
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* The Time facet's presence check (UCS-1150) — a leaf under time governance
|
|
849
|
+
* must carry a usable `verified` date.
|
|
850
|
+
*
|
|
851
|
+
* This is the validator's whole share of the time facet, and it is deliberately
|
|
852
|
+
* DATE-FREE: it asks whether the leaf can be judged, never whether it is stale.
|
|
853
|
+
* Staleness needs an injected `today` and belongs to the surfaces that project
|
|
854
|
+
* verdicts; asking it here would put a wall-clock-dependent finding into the
|
|
855
|
+
* baseline finding set, and diffing that set against a baseline is exactly what
|
|
856
|
+
* D-012 protects. So this check's output is identical on every run forever,
|
|
857
|
+
* whatever day it is.
|
|
858
|
+
*
|
|
859
|
+
* Two findings, because two different things are wrong:
|
|
860
|
+
*
|
|
861
|
+
* missing-verified a leaf declaring a NON-STATIC volatility with no
|
|
862
|
+
* `verified` date. It has asked to be governed by time
|
|
863
|
+
* and given nothing to measure from, so its verdict can
|
|
864
|
+
* only ever be `undated` — a leaf that can never be
|
|
865
|
+
* trusted and never be stale, sitting in the store
|
|
866
|
+
* looking governed.
|
|
867
|
+
* malformed-verified a date that matches the schema's pattern but names no
|
|
868
|
+
* real day. `2026-02-30` is shaped like a date and rolls
|
|
869
|
+
* forward to March 2nd under `Date.parse`, so an age
|
|
870
|
+
* measured from it is off by two days with nothing said —
|
|
871
|
+
* the defect `isCalendarDate` was written for (UCS-957).
|
|
872
|
+
* The schema checks the shape; the calendar is checked
|
|
873
|
+
* here, where the finding can name the file and field.
|
|
874
|
+
*
|
|
875
|
+
* A STATIC leaf with no date is CLEAN, and that asymmetry is the point. Static
|
|
876
|
+
* knowledge never stales, so a date on it would measure an age nothing consumes
|
|
877
|
+
* — demanding one would be demanding a field with no reader, which is how a
|
|
878
|
+
* store fills up with ritual metadata nobody maintains. A static leaf that DOES
|
|
879
|
+
* carry a date still has it checked for the calendar: an author who wrote one
|
|
880
|
+
* meant it, and a malformed one is a mistake whether or not anything measures
|
|
881
|
+
* from it.
|
|
882
|
+
*
|
|
883
|
+
* A leaf declaring NO volatility is clean too — absent means exempt from time
|
|
884
|
+
* governance (see the schema), so there is nothing to be missing. That is what
|
|
885
|
+
* keeps a store mid-migration from failing validation on every un-migrated leaf
|
|
886
|
+
* the day this ticket lands.
|
|
887
|
+
*/
|
|
888
|
+
function checkVerifiedDates(model, push) {
|
|
889
|
+
for (const leaf of model.leaves.values()) {
|
|
890
|
+
const { file, record } = leaf;
|
|
891
|
+
// Read through the module's own reader rather than testing membership here.
|
|
892
|
+
// A second spelling of "is this a known class" is a second chance to get it
|
|
893
|
+
// wrong — `volatility in VOLATILITY_LIMITS` would answer TRUE for inherited
|
|
894
|
+
// names like `toString`, and this check would then demand a date from a
|
|
895
|
+
// class that has no threshold. One reader, one answer.
|
|
896
|
+
//
|
|
897
|
+
// Null covers both a non-string volatility and an unknown class. Either is
|
|
898
|
+
// a schema defect already reported, and judging the date on top of it would
|
|
899
|
+
// be a second finding for one mistake.
|
|
900
|
+
const volatility = leafVolatility(record);
|
|
901
|
+
if (volatility === null) continue;
|
|
902
|
+
const verified = record[VERIFIED_FIELD];
|
|
903
|
+
|
|
904
|
+
if (verified === undefined) {
|
|
905
|
+
// Static never stales, so it needs no date to measure from.
|
|
906
|
+
if (volatility === 'static') continue;
|
|
907
|
+
push({
|
|
908
|
+
severity: 'error', code: 'missing-verified', id: recordId(leaf), file,
|
|
909
|
+
path: VERIFIED_FIELD,
|
|
910
|
+
message: `${volatility} knowledge carries no "${VERIFIED_FIELD}" date — it stales after ${VOLATILITY_LIMITS[volatility]} days and nothing records when it was last checked, so its freshness can never be computed (UCS-1150)`,
|
|
911
|
+
});
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
// A non-string date is a schema defect (KK-02) — do not double-report.
|
|
915
|
+
if (typeof verified !== 'string') continue;
|
|
916
|
+
if (!isCalendarDate(verified)) {
|
|
917
|
+
push({
|
|
918
|
+
severity: 'error', code: 'malformed-verified', id: recordId(leaf), file,
|
|
919
|
+
path: VERIFIED_FIELD,
|
|
920
|
+
message: `"${VERIFIED_FIELD}: ${verified}" is not a real calendar date — an age measured from a day that does not exist is a number no calendar agrees with (YYYY-MM-DD, UCS-957)`,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Decision supersedes chains must be acyclic (§3.3). One finding per cycle. */
|
|
927
|
+
/**
|
|
928
|
+
* Every edition bump must be sanctioned by a phoenix event (UCS-1154).
|
|
929
|
+
*
|
|
930
|
+
* `edition` is not an ordinary field an author may increment. A phoenix event
|
|
931
|
+
* is THE ONLY thing that bumps it in v2, so a leaf at edition > 1 is making a
|
|
932
|
+
* claim — "a governed bulk re-taxonomy moved me" — and this check holds it to
|
|
933
|
+
* that claim. Without it the field would be a number anyone could type, and
|
|
934
|
+
* "the edition tells you a phoenix event happened" would be a convention rather
|
|
935
|
+
* than a fact.
|
|
936
|
+
*
|
|
937
|
+
* Checkable from the WORKING TREE ALONE, which is why the event mappings are
|
|
938
|
+
* retained in `knowledge/_phoenix/` rather than living only in the PR that
|
|
939
|
+
* applied them. The validator sees a fresh clone with no git history, and a
|
|
940
|
+
* governance rule it could only verify by reading commits is a rule it cannot
|
|
941
|
+
* verify at all.
|
|
942
|
+
*
|
|
943
|
+
* A carried-forward row does not count: it records that a leaf was considered
|
|
944
|
+
* and deliberately left alone, so it cannot also be the warrant for an edition
|
|
945
|
+
* that says the leaf changed.
|
|
946
|
+
*/
|
|
947
|
+
function checkEditions(model, push) {
|
|
948
|
+
for (const { id, file, edition, events, expected } of unaccountedEditions(model)) {
|
|
949
|
+
const sanctioned = events.length
|
|
950
|
+
? `${events.length} retained phoenix event(s) move it (${events.join(', ')}), so it should be at ${expected}`
|
|
951
|
+
: `no retained phoenix event moves it, so it should be at ${FIRST_EDITION}`;
|
|
952
|
+
push({
|
|
953
|
+
severity: 'error', code: 'unaccounted-edition', id, file, path: EDITION_FIELD,
|
|
954
|
+
message: `leaf "${id}" is at ${EDITION_FIELD} ${edition}, but ${sanctioned} — an edition COUNTS the governed re-taxonomies that moved a leaf, and a phoenix event is the only thing that bumps it, so a number that matches no mapping is one nobody sanctioned (UCS-1154)`,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Every graduation or revocation entry is held against the category table
|
|
961
|
+
* (UCS-1155).
|
|
962
|
+
*
|
|
963
|
+
* This is the machine-checkable half of trust graduation, and it is worth being
|
|
964
|
+
* precise about which half that is. The engine does NOT compute approved-
|
|
965
|
+
* unmodified counts, does not decide whether a threshold was met, and does not
|
|
966
|
+
* grant or withdraw autonomy: v1's analytics are MANUAL, and the moderator
|
|
967
|
+
* judges the recorded counts themselves (steward-guide.md). What is checked is
|
|
968
|
+
* that the recorded artifacts are well-formed and consistent with the governed
|
|
969
|
+
* table — which is exactly the part a human reviewer cannot reliably do by eye
|
|
970
|
+
* across a growing decisions store.
|
|
971
|
+
*
|
|
972
|
+
* Three findings, because three different things are wrong:
|
|
973
|
+
*
|
|
974
|
+
* missing-graduation-table
|
|
975
|
+
* an entry moves the trust boundary in a store carrying no
|
|
976
|
+
* category table at all. Absence surfaces HERE, at the point a
|
|
977
|
+
* record actually needs the table, for the same reason
|
|
978
|
+
* `missing-registry` does: a store that governs nothing is
|
|
979
|
+
* complete, but a graduation judged against a table that never
|
|
980
|
+
* loaded is a check that never ran (PRD §5).
|
|
981
|
+
* undeclared-category
|
|
982
|
+
* the entry names a category the table does not declare. Autonomy
|
|
983
|
+
* is per category, so a graduation for a category nobody scoped is
|
|
984
|
+
* autonomy with no declared extent — nothing says what class of
|
|
985
|
+
* change it covers, and nothing could later revoke it by name.
|
|
986
|
+
* gated-category-graduation
|
|
987
|
+
* a graduation for a PERMANENTLY GATED category. This is the one
|
|
988
|
+
* the whole mechanism exists to refuse: the gated list is where
|
|
989
|
+
* judgment lives (new domain classes, contradicts/supersedes
|
|
990
|
+
* edges, authority assignments, anything citation-bearing), and a
|
|
991
|
+
* judgment call does not become mechanical by having been made
|
|
992
|
+
* correctly N times. Revoking a gated category is NOT a finding —
|
|
993
|
+
* a revocation only ever narrows autonomy, and refusing to record
|
|
994
|
+
* one would be refusing the safe direction.
|
|
995
|
+
* graduation-not-trust-category
|
|
996
|
+
* an entry carrying a graduation block but filed under some other
|
|
997
|
+
* decision category. `trust` is the category that says the third
|
|
998
|
+
* store governs the trust boundary (PRD §3); an entry filed
|
|
999
|
+
* elsewhere moves that boundary invisibly to anyone auditing it
|
|
1000
|
+
* by category, which is how a steward reads the store
|
|
1001
|
+
* disconnected-revocation
|
|
1002
|
+
* a revocation whose link to the graduation it withdraws is
|
|
1003
|
+
* missing or wrong: it names no `revokes` WHILE a graduation for
|
|
1004
|
+
* the same category stands, or its `revokes` names an entry that
|
|
1005
|
+
* is not a graduation, or one that graduates a DIFFERENT category.
|
|
1006
|
+
* Omission is clean when no graduation exists — the
|
|
1007
|
+
* standing-position case has nothing to point at — and an
|
|
1008
|
+
* unresolved id is the ref graph's to report
|
|
1009
|
+
* graduation-field-shape
|
|
1010
|
+
* the block's fields disagree with its own action: a graduation
|
|
1011
|
+
* with no `observed-cycles` (the only record of what was counted,
|
|
1012
|
+
* since v1 computes nothing) or carrying withdrawal fields, or a
|
|
1013
|
+
* revocation naming no `defect` (the automatic trigger)
|
|
1014
|
+
*/
|
|
1015
|
+
/**
|
|
1016
|
+
* Which `graduation:` fields each action requires and which it refuses.
|
|
1017
|
+
*
|
|
1018
|
+
* The schema states these in prose because it cannot state them in keywords —
|
|
1019
|
+
* there is no conditional in the subset this engine interprets. Enforcing them
|
|
1020
|
+
* here is what keeps that prose from being decoration.
|
|
1021
|
+
*
|
|
1022
|
+
* Each rule earns its place:
|
|
1023
|
+
*
|
|
1024
|
+
* graduate REQUIRES `observed-cycles` — the recorded count is the entire
|
|
1025
|
+
* reviewable basis of the judgment. v1 computes nothing, so a graduation
|
|
1026
|
+
* with no written count is exactly the unreviewable decision the manual-
|
|
1027
|
+
* analytics stance depends on NOT existing: a reader cannot ask whether the
|
|
1028
|
+
* bar was met, because nobody wrote down what was counted.
|
|
1029
|
+
* graduate REFUSES `revokes` and `defect` — both describe a withdrawal. On a
|
|
1030
|
+
* grant they are either copy-paste from the revocation template or a
|
|
1031
|
+
* confusion about which direction the entry moves, and each would leave a
|
|
1032
|
+
* grant carrying the vocabulary of its own reversal.
|
|
1033
|
+
* revoke REQUIRES `defect` — revocation is automatic ON A DEFECT, so the
|
|
1034
|
+
* defect is the trigger. A revocation that names none records that trust
|
|
1035
|
+
* was withdrawn for no stated reason, and the next graduation of that
|
|
1036
|
+
* category has nothing to have fixed.
|
|
1037
|
+
*
|
|
1038
|
+
* `observed-cycles` is deliberately NOT refused on a revoke: it is meaningless
|
|
1039
|
+
* there rather than contradictory, and refusing a harmless field would cost an
|
|
1040
|
+
* author an edit for nothing.
|
|
1041
|
+
*
|
|
1042
|
+
* @param {object} graduation the typed block
|
|
1043
|
+
* @param {'graduate'|'revoke'} action the block's own action
|
|
1044
|
+
* @returns {Array<{field: string, message: string}>}
|
|
1045
|
+
*/
|
|
1046
|
+
function graduationShapeDefects(graduation, action) {
|
|
1047
|
+
const defects = [];
|
|
1048
|
+
if (action === 'graduate') {
|
|
1049
|
+
if (!Number.isInteger(graduation['observed-cycles'])) {
|
|
1050
|
+
defects.push({
|
|
1051
|
+
field: 'observed-cycles',
|
|
1052
|
+
message: 'graduation records no "observed-cycles" — v1 computes no counts, so this number is the ONLY record of what the moderator judged against the category\'s threshold; without it the graduation cannot be reviewed by anyone who was not in the room (UCS-1155: the analytics are manual by design)',
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
for (const field of ['revokes', 'defect']) {
|
|
1056
|
+
if (graduation[field] !== undefined) {
|
|
1057
|
+
defects.push({
|
|
1058
|
+
field,
|
|
1059
|
+
message: `graduation carries "${field}", which describes a WITHDRAWAL of autonomy — this entry grants it. Remove the field, or change "action" to revoke if the entry was meant to withdraw a graduation`,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return defects;
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof graduation.defect !== 'string' || graduation.defect.trim() === '') {
|
|
1066
|
+
defects.push({
|
|
1067
|
+
field: 'defect',
|
|
1068
|
+
message: 'revocation names no "defect" — revocation is automatic ON A DEFECT, so the defect is the trigger and the substance both; without it the record says trust was withdrawn for no stated reason, and the next graduation of this category has nothing to have fixed',
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
return defects;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function checkGraduations(model, push) {
|
|
1075
|
+
// One table per store, keyed "<store>/<name>"; decisions is where graduation
|
|
1076
|
+
// is governed (the change process is the team's truth anchor, D-003).
|
|
1077
|
+
const tables = [...model.graduations.values()];
|
|
1078
|
+
// Which categories the store carries a GRADUATION for, and the entry that
|
|
1079
|
+
// granted it. Built once: a revocation's coherence is a question about the
|
|
1080
|
+
// store as a whole, not about the entry in isolation, so it cannot be
|
|
1081
|
+
// answered while looking at one record.
|
|
1082
|
+
const graduatedBy = new Map();
|
|
1083
|
+
for (const entry of model.decisions.values()) {
|
|
1084
|
+
const block = entry.record.graduation;
|
|
1085
|
+
if (!isObject(block) || block.action !== 'graduate') continue;
|
|
1086
|
+
if (typeof block.category !== 'string') continue;
|
|
1087
|
+
if (!graduatedBy.has(block.category)) graduatedBy.set(block.category, recordId(entry));
|
|
1088
|
+
}
|
|
1089
|
+
for (const entry of model.decisions.values()) {
|
|
1090
|
+
const { file, record } = entry;
|
|
1091
|
+
const graduation = record.graduation;
|
|
1092
|
+
if (!isObject(graduation)) continue; // an ordinary decision carries no block
|
|
1093
|
+
const { action, category } = graduation;
|
|
1094
|
+
// A non-string category or unknown action is a schema defect KK-02 already
|
|
1095
|
+
// reported; judging it again here would double-report one mistake.
|
|
1096
|
+
if (typeof category !== 'string' || (action !== 'graduate' && action !== 'revoke')) continue;
|
|
1097
|
+
// The entry's own `category` field must be `trust`. The third store governs
|
|
1098
|
+
// the trust boundary (PRD §3), and `trust` is the category that says so —
|
|
1099
|
+
// an entry filed under `process` or `governance` that quietly moves the
|
|
1100
|
+
// boundary is invisible to anyone auditing the boundary by category, which
|
|
1101
|
+
// is how a steward reads the decisions store. A malformed value is a schema
|
|
1102
|
+
// defect KK-02 already reported, so only a well-formed MISMATCH is judged
|
|
1103
|
+
// here; one omission earns one finding.
|
|
1104
|
+
if (typeof record.category === 'string' && record.category !== 'trust') {
|
|
1105
|
+
push({
|
|
1106
|
+
severity: 'error', code: 'graduation-not-trust-category', id: recordId(entry), file,
|
|
1107
|
+
path: 'category',
|
|
1108
|
+
message: `entry carries a graduation block but is filed under category "${record.category}" — an entry that moves the trust boundary is a "trust" decision (PRD §3: the third store governs changes to the system's own trust boundary), and one filed elsewhere is invisible to anyone auditing that boundary by category`,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
// Which fields belong to which action. The schema describes these rules in
|
|
1112
|
+
// prose and cannot enforce them — its keyword subset has no conditional —
|
|
1113
|
+
// and a rule stated where nothing enforces it is contract drift wearing the
|
|
1114
|
+
// appearance of a check. One code for the family, like the loader's
|
|
1115
|
+
// `graduation-threshold-shape`, with a message per field: they are one
|
|
1116
|
+
// defect class (this block's fields disagree with its own action) and an
|
|
1117
|
+
// author fixes them the same way.
|
|
1118
|
+
for (const defect of graduationShapeDefects(graduation, action)) {
|
|
1119
|
+
push({
|
|
1120
|
+
severity: 'error', code: 'graduation-field-shape', id: recordId(entry), file,
|
|
1121
|
+
path: `graduation.${defect.field}`, message: defect.message,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
if (!tables.length) {
|
|
1125
|
+
push({
|
|
1126
|
+
severity: 'error', code: 'missing-graduation-table', id: recordId(entry), file,
|
|
1127
|
+
path: 'graduation.category',
|
|
1128
|
+
message: `entry ${action}s the category "${category}", but the store carries no graduation category table — the table that declares which categories may graduate never loaded, so this entry's category was never checked (a check that never ran is a blocking defect, PRD §5)`,
|
|
1129
|
+
});
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const row = tables.map((t) => t.categories.get(category)).find(Boolean);
|
|
1133
|
+
if (!row) {
|
|
1134
|
+
const table = tables[0];
|
|
1135
|
+
push({
|
|
1136
|
+
severity: 'error', code: 'undeclared-category', id: recordId(entry), file,
|
|
1137
|
+
path: 'graduation.category',
|
|
1138
|
+
message: `category "${category}" is not declared in the graduation category table (${table.file}) — autonomy is granted per CATEGORY, so a ${action} naming an undeclared one has no scope anyone wrote down; declare the category with its eligibility and warrant, or correct the name`,
|
|
1139
|
+
});
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
// A revocation only ever narrows autonomy, so it is legitimate against any
|
|
1143
|
+
// declared category — including a gated one, where it is a no-op that
|
|
1144
|
+
// records a defect was found. Only GRADUATION is refused.
|
|
1145
|
+
if (action === 'graduate' && row.eligibility === 'gated') {
|
|
1146
|
+
push({
|
|
1147
|
+
severity: 'error', code: 'gated-category-graduation', id: recordId(entry), file,
|
|
1148
|
+
path: 'graduation.category',
|
|
1149
|
+
message: `category "${category}" is PERMANENTLY GATED in ${row.file} and cannot graduate — the table's warrant for gating it is the standing answer, and a judgment call does not become mechanical by having been made correctly ${typeof graduation['observed-cycles'] === 'number' ? graduation['observed-cycles'] : 'N'} times; changing that is a table edit with its own Decisions entry, never a graduation against the table as it stands`,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
// A revocation must name the graduation it withdraws WHEN THERE IS ONE.
|
|
1153
|
+
// The `revokes` field's whole promise is that the withdrawal and the thing
|
|
1154
|
+
// withdrawn stay connected in the record; a revocation that omits it while
|
|
1155
|
+
// a graduation for that category stands leaves the store asserting both,
|
|
1156
|
+
// with nothing linking them — a reader cannot tell whether the graduation
|
|
1157
|
+
// is live, and the revocation names no target to check against.
|
|
1158
|
+
//
|
|
1159
|
+
// Omission stays CLEAN when no graduation for the category exists. That is
|
|
1160
|
+
// the standing-position case: recording that a never-graduated category is
|
|
1161
|
+
// (and remains) at full inspection is a legitimate entry, and it has no
|
|
1162
|
+
// graduation to point at. Demanding a ref there would demand a citation of
|
|
1163
|
+
// something that does not exist.
|
|
1164
|
+
if (action === 'revoke' && typeof graduation.revokes !== 'string') {
|
|
1165
|
+
const granted = graduatedBy.get(category);
|
|
1166
|
+
if (granted !== undefined) {
|
|
1167
|
+
push({
|
|
1168
|
+
severity: 'error', code: 'disconnected-revocation', id: recordId(entry), file,
|
|
1169
|
+
path: 'graduation.revokes',
|
|
1170
|
+
message: `revocation of "${category}" names no graduation, but ${granted} graduates that category — the withdrawal and the thing withdrawn must stay connected in the record, or a reader cannot tell whether ${granted} still stands; name it in "revokes"`,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
// A PRESENT `revokes` must name an actual graduation OF THIS CATEGORY.
|
|
1175
|
+
// Existence alone is what the ref graph checks, and existence is not the
|
|
1176
|
+
// claim: `revokes` asserts "this entry withdraws that grant", so a
|
|
1177
|
+
// revocation pointing at an ordinary ADR, or at a graduation of some other
|
|
1178
|
+
// category, records a withdrawal of something that was never granted —
|
|
1179
|
+
// clean-looking, and false. The ref graph cannot see this because every id
|
|
1180
|
+
// it resolves is equally a real decision.
|
|
1181
|
+
//
|
|
1182
|
+
// An UNRESOLVED id is skipped: that is the ref graph's `unresolved-ref` to
|
|
1183
|
+
// report, and adding a second finding would double-report one typo.
|
|
1184
|
+
if (action === 'revoke' && typeof graduation.revokes === 'string') {
|
|
1185
|
+
const target = model.decisions.get(graduation.revokes);
|
|
1186
|
+
if (target !== undefined) {
|
|
1187
|
+
const targetBlock = target.record.graduation;
|
|
1188
|
+
const targetIsGraduation = isObject(targetBlock) && targetBlock.action === 'graduate';
|
|
1189
|
+
if (!targetIsGraduation) {
|
|
1190
|
+
push({
|
|
1191
|
+
severity: 'error', code: 'disconnected-revocation', id: recordId(entry), file,
|
|
1192
|
+
path: 'graduation.revokes',
|
|
1193
|
+
message: `"revokes" names ${graduation.revokes}, which is not a graduation — a revocation withdraws a grant of autonomy, so it must name the entry that granted it; ${graduation.revokes} ${isObject(targetBlock) ? 'is itself a revocation' : 'carries no graduation block at all'}`,
|
|
1194
|
+
});
|
|
1195
|
+
} else if (targetBlock.category !== category) {
|
|
1196
|
+
push({
|
|
1197
|
+
severity: 'error', code: 'disconnected-revocation', id: recordId(entry), file,
|
|
1198
|
+
path: 'graduation.revokes',
|
|
1199
|
+
message: `this entry revokes "${category}" but "revokes" names ${graduation.revokes}, which graduates "${targetBlock.category}" — a revocation must withdraw a grant made for its OWN category, or it records the withdrawal of something that was never granted while leaving the real graduation standing`,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function checkDecisionCycles(model, push) {
|
|
1208
|
+
const seen = new Set(); // canonical cycle keys — each loop reported once
|
|
1209
|
+
const color = new Map(); // 0/undefined = white, 1 = on stack, 2 = done
|
|
1210
|
+
const stack = [];
|
|
1211
|
+
|
|
1212
|
+
const visit = (id) => {
|
|
1213
|
+
color.set(id, 1);
|
|
1214
|
+
stack.push(id);
|
|
1215
|
+
const record = model.decisions.get(id)?.record;
|
|
1216
|
+
for (const to of strings(record?.supersedes).sort(compare)) {
|
|
1217
|
+
if (!model.decisions.has(to)) continue; // unresolved-ref is the loader's
|
|
1218
|
+
if (color.get(to) === 1) {
|
|
1219
|
+
const cycle = stack.slice(stack.indexOf(to));
|
|
1220
|
+
// Canonical rotation: start at the smallest id, attribute to it.
|
|
1221
|
+
const start = cycle.indexOf([...cycle].sort(compare)[0]);
|
|
1222
|
+
const rotated = [...cycle.slice(start), ...cycle.slice(0, start)];
|
|
1223
|
+
const key = rotated.join('>');
|
|
1224
|
+
if (!seen.has(key)) {
|
|
1225
|
+
seen.add(key);
|
|
1226
|
+
const head = rotated[0];
|
|
1227
|
+
push({
|
|
1228
|
+
severity: 'error', code: 'ref-cycle', id: head,
|
|
1229
|
+
file: model.decisions.get(head).file, path: 'supersedes',
|
|
1230
|
+
message: `supersedes chain loops: ${[...rotated, head].join(' -> ')} — decision chains must be acyclic (§3.3)`,
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
} else if (color.get(to) !== 2) {
|
|
1234
|
+
visit(to);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
stack.pop();
|
|
1238
|
+
color.set(id, 2);
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
for (const id of [...model.decisions.keys()].sort(compare)) {
|
|
1242
|
+
if (!color.get(id)) visit(id);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Run every structural check over a loaded model — the reusable seam
|
|
1248
|
+
* preflight (KK-26) consumes, so verdicts and this validator can never
|
|
1249
|
+
* disagree. Returns the stable-sorted findings list; loader health gating
|
|
1250
|
+
* (exit 2 on an unhealthy store) stays with the callers. `repoRoot` is where
|
|
1251
|
+
* source-of-truth paths resolve (the KK-08 two-root convention: pointers are
|
|
1252
|
+
* repo-root-relative, §9.1; in the flat dogfood layout it equals model.root).
|
|
1253
|
+
*/
|
|
1254
|
+
export function runChecks(model, repoRoot = model.root) {
|
|
1255
|
+
const findings = [];
|
|
1256
|
+
const push = (f) => findings.push(f);
|
|
1257
|
+
checkCatalogs(model, push);
|
|
1258
|
+
checkConcepts(model, push, repoRoot);
|
|
1259
|
+
checkLeafPaths(model, push, repoRoot);
|
|
1260
|
+
checkOrphans(model, push);
|
|
1261
|
+
checkRegistryMembership(model, push);
|
|
1262
|
+
checkCitations(model, push);
|
|
1263
|
+
checkVerifiedDates(model, push);
|
|
1264
|
+
checkEditions(model, push);
|
|
1265
|
+
checkGraduations(model, push);
|
|
1266
|
+
checkDecisionCycles(model, push);
|
|
1267
|
+
findings.sort((a, b) =>
|
|
1268
|
+
compare(a.file, b.file) || compare(a.path, b.path) || compare(a.code, b.code) || compare(a.id, b.id));
|
|
1269
|
+
return findings;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Decision-entry provenance, published so a defect is traceable (UCS-1155).
|
|
1274
|
+
*
|
|
1275
|
+
* The point of recording an author and a skill version is being able to ask the
|
|
1276
|
+
* question backwards. When a graduated category turns out to be producing bad
|
|
1277
|
+
* changes, "which entries did that skill revision write?" is the question that
|
|
1278
|
+
* bounds the damage — and it is unanswerable if provenance is only ever stored
|
|
1279
|
+
* and never surfaced. So the validator publishes it for every entry that
|
|
1280
|
+
* carries it, and a bad skill revision becomes traceable like any other defect
|
|
1281
|
+
* rather than a thing someone has to remember.
|
|
1282
|
+
*
|
|
1283
|
+
* Entries WITHOUT provenance are omitted rather than listed as nulls. The field
|
|
1284
|
+
* is optional — the whole installed base predates it (D-001: no update channel,
|
|
1285
|
+
* so seeded stores keep what they have) — and a list padded with an entry for
|
|
1286
|
+
* every un-migrated record would bury the ones that can actually be traced.
|
|
1287
|
+
*
|
|
1288
|
+
* Stable-sorted by id and free of timestamps, like every other part of this
|
|
1289
|
+
* payload, so it stays baseline-diffable (D-012).
|
|
1290
|
+
*
|
|
1291
|
+
* @param {object} model the loaded store model
|
|
1292
|
+
* @returns {Array<{id: string, file: string, author: string|null, 'skill-version': string|null}>}
|
|
1293
|
+
*/
|
|
1294
|
+
function decisionProvenance(model) {
|
|
1295
|
+
const rows = [];
|
|
1296
|
+
for (const entry of model.decisions.values()) {
|
|
1297
|
+
const provenance = entry.record?.provenance;
|
|
1298
|
+
if (!isObject(provenance)) continue;
|
|
1299
|
+
const author = typeof provenance.author === 'string' ? provenance.author : null;
|
|
1300
|
+
const skillVersion = typeof provenance['skill-version'] === 'string'
|
|
1301
|
+
? provenance['skill-version']
|
|
1302
|
+
: null;
|
|
1303
|
+
// A provenance block carrying neither field records nothing; publishing it
|
|
1304
|
+
// would advertise traceability the entry does not actually have.
|
|
1305
|
+
if (author === null && skillVersion === null) continue;
|
|
1306
|
+
rows.push({ id: recordId(entry), file: entry.file, author, 'skill-version': skillVersion });
|
|
1307
|
+
}
|
|
1308
|
+
rows.sort((a, b) => compare(a.id, b.id));
|
|
1309
|
+
return rows;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// ------------------------------------------------------------- CLI plumbing
|
|
1313
|
+
|
|
1314
|
+
function parseArgs(argv) {
|
|
1315
|
+
const { options } = parseFlags(argv, {
|
|
1316
|
+
boolean: ['json'],
|
|
1317
|
+
value: ['root'],
|
|
1318
|
+
repeatable: ['concepts'],
|
|
1319
|
+
// `--concepts=` is an empty filter, and an empty filter never ran.
|
|
1320
|
+
allowEmpty: ['concepts'],
|
|
1321
|
+
});
|
|
1322
|
+
const opts = { json: !!options.json, root: options.root ?? process.cwd(), concepts: null };
|
|
1323
|
+
if (options.concepts) {
|
|
1324
|
+
opts.concepts = normalizeConceptIds(options.concepts.flatMap((v) => v.split(',')));
|
|
1325
|
+
if (!opts.concepts.length) {
|
|
1326
|
+
throw new UsageError('--concepts must name at least one concept id — a filter that never ran is a failure, never a silent pass');
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return opts;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function render(payload) {
|
|
1333
|
+
const { counts } = payload;
|
|
1334
|
+
const lines = [];
|
|
1335
|
+
lines.push(counts.errors + counts.warnings === 0
|
|
1336
|
+
? 'structural validate -> 0 findings — structurally clean'
|
|
1337
|
+
: `structural validate -> ${counts.errors + counts.warnings} finding(s) (${counts.errors} error(s), ${counts.warnings} warning(s))`);
|
|
1338
|
+
lines.push(`checks run: ${payload.checks.join(', ')}`, '');
|
|
1339
|
+
const health = payload['store-health'];
|
|
1340
|
+
if (health.warnings) {
|
|
1341
|
+
lines.push(`store health: ${health.errors} error(s), ${health.warnings} warning(s) — loader warnings do not block; errors would have (exit 2)`, '');
|
|
1342
|
+
}
|
|
1343
|
+
if (payload.concepts) lines.push(`filtered to concepts: ${payload.concepts.join(', ')}`, '');
|
|
1344
|
+
// Provenance is traceability, so it is printed where a human reading a
|
|
1345
|
+
// failing run can see it — not only in the JSON a machine parses.
|
|
1346
|
+
if (payload.provenance.length) {
|
|
1347
|
+
lines.push('decision provenance (author / skill version):');
|
|
1348
|
+
for (const p of payload.provenance) {
|
|
1349
|
+
lines.push(` ${p.id} ${p.author ?? '—'} ${p['skill-version'] ?? '—'}`);
|
|
1350
|
+
}
|
|
1351
|
+
lines.push('');
|
|
1352
|
+
}
|
|
1353
|
+
for (const f of payload.findings) {
|
|
1354
|
+
lines.push(`${f.severity} ${f.code} ${f.id} ${f.file} ${f.path}`, ` ${f.message}`);
|
|
1355
|
+
}
|
|
1356
|
+
if (counts.errors) {
|
|
1357
|
+
lines.push('', 'fix every error-severity finding before merging — this validator is blocking-grade (PRD §4)');
|
|
1358
|
+
}
|
|
1359
|
+
return lines;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
export function main(argv) {
|
|
1363
|
+
{
|
|
1364
|
+
const opts = parseArgs(argv);
|
|
1365
|
+
|
|
1366
|
+
let model;
|
|
1367
|
+
try {
|
|
1368
|
+
// KK-08 two-root convention: --root is the REPO root; the stores live
|
|
1369
|
+
// at <root>/unknown-knowledge/ when seeded (§9.1) or at the root itself
|
|
1370
|
+
// (dogfood layout). Pointers stay repo-root-relative either way.
|
|
1371
|
+
model = loadStores(locateKitRoot(opts.root));
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
// An EXPECTED refusal from the loader — an unreadable root, an ambiguous
|
|
1374
|
+
// kit layout, a Store that will not load. The stores this command would
|
|
1375
|
+
// check never loaded, so its checks never ran: exit 2, never 1.
|
|
1376
|
+
process.stderr.write(`validate: ${error.message}\n`);
|
|
1377
|
+
rethrowIfBug(error); // a bug, or a UsageError raised deep in the loader, is not ours to speak for
|
|
1378
|
+
return EXIT_CODES.FAILURE;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Single health model (PRD §4): loader errors mean the stores this
|
|
1382
|
+
// validator would check never fully loaded — its structural checks never
|
|
1383
|
+
// ran, and a check that never ran is a blocking defect (exit 2), never a
|
|
1384
|
+
// silent pass or a partial findings list.
|
|
1385
|
+
if (!model.ok) {
|
|
1386
|
+
const { errors } = storeHealth(model);
|
|
1387
|
+
process.stderr.write(`validate: the store loader reported ${errors.length} error(s) — structural checks never ran (a check that never ran is a blocking defect, PRD §5)\n`);
|
|
1388
|
+
for (const d of errors) {
|
|
1389
|
+
process.stderr.write(` ${d.code} ${d.file}${d.path ? ` ${d.path}` : ''} ${d.message}\n`);
|
|
1390
|
+
}
|
|
1391
|
+
return EXIT_CODES.FAILURE;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
let findings = runChecks(model, opts.root);
|
|
1395
|
+
if (opts.concepts) {
|
|
1396
|
+
for (const id of opts.concepts) {
|
|
1397
|
+
// Loaded concepts plus catalog-declared ids (a declared-but-missing
|
|
1398
|
+
// record is exactly what a mid-session check needs to look at).
|
|
1399
|
+
const declared = model.stores.ontology.catalog?.entries?.some?.(
|
|
1400
|
+
(row) => isObject(row) && row.id === id);
|
|
1401
|
+
if (!model.concepts.has(id) && !declared) {
|
|
1402
|
+
process.stderr.write(`validate: unknown concept id "${id}" — filtering on a typo must never read as a clean pass\n`);
|
|
1403
|
+
return EXIT_CODES.FAILURE;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
const wanted = new Set(opts.concepts);
|
|
1407
|
+
findings = findings.filter((f) => wanted.has(f.id));
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const payload = {
|
|
1411
|
+
checks: CHECKS,
|
|
1412
|
+
...(opts.concepts ? { concepts: opts.concepts } : {}),
|
|
1413
|
+
provenance: decisionProvenance(model),
|
|
1414
|
+
'store-health': healthSummary(storeHealth(model)),
|
|
1415
|
+
counts: {
|
|
1416
|
+
errors: findings.filter((f) => f.severity === 'error').length,
|
|
1417
|
+
warnings: findings.filter((f) => f.severity === 'warning').length,
|
|
1418
|
+
},
|
|
1419
|
+
findings,
|
|
1420
|
+
};
|
|
1421
|
+
|
|
1422
|
+
const lines = opts.json ? [JSON.stringify(payload, null, 2)] : render(payload);
|
|
1423
|
+
process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
|
|
1424
|
+
return payload.counts.errors ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
|
|
1425
|
+
}
|
|
1426
|
+
}
|