@contentrain/types 1.13.0 → 1.14.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 +646 -9
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +528 -30
- 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 = {
|
|
@@ -153,17 +508,35 @@ const CONTENTRAIN_DIR = ".contentrain";
|
|
|
153
508
|
* `{projectRoot}/{content_path}/` instead — but meta files always
|
|
154
509
|
* remain under `.contentrain/meta/`.
|
|
155
510
|
*/
|
|
511
|
+
/**
|
|
512
|
+
* Where each kind of file lives. These are the paths a reader can rely on and
|
|
513
|
+
* a writer must produce; `@contentrain/mcp` resolves exactly these, and a
|
|
514
|
+
* parity test there holds the two together.
|
|
515
|
+
*
|
|
516
|
+
* They show the default `locale_strategy: 'file'`. The `suffix`, `directory`
|
|
517
|
+
* and `none` strategies move the locale within the same directory — see
|
|
518
|
+
* `LocaleStrategy` — and `content_path` replaces the
|
|
519
|
+
* `.contentrain/content/{domain}/{modelId}` prefix entirely.
|
|
520
|
+
*
|
|
521
|
+
* Meta always carries a locale in its file name: a non-i18n model keeps one
|
|
522
|
+
* meta record, pinned to the project's default locale.
|
|
523
|
+
*/
|
|
156
524
|
const PATH_PATTERNS = {
|
|
157
525
|
config: ".contentrain/config.json",
|
|
158
526
|
context: ".contentrain/context.json",
|
|
159
527
|
vocabulary: ".contentrain/vocabulary.json",
|
|
160
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",
|
|
161
533
|
content: {
|
|
162
534
|
singleton: ".contentrain/content/{domain}/{modelId}/{locale}.json",
|
|
163
535
|
collection: ".contentrain/content/{domain}/{modelId}/{locale}.json",
|
|
164
|
-
document: ".contentrain/content/{domain}/{slug}/{locale}.md",
|
|
536
|
+
document: ".contentrain/content/{domain}/{modelId}/{slug}/{locale}.md",
|
|
165
537
|
dictionary: ".contentrain/content/{domain}/{modelId}/{locale}.json",
|
|
166
|
-
noLocale: ".contentrain/content/{domain}/{modelId}/data.json"
|
|
538
|
+
noLocale: ".contentrain/content/{domain}/{modelId}/data.json",
|
|
539
|
+
noLocaleDocument: ".contentrain/content/{domain}/{modelId}/{slug}.md"
|
|
167
540
|
},
|
|
168
541
|
meta: {
|
|
169
542
|
singleton: ".contentrain/meta/{modelId}/{locale}.json",
|
|
@@ -172,6 +545,37 @@ const PATH_PATTERNS = {
|
|
|
172
545
|
dictionary: ".contentrain/meta/{modelId}/{locale}.json"
|
|
173
546
|
}
|
|
174
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
|
+
}
|
|
175
579
|
/** Slug: lowercase alphanumeric with hyphens */
|
|
176
580
|
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
177
581
|
/** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */
|
|
@@ -589,27 +993,6 @@ function validateFieldValue(value, fieldDef) {
|
|
|
589
993
|
}
|
|
590
994
|
return issues;
|
|
591
995
|
}
|
|
592
|
-
/** Recursively sort object keys — respects optional fieldOrder for top-level keys */
|
|
593
|
-
function sortKeys(obj, fieldOrder) {
|
|
594
|
-
if (obj === null || obj === void 0) return void 0;
|
|
595
|
-
if (Array.isArray(obj)) return obj.map((item) => sortKeys(item, fieldOrder));
|
|
596
|
-
if (typeof obj !== "object") return obj;
|
|
597
|
-
const record = obj;
|
|
598
|
-
const sorted = {};
|
|
599
|
-
const keys = fieldOrder ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])] : Object.keys(record).toSorted();
|
|
600
|
-
for (const key of keys) {
|
|
601
|
-
if (!(key in record)) continue;
|
|
602
|
-
const val = record[key];
|
|
603
|
-
if (val === null || val === void 0) continue;
|
|
604
|
-
sorted[key] = sortKeys(val);
|
|
605
|
-
}
|
|
606
|
-
return sorted;
|
|
607
|
-
}
|
|
608
|
-
/** Canonical JSON serialization — deterministic output for stable git diffs */
|
|
609
|
-
function canonicalStringify(data, fieldOrder) {
|
|
610
|
-
const sorted = sortKeys(data, fieldOrder);
|
|
611
|
-
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
612
|
-
}
|
|
613
996
|
/** Generate a 12-character hex entry ID */
|
|
614
997
|
function generateEntryId() {
|
|
615
998
|
const hex = "0123456789abcdef";
|
|
@@ -655,18 +1038,128 @@ function conflictId(conflict) {
|
|
|
655
1038
|
sortKeys(conflict.theirs) ?? null
|
|
656
1039
|
]));
|
|
657
1040
|
}
|
|
658
|
-
|
|
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) {
|
|
659
1105
|
if (raw === "true") return true;
|
|
660
1106
|
if (raw === "false") return false;
|
|
661
1107
|
if (raw === "null") return null;
|
|
662
1108
|
if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
|
|
663
1109
|
if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
|
|
664
|
-
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;
|
|
665
1112
|
return raw;
|
|
666
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
|
+
}
|
|
667
1160
|
function yamlScalar(value) {
|
|
668
1161
|
const str = String(value);
|
|
669
|
-
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")}"`;
|
|
670
1163
|
return str;
|
|
671
1164
|
}
|
|
672
1165
|
function serializeYamlValue(value, indent) {
|
|
@@ -714,6 +1207,10 @@ function serializeYamlFields(data) {
|
|
|
714
1207
|
lines.push(`${key}:`);
|
|
715
1208
|
lines.push(...serializeYamlValue(value, 1));
|
|
716
1209
|
} else if (Array.isArray(value)) {
|
|
1210
|
+
if (value.length === 0) {
|
|
1211
|
+
lines.push(`${key}: []`);
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
717
1214
|
lines.push(`${key}:`);
|
|
718
1215
|
for (const item of value) if (typeof item === "object" && item !== null && !Array.isArray(item)) {
|
|
719
1216
|
const nested = serializeYamlValue(item, 2);
|
|
@@ -741,7 +1238,7 @@ function parseMarkdownFrontmatter(content) {
|
|
|
741
1238
|
let currentArray = null;
|
|
742
1239
|
for (const line of frontmatterStr.split("\n")) {
|
|
743
1240
|
if (/^\s+-\s+/.test(line) && currentKey) {
|
|
744
|
-
const value = line.replace(/^\s+-\s+/, "").trim();
|
|
1241
|
+
const value = parseFrontmatterScalarString(line.replace(/^\s+-\s+/, "").trim());
|
|
745
1242
|
if (!currentArray) currentArray = [];
|
|
746
1243
|
currentArray.push(value);
|
|
747
1244
|
continue;
|
|
@@ -761,10 +1258,11 @@ function parseMarkdownFrontmatter(content) {
|
|
|
761
1258
|
continue;
|
|
762
1259
|
}
|
|
763
1260
|
if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
|
|
764
|
-
|
|
1261
|
+
const inner = rawValue.slice(1, -1).trim();
|
|
1262
|
+
frontmatter[key] = inner === "" ? [] : splitFrontmatterList(inner).map((item) => parseFrontmatterScalarString(item.trim()));
|
|
765
1263
|
continue;
|
|
766
1264
|
}
|
|
767
|
-
frontmatter[key] =
|
|
1265
|
+
frontmatter[key] = parseFrontmatterScalar(rawValue);
|
|
768
1266
|
}
|
|
769
1267
|
if (currentKey && currentArray) frontmatter[currentKey] = currentArray;
|
|
770
1268
|
return {
|
|
@@ -810,6 +1308,6 @@ function serializeMarkdownFrontmatter(data, body) {
|
|
|
810
1308
|
return lines.join("\n");
|
|
811
1309
|
}
|
|
812
1310
|
//#endregion
|
|
813
|
-
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 };
|
|
814
1312
|
|
|
815
1313
|
//# sourceMappingURL=index.mjs.map
|