@contentrain/types 1.13.1 → 1.15.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/README.md +188 -1
- package/dist/index.d.mts +678 -8
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +512 -28
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
//#region src/canonical.ts
|
|
2
|
+
/** Recursively sort object keys — respects optional fieldOrder for top-level keys */
|
|
3
|
+
function sortKeys(obj, fieldOrder) {
|
|
4
|
+
if (obj === null || obj === void 0) return void 0;
|
|
5
|
+
if (Array.isArray(obj)) return obj.map((item) => sortKeys(item, fieldOrder));
|
|
6
|
+
if (typeof obj !== "object") return obj;
|
|
7
|
+
const record = obj;
|
|
8
|
+
const sorted = {};
|
|
9
|
+
const keys = fieldOrder ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])] : Object.keys(record).toSorted();
|
|
10
|
+
for (const key of keys) {
|
|
11
|
+
if (!(key in record)) continue;
|
|
12
|
+
const val = record[key];
|
|
13
|
+
if (val === null || val === void 0) continue;
|
|
14
|
+
sorted[key] = sortKeys(val);
|
|
15
|
+
}
|
|
16
|
+
return sorted;
|
|
17
|
+
}
|
|
18
|
+
/** Canonical JSON serialization — deterministic output for stable git diffs */
|
|
19
|
+
function canonicalStringify(data, fieldOrder) {
|
|
20
|
+
const sorted = sortKeys(data, fieldOrder);
|
|
21
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
1
24
|
//#region src/migration.ts
|
|
2
25
|
/**
|
|
3
26
|
* Version stamped into every migration document (`version` field on each root).
|
|
@@ -127,6 +150,338 @@ const COMPONENT_TYPES = [
|
|
|
127
150
|
];
|
|
128
151
|
const COMMENTS_EXPORT_FORMAT = "contentrain-comments@1";
|
|
129
152
|
//#endregion
|
|
153
|
+
//#region src/execution.ts
|
|
154
|
+
/**
|
|
155
|
+
* Version stamped into every execution document (`version` field on each root).
|
|
156
|
+
* A reader that sees a higher version than it knows should refuse rather than
|
|
157
|
+
* guess — a misread plan is a plan executed with the wrong scope.
|
|
158
|
+
*/
|
|
159
|
+
const EXECUTION_CONTRACT_VERSION = 1;
|
|
160
|
+
/**
|
|
161
|
+
* What is at stake in an operation, in ascending severity.
|
|
162
|
+
*
|
|
163
|
+
* The order is the contract, not a convenience: `RISK_CLASSES` is a ladder and
|
|
164
|
+
* a policy written for `bulk_content` is expected to also cover everything
|
|
165
|
+
* above it. The seven rungs come from the approval model — read-only work is
|
|
166
|
+
* automatic, content edits follow project policy, and anything that leaves the
|
|
167
|
+
* repository (money, third parties, production) is a separate decision from
|
|
168
|
+
* whether the content is correct.
|
|
169
|
+
*/
|
|
170
|
+
const RISK_CLASSES = [
|
|
171
|
+
"read_only",
|
|
172
|
+
"low_risk_content",
|
|
173
|
+
"bulk_content",
|
|
174
|
+
"destructive_schema",
|
|
175
|
+
"external_effect",
|
|
176
|
+
"financially_material",
|
|
177
|
+
"deployment"
|
|
178
|
+
];
|
|
179
|
+
/** Position on the ladder; higher is more severe. */
|
|
180
|
+
function riskRank(risk) {
|
|
181
|
+
return RISK_CLASSES.indexOf(risk);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The most severe of the given classes — the class a multi-step plan is
|
|
185
|
+
* evaluated against. A plan is exactly as safe as its worst step, so a
|
|
186
|
+
* read-only survey that ends in a deploy is a deploy.
|
|
187
|
+
*/
|
|
188
|
+
function highestRisk(risks) {
|
|
189
|
+
let worst = "read_only";
|
|
190
|
+
for (const risk of risks) if (riskRank(risk) > riskRank(worst)) worst = risk;
|
|
191
|
+
return worst;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The three moments at which a decision is asked for. They are distinct
|
|
195
|
+
* questions and collapsing them is the mistake this type exists to prevent:
|
|
196
|
+
* approving *what will be done* is not approving *what was produced*, and
|
|
197
|
+
* neither is permission to put it in front of the public.
|
|
198
|
+
*/
|
|
199
|
+
const APPROVAL_GATES = [
|
|
200
|
+
"plan",
|
|
201
|
+
"change",
|
|
202
|
+
"release"
|
|
203
|
+
];
|
|
204
|
+
/**
|
|
205
|
+
* How many people, and which, must say yes. Deliberately three modes: a
|
|
206
|
+
* generic workflow canvas was considered and rejected — policy-based approval
|
|
207
|
+
* covers every case the product actually has.
|
|
208
|
+
*/
|
|
209
|
+
const APPROVAL_MODES = [
|
|
210
|
+
"auto",
|
|
211
|
+
"single",
|
|
212
|
+
"quorum"
|
|
213
|
+
];
|
|
214
|
+
/**
|
|
215
|
+
* Run lifecycle. The main line is `draft → planned → awaiting_approval →
|
|
216
|
+
* approved → scheduled → queued → running → verifying → completed`; the rest
|
|
217
|
+
* are the states a run can sit in when something interrupts it.
|
|
218
|
+
*/
|
|
219
|
+
const RUN_STATUSES = [
|
|
220
|
+
"draft",
|
|
221
|
+
"planned",
|
|
222
|
+
"awaiting_approval",
|
|
223
|
+
"approved",
|
|
224
|
+
"scheduled",
|
|
225
|
+
"queued",
|
|
226
|
+
"running",
|
|
227
|
+
"verifying",
|
|
228
|
+
"completed",
|
|
229
|
+
"paused",
|
|
230
|
+
"needs_attention",
|
|
231
|
+
"retrying",
|
|
232
|
+
"failed",
|
|
233
|
+
"cancelled",
|
|
234
|
+
"expired",
|
|
235
|
+
"rolled_back"
|
|
236
|
+
];
|
|
237
|
+
/** Statuses from which a run will not proceed on its own. */
|
|
238
|
+
const TERMINAL_RUN_STATUSES = [
|
|
239
|
+
"completed",
|
|
240
|
+
"failed",
|
|
241
|
+
"cancelled",
|
|
242
|
+
"expired",
|
|
243
|
+
"rolled_back"
|
|
244
|
+
];
|
|
245
|
+
function isTerminalRunStatus(status) {
|
|
246
|
+
return TERMINAL_RUN_STATUSES.includes(status);
|
|
247
|
+
}
|
|
248
|
+
/** Approvers recorded on a receipt for one gate, in grant order. */
|
|
249
|
+
function approversFor(receipt, gate) {
|
|
250
|
+
return (receipt.approvals ?? []).filter((grant) => grant.gate === gate).map((grant) => grant.approver);
|
|
251
|
+
}
|
|
252
|
+
const SCHEDULE_KINDS = [
|
|
253
|
+
"once",
|
|
254
|
+
"recurring",
|
|
255
|
+
"content_event",
|
|
256
|
+
"git_event",
|
|
257
|
+
"runtime_event",
|
|
258
|
+
"external_webhook",
|
|
259
|
+
"manual"
|
|
260
|
+
];
|
|
261
|
+
const SOURCE_DELTA_OPS = [
|
|
262
|
+
"created",
|
|
263
|
+
"updated",
|
|
264
|
+
"deleted",
|
|
265
|
+
"moved"
|
|
266
|
+
];
|
|
267
|
+
/**
|
|
268
|
+
* The exact bytes `plan_hash` is computed over. Exposed because a hash is only
|
|
269
|
+
* useful if a disagreement can be investigated: when two sides compute
|
|
270
|
+
* different hashes for the same plan, comparing payloads shows which field
|
|
271
|
+
* differs, and comparing hashes shows nothing.
|
|
272
|
+
*
|
|
273
|
+
* Excluded: `plan_hash` itself (it cannot cover itself), and `id`,
|
|
274
|
+
* `created_at`, `created_by` and `idempotency_key` — who built a plan, when,
|
|
275
|
+
* under which run id, and with which deduplication key do not change what the
|
|
276
|
+
* plan will do. Everything else is covered, so a changed step, a widened
|
|
277
|
+
* scope, a raised estimate or a withdrawn rollback all invalidate every
|
|
278
|
+
* approval the plan had collected.
|
|
279
|
+
*/
|
|
280
|
+
function planHashPayload(plan) {
|
|
281
|
+
const { plan_hash: _hash, id: _id, created_at: _at, created_by: _by, idempotency_key: _key, ...semantic } = plan;
|
|
282
|
+
return canonicalStringify(semantic);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* SHA-256 of {@link planHashPayload}, lowercase hex.
|
|
286
|
+
*
|
|
287
|
+
* Async because it uses Web Crypto (`globalThis.crypto.subtle`), which is
|
|
288
|
+
* available in Node 18+, Deno, Bun, workers and browsers alike — this package
|
|
289
|
+
* is consumed in all of them and must not reach for `node:crypto`. A non-
|
|
290
|
+
* cryptographic hash was considered and rejected: approvals are pinned to this
|
|
291
|
+
* value, so a collision is an approval bypass, not a cache miss.
|
|
292
|
+
*/
|
|
293
|
+
async function computePlanHash(plan) {
|
|
294
|
+
const bytes = new TextEncoder().encode(planHashPayload(plan));
|
|
295
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
296
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
297
|
+
}
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/approval.ts
|
|
300
|
+
/**
|
|
301
|
+
* Used when a project has no `.contentrain/approval-policies.json`.
|
|
302
|
+
*
|
|
303
|
+
* Read-only work proceeds; everything else wants one reviewer on the produced
|
|
304
|
+
* diff. Deliberately stated as a value rather than buried in the evaluator so
|
|
305
|
+
* a project can inspect it, extend it, or reject it — and so "what happens
|
|
306
|
+
* without a policy file" has one answer instead of one per caller.
|
|
307
|
+
*
|
|
308
|
+
* Note this is a *default*, not the only gate in the system: whether a project
|
|
309
|
+
* consults the evaluator at all is still governed by its `workflow` setting.
|
|
310
|
+
*/
|
|
311
|
+
const DEFAULT_APPROVAL_POLICY = {
|
|
312
|
+
version: 1,
|
|
313
|
+
rules: [{
|
|
314
|
+
risk: "low_risk_content",
|
|
315
|
+
gate: "change",
|
|
316
|
+
mode: "single"
|
|
317
|
+
}]
|
|
318
|
+
};
|
|
319
|
+
/** Ascending strictness — used to keep the stricter of two modes. */
|
|
320
|
+
const MODE_RANK = {
|
|
321
|
+
auto: 0,
|
|
322
|
+
single: 1,
|
|
323
|
+
quorum: 2
|
|
324
|
+
};
|
|
325
|
+
/** How many distinct approvers a mode asks for. */
|
|
326
|
+
function approvalsNeeded(rule) {
|
|
327
|
+
if (rule.mode === "auto") return 0;
|
|
328
|
+
if (rule.mode === "single") return 1;
|
|
329
|
+
return Math.max(1, rule.min_approvals ?? 2);
|
|
330
|
+
}
|
|
331
|
+
/** Whether a rule's narrowing clauses match what the plan touches. */
|
|
332
|
+
function ruleMatchesScope(rule, plan) {
|
|
333
|
+
if (rule.models && rule.models.length > 0) {
|
|
334
|
+
const touched = plan.scope.models ?? [];
|
|
335
|
+
if (!rule.models.some((model) => touched.includes(model))) return false;
|
|
336
|
+
}
|
|
337
|
+
if (rule.locales && rule.locales.length > 0) {
|
|
338
|
+
const touched = plan.scope.locales ?? [];
|
|
339
|
+
if (!rule.locales.some((locale) => touched.includes(locale))) return false;
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The class a plan is judged at: its declared risk, or its worst step's if
|
|
345
|
+
* that is higher. A plan cannot understate itself — declaring `read_only`
|
|
346
|
+
* while carrying a deploy step would otherwise walk straight past the policy.
|
|
347
|
+
*/
|
|
348
|
+
function effectiveRisk(plan) {
|
|
349
|
+
return highestRisk([plan.risk, ...plan.steps.map((step) => step.risk)]);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The requirements a policy produces for a plan, before any decision is
|
|
353
|
+
* collected. This is what a plan card renders: "this needs one reviewer on the
|
|
354
|
+
* diff and two on release, because it deploys".
|
|
355
|
+
*
|
|
356
|
+
* Each matching rule contributes its own requirement and all of them must be
|
|
357
|
+
* met. Merging two rules into one would need a rule for combining their modes,
|
|
358
|
+
* roles and counts — and every such rule has a case where the result is
|
|
359
|
+
* *looser* than one of its inputs. Keeping them separate is what makes the
|
|
360
|
+
* policy additive in fact and not just in intent.
|
|
361
|
+
*
|
|
362
|
+
* `auto` rules are dropped rather than kept as zero-approver requirements: a
|
|
363
|
+
* requirement nobody has to satisfy is noise on the card, and a stricter rule
|
|
364
|
+
* matching the same gate still stands on its own.
|
|
365
|
+
*/
|
|
366
|
+
function requiredApprovals(plan, policy = DEFAULT_APPROVAL_POLICY) {
|
|
367
|
+
const risk = effectiveRisk(plan);
|
|
368
|
+
const requirements = [];
|
|
369
|
+
let matched = false;
|
|
370
|
+
for (const rule of policy.rules) {
|
|
371
|
+
if (!ruleMatchesScope(rule, plan)) continue;
|
|
372
|
+
if (!(rule.mode !== "auto" ? riskRank(risk) >= riskRank(rule.risk) : risk === rule.risk)) continue;
|
|
373
|
+
matched = true;
|
|
374
|
+
const min_approvals = approvalsNeeded(rule);
|
|
375
|
+
if (min_approvals === 0) continue;
|
|
376
|
+
requirements.push({
|
|
377
|
+
gate: rule.gate,
|
|
378
|
+
mode: rule.mode,
|
|
379
|
+
min_approvals,
|
|
380
|
+
...rule.roles && rule.roles.length > 0 ? { roles: [...rule.roles] } : {},
|
|
381
|
+
because: rule.risk
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (!matched && policy.default_mode && policy.default_mode !== "auto") requirements.push({
|
|
385
|
+
gate: "change",
|
|
386
|
+
mode: policy.default_mode,
|
|
387
|
+
min_approvals: approvalsNeeded({
|
|
388
|
+
risk,
|
|
389
|
+
gate: "change",
|
|
390
|
+
mode: policy.default_mode
|
|
391
|
+
}),
|
|
392
|
+
because: risk
|
|
393
|
+
});
|
|
394
|
+
return requirements.toSorted((a, b) => APPROVAL_GATES.indexOf(a.gate) - APPROVAL_GATES.indexOf(b.gate) || MODE_RANK[b.mode] - MODE_RANK[a.mode] || b.min_approvals - a.min_approvals);
|
|
395
|
+
}
|
|
396
|
+
/** Whether a grant may be counted at all, independent of any requirement. */
|
|
397
|
+
function grantDisqualification(grant, plan, policy, commit_sha, now) {
|
|
398
|
+
if (grant.plan_hash !== plan.plan_hash) return "plan_hash_mismatch";
|
|
399
|
+
if (grant.expires_at && grant.expires_at <= now) return "expired";
|
|
400
|
+
if (grant.approver.kind === "agent") return "agent_approver";
|
|
401
|
+
if (!policy.allow_self_approval && plan.created_by && grant.approver.id === plan.created_by.id && grant.approver.kind === plan.created_by.kind) return "self_approval";
|
|
402
|
+
if (grant.gate === "change" && commit_sha && grant.commit_sha !== commit_sha) return "commit_mismatch";
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* May this plan proceed?
|
|
406
|
+
*
|
|
407
|
+
* Answers with the full picture rather than a boolean: which requirements
|
|
408
|
+
* exist, which are met, who met them, and — for every decision that was
|
|
409
|
+
* supplied but did not count — why it did not. The last part is what makes
|
|
410
|
+
* "I approved this, why is it still blocked?" answerable without reading the
|
|
411
|
+
* evaluator's source.
|
|
412
|
+
*/
|
|
413
|
+
function evaluateApproval(input) {
|
|
414
|
+
const { plan, commit_sha } = input;
|
|
415
|
+
const policy = input.policy ?? DEFAULT_APPROVAL_POLICY;
|
|
416
|
+
const grants = input.grants ?? [];
|
|
417
|
+
const now = input.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
418
|
+
const risk = effectiveRisk(plan);
|
|
419
|
+
const requirements = requiredApprovals(plan, policy).map((requirement) => Object.assign(requirement, {
|
|
420
|
+
approvers: [],
|
|
421
|
+
remaining: requirement.min_approvals
|
|
422
|
+
}));
|
|
423
|
+
const rejected_grants = [];
|
|
424
|
+
for (const grant of grants) {
|
|
425
|
+
const disqualified = grantDisqualification(grant, plan, policy, commit_sha, now);
|
|
426
|
+
if (disqualified) {
|
|
427
|
+
rejected_grants.push({
|
|
428
|
+
grant,
|
|
429
|
+
reason: disqualified
|
|
430
|
+
});
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
let counted = false;
|
|
434
|
+
let sawGate = false;
|
|
435
|
+
let roleBlocked = false;
|
|
436
|
+
let duplicate = false;
|
|
437
|
+
for (const requirement of requirements) {
|
|
438
|
+
if (requirement.gate !== grant.gate) continue;
|
|
439
|
+
sawGate = true;
|
|
440
|
+
if (requirement.roles && !requirement.roles.includes(grant.approver.role ?? "")) {
|
|
441
|
+
roleBlocked = true;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (requirement.approvers.some((approver) => approver.id === grant.approver.id)) {
|
|
445
|
+
duplicate = true;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
requirement.approvers.push(grant.approver);
|
|
449
|
+
requirement.remaining = Math.max(0, requirement.min_approvals - requirement.approvers.length);
|
|
450
|
+
counted = true;
|
|
451
|
+
}
|
|
452
|
+
if (counted) continue;
|
|
453
|
+
if (!sawGate) rejected_grants.push({
|
|
454
|
+
grant,
|
|
455
|
+
reason: "no_matching_requirement"
|
|
456
|
+
});
|
|
457
|
+
else if (duplicate) rejected_grants.push({
|
|
458
|
+
grant,
|
|
459
|
+
reason: "duplicate_approver"
|
|
460
|
+
});
|
|
461
|
+
else if (roleBlocked) rejected_grants.push({
|
|
462
|
+
grant,
|
|
463
|
+
reason: "role_not_permitted"
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const outstanding = requirements.filter((requirement) => requirement.remaining > 0);
|
|
467
|
+
const reasons = [];
|
|
468
|
+
const expired = Boolean(plan.expires_at && plan.expires_at <= now);
|
|
469
|
+
if (expired) reasons.push(`The plan expired at ${plan.expires_at}; rebuild it rather than running it.`);
|
|
470
|
+
for (const requirement of outstanding) {
|
|
471
|
+
const who = requirement.roles ? ` from ${requirement.roles.join(" or ")}` : "";
|
|
472
|
+
const have = requirement.approvers.length;
|
|
473
|
+
reasons.push(`${requirement.gate} approval: ${requirement.remaining} more${who} needed (${have}/${requirement.min_approvals}), because the plan is ${requirement.because}.`);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
allowed: !expired && outstanding.length === 0,
|
|
477
|
+
risk,
|
|
478
|
+
requirements,
|
|
479
|
+
outstanding,
|
|
480
|
+
rejected_grants,
|
|
481
|
+
reasons
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
130
485
|
//#region src/provider.ts
|
|
131
486
|
/** Capability set for the LocalProvider (simple-git + worktree). */
|
|
132
487
|
const LOCAL_CAPABILITIES = {
|
|
@@ -171,6 +526,10 @@ const PATH_PATTERNS = {
|
|
|
171
526
|
context: ".contentrain/context.json",
|
|
172
527
|
vocabulary: ".contentrain/vocabulary.json",
|
|
173
528
|
model: ".contentrain/models/{modelId}.json",
|
|
529
|
+
capabilities: ".contentrain/capabilities.json",
|
|
530
|
+
automations: ".contentrain/automations.json",
|
|
531
|
+
approvalPolicies: ".contentrain/approval-policies.json",
|
|
532
|
+
redirects: ".contentrain/redirects.json",
|
|
174
533
|
content: {
|
|
175
534
|
singleton: ".contentrain/content/{domain}/{modelId}/{locale}.json",
|
|
176
535
|
collection: ".contentrain/content/{domain}/{modelId}/{locale}.json",
|
|
@@ -186,6 +545,37 @@ const PATH_PATTERNS = {
|
|
|
186
545
|
dictionary: ".contentrain/meta/{modelId}/{locale}.json"
|
|
187
546
|
}
|
|
188
547
|
};
|
|
548
|
+
/**
|
|
549
|
+
* Paths this repository reserves but does not yet write.
|
|
550
|
+
*
|
|
551
|
+
* Reserving them is the point. `.contentrain/` is a shared namespace: the
|
|
552
|
+
* migration engine, Studio and a customer's own tooling all write into it. A
|
|
553
|
+
* name claimed here cannot later be taken for something else, and — more
|
|
554
|
+
* usefully — the tools that walk the directory know these four are *expected*
|
|
555
|
+
* rather than stray.
|
|
556
|
+
*
|
|
557
|
+
* The contract for them is deliberately narrow and must stay that way until a
|
|
558
|
+
* tool actually owns one:
|
|
559
|
+
*
|
|
560
|
+
* - `contentrain_doctor` and `contentrain_validate` ignore them. They are not
|
|
561
|
+
* orphans, not broken content, and not the validator's business.
|
|
562
|
+
* - `contentrain_reconcile` treats each as one opaque file: it takes the side
|
|
563
|
+
* that changed, and reports `file_conflict` when both sides did. It never
|
|
564
|
+
* merges their interiors, because it does not know their interiors.
|
|
565
|
+
*
|
|
566
|
+
* Both behaviours are pinned by tests in `@contentrain/mcp`, so a future
|
|
567
|
+
* change to directory walking cannot quietly start flagging or auto-merging
|
|
568
|
+
* these files.
|
|
569
|
+
*/
|
|
570
|
+
const RESERVED_PATHS = [
|
|
571
|
+
PATH_PATTERNS.capabilities,
|
|
572
|
+
PATH_PATTERNS.automations,
|
|
573
|
+
PATH_PATTERNS.approvalPolicies,
|
|
574
|
+
PATH_PATTERNS.redirects
|
|
575
|
+
];
|
|
576
|
+
function isReservedPath(path) {
|
|
577
|
+
return RESERVED_PATHS.includes(path);
|
|
578
|
+
}
|
|
189
579
|
/** Slug: lowercase alphanumeric with hyphens */
|
|
190
580
|
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
191
581
|
/** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */
|
|
@@ -603,27 +993,6 @@ function validateFieldValue(value, fieldDef) {
|
|
|
603
993
|
}
|
|
604
994
|
return issues;
|
|
605
995
|
}
|
|
606
|
-
/** Recursively sort object keys — respects optional fieldOrder for top-level keys */
|
|
607
|
-
function sortKeys(obj, fieldOrder) {
|
|
608
|
-
if (obj === null || obj === void 0) return void 0;
|
|
609
|
-
if (Array.isArray(obj)) return obj.map((item) => sortKeys(item, fieldOrder));
|
|
610
|
-
if (typeof obj !== "object") return obj;
|
|
611
|
-
const record = obj;
|
|
612
|
-
const sorted = {};
|
|
613
|
-
const keys = fieldOrder ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])] : Object.keys(record).toSorted();
|
|
614
|
-
for (const key of keys) {
|
|
615
|
-
if (!(key in record)) continue;
|
|
616
|
-
const val = record[key];
|
|
617
|
-
if (val === null || val === void 0) continue;
|
|
618
|
-
sorted[key] = sortKeys(val);
|
|
619
|
-
}
|
|
620
|
-
return sorted;
|
|
621
|
-
}
|
|
622
|
-
/** Canonical JSON serialization — deterministic output for stable git diffs */
|
|
623
|
-
function canonicalStringify(data, fieldOrder) {
|
|
624
|
-
const sorted = sortKeys(data, fieldOrder);
|
|
625
|
-
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
626
|
-
}
|
|
627
996
|
/** Generate a 12-character hex entry ID */
|
|
628
997
|
function generateEntryId() {
|
|
629
998
|
const hex = "0123456789abcdef";
|
|
@@ -669,18 +1038,128 @@ function conflictId(conflict) {
|
|
|
669
1038
|
sortKeys(conflict.theirs) ?? null
|
|
670
1039
|
]));
|
|
671
1040
|
}
|
|
672
|
-
|
|
1041
|
+
/**
|
|
1042
|
+
* Decode the interior of a double-quoted scalar, or `undefined` when the text
|
|
1043
|
+
* is not one scalar after all — an unescaped `"` inside it, or a backslash with
|
|
1044
|
+
* nothing after it.
|
|
1045
|
+
*
|
|
1046
|
+
* Returning `undefined` rather than guessing matters: `"a" and "b"` starts and
|
|
1047
|
+
* ends with a quote without being a quoted scalar, and slicing the ends off it
|
|
1048
|
+
* would silently corrupt the value.
|
|
1049
|
+
*
|
|
1050
|
+
* An unrecognised escape keeps its backslash instead of erroring. Our own
|
|
1051
|
+
* serializer only ever emits `\\`, `\"`, `\n`, `\r` and `\t`, so round-tripping is
|
|
1052
|
+
* unaffected either way — but hand-written frontmatter says `"C:\Users"` and
|
|
1053
|
+
* losing that to strictness would be a worse trade than keeping the bytes.
|
|
1054
|
+
*/
|
|
1055
|
+
function decodeDoubleQuoted(inner) {
|
|
1056
|
+
let out = "";
|
|
1057
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
1058
|
+
const char = inner[i];
|
|
1059
|
+
if (char === "\"") return void 0;
|
|
1060
|
+
if (char !== "\\") {
|
|
1061
|
+
out += char;
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const next = inner[i + 1];
|
|
1065
|
+
if (next === void 0) return void 0;
|
|
1066
|
+
i += 1;
|
|
1067
|
+
if (next === "n") out += "\n";
|
|
1068
|
+
else if (next === "r") out += "\r";
|
|
1069
|
+
else if (next === "t") out += " ";
|
|
1070
|
+
else if (next === "\\" || next === "\"" || next === "/") out += next;
|
|
1071
|
+
else if (next === "u" && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 1, i + 5))) {
|
|
1072
|
+
out += String.fromCharCode(parseInt(inner.slice(i + 1, i + 5), 16));
|
|
1073
|
+
i += 4;
|
|
1074
|
+
} else out += `\\${next}`;
|
|
1075
|
+
}
|
|
1076
|
+
return out;
|
|
1077
|
+
}
|
|
1078
|
+
/** Decode a single-quoted scalar, where YAML escapes a quote by doubling it. */
|
|
1079
|
+
function decodeSingleQuoted(inner) {
|
|
1080
|
+
let out = "";
|
|
1081
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
1082
|
+
const char = inner[i];
|
|
1083
|
+
if (char !== "'") {
|
|
1084
|
+
out += char;
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (inner[i + 1] !== "'") return void 0;
|
|
1088
|
+
out += "'";
|
|
1089
|
+
i += 1;
|
|
1090
|
+
}
|
|
1091
|
+
return out;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Read one frontmatter scalar: booleans, null, numbers, quoted strings with
|
|
1095
|
+
* their escapes decoded, or the text as written.
|
|
1096
|
+
*
|
|
1097
|
+
* Exported because it is a **contract**, not a utility. Two readers open these
|
|
1098
|
+
* documents — this package (through `parseMarkdownFrontmatter`, which
|
|
1099
|
+
* `@contentrain/mcp` re-exports) and `@contentrain/query`'s generator and Astro
|
|
1100
|
+
* loader. When each had its own copy they disagreed about what `"He said
|
|
1101
|
+
* \"Hi\""` says, which means the generated client and the content engine
|
|
1102
|
+
* disagreed about the same file. One implementation, imported by both.
|
|
1103
|
+
*/
|
|
1104
|
+
function parseFrontmatterScalar(raw) {
|
|
673
1105
|
if (raw === "true") return true;
|
|
674
1106
|
if (raw === "false") return false;
|
|
675
1107
|
if (raw === "null") return null;
|
|
676
1108
|
if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
|
|
677
1109
|
if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
|
|
678
|
-
if (raw.
|
|
1110
|
+
if (raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"")) return decodeDoubleQuoted(raw.slice(1, -1)) ?? raw;
|
|
1111
|
+
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return decodeSingleQuoted(raw.slice(1, -1)) ?? raw;
|
|
679
1112
|
return raw;
|
|
680
1113
|
}
|
|
1114
|
+
/**
|
|
1115
|
+
* A scalar read as a string: quoting and escapes are resolved, but a value that
|
|
1116
|
+
* merely looks like a number or a boolean stays the text it was. Array items
|
|
1117
|
+
* use this, and so does any reader that knows from the model that a field is
|
|
1118
|
+
* string-typed — a SKU of `"007"` must not become the number 7.
|
|
1119
|
+
*/
|
|
1120
|
+
function parseFrontmatterScalarString(raw) {
|
|
1121
|
+
const value = parseFrontmatterScalar(raw);
|
|
1122
|
+
return typeof value === "string" ? value : raw;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Split an inline array's interior on commas that are not inside a quoted
|
|
1126
|
+
* item — `["a, b", c]` is two items, not three. Exported for the same reason
|
|
1127
|
+
* as {@link parseFrontmatterScalar}: both readers must split it the same way.
|
|
1128
|
+
*/
|
|
1129
|
+
function splitFrontmatterList(inner) {
|
|
1130
|
+
const items = [];
|
|
1131
|
+
let current = "";
|
|
1132
|
+
let quote;
|
|
1133
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
1134
|
+
const char = inner[i];
|
|
1135
|
+
if (quote) {
|
|
1136
|
+
if (char === "\\" && quote === "\"" && i + 1 < inner.length) {
|
|
1137
|
+
current += char + inner[i + 1];
|
|
1138
|
+
i += 1;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
current += char;
|
|
1142
|
+
if (char === quote) quote = void 0;
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (char === "\"" || char === "'") {
|
|
1146
|
+
quote = char;
|
|
1147
|
+
current += char;
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
if (char === ",") {
|
|
1151
|
+
items.push(current);
|
|
1152
|
+
current = "";
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
current += char;
|
|
1156
|
+
}
|
|
1157
|
+
items.push(current);
|
|
1158
|
+
return items;
|
|
1159
|
+
}
|
|
681
1160
|
function yamlScalar(value) {
|
|
682
1161
|
const str = String(value);
|
|
683
|
-
if (str.includes(":") || str.includes("#") || str.includes("{") || str.includes("}") || str.includes("[") || str.includes("]") || str.includes("*") || str.includes("&") || str.includes("!") || str.includes("|") || str.includes(">") || str.includes("'") || str.includes("\"") || str.includes("%") || str.includes("@") || str.includes("`") || str.
|
|
1162
|
+
if (str.includes(":") || str.includes("#") || str.includes("{") || str.includes("}") || str.includes("[") || str.includes("]") || str.includes("*") || str.includes("&") || str.includes("!") || str.includes("|") || str.includes(">") || str.includes("'") || str.includes("\"") || str.includes("%") || str.includes("@") || str.includes("`") || str.includes("\\") || str.includes("\n") || str.includes("\r") || str.includes(" ") || str.startsWith("-") || str.startsWith("?") || str !== str.trim() || str === "" || typeof value === "string" && (parseFrontmatterScalar(str) !== str || str === "yes" || str === "no")) return `"${str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
684
1163
|
return str;
|
|
685
1164
|
}
|
|
686
1165
|
function serializeYamlValue(value, indent) {
|
|
@@ -728,6 +1207,10 @@ function serializeYamlFields(data) {
|
|
|
728
1207
|
lines.push(`${key}:`);
|
|
729
1208
|
lines.push(...serializeYamlValue(value, 1));
|
|
730
1209
|
} else if (Array.isArray(value)) {
|
|
1210
|
+
if (value.length === 0) {
|
|
1211
|
+
lines.push(`${key}: []`);
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
731
1214
|
lines.push(`${key}:`);
|
|
732
1215
|
for (const item of value) if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
733
1216
|
const nested = serializeYamlValue(item, 2);
|
|
@@ -755,7 +1238,7 @@ function parseMarkdownFrontmatter(content) {
|
|
|
755
1238
|
let currentArray = null;
|
|
756
1239
|
for (const line of frontmatterStr.split("\n")) {
|
|
757
1240
|
if (/^\s+-\s+/.test(line) && currentKey) {
|
|
758
|
-
const value = line.replace(/^\s+-\s+/, "").trim();
|
|
1241
|
+
const value = parseFrontmatterScalarString(line.replace(/^\s+-\s+/, "").trim());
|
|
759
1242
|
if (!currentArray) currentArray = [];
|
|
760
1243
|
currentArray.push(value);
|
|
761
1244
|
continue;
|
|
@@ -775,10 +1258,11 @@ function parseMarkdownFrontmatter(content) {
|
|
|
775
1258
|
continue;
|
|
776
1259
|
}
|
|
777
1260
|
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
|
|
778
|
-
|
|
1261
|
+
const inner = rawValue.slice(1, -1).trim();
|
|
1262
|
+
frontmatter[key] = inner === "" ? [] : splitFrontmatterList(inner).map((item) => parseFrontmatterScalarString(item.trim()));
|
|
779
1263
|
continue;
|
|
780
1264
|
}
|
|
781
|
-
frontmatter[key] =
|
|
1265
|
+
frontmatter[key] = parseFrontmatterScalar(rawValue);
|
|
782
1266
|
}
|
|
783
1267
|
if (currentKey && currentArray) frontmatter[currentKey] = currentArray;
|
|
784
1268
|
return {
|
|
@@ -824,6 +1308,6 @@ function serializeMarkdownFrontmatter(data, body) {
|
|
|
824
1308
|
return lines.join("\n");
|
|
825
1309
|
}
|
|
826
1310
|
//#endregion
|
|
827
|
-
export { CANONICAL_JSON, CAPABILITY_KEYS, CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, CHROME_IF_CLOSE, CHROME_IF_OPEN, CHROME_REPEAT_CLOSE, CHROME_REPEAT_OPEN, COMMENTS_EXPORT_FORMAT, COMPONENT_TYPES, CONTENTRAIN_BRANCH, CONTENTRAIN_DIR, DICTIONARY_TITLE_FIELD, ENTRY_ID_PATTERN, LEGACY_CSS_LAYER, LIST_ITEMS_SLOT, LOCALE_PATTERN, LOCAL_CAPABILITIES, MIGRATION_CONTRACT_VERSION, MODEL_EXTENSION_KEYS, MODEL_FIELD_ORDER, PATH_PATTERNS, RAW_MARK_SUFFIX, SECRET_PATTERNS, SLUG_PATTERN, SOURCE_ACCESS_LADDER, TITLE_FIELD_TYPES, canonicalStringify, componentSlot, conflictId, detectSecrets, generateEntryId, isMediaType, isTitleFieldType, looksLikeCredential, orderedFieldNames, parseMarkdownFrontmatter, resolveFieldLabel, serializeMarkdownFrontmatter, sortKeys, stableHash, validateAccept, validateEntryId, validateFieldValue, validateLocale, validateSemanticType, validateSlug };
|
|
1311
|
+
export { APPROVAL_GATES, APPROVAL_MODES, CANONICAL_JSON, CAPABILITY_KEYS, CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, CHROME_IF_CLOSE, CHROME_IF_OPEN, CHROME_REPEAT_CLOSE, CHROME_REPEAT_OPEN, COMMENTS_EXPORT_FORMAT, COMPONENT_TYPES, CONTENTRAIN_BRANCH, CONTENTRAIN_DIR, DEFAULT_APPROVAL_POLICY, DICTIONARY_TITLE_FIELD, ENTRY_ID_PATTERN, EXECUTION_CONTRACT_VERSION, LEGACY_CSS_LAYER, LIST_ITEMS_SLOT, LOCALE_PATTERN, LOCAL_CAPABILITIES, MIGRATION_CONTRACT_VERSION, MODEL_EXTENSION_KEYS, MODEL_FIELD_ORDER, PATH_PATTERNS, RAW_MARK_SUFFIX, RESERVED_PATHS, RISK_CLASSES, RUN_STATUSES, SCHEDULE_KINDS, SECRET_PATTERNS, SLUG_PATTERN, SOURCE_ACCESS_LADDER, SOURCE_DELTA_OPS, TERMINAL_RUN_STATUSES, TITLE_FIELD_TYPES, approversFor, canonicalStringify, componentSlot, computePlanHash, conflictId, detectSecrets, effectiveRisk, evaluateApproval, generateEntryId, highestRisk, isMediaType, isReservedPath, isTerminalRunStatus, isTitleFieldType, looksLikeCredential, orderedFieldNames, parseFrontmatterScalar, parseFrontmatterScalarString, parseMarkdownFrontmatter, planHashPayload, requiredApprovals, resolveFieldLabel, riskRank, serializeMarkdownFrontmatter, sortKeys, splitFrontmatterList, stableHash, validateAccept, validateEntryId, validateFieldValue, validateLocale, validateSemanticType, validateSlug };
|
|
828
1312
|
|
|
829
1313
|
//# sourceMappingURL=index.mjs.map
|