@superbee/core 0.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/dist/backend.d.ts +77 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +380 -0
- package/dist/backend.js.map +1 -0
- package/dist/bundle.d.ts +38 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +107 -0
- package/dist/bundle.js.map +1 -0
- package/dist/content-type.d.ts +72 -0
- package/dist/content-type.d.ts.map +1 -0
- package/dist/content-type.js +149 -0
- package/dist/content-type.js.map +1 -0
- package/dist/document-mutation.d.ts +83 -0
- package/dist/document-mutation.d.ts.map +1 -0
- package/dist/document-mutation.js +319 -0
- package/dist/document-mutation.js.map +1 -0
- package/dist/document-precondition.d.ts +62 -0
- package/dist/document-precondition.d.ts.map +1 -0
- package/dist/document-precondition.js +134 -0
- package/dist/document-precondition.js.map +1 -0
- package/dist/document-write-policy.d.ts +42 -0
- package/dist/document-write-policy.d.ts.map +1 -0
- package/dist/document-write-policy.js +134 -0
- package/dist/document-write-policy.js.map +1 -0
- package/dist/engine.d.ts +48 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +209 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +50 -0
- package/dist/errors.js.map +1 -0
- package/dist/filesystem-identity.d.ts +208 -0
- package/dist/filesystem-identity.d.ts.map +1 -0
- package/dist/filesystem-identity.js +675 -0
- package/dist/filesystem-identity.js.map +1 -0
- package/dist/filesystem-lock.d.ts +65 -0
- package/dist/filesystem-lock.d.ts.map +1 -0
- package/dist/filesystem-lock.js +460 -0
- package/dist/filesystem-lock.js.map +1 -0
- package/dist/freshness.d.ts +31 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +98 -0
- package/dist/freshness.js.map +1 -0
- package/dist/frontmatter-contract.d.ts +17 -0
- package/dist/frontmatter-contract.d.ts.map +1 -0
- package/dist/frontmatter-contract.js +29 -0
- package/dist/frontmatter-contract.js.map +1 -0
- package/dist/frontmatter.d.ts +30 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +115 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index-marker.d.ts +3 -0
- package/dist/index-marker.d.ts.map +1 -0
- package/dist/index-marker.js +3 -0
- package/dist/index-marker.js.map +1 -0
- package/dist/index-projection.d.ts +79 -0
- package/dist/index-projection.d.ts.map +1 -0
- package/dist/index-projection.js +245 -0
- package/dist/index-projection.js.map +1 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +110 -0
- package/dist/index.js.map +1 -0
- package/dist/kinds-load.d.ts +13 -0
- package/dist/kinds-load.d.ts.map +1 -0
- package/dist/kinds-load.js +34 -0
- package/dist/kinds-load.js.map +1 -0
- package/dist/kinds.d.ts +241 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +1001 -0
- package/dist/kinds.js.map +1 -0
- package/dist/links.d.ts +62 -0
- package/dist/links.d.ts.map +1 -0
- package/dist/links.js +203 -0
- package/dist/links.js.map +1 -0
- package/dist/meaningful-change-time.d.ts +11 -0
- package/dist/meaningful-change-time.d.ts.map +1 -0
- package/dist/meaningful-change-time.js +16 -0
- package/dist/meaningful-change-time.js.map +1 -0
- package/dist/memory-backend.d.ts +55 -0
- package/dist/memory-backend.d.ts.map +1 -0
- package/dist/memory-backend.js +214 -0
- package/dist/memory-backend.js.map +1 -0
- package/dist/mutation-attribution.d.ts +19 -0
- package/dist/mutation-attribution.d.ts.map +1 -0
- package/dist/mutation-attribution.js +34 -0
- package/dist/mutation-attribution.js.map +1 -0
- package/dist/mutation.d.ts +89 -0
- package/dist/mutation.d.ts.map +1 -0
- package/dist/mutation.js +94 -0
- package/dist/mutation.js.map +1 -0
- package/dist/page.d.ts +87 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +151 -0
- package/dist/page.js.map +1 -0
- package/dist/paths.d.ts +78 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +148 -0
- package/dist/paths.js.map +1 -0
- package/dist/portable-frontmatter.d.ts +8 -0
- package/dist/portable-frontmatter.d.ts.map +1 -0
- package/dist/portable-frontmatter.js +35 -0
- package/dist/portable-frontmatter.js.map +1 -0
- package/dist/publication-filesystem.d.ts +6 -0
- package/dist/publication-filesystem.d.ts.map +1 -0
- package/dist/publication-filesystem.js +6 -0
- package/dist/publication-filesystem.js.map +1 -0
- package/dist/query-filter.d.ts +12 -0
- package/dist/query-filter.d.ts.map +1 -0
- package/dist/query-filter.js +29 -0
- package/dist/query-filter.js.map +1 -0
- package/dist/query-selection.d.ts +25 -0
- package/dist/query-selection.d.ts.map +1 -0
- package/dist/query-selection.js +48 -0
- package/dist/query-selection.js.map +1 -0
- package/dist/remote-backend.d.ts +152 -0
- package/dist/remote-backend.d.ts.map +1 -0
- package/dist/remote-backend.js +505 -0
- package/dist/remote-backend.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +3 -0
- package/dist/remote.js.map +1 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +6 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +438 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +12 -0
- package/dist/validation.js.map +1 -0
- package/dist/version-transport.d.ts +13 -0
- package/dist/version-transport.d.ts.map +1 -0
- package/dist/version-transport.js +26 -0
- package/dist/version-transport.js.map +1 -0
- package/dist/versioning.d.ts +48 -0
- package/dist/versioning.d.ts.map +1 -0
- package/dist/versioning.js +62 -0
- package/dist/versioning.js.map +1 -0
- package/package.json +84 -0
package/dist/kinds.js
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kind conventions — the bundle-declared, opt-in document-kind mechanism. The mechanism lives in
|
|
3
|
+
* core while each bundle chooses whether to declare and use conventions.
|
|
4
|
+
*
|
|
5
|
+
* A bundle MAY declare document kinds as plain OKF convention docs: a normal concept
|
|
6
|
+
* document with `frontmatter.type: "Convention"`, living under the `conventions/`
|
|
7
|
+
* prefix (the ONE documented discovery contract — see {@link CONVENTIONS_PREFIX}), that
|
|
8
|
+
* names the `type` value it governs plus its required/optional fields, allowed enum
|
|
9
|
+
* values, expected body sections, and an optional freshness horizon. A convention doc
|
|
10
|
+
* is NOT a schema fork: it is a plain OKF doc with a well-known `type`, read by this
|
|
11
|
+
* ONE registry (consumed by the CLI's `kinds`/`new`/`doc write`, and additively by any
|
|
12
|
+
* future consumer — server/MCP).
|
|
13
|
+
*
|
|
14
|
+
* Registry discovery is PREFIX-SCOPED and built ONCE per invocation, in the COMMAND
|
|
15
|
+
* layer — no engine path (`readDoc`, `writeDoc`, …) loads it implicitly, so
|
|
16
|
+
* a conventions-free bundle (every external OKF bundle today) pays only a cheap
|
|
17
|
+
* list-of-nothing and behaves byte-for-byte as before.
|
|
18
|
+
*
|
|
19
|
+
* Pure derivation logic (`validateAgainstKind`, `freshnessHorizonMs`, `isTerminal`, the
|
|
20
|
+
* convention-doc (de)serialization) is dependency-free — this module carries ONLY that, and is
|
|
21
|
+
* exported as the browser-safe `@superbee/core/kinds` subpath (the ui shell's bridge
|
|
22
|
+
* `open` filter consumes `isTerminal` there; a browser bundler must never be dragged through the
|
|
23
|
+
* engine's node built-ins for a pure predicate). The one backend-touching entry point,
|
|
24
|
+
* `loadKinds`, lives in `kinds-load.ts` — same ONE registry, split only by dependency weight.
|
|
25
|
+
*/
|
|
26
|
+
import { isUsableTimestamp } from "./frontmatter.js";
|
|
27
|
+
/** The bundle-relative prefix a kind convention doc MUST live under to be discovered. */
|
|
28
|
+
export const CONVENTIONS_PREFIX = "conventions/";
|
|
29
|
+
/** The OKF `type` value a kind convention doc itself carries. */
|
|
30
|
+
export const CONVENTION_TYPE = "Convention";
|
|
31
|
+
/**
|
|
32
|
+
* Build the one authoritative Kind registry from already-selected convention documents.
|
|
33
|
+
* Callers own discovery; this pure half owns parsing, warnings, and first-by-id duplicate
|
|
34
|
+
* resolution so prospective registries cannot drift from {@link loadKinds} semantics.
|
|
35
|
+
*/
|
|
36
|
+
export function buildKindRegistry(docs, initialWarnings = []) {
|
|
37
|
+
const kinds = new Map();
|
|
38
|
+
const warnings = [...initialWarnings];
|
|
39
|
+
for (const doc of [...docs].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
40
|
+
const parsed = parseConventionDoc(doc);
|
|
41
|
+
if (!parsed.ok) {
|
|
42
|
+
warnings.push({
|
|
43
|
+
code: "KIND_CONVENTION_MALFORMED",
|
|
44
|
+
message: `skipped malformed kind convention '${doc.id}': ${parsed.reason}`,
|
|
45
|
+
field: doc.id,
|
|
46
|
+
severity: "warning",
|
|
47
|
+
});
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const { kind, reservedFieldsIgnored, reservedFieldPaths } = parsed;
|
|
51
|
+
warnings.push(...parsed.warnings);
|
|
52
|
+
if (reservedFieldsIgnored.length > 0) {
|
|
53
|
+
warnings.push({
|
|
54
|
+
code: "KIND_RESERVED_FIELD",
|
|
55
|
+
message: `kind convention '${doc.id}' declares reserved field name(s) ${reservedFieldsIgnored.join(", ")} (reserved by the CLI: ${RESERVED_KIND_FIELD_NAMES.join("/")}); ignoring them — rename those domain fields before authoring instances.`,
|
|
56
|
+
field: reservedFieldPaths.join(","),
|
|
57
|
+
severity: "warning",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (kinds.has(kind.governs)) {
|
|
61
|
+
warnings.push({
|
|
62
|
+
code: "KIND_DUPLICATE_GOVERNS",
|
|
63
|
+
message: `duplicate kind convention for '${kind.governs}': '${doc.id}' ignored, keeping the first-declared '${kinds.get(kind.governs).id}'.`,
|
|
64
|
+
field: kind.governs,
|
|
65
|
+
severity: "warning",
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (kind.freshnessHorizon !== undefined && freshnessHorizonMs(kind) === undefined) {
|
|
70
|
+
warnings.push({
|
|
71
|
+
code: "KIND_HORIZON_MALFORMED",
|
|
72
|
+
message: `kind convention '${doc.id}' has a malformed freshness_horizon '${kind.freshnessHorizon}' (expected <n>(m|h|d)); ignoring it.`,
|
|
73
|
+
field: "freshness_horizon",
|
|
74
|
+
severity: "warning",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
kinds.set(kind.governs, kind);
|
|
78
|
+
}
|
|
79
|
+
return { kinds, warnings };
|
|
80
|
+
}
|
|
81
|
+
/** Stable product-level name for a Kind's workflow progress field. */
|
|
82
|
+
export const PROGRESS_STATUS_FIELD = "progress_status";
|
|
83
|
+
/** Producer-qualified v0.2 storage coordinate for Superbee workflow progress. */
|
|
84
|
+
export const SUPERBEE_PROGRESS_STATUS_FIELD = "superbee_progress_status";
|
|
85
|
+
/** Physical coordinate used for Superbee's logical workflow-progress field in one OKF edition. */
|
|
86
|
+
export function progressStatusStorageField(okfVersion) {
|
|
87
|
+
const version = okfVersion?.trim() || "0.1";
|
|
88
|
+
if (version === "0.1")
|
|
89
|
+
return "status";
|
|
90
|
+
if (version === "0.2")
|
|
91
|
+
return SUPERBEE_PROGRESS_STATUS_FIELD;
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
function declaresField(kind, field) {
|
|
95
|
+
return kind.fields.required.includes(field) || kind.fields.optional.includes(field);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Compile the declaration-driven workflow-progress coordinate for one Kind. Missing root version
|
|
99
|
+
* means v0.1 (OKF's compatibility default). Values never activate this mapping.
|
|
100
|
+
*/
|
|
101
|
+
export function progressStatusCoordinate(okfVersion, kind) {
|
|
102
|
+
// A Kind may already own a concrete field with the product-level name. That field remains a
|
|
103
|
+
// normal declared coordinate; never shadow or overwrite it with a compatibility alias.
|
|
104
|
+
if (declaresField(kind, PROGRESS_STATUS_FIELD))
|
|
105
|
+
return undefined;
|
|
106
|
+
const storageField = progressStatusStorageField(okfVersion);
|
|
107
|
+
if (storageField && declaresField(kind, storageField)) {
|
|
108
|
+
return {
|
|
109
|
+
logicalField: PROGRESS_STATUS_FIELD,
|
|
110
|
+
storageField,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/** Resolve an agent-facing Kind field to its declared storage coordinate. */
|
|
116
|
+
export function resolveKindFieldCoordinate(okfVersion, kind, field) {
|
|
117
|
+
if (declaresField(kind, field))
|
|
118
|
+
return { logicalField: field, storageField: field };
|
|
119
|
+
const progress = progressStatusCoordinate(okfVersion, kind);
|
|
120
|
+
if (field === PROGRESS_STATUS_FIELD && progress)
|
|
121
|
+
return progress;
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve a Kind's declared claim vocabulary onto this bundle edition's storage coordinates
|
|
126
|
+
* through the ONE field resolver ({@link resolveKindFieldCoordinate}), so a logical declaration
|
|
127
|
+
* (`state_field: progress_status`) lands on the edition's real key. A declared name the Kind does
|
|
128
|
+
* not actually carry resolves to NOTHING and is dropped: a consumer then compares no coordinate
|
|
129
|
+
* at all rather than a key that can never match. Undefined when the Kind declares no `claim:`
|
|
130
|
+
* block, or when nothing in it resolves.
|
|
131
|
+
*/
|
|
132
|
+
export function claimCoordinates(okfVersion, kind) {
|
|
133
|
+
if (!kind.claim)
|
|
134
|
+
return undefined;
|
|
135
|
+
const resolve = (field) => field === undefined ? undefined : resolveKindFieldCoordinate(okfVersion, kind, field)?.storageField;
|
|
136
|
+
const ownerField = resolve(kind.claim.ownerField);
|
|
137
|
+
const stateField = resolve(kind.claim.stateField);
|
|
138
|
+
const fields = [...new Set([ownerField, stateField].filter((f) => f !== undefined))];
|
|
139
|
+
if (fields.length === 0)
|
|
140
|
+
return undefined;
|
|
141
|
+
return {
|
|
142
|
+
...(ownerField !== undefined ? { ownerField } : {}),
|
|
143
|
+
...(stateField !== undefined ? { stateField } : {}),
|
|
144
|
+
fields,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** Declared authoring names plus the logical progress alias, when this Kind proves one exists. */
|
|
148
|
+
export function kindInputFieldNames(okfVersion, kind) {
|
|
149
|
+
const fields = [...new Set([...kind.fields.required, ...kind.fields.optional])];
|
|
150
|
+
if (progressStatusCoordinate(okfVersion, kind) && !fields.includes(PROGRESS_STATUS_FIELD)) {
|
|
151
|
+
fields.push(PROGRESS_STATUS_FIELD);
|
|
152
|
+
}
|
|
153
|
+
return fields;
|
|
154
|
+
}
|
|
155
|
+
/** Read a declared logical or physical Kind field without changing the stored frontmatter. */
|
|
156
|
+
export function readKindField(okfVersion, kind, frontmatter, field) {
|
|
157
|
+
const coordinate = resolveKindFieldCoordinate(okfVersion, kind, field);
|
|
158
|
+
return coordinate
|
|
159
|
+
? frontmatter[coordinate.storageField]
|
|
160
|
+
: undefined;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Add logical field aliases to a product-facing projection while preserving every raw key. Generic
|
|
164
|
+
* document reads remain raw; only Kind-aware product surfaces call this helper.
|
|
165
|
+
*/
|
|
166
|
+
export function projectLogicalKindFields(okfVersion, kind, frontmatter) {
|
|
167
|
+
const progress = progressStatusCoordinate(okfVersion, kind);
|
|
168
|
+
if (!progress ||
|
|
169
|
+
!hasOwn(frontmatter, progress.storageField) ||
|
|
170
|
+
hasOwn(frontmatter, progress.logicalField))
|
|
171
|
+
return frontmatter;
|
|
172
|
+
const projected = { ...frontmatter };
|
|
173
|
+
setOwn(projected, progress.logicalField, frontmatter[progress.storageField]);
|
|
174
|
+
return projected;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Project a parsed Kind convention into Superbee's authoring vocabulary. Stored convention bytes
|
|
178
|
+
* remain untouched; product-facing schema/help surfaces use this view so an edition-specific
|
|
179
|
+
* workflow coordinate never becomes something an author has to learn.
|
|
180
|
+
*/
|
|
181
|
+
export function projectKindForAuthoring(okfVersion, kind) {
|
|
182
|
+
const progress = progressStatusCoordinate(okfVersion, kind);
|
|
183
|
+
if (!progress)
|
|
184
|
+
return kind;
|
|
185
|
+
const projectField = (field) => field === progress.storageField ? progress.logicalField : field;
|
|
186
|
+
const projectRecord = (record) => {
|
|
187
|
+
const projected = {};
|
|
188
|
+
for (const [field, value] of Object.entries(record)) {
|
|
189
|
+
setOwn(projected, projectField(field), value);
|
|
190
|
+
}
|
|
191
|
+
return projected;
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
...kind,
|
|
195
|
+
fields: {
|
|
196
|
+
required: kind.fields.required.map(projectField),
|
|
197
|
+
optional: kind.fields.optional.map(projectField),
|
|
198
|
+
values: projectRecord(kind.fields.values),
|
|
199
|
+
valueDescriptions: kind.fields.valueDescriptions
|
|
200
|
+
? projectRecord(kind.fields.valueDescriptions)
|
|
201
|
+
: undefined,
|
|
202
|
+
terminal: projectRecord(kind.fields.terminal),
|
|
203
|
+
descriptions: projectRecord(kind.fields.descriptions),
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Project Kind-validation findings into the same authoring vocabulary as the Kind schema. */
|
|
208
|
+
export function projectKindValidationWarnings(okfVersion, kind, warnings) {
|
|
209
|
+
const progress = progressStatusCoordinate(okfVersion, kind);
|
|
210
|
+
if (!progress)
|
|
211
|
+
return warnings;
|
|
212
|
+
const stored = `'${progress.storageField}'`;
|
|
213
|
+
const logical = `'${progress.logicalField}'`;
|
|
214
|
+
return warnings.map((warning) => warning.field === progress.storageField
|
|
215
|
+
? {
|
|
216
|
+
...warning,
|
|
217
|
+
field: progress.logicalField,
|
|
218
|
+
// Core's Kind warnings name the field first. Replace that one coordinate only; a later
|
|
219
|
+
// occurrence may be the user's literal value and must remain byte-truthful.
|
|
220
|
+
message: warning.message.replace(stored, logical),
|
|
221
|
+
}
|
|
222
|
+
: warning);
|
|
223
|
+
}
|
|
224
|
+
/** True for a plain YAML/JSON map (excludes arrays, `null`, dates, and other object instances). */
|
|
225
|
+
function isPlainObject(value) {
|
|
226
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
227
|
+
return false;
|
|
228
|
+
const prototype = Object.getPrototypeOf(value);
|
|
229
|
+
return prototype === Object.prototype || prototype === null;
|
|
230
|
+
}
|
|
231
|
+
/** Prototype-safe own-key check for user-authored YAML maps. */
|
|
232
|
+
function hasOwn(record, key) {
|
|
233
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
234
|
+
}
|
|
235
|
+
/** Define an arbitrary user-authored key without invoking the legacy `__proto__` setter. */
|
|
236
|
+
function setOwn(record, key, value) {
|
|
237
|
+
Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
|
|
238
|
+
}
|
|
239
|
+
/** True for a scalar YAML value (string/number/boolean) — the shape an enum/field-name member should be. */
|
|
240
|
+
function isScalar(value) {
|
|
241
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
242
|
+
}
|
|
243
|
+
/** Human-readable shape name for a warning message (`"an object"`, `"an array"`, `"null"`, …). */
|
|
244
|
+
function describeShape(value) {
|
|
245
|
+
if (Array.isArray(value))
|
|
246
|
+
return "an array";
|
|
247
|
+
if (value === null)
|
|
248
|
+
return "null";
|
|
249
|
+
if (typeof value === "object")
|
|
250
|
+
return "an object";
|
|
251
|
+
return typeof value;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* String-coerce every scalar element of an array-ish value (tolerating YAML 1.1 boolean/Date
|
|
255
|
+
* coercion on unquoted enum members), WARNING and DROPPING any non-scalar member (an object or
|
|
256
|
+
* nested array) instead of silently stringifying it to `"[object Object]"` — the exact silent
|
|
257
|
+
* corruption caused by agents feeding a list of objects into `required`/`optional` or an enum's
|
|
258
|
+
* `values` list. A present-but-non-array `value` warns (wrong shape); an
|
|
259
|
+
* ABSENT (`undefined`) `value` does not, since "not declared" is normal, not a shape error.
|
|
260
|
+
*/
|
|
261
|
+
function toStringArrayLenient(value, path, docId, warnings) {
|
|
262
|
+
if (!Array.isArray(value)) {
|
|
263
|
+
if (value !== undefined) {
|
|
264
|
+
warnings.push({
|
|
265
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
266
|
+
message: `kind convention '${docId}' has a non-list '${path}' (${describeShape(value)}; expected a list of strings); ignoring it.`,
|
|
267
|
+
field: path,
|
|
268
|
+
severity: "warning",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const v of value) {
|
|
275
|
+
if (isScalar(v)) {
|
|
276
|
+
out.push(String(v));
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
warnings.push({
|
|
280
|
+
code: "KIND_CONVENTION_BAD_MEMBER",
|
|
281
|
+
message: `kind convention '${docId}' has a non-scalar member (${describeShape(v)}) in '${path}'; skipping it.`,
|
|
282
|
+
field: path,
|
|
283
|
+
severity: "warning",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Field names the CLI reserves for its own machinery and can never treat as a kind-declared
|
|
291
|
+
* field: `type` is stamped from `kind.governs` (a kind declaring it as a field would let
|
|
292
|
+
* `new --type <v>` silently overwrite the governed type it just validated against), and
|
|
293
|
+
* `dir`/`remote`/`json`/`help` are consumed by every command's common flag handling before a
|
|
294
|
+
* `--<field> <value>` pair ever reaches kind-field mapping. `body` and `body-file` are `new`'s
|
|
295
|
+
* complete-body inline and file channels. Declaring any of them would make the field permanently
|
|
296
|
+
* unreachable, not merely confusing. Filtered out of `required`/`optional`/`values` at parse time
|
|
297
|
+
* with a collected warning — never silently accepted.
|
|
298
|
+
*/
|
|
299
|
+
export const RESERVED_KIND_FIELD_NAMES = ["type", "dir", "remote", "json", "help", "body", "body-file"];
|
|
300
|
+
const RESERVED_FIELD_NAMES = new Set(RESERVED_KIND_FIELD_NAMES);
|
|
301
|
+
/** The only recognized keys inside a convention doc's `fields:` block. */
|
|
302
|
+
const VALID_FIELDS_KEYS = new Set([
|
|
303
|
+
"required",
|
|
304
|
+
"optional",
|
|
305
|
+
"values",
|
|
306
|
+
"value_descriptions",
|
|
307
|
+
"terminal",
|
|
308
|
+
"descriptions",
|
|
309
|
+
]);
|
|
310
|
+
/**
|
|
311
|
+
* Top-level convention-doc keys that are near-misses for the ONE correct enum-constraint shape
|
|
312
|
+
* (`fields.values.<field>: [...]`) — the exact wrong shapes agents commonly reach for
|
|
313
|
+
* reaching for (`enum:`, `enums:`, top-level `values:`, `constraints:`). This is a SMALL, DENY-
|
|
314
|
+
* ADJACENT set, not a generic top-level-key linter: OKF v0.1 §9 / v0.2 §11 permits unknown
|
|
315
|
+
* frontmatter, and a
|
|
316
|
+
* bundle producer may legitimately add other top-level keys to a convention doc (title, tags,
|
|
317
|
+
* whatever) — those get NO warning. Strict inside the blocks we own (`fields:`), permissive
|
|
318
|
+
* everywhere else.
|
|
319
|
+
*/
|
|
320
|
+
const MISPLACED_TOP_LEVEL_KEYS = new Set(["enum", "enums", "values", "constraints"]);
|
|
321
|
+
/** The `claim:` keys this parse consumes, paired with their {@link KindClaimPolicy} property. */
|
|
322
|
+
const CLAIM_COORDINATE_KEYS = [
|
|
323
|
+
["owner_field", "ownerField"],
|
|
324
|
+
["state_field", "stateField"],
|
|
325
|
+
];
|
|
326
|
+
// Level-1 headings only: `# Foo` (not `## Foo`). The ONE heading splitter, reused by
|
|
327
|
+
// validateAgainstKind's section lint below and re-exported as public API from index.ts.
|
|
328
|
+
const H1_RE = /^#\s+(.+?)\s*$/gm;
|
|
329
|
+
/** Split a body into `{ headingText: sectionContent }` by its level-1 headings. */
|
|
330
|
+
export function splitSections(body) {
|
|
331
|
+
const out = {};
|
|
332
|
+
const matches = [...body.matchAll(H1_RE)];
|
|
333
|
+
for (let i = 0; i < matches.length; i++) {
|
|
334
|
+
const current = matches[i];
|
|
335
|
+
const name = (current[1] ?? "").trim();
|
|
336
|
+
const start = (current.index ?? 0) + current[0].length;
|
|
337
|
+
const end = i + 1 < matches.length ? (matches[i + 1].index ?? body.length) : body.length;
|
|
338
|
+
setOwn(out, name, body.slice(start, end).trim());
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
/** Parse one convention doc into a {@link KindConvention}, or a reason it was skipped. */
|
|
343
|
+
export function parseConventionDoc(doc) {
|
|
344
|
+
const fm = doc.frontmatter;
|
|
345
|
+
const governs = typeof fm.governs === "string" ? fm.governs.trim() : "";
|
|
346
|
+
if (governs === "") {
|
|
347
|
+
return { ok: false, reason: "missing or empty 'governs' field", warnings: [] };
|
|
348
|
+
}
|
|
349
|
+
const warnings = [];
|
|
350
|
+
// Top-level near-miss constraint keys (see MISPLACED_TOP_LEVEL_KEYS doc comment): warn ONLY on
|
|
351
|
+
// this small deny-adjacent set — never on arbitrary other top-level keys.
|
|
352
|
+
for (const key of MISPLACED_TOP_LEVEL_KEYS) {
|
|
353
|
+
if (key in fm) {
|
|
354
|
+
warnings.push({
|
|
355
|
+
code: "KIND_CONVENTION_MISPLACED_KEY",
|
|
356
|
+
message: `kind convention '${doc.id}' declares a top-level '${key}' key, which core does not read; enum constraints go under 'fields.values.<field>: [...]', not '${key}'.`,
|
|
357
|
+
field: key,
|
|
358
|
+
severity: "warning",
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const fieldsSource = fm.fields;
|
|
363
|
+
let fieldsRaw = {};
|
|
364
|
+
if (fieldsSource === undefined) {
|
|
365
|
+
// absent 'fields:' — nothing to declare, nothing to warn about.
|
|
366
|
+
}
|
|
367
|
+
else if (!isPlainObject(fieldsSource)) {
|
|
368
|
+
warnings.push({
|
|
369
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
370
|
+
message: `kind convention '${doc.id}' has a non-map 'fields' key (${describeShape(fieldsSource)}; expected a map with required/optional/values/value_descriptions/descriptions); ignoring it.`,
|
|
371
|
+
field: "fields",
|
|
372
|
+
severity: "warning",
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
fieldsRaw = fieldsSource;
|
|
377
|
+
for (const key of Object.keys(fieldsRaw)) {
|
|
378
|
+
if (!VALID_FIELDS_KEYS.has(key)) {
|
|
379
|
+
warnings.push({
|
|
380
|
+
code: "KIND_CONVENTION_UNKNOWN_FIELDS_KEY",
|
|
381
|
+
message: `kind convention '${doc.id}' declares an unrecognized key 'fields.${key}' (valid keys: fields.required, fields.optional, fields.values, fields.value_descriptions, fields.terminal, fields.descriptions); ignoring it.`,
|
|
382
|
+
field: `fields.${key}`,
|
|
383
|
+
severity: "warning",
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const reservedFieldsIgnored = new Set();
|
|
389
|
+
const reservedFieldPaths = new Set();
|
|
390
|
+
const dropReserved = (name, semanticPath) => {
|
|
391
|
+
if (!RESERVED_FIELD_NAMES.has(name))
|
|
392
|
+
return false;
|
|
393
|
+
reservedFieldsIgnored.add(name);
|
|
394
|
+
reservedFieldPaths.add(semanticPath);
|
|
395
|
+
return true;
|
|
396
|
+
};
|
|
397
|
+
const required = toStringArrayLenient(fieldsRaw.required, "fields.required", doc.id, warnings).filter((f) => !dropReserved(f, `fields.required.${f}`));
|
|
398
|
+
const optional = toStringArrayLenient(fieldsRaw.optional, "fields.optional", doc.id, warnings).filter((f) => !dropReserved(f, `fields.optional.${f}`));
|
|
399
|
+
const valuesSource = fieldsRaw.values;
|
|
400
|
+
const values = {};
|
|
401
|
+
if (valuesSource !== undefined) {
|
|
402
|
+
if (!isPlainObject(valuesSource)) {
|
|
403
|
+
warnings.push({
|
|
404
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
405
|
+
message: `kind convention '${doc.id}' has a non-map 'fields.values' (${describeShape(valuesSource)}; expected a map of field name -> list of allowed values); ignoring it.`,
|
|
406
|
+
field: "fields.values",
|
|
407
|
+
severity: "warning",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
for (const [field, allowed] of Object.entries(valuesSource)) {
|
|
412
|
+
if (dropReserved(field, `fields.values.${field}`))
|
|
413
|
+
continue;
|
|
414
|
+
setOwn(values, field, toStringArrayLenient(allowed, `fields.values.${field}`, doc.id, warnings));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// A values-constrained field that names neither a required nor an optional field is almost
|
|
419
|
+
// certainly a mistake (a declared constraint on an undeclared field can never fire, since
|
|
420
|
+
// `validateAgainstKind` only sees fields the instance actually carries — but the AUTHOR meant
|
|
421
|
+
// something).
|
|
422
|
+
const declaredFieldNames = new Set([...required, ...optional]);
|
|
423
|
+
for (const field of Object.keys(values)) {
|
|
424
|
+
if (!declaredFieldNames.has(field)) {
|
|
425
|
+
warnings.push({
|
|
426
|
+
code: "KIND_CONVENTION_UNDECLARED_VALUES_FIELD",
|
|
427
|
+
message: `kind convention '${doc.id}' declares 'fields.values.${field}' but '${field}' is not in fields.required or fields.optional.`,
|
|
428
|
+
field: `fields.values.${field}`,
|
|
429
|
+
severity: "warning",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const valueDescriptionsSource = fieldsRaw.value_descriptions;
|
|
434
|
+
const valueDescriptions = {};
|
|
435
|
+
if (valueDescriptionsSource !== undefined) {
|
|
436
|
+
if (!isPlainObject(valueDescriptionsSource)) {
|
|
437
|
+
warnings.push({
|
|
438
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
439
|
+
message: `kind convention '${doc.id}' has a non-map 'fields.value_descriptions' (${describeShape(valueDescriptionsSource)}; expected a map of enum field -> allowed value -> non-empty description); ignoring it.`,
|
|
440
|
+
field: "fields.value_descriptions",
|
|
441
|
+
severity: "warning",
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
for (const [field, rawValueDescriptions] of Object.entries(valueDescriptionsSource)) {
|
|
446
|
+
const fieldPath = `fields.value_descriptions.${field}`;
|
|
447
|
+
if (!isPlainObject(rawValueDescriptions)) {
|
|
448
|
+
warnings.push({
|
|
449
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
450
|
+
message: `kind convention '${doc.id}' has a non-map '${fieldPath}' (${describeShape(rawValueDescriptions)}; expected a map of allowed value -> non-empty description); ignoring it.`,
|
|
451
|
+
field: fieldPath,
|
|
452
|
+
severity: "warning",
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (!hasOwn(values, field)) {
|
|
457
|
+
warnings.push({
|
|
458
|
+
code: "KIND_CONVENTION_UNDECLARED_VALUE_DESCRIPTION_FIELD",
|
|
459
|
+
message: `kind convention '${doc.id}' declares '${fieldPath}' but '${field}' has no 'fields.values.${field}' enum declared; skipping it.`,
|
|
460
|
+
field: fieldPath,
|
|
461
|
+
severity: "warning",
|
|
462
|
+
});
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const allowed = values[field];
|
|
466
|
+
const parsed = {};
|
|
467
|
+
for (const [value, rawDescription] of Object.entries(rawValueDescriptions)) {
|
|
468
|
+
const valuePath = `${fieldPath}.${value}`;
|
|
469
|
+
if (typeof rawDescription !== "string" || rawDescription.trim() === "") {
|
|
470
|
+
warnings.push({
|
|
471
|
+
code: "KIND_CONVENTION_BAD_MEMBER",
|
|
472
|
+
message: `kind convention '${doc.id}' has a malformed '${valuePath}' (${describeShape(rawDescription)}; expected a non-empty string); skipping it.`,
|
|
473
|
+
field: valuePath,
|
|
474
|
+
severity: "warning",
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!allowed.includes(value)) {
|
|
479
|
+
warnings.push({
|
|
480
|
+
code: "KIND_CONVENTION_UNDECLARED_VALUE_DESCRIPTION_VALUE",
|
|
481
|
+
message: `kind convention '${doc.id}' declares '${valuePath}' but '${value}' is not one of the declared 'fields.values.${field}' values (${allowed.join(", ")}); skipping it.`,
|
|
482
|
+
field: valuePath,
|
|
483
|
+
severity: "warning",
|
|
484
|
+
});
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
setOwn(parsed, value, rawDescription.trim());
|
|
488
|
+
}
|
|
489
|
+
if (Object.keys(parsed).length > 0)
|
|
490
|
+
setOwn(valueDescriptions, field, parsed);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const descriptionsSource = fieldsRaw.descriptions;
|
|
495
|
+
const descriptions = {};
|
|
496
|
+
if (descriptionsSource !== undefined) {
|
|
497
|
+
if (!isPlainObject(descriptionsSource)) {
|
|
498
|
+
warnings.push({
|
|
499
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
500
|
+
message: `kind convention '${doc.id}' has a non-map 'fields.descriptions' (${describeShape(descriptionsSource)}; expected a map of field name -> non-empty description); ignoring it.`,
|
|
501
|
+
field: "fields.descriptions",
|
|
502
|
+
severity: "warning",
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
for (const [field, rawDescription] of Object.entries(descriptionsSource)) {
|
|
507
|
+
if (dropReserved(field, `fields.descriptions.${field}`))
|
|
508
|
+
continue;
|
|
509
|
+
if (typeof rawDescription !== "string" || rawDescription.trim() === "") {
|
|
510
|
+
warnings.push({
|
|
511
|
+
code: "KIND_CONVENTION_BAD_MEMBER",
|
|
512
|
+
message: `kind convention '${doc.id}' has a malformed 'fields.descriptions.${field}' (${describeShape(rawDescription)}; expected a non-empty string); skipping it.`,
|
|
513
|
+
field: `fields.descriptions.${field}`,
|
|
514
|
+
severity: "warning",
|
|
515
|
+
});
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
setOwn(descriptions, field, rawDescription.trim());
|
|
519
|
+
if (!declaredFieldNames.has(field)) {
|
|
520
|
+
warnings.push({
|
|
521
|
+
code: "KIND_CONVENTION_UNDECLARED_DESCRIPTION_FIELD",
|
|
522
|
+
message: `kind convention '${doc.id}' declares 'fields.descriptions.${field}' but '${field}' is not in fields.required or fields.optional.`,
|
|
523
|
+
field: `fields.descriptions.${field}`,
|
|
524
|
+
severity: "warning",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// `fields.terminal` — the subset of values (per field) that mark an instance "done" (task board
|
|
531
|
+
// `tasks/status-terminal-declaration.md`). EXACTLY the lenient posture of `fields.values` above:
|
|
532
|
+
// absent is normal, a non-map shape warns+ignores, a non-scalar member warns+skips.
|
|
533
|
+
const terminalSource = fieldsRaw.terminal;
|
|
534
|
+
const terminal = {};
|
|
535
|
+
if (terminalSource !== undefined) {
|
|
536
|
+
if (!isPlainObject(terminalSource)) {
|
|
537
|
+
warnings.push({
|
|
538
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
539
|
+
message: `kind convention '${doc.id}' has a non-map 'fields.terminal' (${describeShape(terminalSource)}; expected a map of field name -> list of terminal values); ignoring it.`,
|
|
540
|
+
field: "fields.terminal",
|
|
541
|
+
severity: "warning",
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
for (const [field, terminalValues] of Object.entries(terminalSource)) {
|
|
546
|
+
if (dropReserved(field, `fields.terminal.${field}`))
|
|
547
|
+
continue;
|
|
548
|
+
setOwn(terminal, field, toStringArrayLenient(terminalValues, `fields.terminal.${field}`, doc.id, warnings));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Coherence warning 1: a terminal set declared over a field with no `fields.values` enum at all
|
|
553
|
+
// (mirrors the UNDECLARED_VALUES_FIELD check above — the author probably meant to declare the
|
|
554
|
+
// enum too). Coherence warning 2: a terminal VALUE that isn't one of that field's declared enum
|
|
555
|
+
// values (only checked when the field's enum IS declared, to avoid double-warning the same
|
|
556
|
+
// mistake two different ways).
|
|
557
|
+
for (const field of Object.keys(terminal)) {
|
|
558
|
+
if (!hasOwn(values, field)) {
|
|
559
|
+
warnings.push({
|
|
560
|
+
code: "KIND_CONVENTION_TERMINAL_UNDECLARED_FIELD",
|
|
561
|
+
message: `kind convention '${doc.id}' declares 'fields.terminal.${field}' but '${field}' has no 'fields.values.${field}' enum declared.`,
|
|
562
|
+
field: `fields.terminal.${field}`,
|
|
563
|
+
severity: "warning",
|
|
564
|
+
});
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const allowed = values[field];
|
|
568
|
+
for (const v of terminal[field]) {
|
|
569
|
+
if (!allowed.includes(v)) {
|
|
570
|
+
warnings.push({
|
|
571
|
+
code: "KIND_CONVENTION_TERMINAL_VALUE",
|
|
572
|
+
message: `kind convention '${doc.id}' declares terminal value '${v}' for field '${field}' but it is not one of the declared 'fields.values.${field}' values (${allowed.join(", ")}).`,
|
|
573
|
+
field: `fields.terminal.${field}`,
|
|
574
|
+
severity: "warning",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// `links:` — the typed-edge vocabulary (see the KindConvention.links doc comment). Same
|
|
580
|
+
// lenient posture as `fields`: absent is normal (no warning), a non-map shape warns and is
|
|
581
|
+
// ignored, a malformed entry warns and is skipped — never thrown.
|
|
582
|
+
const linksSource = fm.links;
|
|
583
|
+
let links;
|
|
584
|
+
if (linksSource !== undefined) {
|
|
585
|
+
if (!isPlainObject(linksSource)) {
|
|
586
|
+
warnings.push({
|
|
587
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
588
|
+
message: `kind convention '${doc.id}' has a non-map 'links' key (${describeShape(linksSource)}; expected a map of link type name -> target kind); ignoring it.`,
|
|
589
|
+
field: "links",
|
|
590
|
+
severity: "warning",
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
const parsed = {};
|
|
595
|
+
for (const [linkType, target] of Object.entries(linksSource)) {
|
|
596
|
+
const name = linkType.trim();
|
|
597
|
+
if (name === "" || !isScalar(target) || String(target).trim() === "") {
|
|
598
|
+
warnings.push({
|
|
599
|
+
code: "KIND_CONVENTION_BAD_MEMBER",
|
|
600
|
+
message: `kind convention '${doc.id}' has a malformed 'links' entry ('${linkType}': ${describeShape(target)}; expected 'link type name: target kind'); skipping it.`,
|
|
601
|
+
field: `links.${linkType}`,
|
|
602
|
+
severity: "warning",
|
|
603
|
+
});
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
setOwn(parsed, name, String(target).trim());
|
|
607
|
+
}
|
|
608
|
+
if (Object.keys(parsed).length > 0)
|
|
609
|
+
links = parsed;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Relationship descriptions belong to the SOURCE kind's `links` declaration. They are
|
|
613
|
+
// guidance only: malformed or undeclared entries warn and are skipped, never becoming a
|
|
614
|
+
// second link vocabulary or changing validation/storage behavior.
|
|
615
|
+
const linkDescriptionsSource = fm.link_descriptions;
|
|
616
|
+
let linkDescriptions;
|
|
617
|
+
if (linkDescriptionsSource !== undefined) {
|
|
618
|
+
if (!isPlainObject(linkDescriptionsSource)) {
|
|
619
|
+
warnings.push({
|
|
620
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
621
|
+
message: `kind convention '${doc.id}' has a non-map 'link_descriptions' key (${describeShape(linkDescriptionsSource)}; expected a map of declared link type name -> non-empty description); ignoring it.`,
|
|
622
|
+
field: "link_descriptions",
|
|
623
|
+
severity: "warning",
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
const parsed = {};
|
|
628
|
+
for (const [linkType, rawDescription] of Object.entries(linkDescriptionsSource)) {
|
|
629
|
+
const name = linkType.trim();
|
|
630
|
+
const semanticPath = `link_descriptions.${linkType}`;
|
|
631
|
+
if (name === "" || typeof rawDescription !== "string" || rawDescription.trim() === "") {
|
|
632
|
+
warnings.push({
|
|
633
|
+
code: "KIND_CONVENTION_BAD_MEMBER",
|
|
634
|
+
message: `kind convention '${doc.id}' has a malformed '${semanticPath}' (${describeShape(rawDescription)}; expected a declared link type name with a non-empty string description); skipping it.`,
|
|
635
|
+
field: semanticPath,
|
|
636
|
+
severity: "warning",
|
|
637
|
+
});
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (!links || !hasOwn(links, name)) {
|
|
641
|
+
warnings.push({
|
|
642
|
+
code: "KIND_CONVENTION_UNDECLARED_LINK_DESCRIPTION",
|
|
643
|
+
message: `kind convention '${doc.id}' declares '${semanticPath}' but '${name}' is not declared in links.`,
|
|
644
|
+
field: semanticPath,
|
|
645
|
+
severity: "warning",
|
|
646
|
+
});
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
setOwn(parsed, name, rawDescription.trim());
|
|
650
|
+
}
|
|
651
|
+
if (Object.keys(parsed).length > 0)
|
|
652
|
+
linkDescriptions = parsed;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// `expects_inbound:` — inbound-link expectations this kind declares on ITSELF as link TARGET
|
|
656
|
+
// (see the KindConvention.expectsInbound doc comment). Same lenient posture as `links`: absent
|
|
657
|
+
// is normal (no warning), a non-map shape warns and is ignored, a malformed entry warns and is
|
|
658
|
+
// skipped — never thrown.
|
|
659
|
+
const expectsInboundSource = fm.expects_inbound;
|
|
660
|
+
let expectsInbound;
|
|
661
|
+
if (expectsInboundSource !== undefined) {
|
|
662
|
+
if (!isPlainObject(expectsInboundSource)) {
|
|
663
|
+
warnings.push({
|
|
664
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
665
|
+
message: `kind convention '${doc.id}' has a non-map 'expects_inbound' key (${describeShape(expectsInboundSource)}; expected a map of link type name -> expected source kind); ignoring it.`,
|
|
666
|
+
field: "expects_inbound",
|
|
667
|
+
severity: "warning",
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
const parsed = {};
|
|
672
|
+
for (const [linkType, source] of Object.entries(expectsInboundSource)) {
|
|
673
|
+
const name = linkType.trim();
|
|
674
|
+
if (name === "" || !isScalar(source) || String(source).trim() === "") {
|
|
675
|
+
warnings.push({
|
|
676
|
+
code: "KIND_CONVENTION_BAD_MEMBER",
|
|
677
|
+
message: `kind convention '${doc.id}' has a malformed 'expects_inbound' entry ('${linkType}': ${describeShape(source)}; expected 'link type name: expected source kind'); skipping it.`,
|
|
678
|
+
field: `expects_inbound.${linkType}`,
|
|
679
|
+
severity: "warning",
|
|
680
|
+
});
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
setOwn(parsed, name, String(source).trim());
|
|
684
|
+
}
|
|
685
|
+
if (Object.keys(parsed).length > 0)
|
|
686
|
+
expectsInbound = parsed;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// `claim:` — the bundle-DECLARED claim vocabulary (see the KindClaimPolicy doc comment). Same
|
|
690
|
+
// lenient posture as `links`/`expects_inbound`: absent is normal (no warning), a non-map shape
|
|
691
|
+
// warns and is ignored, a malformed member warns and is skipped — never thrown. Unrecognized
|
|
692
|
+
// keys inside the block are IGNORED without a warning: this parse deliberately consumes only
|
|
693
|
+
// the two coordinates, so warning on the block's value vocabulary would be wrong.
|
|
694
|
+
const claimSource = fm.claim;
|
|
695
|
+
let claim;
|
|
696
|
+
if (claimSource !== undefined) {
|
|
697
|
+
if (!isPlainObject(claimSource)) {
|
|
698
|
+
warnings.push({
|
|
699
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
700
|
+
message: `kind convention '${doc.id}' has a non-map 'claim' key (${describeShape(claimSource)}; expected a map declaring 'owner_field' and/or 'state_field'); ignoring it.`,
|
|
701
|
+
field: "claim",
|
|
702
|
+
severity: "warning",
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const parsed = {};
|
|
707
|
+
for (const [key, target] of CLAIM_COORDINATE_KEYS) {
|
|
708
|
+
const declared = claimSource[key];
|
|
709
|
+
if (declared === undefined)
|
|
710
|
+
continue;
|
|
711
|
+
if (!isScalar(declared) || String(declared).trim() === "") {
|
|
712
|
+
warnings.push({
|
|
713
|
+
code: "KIND_CONVENTION_BAD_MEMBER",
|
|
714
|
+
message: `kind convention '${doc.id}' has a malformed 'claim.${key}' (${describeShape(declared)}; expected a declared field name); skipping it.`,
|
|
715
|
+
field: `claim.${key}`,
|
|
716
|
+
severity: "warning",
|
|
717
|
+
});
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
parsed[target] = String(declared).trim();
|
|
721
|
+
}
|
|
722
|
+
if (parsed.ownerField !== undefined || parsed.stateField !== undefined)
|
|
723
|
+
claim = parsed;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const sections = Array.isArray(fm.sections)
|
|
727
|
+
? fm.sections.filter((s) => typeof s === "string" && s.trim() !== "")
|
|
728
|
+
: undefined;
|
|
729
|
+
const title = typeof fm.title === "string" && fm.title.trim() !== "" ? fm.title.trim() : governs;
|
|
730
|
+
let description;
|
|
731
|
+
if (fm.description !== undefined) {
|
|
732
|
+
if (typeof fm.description === "string" && fm.description.trim() !== "") {
|
|
733
|
+
description = fm.description.trim();
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
warnings.push({
|
|
737
|
+
code: "KIND_CONVENTION_BAD_SHAPE",
|
|
738
|
+
message: `kind convention '${doc.id}' has an invalid 'description' (${describeShape(fm.description)}; expected a non-empty string); ignoring it.`,
|
|
739
|
+
field: "description",
|
|
740
|
+
severity: "warning",
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
const path = typeof fm.path === "string" && fm.path.trim() !== "" ? fm.path.trim() : undefined;
|
|
745
|
+
const freshnessHorizon = typeof fm.freshness_horizon === "string" && fm.freshness_horizon.trim() !== ""
|
|
746
|
+
? fm.freshness_horizon.trim()
|
|
747
|
+
: undefined;
|
|
748
|
+
// Strict boolean `true` only — an absent, false, or non-boolean value means "expanded" (the default).
|
|
749
|
+
const browseCollapsed = fm.browse_collapsed === true ? true : undefined;
|
|
750
|
+
const kind = {
|
|
751
|
+
id: doc.id,
|
|
752
|
+
title,
|
|
753
|
+
governs,
|
|
754
|
+
fields: { required, optional, values, valueDescriptions, terminal, descriptions },
|
|
755
|
+
};
|
|
756
|
+
if (description !== undefined)
|
|
757
|
+
kind.description = description;
|
|
758
|
+
if (path !== undefined)
|
|
759
|
+
kind.path = path;
|
|
760
|
+
if (links !== undefined)
|
|
761
|
+
kind.links = links;
|
|
762
|
+
if (linkDescriptions !== undefined)
|
|
763
|
+
kind.linkDescriptions = linkDescriptions;
|
|
764
|
+
if (expectsInbound !== undefined)
|
|
765
|
+
kind.expectsInbound = expectsInbound;
|
|
766
|
+
if (sections && sections.length > 0)
|
|
767
|
+
kind.sections = sections;
|
|
768
|
+
if (freshnessHorizon !== undefined)
|
|
769
|
+
kind.freshnessHorizon = freshnessHorizon;
|
|
770
|
+
if (browseCollapsed !== undefined)
|
|
771
|
+
kind.browseCollapsed = browseCollapsed;
|
|
772
|
+
if (claim !== undefined)
|
|
773
|
+
kind.claim = claim;
|
|
774
|
+
return {
|
|
775
|
+
ok: true,
|
|
776
|
+
kind,
|
|
777
|
+
reservedFieldsIgnored: [...reservedFieldsIgnored].sort(),
|
|
778
|
+
reservedFieldPaths: [...reservedFieldPaths].sort(),
|
|
779
|
+
warnings,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const HORIZON_RE = /^(\d+)(m|h|d)$/;
|
|
783
|
+
const HORIZON_UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
784
|
+
/**
|
|
785
|
+
* Parse a kind's declared `freshness_horizon` (`<n>(m|h|d)`) to milliseconds, or
|
|
786
|
+
* `undefined` if the kind declares none or it is malformed. This FEEDS the existing
|
|
787
|
+
* {@link FreshnessOptions.maxAgeMs} at the CLI layer — it does not fork `freshness()`.
|
|
788
|
+
*/
|
|
789
|
+
export function freshnessHorizonMs(kind) {
|
|
790
|
+
const raw = kind.freshnessHorizon;
|
|
791
|
+
if (raw === undefined)
|
|
792
|
+
return undefined;
|
|
793
|
+
const m = HORIZON_RE.exec(raw);
|
|
794
|
+
if (!m)
|
|
795
|
+
return undefined;
|
|
796
|
+
const n = Number(m[1]);
|
|
797
|
+
// A zero horizon (e.g. "0h") would make every instance instantly stale — reject it the same
|
|
798
|
+
// way as a malformed string, rather than silently accepting a horizon that can never be met.
|
|
799
|
+
if (n <= 0)
|
|
800
|
+
return undefined;
|
|
801
|
+
const unit = m[2];
|
|
802
|
+
return n * HORIZON_UNIT_MS[unit];
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* True when a required-field value counts as "present" (non-empty string / non-empty array / any
|
|
806
|
+
* other defined value). Exported so consumers that must AGREE with validation's presence predicate
|
|
807
|
+
* (e.g. the CLI's `kind draft` inference) reuse this one authority instead of forking it.
|
|
808
|
+
*/
|
|
809
|
+
export function isPresent(value) {
|
|
810
|
+
if (value === undefined || value === null)
|
|
811
|
+
return false;
|
|
812
|
+
if (typeof value === "string")
|
|
813
|
+
return value.trim() !== "";
|
|
814
|
+
if (Array.isArray(value))
|
|
815
|
+
return value.length > 0;
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Validate `doc` against `kind`: required fields present + non-empty, enum-restricted
|
|
820
|
+
* field values within the declared allowed set, and declared body `sections` present
|
|
821
|
+
* (reusing the ONE heading splitter, {@link splitSections} — no second heading parser).
|
|
822
|
+
* Returns core's EXISTING {@link ValidationWarning} shape; never throws. Purely
|
|
823
|
+
* additive derivation — callers (the CLI) decide whether a warning blocks a write.
|
|
824
|
+
*/
|
|
825
|
+
export function validateAgainstKind(doc, kind) {
|
|
826
|
+
const warnings = [];
|
|
827
|
+
const fm = doc.frontmatter;
|
|
828
|
+
for (const field of kind.fields.required) {
|
|
829
|
+
if (!hasOwn(fm, field) || !isPresent(fm[field])) {
|
|
830
|
+
warnings.push({
|
|
831
|
+
code: "KIND_FIELD_MISSING",
|
|
832
|
+
message: `'${kind.governs}' requires a non-empty '${field}' field (declared by ${kind.id}).`,
|
|
833
|
+
field,
|
|
834
|
+
severity: "warning",
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
for (const [field, allowed] of Object.entries(kind.fields.values)) {
|
|
839
|
+
if (!hasOwn(fm, field))
|
|
840
|
+
continue;
|
|
841
|
+
const raw = fm[field];
|
|
842
|
+
if (raw === undefined || raw === null)
|
|
843
|
+
continue;
|
|
844
|
+
// Arity: a `fields.values`-constrained field has SCALAR semantics by construction
|
|
845
|
+
// (an enum picks ONE state — `status`), so an ARRAY value is a violation even when
|
|
846
|
+
// every member passes the per-element membership check below. Without this,
|
|
847
|
+
// `--status todo --status done` persists a two-status doc with ZERO warnings, even
|
|
848
|
+
// strict (repeated-flag → array is a real FEATURE for non-enum fields like `tags`,
|
|
849
|
+
// so the guard lives HERE on the enum constraint, not in any command's parser — one
|
|
850
|
+
// validation locus, every consumer inherits: `new`, `doc update`, `doc write
|
|
851
|
+
// --strict`, `status`'s bundle lint). A future kind wanting a multi-select enum
|
|
852
|
+
// needs declared arity in the convention schema — not silently via arrays.
|
|
853
|
+
if (Array.isArray(raw)) {
|
|
854
|
+
warnings.push({
|
|
855
|
+
code: "KIND_FIELD_ARITY",
|
|
856
|
+
message: `'${field}' is enum-restricted and takes exactly ONE value for '${kind.governs}'; ` +
|
|
857
|
+
`got ${raw.length} (${raw.map((v) => String(v)).join(", ")}).`,
|
|
858
|
+
field,
|
|
859
|
+
severity: "warning",
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
const allowedStrs = allowed.map((v) => String(v));
|
|
863
|
+
const actual = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v));
|
|
864
|
+
for (const v of actual) {
|
|
865
|
+
if (!allowedStrs.includes(v)) {
|
|
866
|
+
warnings.push({
|
|
867
|
+
code: "KIND_FIELD_VALUE",
|
|
868
|
+
message: `'${field}' value '${v}' is not one of the allowed values for '${kind.governs}': ${allowedStrs.join(", ")}.`,
|
|
869
|
+
field,
|
|
870
|
+
severity: "warning",
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (kind.sections && kind.sections.length > 0) {
|
|
876
|
+
const sections = splitSections(doc.body ?? "");
|
|
877
|
+
for (const heading of kind.sections) {
|
|
878
|
+
if (!hasOwn(sections, heading)) {
|
|
879
|
+
warnings.push({
|
|
880
|
+
code: "KIND_SECTION_MISSING",
|
|
881
|
+
message: `'${kind.governs}' expects a '# ${heading}' body section (declared by ${kind.id}).`,
|
|
882
|
+
field: heading,
|
|
883
|
+
severity: "warning",
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return warnings;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Apply edition-aware legacy timestamp defaulting before evaluating the governing kind. v0.1
|
|
892
|
+
* retains the historical default on every write; v0.2 defaults it only when the bundle-local Kind
|
|
893
|
+
* explicitly requires that extension. The registry is supplied by the caller so mutation does
|
|
894
|
+
* not perform hidden discovery.
|
|
895
|
+
*/
|
|
896
|
+
export function defaultTimestampAndValidateAgainstRegistry(doc, registry, options = {}) {
|
|
897
|
+
const kind = registry.kinds.get(String(doc.frontmatter.type));
|
|
898
|
+
const shouldDefaultTimestamp = options.okfVersion !== "0.2"
|
|
899
|
+
|| kind?.fields.required.includes("timestamp") === true;
|
|
900
|
+
if (shouldDefaultTimestamp && !isUsableTimestamp(doc.frontmatter.timestamp)) {
|
|
901
|
+
doc.frontmatter.timestamp = (options.now ?? (() => new Date().toISOString()))();
|
|
902
|
+
}
|
|
903
|
+
if (!kind)
|
|
904
|
+
return { warnings: [] };
|
|
905
|
+
return { kind, warnings: validateAgainstKind(doc, kind) };
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* True iff `frontmatter` carries a terminal value on any field `kind.fields.terminal` declares
|
|
909
|
+
* (task board `tasks/status-terminal-declaration.md`) — THE one derivation every consumer calls
|
|
910
|
+
* (list's `--open`, the `status` sweep's exclusion + sort fallback). Coercion mirrors
|
|
911
|
+
* `validateAgainstKind`'s enum check: `String(v)` per element, so an unquoted YAML scalar still
|
|
912
|
+
* matches, and an array field matches on ANY-member semantics: ONE terminal value anywhere in an
|
|
913
|
+
* array field marks the whole doc terminal (a doc with `status: [done, doing]` IS terminal), so
|
|
914
|
+
* multi-valued fields should only declare terminal values whose mere presence means "closed". A
|
|
915
|
+
* kind with an empty `fields.terminal` (no declaration), or a doc missing every declared terminal
|
|
916
|
+
* field, is never terminal — not-terminal is the safe default.
|
|
917
|
+
*/
|
|
918
|
+
export function isTerminal(kind, frontmatter) {
|
|
919
|
+
const fm = frontmatter;
|
|
920
|
+
for (const [field, terminalValues] of Object.entries(kind.fields.terminal)) {
|
|
921
|
+
if (!hasOwn(fm, field))
|
|
922
|
+
continue;
|
|
923
|
+
const raw = fm[field];
|
|
924
|
+
if (raw === undefined || raw === null)
|
|
925
|
+
continue;
|
|
926
|
+
const actual = (Array.isArray(raw) ? raw : [raw]).map((v) => String(v));
|
|
927
|
+
if (actual.some((v) => terminalValues.includes(v)))
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Build the OKF concept document for a kind convention (the shape a `Convention` doc
|
|
934
|
+
* takes on disk — used to serialize any KindConvention to its on-disk Convention-doc form
|
|
935
|
+
* (e.g. by the CLI's recipe machinery)). `timestamp` is the caller's ISO instant (kept
|
|
936
|
+
* explicit rather than defaulted here, since `writeDocVersioned` already guarantees one).
|
|
937
|
+
*/
|
|
938
|
+
export function kindConventionDoc(kind, prose, timestamp) {
|
|
939
|
+
const fields = { required: kind.fields.required, optional: kind.fields.optional };
|
|
940
|
+
if (Object.keys(kind.fields.values).length > 0)
|
|
941
|
+
fields.values = kind.fields.values;
|
|
942
|
+
const valueDescriptions = Object.fromEntries(Object.entries(kind.fields.valueDescriptions ?? {})
|
|
943
|
+
.filter(([field]) => hasOwn(kind.fields.values, field))
|
|
944
|
+
.map(([field, descriptions]) => {
|
|
945
|
+
const allowed = kind.fields.values[field];
|
|
946
|
+
const validDescriptions = Object.fromEntries(Object.entries(descriptions)
|
|
947
|
+
.filter(([value, description]) => hasOwn(descriptions, value) &&
|
|
948
|
+
allowed.includes(value) &&
|
|
949
|
+
typeof description === "string" &&
|
|
950
|
+
description.trim() !== "")
|
|
951
|
+
.map(([value, description]) => [value, description.trim()]));
|
|
952
|
+
return [field, validDescriptions];
|
|
953
|
+
})
|
|
954
|
+
.filter(([, descriptions]) => Object.keys(descriptions).length > 0));
|
|
955
|
+
if (Object.keys(valueDescriptions).length > 0)
|
|
956
|
+
fields.value_descriptions = valueDescriptions;
|
|
957
|
+
if (Object.keys(kind.fields.terminal).length > 0)
|
|
958
|
+
fields.terminal = kind.fields.terminal;
|
|
959
|
+
const descriptions = Object.fromEntries(Object.entries(kind.fields.descriptions)
|
|
960
|
+
.filter((entry) => typeof entry[1] === "string" && entry[1].trim() !== "")
|
|
961
|
+
.map(([field, description]) => [field, description.trim()]));
|
|
962
|
+
if (Object.keys(descriptions).length > 0)
|
|
963
|
+
fields.descriptions = descriptions;
|
|
964
|
+
const frontmatter = { type: CONVENTION_TYPE, title: kind.title, governs: kind.governs, timestamp };
|
|
965
|
+
if (typeof kind.description === "string" && kind.description.trim() !== "") {
|
|
966
|
+
frontmatter.description = kind.description.trim();
|
|
967
|
+
}
|
|
968
|
+
if (kind.path !== undefined)
|
|
969
|
+
frontmatter.path = kind.path;
|
|
970
|
+
if (kind.links && Object.keys(kind.links).length > 0)
|
|
971
|
+
frontmatter.links = kind.links;
|
|
972
|
+
const linkDescriptions = Object.fromEntries(Object.entries(kind.linkDescriptions ?? {})
|
|
973
|
+
.filter((entry) => Boolean(kind.links && hasOwn(kind.links, entry[0])) &&
|
|
974
|
+
typeof entry[1] === "string" &&
|
|
975
|
+
entry[1].trim() !== "")
|
|
976
|
+
.map(([linkType, description]) => [linkType, description.trim()]));
|
|
977
|
+
if (Object.keys(linkDescriptions).length > 0)
|
|
978
|
+
frontmatter.link_descriptions = linkDescriptions;
|
|
979
|
+
if (kind.expectsInbound && Object.keys(kind.expectsInbound).length > 0) {
|
|
980
|
+
frontmatter.expects_inbound = kind.expectsInbound;
|
|
981
|
+
}
|
|
982
|
+
// Round-trip the claim declaration: a serializer that dropped it would silently un-declare
|
|
983
|
+
// ownership the first time a convention passed back through this writer.
|
|
984
|
+
if (kind.claim && (kind.claim.ownerField !== undefined || kind.claim.stateField !== undefined)) {
|
|
985
|
+
const claim = {};
|
|
986
|
+
if (kind.claim.ownerField !== undefined)
|
|
987
|
+
claim.owner_field = kind.claim.ownerField;
|
|
988
|
+
if (kind.claim.stateField !== undefined)
|
|
989
|
+
claim.state_field = kind.claim.stateField;
|
|
990
|
+
frontmatter.claim = claim;
|
|
991
|
+
}
|
|
992
|
+
frontmatter.fields = fields;
|
|
993
|
+
if (kind.sections && kind.sections.length > 0)
|
|
994
|
+
frontmatter.sections = kind.sections;
|
|
995
|
+
if (kind.freshnessHorizon !== undefined)
|
|
996
|
+
frontmatter.freshness_horizon = kind.freshnessHorizon;
|
|
997
|
+
if (kind.browseCollapsed)
|
|
998
|
+
frontmatter.browse_collapsed = true;
|
|
999
|
+
return { id: kind.id, frontmatter, body: prose };
|
|
1000
|
+
}
|
|
1001
|
+
//# sourceMappingURL=kinds.js.map
|