@contentrain/types 1.19.1 → 1.20.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 +27 -0
- package/dist/index.d.mts +96 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +82 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/canonical.ts","../src/migration.ts","../src/execution.ts","../src/approval.ts","../src/provider.ts","../src/media.ts","../src/index.ts"],"sourcesContent":["// ─── Canonical Serialization ───\n//\n// Deterministic JSON: the same data must produce the same bytes on every\n// machine, every run. Stable git diffs depend on it, and so does `plan_hash`\n// in `execution.ts` — which is why these two functions live in their own\n// module rather than in `index.ts`. `execution.ts` needs them and `index.ts`\n// re-exports `execution.ts`; keeping them here is what stops that from being\n// an import cycle.\n\n/** Recursively sort object keys — respects optional fieldOrder for top-level keys */\nexport function sortKeys(obj: unknown, fieldOrder?: readonly string[]): unknown {\n if (obj === null || obj === undefined) return undefined\n if (Array.isArray(obj)) return obj.map(item => sortKeys(item, fieldOrder))\n if (typeof obj !== 'object') return obj\n\n const record = obj as Record<string, unknown>\n const sorted: Record<string, unknown> = {}\n\n const keys = fieldOrder\n ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])]\n : Object.keys(record).toSorted()\n\n for (const key of keys) {\n if (!(key in record)) continue\n const val = record[key]\n if (val === null || val === undefined) continue\n sorted[key] = sortKeys(val)\n }\n\n return sorted\n}\n\n/** Canonical JSON serialization — deterministic output for stable git diffs */\nexport function canonicalStringify(data: unknown, fieldOrder?: readonly string[]): string {\n const sorted = sortKeys(data, fieldOrder)\n return `${JSON.stringify(sorted, null, 2)}\\n`\n}\n","// ─── Migration Contracts ───\n//\n// Shared, MIT-licensed shapes for the WordPress → static-site migration pipeline.\n// Producers: the WordPress Bridge plugin (GPL, separate repo), REST/WXR importers.\n// Consumers: the migration engine, Studio, and the open Astro emitter.\n//\n// Every shape here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository, and license\n// boundaries. A GPL plugin writes RawIR; a proprietary service reads it; an MIT\n// emitter consumes ProjectIR. The contract is the only thing they share, so it\n// lives in the one package all of them may depend on.\n\n/**\n * Version stamped into every migration document (`version` field on each root).\n * The four contracts evolve together with this package; a reader that sees a\n * higher version than it knows should refuse rather than guess.\n */\nexport const MIGRATION_CONTRACT_VERSION = 1\n\n// ─── Source access ───\n\n/**\n * How the WordPress site's data was reached, ordered from least to most\n * complete. Field coverage rises with each rung — measured in August 2026 at\n * roughly 34% for public REST, 57% with an Application Password and 82% from a\n * WXR export; the Bridge plugin reads WordPress from inside and is meant to\n * reach every field — which is why provenance is recorded on every RawIR:\n * two documents for the same site are only comparable if their rungs are.\n */\nexport type SourceAccessKind = 'rest_public' | 'rest_auth' | 'wxr' | 'bridge'\n\n/** The ladder in ascending completeness order. */\nexport const SOURCE_ACCESS_LADDER = [\n 'rest_public',\n 'rest_auth',\n 'wxr',\n 'bridge',\n] as const satisfies readonly SourceAccessKind[]\n\n// ─── RawIR ───\n//\n// Source-faithful extraction of a WordPress site: what the source said, before\n// any interpretation. Unresolved references are kept and *marked* (`resolved`\n// flags) instead of dropped — deciding what a broken reference means is the\n// consumer's job, not the extractor's.\n\nexport interface RawProvenance {\n kind: SourceAccessKind\n /** ISO 8601 UTC time the extraction ran. */\n fetched_at?: string\n /** Name/version of the producing tool (e.g. \"wordpress-bridge/1.0\"). */\n tool?: string\n}\n\nexport interface RawSite {\n url: string\n title?: string\n description?: string\n /** WXR distinguishes the WP install URL from the public site URL. */\n base_site_url?: string\n base_blog_url?: string\n /** Site language as reported (e.g. \"en-US\"); multilingual detail lives in `language_pairs`. */\n language?: string | null\n /** Generator string (WordPress version) when the source exposed it. */\n generator?: string | null\n /** WXR export timestamp, when the source was a WXR file. */\n export_date?: string | null\n wxr_version?: string | null\n}\n\nexport interface RawAuthor {\n id: number | null\n login: string\n email?: string | null\n display_name: string\n first_name?: string | null\n last_name?: string | null\n}\n\nexport interface RawTerm {\n id: number | null\n taxonomy: string\n slug: string\n name: string\n /** Parent term slug within the same taxonomy, when hierarchical. */\n parent?: string | null\n /** Whether `parent` names a term present in this document. */\n parent_resolved?: boolean | null\n description?: string\n}\n\n/** A post's reference to a term — kept even when the term itself is absent. */\nexport interface RawTermRef {\n taxonomy: string\n slug: string\n name: string\n /** Whether the referenced term exists in this document's `terms`. */\n resolved: boolean\n}\n\n/** An ACF field value paired with its field-definition key (`field_…`). */\nexport interface RawAcfValue {\n value: unknown\n field_key: string\n}\n\nexport interface RawPost {\n id: number\n /** WordPress post type — \"post\", \"page\", or a custom post type slug. */\n type: string\n /** WordPress status verbatim (\"publish\", \"draft\", \"future\", \"pending\", …). */\n status: string\n slug: string\n title: string\n /** Public permalink, when known. Permalink *structure* is a site setting; the link is the fact. */\n link?: string | null\n guid?: string | null\n /** Author login; resolve against `RawIR.authors`. */\n author: string | null\n /** ISO 8601 UTC, null when the source carried no usable date. */\n date: string | null\n modified: string | null\n /** Rendered/exported HTML body, untransformed. */\n content: string\n excerpt: string\n parent?: number | null\n menu_order?: number\n sticky?: boolean\n password?: string | null\n comment_status?: string | null\n ping_status?: string | null\n terms: RawTermRef[]\n /**\n * Post meta, PHP-serialized values already decoded where possible.\n * Core WP keys (underscore-prefixed) are included: which keys matter is a\n * downstream decision.\n */\n meta: Record<string, unknown>\n /** Meta keys whose values were PHP-serialized in the source (decoded above). */\n serialized_keys?: string[]\n /** ACF fields recovered by pairing `foo` with `_foo = \"field_…\"`. */\n acf?: Record<string, RawAcfValue>\n /**\n * Language of this post as the multilingual plugin reports it — Polylang's\n * `lang` slug (`tr`), WPML's `wpml_current_locale` (`tr_TR`), or the WXR\n * `language` taxonomy term. Absent on monolingual sites. Which posts are\n * translations of each other is `RawIR.language_pairs`, not this field.\n */\n lang?: string | null\n}\n\nexport interface RawAttachment {\n id: number\n title: string\n slug: string\n /** Original file URL. Downloading/re-hosting is a consumer concern. */\n url: string | null\n alt?: string\n caption?: string\n description?: string\n /** Relative upload path (`_wp_attached_file`), when present. */\n file?: string | null\n /** `_wp_attachment_metadata` verbatim (sizes, EXIF, …). */\n image_meta?: unknown\n mime?: string | null\n parent?: number | null\n parent_resolved?: boolean | null\n author?: string | null\n date?: string | null\n status?: string\n meta?: Record<string, unknown>\n}\n\n/** Where a menu item points. `resolved` says whether the target exists in this document. */\nexport type RawMenuTarget =\n | { kind: 'url'; url: string; resolved: true }\n | { kind: 'post'; post_type: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'term'; taxonomy: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'archive'; post_type: string; resolved: true }\n | { kind: 'unknown'; resolved: false }\n\nexport interface RawMenuItem {\n id: number\n title: string\n order?: number\n /** Parent menu-item id for nested menus. */\n parent?: number | null\n parent_unresolved?: boolean\n url?: string | null\n target: RawMenuTarget\n /** `target` attribute for the rendered link (e.g. \"_blank\"). */\n target_attr?: string | null\n classes?: string[]\n description?: string\n status?: string\n}\n\nexport interface RawMenu {\n id: number | null\n slug: string\n name: string\n items: RawMenuItem[]\n}\n\nexport interface RawComment {\n id: number\n /** Post id this comment belongs to. */\n post: number\n post_type?: string\n parent?: number | null\n parent_resolved?: boolean | null\n author: string\n email?: string | null\n url?: string | null\n /**\n * ISO 8601 UTC. Producers MUST normalize to UTC — comment import fidelity\n * (\"zero record and parent loss\") includes `created_at`, and a site-local\n * date silently corrupts it. When normalization is impossible, ship the\n * source's GMT column in `date_gmt` and leave `date` null.\n */\n date: string | null\n date_gmt?: string | null\n content: string\n /**\n * WordPress approval flag. Fixed vocabulary: `'1'` (approved), `'0'`\n * (pending), `'spam'`, `'trash'` — consumers map these; unknown strings\n * pass through for forward compatibility, never dropped.\n */\n approved?: '1' | '0' | 'spam' | 'trash' | (string & {})\n type?: string\n user_id?: number | null\n meta?: Record<string, unknown>\n}\n\n/**\n * How a redirect's `from` is compared with a request path. Only `url` without\n * `regex` is a plain one-to-one mapping; the others are patterns, and turning\n * one into a literal `from` produces the wrong redirect.\n */\nexport type RawRedirectMatch = 'url' | 'regex' | 'start' | 'contains' | 'end'\n\n/** A redirect rule the live site serves (e.g. from the Redirection plugin — visible from the authenticated rung up). */\nexport interface RawRedirect {\n from: string\n to: string\n status?: number\n /** Which plugin/table produced the rule. */\n source?: string\n /** Stable id within the export, `<source>:<source-specific id>`. */\n id?: string\n /** How `from` matches; absent means `url`. */\n match?: RawRedirectMatch\n /** `from` is a regular expression in the source's own dialect. */\n regex?: boolean\n /** The source's own module/group serving it, when it has one (e.g. Redirection's \"Apache\"). */\n served_by?: string\n /** An adjustment the extractor made, stated rather than hidden (e.g. a non-redirect status read as 301). */\n status_note?: string\n}\n\n/**\n * A rule a source holds that the site does not serve as a plain redirect, with\n * the reason. Every rule lands in `RawIR.redirects` or here, so the two\n * together account for the source's whole table.\n */\nexport interface RawRedirectExcluded extends Omit<Partial<RawRedirect>, 'match'> {\n id: string\n source: string\n /** The source's own match type — may be a condition (`login`, `referrer`, …) rather than a {@link RawRedirectMatch}. */\n match?: RawRedirectMatch | (string & {})\n reason:\n | 'disabled'\n | 'source-inactive'\n | 'slug-reused'\n | 'no-slug-address'\n | `not-a-redirect:${string}`\n | `conditional-match:${string}`\n /** The condition a conditional rule depends on, verbatim. */\n condition?: unknown\n}\n\n// ─── SEO ───\n\nexport const SEO_PROVIDERS = ['yoast', 'rank_math', 'aioseo'] as const\nexport type SeoProvider = (typeof SEO_PROVIDERS)[number]\n\n/** One page's SEO as one provider holds it. */\nexport interface RawSeoEntry {\n /** true: the running plugin rendered these values; false: stored values and templates only. */\n resolved?: boolean\n title?: string\n description?: string\n canonical?: string\n robots?: { index?: 'index' | 'noindex', follow?: 'follow' | 'nofollow', advanced?: string[] }\n /** The directives the page actually carries, after WordPress core and the plugin reconcile them. */\n robots_served?: string[]\n open_graph?: { title?: string, description?: string, type?: string, url?: string, image?: string, site_name?: string }\n twitter?: { card?: string, title?: string, description?: string, image?: string }\n focus_keyword?: string\n /** Schema.org types, and the JSON-LD graph when the plugin rendered it. */\n schema?: { types: string[], graph?: unknown }\n /** What the plugin stored for this page (templates such as `%%title%% %%sep%%`), secrets removed. */\n stored?: Record<string, unknown>\n}\n\n/** A provider's site-wide settings. */\nexport interface RawSeoSettings {\n separator?: string | null\n /** Keyed by context: `post`, `page`, `home`, `author`, `archive`, `tax:<taxonomy>`, `ptarchive-<type>`, … */\n title_templates: Record<string, string>\n description_templates: Record<string, string>\n noindex?: Record<string, boolean>\n social?: Record<string, unknown>\n /** Site verification codes — public meta values. */\n verification?: Record<string, string>\n /** Option name → cleaned value, verbatim otherwise. */\n raw: Record<string, unknown>\n}\n\n/**\n * The site's SEO layer: which plugin serves the head, its settings, and each\n * page's values. `status: 'none'` is an answer — no SEO plugin, and WordPress\n * core renders only the title — not a missing export.\n */\nexport interface RawSeo {\n /** Producer's format identifier, e.g. `contentrain-bridge-seo@1`. */\n format?: string\n status: 'present' | 'none'\n /** Whose output the live site serves. */\n serving: SeoProvider | 'wordpress-core'\n providers: Record<SeoProvider, { status: 'active' | 'inactive-with-data' | 'absent', version?: string }>\n settings: Partial<Record<SeoProvider, RawSeoSettings>>\n /** `post:<id>` or `term:<taxonomy>:<id>` → one block per provider that has data for it. */\n entries: Record<string, Partial<Record<SeoProvider, RawSeoEntry>>>\n /** Values deliberately not exported (e.g. a sensitive key), by address. */\n excluded?: { source: string, reason: string }[]\n}\n\n// ─── Routing ───\n\n/** A page WordPress routes to itself (front page, posts page). */\nexport interface RawRoutedPage {\n id: number\n slug: string\n /** Site-root-relative address. */\n path: string\n}\n\n/**\n * The site's URL rules — what `RawPost.link` holds as facts, stated as the\n * structure that produced them. Permastructs are site-root-relative with\n * `front` already applied where `with_front` asks for it.\n */\nexport interface RawRouting {\n /** Producer's format identifier, e.g. `contentrain-bridge-routing@1`. */\n format?: string\n /** The site's home URL. */\n home?: string\n permalink_structure: string\n /** Plain `?p=` permalinks — no structure at all. */\n plain: boolean\n trailing_slash: boolean\n front: string\n category_base: string\n tag_base: string\n pagination_base: string\n author_base: string\n search_base: string\n feed_base?: string\n comments_pagination_base?: string\n author_structure?: string | null\n date_structure?: string | null\n page_structure?: string | null\n /** `posts` or `page`. */\n show_on_front: string\n page_on_front?: RawRoutedPage | null\n page_for_posts?: RawRoutedPage | null\n posts_per_page: number\n post_types: {\n name: string\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, feeds?: boolean, pages?: boolean } | false\n permastruct: string | null\n has_archive: boolean\n archive_path: string | null\n query_var: string | false\n }[]\n taxonomies: {\n name: string\n object_types: string[]\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, hierarchical?: boolean } | false\n permastruct: string | null\n }[]\n}\n\n// ─── Hardcoded text ───\n//\n// Interface text that lives outside the content tables — theme templates,\n// scripts, widgets, menus, options, Customizer mods, and what the rendered\n// pages show. Every piece found gets exactly one outcome: transferred to a\n// named target, excluded with a reason, or — for a source that could not be\n// read — an error. Nothing is dropped silently.\n\n/** Where a candidate was found. */\nexport const TEXT_CANDIDATE_KINDS = [\n 'php-gettext',\n 'php-html',\n 'php-echo',\n 'html',\n 'js',\n 'widget',\n 'menu',\n 'option',\n 'customizer',\n 'render',\n] as const\n\nexport type RawTextCandidateKind = (typeof TEXT_CANDIDATE_KINDS)[number]\n\n/** Why a candidate is not transferred. `RawTextCandidate.reason` joins one or more with `,`. */\nexport const TEXT_EXCLUDE_REASONS = [\n 'empty',\n 'too-long',\n 'secret',\n 'code',\n 'url',\n 'number',\n 'no-letters',\n 'placeholder-only',\n 'dynamic',\n 'not-text',\n 'content',\n 'rendered-from-source',\n] as const\n\nexport type RawTextExcludeReason = (typeof TEXT_EXCLUDE_REASONS)[number]\n\n/** Where a transferred candidate lands in the store. */\nexport type RawTextTarget =\n | 'dictionary:ui-strings'\n | `theme-settings.${string}`\n | 'site.title'\n | 'site.description'\n | 'content:wp-menu-items'\n\n/** One place a candidate's text was found. */\nexport interface RawTextOccurrence {\n kind: RawTextCandidateKind\n /** A file path relative to `wp-content`, `render:<state>`, `nav_menu_item#<id>`, … */\n source: string\n /** 1-based line in a file; `0` for a source without lines (render, menu, option). */\n line: number\n}\n\n/**\n * One piece of interface text in one place. Candidates merge only when text,\n * locale and context are all equal: \"Read more\" in an `a` and in a `button`\n * are two candidates, and so are `_x('Post', 'noun')` and `_x('Post', 'verb')`.\n */\nexport interface RawTextCandidate {\n /** `sha256(text \\0 locale \\0 context)`, first 20 hex characters. */\n id: string\n /** The text; `[redacted]` when `reason` includes `secret`. */\n value: string\n locale: string\n /**\n * Where the text sits: an HTML tag (`a`, `input@placeholder`),\n * `gettext[:<ctx>][:plural]`, `js`, `widget:<tag>` / `widget:title`, `menu`,\n * `option:<name>`, `customizer:<mod>`, or for rendered text\n * `<landmark>><tag>` (`footer>p`, `nav>nav@aria-label`).\n */\n context: string\n kind: RawTextCandidateKind\n /** The first occurrence's `source` and `line`. */\n source: string\n line: number\n /** Every place the text was found, in the producer's listing order. */\n occurrences: RawTextOccurrence[]\n outcome: 'transfer' | 'exclude'\n /** Set exactly when `outcome` is `transfer`. */\n target?: RawTextTarget\n /** Set exactly when `outcome` is `exclude`: one or more `RawTextExcludeReason`s joined with `,`. */\n reason?: string\n /**\n * With `rendered-from-source`: ids of the source candidates this page text\n * came from. Page text found in source is excluded here and transferred\n * there, so the same words never land twice and contexts are not merged.\n */\n related?: string[]\n /**\n * The store key: `<group>.<context>.<words>-<sha256(text \\0 context)[0:6]>`,\n * or `theme-settings.<mod>` for a Customizer value. A function of text and\n * context only — moving a string to another file changes no key. A\n * collision fails the export rather than merging two texts.\n */\n key: string\n decision: 'review' | 'include' | 'exclude'\n}\n\n/**\n * Every piece of interface text a site shows, each with one outcome. Totals\n * close: `by_outcome.transfer + by_outcome.exclude = candidates`,\n * `by_outcome.error = errors.length`, and `occurrences` counts every\n * occurrence the scanners found — listed ones plus those past the per-file\n * listing cap, which are counted by reason instead.\n */\nexport interface RawHardcodedText {\n /** Producer's format identifier, e.g. `contentrain-bridge-hardcoded-text@1`. */\n format: string\n candidates: RawTextCandidate[]\n /**\n * Sources that could not be read — an outcome, not a silent gap. Reasons:\n * `source-over-2MiB`, `source-unreadable`, `render-fetch-failed: <code>`,\n * `render-http-<status>`, `render-no-body`.\n */\n errors: { source: string, reason: string }[]\n totals: {\n occurrences: number\n occurrences_listed: number\n /** Occurrences past the listing cap: excluded and counted, not listed. */\n unlisted_excluded: number\n unlisted_by_reason: Record<string, number>\n candidates: number\n by_outcome: { transfer: number, exclude: number, error: number }\n sources: { files: number, settings: number, render_states: number }\n }\n}\n\n// ─── Integrations ───\n//\n// Outside services the site is connected to — analytics, CRM, newsletter,\n// captcha, CDN, embeds. A static site cannot carry these connections over:\n// each one with an account must be connected again, with the new site's own\n// credentials. Credentials never leave WordPress; only whether one is set does.\n\nexport const INTEGRATION_CATEGORIES = ['analytics', 'crm', 'newsletter', 'ads', 'comments', 'captcha', 'cdn', 'other'] as const\n\nexport type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number]\n\nexport const INTEGRATION_EVIDENCE_KINDS = ['plugin', 'option-key', 'script-domain', 'form-config', 'embed-domain'] as const\n\nexport type IntegrationEvidenceKind = (typeof INTEGRATION_EVIDENCE_KINDS)[number]\n\n/** One outside service the site uses, and why we believe it does. */\nexport interface RawIntegration {\n /** Catalog id: `mailchimp`, `hubspot`, `google-tag-manager`, `jetpack-stats`, `embed-youtube`, … */\n service: string\n /** Display name. */\n name: string\n category: IntegrationCategory\n /**\n * Never a value. `detail` names a plugin and its version, a setting by name\n * (`mc4wp (api_key)`), a script host and where it was seen\n * (`static.hotjar.com @ render:home`), a form's wiring, or an embed host.\n */\n evidence: { kind: IntegrationEvidenceKind, detail: string }[]\n /** An account must be connected again on the new site; `false` for an embed, which has no account. */\n reconnect_required: boolean\n /** A credential is configured on WordPress. Only this boolean is exported, never the value. */\n secret_present: boolean\n /** What to do, in words for the person receiving the site. */\n notes: string\n}\n\n/**\n * The document a bridge writes (`bridge/integrations.json`). `RawIR.integrations`\n * carries its `services`. An empty `services` with `scanned` beside it is an\n * answer — the site uses no outside service we recognise — not a missing scan.\n */\nexport interface RawIntegrationScan {\n /** Producer's format identifier, e.g. `contentrain-bridge-integrations@1`. */\n format: string\n /** How much was looked at: option and post counts, active plugins, script sources, and whether the home page was rendered. */\n scanned: { options: number, plugins: number, posts: number, rendered_home: boolean, script_sources: number }\n services: RawIntegration[]\n totals: { services: number, reconnect_required: number }\n}\n\n/**\n * The intake / handoff issue for services that must be connected again. Raised\n * once, listing every `RawIntegration` with `reconnect_required: true`; not\n * raised when there are none.\n */\nexport interface IntegrationReconnectRequiredIssue {\n code: 'integration_reconnect_required'\n services: Pick<RawIntegration, 'service' | 'name' | 'category' | 'secret_present'>[]\n}\n\n/**\n * Translation grouping for multilingual sites. The REST rungs read it from\n * Polylang's `translations` / WPML's `wpml_translations` post fields, WXR from\n * the `post_translations` taxonomy; the bridge rung from the plugin tables.\n * One pair per group; `translations` includes the post itself.\n */\nexport interface RawLanguagePair {\n post: number\n /** locale → post id of the translation. */\n translations: Record<string, number>\n}\n\n/**\n * The complete raw extraction of one WordPress site at one access rung.\n * This is the boundary document between extraction (Bridge/importers) and\n * everything downstream — content conversion, capability analysis, migration.\n */\nexport interface RawIR {\n version: number\n provenance: RawProvenance\n site: RawSite\n authors: RawAuthor[]\n terms: RawTerm[]\n posts: RawPost[]\n attachments: RawAttachment[]\n menus?: RawMenu[]\n comments?: RawComment[]\n redirects?: RawRedirect[]\n /** Rules the sources hold that the site does not serve as plain redirects. */\n redirects_excluded?: RawRedirectExcluded[]\n seo?: RawSeo\n routing?: RawRouting\n /** Interface text outside the content tables, each piece with one outcome. */\n hardcoded_text?: RawHardcodedText\n /** Outside services the site is connected to (`RawIntegrationScan.services`). */\n integrations?: RawIntegration[]\n language_pairs?: RawLanguagePair[]\n /** Site options (bridge rung), verbatim. */\n options?: Record<string, unknown>\n}\n\n// ─── CapabilityManifest ───\n//\n// What the site *uses*, as detected — the input for migration planning,\n// effort/pricing, and the \"what happens to X\" conversation with the user.\n// Detection is evidence-based and never certain; `present: true` with evidence\n// beats a silent guess.\n\nexport const CAPABILITY_KEYS = [\n 'seo',\n 'forms',\n 'comments',\n 'search',\n 'newsletter',\n 'analytics',\n 'consent',\n 'share',\n 'video',\n 'ads',\n 'builder',\n 'blocks',\n 'i18n',\n 'cache',\n 'security',\n 'acf',\n 'ecommerce',\n 'jetpack',\n 'redirects',\n 'membership',\n 'accessibility',\n 'media',\n 'scheduling',\n] as const\n\nexport type CapabilityKey = (typeof CAPABILITY_KEYS)[number]\n\nexport interface CapabilityDetection {\n present: boolean\n /** Identified plugin/provider, when known (e.g. \"yoast\", \"contact-form-7\"). */\n plugin?: string | null\n /** How it was detected: \"rest\", \"dom\", \"slug\", \"headers\", \"meta\", … */\n evidence?: string[]\n detail?: string\n}\n\nexport interface CapabilityManifest {\n version: number\n site_url: string\n access: {\n html_status?: number | null\n rest_status?: number | null\n /**\n * Highest source-access rung actually achieved for this site, or `null`\n * when none was — the site answered nothing a rung describes.\n *\n * Nullable rather than optional, and rather than a `'none'` rung. Optional\n * would let a producer omit the field and call that \"not measured\", which\n * is the failure this field exists to prevent: a manifest that reports\n * `rest_public` for every site makes the access ladder unreadable, and the\n * ladder is what the coverage figures are computed from. Required-and-\n * nullable makes \"I reached nothing\" a thing the producer has to say.\n *\n * A `'none'` rung was the other candidate and is worse: `SourceAccessKind`\n * is also `RawProvenance.kind`, where it answers \"how was this data\n * obtained\". A RawIR stamped `kind: 'none'` is a document that exists\n * without having been obtained — an impossible state made representable in\n * an unrelated contract. `SOURCE_ACCESS_LADDER` would stop being a ladder\n * too: it is an ordered array and coverage arithmetic indexes into it.\n *\n * `null` reads with `rest_status: null` beside it as its evidence. Coverage\n * arithmetic should drop these sites from the numerator, not score them\n * zero — a site behind an access wall was not measured, it did not fail.\n */\n achieved: SourceAccessKind | null\n }\n generator?: string | null\n theme?: string | null\n plugins?: string[]\n rest_namespaces?: string[]\n custom_post_types?: Array<{\n slug: string\n /** Whether the CPT is exposed in public REST (`rest: false` CPTs need a higher rung). */\n rest_visible: boolean\n count?: number | null\n }>\n /** Comments get first-class treatment: they are a launch-critical capability. */\n comments: {\n active: boolean\n form_status?: 'open' | 'closed' | 'mixed' | null\n rest_total?: number | null\n plugin?: string | null\n }\n capabilities: Partial<Record<CapabilityKey, CapabilityDetection>>\n /** Dynamic front-end behaviors observed in the DOM (\"carousel\", \"modal\", \"search-overlay\", …). */\n behaviors?: string[]\n languages?: string[]\n}\n\n// ─── ProjectIR ───\n//\n// The reproducible model of the site: not \"this page's HTML\" but the design\n// system, component architecture, route model, and content queries that let\n// *unseen* pages — tomorrow's posts, another category — be generated correctly.\n// Produced by analysis; consumed by the open emitter. Three layers are kept\n// together on purpose: visual structure (families/chrome/tokens), content\n// binding (slots), and business rules (routes/queries). A page family exists\n// per genuinely different behavior, not per page.\n\n/**\n * How the legacy CSS for a family was packaged.\n * - `purge_set`: rules kept if used by any page in the family's page set —\n * small file, safe against per-page rule loss (single-page purging is not).\n * - `localcss`: the site's stylesheets localized wholesale — larger, zero risk.\n */\nexport type CssStrategy = 'purge_set' | 'localcss'\n\n/**\n * Cascade layer name the emitter quarantines legacy CSS under, so a modern\n * utility layer (e.g. Tailwind) can coexist with pixel-faithful migrated pages.\n */\nexport const LEGACY_CSS_LAYER = 'legacy'\n\n/** Design tokens extracted from the source site, e.g. for a Tailwind `@theme` block. */\nexport interface DesignTokens {\n colors?: Record<string, string>\n font_families?: Record<string, string>\n font_sizes?: Record<string, string>\n spacing?: Record<string, string>\n breakpoints?: Record<string, string>\n}\n\nexport type RouteKind =\n | 'front'\n | 'single'\n | 'page'\n | 'archive'\n | 'term'\n | 'author'\n | 'date'\n | 'search'\n | 'not_found'\n | 'custom'\n\nexport interface RouteParamDef {\n name: string\n /**\n * Where the value comes from. The date parts exist because\n * `/%year%/%monthnum%/%day%/%postname%/` is a common WordPress permalink\n * structure: treating the template post's date as a fixed prefix sends every\n * other post to a wrong address, and SEO continuity is the point of a\n * migration. Posts carry their own values (`EmitPost.params`).\n */\n source:\n | 'post_slug'\n | 'post_id'\n | 'post_year'\n | 'post_month'\n | 'post_day'\n | 'term_slug'\n | 'author_slug'\n | 'page_number'\n | 'custom'\n}\n\n/** Selects a family/component variant when a route parameter matches. */\nexport interface RouteVariantRule {\n param: string\n in: string[]\n variant: string\n}\n\n/**\n * One route pattern → one layout family. Pagination is a parameter of a route\n * (`/news/page/2` is `/news` with `page_number = 2`), never a separate family —\n * across a 30-site corpus, page 2 never produced a new layout.\n */\nexport interface RouteModel {\n id: string\n /**\n * URL pattern with `:param` placeholders — `/category/:term`, `/news/page/:page`.\n *\n * A trailing `*` marks a **multi-segment (rest) parameter**: `/category/:term*`\n * matches `/category/about-cc/events/` with `term = \"about-cc/events\"`.\n * Hierarchical taxonomies and nested pages need this — a single-segment\n * parameter silently flattens `/category/about-cc/events/` to\n * `/category/events/`, breaking link continuity for every nested address.\n */\n pattern: string\n kind: RouteKind\n /** LayoutFamily id. */\n family: string\n params?: RouteParamDef[]\n /**\n * Content collection this route generates one page per entry from. `single`\n * routes default to `posts`; naming a collection makes any route\n * collection-driven — a site's pages and custom post types are per-entry\n * routes too, they just are not called \"single\".\n *\n * Without a name per route they would all write to (and overwrite) the same\n * collection.\n */\n collection?: string\n /** QueryBinding id feeding this route's list, when it renders one. */\n query?: string\n /**\n * Document title for routes whose pages have no per-page title of their own\n * (a static page, an archive without `QueryPage.title`).\n */\n title?: string\n /**\n * Locale this route serves. A multilingual site has a route per language\n * (`/:slug`, `/en/:slug`) with its own family and query; the emitted page's\n * `lang` comes from here rather than from the project default.\n */\n locale?: string\n variant_rules?: RouteVariantRule[]\n}\n\nexport type SlotKind =\n | 'body'\n | 'title'\n | 'date'\n | 'excerpt'\n | 'author_name'\n | 'author_link'\n | 'featured_image'\n | 'term_list'\n | 'self_link'\n | 'custom'\n\n/** Where a content field lands in a family's markup. */\nexport interface SlotBinding {\n kind: SlotKind\n /** The region is printed only on posts that have this slot filled — see `CHROME_IF_OPEN`. */\n optional?: boolean\n /** The region repeats per list item (terms, authors) — see `CHROME_REPEAT_OPEN`. */\n repeat?: boolean\n /** CSS selector of the slot's container, when one could be determined. */\n selector?: string\n /** Content-model field backing a `custom` slot. */\n field?: string\n /** Date rendering format for `date` slots, as observed on the source site. */\n date_format?: string\n}\n\n/**\n * Marks whose name ends in this suffix are inserted as raw HTML instead of\n * being escaped. Escaping stays the default — content-derived text must never\n * be able to break the page — but some themes render a post's full content or\n * a link-bearing excerpt inside a list card, and escaping those prints markup\n * as text (measured on one theme's category page: 48.6).\n */\nexport const RAW_MARK_SUFFIX = '_html'\n\n/**\n * Repeat block: `<!--@@repeat:NAME@@-->…<!--@@/repeat@@-->`, optionally with a\n * separator — `<!--@@repeat:NAME|, @@-->`. The inner fragment renders once per\n * item of the list named NAME, with per-item marks `item` / `item_index` (and\n * `item_<key>` for object items).\n *\n * Fixed marks (`term0…termN`) cannot express a list whose length varies per\n * post: a template built from a 3-term post leaves stray separators on a\n * 6-term one (measured symptoms: `\"Business,\"`, `\"Releases, Events,\"`,\n * `\"Automattic, ,\"`), and the same class shows up with multiple authors.\n */\nexport const CHROME_REPEAT_OPEN = '<!--@@repeat:'\nexport const CHROME_REPEAT_CLOSE = '<!--@@/repeat@@-->'\n\n/**\n * Conditional block: `<!--@@if:NAME@@-->…<!--@@/if@@-->`, negated with\n * `<!--@@if:!NAME@@-->`. The fragment survives only when NAME has a value\n * (non-empty string, non-empty list).\n *\n * Route-parameter variants (`RouteVariantRule`) cannot express this: two posts\n * on the SAME route differ by whether the theme printed a region at all\n * (measured: five posts of one family scored 3.5–84.5, the only difference\n * being a featured block that some posts render and others do not).\n */\nexport const CHROME_IF_OPEN = '<!--@@if:'\nexport const CHROME_IF_CLOSE = '<!--@@/if@@-->'\n\n/**\n * Marker a list section's wrapper carries where its items go —\n * `<div class=\"grid\"><!--@@items@@--></div>`.\n */\nexport const LIST_ITEMS_SLOT = '<!--@@items@@-->'\n\n/**\n * Marker a `body`-position chrome chunk carries where the page content goes.\n * A comment survives serialization, renders as nothing if ever left behind,\n * and — critically — can sit at ANY nesting depth: real themes put the content\n * container deep inside the chrome (`article > div.entry-content`), so the\n * chrome cannot be split into balanced before/after fragments. The emitter\n * splices content in at this marker and injects the result as ONE fragment.\n */\nexport const CHROME_BODY_SLOT = '<!--@@body@@-->'\n\n/**\n * Mount point of a component inside chrome: `<!--@@component:ID@@-->`, where\n * ID is a `ComponentDef.id`. The producer replaces the source region (the\n * theme's comment list and form, a contact form) with this marker; the emitter\n * renders the real component there and imports it into the layout, so a\n * runtime component is MOUNTED, not merely emitted as an unused file.\n *\n * Same shape as `CHROME_BODY_SLOT` for the same reasons: a comment survives\n * serialization, renders as nothing if left behind, and can sit at any depth.\n */\nexport const CHROME_COMPONENT_OPEN = '<!--@@component:'\nexport const CHROME_COMPONENT_CLOSE = '@@-->'\n\n/** The marker for one component id — `componentSlot('c-comments')`. */\nexport const componentSlot = (id: string): string => `${CHROME_COMPONENT_OPEN}${id}${CHROME_COMPONENT_CLOSE}`\n\n/**\n * A rendered, asset-rewritten chunk of site chrome the emitter injects verbatim.\n * Positions: `head` lands in `<head>`; `body` is the whole body chrome carrying\n * `CHROME_BODY_SLOT` where content goes (preferred — nesting-safe); the legacy\n * `before_body`/`after_body` pair is composed into a single body with the slot\n * between them (only correct when the content container is top-level).\n *\n * `header` and `footer` lift a region OUT of the body chrome into its own Astro\n * component, rendered as a sibling of the body fragment. They are for the two\n * regions a site actually shares — the masthead and the colophon — and they buy\n * two things one blob cannot: families that carry the same header emit ONE\n * component instead of N copies of the markup, and the nav lives at a single\n * address, which is where a jQuery-free menu replaces the theme's.\n *\n * The producer emits them ONLY after verifying both properties:\n *\n * 1. **Balanced.** The fragment closes every element it opens. Splitting chrome\n * at an arbitrary point produces halves the parser silently repairs — the\n * failure that cost a page 36 against 100 and the reason `body` exists.\n * 2. **Outside the content path.** The region sits wholly before (header) or\n * after (footer) `CHROME_BODY_SLOT` and is not an ancestor of it. A region\n * that wraps the content cannot be lifted; it belongs in `body`.\n *\n * When either is in doubt, keep everything in one `body` chunk: a single blob\n * is always correct, and this split is an optimisation on top of it.\n */\nexport interface ChromeChunk {\n id: string\n position: 'head' | 'body' | 'before_body' | 'after_body' | 'header' | 'footer'\n html: string\n /**\n * Shared component name for a `header`/`footer` chunk — `SiteHeader`,\n * `BlogFooter`. Chunks that carry the same name AND the same html collapse\n * into one emitted component; same name with different html gets a suffixed\n * name and a warning, never a silent swap. Default: `SiteHeader`/`SiteFooter`.\n */\n component?: string\n}\n\nexport interface ComponentPlacement {\n /** ComponentDef id. */\n component: string\n variant?: string\n selector?: string\n /**\n * `QueryBinding.id` whose results fill this region, instead of the cloned\n * markup that was there.\n *\n * The case this exists for: a theme's \"recent posts\" block sits in the\n * chrome of every article. Cloned, it freezes on the day of the migration —\n * it keeps listing the same posts forever, and nobody notices because it\n * still looks right. Bound to a query it stays current and becomes editable,\n * which is the whole difference between a copy of a site and a site.\n *\n * On the placement rather than on `ComponentDef`, because it is a per-mount\n * fact: one `related` component can be mounted by a post family filtered to\n * the post's category and by an author family filtered to the author. The\n * definition says what the region *is*; the placement says what it shows\n * here — the same split `variant` (card shape) and `selector` (region)\n * already follow.\n */\n query?: string\n}\n\nexport interface FamilyVariant {\n key: string\n description?: string\n}\n\n/**\n * Attributes on the document's root elements, carried verbatim from the source\n * page. Themes hang layout on them — WordPress writes `<body class=\"wp-singular\n * post-template-default single …\">` and scripts add `<html class=\"js wf-…\">`,\n * and the stylesheet's container rules key off exactly those classes. Dropping\n * them costs a correct-content page its entire layout (measured: 36.4 vs 100).\n *\n * Values may carry `@@mark@@` placeholders, so per-page classes (`postid-123`)\n * survive the same filling as chrome.\n *\n * Whether a site needs them is not knowable from one page: the same test that\n * moved 10up 36.4 → 100 moved wptavern 36 → 36, because that theme does not key\n * off body classes. Carry them always.\n */\nexport interface RootAttrs {\n html?: Record<string, string>\n body?: Record<string, string>\n}\n\nexport interface LayoutFamily {\n id: string\n name?: string\n kind?: RouteKind\n chrome?: ChromeChunk[]\n /** `<html>` / `<body>` attributes from the source page — see `RootAttrs`. */\n root_attrs?: RootAttrs\n slots?: SlotBinding[]\n components?: ComponentPlacement[]\n css: {\n strategy: CssStrategy\n /**\n * Stylesheets every page of the family loads.\n *\n * Page-builder sites emit CSS per page (`post-11368.css`,\n * `local/global-11368-frontend-*`), so one member's stylesheet set is not\n * the family's: building from a single template page scored 35.6, while\n * the UNION of the members' stylesheets scored 100. Producers put the\n * union here and the per-page remainder on the page itself.\n */\n files?: string[]\n }\n /** Observed column counts per viewport class (e.g. desktop 4 → mobile 1). */\n columns?: Partial<Record<'desktop' | 'mobile', number>>\n variants?: FamilyVariant[]\n /** Verification trail: which pages formed the family, held-out fidelity score. */\n evidence?: {\n pages?: string[]\n holdout_score?: number\n }\n}\n\n/**\n * Component semantic vocabulary. `source` records where its content can come\n * from: `rest` (derivable from content data — author box, term list),\n * `runtime` (needs a live service — comments form, search), `chrome` (carried\n * as rendered markup — nav, ads).\n */\nexport const COMPONENT_TYPES = [\n 'nav',\n 'related',\n 'comments',\n 'form',\n 'ads',\n 'chrome',\n 'taxonomy',\n 'author',\n 'share',\n 'breadcrumb',\n 'meta',\n 'pagination',\n 'card',\n 'custom',\n] as const\n\nexport type ComponentType = (typeof COMPONENT_TYPES)[number]\n\nexport type ComponentSource = 'rest' | 'runtime' | 'chrome'\n\nexport interface ComponentVariantDef {\n key: string\n conditions?: RouteVariantRule[]\n description?: string\n}\n\nexport interface ComponentDef {\n id: string\n type: ComponentType\n source: ComponentSource\n name?: string\n selector?: string\n /**\n * Content model a runtime component talks to — the collection a `form`\n * submits to. A `comments` component needs none: its thread is addressed by\n * the entry of the page it is mounted on.\n */\n model?: string\n /** e.g. a card component with `news`, `compact`, `featured` variants. */\n variants?: ComponentVariantDef[]\n}\n\nexport type ExcerptSource = 'excerpt' | 'content_first_paragraph' | 'none'\nexport type PaginationKind = 'numbered' | 'infinite' | 'none'\n\n/**\n * The query behind a rendered list: which content, filtered how, in what\n * order, how many. Inferred by matching rendered items against candidate\n * queries — order matters, so the sequence is part of the contract.\n */\nexport interface QueryBinding {\n id: string\n /** Content source: \"posts\", \"pages\", a CPT slug, or a content-model id. */\n source: string\n taxonomy?: {\n taxonomy: string\n /** Fixed term slug, or the route param carrying it. */\n term?: string\n term_param?: string\n }\n author?: {\n slug?: string\n param?: string\n }\n order: {\n by: 'date' | 'title' | 'menu_order' | 'custom'\n direction: 'asc' | 'desc'\n }\n per_page: number | null\n pagination: PaginationKind\n excerpt_source?: ExcerptSource\n /** WordPress image size name the list renders (e.g. \"medium_large\"). */\n image_size?: string\n /** Locale this query is filtered to, on a multilingual site. */\n locale?: string\n}\n\n/**\n * - `responsive`: one build, CSS handles viewports.\n * - `split`: desktop and mobile variants are produced separately and served\n * by device — for sites whose mobile DOM differs beyond CSS.\n */\nexport type ViewportStrategy = 'responsive' | 'split'\n\nexport interface ProjectIR {\n version: number\n site: {\n url: string\n title?: string\n locales?: string[]\n }\n routes: RouteModel[]\n families: LayoutFamily[]\n components?: ComponentDef[]\n queries?: QueryBinding[]\n tokens?: DesignTokens\n /** Default CSS packaging for families that don't override it. */\n css_default: CssStrategy\n viewport_strategy?: ViewportStrategy\n /** Content-model ids this project's queries and slots refer to. */\n content_models?: string[]\n}\n\n// ─── Comments export ───\n//\n// Comments cross one more boundary than the rest of RawIR: they leave the\n// generated static site entirely and land in a live service's database,\n// addressed by content entry — not by WordPress post id. This export is that\n// bridge: the source map translates WP ids to entry addresses, and the\n// comments ride along unmodified.\n\nexport const COMMENTS_EXPORT_FORMAT = 'contentrain-comments@1'\n\n/** Where one WordPress post's content ended up. */\nexport interface EntrySourceRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\n/**\n * WordPress post id (stringified) → content entry address. Only the tool that\n * wrote the content store can produce this; nothing downstream can recover it.\n */\nexport type EntrySourceMap = Record<string, EntrySourceRef>\n\nexport interface CommentsExport {\n version: number\n format: typeof COMMENTS_EXPORT_FORMAT\n source: RawProvenance\n site_url?: string\n /** ISO 8601 UTC. */\n generated_at: string\n entries: EntrySourceMap\n /** WP post ids whose comment form was closed — the receiving side opens those threads closed. */\n threads_closed?: number[]\n /** Verbatim `RawComment`s; parents are re-linked via `RawComment.parent` in a second pass. */\n comments: RawComment[]\n}\n\n/** Comments summary + payload pointer on the handoff (capability counts are not enough for intake). */\nexport interface HandoffComments {\n total: number\n by_status?: Record<string, number>\n types?: Record<string, number>\n export?: {\n format: typeof COMMENTS_EXPORT_FORMAT\n /** Where the full export can be fetched… */\n url?: string\n /** …or the export itself, inline, for small sites. */\n inline?: CommentsExport\n }\n threads_closed?: number[]\n unresolved?: Array<{ comment_id: number; post: number; reason: string }>\n}\n\n// ─── Runtime binding ───\n//\n// A runtime component (comments, forms) talks to a live service. The static\n// site only needs two facts to do that: where the public API lives and which\n// project it belongs to. Everything else — the model, the entry, the locale —\n// is already on the page that mounts the component.\n\n/**\n * Public API root of the runtime provider plus the project id. `base_url` is\n * the origin (`https://studio.contentrain.io`); components append their own\n * `/api/{forms,comments}/v1/{project_id}/…` paths. No credential travels with\n * it: the public endpoints are unauthenticated by design, and a browser page\n * cannot keep a secret.\n */\nexport interface RuntimeBinding {\n base_url: string\n project_id: string\n}\n\n// ─── MigrationHandoff ───\n//\n// What the migration hands the user: where the generated project lives, what\n// happened to each detected capability, and — for capabilities that need a\n// runtime — the offers. Offering is this document's job; fulfilling an offer\n// is the receiving product's. The migration itself is complete without any\n// offer being accepted.\n\nexport type CapabilityDisposition =\n | 'migrated_static'\n | 'archived'\n | 'needs_runtime'\n | 'external_adapter'\n | 'kept_on_wordpress'\n | 'dropped'\n\nexport interface HandoffCapability {\n key: CapabilityKey\n disposition: CapabilityDisposition\n detail?: string\n /** e.g. comments: { migrated: 412, spam_skipped: 60 } */\n counts?: Record<string, number>\n}\n\nexport type OfferProvider = 'studio_managed' | 'adapter' | 'keep_wordpress'\n\nexport interface CostEstimate {\n currency: string\n monthly: number\n assumptions?: string[]\n}\n\n/**\n * The comparison shown when a runtime capability is offered: what running the\n * old server costs (maintenance, security, hosting) against the managed\n * option — so declining is an informed choice, not a default.\n */\nexport interface CostComparison {\n self_host?: CostEstimate\n managed?: CostEstimate\n}\n\nexport interface HandoffOffer {\n capability: CapabilityKey\n provider: OfferProvider\n /** Adapter identifier when `provider` is `adapter`. */\n adapter?: string\n cost_comparison?: CostComparison\n /** e.g. \"keeping comments on WordPress means the WordPress server stays live\". */\n warning?: string\n}\n\n/**\n * What a standalone page's body is, as far as the migration can carry it.\n *\n * - `html` — markup written in the editor. It travels as content and stays\n * editable.\n * - `builder_html` — markup a page builder rendered (the builder's own data\n * is not HTML). It travels as it was rendered: the page looks right, but its\n * layout is no longer edited in the builder that made it.\n * - `none` — no body; the theme renders the page from other data. Nothing to\n * carry as content.\n *\n * Counted because they promise different things to the person receiving the\n * site, and a single \"pages migrated\" number hides which promise was made.\n */\nexport const PAGE_BODY_KINDS = ['html', 'builder_html', 'none'] as const\nexport type PageBodyKind = (typeof PAGE_BODY_KINDS)[number]\n\nexport interface MigrationHandoff {\n version: number\n site_url: string\n /** ISO 8601 UTC. */\n generated_at: string\n repository?: {\n provider: 'github' | 'gitlab'\n owner: string\n name: string\n default_branch: string\n }\n preview_url?: string\n content_summary?: {\n models: number\n entries: number\n locales?: string[]\n /**\n * Standalone pages by body kind — see `PageBodyKind`. Every kind is present\n * when the field is, so a zero is a count, not an omission.\n */\n body_kinds?: Record<PageBodyKind, number>\n }\n capabilities: HandoffCapability[]\n /** Present whenever the source had comments — see `HandoffComments`. */\n comments?: HandoffComments\n offers?: HandoffOffer[]\n /** Where the generated site's runtime components were bound, when an offer was fulfilled. */\n runtime?: RuntimeBinding\n notes?: string[]\n}\n","// ─── Execution, Approval and Source-Delta Contracts ───\n//\n// Shared, MIT-licensed shapes for operations that *do* something: an agent run,\n// a bulk edit, a deploy, a cutover. `migration.ts` describes a site; this file\n// describes an act upon one.\n//\n// Three repositories meet here and none of them may define these shapes for\n// itself. The migration engine (private) produces plans and receipts. Studio\n// (AGPL) renders the plan card, collects approvals and stores the run. The MCP\n// server (MIT) is where a plan's steps actually execute. If each wrote its own\n// `RiskClass`, a \"destructive\" operation would mean three different things and\n// the approval that guards it would be theatre.\n//\n// Everything here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository and license\n// boundaries, and because a plan must survive being written to disk, approved\n// hours later, and executed by a different process than the one that built it.\n\nimport { canonicalStringify } from './canonical.js'\n\n/**\n * Version stamped into every execution document (`version` field on each root).\n * A reader that sees a higher version than it knows should refuse rather than\n * guess — a misread plan is a plan executed with the wrong scope.\n */\nexport const EXECUTION_CONTRACT_VERSION = 1\n\n// ─── Actors ───\n\n/**\n * Who is acting. `kind` is load-bearing for approval: an agent may never\n * approve its own work, so the evaluator has to be able to tell an agent\n * from a person without consulting Studio's user table.\n */\nexport interface ActorRef {\n kind: 'human' | 'agent' | 'system'\n /** Stable identifier in the issuing system (Studio user id, agent name). */\n id: string\n /** Display name at the time of acting; advisory, never matched against. */\n name?: string\n /** Project role held at the time of acting (e.g. \"owner\", \"admin\", \"editor\"). */\n role?: string\n}\n\n// ─── Risk ───\n\n/**\n * What is at stake in an operation, in ascending severity.\n *\n * The order is the contract, not a convenience: `RISK_CLASSES` is a ladder and\n * a policy written for `bulk_content` is expected to also cover everything\n * above it. The seven rungs come from the approval model — read-only work is\n * automatic, content edits follow project policy, and anything that leaves the\n * repository (money, third parties, production) is a separate decision from\n * whether the content is correct.\n */\nexport const RISK_CLASSES = [\n /** Reads only; no file, no side effect. Automatic by default. */\n 'read_only',\n /** A bounded content edit — one entry, one locale. Project policy decides. */\n 'low_risk_content',\n /** Many entries at once: bulk status changes, mass field edits, imports. */\n 'bulk_content',\n /** Deletes content or changes a model's shape. Both plan and change approval. */\n 'destructive_schema',\n /** Reaches a third party: email, CRM, webhook, provider API. */\n 'external_effect',\n /** Spends money in a way worth a budget decision. */\n 'financially_material',\n /** Publishes, deploys, cuts over, or rewrites redirects. Release approval. */\n 'deployment',\n] as const\n\nexport type RiskClass = (typeof RISK_CLASSES)[number]\n\n/** Position on the ladder; higher is more severe. */\nexport function riskRank(risk: RiskClass): number {\n return RISK_CLASSES.indexOf(risk)\n}\n\n/**\n * The most severe of the given classes — the class a multi-step plan is\n * evaluated against. A plan is exactly as safe as its worst step, so a\n * read-only survey that ends in a deploy is a deploy.\n */\nexport function highestRisk(risks: readonly RiskClass[]): RiskClass {\n let worst: RiskClass = 'read_only'\n for (const risk of risks) {\n if (riskRank(risk) > riskRank(worst)) worst = risk\n }\n return worst\n}\n\n// ─── Approval ───\n\n/**\n * The three moments at which a decision is asked for. They are distinct\n * questions and collapsing them is the mistake this type exists to prevent:\n * approving *what will be done* is not approving *what was produced*, and\n * neither is permission to put it in front of the public.\n */\nexport const APPROVAL_GATES = [\n /** Before the work starts: scope, cost, risk, tools, affected resources. */\n 'plan',\n /** After the branch exists: semantic diff, schema change, validation output. */\n 'change',\n /** Before production effect: publish, deploy, cutover, redirects, bulk delete. */\n 'release',\n] as const\n\nexport type ApprovalGate = (typeof APPROVAL_GATES)[number]\n\n/**\n * How many people, and which, must say yes. Deliberately three modes: a\n * generic workflow canvas was considered and rejected — policy-based approval\n * covers every case the product actually has.\n */\nexport const APPROVAL_MODES = [\n /** No human decision needed. */\n 'auto',\n /** One authorised reviewer is enough. */\n 'single',\n /** A count, or a role combination, must be met. */\n 'quorum',\n] as const\n\nexport type ApprovalMode = (typeof APPROVAL_MODES)[number]\n\n/**\n * One rule in `.contentrain/approval-policies.json`: for operations at or\n * above `risk`, this gate is required in this mode.\n *\n * Rules are additive. Every matching rule contributes a requirement; nothing a\n * rule says can remove a requirement another rule created. A policy file can\n * therefore only ever make a project stricter, which is what makes it safe to\n * merge one from a template.\n */\nexport interface ApprovalRule {\n /** Applies to this class and — because the classes are a ladder — everything above it. */\n risk: RiskClass\n gate: ApprovalGate\n mode: ApprovalMode\n /** Quorum only: how many distinct approvers are needed. Ignored otherwise. */\n min_approvals?: number\n /** Roles that may satisfy this rule. Empty/absent = any authorised reviewer. */\n roles?: string[]\n /** Narrow the rule to certain models. Absent = every model. */\n models?: string[]\n /** Narrow the rule to certain locales. Absent = every locale. */\n locales?: string[]\n}\n\n/**\n * `.contentrain/approval-policies.json`. Lives in git, beside the content it\n * governs, so that the policy in force is the policy on the branch — a policy\n * stored only in a database can be changed after a plan is built and before it\n * is approved.\n */\nexport interface ApprovalPolicyFile {\n version: number\n /**\n * Applied at the `change` gate when no rule matches at all. Absent means no\n * rule matched and therefore nothing is required — the policy's silence is\n * taken literally rather than filled in with each consumer's idea of a\n * sensible default.\n */\n default_mode?: ApprovalMode\n rules: ApprovalRule[]\n /**\n * Whether an actor may approve a plan they themselves created. Defaults to\n * false; an agent can never self-approve regardless of this setting.\n */\n allow_self_approval?: boolean\n}\n\n/**\n * An outstanding or satisfied demand for a decision, derived from a rule.\n * `because` carries the rule's risk class so a UI can say *why* a gate\n * appeared without re-running the policy.\n */\nexport interface ApprovalRequirement {\n gate: ApprovalGate\n mode: ApprovalMode\n /** Distinct approvers still needed; 0 once satisfied. */\n min_approvals: number\n roles?: string[]\n /** The risk class of the rule that produced this requirement. */\n because: RiskClass\n}\n\n/**\n * A decision that was actually given.\n *\n * A grant is bound to an exact `plan_hash`: if the plan changes, its grants no\n * longer apply and the gate reopens. That binding is the whole point — an\n * approval of \"publish these 12 posts\" must not carry over to a plan that\n * publishes 400.\n */\nexport interface ApprovalGrant {\n gate: ApprovalGate\n /** The plan this decision was given for. */\n plan_hash: string\n /** The branch tip reviewed, for `change` grants on a produced diff. */\n commit_sha?: string\n approver: ActorRef\n /** ISO 8601 UTC. */\n approved_at: string\n /** ISO 8601 UTC; a grant past this instant does not count. */\n expires_at?: string\n note?: string\n}\n\n// ─── Execution scope ───\n\n/**\n * What an operation touches. Used twice with the same shape: on a plan it is\n * the prediction, on a receipt it is what actually happened. Keeping one shape\n * for both is deliberate — the diff between them is the most useful thing a\n * receipt can show, and it is only computable if the two are comparable.\n *\n * Every field is optional and an absent field means \"none\", not \"unknown\": a\n * plan that cannot enumerate what it will touch is not a plan that should be\n * approved.\n */\nexport interface ExecutionScope {\n models?: string[]\n locales?: string[]\n /** Entry ids, or `model:id` where the model is not implied by `models`. */\n entries?: string[]\n /** Routes/URLs affected — cutovers and redirect rewrites report these. */\n routes?: string[]\n /** Repository-relative file paths. */\n files?: string[]\n /** Asset ids or paths. */\n assets?: string[]\n /** Provider identifiers reached (deployment, email, CRM). */\n providers?: string[]\n /** Hosts contacted outside the project — the evidence behind `external_effect`. */\n external_domains?: string[]\n}\n\n/** One tool invocation inside a plan. */\nexport interface ExecutionStep {\n id: string\n /** Tool name as the runner knows it (e.g. \"contentrain_bulk\"). */\n tool: string\n /** One line, written for the person approving, not for the log. */\n summary: string\n risk: RiskClass\n /** What this step alone touches; the plan's scope is the union. */\n scope?: ExecutionScope\n /** Opaque tool arguments. Hashed with the rest of the plan. */\n input?: Record<string, unknown>\n}\n\n/**\n * Predicted cost. Currency is ISO 4217; absent means the cost is not monetary.\n *\n * The non-monetary fields exist because cost is metered before it is priced:\n * a run's bill is assembled from tokens, compute time, bytes stored and bytes\n * transferred, and those are what a runner can actually measure at the moment\n * it finishes a step. `cost` is the conversion, and it is often computed later\n * from these.\n */\nexport interface ExecutionEstimate {\n currency?: string\n /** Money, in major units. */\n cost?: number\n /** Model tokens, when the step runs an agent. */\n tokens?: number\n duration_ms?: number\n /**\n * Items the operation expects to write — entries, files, assets. A count,\n * not a size: use `bytes_stored` for how much was written.\n */\n items?: number\n /**\n * Bytes the operation leaves at rest — a built artefact, an uploaded asset.\n * Object storage bills for this per unit time, so it is the field a storage\n * line on an invoice traces back to.\n */\n bytes_stored?: number\n /**\n * Bytes the operation moves across the network — egress, and fetches made on\n * the way. Billed separately from storage and by a different rate, which is\n * why it is a separate field rather than one `bytes`: a run that stores 40 MB\n * once and serves it a thousand times has one storage figure and a very\n * different transfer figure.\n */\n bytes_out?: number\n}\n\n/** Cost as measured. Same shape as the estimate so the two can be subtracted. */\nexport type ExecutionCost = ExecutionEstimate\n\n/**\n * How to undo the operation. A command, not a promise: if the runner cannot\n * state the undo, `available` is false and the approver learns that *before*\n * deciding rather than after failing.\n */\nexport interface RollbackPlan {\n available: boolean\n /** Shell command or tool call that reverses the effect. */\n command?: string\n /** Commit to reset the content branch to. */\n revert_to?: string\n /** Why it cannot be undone, when `available` is false. */\n reason?: string\n}\n\n/** The repository state a plan was built against. */\nexport interface ProjectRef {\n branch?: string\n commit_sha?: string\n /** Content branch tip, when it differs from the base branch. */\n content_commit_sha?: string\n}\n\n/**\n * An operation, fully described, before it runs.\n *\n * `plan_hash` is the identity of *what will happen*. It covers the steps,\n * scope, risk, estimate and rollback, and excludes who built the plan and\n * when — regenerating the same operation must produce the same hash, or\n * idempotency and approval binding both break. Compute it with\n * {@link computePlanHash}; never hand-roll it, because a hash computed two\n * different ways is a gate that silently opens.\n */\nexport interface ExecutionPlan {\n version: number\n id: string\n /** SHA-256 of the plan's semantic payload — see {@link computePlanHash}. */\n plan_hash: string\n /** What this achieves, in the requester's words. */\n intent: string\n /** The plan's class: the highest risk among its steps. */\n risk: RiskClass\n steps: ExecutionStep[]\n /** Union of the steps' scopes, stated once so an approver reads one list. */\n scope: ExecutionScope\n estimate?: ExecutionEstimate\n rollback?: RollbackPlan\n /** Repository state the plan assumes; re-checked at execution time. */\n project?: ProjectRef\n /** ISO 8601 UTC. Excluded from the hash. */\n created_at?: string\n /** Excluded from the hash, but checked for self-approval. */\n created_by?: ActorRef\n /** ISO 8601 UTC; a plan past this instant must be rebuilt, not run. */\n expires_at?: string\n /**\n * Caller-supplied key that makes a re-submission a no-op rather than a\n * second run. Excluded from the hash: two runs of the same work differ by\n * this key alone and must still share a plan hash.\n */\n idempotency_key?: string\n}\n\n// ─── Receipt ───\n\n/**\n * Run lifecycle. The main line is `draft → planned → awaiting_approval →\n * approved → scheduled → queued → running → verifying → completed`; the rest\n * are the states a run can sit in when something interrupts it.\n */\nexport const RUN_STATUSES = [\n 'draft',\n 'planned',\n 'awaiting_approval',\n 'approved',\n 'scheduled',\n 'queued',\n 'running',\n 'verifying',\n 'completed',\n 'paused',\n 'needs_attention',\n 'retrying',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const\n\nexport type RunStatus = (typeof RUN_STATUSES)[number]\n\n/** Statuses from which a run will not proceed on its own. */\nexport const TERMINAL_RUN_STATUSES = [\n 'completed',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const satisfies readonly RunStatus[]\n\nexport function isTerminalRunStatus(status: RunStatus): boolean {\n return (TERMINAL_RUN_STATUSES as readonly RunStatus[]).includes(status)\n}\n\n/**\n * A step boundary the runner survived. Checkpoints are what make a resumed\n * run skip work it already did instead of repeating it.\n */\nexport interface Checkpoint {\n step_id: string\n status: 'completed' | 'skipped' | 'failed'\n /** ISO 8601 UTC. */\n at: string\n /** What this step actually touched. */\n scope?: ExecutionScope\n /** Commit written by this step, when it wrote one. */\n commit_sha?: string\n message?: string\n}\n\n/** One post-run assertion — a build, a link check, a parity score. */\nexport interface VerificationCheck {\n id: string\n /** One line naming what was asserted. */\n name: string\n passed: boolean\n /** Measured value, when the check produced one. */\n value?: number | string\n /** Threshold the value was compared against. */\n expected?: number | string\n detail?: string\n}\n\n/**\n * What happened, after the fact. The receipt is the durable record: it\n * survives the run, is exported for audit, and is the only artefact that can\n * answer \"who allowed this and what did it actually change?\".\n *\n * Release approvers are the entries of `approvals` whose `gate` is `release`\n * — there is deliberately no separate `release_approved_by` field, because two\n * places recording the same fact is two places that can disagree. Read them\n * with {@link approversFor}.\n */\nexport interface ExecutionReceipt {\n version: number\n id: string\n plan_id: string\n /** The plan that ran. Must match the approvals' `plan_hash`. */\n plan_hash: string\n status: RunStatus\n actor: ActorRef\n /** Every decision that permitted this run, plan/change/release alike. */\n approvals?: ApprovalGrant[]\n /** ISO 8601 UTC. */\n started_at: string\n finished_at?: string\n /** What was actually touched — compare against the plan's `scope`. */\n applied: ExecutionScope\n checkpoints?: Checkpoint[]\n verification?: VerificationCheck[]\n /** Measured cost — compare against the plan's `estimate`. */\n cost?: ExecutionCost\n /** The undo, as it stands now that the run has happened. */\n rollback?: RollbackPlan\n /** Repository state the run left behind. */\n project?: ProjectRef\n /** Set when `status` is `failed` or `needs_attention`. */\n error?: { code: string, message: string, step_id?: string }\n}\n\n/** Approvers recorded on a receipt for one gate, in grant order. */\nexport function approversFor(receipt: ExecutionReceipt, gate: ApprovalGate): ActorRef[] {\n return (receipt.approvals ?? []).filter(grant => grant.gate === gate).map(grant => grant.approver)\n}\n\n// ─── Deployment ───\n\n/**\n * Where a build is published. Carries a *reference* to a secret, never a\n * secret: this document is written to git, and a token in git is a token\n * leaked. The reference is resolved at execution time by whoever holds the\n * vault.\n */\nexport interface DeploymentTarget {\n id: string\n /** Provider identity (e.g. \"netlify\", \"vercel\", \"cloudflare-pages\"). */\n provider: string\n /** Provider-side site/project identifier. */\n site_id?: string\n branch?: string\n /** Key naming the credential in the executing side's secret store. */\n secret_ref?: string\n /** Non-secret provider settings. */\n config?: Record<string, string>\n}\n\n// ─── Automation (type reservation) ───\n//\n// Reserved now so that the file `.contentrain/automations.json` has a declared\n// shape before anything writes one. No tool in this repository reads these\n// yet; the shape exists so that the first writer does not invent a fourth\n// vocabulary for schedules.\n\nexport const SCHEDULE_KINDS = [\n 'once',\n 'recurring',\n 'content_event',\n 'git_event',\n 'runtime_event',\n 'external_webhook',\n 'manual',\n] as const\n\nexport type ScheduleKind = (typeof SCHEDULE_KINDS)[number]\n\n/** What to do with an occurrence that was missed while the runner was down. */\nexport type MisfirePolicy = 'skip' | 'run_once' | 'run_all'\n\nexport interface AutomationSchedule {\n kind: ScheduleKind\n /** ISO 8601 UTC, for `once`. */\n at?: string\n /** Cron expression, for `recurring`. */\n cron?: string\n /** IANA zone (e.g. \"Europe/Istanbul\"); cron without one is ambiguous twice a year. */\n timezone?: string\n /** Event name, for the event kinds. */\n event?: string\n misfire?: MisfirePolicy\n}\n\nexport interface AutomationDefinition {\n version: number\n id: string\n name: string\n enabled: boolean\n schedule: AutomationSchedule\n /** What the run should do, in the requester's words. */\n intent: string\n /** Risk the automation declares; the policy is re-evaluated at fire time. */\n risk?: RiskClass\n /** Budget ceiling per occurrence. */\n budget?: ExecutionEstimate\n /** Deduplication key for the occurrence. */\n idempotency_key?: string\n /** Bounded retry; absent means no retry. */\n max_retries?: number\n}\n\n// ─── SourceDeltaPlan ───\n//\n// The WordPress→repo delta layer. This is NOT `contentrain_reconcile`: that\n// tool merges two git branches through their common ancestor and knows\n// nothing about WordPress. A source delta is the other axis — what changed at\n// the origin since the snapshot the repository was built from — and it must be\n// written to the repository *before* reconcile runs on the git side.\n//\n// The trap this shape exists to close: `modified_after` is a filter on changed\n// records, not a deletion feed. A post deleted in WordPress simply stops\n// appearing, and a delta built from \"what changed\" will never notice. Hence\n// explicit tombstones, produced by comparing inventories rather than by\n// reading a feed.\n\n/** The origin state a delta is measured from. */\nexport interface SourceCursor {\n kind: 'rest_modified_after' | 'wxr_export' | 'bridge_inventory'\n /** ISO 8601 UTC, for `rest_modified_after`. */\n modified_after?: string\n /** Export identifier, for `wxr_export`. */\n export_id?: string\n /** Inventory hash, for `bridge_inventory` — the only kind that can prove deletions. */\n inventory_hash?: string\n /** ISO 8601 UTC time the cursor was taken (T0). */\n taken_at: string\n}\n\nexport const SOURCE_DELTA_OPS = [\n /** Present at the origin, absent in the repository. */\n 'created',\n /** Present in both, origin's fingerprint changed. */\n 'updated',\n /** Absent at the origin, present in the repository — a tombstone. */\n 'deleted',\n /** Same record, different slug/permalink. Drives redirect generation. */\n 'moved',\n] as const\n\nexport type SourceDeltaOp = (typeof SOURCE_DELTA_OPS)[number]\n\n/**\n * One record's delta.\n *\n * `conflict` is the case that cannot be resolved mechanically: the same record\n * changed at the origin *and* in the repository since the cursor. Taking\n * either side silently loses an edit, so the plan reports it and a human\n * decides.\n */\nexport interface SourceDeltaEntry {\n op: SourceDeltaOp\n /** Origin identifier (WordPress post/term/attachment id). */\n wp_id: number\n /** Origin object type (e.g. \"post\", \"page\", \"attachment\", \"category\"). */\n wp_type?: string\n /** Where it lives in the store, when it is already mapped. */\n model?: string\n entry_id?: string\n locale?: string\n /** Origin content fingerprint at cursor time and now. */\n fingerprint_before?: string\n fingerprint_after?: string\n /** For `moved`: the slugs, in that order. */\n slug_before?: string\n slug_after?: string\n /**\n * For `moved`: the site-root-relative addresses, in that order. A slug\n * cannot say that a page changed parent, a term base changed or a dated\n * permalink moved; the path can, and redirects are generated from it.\n */\n path_before?: string\n path_after?: string\n /**\n * For `deleted`: `trashed` is still at the origin and can come back;\n * `purged` is gone from it. A draft or private record is `updated`, never\n * `deleted` — unpublishing is the planner's decision.\n */\n deleted_kind?: 'trashed' | 'purged'\n /** True when the repository also changed this record since the cursor. */\n conflict?: boolean\n /**\n * The repository-side write behind `conflict`: the entry's meta when it was\n * last written by something other than the importer (Studio, an agent, a\n * person). `updated_at` is absent when the store never recorded it — an\n * unknown time is still a conflict.\n */\n repo_edit?: { updated_by: string, updated_at?: string, source: string }\n /**\n * Why the planner could not place this record in the store:\n * `not-in-source-map` (a post-type record the source map does not list),\n * `no-model-for-type` (no store model holds this origin type),\n * `entry-not-found` (the model exists, the entry does not).\n */\n unmapped?: 'not-in-source-map' | 'no-model-for-type' | 'entry-not-found'\n /**\n * For `updated` and `moved`: the fields whose stored value differs from the\n * incoming export's, by field name (`body` for a document's body). Computed\n * against the repository as it is now, so a repository-side edit shows here\n * too — which is what `conflict` is for.\n */\n fields_changed?: string[]\n /** For `moved`: the entry id at the new address, when the store derives ids from slugs and the id changes. */\n entry_id_after?: string\n /** Why this entry is here, for the person reading the plan. */\n detail?: string\n}\n\n/**\n * A one-way transfer plan from the WordPress origin to the repository.\n *\n * Not a sync: there is no path back. The plan is produced, written, and only\n * then does `contentrain_reconcile` merge the result on the git side.\n */\nexport interface SourceDeltaPlan {\n version: number\n /** ISO 8601 UTC. */\n generated_at: string\n /** Origin state this delta is measured from. */\n cursor: SourceCursor\n /** Origin state this delta brings the repository up to. */\n next_cursor?: SourceCursor\n entries: SourceDeltaEntry[]\n /**\n * Whether deletions could be determined at all. A `rest_modified_after`\n * cursor cannot see them; a plan that says `false` must not be read as\n * \"nothing was deleted\".\n */\n deletions_detectable: boolean\n /**\n * Origin types whose deletions could not be determined even though the plan\n * as a whole could — a type that left the inventory's scope (its plugin was\n * deactivated) is not a deletion of every record in it.\n */\n deletions_undetectable_types?: string[]\n /** Redirects implied by the `moved` entries. */\n redirects?: { from: string, to: string, status: 301 | 302 | 307 | 308 }[]\n /** Human-facing notes: truncation, skipped types, coverage limits. */\n warnings?: string[]\n}\n\n// ─── Plan hash ───\n\n/**\n * The exact bytes `plan_hash` is computed over. Exposed because a hash is only\n * useful if a disagreement can be investigated: when two sides compute\n * different hashes for the same plan, comparing payloads shows which field\n * differs, and comparing hashes shows nothing.\n *\n * Excluded: `plan_hash` itself (it cannot cover itself), and `id`,\n * `created_at`, `created_by` and `idempotency_key` — who built a plan, when,\n * under which run id, and with which deduplication key do not change what the\n * plan will do. Everything else is covered, so a changed step, a widened\n * scope, a raised estimate or a withdrawn rollback all invalidate every\n * approval the plan had collected.\n */\nexport function planHashPayload(plan: ExecutionPlan): string {\n const { plan_hash: _hash, id: _id, created_at: _at, created_by: _by, idempotency_key: _key, ...semantic } = plan\n return canonicalStringify(semantic)\n}\n\n/**\n * SHA-256 of {@link planHashPayload}, lowercase hex.\n *\n * Async because it uses Web Crypto (`globalThis.crypto.subtle`), which is\n * available in Node 18+, Deno, Bun, workers and browsers alike — this package\n * is consumed in all of them and must not reach for `node:crypto`. A non-\n * cryptographic hash was considered and rejected: approvals are pinned to this\n * value, so a collision is an approval bypass, not a cache miss.\n */\nexport async function computePlanHash(plan: ExecutionPlan): Promise<string> {\n const bytes = new TextEncoder().encode(planHashPayload(plan))\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')\n}\n\n/** One origin record as the inventory saw it. */\nexport interface SourceInventoryRecord {\n /** Origin object type (\"post\", \"page\", \"attachment\", or a taxonomy name for a term). */\n wp_type: string\n /** Origin id — unique only together with `wp_type`: post 5 and category 5 are different records. */\n wp_id: number\n /** Origin status (`publish`, `draft`, `trash`, …); a term has none. */\n status?: string\n /**\n * Password-protected at the origin. The record is inventoried like any other\n * — so removing the password is an `updated`, not a `created` — but has no\n * public address.\n */\n protected?: boolean\n slug?: string\n /** Site-root-relative public address. A change here is a `moved`. */\n path?: string\n /** Origin id of the parent record, when it has one. */\n parent?: number\n /** ISO 8601 UTC, from the origin — a hint only: a meta-only edit does not change it. */\n modified_at?: string\n /**\n * Hash of the record as mapped, volatile fields excluded. `updated` is\n * decided on this, never on `modified_at`.\n */\n fingerprint: string\n /** Slugs the origin remembers for this record (WordPress `_wp_old_slug`). Corroborates a move; never the authority. */\n old_slugs?: string[]\n /** Locale of the record, when the origin is multilingual. */\n locale?: string\n}\n\n/**\n * Every record in scope at one moment — what a `bridge_inventory` cursor's\n * `inventory_hash` identifies.\n *\n * Deletions and moves are proven by comparing two inventories, not by\n * reading a feed; a `modified_after` query only narrows which bodies to fetch\n * again. A type in the earlier inventory's `scope` but not in this one's left\n * the scope — it was not deleted.\n */\nexport interface SourceInventory {\n /** Inventory format identifier, e.g. `contentrain-bridge-inventory@2`. */\n format: string\n /** ISO 8601 UTC time the inventory was taken. */\n taken_at: string\n /** What was enumerated. */\n scope: {\n post_types: string[]\n taxonomies?: string[]\n }\n records: SourceInventoryRecord[]\n /**\n * The value a cursor's `inventory_hash` carries: lowercase hex SHA-256 of the\n * records sorted by `wp_type` (byte order) then `wp_id` (numeric), each\n * serialized as the JSON array `[wp_type, wp_id, fingerprint, path ?? null,\n * status ?? null]` — no spaces, slashes and non-ASCII unescaped (what\n * `JSON.stringify` prints) — joined by `\\n`, no trailing newline. Other fields\n * do not enter it; a change they make shows through `fingerprint`.\n */\n inventory_hash: string\n}\n","// ─── Approval Evaluator ───\n//\n// The decision procedure behind `execution.ts`'s approval types: given a plan,\n// a policy and the decisions collected so far, may this proceed?\n//\n// It replaces a role check. Studio's `shouldAutoMerge` asks \"is this person an\n// owner?\" — which cannot express \"a bulk publish needs a second pair of eyes\n// even from the owner\", and cannot distinguish fixing a typo from cutting over\n// a domain. This asks about the action instead: its risk, its scope, and what\n// the project's own policy says about that combination.\n//\n// Pure and dependency-free, so the same answer comes out in Studio's server,\n// in a CLI, and in a test. Nothing here reads a file, a clock or a user table:\n// every input is passed in, `now` included, because a decision that silently\n// depends on the wall clock cannot be reproduced when someone asks why a run\n// was blocked.\n\nimport type {\n ActorRef,\n ApprovalGrant,\n ApprovalMode,\n ApprovalPolicyFile,\n ApprovalRequirement,\n ApprovalRule,\n ExecutionPlan,\n RiskClass,\n} from './execution.js'\nimport { APPROVAL_GATES, highestRisk, riskRank } from './execution.js'\n\n/**\n * Used when a project has no `.contentrain/approval-policies.json`.\n *\n * Read-only work proceeds; everything else wants one reviewer on the produced\n * diff. Deliberately stated as a value rather than buried in the evaluator so\n * a project can inspect it, extend it, or reject it — and so \"what happens\n * without a policy file\" has one answer instead of one per caller.\n *\n * Note this is a *default*, not the only gate in the system: whether a project\n * consults the evaluator at all is still governed by its `workflow` setting.\n */\nexport const DEFAULT_APPROVAL_POLICY: ApprovalPolicyFile = {\n version: 1,\n rules: [\n { risk: 'low_risk_content', gate: 'change', mode: 'single' },\n ],\n}\n\n/** Ascending strictness — used to keep the stricter of two modes. */\nconst MODE_RANK: Record<ApprovalMode, number> = { auto: 0, single: 1, quorum: 2 }\n\n/** How many distinct approvers a mode asks for. */\nfunction approvalsNeeded(rule: ApprovalRule): number {\n if (rule.mode === 'auto') return 0\n if (rule.mode === 'single') return 1\n return Math.max(1, rule.min_approvals ?? 2)\n}\n\n/**\n * Why a grant did not count. Machine-readable because the useful question is\n * never \"is it blocked\" but \"I approved this, why is it still blocked\".\n */\nexport type GrantRejection =\n /** Given for a different plan — the plan changed after the decision. */\n | 'plan_hash_mismatch'\n /** A `change` grant reviewed a different branch tip than the one presented. */\n | 'commit_mismatch'\n /** Past its `expires_at`. */\n | 'expired'\n /** An agent cannot approve. */\n | 'agent_approver'\n /** The approver authored the plan and the policy forbids self-approval. */\n | 'self_approval'\n /** The approver's role is not one the rule accepts. */\n | 'role_not_permitted'\n /** This approver already counted toward the requirement. */\n | 'duplicate_approver'\n /** No requirement at this gate — nothing for the grant to satisfy. */\n | 'no_matching_requirement'\n\nexport interface RejectedGrant {\n grant: ApprovalGrant\n reason: GrantRejection\n}\n\n/** A requirement plus the decisions that have accumulated against it. */\nexport interface ApprovalStatus extends ApprovalRequirement {\n /** Distinct approvers counted so far, in grant order. */\n approvers: ActorRef[]\n /** Still needed; 0 means satisfied. */\n remaining: number\n}\n\nexport interface ApprovalDecision {\n /** Every requirement is satisfied and the plan has not expired. */\n allowed: boolean\n /** The class the plan was evaluated at — its own, or its worst step's. */\n risk: RiskClass\n /** Every requirement the policy produced, satisfied or not. */\n requirements: ApprovalStatus[]\n /** The subset still needing approvers, in gate order. */\n outstanding: ApprovalStatus[]\n /** Decisions that were supplied but did not count, each with a reason. */\n rejected_grants: RejectedGrant[]\n /** One line per reason the plan cannot proceed; empty when it can. */\n reasons: string[]\n}\n\nexport interface ApprovalEvaluation {\n plan: ExecutionPlan\n /** Absent falls back to {@link DEFAULT_APPROVAL_POLICY}. */\n policy?: ApprovalPolicyFile\n /** Decisions collected so far. */\n grants?: readonly ApprovalGrant[]\n /**\n * The branch tip being presented for a `change` decision. When given, a\n * `change` grant must name this commit: a review of an older tip is not a\n * review of what is about to merge. When absent, `change` grants are matched\n * on `plan_hash` alone.\n */\n commit_sha?: string\n /** ISO 8601 UTC. Passed in, never read from the clock. */\n now?: string\n}\n\n/** Whether a rule's narrowing clauses match what the plan touches. */\nfunction ruleMatchesScope(rule: ApprovalRule, plan: ExecutionPlan): boolean {\n if (rule.models && rule.models.length > 0) {\n const touched = plan.scope.models ?? []\n if (!rule.models.some(model => touched.includes(model))) return false\n }\n if (rule.locales && rule.locales.length > 0) {\n const touched = plan.scope.locales ?? []\n if (!rule.locales.some(locale => touched.includes(locale))) return false\n }\n return true\n}\n\n/**\n * The class a plan is judged at: its declared risk, or its worst step's if\n * that is higher. A plan cannot understate itself — declaring `read_only`\n * while carrying a deploy step would otherwise walk straight past the policy.\n */\nexport function effectiveRisk(plan: ExecutionPlan): RiskClass {\n return highestRisk([plan.risk, ...plan.steps.map(step => step.risk)])\n}\n\n/**\n * The requirements a policy produces for a plan, before any decision is\n * collected. This is what a plan card renders: \"this needs one reviewer on the\n * diff and two on release, because it deploys\".\n *\n * Each matching rule contributes its own requirement and all of them must be\n * met. Merging two rules into one would need a rule for combining their modes,\n * roles and counts — and every such rule has a case where the result is\n * *looser* than one of its inputs. Keeping them separate is what makes the\n * policy additive in fact and not just in intent.\n *\n * `auto` rules are dropped rather than kept as zero-approver requirements: a\n * requirement nobody has to satisfy is noise on the card, and a stricter rule\n * matching the same gate still stands on its own.\n */\nexport function requiredApprovals(plan: ExecutionPlan, policy: ApprovalPolicyFile = DEFAULT_APPROVAL_POLICY): ApprovalRequirement[] {\n const risk = effectiveRisk(plan)\n const requirements: ApprovalRequirement[] = []\n let matched = false\n\n for (const rule of policy.rules) {\n if (!ruleMatchesScope(rule, plan)) continue\n\n // A demanding rule covers its own class and everything above it: the\n // classes are a ladder, so a policy written for `bulk_content` also\n // governs a deploy.\n //\n // An `auto` rule does NOT climb. It says \"this class needs nothing\", and\n // if it inherited upward a single `auto` rule on a low rung would exempt\n // every heavier operation above it — turning the one mode that demands\n // nothing into the only mode that can loosen a policy. Matching its own\n // class alone keeps the file additive in fact and not just in intent.\n const climbs = rule.mode !== 'auto'\n const applies = climbs ? riskRank(risk) >= riskRank(rule.risk) : risk === rule.risk\n if (!applies) continue\n\n matched = true\n const min_approvals = approvalsNeeded(rule)\n // `auto` produces no requirement — but it did match, so the policy's\n // `default_mode` does not step in behind it.\n if (min_approvals === 0) continue\n\n requirements.push({\n gate: rule.gate,\n mode: rule.mode,\n min_approvals,\n ...(rule.roles && rule.roles.length > 0 ? { roles: [...rule.roles] } : {}),\n because: rule.risk,\n })\n }\n\n if (!matched && policy.default_mode && policy.default_mode !== 'auto') {\n requirements.push({\n gate: 'change',\n mode: policy.default_mode,\n min_approvals: approvalsNeeded({ risk, gate: 'change', mode: policy.default_mode }),\n because: risk,\n })\n }\n\n // Gate order, then the strictest first within a gate, so a card reads\n // chronologically and the heaviest demand is not buried.\n return requirements.toSorted((a, b) =>\n APPROVAL_GATES.indexOf(a.gate) - APPROVAL_GATES.indexOf(b.gate)\n || MODE_RANK[b.mode] - MODE_RANK[a.mode]\n || b.min_approvals - a.min_approvals)\n}\n\n/** Whether a grant may be counted at all, independent of any requirement. */\nfunction grantDisqualification(\n grant: ApprovalGrant,\n plan: ExecutionPlan,\n policy: ApprovalPolicyFile,\n commit_sha: string | undefined,\n now: string,\n): GrantRejection | undefined {\n if (grant.plan_hash !== plan.plan_hash) return 'plan_hash_mismatch'\n if (grant.expires_at && grant.expires_at <= now) return 'expired'\n // An agent may never approve — not its own work, not anyone's. The whole\n // point of the gate is that something outside the automation looked.\n if (grant.approver.kind === 'agent') return 'agent_approver'\n if (\n !policy.allow_self_approval\n && plan.created_by\n && grant.approver.id === plan.created_by.id\n && grant.approver.kind === plan.created_by.kind\n ) return 'self_approval'\n // A `change` decision is a decision about a diff. Presented with a different\n // tip than the one reviewed, it says nothing about what is about to merge.\n if (grant.gate === 'change' && commit_sha && grant.commit_sha !== commit_sha) return 'commit_mismatch'\n return undefined\n}\n\n/**\n * May this plan proceed?\n *\n * Answers with the full picture rather than a boolean: which requirements\n * exist, which are met, who met them, and — for every decision that was\n * supplied but did not count — why it did not. The last part is what makes\n * \"I approved this, why is it still blocked?\" answerable without reading the\n * evaluator's source.\n */\nexport function evaluateApproval(input: ApprovalEvaluation): ApprovalDecision {\n const { plan, commit_sha } = input\n const policy = input.policy ?? DEFAULT_APPROVAL_POLICY\n const grants = input.grants ?? []\n const now = input.now ?? new Date().toISOString()\n\n const risk = effectiveRisk(plan)\n // `requiredApprovals` builds fresh objects (roles included) on every call, so\n // completing them in place is safe and costs no second allocation.\n const requirements: ApprovalStatus[] = requiredApprovals(plan, policy).map(requirement =>\n Object.assign(requirement, { approvers: [] as ActorRef[], remaining: requirement.min_approvals }))\n\n const rejected_grants: RejectedGrant[] = []\n\n for (const grant of grants) {\n const disqualified = grantDisqualification(grant, plan, policy, commit_sha, now)\n if (disqualified) {\n rejected_grants.push({ grant, reason: disqualified })\n continue\n }\n\n // One decision can satisfy several requirements at the same gate — it is\n // the same person saying yes to the same thing once.\n let counted = false\n let sawGate = false\n let roleBlocked = false\n let duplicate = false\n\n for (const requirement of requirements) {\n if (requirement.gate !== grant.gate) continue\n sawGate = true\n if (requirement.roles && !requirement.roles.includes(grant.approver.role ?? '')) {\n roleBlocked = true\n continue\n }\n if (requirement.approvers.some(approver => approver.id === grant.approver.id)) {\n duplicate = true\n continue\n }\n requirement.approvers.push(grant.approver)\n requirement.remaining = Math.max(0, requirement.min_approvals - requirement.approvers.length)\n counted = true\n }\n\n if (counted) continue\n if (!sawGate) rejected_grants.push({ grant, reason: 'no_matching_requirement' })\n else if (duplicate) rejected_grants.push({ grant, reason: 'duplicate_approver' })\n else if (roleBlocked) rejected_grants.push({ grant, reason: 'role_not_permitted' })\n }\n\n const outstanding = requirements.filter(requirement => requirement.remaining > 0)\n const reasons: string[] = []\n\n const expired = Boolean(plan.expires_at && plan.expires_at <= now)\n if (expired) {\n reasons.push(`The plan expired at ${plan.expires_at}; rebuild it rather than running it.`)\n }\n\n for (const requirement of outstanding) {\n const who = requirement.roles ? ` from ${requirement.roles.join(' or ')}` : ''\n const have = requirement.approvers.length\n reasons.push(\n `${requirement.gate} approval: ${requirement.remaining} more${who} needed `\n + `(${have}/${requirement.min_approvals}), because the plan is ${requirement.because}.`,\n )\n }\n\n return {\n allowed: !expired && outstanding.length === 0,\n risk,\n requirements,\n outstanding,\n rejected_grants,\n reasons,\n }\n}\n","import type { SyncResult } from './index.js'\n\n// ─── Repository Provider Contracts ───\n//\n// Shared interfaces for the provider-agnostic content repository model used\n// by @contentrain/mcp. They live in @contentrain/types so third-party tools\n// can implement a custom RepoProvider (e.g. for a private git host, an\n// internal service, a mock in a test suite) without depending on MCP\n// internals.\n//\n// @contentrain/mcp re-exports every symbol here from\n// @contentrain/mcp/core/contracts so existing consumers do not have to\n// migrate imports.\n\n// ─── File change ───\n\n/**\n * A single file change within a plan.\n *\n * - `content: string` — write or overwrite the file with this UTF-8 content.\n * - `content: null` — delete the file.\n *\n * Paths are content-root relative, use forward slashes, and must not contain\n * `..` segments or absolute anchors. Providers are responsible for resolving\n * paths against their backing store (worktree, git tree, etc.).\n */\nexport interface FileChange {\n path: string\n content: string | null\n}\n\n// ─── Capabilities ───\n\n/**\n * Capabilities describe what a provider can and cannot do. Operations check\n * required capabilities before running. Normalize extract needs `sourceRead`;\n * normalize reuse needs `sourceWrite`; submit needs `pushRemote`; AST scans\n * need `astScan` (which implies a local working tree).\n *\n * The consistent position is: all git hosts are commodity MIT providers; the\n * distinction between providers is operational (how they read/write), not\n * commercial. Enterprise features live in Studio, not in capability gates.\n */\nexport interface ProviderCapabilities {\n /** Provider backs onto a local worktree and can selectively sync changes into the developer's working tree. */\n localWorktree: boolean\n /** Provider can read arbitrary source files outside `.contentrain/`. Required for normalize extract. */\n sourceRead: boolean\n /** Provider can write arbitrary source files outside `.contentrain/`. Required for normalize reuse. */\n sourceWrite: boolean\n /** Provider can push commits to a remote. Required for submit. */\n pushRemote: boolean\n /** Provider detects branch protection rules on the remote. */\n branchProtection: boolean\n /** Provider can open a pull request as a merge fallback when branch protection blocks direct merge. */\n pullRequestFallback: boolean\n /** Provider can execute AST scanners against source files. Implies local disk access. */\n astScan: boolean\n /**\n * Provider can write a two-parent merge commit (see\n * `RepoProvider.createMergeCommit`). Absent = false. Optional so that\n * existing external `implements` code keeps compiling: a required member\n * here would be a breaking change for every custom provider.\n */\n mergeCommit?: boolean\n}\n\n/** Capability set for the LocalProvider (simple-git + worktree). */\nexport const LOCAL_CAPABILITIES: ProviderCapabilities = {\n localWorktree: true,\n sourceRead: true,\n sourceWrite: true,\n pushRemote: true,\n branchProtection: false,\n pullRequestFallback: false,\n astScan: true,\n mergeCommit: true,\n}\n\n// ─── Reader ───\n\n/**\n * Read-only interface to a content repository.\n *\n * Paths are relative to the repository's content root (e.g.\n * `.contentrain/config.json`). The `ref` parameter is a branch name, tag,\n * or commit SHA. Providers that operate on a single working tree\n * (LocalReader) ignore `ref`; API-backed providers use it to resolve the\n * correct revision.\n *\n * `readFile` and `listDirectory` deliberately have different error semantics:\n * - `readFile` THROWS when the file is missing so callers must opt into\n * tolerance explicitly (typically with a try/catch returning a default).\n * - `listDirectory` returns `[]` for a missing directory because the empty\n * case is the common, uninteresting one.\n */\nexport interface RepoReader {\n /**\n * Read a file's contents as UTF-8.\n * @throws when the file does not exist or cannot be read.\n */\n readFile(path: string, ref?: string): Promise<string>\n\n /**\n * List file and directory names directly under `path`. Does not recurse.\n * Returns an empty array when the directory does not exist.\n */\n listDirectory(path: string, ref?: string): Promise<string[]>\n\n /** Check whether a file or directory exists at `path`. */\n fileExists(path: string, ref?: string): Promise<boolean>\n}\n\n// ─── Writer ───\n\nexport interface CommitAuthor {\n name: string\n email: string\n}\n\nexport interface Commit {\n sha: string\n message: string\n author: CommitAuthor\n timestamp: string\n /**\n * What the provider did with the branch. Providers that own the merge\n * decision (a local worktree honouring the project's `workflow` setting)\n * report it here; providers that hand the branch off to an external\n * orchestrator leave it undefined, and the caller treats that as\n * `pending-review`.\n */\n workflowAction?: 'auto-merged' | 'pending-review'\n /**\n * Selective-sync bookkeeping — which files were copied into the developer's\n * working tree and which were skipped because of local edits. Only\n * providers backed by a local worktree populate it.\n */\n sync?: SyncResult\n /** Non-fatal problem encountered while completing the write. */\n warning?: string\n /**\n * Whether the base branch advanced to the contentrain tip after an\n * auto-merge. Only providers that own the base advance (a local worktree)\n * report it. `blocked_diverged` is a partial success: the content is on\n * the contentrain branch; only the fast-forward is pending.\n */\n base_advance?: BaseAdvance\n /** Outcome of pushing the contentrain branch, when the provider pushes. */\n remote_push?: RemotePush\n}\n\n/**\n * Input to `applyPlan`. Represents a single atomic commit: all changes land\n * in one commit on `branch`, created from `base` if it does not yet exist.\n */\nexport interface ApplyPlanInput {\n /** Branch name to commit to. Created from `base` if missing. */\n branch: string\n /** File additions, modifications and deletions to apply in a single commit. */\n changes: FileChange[]\n /** Commit message. */\n message: string\n /** Commit author. */\n author: CommitAuthor\n /**\n * Optional base branch. Defaults to the Contentrain content-tracking\n * branch (`CONTENTRAIN_BRANCH` — the `contentrain` ref) — NOT the\n * repository's default branch. This is the single source of truth for\n * content state; every feature branch forks from it. Pass an explicit\n * `base` only when you know you want to bypass the invariant.\n *\n * The GitHub provider also takes a full 40-hex commit SHA here, as a\n * compare-and-set: a missing `branch` forks from that commit, and an\n * existing one must still point at it or the write is refused with a\n * 409 (`PROVIDER_CONFLICT`).\n */\n base?: string\n /**\n * Describes the operation that produced these changes. A provider that\n * regenerates `.contentrain/context.json` itself (the local worktree flow\n * does, after merge, single-threaded) uses it; others ignore it. It is\n * never committed on the feature branch — context.json embeds timestamps,\n * so two branches forked from the same commit would always conflict on it.\n */\n context?: { tool: string; model: string; locale?: string; entries?: string[] }\n /** Override the project's configured workflow for this write. */\n workflowOverride?: 'auto-merge' | 'review'\n}\n\n/**\n * Write-side interface. Providers implement this to persist a set of file\n * changes as a single atomic commit. LocalProvider writes through a worktree\n * and `git commit`; API-backed providers post to the Git Data API or\n * equivalent.\n */\nexport interface RepoWriter {\n applyPlan(input: ApplyPlanInput): Promise<Commit>\n}\n\n// ─── Branch / diff / merge ───\n\nexport interface Branch {\n name: string\n sha: string\n protected?: boolean\n}\n\nexport interface FileDiff {\n path: string\n status: 'added' | 'modified' | 'removed'\n before: string | null\n after: string | null\n}\n\nexport interface MergeResult {\n merged: boolean\n sha: string | null\n pullRequestUrl: string | null\n /**\n * Selective-sync bookkeeping — only populated by providers that back onto\n * a local worktree (LocalProvider). Remote-API providers (GitHub, GitLab,\n * etc.) omit it because they do not touch a developer's working tree.\n */\n sync?: SyncResult\n /**\n * Outcome of the post-merge source-branch cleanup on the remote.\n * `deleted: false` with `skipped` is an expected no-op (cleanup disabled,\n * no remote, ref already gone); `warning` is a real failure (offline,\n * auth) that did NOT fail the merge itself.\n */\n remote?: {\n deleted: boolean\n skipped?: string\n warning?: string\n }\n}\n\n// ─── Base-branch advance / remote push outcomes ───\n\n/**\n * What happened to the base branch after content landed on the\n * content-tracking branch.\n *\n * `advanced` — the base branch fast-forwarded to the contentrain tip.\n * `blocked_diverged` — the base branch holds commits that are not in\n * contentrain, so fast-forwarding was impossible. The content itself is\n * safe on the contentrain branch; only the advance is pending until the\n * branches are reconciled.\n *\n * These two values are shared vocabulary across the ecosystem — Studio\n * reports the same enum for its main-advance state. A pull request is an\n * attachment (`pullRequestUrl`), never a third state: \"PR opened\" is\n * `blocked_diverged` with a non-null URL.\n */\nexport type BaseAdvance = 'advanced' | 'blocked_diverged'\n\n/**\n * Outcome of pushing the content-tracking branch to the configured remote.\n *\n * `pushed` — the remote accepted the push (possibly after one\n * fetch-merge-retry). `rejected` — the push failed even after the retry;\n * local and remote have diverged and need reconciling. `no-remote` — the\n * repository has no configured remote, so nothing was attempted.\n */\nexport type RemotePush = 'pushed' | 'rejected' | 'no-remote'\n\n// ─── Reconcile / conflicts ───\n\n/**\n * Machine-readable conflict kinds produced by the content-aware three-way\n * reconcile planner. CLOSED SET: consumers (Studio) key their localized\n * editor questions on these values, so a free string would drift. Adding a\n * value here is a minor version bump and MUST carry a changelog entry;\n * renaming or removing one is breaking.\n */\nexport type ConflictCode =\n | 'field_value_conflict' // collection/singleton: same field changed differently on both sides\n | 'dictionary_value_conflict' // same dictionary key, two different values\n | 'vocabulary_value_conflict' // same term + locale, two different translations\n | 'model_key_conflict' // same model schema key changed differently\n | 'meta_status_conflict' // both sides moved an entry's publish status differently\n | 'document_body_conflict' // both sides edited a document body (never text-merged)\n | 'frontmatter_value_conflict' // same frontmatter key, two different values\n | 'delete_edit_conflict' // one side deleted what the other edited\n | 'file_conflict' // unrecognized file changed on both sides — resolved by choosing a side\n\n/**\n * One surviving conflict from a three-way reconcile: base (merge-base),\n * ours (content branch), theirs (base branch) disagree in a way the policy\n * table cannot resolve mechanically. Everything mechanical has already been\n * merged; only these items need a decision from an editor or agent.\n *\n * `id` is derived from the position AND the three values (see\n * `conflictId`), so a resolution made against a stale dry-run no longer\n * matches once any side changes — compare-and-set for free.\n */\nexport interface ConflictItem {\n id: string\n /** Content-root-relative path of the file the conflict lives in. */\n path: string\n model?: string\n kind: 'collection' | 'singleton' | 'document' | 'dictionary'\n | 'vocabulary' | 'model' | 'meta' | 'file'\n /** Entry ID, dictionary key, vocabulary term, or model schema key. */\n key?: string\n /** Field inside the entry / frontmatter key, when the conflict is field-level. */\n field?: string\n locale?: string\n /** The three values in question — for display; omitted for `kind: 'file'`. */\n base?: unknown\n ours?: unknown\n theirs?: unknown\n code: ConflictCode\n /** Ready-to-display English sentence naming the conflict. */\n message: string\n /**\n * Policy-table suggestion, never auto-applied. Present only where the\n * approved policy names a preferred side (model schema keys → `theirs`).\n */\n suggested?: 'ours' | 'theirs'\n}\n\n/**\n * A decision for one conflict, keyed by `ConflictItem.id`. `choose` picks a\n * side (absence of the item on that side means deletion); `value` supplies\n * a hand-authored replacement. A resolution whose `id` no longer matches a\n * live conflict is dropped and the conflict re-reported with a fresh id.\n */\nexport type ConflictResolution =\n | { id: string; choose: 'ours' | 'theirs' }\n | { id: string; value: unknown }\n\n// ─── Media (optional provider facet) ───\n\n/**\n * One media asset as the provider's media stack reports it. `path` is the\n * repo-relative storage path content fields reference (`media/...`); `url`\n * is the absolute delivery URL when the provider can resolve one (see\n * `RepoProvider.mediaBaseUrl`). `meta` carries provider-defined extras\n * (dimensions, blurhash, variants) without widening this contract.\n */\nexport interface MediaAsset {\n id: string\n path: string\n url?: string\n mime?: string\n size?: number\n alt?: string\n tags?: string[]\n createdAt?: string\n meta?: Record<string, unknown>\n}\n\nexport interface MediaListOptions {\n /** Substring match on filename/path/alt. */\n search?: string\n /** Filter to assets carrying this tag. */\n tag?: string\n /** Page size — implementations may clamp. */\n limit?: number\n /** Opaque cursor from a previous `MediaListResult.nextCursor`. */\n cursor?: string\n}\n\nexport interface MediaListResult {\n assets: MediaAsset[]\n nextCursor?: string\n total?: number\n}\n\n/**\n * URL-based ingest — MCP has no binary channel, so the source is always a\n * URL the provider fetches server-side. Implementations MUST enforce their\n * own SSRF, MIME, and size policies before fetching; MCP passes the input\n * through verbatim and never fetches the URL itself.\n */\nexport interface MediaIngestInput {\n url: string\n filename?: string\n alt?: string\n tags?: string[]\n}\n\nexport interface MediaUpdateInput {\n alt?: string\n tags?: string[]\n filename?: string\n}\n\n/**\n * Optional media facet of a provider. When present on `RepoProvider.media`,\n * the MCP server registers the `contentrain_media_*` tools; when absent\n * (LocalProvider, plain GitHub/GitLab providers), those tools are not\n * listed at all. Hosted providers (Studio MCP Cloud) implement this by\n * delegating to their media stack — deterministic passthrough, no content\n * decisions in MCP.\n */\nexport interface MediaProvider {\n list(opts?: MediaListOptions): Promise<MediaListResult>\n get(id: string): Promise<MediaAsset | null>\n ingest(input: MediaIngestInput): Promise<MediaAsset>\n update(id: string, patch: MediaUpdateInput): Promise<MediaAsset>\n delete(id: string): Promise<void>\n}\n\n// ─── Provider (full surface) ───\n\n/**\n * A content repository provider — the unified surface that MCP tools drive.\n *\n * Implementations wrap a git backend:\n *\n * - `LocalProvider` — simple-git + temp worktree + selective sync\n * - `GitHubProvider` — Octokit Git Data API (no clone)\n * - `GitLabProvider` — gitbeaker REST client (no clone)\n * - `BitbucketProvider` — planned, coming soon\n *\n * Providers are commodity and all live in MIT.\n */\n/** Result of {@link RepoProvider.checkWriteReadiness}. */\nexport interface WriteReadiness {\n blocked: boolean\n /** Human-readable reason, present when `blocked`. */\n message?: string\n}\n\nexport interface RepoProvider extends RepoReader, RepoWriter {\n readonly capabilities: ProviderCapabilities\n\n /**\n * Per-project public media delivery base, e.g.\n * `https://studio.example/api/cdn/v1/{projectId}` — already includes the\n * project segment, so the content-write path only joins `{base}/{path}`.\n *\n * Set by hosted providers (Studio's MCP Cloud loopback resolves it from a\n * request header and attaches it here). When present, content writes\n * normalize relative `media/...` references — in media/image/file fields and\n * markdown bodies — to absolute delivery URLs, so external-agent writes\n * render anywhere without an SDK. Undefined for local/CLI providers, where\n * media stays a relative path (the OSS file model). Never affects reads.\n */\n readonly mediaBaseUrl?: string\n\n /**\n * Optional media facet. Present only on providers whose backend exposes a\n * media stack (Studio MCP Cloud); drives the `contentrain_media_*` MCP\n * tools, which are not registered when this is absent.\n */\n readonly media?: MediaProvider\n\n /**\n * Whether the provider will accept a write right now.\n *\n * A local worktree accumulates unmerged `cr/*` branches and eventually has\n * to refuse new ones; a hosted provider manages that server-side. Asking the\n * provider keeps the tool layer out of the business of counting git branches\n * — it previously reached through an `instanceof` check to a `projectRoot`\n * only one implementation has.\n *\n * Optional: a provider that omits it is always ready.\n */\n checkWriteReadiness?(): Promise<WriteReadiness>\n\n listBranches(prefix?: string): Promise<Branch[]>\n createBranch(name: string, fromRef?: string): Promise<void>\n deleteBranch(name: string): Promise<void>\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]>\n /**\n * Merge `branch` into `into`. Like `git merge` and the platform merge APIs,\n * the source branch is left in place by default. Pass\n * `opts.removeSourceBranch: true` to also delete it after a successful merge\n * (best-effort — reported via `MergeResult.remote`, never a thrown error).\n * Even when opted in, a long-lived branch is never deleted: not `into`, not\n * the `contentrain` content branch, and not the repo's default branch.\n * LocalProvider ignores the option: its cleanup is governed by\n * `config.remoteBranchCleanup` and its own `cr/*`-only guard.\n */\n mergeBranch(branch: string, into: string, opts?: { removeSourceBranch?: boolean }): Promise<MergeResult>\n isMerged(branch: string, into?: string): Promise<boolean>\n getDefaultBranch(): Promise<string>\n\n /**\n * The merge-base commit of two refs — the `base` input of a three-way\n * reconcile.\n *\n * Reconciling a diverged content branch needs the last common ancestor,\n * and every backend can name it (git `merge-base`, GitHub's compare API,\n * GitLab's merge_base endpoint) — but not every existing implementation\n * does yet, and demanding it would break external `implements` code.\n *\n * Optional: a provider that omits it cannot drive a reconcile; the caller\n * falls back to a pull-request flow. Returns `null` when the refs share\n * no history.\n */\n getMergeBase?(refA: string, refB: string): Promise<string | null>\n\n /**\n * Write a set of resolved changes as a TWO-PARENT merge commit on\n * `branch`, joining `ours` and `theirs` so that git history records the\n * reconcile — after it, `theirs` is an ancestor of `branch` and a plain\n * fast-forward advance works again. `changes` are applied on top of the\n * `ours` tree; for content-owned paths the planner's output is\n * authoritative, never a textual auto-merge.\n *\n * Optional, mirrored by `capabilities.mergeCommit`: a backend whose\n * commits API cannot express two parents (GitLab) omits it and the\n * caller falls back to a merge-request flow.\n */\n createMergeCommit?(input: {\n /** Branch whose tip becomes the merge commit (the content branch). */\n branch: string\n /** First parent — the current tip of `branch`. */\n ours: string\n /** Second parent — the ref being reconciled in. */\n theirs: string\n changes: FileChange[]\n message: string\n author: CommitAuthor\n }): Promise<Commit>\n}\n","import type { FieldDef, FieldType } from './index.js'\n\n/**\n * Media path → delivery URL rewriting. The single source of truth for how a\n * stored `media/...` reference becomes an absolute delivery URL — every\n * reader of Contentrain content that resolves this at read or write time\n * shares this module, so the rules can only be defined once.\n *\n * Two independent consumers need the exact same answer: `@contentrain/mcp`'s\n * `planContentSave` normalizes on write, for hosted/cloud mode, so a\n * git-committed value is ready to use with no SDK; `@contentrain/query`\n * resolves the same relative paths at read time, for the local-file model\n * (the generated client, the Astro loader, raw markdown) where nothing\n * upstream rewrote them. A second, hand-rolled implementation on either side\n * drifts the moment one of the rules below changes — titled markdown embeds,\n * an HTML `src`/`href`, or a media field nested inside an `object`/`array`\n * were all missed by an earlier, separate read-side implementation before\n * this module existed.\n *\n * Only image/video/file fields (resolved via the model schema, including those\n * nested in object/array fields) and `media/...` src/href targets in\n * markdown/HTML are rewritten. External URLs (`http(s)://`, `//`, `data:`) and\n * already-absolute delivery URLs pass through untouched, so every entry point\n * is idempotent and safe to call repeatedly. No base means no-op — the\n * relative path is kept verbatim, which is the OSS local-file model's default.\n */\n\nconst MEDIA_FIELD_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\n/**\n * Whether a stored field value is a relative media-storage path (`media/...`)\n * rather than an already-absolute URL or external link. Only these are\n * rewritten to delivery URLs — `http(s)://`, `//`, and `data:` are left as-is.\n */\nexport function isStoredMediaPath(value: unknown): value is string {\n return typeof value === 'string' && value.startsWith('media/')\n}\n\n/** Join a relative media path onto the per-project delivery base. */\nexport function toDeliveryUrl(base: string, path: string): string {\n return `${base.replace(/\\/+$/, '')}/${path}`\n}\n\n/**\n * Rewrite a stored media path to its absolute delivery URL. Non-media values\n * (external URLs, empty, non-strings) pass through untouched, so this is safe\n * to call on any field value.\n */\nexport function rewriteMediaUrl(base: string, value: unknown): unknown {\n return isStoredMediaPath(value) ? toDeliveryUrl(base, value) : value\n}\n\n/**\n * Rewrite media paths within a single field value, guided by its FieldDef so\n * only media fields — including those nested inside object/array fields — are\n * touched. Non-media values pass through.\n */\nexport function rewriteFieldMedia(value: unknown, field: FieldDef, base: string): unknown {\n if (value == null) return value\n\n if (MEDIA_FIELD_TYPES.has(field.type))\n return rewriteMediaUrl(base, value)\n\n if (field.type === 'object' && field.fields && typeof value === 'object' && !Array.isArray(value))\n return rewriteEntryMedia(value as Record<string, unknown>, field.fields, base)\n\n if (field.type === 'array' && Array.isArray(value)) {\n const itemDef: FieldDef | null = typeof field.items === 'string'\n ? { type: field.items as FieldType }\n : field.items ?? null\n if (!itemDef) return value\n return value.map(v => rewriteFieldMedia(v, itemDef, base))\n }\n\n return value\n}\n\n/**\n * Rewrite media paths across one entry/frontmatter object using a field map.\n * Returns a shallow copy (the input is never mutated); a no-op when the object\n * has no media fields.\n */\nexport function rewriteEntryMedia(\n entry: Record<string, unknown>,\n fields: Record<string, FieldDef>,\n base: string,\n): Record<string, unknown> {\n const out = { ...entry }\n for (const [fieldId, field] of Object.entries(fields)) {\n if (fieldId in out)\n out[fieldId] = rewriteFieldMedia(out[fieldId], field, base)\n }\n return out\n}\n\n/** Rewrite `media/...` markdown image/link targets and inline src/href attrs. */\nexport function rewriteMarkdownMedia(body: string, base: string): string {\n return body\n // markdown image/link target: ](media/...) — stops at whitespace, ) or \"\n .replace(/(\\]\\()(media\\/[^)\\s\"]+)/g, (_m, open, p) => `${open}${toDeliveryUrl(base, p)}`)\n // inline HTML src=/href=\"media/...\"\n .replace(/(\\s(?:src|href)=)([\"'])(media\\/[^\"']+)\\2/gi,\n (_m, attr, quote, p) => `${attr}${quote}${toDeliveryUrl(base, p)}${quote}`)\n}\n","import { sortKeys } from './canonical.js'\n\n// ─── Type System ───\n\nexport type FieldType =\n | 'string' | 'text' | 'email' | 'url' | 'slug' | 'color' | 'phone' | 'code' | 'icon'\n | 'markdown' | 'richtext'\n | 'number' | 'integer' | 'decimal' | 'percent' | 'rating'\n | 'boolean'\n | 'date' | 'datetime'\n | 'image' | 'video' | 'file'\n | 'relation' | 'relations'\n | 'select' | 'array' | 'object'\n\nexport type ModelKind = 'singleton' | 'collection' | 'document' | 'dictionary'\nexport type ContentStatus = 'draft' | 'in_review' | 'published' | 'rejected' | 'archived'\nexport type ContentSource = 'agent' | 'human' | 'import'\nexport type WorkflowMode = 'auto-merge' | 'review'\n// Meta-frameworks\nexport type StackType =\n | 'nuxt' | 'next' | 'astro' | 'sveltekit' | 'remix' | 'analog'\n // Plain frameworks\n | 'vue' | 'react' | 'svelte' | 'solid' | 'angular'\n // Mobile\n | 'react-native' | 'expo' | 'flutter'\n // Backend\n | 'node' | 'express' | 'fastify' | 'nestjs' | 'django' | 'rails' | 'laravel' | 'go' | 'rust' | 'dotnet'\n // Static site generators\n | 'hugo' | 'jekyll' | 'eleventy'\n // Desktop\n | 'electron' | 'tauri'\n // Catch-all\n | 'other'\n\nexport type Platform = 'web' | 'mobile' | 'api' | 'desktop' | 'static' | 'other'\nexport type ContextSource = 'mcp-local' | 'mcp-studio' | 'studio-ui'\nexport type CollectionRuntimeFormat = 'map' | 'array'\n\n// ─── Field Definition ───\n\nexport interface FieldDef {\n type: FieldType\n required?: boolean\n unique?: boolean\n default?: unknown\n min?: number\n max?: number\n pattern?: string\n options?: string[]\n model?: string | string[]\n items?: string | FieldDef\n fields?: Record<string, FieldDef>\n accept?: string\n maxSize?: number\n description?: string\n /**\n * Human-readable name for this field in an editing UI.\n *\n * A plain string is one label for every locale; an object keyed by locale\n * carries a translation per locale. The union exists now rather than later\n * because widening `string` to it afterwards would break every consumer\n * that treats the value as a string.\n *\n * Absent means the field name itself is the label — which is why an editor\n * shows `body_public` and `is_category_hero` today.\n */\n label?: string | Record<string, string>\n /**\n * Display position in an editing UI, ascending.\n *\n * Fields are stored in canonical alphabetical order, so without this an\n * editor shows them alphabetically: on a 16-field article model `author`\n * comes first and `title` fifteenth. Fractional values are allowed so a\n * field can be inserted between two others without renumbering the rest.\n *\n * Fields without one sort after every field that has one, alphabetically\n * among themselves — so a model that has not been given an order behaves\n * exactly as it does today.\n */\n order?: number\n}\n\n// ─── Model Definition ───\n\nexport type LocaleStrategy = 'file' | 'suffix' | 'directory' | 'none'\n\nexport interface ModelDefinition {\n id: string\n name: string\n kind: ModelKind\n domain: string\n i18n: boolean\n /**\n * The project locales this model's content actually covers — a subset of\n * `config.locales.supported`.\n *\n * Absent means every project locale, which is the behaviour every model had\n * before this field existed: parity is checked against the whole supported\n * list. Present, it narrows that check to the locales named here, so a\n * partially-translated site can declare the truth instead of failing.\n *\n * A locale not in `config.locales.supported` is a validation error — see\n * {@link validateModelLocales}. Meaningless on a non-i18n model, which has\n * only the default locale.\n *\n * It narrows validation; it never grants a locale. Writing content in a\n * locale outside this list is still governed by `config.locales.supported`.\n */\n locales?: string[]\n /**\n * Name of the field whose value is shown as an entry's title — in a Studio row,\n * a picker, a relation reference. Declared, never guessed: a consumer that infers\n * the title from field order or length picks the icon over the headline.\n *\n * Must name a field that exists on this model and whose type can render as text\n * (see `isTitleFieldType`). Dictionary models have no fields, so their only legal\n * value is `DICTIONARY_TITLE_FIELD` — the entry key is the title.\n */\n title_field: string\n description?: string\n fields?: Record<string, FieldDef>\n /** Framework-relative path for content files (e.g. \"content/blog\", \"locales\"). When set, content is written here instead of .contentrain/content/ */\n content_path?: string\n /** How locale is encoded in file names. Default: \"file\" ({dir}/{locale}.json or {dir}/{slug}/{locale}.md) */\n locale_strategy?: LocaleStrategy\n /**\n * Public form configuration for this model — which fields a visitor-facing\n * form exposes, honeypot, captcha, auto-approve. Owned and interpreted by the\n * runtime provider (Studio writes it through its model PATCH); the content\n * engine carries it verbatim and never reads it. A model edit that rebuilds\n * the definition must keep it, or the site's form silently stops working.\n */\n form?: Record<string, unknown>\n /** Public comments configuration for this model — same ownership and handling as `form`. */\n comments?: Record<string, unknown>\n}\n\n/** Model keys the content engine carries verbatim for the runtime provider — see `ModelDefinition.form`. */\nexport const MODEL_EXTENSION_KEYS = ['form', 'comments'] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Config ───\n\nexport interface ContentrainConfig {\n version: number\n platform?: Platform\n stack: StackType\n workflow: WorkflowMode\n repository?: {\n /**\n * Git host backing this project's remote. Widens as new providers ship\n * in @contentrain/mcp — `github` and `gitlab` are currently supported.\n * The value is informational for tooling: the concrete provider the\n * MCP server talks to is chosen at server construction time, not from\n * this config.\n */\n provider: 'github' | 'gitlab'\n owner: string\n name: string\n /**\n * Not informational: local writes advance this branch. A local\n * auto-merge merges it into `contentrain` before each write, then\n * fast-forwards it to the `contentrain` tip and pushes it. The\n * `CONTENTRAIN_BRANCH` env overrides it; without either, the base is\n * the remote's default branch, then `main`, then `master` — never\n * merely the checked-out branch.\n */\n default_branch: string\n }\n locales: {\n default: string\n supported: string[]\n }\n domains: string[]\n assets_path?: string\n /**\n * Public media delivery base for resolving relative `media/...` references to\n * absolute URLs (e.g. a deployed CDN delivery endpoint). When set,\n * `@contentrain/query`'s `generate` bakes a `media()` resolver into the\n * generated client. The base may already include any project segment — the\n * resolver only joins `{cdn.url}/{path}`. Distinct from `assets_path` (the\n * local on-disk asset directory).\n */\n cdn?: {\n url?: string\n }\n /** Days a merged `cr/*` branch is kept before lazy cleanup. Default: 30. */\n branchRetention?: number\n /** Unmerged `cr/*` branch count that triggers a warning. Default: 50. */\n branchWarnLimit?: number\n /** Unmerged `cr/*` branch count that blocks new writes. Default: 80. */\n branchBlockLimit?: number\n /**\n * Delete the remote copy of a `cr/*` branch when it is merged or deleted\n * locally, and allow lazy pruning of already-merged remote branches.\n * Read-only remote visibility (doctor, branch listing) is not affected.\n * Default: true.\n */\n remoteBranchCleanup?: boolean\n}\n\n// ─── Vocabulary ───\n\nexport interface Vocabulary {\n version: number\n terms: Record<string, Record<string, string>>\n}\n\n// ─── Metadata ───\n\nexport interface EntryMeta {\n status: ContentStatus\n source: ContentSource\n updated_by: string\n /**\n * When this entry was last written, ISO 8601 UTC.\n *\n * Optional, and deliberately not backfilled. It is a fact about the past,\n * and an entry written before the field existed has no recoverable value —\n * a fabricated timestamp would be worse than an absent one, because it\n * would sort. Absent means unknown; the first write mints the real value.\n */\n updated_at?: string\n approved_by?: string | null\n version?: string\n publish_at?: string\n expire_at?: string\n}\n\nexport type SingletonMeta = EntryMeta\nexport type CollectionMeta = Record<string, EntryMeta>\nexport type DocumentMeta = EntryMeta\nexport type DictionaryMeta = EntryMeta\n\n// ─── Asset ───\n\nexport interface AssetEntry {\n path: string\n type: string\n size: number\n alt?: string\n}\n\n// ─── Validation ───\n\nexport interface ValidationError {\n severity: 'error' | 'warning' | 'notice'\n model?: string\n locale?: string\n entry?: string\n slug?: string\n field?: string\n message: string\n}\n\nexport interface ValidationResult {\n valid: boolean\n errors: ValidationError[]\n}\n\n// ─── Content Types (Storage vs Output) ───\n\n// Storage formats — how content is written to disk (canonical JSON)\nexport type SingletonContentFile = Record<string, unknown>\nexport type CollectionContentFile = Record<string, Record<string, unknown>>\nexport type DictionaryContentFile = Record<string, string>\n// Document storage = markdown file (frontmatter + body), not JSON\n\n// Output formats — how MCP/SDK returns content to consumers\nexport type CollectionEntry = { id: string } & Record<string, unknown>\nexport type CollectionContentOutput = CollectionEntry[]\n\n/** Document entry as returned by MCP/SDK — parsed markdown with frontmatter */\nexport interface DocumentEntry {\n slug: string\n frontmatter: Record<string, unknown>\n body: string\n}\n\nexport type DocumentContentOutput = DocumentEntry[]\n\n/** Polymorphic relation storage — used when model field is string[] (multiple targets) */\nexport interface PolymorphicRelationRef {\n model: string\n ref: string\n}\n\n// ─── Model Summary ───\n\n/** Lightweight model info for listing and status operations */\nexport interface ModelSummary {\n id: string\n kind: ModelKind\n domain: string\n i18n: boolean\n fields: number\n}\n\n// ─── File Framework ───\n\n/** Source file framework — detected from extension for normalize/apply operations */\nexport type FileFramework = 'vue' | 'svelte' | 'jsx' | 'astro' | 'script'\n\n// ─── Path Conventions ───\n\n/** Root directory name for all Contentrain data */\nexport const CONTENTRAIN_DIR = '.contentrain' as const\n\n/**\n * Default path patterns for Contentrain projects.\n * Variables: {domain}, {modelId}, {locale}, {slug}\n *\n * When a model has `content_path` set, content files are written to\n * `{projectRoot}/{content_path}/` instead — but meta files always\n * remain under `.contentrain/meta/`.\n */\n/**\n * Where each kind of file lives. These are the paths a reader can rely on and\n * a writer must produce; `@contentrain/mcp` resolves exactly these, and a\n * parity test there holds the two together.\n *\n * They show the default `locale_strategy: 'file'`. The `suffix`, `directory`\n * and `none` strategies move the locale within the same directory — see\n * `LocaleStrategy` — and `content_path` replaces the\n * `.contentrain/content/{domain}/{modelId}` prefix entirely.\n *\n * Meta always carries a locale in its file name: a non-i18n model keeps one\n * meta record, pinned to the project's default locale.\n */\nexport const PATH_PATTERNS = {\n config: '.contentrain/config.json',\n context: '.contentrain/context.json',\n vocabulary: '.contentrain/vocabulary.json',\n model: '.contentrain/models/{modelId}.json',\n /** Detected source capabilities and the provider chosen for each — `CapabilityManifest`. */\n capabilities: '.contentrain/capabilities.json',\n /** Scheduled and event-driven agent work — `AutomationDefinition[]`. */\n automations: '.contentrain/automations.json',\n /** Which operations need whose approval — `ApprovalPolicyFile`. */\n approvalPolicies: '.contentrain/approval-policies.json',\n /** Source→destination URL map, the output of a migration's `moved` entries. */\n redirects: '.contentrain/redirects.json',\n content: {\n singleton: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n collection: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n document: '.contentrain/content/{domain}/{modelId}/{slug}/{locale}.md',\n dictionary: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n /** Non-i18n JSON content (i18n: false) */\n noLocale: '.contentrain/content/{domain}/{modelId}/data.json',\n /** Non-i18n document (i18n: false) */\n noLocaleDocument: '.contentrain/content/{domain}/{modelId}/{slug}.md',\n },\n meta: {\n singleton: '.contentrain/meta/{modelId}/{locale}.json',\n collection: '.contentrain/meta/{modelId}/{locale}.json',\n document: '.contentrain/meta/{modelId}/{slug}/{locale}.json',\n dictionary: '.contentrain/meta/{modelId}/{locale}.json',\n },\n} as const\n\n/**\n * Paths this repository reserves but does not yet write.\n *\n * Reserving them is the point. `.contentrain/` is a shared namespace: the\n * migration engine, Studio and a customer's own tooling all write into it. A\n * name claimed here cannot later be taken for something else, and — more\n * usefully — the tools that walk the directory know these four are *expected*\n * rather than stray.\n *\n * The contract for them is deliberately narrow and must stay that way until a\n * tool actually owns one:\n *\n * - `contentrain_doctor` and `contentrain_validate` ignore them. They are not\n * orphans, not broken content, and not the validator's business.\n * - `contentrain_reconcile` treats each as one opaque file: it takes the side\n * that changed, and reports `file_conflict` when both sides did. It never\n * merges their interiors, because it does not know their interiors.\n *\n * Both behaviours are pinned by tests in `@contentrain/mcp`, so a future\n * change to directory walking cannot quietly start flagging or auto-merging\n * these files.\n */\nexport const RESERVED_PATHS = [\n PATH_PATTERNS.capabilities,\n PATH_PATTERNS.automations,\n PATH_PATTERNS.approvalPolicies,\n PATH_PATTERNS.redirects,\n] as const\n\nexport function isReservedPath(path: string): boolean {\n return (RESERVED_PATHS as readonly string[]).includes(path)\n}\n\n// ─── Validation Patterns ───\n\n/** Slug: lowercase alphanumeric with hyphens */\nexport const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n/** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */\nexport const ENTRY_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/\n/** Locale: ISO 639-1 with optional region code */\nexport const LOCALE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2})?$/\n\n// ─── Canonical Serialization ───\n\n/** Rules for deterministic JSON output — ensures stable git diffs */\nexport const CANONICAL_JSON = {\n indent: 2,\n encoding: 'utf-8',\n trailingNewline: true,\n omitNull: true,\n omitDefaults: true,\n sortKeys: true,\n} as const\n\n/**\n * Key order for canonical model JSON — the required keys first, then the optional\n * ones, then `fields` last so a reader sees the model's identity before scrolling\n * past its schema.\n *\n * This lives here, and only here, because `canonicalStringify` falls back to\n * alphabetical for any key absent from the order: a forgotten entry does not error,\n * it silently relocates. `satisfies` catches a key that is not on `ModelDefinition`;\n * the `keyof` assertion in index.test.ts catches a `ModelDefinition` key missing\n * from this array. Between them, drift fails `tsc`, which runs on every commit.\n */\nexport const MODEL_FIELD_ORDER = [\n 'id',\n 'name',\n 'kind',\n 'domain',\n 'i18n',\n 'locales',\n 'title_field',\n 'description',\n 'content_path',\n 'locale_strategy',\n 'fields',\n 'form',\n 'comments',\n] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Scaffold ───\n\nexport interface ScaffoldTemplate {\n id: string\n models: ModelDefinition[]\n sample_content?: Record<string, Record<string, unknown>>\n vocabulary?: Record<string, Record<string, string>>\n}\n\n// ─── Scan Types ───\n\nexport type StringContext =\n | 'jsx_text' | 'jsx_attribute'\n | 'template_text' | 'template_attribute'\n | 'variable_assignment' | 'function_argument'\n | 'object_value' | 'other'\n\nexport type FileCategory = 'page' | 'component' | 'layout' | 'other'\n\nexport interface ScanCandidate {\n file: string\n line: number\n column: number\n value: string\n context: StringContext\n surrounding: string\n /** Content confidence score (0-1). Higher = more likely user-visible content. */\n contentScore: number\n /** All locations where this value appears (populated when dedup is enabled). */\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface DuplicateGroup {\n value: string\n count: number\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface GraphNode {\n file: string\n category: FileCategory\n components?: string[]\n imports: string[]\n used_by: string[]\n strings: number\n}\n\nexport interface ProjectGraph {\n pages: GraphNode[]\n components: GraphNode[]\n layouts: GraphNode[]\n orphan_files: string[]\n stats: {\n total_files: number\n total_components: number\n total_pages: number\n total_strings_estimate: number\n }\n}\n\nexport interface ScanCandidatesResult {\n candidates: ScanCandidate[]\n duplicates: DuplicateGroup[]\n stats: {\n files_scanned: number\n raw_strings_found: number\n skipped: number\n low_confidence: number\n unique_candidates: number\n candidates_returned: number\n has_more: boolean\n skip_reasons: Record<string, number>\n }\n}\n\nexport interface ScanSummaryResult {\n total_files: number\n total_candidates_estimate: number\n by_directory: Record<string, { files: number; candidates: number }>\n top_repeated: Array<{ value: string; count: number }>\n sampling_note?: string\n file_types: Record<string, number>\n}\n\n// ─── Normalize Plan ───\n\nexport interface NormalizePlan {\n version: number\n status: 'pending' | 'approved' | 'rejected'\n created_at: string\n approved_at?: string\n agent: string\n scan_stats: {\n files_scanned: number\n raw_strings: number\n candidates_sent: number\n extracted: number\n skipped: number\n }\n models: NormalizePlanModel[]\n extractions: NormalizePlanExtraction[]\n patches: NormalizePlanPatch[]\n approved_models?: string[]\n}\n\nexport interface NormalizePlanModel {\n id: string\n kind: ModelKind\n domain: string\n i18n?: boolean\n fields: Record<string, FieldDef>\n}\n\nexport interface NormalizePlanExtraction {\n value: string\n file: string\n line: number\n model: string\n field: string\n locale?: string\n}\n\nexport interface NormalizePlanPatch {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\n// ─── Context ───\n\nexport interface ContextJson {\n version: string\n lastOperation: {\n tool: string\n model: string\n locale: string\n entries?: string[]\n timestamp: string\n source: ContextSource\n }\n stats: {\n models: number\n entries: number\n locales: string[]\n lastSync: string\n }\n}\n\n// ─── Git Transaction Types ───\n\n/** Standard name for the dedicated content branch */\nexport const CONTENTRAIN_BRANCH = 'contentrain' as const\n\n/** Result of selective file sync to developer's working tree */\nexport interface SyncResult {\n /** Files successfully synced to working tree */\n synced: string[]\n /** Files skipped due to uncommitted local changes */\n skipped: string[]\n /** Human-readable warning if files were skipped */\n warning?: string\n}\n\n/** Structured error for git operations with actionable guidance */\nexport interface ContentrainError {\n /** Machine-readable error code */\n code: string\n /** Human-readable error message */\n message: string\n /** Guidance for AI agents on how to handle this error */\n agent_hint: string\n /** Command or action the developer should take */\n developer_action: string\n}\n\n// ─── Validate: Pure Functions ───\n\n/**\n * Patterns for detecting a credential that has been pasted into content.\n *\n * Each one matches a credential-shaped *value*, not prose that mentions\n * credentials. That distinction is the whole design: the earlier set matched\n * bare prefixes, so `task_id` and `risk_level` tripped `sk_`, every page\n * documenting `Authorization: Bearer <token>` tripped `Bearer `, and\n * `postgres://localhost:5432/mydb` in a getting-started guide tripped the\n * connection-string rule. On this repository — which stores its own\n * documentation as content — that was 7 of 12 validation errors, and the real\n * findings were buried under them.\n *\n * A check that fires on the wrong thing is not a strict check; it is a check\n * people learn to scroll past.\n *\n * Every pattern here carries a provider-specific shape — a fixed prefix, a\n * credential-only URL form, a PEM header — and is trusted on its own. The one\n * generic rule, `api_key = …`, has no such shape and lives in\n * {@link API_KEY_ASSIGNMENT}, where what it captures is checked before it fires.\n */\nexport const SECRET_PATTERNS: ReadonlyArray<RegExp> = [\n // Stripe-style keys: the prefix must start a word and carry a long body,\n // so `task_id` and `risk_level` no longer match.\n /\\b[sp]k_(?:live|test)_[A-Za-z0-9]{6,}/,\n // GitHub tokens are a fixed prefix plus a long body.\n /\\bgh[pousr]_[A-Za-z0-9]{10,}/,\n // A bearer token, not the word. Placeholders — <token>, $TOKEN, {{token}},\n // YOUR_TOKEN — are how documentation writes it, and are not secrets.\n /\\bBearer\\s+(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})[A-Za-z0-9._~+/-]{20,}/,\n /\\baws_secret[_a-z]*\\s*[:=]\\s*['\"`]?[A-Za-z0-9/+=]{20,}/i,\n // AWS access key IDs have a fixed shape.\n /\\bAKIA[0-9A-Z]{16}\\b/,\n // A connection string only carries a secret when it carries credentials;\n // `postgres://localhost:5432/mydb` in a guide does not.\n /\\b(?:postgres(?:ql)?|mysql|mongodb)(?:\\+srv)?:\\/\\/[^\\s:@/]+:[^\\s@/]+@/,\n // A pasted private key is unambiguous.\n /-----BEGIN [A-Z ]*PRIVATE KEY-----/,\n]\n\n/** Validate a slug — returns error message or null if valid */\nexport function validateSlug(slug: string): string | null {\n if (!slug) return 'Slug is required'\n if (!SLUG_PATTERN.test(slug)) return `Invalid slug \"${slug}\": must be kebab-case alphanumeric (a-z, 0-9, hyphens)`\n if (slug.startsWith('.') || slug.includes('..')) return `Invalid slug \"${slug}\": path traversal not allowed`\n return null\n}\n\n/** Validate an entry ID — returns error message or null if valid */\nexport function validateEntryId(id: string): string | null {\n if (!ENTRY_ID_PATTERN.test(id)) return `Invalid entry ID \"${id}\": must be 1-40 alphanumeric characters (hyphens and underscores allowed)`\n return null\n}\n\n/** Validate a locale code against pattern and config — returns error message or null if valid */\nexport function validateLocale(locale: string, config: ContentrainConfig): string | null {\n if (!LOCALE_PATTERN.test(locale)) return `Invalid locale \"${locale}\": must be ISO 639-1 format (e.g. \"en\", \"tr\", \"pt-BR\")`\n if (!config.locales.supported.includes(locale)) {\n return `Locale \"${locale}\" is not in supported locales [${config.locales.supported.join(', ')}]. Update config first.`\n }\n return null\n}\n\n/**\n * The locales one model's content is checked against, and where that list came\n * from — `'model'` when the model narrowed it with `locales`, `'project'` when\n * it is the project-wide list (or, for a non-i18n model, the default locale).\n *\n * The provenance travels with the list because every message derived from it\n * has to say which list it evaluated: \"missing tr\" means something different\n * when tr is one of four project locales than when the model declares it.\n */\nexport interface ModelLocaleScope {\n locales: string[]\n source: 'model' | 'project'\n}\n\n/**\n * The locales a model's content is expected to cover.\n *\n * A non-i18n model stores content locale-agnostically, so its scope is the\n * default locale alone — unchanged, and `locales` never applies to it. An i18n\n * model narrows to `model.locales` when it declares one, otherwise it covers\n * every supported locale, which is what every model did before the field\n * existed.\n *\n * Locales are returned in `config.locales.supported` order, not the order the\n * model happened to list them: a message that names the list should read the\n * same for two models that declare the same locales differently. Anything the\n * model names that the project does not support is dropped here and reported\n * by {@link validateModelLocales} — resolution never invents coverage.\n */\nexport function resolveModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ModelLocaleScope {\n if (!model.i18n) return { locales: [config.locales.default], source: 'project' }\n const declared = model.locales\n if (!Array.isArray(declared) || declared.length === 0) {\n return { locales: [...config.locales.supported], source: 'project' }\n }\n const narrowed = config.locales.supported.filter(l => declared.includes(l))\n if (narrowed.length === 0) return { locales: [...config.locales.supported], source: 'project' }\n return { locales: narrowed, source: 'model' }\n}\n\n/** Name a locale scope the way a validation message should quote it. */\nexport function describeModelLocaleScope(scope: ModelLocaleScope): string {\n const list = `[${scope.locales.join(', ')}]`\n return scope.source === 'model'\n ? `the model's own locales ${list}`\n : `the project's supported locales ${list}`\n}\n\n/**\n * Check a model's `locales` declaration against the project config.\n *\n * Absent is valid and means every supported locale, so this returns nothing for\n * the models that predate the field. What it rejects is a declaration that\n * cannot be honoured: a locale the project does not support (the coverage would\n * be unreachable), an empty list (a model that covers nothing is a deleted\n * model, not a narrowed one), a duplicate, or a non-string entry.\n *\n * Declaring `locales` on a non-i18n model is a warning, not an error: the field\n * has no effect there, but the model is still readable and its content still\n * validates, so failing the project over it would be out of proportion.\n */\nexport function validateModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ValidationError[] {\n if (model.locales === undefined) return []\n\n const errors: ValidationError[] = []\n const err = (message: string): void => { errors.push({ severity: 'error', field: 'locales', message }) }\n\n if (!Array.isArray(model.locales)) {\n err(`Invalid \"locales\": must be an array of locale codes, got ${typeof model.locales}.`)\n return errors\n }\n if (model.locales.length === 0) {\n err('Invalid \"locales\": an empty list covers nothing. Omit the field to cover every supported locale.')\n return errors\n }\n\n const seen = new Set<string>()\n for (const locale of model.locales) {\n if (typeof locale !== 'string') {\n err(`Invalid \"locales\": every entry must be a locale code, got ${typeof locale}.`)\n continue\n }\n if (seen.has(locale)) {\n err(`Invalid \"locales\": locale \"${locale}\" is listed more than once.`)\n continue\n }\n seen.add(locale)\n if (!config.locales.supported.includes(locale)) {\n err(\n `Invalid \"locales\": locale \"${locale}\" is not in the project's supported locales `\n + `[${config.locales.supported.join(', ')}]. A model's locales must be a subset — `\n + `add it to config.json first, or drop it here.`,\n )\n }\n }\n\n if (errors.length === 0 && !model.i18n) {\n errors.push({\n severity: 'warning',\n field: 'locales',\n message: '\"locales\" has no effect on a model with i18n:false — such a model stores one locale-agnostic copy. Remove it, or set i18n:true.',\n })\n }\n\n return errors\n}\n\n/**\n * An API key being assigned a value: `api_key = <tail>`.\n *\n * Generic by nature — there is no provider prefix, so the word plus a separator\n * is the only structural signal — and therefore the one rule that also has to\n * look at what it captured before it fires; see {@link looksLikeCredential}.\n * Without that, `api_key = your_project_api_key_here` in a setup guide was a\n * blocking error, and once `contentrain validate` exited non-zero, a permanent\n * CI failure for any project whose content discusses API keys — which, for a\n * CMS, is ordinary content. Placeholders documentation uses (`<key>`, `$KEY`,\n * `{{key}}`, `YOUR_KEY`, `xxx`, `…`) are excluded up front, as for `Bearer`.\n */\nconst API_KEY_ASSIGNMENT = /\\bapi[_-]?key\\s*[:=]\\s*['\"`]?(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})([A-Za-z0-9_-]{16,})/gi\n\n/**\n * Does a value assigned to an API-key setting look like a credential rather\n * than prose about one?\n *\n * Two shapes are documentation, not secrets: a tail with no digit at all\n * (`your_project_api_key_here`, `xxxxxxxxxxxxxxxxxxxx`), and a tail whose\n * `_`/`-` separated tokens are each purely letters or purely digits\n * (`my_api_key_2024`, `REPLACE-WITH-KEY-1`) — words with a version number. An\n * issued key mixes letters and digits inside one token (`8f14e45fceea167a`,\n * `k1p8-q7x2m9v4z3w6`), which no sentence does by accident.\n */\nexport function looksLikeCredential(tail: string): boolean {\n if (!/\\d/.test(tail)) return false\n const tokens = tail.split(/[_-]+/).filter(Boolean)\n if (tokens.length > 1 && tokens.every(t => /^[A-Za-z]+$/.test(t) || /^\\d+$/.test(t))) return false\n return true\n}\n\n/** Detect potential secrets in a value — returns validation errors if found */\nexport function detectSecrets(value: unknown): ValidationError[] {\n if (typeof value !== 'string') return []\n const found = SECRET_PATTERNS.some(p => p.test(value))\n || [...value.matchAll(API_KEY_ASSIGNMENT)].some(m => looksLikeCredential(m[1]!))\n if (found) {\n return [{ severity: 'error', message: 'Potential secret detected in value' }]\n }\n return []\n}\n\n/** The models a relation field may point into. */\nfunction relationTargets(fieldDef: FieldDef): string[] {\n if (Array.isArray(fieldDef.model)) return fieldDef.model\n return fieldDef.model ? [fieldDef.model] : []\n}\n\n/**\n * A reference into one of several models: `{ model, ref }`.\n *\n * A relation with more than one target model cannot store a bare id — the id\n * alone does not say which model's entry it is — so it stores the pair. With a\n * single target the id is enough and stays a string. A one-element `model`\n * array is a single target. This is the storage rule the schema documents and\n * the MCP entry validator enforces; a type check that knew only the string form\n * rejected every correctly stored polymorphic reference.\n */\nfunction isPolymorphicRef(value: unknown): value is { model: string, ref: string } {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n && typeof (value as { model?: unknown }).model === 'string'\n && typeof (value as { ref?: unknown }).ref === 'string'\n}\n\nfunction fieldTypeMatches(value: unknown, fieldDef: FieldDef): boolean {\n if (value === null || value === undefined) return true\n\n switch (fieldDef.type) {\n case 'string':\n case 'text':\n case 'email':\n case 'url':\n case 'slug':\n case 'color':\n case 'phone':\n case 'code':\n case 'icon':\n case 'markdown':\n case 'richtext':\n case 'date':\n case 'datetime':\n case 'image':\n case 'video':\n case 'file':\n case 'select':\n return typeof value === 'string'\n\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return typeof value === 'number'\n\n case 'boolean':\n return typeof value === 'boolean'\n\n case 'relation':\n return relationTargets(fieldDef).length > 1 ? isPolymorphicRef(value) : typeof value === 'string'\n\n case 'relations':\n case 'array':\n return Array.isArray(value)\n\n case 'object':\n return typeof value === 'object' && !Array.isArray(value)\n\n default:\n return true\n }\n}\n\nfunction checkMinMax(value: unknown, fieldDef: FieldDef): string | null {\n if (fieldDef.min !== undefined) {\n if (typeof value === 'number' && value < fieldDef.min) {\n return `Value ${value} is below minimum ${fieldDef.min}`\n }\n if (typeof value === 'string' && value.length < fieldDef.min) {\n return `String length ${value.length} is below minimum ${fieldDef.min}`\n }\n if (Array.isArray(value) && value.length < fieldDef.min) {\n return `Array length ${value.length} is below minimum ${fieldDef.min}`\n }\n }\n if (fieldDef.max !== undefined) {\n if (typeof value === 'number' && value > fieldDef.max) {\n return `Value ${value} is above maximum ${fieldDef.max}`\n }\n if (typeof value === 'string' && value.length > fieldDef.max) {\n return `String length ${value.length} is above maximum ${fieldDef.max}`\n }\n if (Array.isArray(value) && value.length > fieldDef.max) {\n return `Array length ${value.length} is above maximum ${fieldDef.max}`\n }\n }\n return null\n}\n\n// ─── Semantic type validation ───\n\n/** Hex (#rgb/#rrggbb/#rrggbbaa), rgb()/rgba(), hsl()/hsla(), or a bare CSS keyword. */\nconst COLOR_PATTERN = /^(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|hsl)a?\\([^)]*\\)|[a-zA-Z]+)$/\n/** Deliberately loose: digits, spaces, and the usual separators, 4-20 digits total. */\nconst PHONE_PATTERN = /^\\+?[\\d\\s().-]{4,}$/\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$/\n\nfunction isParsableDate(value: string): boolean {\n return !Number.isNaN(new Date(value).getTime())\n}\n\n/**\n * Rules a field type implies beyond its runtime `typeof`.\n *\n * Severity follows one rule: a check that is *definitional* is an error — a `slug`\n * that does not match `SLUG_PATTERN` is not a slug. A check that is a *heuristic* is\n * a warning, because the pattern is an approximation and a legitimate value may sit\n * outside it (international phone formats, CSS colour keywords).\n *\n * `rating` is deliberately absent: its scale is never declared, so any range here\n * would be invented. Declare `min`/`max` on the field instead.\n *\n * Returns `[]` for types whose only contract is their `typeof` (`string`, `text`,\n * `code`, `icon`, `markdown`, `richtext`, `number`, `decimal`, `boolean`, media).\n */\nexport function validateSemanticType(value: unknown, type: FieldType): ValidationError[] {\n if (typeof value === 'number') {\n if (type === 'integer' && !Number.isInteger(value)) {\n return [{ severity: 'error', message: `Value ${value} is not an integer` }]\n }\n if (type === 'percent' && (value < 0 || value > 100)) {\n return [{ severity: 'error', message: `Percent ${value} is outside 0-100` }]\n }\n return []\n }\n\n if (typeof value !== 'string' || value === '') return []\n\n switch (type) {\n case 'slug': {\n const err = validateSlug(value)\n return err ? [{ severity: 'error', message: err }] : []\n }\n case 'date':\n return DATE_PATTERN.test(value) && isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid date \"${value}\": must be YYYY-MM-DD` }]\n case 'datetime':\n return isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid datetime \"${value}\": must be an ISO 8601 date-time` }]\n case 'email':\n return EMAIL_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid email` }]\n case 'url':\n return /^https?:\\/\\/.+/.test(value) || value.startsWith('/')\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid URL` }]\n case 'color':\n return COLOR_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid color (expected hex, rgb(), hsl() or a keyword)` }]\n case 'phone':\n return PHONE_PATTERN.test(value) && (value.match(/\\d/g)?.length ?? 0) >= 4\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid phone number` }]\n default:\n return []\n }\n}\n\n/** Media field types — the ones whose value is a path/URL to an asset. */\nconst MEDIA_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\nexport function isMediaType(type: FieldType): boolean {\n return MEDIA_TYPES.has(type)\n}\n\n// ─── Field presentation ───\n\n/**\n * Resolve a field's label for a locale.\n *\n * Falls back in the order a reader would expect: the requested locale, the\n * default locale, any locale present, then the field name. A field with no\n * label resolves to its own name, which is what an editor shows today.\n */\nexport function resolveFieldLabel(\n fieldName: string,\n field: Pick<FieldDef, 'label'>,\n locale?: string,\n defaultLocale?: string,\n): string {\n const { label } = field\n if (typeof label === 'string') return label\n if (label && typeof label === 'object') {\n const byLocale = (locale && label[locale])\n ?? (defaultLocale && label[defaultLocale])\n ?? Object.values(label)[0]\n if (byLocale) return byLocale\n }\n return fieldName\n}\n\n/**\n * Field names in display order.\n *\n * Exported so every consumer orders identically instead of each reimplementing\n * the fallback. Ascending `order`; fields without one come last, alphabetically\n * among themselves, which is the behaviour a model that has no `order` already\n * has.\n */\nexport function orderedFieldNames(fields: Record<string, FieldDef> | undefined): string[] {\n if (!fields) return []\n return Object.keys(fields).toSorted((a, b) => {\n const oa = fields[a]?.order\n const ob = fields[b]?.order\n if (oa !== undefined && ob !== undefined && oa !== ob) return oa - ob\n if (oa !== undefined && ob === undefined) return -1\n if (oa === undefined && ob !== undefined) return 1\n return a.localeCompare(b, 'en')\n })\n}\n\n// ─── Title field ───\n\n/**\n * The reserved `title_field` value for dictionary models. A dictionary has no\n * fields — it is a flat key→string map — so there is nothing to point at. Its key\n * *is* the title, and this names that fact rather than leaving the property blank.\n * Only dictionaries may use it; on any other kind `title_field` must name a real field.\n */\nexport const DICTIONARY_TITLE_FIELD = 'key'\n\n/**\n * Field types whose value can stand in as a human-readable title.\n *\n * Narrower than \"types that hold a string\": `color`, `phone`, `icon`, `select`,\n * `date`, `datetime`, `relation` and the media types all store strings, but a row\n * labelled `#ff0000` or `f3a81c09d24e` is exactly the failure this contract exists\n * to prevent.\n */\nexport const TITLE_FIELD_TYPES = [\n 'string', 'text', 'slug', 'email', 'url', 'code', 'markdown', 'richtext',\n] as const satisfies readonly FieldType[]\n\nconst TITLE_FIELD_TYPE_SET = new Set<FieldType>(TITLE_FIELD_TYPES)\n\n/** Whether a field of this type may be named as a model's `title_field`. */\nexport function isTitleFieldType(type: FieldType): boolean {\n return TITLE_FIELD_TYPE_SET.has(type)\n}\n\n/** Extensions we can name a MIME type for. Deliberately small — this is a sniff. */\nconst EXTENSION_MIME: Record<string, string> = {\n jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',\n webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', ico: 'image/x-icon',\n mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',\n pdf: 'application/pdf', zip: 'application/zip', json: 'application/json',\n csv: 'text/csv', txt: 'text/plain',\n}\n\n/**\n * Check a media value's file extension against an `accept` list.\n *\n * This is an approximation and says so: the value is a path or URL, so the real\n * MIME type is only knowable where the bytes are — the provider, at ingest\n * (`provider.ts:MediaProvider.ingest` owns that policy). An extension can lie, and\n * an unknown extension is not evidence of a violation. Hence: warning, never error,\n * and silence when we cannot tell.\n *\n * `accept` follows the HTML input syntax: `image/jpeg`, `image/*`, `.jpg`, comma-separated.\n */\nexport function validateAccept(value: string, accept: string): ValidationError[] {\n const path = value.split('?')[0]?.split('#')[0] ?? value\n const ext = path.split('.').pop()?.toLowerCase()\n if (!ext || ext === path.toLowerCase()) return []\n\n const mime = EXTENSION_MIME[ext]\n const patterns = accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean)\n if (patterns.length === 0) return []\n\n const matches = patterns.some((p) => {\n if (p.startsWith('.')) return p.slice(1) === ext\n if (p.endsWith('/*')) return mime ? mime.startsWith(p.slice(0, -1)) : false\n return mime === p\n })\n if (matches) return []\n\n // An extension we have no MIME for is not evidence of a violation.\n if (!mime && !patterns.some(p => p.startsWith('.'))) return []\n\n return [{\n severity: 'warning',\n message: `File extension \".${ext}\" does not match accept \"${accept}\" (extension check — the provider enforces the real MIME type at ingest)`,\n }]\n}\n\n/**\n * Validate a field value against its FieldDef schema.\n * Checks: required, type match, semantic type rules, min/max, pattern, select\n * options, and `accept` on media fields (by extension).\n * Does NOT check: secrets (use detectSecrets), uniqueness (stateful), relations (I/O),\n * or `maxSize` — byte length is not knowable from a stored path.\n */\nexport function validateFieldValue(value: unknown, fieldDef: FieldDef): ValidationError[] {\n const issues: ValidationError[] = []\n\n if (fieldDef.required && (value === undefined || value === null || value === '')) {\n issues.push({ severity: 'error', message: 'Required field is missing or empty' })\n return issues\n }\n\n if (value === undefined || value === null) return issues\n\n if (!fieldTypeMatches(value, fieldDef)) {\n issues.push({ severity: 'error', message: `Type mismatch: expected ${fieldDef.type}, got ${typeof value}` })\n return issues\n }\n\n issues.push(...validateSemanticType(value, fieldDef.type))\n\n if (fieldDef.type === 'relation' && isPolymorphicRef(value)) {\n const targets = relationTargets(fieldDef)\n if (!targets.includes(value.model)) {\n issues.push({ severity: 'error', message: `Relation target model \"${value.model}\" is not one of: ${targets.join(', ')}` })\n }\n }\n\n if (fieldDef.accept && isMediaType(fieldDef.type) && typeof value === 'string') {\n issues.push(...validateAccept(value, fieldDef.accept))\n }\n\n const minMaxErr = checkMinMax(value, fieldDef)\n if (minMaxErr) {\n issues.push({ severity: 'error', message: minMaxErr })\n }\n\n if (fieldDef.pattern && typeof value === 'string') {\n try {\n const regex = new RegExp(fieldDef.pattern)\n if (!regex.test(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" does not match pattern /${fieldDef.pattern}/` })\n }\n } catch {\n issues.push({ severity: 'warning', message: `Invalid pattern regex: ${fieldDef.pattern}` })\n }\n }\n\n if (fieldDef.type === 'select' && fieldDef.options && typeof value === 'string') {\n if (!fieldDef.options.includes(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" is not in allowed options: [${fieldDef.options.join(', ')}]` })\n }\n }\n\n return issues\n}\n\n// ─── Serialize: Pure Functions ───\n//\n// Implemented in `canonical.ts` and re-exported here: `execution.ts` hashes\n// plans with `canonicalStringify`, and this module re-exports `execution.ts`,\n// so the implementation has to sit outside the cycle.\n\nexport { canonicalStringify, sortKeys } from './canonical.js'\n\n/** Generate a 12-character hex entry ID */\nexport function generateEntryId(): string {\n const hex = '0123456789abcdef'\n let id = ''\n for (let i = 0; i < 12; i++) {\n id += hex[Math.floor(Math.random() * 16)]\n }\n return id\n}\n\n// ─── Reconcile: conflict id derivation ───\n\n/**\n * FNV-1a 64-bit over the input's UTF-8 code points, returned as 16 hex\n * chars. Deliberately dependency-free (no `node:crypto` — this package has\n * zero runtime imports and runs anywhere). Not cryptographic; the use case\n * is change detection, not tamper resistance.\n */\nexport function stableHash(input: string): string {\n const PRIME = 0x100000001B3n\n const MASK = 0xFFFFFFFFFFFFFFFFn\n let hash = 0xCBF29CE484222325n\n const bytes = new TextEncoder().encode(input)\n for (const byte of bytes) {\n hash ^= BigInt(byte)\n hash = (hash * PRIME) & MASK\n }\n return hash.toString(16).padStart(16, '0')\n}\n\n/**\n * Identity of a reconcile conflict: position AND values. Hashing the three\n * values into the id makes every resolution a compare-and-set — if any side\n * changes between the dry-run that produced the conflict and the apply that\n * carries the decision, the id no longer matches, the stale resolution is\n * dropped, and the conflict is re-reported with a fresh id. Values are\n * normalized through `sortKeys` first so semantically equal objects hash\n * equal regardless of key order; the positional array keeps absent and\n * null distinguishable per slot.\n */\nexport function conflictId(conflict: {\n path: string\n key?: string\n field?: string\n locale?: string\n base?: unknown\n ours?: unknown\n theirs?: unknown\n}): string {\n return stableHash(JSON.stringify([\n conflict.path,\n conflict.key ?? null,\n conflict.field ?? null,\n conflict.locale ?? null,\n sortKeys(conflict.base) ?? null,\n sortKeys(conflict.ours) ?? null,\n sortKeys(conflict.theirs) ?? null,\n ]))\n}\n\n// ─── Markdown Frontmatter ───\n\n/**\n * Decode the interior of a double-quoted scalar, or `undefined` when the text\n * is not one scalar after all — an unescaped `\"` inside it, or a backslash with\n * nothing after it.\n *\n * Returning `undefined` rather than guessing matters: `\"a\" and \"b\"` starts and\n * ends with a quote without being a quoted scalar, and slicing the ends off it\n * would silently corrupt the value.\n *\n * An unrecognised escape keeps its backslash instead of erroring. Our own\n * serializer only ever emits `\\\\`, `\\\"`, `\\n`, `\\r` and `\\t`, so round-tripping is\n * unaffected either way — but hand-written frontmatter says `\"C:\\Users\"` and\n * losing that to strictness would be a worse trade than keeping the bytes.\n */\nfunction decodeDoubleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char === '\"') return undefined\n if (char !== '\\\\') {\n out += char\n continue\n }\n const next = inner[i + 1]\n if (next === undefined) return undefined\n i += 1\n if (next === 'n') out += '\\n'\n else if (next === 'r') out += '\\r'\n else if (next === 't') out += '\\t'\n else if (next === '\\\\' || next === '\"' || next === '/') out += next\n else if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 1, i + 5))) {\n out += String.fromCharCode(parseInt(inner.slice(i + 1, i + 5), 16))\n i += 4\n } else out += `\\\\${next}`\n }\n return out\n}\n\n/** Decode a single-quoted scalar, where YAML escapes a quote by doubling it. */\nfunction decodeSingleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char !== \"'\") {\n out += char\n continue\n }\n if (inner[i + 1] !== \"'\") return undefined\n out += \"'\"\n i += 1\n }\n return out\n}\n\n/**\n * Read one frontmatter scalar: booleans, null, numbers, quoted strings with\n * their escapes decoded, or the text as written.\n *\n * Exported because it is a **contract**, not a utility. Two readers open these\n * documents — this package (through `parseMarkdownFrontmatter`, which\n * `@contentrain/mcp` re-exports) and `@contentrain/query`'s generator and Astro\n * loader. When each had its own copy they disagreed about what `\"He said\n * \\\"Hi\\\"\"` says, which means the generated client and the content engine\n * disagreed about the same file. One implementation, imported by both.\n */\nexport function parseFrontmatterScalar(raw: string): unknown {\n if (raw === 'true') return true\n if (raw === 'false') return false\n if (raw === 'null') return null\n if (/^-?\\d+$/.test(raw)) return parseInt(raw, 10)\n if (/^-?\\d+\\.\\d+$/.test(raw)) return parseFloat(raw)\n // Quoted scalars carry escapes. Stripping the quotes without decoding them is\n // how `He said \\\"Hi\\\"` came back with its backslashes, and how a value with a\n // backslash in it doubled them on every export.\n if (raw.length >= 2 && raw.startsWith('\"') && raw.endsWith('\"')) {\n return decodeDoubleQuoted(raw.slice(1, -1)) ?? raw\n }\n if (raw.length >= 2 && raw.startsWith(\"'\") && raw.endsWith(\"'\")) {\n return decodeSingleQuoted(raw.slice(1, -1)) ?? raw\n }\n return raw\n}\n\n/**\n * A scalar read as a string: quoting and escapes are resolved, but a value that\n * merely looks like a number or a boolean stays the text it was. Array items\n * use this, and so does any reader that knows from the model that a field is\n * string-typed — a SKU of `\"007\"` must not become the number 7.\n */\nexport function parseFrontmatterScalarString(raw: string): string {\n const value = parseFrontmatterScalar(raw)\n return typeof value === 'string' ? value : raw\n}\n\n/**\n * Split an inline array's interior on commas that are not inside a quoted\n * item — `[\"a, b\", c]` is two items, not three. Exported for the same reason\n * as {@link parseFrontmatterScalar}: both readers must split it the same way.\n */\nexport function splitFrontmatterList(inner: string): string[] {\n const items: string[] = []\n let current = ''\n let quote: '\"' | \"'\" | undefined\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (quote) {\n if (char === '\\\\' && quote === '\"' && i + 1 < inner.length) {\n current += char + inner[i + 1]!\n i += 1\n continue\n }\n current += char\n if (char === quote) quote = undefined\n continue\n }\n if (char === '\"' || char === \"'\") {\n quote = char\n current += char\n continue\n }\n if (char === ',') {\n items.push(current)\n current = ''\n continue\n }\n current += char\n }\n items.push(current)\n return items\n}\n\nfunction yamlScalar(value: unknown): string {\n const str = String(value)\n // A newline, a tab or edge whitespace cannot survive a bare scalar: the\n // second line of a multi-line value was being written straight into the\n // frontmatter, where the reader saw a line that is not `key: value` and\n // skipped it — the value was silently truncated at its first newline. Edge\n // whitespace was lost to the reader's `trim()` for the same reason.\n if (str.includes(':') || str.includes('#') || str.includes('{') || str.includes('}')\n || str.includes('[') || str.includes(']') || str.includes('*') || str.includes('&')\n || str.includes('!') || str.includes('|') || str.includes('>') || str.includes(\"'\")\n || str.includes('\"') || str.includes('%') || str.includes('@') || str.includes('`')\n || str.includes('\\\\') || str.includes('\\n') || str.includes('\\r') || str.includes('\\t')\n || str.startsWith('-') || str.startsWith('?') || str !== str.trim()\n || str === ''\n // A string whose bare form would read back as something else. `'42'` was\n // written bare and returned as the number 42 — a slug, a version or a title\n // that happens to be digits changed type on every save. Asking the reader\n // is exact where a list of shapes drifts away from it.\n //\n // The type check is the other half. The old rule quoted anything spelled\n // `true`, so a real boolean was written as `\"true\"` and came back a string.\n // Only a *string* that would be misread needs the quotes; a boolean, a\n // number or a null is written bare precisely so it reads back as itself.\n // `yes`/`no` are not keywords to this reader but are to others, so a string\n // spelled that way is quoted for the benefit of whoever else opens the file.\n || (typeof value === 'string' && (parseFrontmatterScalar(str) !== str || str === 'yes' || str === 'no'))) {\n return `\"${str\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/\\t/g, '\\\\t')}\"`\n }\n return str\n}\n\nfunction serializeYamlValue(value: unknown, indent: number): string[] {\n const pad = ' '.repeat(indent)\n if (value === null || value === undefined) return []\n if (typeof value === 'object' && !Array.isArray(value)) {\n const lines: string[] = []\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n if (v === null || v === undefined) continue\n if (typeof v === 'object' && !Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n lines.push(...serializeYamlValue(v, indent + 1))\n } else if (Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n for (const item of v) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 2)\n if (nested.length > 0) {\n lines.push(`${pad} - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad} - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${pad}${k}: ${yamlScalar(v)}`)\n }\n }\n return lines\n }\n if (Array.isArray(value)) {\n const lines: string[] = []\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 1)\n if (nested.length > 0) {\n lines.push(`${pad}- ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad}- ${yamlScalar(item)}`)\n }\n }\n return lines\n }\n return [`${pad}${yamlScalar(value)}`]\n}\n\nfunction serializeYamlFields(data: Record<string, unknown>): string[] {\n const lines: string[] = []\n for (const [key, value] of Object.entries(data)) {\n if (key === 'body') continue\n if (value === null || value === undefined) continue\n if (typeof value === 'object' && !Array.isArray(value)) {\n lines.push(`${key}:`)\n lines.push(...serializeYamlValue(value, 1))\n } else if (Array.isArray(value)) {\n // A bare `key:` is genuinely ambiguous — empty array, empty object, or\n // null — and two readers guessed it differently. Writing `[]` says which\n // one it is instead of asking anyone to infer it from the next line.\n if (value.length === 0) {\n lines.push(`${key}: []`)\n continue\n }\n lines.push(`${key}:`)\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, 2)\n if (nested.length > 0) {\n lines.push(` - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(` - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${key}: ${yamlScalar(value)}`)\n }\n }\n return lines\n}\n\n/** Parse markdown frontmatter — extracts YAML frontmatter and body from a markdown string */\nexport function parseMarkdownFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {\n const normalized = content.replace(/\\r\\n/g, '\\n')\n const match = normalized.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/)\n if (!match) return { frontmatter: {}, body: normalized }\n\n const frontmatterStr = match[1]!\n const body = match[2]!.trim()\n const frontmatter: Record<string, unknown> = {}\n\n let currentKey: string | null = null\n let currentArray: string[] | null = null\n\n for (const line of frontmatterStr.split('\\n')) {\n if (/^\\s+-\\s+/.test(line) && currentKey) {\n const value = parseFrontmatterScalarString(line.replace(/^\\s+-\\s+/, '').trim())\n if (!currentArray) currentArray = []\n currentArray.push(value)\n continue\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n currentKey = null\n currentArray = null\n }\n\n const kvMatch = line.match(/^([\\w][\\w.-]*)\\s*:\\s*(.*)$/)\n if (!kvMatch) continue\n\n const key = kvMatch[1]!\n const rawValue = kvMatch[2]!.trim()\n\n if (rawValue === '') {\n currentKey = key\n currentArray = []\n continue\n }\n\n if (rawValue.startsWith('[') && rawValue.endsWith(']')) {\n const inner = rawValue.slice(1, -1).trim()\n frontmatter[key] = inner === '' ? [] : splitFrontmatterList(inner).map(item => parseFrontmatterScalarString(item.trim()))\n continue\n }\n\n frontmatter[key] = parseFrontmatterScalar(rawValue)\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n }\n\n return { frontmatter, body }\n}\n\n/** Serialize data + body into markdown frontmatter format */\nexport function serializeMarkdownFrontmatter(data: Record<string, unknown>, body: string): string {\n const trimmedBody = body.trimStart()\n if (trimmedBody.startsWith('---')) {\n const endIdx = trimmedBody.indexOf('---', 3)\n if (endIdx > 0) {\n const bodyFmContent = trimmedBody.slice(3, endIdx).trim()\n const afterFm = trimmedBody.slice(endIdx + 3)\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n if (bodyFmContent) {\n const modelKeys = new Set(Object.keys(data))\n for (const line of bodyFmContent.split('\\n')) {\n if (line.length > 0 && line[0] !== ' ' && line[0] !== '-') {\n const colonIdx = line.indexOf(':')\n const lineKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : ''\n if (lineKey && modelKeys.has(lineKey)) continue\n }\n lines.push(line)\n }\n }\n lines.push('---')\n if (afterFm.trim()) {\n lines.push(afterFm.trimStart())\n }\n lines.push('')\n return lines.join('\\n')\n }\n }\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n lines.push('---')\n lines.push('')\n if (body) {\n lines.push(body)\n lines.push('')\n }\n return lines.join('\\n')\n}\n\n// ─── Migration contracts ───\n\nexport * from './migration.js'\n\n// ─── Execution, approval and source-delta contracts ───\n\nexport * from './execution.js'\n\n// ─── Approval evaluator ───\n//\n// The decision procedure over the contracts above: risk + scope + policy →\n// what must be approved, and whether it has been. Pure and dependency-free, so\n// Studio, a CLI and a test all reach the same answer.\n\nexport * from './approval.js'\n\n// ─── Repository provider contracts ───\n//\n// Provider-agnostic engine contracts used by @contentrain/mcp. Exposed from\n// @contentrain/types so third-party tools can implement a custom\n// RepoProvider without taking a dependency on @contentrain/mcp.\nexport type {\n ApplyPlanInput,\n BaseAdvance,\n Branch,\n Commit,\n CommitAuthor,\n ConflictCode,\n ConflictItem,\n ConflictResolution,\n FileChange,\n FileDiff,\n MediaAsset,\n MediaIngestInput,\n MediaListOptions,\n MediaListResult,\n MediaProvider,\n MediaUpdateInput,\n MergeResult,\n ProviderCapabilities,\n RemotePush,\n RepoProvider,\n RepoReader,\n RepoWriter,\n WriteReadiness,\n} from './provider.js'\nexport { LOCAL_CAPABILITIES } from './provider.js'\n\n// Implemented in `media.ts` and re-exported here: both `@contentrain/mcp`\n// (write-time normalization) and `@contentrain/query` (read-time resolution)\n// share this single rewrite engine, so the rules can only be defined once.\nexport {\n isStoredMediaPath,\n rewriteEntryMedia,\n rewriteFieldMedia,\n rewriteMarkdownMedia,\n rewriteMediaUrl,\n toDeliveryUrl,\n} from './media.js'\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/canonical.ts","../src/migration.ts","../src/execution.ts","../src/approval.ts","../src/migrate-claim.ts","../src/provider.ts","../src/media.ts","../src/index.ts"],"sourcesContent":["// ─── Canonical Serialization ───\n//\n// Deterministic JSON: the same data must produce the same bytes on every\n// machine, every run. Stable git diffs depend on it, and so does `plan_hash`\n// in `execution.ts` — which is why these two functions live in their own\n// module rather than in `index.ts`. `execution.ts` needs them and `index.ts`\n// re-exports `execution.ts`; keeping them here is what stops that from being\n// an import cycle.\n\n/** Recursively sort object keys — respects optional fieldOrder for top-level keys */\nexport function sortKeys(obj: unknown, fieldOrder?: readonly string[]): unknown {\n if (obj === null || obj === undefined) return undefined\n if (Array.isArray(obj)) return obj.map(item => sortKeys(item, fieldOrder))\n if (typeof obj !== 'object') return obj\n\n const record = obj as Record<string, unknown>\n const sorted: Record<string, unknown> = {}\n\n const keys = fieldOrder\n ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])]\n : Object.keys(record).toSorted()\n\n for (const key of keys) {\n if (!(key in record)) continue\n const val = record[key]\n if (val === null || val === undefined) continue\n sorted[key] = sortKeys(val)\n }\n\n return sorted\n}\n\n/** Canonical JSON serialization — deterministic output for stable git diffs */\nexport function canonicalStringify(data: unknown, fieldOrder?: readonly string[]): string {\n const sorted = sortKeys(data, fieldOrder)\n return `${JSON.stringify(sorted, null, 2)}\\n`\n}\n","// ─── Migration Contracts ───\n//\n// Shared, MIT-licensed shapes for the WordPress → static-site migration pipeline.\n// Producers: the WordPress Bridge plugin (GPL, separate repo), REST/WXR importers.\n// Consumers: the migration engine, Studio, and the open Astro emitter.\n//\n// Every shape here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository, and license\n// boundaries. A GPL plugin writes RawIR; a proprietary service reads it; an MIT\n// emitter consumes ProjectIR. The contract is the only thing they share, so it\n// lives in the one package all of them may depend on.\n\n/**\n * Version stamped into every migration document (`version` field on each root).\n * The four contracts evolve together with this package; a reader that sees a\n * higher version than it knows should refuse rather than guess.\n */\nexport const MIGRATION_CONTRACT_VERSION = 1\n\n// ─── Source access ───\n\n/**\n * How the WordPress site's data was reached, ordered from least to most\n * complete. Field coverage rises with each rung — measured in August 2026 at\n * roughly 34% for public REST, 57% with an Application Password and 82% from a\n * WXR export; the Bridge plugin reads WordPress from inside and is meant to\n * reach every field — which is why provenance is recorded on every RawIR:\n * two documents for the same site are only comparable if their rungs are.\n */\nexport type SourceAccessKind = 'rest_public' | 'rest_auth' | 'wxr' | 'bridge'\n\n/** The ladder in ascending completeness order. */\nexport const SOURCE_ACCESS_LADDER = [\n 'rest_public',\n 'rest_auth',\n 'wxr',\n 'bridge',\n] as const satisfies readonly SourceAccessKind[]\n\n// ─── RawIR ───\n//\n// Source-faithful extraction of a WordPress site: what the source said, before\n// any interpretation. Unresolved references are kept and *marked* (`resolved`\n// flags) instead of dropped — deciding what a broken reference means is the\n// consumer's job, not the extractor's.\n\nexport interface RawProvenance {\n kind: SourceAccessKind\n /** ISO 8601 UTC time the extraction ran. */\n fetched_at?: string\n /** Name/version of the producing tool (e.g. \"wordpress-bridge/1.0\"). */\n tool?: string\n}\n\nexport interface RawSite {\n url: string\n title?: string\n description?: string\n /** WXR distinguishes the WP install URL from the public site URL. */\n base_site_url?: string\n base_blog_url?: string\n /** Site language as reported (e.g. \"en-US\"); multilingual detail lives in `language_pairs`. */\n language?: string | null\n /** Generator string (WordPress version) when the source exposed it. */\n generator?: string | null\n /** WXR export timestamp, when the source was a WXR file. */\n export_date?: string | null\n wxr_version?: string | null\n}\n\nexport interface RawAuthor {\n id: number | null\n login: string\n email?: string | null\n display_name: string\n first_name?: string | null\n last_name?: string | null\n}\n\nexport interface RawTerm {\n id: number | null\n taxonomy: string\n slug: string\n name: string\n /** Parent term slug within the same taxonomy, when hierarchical. */\n parent?: string | null\n /** Whether `parent` names a term present in this document. */\n parent_resolved?: boolean | null\n description?: string\n}\n\n/** A post's reference to a term — kept even when the term itself is absent. */\nexport interface RawTermRef {\n taxonomy: string\n slug: string\n name: string\n /** Whether the referenced term exists in this document's `terms`. */\n resolved: boolean\n}\n\n/** An ACF field value paired with its field-definition key (`field_…`). */\nexport interface RawAcfValue {\n value: unknown\n field_key: string\n}\n\nexport interface RawPost {\n id: number\n /** WordPress post type — \"post\", \"page\", or a custom post type slug. */\n type: string\n /** WordPress status verbatim (\"publish\", \"draft\", \"future\", \"pending\", …). */\n status: string\n slug: string\n title: string\n /** Public permalink, when known. Permalink *structure* is a site setting; the link is the fact. */\n link?: string | null\n guid?: string | null\n /** Author login; resolve against `RawIR.authors`. */\n author: string | null\n /** ISO 8601 UTC, null when the source carried no usable date. */\n date: string | null\n modified: string | null\n /** Rendered/exported HTML body, untransformed. */\n content: string\n excerpt: string\n parent?: number | null\n menu_order?: number\n sticky?: boolean\n password?: string | null\n comment_status?: string | null\n ping_status?: string | null\n terms: RawTermRef[]\n /**\n * Post meta, PHP-serialized values already decoded where possible.\n * Core WP keys (underscore-prefixed) are included: which keys matter is a\n * downstream decision.\n */\n meta: Record<string, unknown>\n /** Meta keys whose values were PHP-serialized in the source (decoded above). */\n serialized_keys?: string[]\n /** ACF fields recovered by pairing `foo` with `_foo = \"field_…\"`. */\n acf?: Record<string, RawAcfValue>\n /**\n * Language of this post as the multilingual plugin reports it — Polylang's\n * `lang` slug (`tr`), WPML's `wpml_current_locale` (`tr_TR`), or the WXR\n * `language` taxonomy term. Absent on monolingual sites. Which posts are\n * translations of each other is `RawIR.language_pairs`, not this field.\n */\n lang?: string | null\n}\n\nexport interface RawAttachment {\n id: number\n title: string\n slug: string\n /** Original file URL. Downloading/re-hosting is a consumer concern. */\n url: string | null\n alt?: string\n caption?: string\n description?: string\n /** Relative upload path (`_wp_attached_file`), when present. */\n file?: string | null\n /** `_wp_attachment_metadata` verbatim (sizes, EXIF, …). */\n image_meta?: unknown\n mime?: string | null\n parent?: number | null\n parent_resolved?: boolean | null\n author?: string | null\n date?: string | null\n status?: string\n meta?: Record<string, unknown>\n}\n\n/** Where a menu item points. `resolved` says whether the target exists in this document. */\nexport type RawMenuTarget =\n | { kind: 'url'; url: string; resolved: true }\n | { kind: 'post'; post_type: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'term'; taxonomy: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'archive'; post_type: string; resolved: true }\n | { kind: 'unknown'; resolved: false }\n\nexport interface RawMenuItem {\n id: number\n title: string\n order?: number\n /** Parent menu-item id for nested menus. */\n parent?: number | null\n parent_unresolved?: boolean\n url?: string | null\n target: RawMenuTarget\n /** `target` attribute for the rendered link (e.g. \"_blank\"). */\n target_attr?: string | null\n classes?: string[]\n description?: string\n status?: string\n}\n\nexport interface RawMenu {\n id: number | null\n slug: string\n name: string\n items: RawMenuItem[]\n}\n\nexport interface RawComment {\n id: number\n /** Post id this comment belongs to. */\n post: number\n post_type?: string\n parent?: number | null\n parent_resolved?: boolean | null\n author: string\n email?: string | null\n url?: string | null\n /**\n * ISO 8601 UTC. Producers MUST normalize to UTC — comment import fidelity\n * (\"zero record and parent loss\") includes `created_at`, and a site-local\n * date silently corrupts it. When normalization is impossible, ship the\n * source's GMT column in `date_gmt` and leave `date` null.\n */\n date: string | null\n date_gmt?: string | null\n content: string\n /**\n * WordPress approval flag. Fixed vocabulary: `'1'` (approved), `'0'`\n * (pending), `'spam'`, `'trash'` — consumers map these; unknown strings\n * pass through for forward compatibility, never dropped.\n */\n approved?: '1' | '0' | 'spam' | 'trash' | (string & {})\n type?: string\n user_id?: number | null\n meta?: Record<string, unknown>\n}\n\n/**\n * How a redirect's `from` is compared with a request path. Only `url` without\n * `regex` is a plain one-to-one mapping; the others are patterns, and turning\n * one into a literal `from` produces the wrong redirect.\n */\nexport type RawRedirectMatch = 'url' | 'regex' | 'start' | 'contains' | 'end'\n\n/** A redirect rule the live site serves (e.g. from the Redirection plugin — visible from the authenticated rung up). */\nexport interface RawRedirect {\n from: string\n to: string\n status?: number\n /** Which plugin/table produced the rule. */\n source?: string\n /** Stable id within the export, `<source>:<source-specific id>`. */\n id?: string\n /** How `from` matches; absent means `url`. */\n match?: RawRedirectMatch\n /** `from` is a regular expression in the source's own dialect. */\n regex?: boolean\n /** The source's own module/group serving it, when it has one (e.g. Redirection's \"Apache\"). */\n served_by?: string\n /** An adjustment the extractor made, stated rather than hidden (e.g. a non-redirect status read as 301). */\n status_note?: string\n}\n\n/**\n * A rule a source holds that the site does not serve as a plain redirect, with\n * the reason. Every rule lands in `RawIR.redirects` or here, so the two\n * together account for the source's whole table.\n */\nexport interface RawRedirectExcluded extends Omit<Partial<RawRedirect>, 'match'> {\n id: string\n source: string\n /** The source's own match type — may be a condition (`login`, `referrer`, …) rather than a {@link RawRedirectMatch}. */\n match?: RawRedirectMatch | (string & {})\n reason:\n | 'disabled'\n | 'source-inactive'\n | 'slug-reused'\n | 'no-slug-address'\n | `not-a-redirect:${string}`\n | `conditional-match:${string}`\n /** The condition a conditional rule depends on, verbatim. */\n condition?: unknown\n}\n\n// ─── SEO ───\n\nexport const SEO_PROVIDERS = ['yoast', 'rank_math', 'aioseo'] as const\nexport type SeoProvider = (typeof SEO_PROVIDERS)[number]\n\n/** One page's SEO as one provider holds it. */\nexport interface RawSeoEntry {\n /** true: the running plugin rendered these values; false: stored values and templates only. */\n resolved?: boolean\n title?: string\n description?: string\n canonical?: string\n robots?: { index?: 'index' | 'noindex', follow?: 'follow' | 'nofollow', advanced?: string[] }\n /** The directives the page actually carries, after WordPress core and the plugin reconcile them. */\n robots_served?: string[]\n open_graph?: { title?: string, description?: string, type?: string, url?: string, image?: string, site_name?: string }\n twitter?: { card?: string, title?: string, description?: string, image?: string }\n focus_keyword?: string\n /** Schema.org types, and the JSON-LD graph when the plugin rendered it. */\n schema?: { types: string[], graph?: unknown }\n /** What the plugin stored for this page (templates such as `%%title%% %%sep%%`), secrets removed. */\n stored?: Record<string, unknown>\n}\n\n/** A provider's site-wide settings. */\nexport interface RawSeoSettings {\n separator?: string | null\n /** Keyed by context: `post`, `page`, `home`, `author`, `archive`, `tax:<taxonomy>`, `ptarchive-<type>`, … */\n title_templates: Record<string, string>\n description_templates: Record<string, string>\n noindex?: Record<string, boolean>\n social?: Record<string, unknown>\n /** Site verification codes — public meta values. */\n verification?: Record<string, string>\n /** Option name → cleaned value, verbatim otherwise. */\n raw: Record<string, unknown>\n}\n\n/**\n * The site's SEO layer: which plugin serves the head, its settings, and each\n * page's values. `status: 'none'` is an answer — no SEO plugin, and WordPress\n * core renders only the title — not a missing export.\n */\nexport interface RawSeo {\n /** Producer's format identifier, e.g. `contentrain-bridge-seo@1`. */\n format?: string\n status: 'present' | 'none'\n /** Whose output the live site serves. */\n serving: SeoProvider | 'wordpress-core'\n providers: Record<SeoProvider, { status: 'active' | 'inactive-with-data' | 'absent', version?: string }>\n settings: Partial<Record<SeoProvider, RawSeoSettings>>\n /** `post:<id>` or `term:<taxonomy>:<id>` → one block per provider that has data for it. */\n entries: Record<string, Partial<Record<SeoProvider, RawSeoEntry>>>\n /** Values deliberately not exported (e.g. a sensitive key), by address. */\n excluded?: { source: string, reason: string }[]\n}\n\n// ─── Routing ───\n\n/** A page WordPress routes to itself (front page, posts page). */\nexport interface RawRoutedPage {\n id: number\n slug: string\n /** Site-root-relative address. */\n path: string\n}\n\n/**\n * The site's URL rules — what `RawPost.link` holds as facts, stated as the\n * structure that produced them. Permastructs are site-root-relative with\n * `front` already applied where `with_front` asks for it.\n */\nexport interface RawRouting {\n /** Producer's format identifier, e.g. `contentrain-bridge-routing@1`. */\n format?: string\n /** The site's home URL. */\n home?: string\n permalink_structure: string\n /** Plain `?p=` permalinks — no structure at all. */\n plain: boolean\n trailing_slash: boolean\n front: string\n category_base: string\n tag_base: string\n pagination_base: string\n author_base: string\n search_base: string\n feed_base?: string\n comments_pagination_base?: string\n author_structure?: string | null\n date_structure?: string | null\n page_structure?: string | null\n /** `posts` or `page`. */\n show_on_front: string\n page_on_front?: RawRoutedPage | null\n page_for_posts?: RawRoutedPage | null\n posts_per_page: number\n post_types: {\n name: string\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, feeds?: boolean, pages?: boolean } | false\n permastruct: string | null\n has_archive: boolean\n archive_path: string | null\n query_var: string | false\n }[]\n taxonomies: {\n name: string\n object_types: string[]\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, hierarchical?: boolean } | false\n permastruct: string | null\n }[]\n}\n\n// ─── Hardcoded text ───\n//\n// Interface text that lives outside the content tables — theme templates,\n// scripts, widgets, menus, options, Customizer mods, and what the rendered\n// pages show. Every piece found gets exactly one outcome: transferred to a\n// named target, excluded with a reason, or — for a source that could not be\n// read — an error. Nothing is dropped silently.\n\n/** Where a candidate was found. */\nexport const TEXT_CANDIDATE_KINDS = [\n 'php-gettext',\n 'php-html',\n 'php-echo',\n 'html',\n 'js',\n 'widget',\n 'menu',\n 'option',\n 'customizer',\n 'render',\n] as const\n\nexport type RawTextCandidateKind = (typeof TEXT_CANDIDATE_KINDS)[number]\n\n/** Why a candidate is not transferred. `RawTextCandidate.reason` joins one or more with `,`. */\nexport const TEXT_EXCLUDE_REASONS = [\n 'empty',\n 'too-long',\n 'secret',\n 'code',\n 'url',\n 'number',\n 'no-letters',\n 'placeholder-only',\n 'dynamic',\n 'not-text',\n 'content',\n 'rendered-from-source',\n] as const\n\nexport type RawTextExcludeReason = (typeof TEXT_EXCLUDE_REASONS)[number]\n\n/** Where a transferred candidate lands in the store. */\nexport type RawTextTarget =\n | 'dictionary:ui-strings'\n | `theme-settings.${string}`\n | 'site.title'\n | 'site.description'\n | 'content:wp-menu-items'\n\n/** One place a candidate's text was found. */\nexport interface RawTextOccurrence {\n kind: RawTextCandidateKind\n /** A file path relative to `wp-content`, `render:<state>`, `nav_menu_item#<id>`, … */\n source: string\n /** 1-based line in a file; `0` for a source without lines (render, menu, option). */\n line: number\n}\n\n/**\n * One piece of interface text in one place. Candidates merge only when text,\n * locale and context are all equal: \"Read more\" in an `a` and in a `button`\n * are two candidates, and so are `_x('Post', 'noun')` and `_x('Post', 'verb')`.\n */\nexport interface RawTextCandidate {\n /** `sha256(text \\0 locale \\0 context)`, first 20 hex characters. */\n id: string\n /** The text; `[redacted]` when `reason` includes `secret`. */\n value: string\n locale: string\n /**\n * Where the text sits: an HTML tag (`a`, `input@placeholder`),\n * `gettext[:<ctx>][:plural]`, `js`, `widget:<tag>` / `widget:title`, `menu`,\n * `option:<name>`, `customizer:<mod>`, or for rendered text\n * `<landmark>><tag>` (`footer>p`, `nav>nav@aria-label`).\n */\n context: string\n kind: RawTextCandidateKind\n /** The first occurrence's `source` and `line`. */\n source: string\n line: number\n /** Every place the text was found, in the producer's listing order. */\n occurrences: RawTextOccurrence[]\n outcome: 'transfer' | 'exclude'\n /** Set exactly when `outcome` is `transfer`. */\n target?: RawTextTarget\n /** Set exactly when `outcome` is `exclude`: one or more `RawTextExcludeReason`s joined with `,`. */\n reason?: string\n /**\n * With `rendered-from-source`: ids of the source candidates this page text\n * came from. Page text found in source is excluded here and transferred\n * there, so the same words never land twice and contexts are not merged.\n */\n related?: string[]\n /**\n * The store key: `<group>.<context>.<words>-<sha256(text \\0 context)[0:6]>`,\n * or `theme-settings.<mod>` for a Customizer value. A function of text and\n * context only — moving a string to another file changes no key. A\n * collision fails the export rather than merging two texts.\n */\n key: string\n decision: 'review' | 'include' | 'exclude'\n}\n\n/**\n * Every piece of interface text a site shows, each with one outcome. Totals\n * close: `by_outcome.transfer + by_outcome.exclude = candidates`,\n * `by_outcome.error = errors.length`, and `occurrences` counts every\n * occurrence the scanners found — listed ones plus those past the per-file\n * listing cap, which are counted by reason instead.\n */\nexport interface RawHardcodedText {\n /** Producer's format identifier, e.g. `contentrain-bridge-hardcoded-text@1`. */\n format: string\n candidates: RawTextCandidate[]\n /**\n * Sources that could not be read — an outcome, not a silent gap. Reasons:\n * `source-over-2MiB`, `source-unreadable`, `render-fetch-failed: <code>`,\n * `render-http-<status>`, `render-no-body`.\n */\n errors: { source: string, reason: string }[]\n totals: {\n occurrences: number\n occurrences_listed: number\n /** Occurrences past the listing cap: excluded and counted, not listed. */\n unlisted_excluded: number\n unlisted_by_reason: Record<string, number>\n candidates: number\n by_outcome: { transfer: number, exclude: number, error: number }\n sources: { files: number, settings: number, render_states: number }\n }\n}\n\n// ─── Integrations ───\n//\n// Outside services the site is connected to — analytics, CRM, newsletter,\n// captcha, CDN, embeds. A static site cannot carry these connections over:\n// each one with an account must be connected again, with the new site's own\n// credentials. Credentials never leave WordPress; only whether one is set does.\n\nexport const INTEGRATION_CATEGORIES = ['analytics', 'crm', 'newsletter', 'ads', 'comments', 'captcha', 'cdn', 'other'] as const\n\nexport type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number]\n\nexport const INTEGRATION_EVIDENCE_KINDS = ['plugin', 'option-key', 'script-domain', 'form-config', 'embed-domain'] as const\n\nexport type IntegrationEvidenceKind = (typeof INTEGRATION_EVIDENCE_KINDS)[number]\n\n/** One outside service the site uses, and why we believe it does. */\nexport interface RawIntegration {\n /** Catalog id: `mailchimp`, `hubspot`, `google-tag-manager`, `jetpack-stats`, `embed-youtube`, … */\n service: string\n /** Display name. */\n name: string\n category: IntegrationCategory\n /**\n * Never a value. `detail` names a plugin and its version, a setting by name\n * (`mc4wp (api_key)`), a script host and where it was seen\n * (`static.hotjar.com @ render:home`), a form's wiring, or an embed host.\n */\n evidence: { kind: IntegrationEvidenceKind, detail: string }[]\n /** An account must be connected again on the new site; `false` for an embed, which has no account. */\n reconnect_required: boolean\n /** A credential is configured on WordPress. Only this boolean is exported, never the value. */\n secret_present: boolean\n /** What to do, in words for the person receiving the site. */\n notes: string\n}\n\n/**\n * The document a bridge writes (`bridge/integrations.json`). `RawIR.integrations`\n * carries its `services`. An empty `services` with `scanned` beside it is an\n * answer — the site uses no outside service we recognise — not a missing scan.\n */\nexport interface RawIntegrationScan {\n /** Producer's format identifier, e.g. `contentrain-bridge-integrations@1`. */\n format: string\n /** How much was looked at: option and post counts, active plugins, script sources, and whether the home page was rendered. */\n scanned: { options: number, plugins: number, posts: number, rendered_home: boolean, script_sources: number }\n services: RawIntegration[]\n totals: { services: number, reconnect_required: number }\n}\n\n/**\n * The intake / handoff issue for services that must be connected again. Raised\n * once, listing every `RawIntegration` with `reconnect_required: true`; not\n * raised when there are none.\n */\nexport interface IntegrationReconnectRequiredIssue {\n code: 'integration_reconnect_required'\n services: Pick<RawIntegration, 'service' | 'name' | 'category' | 'secret_present'>[]\n}\n\n/**\n * Translation grouping for multilingual sites. The REST rungs read it from\n * Polylang's `translations` / WPML's `wpml_translations` post fields, WXR from\n * the `post_translations` taxonomy; the bridge rung from the plugin tables.\n * One pair per group; `translations` includes the post itself.\n */\nexport interface RawLanguagePair {\n post: number\n /** locale → post id of the translation. */\n translations: Record<string, number>\n}\n\n/**\n * The complete raw extraction of one WordPress site at one access rung.\n * This is the boundary document between extraction (Bridge/importers) and\n * everything downstream — content conversion, capability analysis, migration.\n */\nexport interface RawIR {\n version: number\n provenance: RawProvenance\n site: RawSite\n authors: RawAuthor[]\n terms: RawTerm[]\n posts: RawPost[]\n attachments: RawAttachment[]\n menus?: RawMenu[]\n comments?: RawComment[]\n redirects?: RawRedirect[]\n /** Rules the sources hold that the site does not serve as plain redirects. */\n redirects_excluded?: RawRedirectExcluded[]\n seo?: RawSeo\n routing?: RawRouting\n /** Interface text outside the content tables, each piece with one outcome. */\n hardcoded_text?: RawHardcodedText\n /** Outside services the site is connected to (`RawIntegrationScan.services`). */\n integrations?: RawIntegration[]\n language_pairs?: RawLanguagePair[]\n /** Site options (bridge rung), verbatim. */\n options?: Record<string, unknown>\n}\n\n// ─── CapabilityManifest ───\n//\n// What the site *uses*, as detected — the input for migration planning,\n// effort/pricing, and the \"what happens to X\" conversation with the user.\n// Detection is evidence-based and never certain; `present: true` with evidence\n// beats a silent guess.\n\nexport const CAPABILITY_KEYS = [\n 'seo',\n 'forms',\n 'comments',\n 'search',\n 'newsletter',\n 'analytics',\n 'consent',\n 'share',\n 'video',\n 'ads',\n 'builder',\n 'blocks',\n 'i18n',\n 'cache',\n 'security',\n 'acf',\n 'ecommerce',\n 'jetpack',\n 'redirects',\n 'membership',\n 'accessibility',\n 'media',\n 'scheduling',\n] as const\n\nexport type CapabilityKey = (typeof CAPABILITY_KEYS)[number]\n\nexport interface CapabilityDetection {\n present: boolean\n /** Identified plugin/provider, when known (e.g. \"yoast\", \"contact-form-7\"). */\n plugin?: string | null\n /** How it was detected: \"rest\", \"dom\", \"slug\", \"headers\", \"meta\", … */\n evidence?: string[]\n detail?: string\n}\n\nexport interface CapabilityManifest {\n version: number\n site_url: string\n access: {\n html_status?: number | null\n rest_status?: number | null\n /**\n * Highest source-access rung actually achieved for this site, or `null`\n * when none was — the site answered nothing a rung describes.\n *\n * Nullable rather than optional, and rather than a `'none'` rung. Optional\n * would let a producer omit the field and call that \"not measured\", which\n * is the failure this field exists to prevent: a manifest that reports\n * `rest_public` for every site makes the access ladder unreadable, and the\n * ladder is what the coverage figures are computed from. Required-and-\n * nullable makes \"I reached nothing\" a thing the producer has to say.\n *\n * A `'none'` rung was the other candidate and is worse: `SourceAccessKind`\n * is also `RawProvenance.kind`, where it answers \"how was this data\n * obtained\". A RawIR stamped `kind: 'none'` is a document that exists\n * without having been obtained — an impossible state made representable in\n * an unrelated contract. `SOURCE_ACCESS_LADDER` would stop being a ladder\n * too: it is an ordered array and coverage arithmetic indexes into it.\n *\n * `null` reads with `rest_status: null` beside it as its evidence. Coverage\n * arithmetic should drop these sites from the numerator, not score them\n * zero — a site behind an access wall was not measured, it did not fail.\n */\n achieved: SourceAccessKind | null\n }\n generator?: string | null\n theme?: string | null\n plugins?: string[]\n rest_namespaces?: string[]\n custom_post_types?: Array<{\n slug: string\n /** Whether the CPT is exposed in public REST (`rest: false` CPTs need a higher rung). */\n rest_visible: boolean\n count?: number | null\n }>\n /** Comments get first-class treatment: they are a launch-critical capability. */\n comments: {\n active: boolean\n form_status?: 'open' | 'closed' | 'mixed' | null\n rest_total?: number | null\n plugin?: string | null\n }\n capabilities: Partial<Record<CapabilityKey, CapabilityDetection>>\n /** Dynamic front-end behaviors observed in the DOM (\"carousel\", \"modal\", \"search-overlay\", …). */\n behaviors?: string[]\n languages?: string[]\n}\n\n// ─── ProjectIR ───\n//\n// The reproducible model of the site: not \"this page's HTML\" but the design\n// system, component architecture, route model, and content queries that let\n// *unseen* pages — tomorrow's posts, another category — be generated correctly.\n// Produced by analysis; consumed by the open emitter. Three layers are kept\n// together on purpose: visual structure (families/chrome/tokens), content\n// binding (slots), and business rules (routes/queries). A page family exists\n// per genuinely different behavior, not per page.\n\n/**\n * How the legacy CSS for a family was packaged.\n * - `purge_set`: rules kept if used by any page in the family's page set —\n * small file, safe against per-page rule loss (single-page purging is not).\n * - `localcss`: the site's stylesheets localized wholesale — larger, zero risk.\n */\nexport type CssStrategy = 'purge_set' | 'localcss'\n\n/**\n * Cascade layer name the emitter quarantines legacy CSS under, so a modern\n * utility layer (e.g. Tailwind) can coexist with pixel-faithful migrated pages.\n */\nexport const LEGACY_CSS_LAYER = 'legacy'\n\n/** Design tokens extracted from the source site, e.g. for a Tailwind `@theme` block. */\nexport interface DesignTokens {\n colors?: Record<string, string>\n font_families?: Record<string, string>\n font_sizes?: Record<string, string>\n spacing?: Record<string, string>\n breakpoints?: Record<string, string>\n}\n\nexport type RouteKind =\n | 'front'\n | 'single'\n | 'page'\n | 'archive'\n | 'term'\n | 'author'\n | 'date'\n | 'search'\n | 'not_found'\n | 'custom'\n\nexport interface RouteParamDef {\n name: string\n /**\n * Where the value comes from. The date parts exist because\n * `/%year%/%monthnum%/%day%/%postname%/` is a common WordPress permalink\n * structure: treating the template post's date as a fixed prefix sends every\n * other post to a wrong address, and SEO continuity is the point of a\n * migration. Posts carry their own values (`EmitPost.params`).\n */\n source:\n | 'post_slug'\n | 'post_id'\n | 'post_year'\n | 'post_month'\n | 'post_day'\n | 'term_slug'\n | 'author_slug'\n | 'page_number'\n | 'custom'\n}\n\n/** Selects a family/component variant when a route parameter matches. */\nexport interface RouteVariantRule {\n param: string\n in: string[]\n variant: string\n}\n\n/**\n * One route pattern → one layout family. Pagination is a parameter of a route\n * (`/news/page/2` is `/news` with `page_number = 2`), never a separate family —\n * across a 30-site corpus, page 2 never produced a new layout.\n */\nexport interface RouteModel {\n id: string\n /**\n * URL pattern with `:param` placeholders — `/category/:term`, `/news/page/:page`.\n *\n * A trailing `*` marks a **multi-segment (rest) parameter**: `/category/:term*`\n * matches `/category/about-cc/events/` with `term = \"about-cc/events\"`.\n * Hierarchical taxonomies and nested pages need this — a single-segment\n * parameter silently flattens `/category/about-cc/events/` to\n * `/category/events/`, breaking link continuity for every nested address.\n */\n pattern: string\n kind: RouteKind\n /** LayoutFamily id. */\n family: string\n params?: RouteParamDef[]\n /**\n * Content collection this route generates one page per entry from. `single`\n * routes default to `posts`; naming a collection makes any route\n * collection-driven — a site's pages and custom post types are per-entry\n * routes too, they just are not called \"single\".\n *\n * Without a name per route they would all write to (and overwrite) the same\n * collection.\n */\n collection?: string\n /** QueryBinding id feeding this route's list, when it renders one. */\n query?: string\n /**\n * Document title for routes whose pages have no per-page title of their own\n * (a static page, an archive without `QueryPage.title`).\n */\n title?: string\n /**\n * Locale this route serves. A multilingual site has a route per language\n * (`/:slug`, `/en/:slug`) with its own family and query; the emitted page's\n * `lang` comes from here rather than from the project default.\n */\n locale?: string\n variant_rules?: RouteVariantRule[]\n}\n\nexport type SlotKind =\n | 'body'\n | 'title'\n | 'date'\n | 'excerpt'\n | 'author_name'\n | 'author_link'\n | 'featured_image'\n | 'term_list'\n | 'self_link'\n | 'custom'\n\n/** Where a content field lands in a family's markup. */\nexport interface SlotBinding {\n kind: SlotKind\n /** The region is printed only on posts that have this slot filled — see `CHROME_IF_OPEN`. */\n optional?: boolean\n /** The region repeats per list item (terms, authors) — see `CHROME_REPEAT_OPEN`. */\n repeat?: boolean\n /** CSS selector of the slot's container, when one could be determined. */\n selector?: string\n /** Content-model field backing a `custom` slot. */\n field?: string\n /** Date rendering format for `date` slots, as observed on the source site. */\n date_format?: string\n}\n\n/**\n * Marks whose name ends in this suffix are inserted as raw HTML instead of\n * being escaped. Escaping stays the default — content-derived text must never\n * be able to break the page — but some themes render a post's full content or\n * a link-bearing excerpt inside a list card, and escaping those prints markup\n * as text (measured on one theme's category page: 48.6).\n */\nexport const RAW_MARK_SUFFIX = '_html'\n\n/**\n * Repeat block: `<!--@@repeat:NAME@@-->…<!--@@/repeat@@-->`, optionally with a\n * separator — `<!--@@repeat:NAME|, @@-->`. The inner fragment renders once per\n * item of the list named NAME, with per-item marks `item` / `item_index` (and\n * `item_<key>` for object items).\n *\n * Fixed marks (`term0…termN`) cannot express a list whose length varies per\n * post: a template built from a 3-term post leaves stray separators on a\n * 6-term one (measured symptoms: `\"Business,\"`, `\"Releases, Events,\"`,\n * `\"Automattic, ,\"`), and the same class shows up with multiple authors.\n */\nexport const CHROME_REPEAT_OPEN = '<!--@@repeat:'\nexport const CHROME_REPEAT_CLOSE = '<!--@@/repeat@@-->'\n\n/**\n * Conditional block: `<!--@@if:NAME@@-->…<!--@@/if@@-->`, negated with\n * `<!--@@if:!NAME@@-->`. The fragment survives only when NAME has a value\n * (non-empty string, non-empty list).\n *\n * Route-parameter variants (`RouteVariantRule`) cannot express this: two posts\n * on the SAME route differ by whether the theme printed a region at all\n * (measured: five posts of one family scored 3.5–84.5, the only difference\n * being a featured block that some posts render and others do not).\n */\nexport const CHROME_IF_OPEN = '<!--@@if:'\nexport const CHROME_IF_CLOSE = '<!--@@/if@@-->'\n\n/**\n * Marker a list section's wrapper carries where its items go —\n * `<div class=\"grid\"><!--@@items@@--></div>`.\n */\nexport const LIST_ITEMS_SLOT = '<!--@@items@@-->'\n\n/**\n * Marker a `body`-position chrome chunk carries where the page content goes.\n * A comment survives serialization, renders as nothing if ever left behind,\n * and — critically — can sit at ANY nesting depth: real themes put the content\n * container deep inside the chrome (`article > div.entry-content`), so the\n * chrome cannot be split into balanced before/after fragments. The emitter\n * splices content in at this marker and injects the result as ONE fragment.\n */\nexport const CHROME_BODY_SLOT = '<!--@@body@@-->'\n\n/**\n * Mount point of a component inside chrome: `<!--@@component:ID@@-->`, where\n * ID is a `ComponentDef.id`. The producer replaces the source region (the\n * theme's comment list and form, a contact form) with this marker; the emitter\n * renders the real component there and imports it into the layout, so a\n * runtime component is MOUNTED, not merely emitted as an unused file.\n *\n * Same shape as `CHROME_BODY_SLOT` for the same reasons: a comment survives\n * serialization, renders as nothing if left behind, and can sit at any depth.\n */\nexport const CHROME_COMPONENT_OPEN = '<!--@@component:'\nexport const CHROME_COMPONENT_CLOSE = '@@-->'\n\n/** The marker for one component id — `componentSlot('c-comments')`. */\nexport const componentSlot = (id: string): string => `${CHROME_COMPONENT_OPEN}${id}${CHROME_COMPONENT_CLOSE}`\n\n/**\n * A rendered, asset-rewritten chunk of site chrome the emitter injects verbatim.\n * Positions: `head` lands in `<head>`; `body` is the whole body chrome carrying\n * `CHROME_BODY_SLOT` where content goes (preferred — nesting-safe); the legacy\n * `before_body`/`after_body` pair is composed into a single body with the slot\n * between them (only correct when the content container is top-level).\n *\n * `header` and `footer` lift a region OUT of the body chrome into its own Astro\n * component, rendered as a sibling of the body fragment. They are for the two\n * regions a site actually shares — the masthead and the colophon — and they buy\n * two things one blob cannot: families that carry the same header emit ONE\n * component instead of N copies of the markup, and the nav lives at a single\n * address, which is where a jQuery-free menu replaces the theme's.\n *\n * The producer emits them ONLY after verifying both properties:\n *\n * 1. **Balanced.** The fragment closes every element it opens. Splitting chrome\n * at an arbitrary point produces halves the parser silently repairs — the\n * failure that cost a page 36 against 100 and the reason `body` exists.\n * 2. **Outside the content path.** The region sits wholly before (header) or\n * after (footer) `CHROME_BODY_SLOT` and is not an ancestor of it. A region\n * that wraps the content cannot be lifted; it belongs in `body`.\n *\n * When either is in doubt, keep everything in one `body` chunk: a single blob\n * is always correct, and this split is an optimisation on top of it.\n */\nexport interface ChromeChunk {\n id: string\n position: 'head' | 'body' | 'before_body' | 'after_body' | 'header' | 'footer'\n html: string\n /**\n * Shared component name for a `header`/`footer` chunk — `SiteHeader`,\n * `BlogFooter`. Chunks that carry the same name AND the same html collapse\n * into one emitted component; same name with different html gets a suffixed\n * name and a warning, never a silent swap. Default: `SiteHeader`/`SiteFooter`.\n */\n component?: string\n}\n\nexport interface ComponentPlacement {\n /** ComponentDef id. */\n component: string\n variant?: string\n selector?: string\n /**\n * `QueryBinding.id` whose results fill this region, instead of the cloned\n * markup that was there.\n *\n * The case this exists for: a theme's \"recent posts\" block sits in the\n * chrome of every article. Cloned, it freezes on the day of the migration —\n * it keeps listing the same posts forever, and nobody notices because it\n * still looks right. Bound to a query it stays current and becomes editable,\n * which is the whole difference between a copy of a site and a site.\n *\n * On the placement rather than on `ComponentDef`, because it is a per-mount\n * fact: one `related` component can be mounted by a post family filtered to\n * the post's category and by an author family filtered to the author. The\n * definition says what the region *is*; the placement says what it shows\n * here — the same split `variant` (card shape) and `selector` (region)\n * already follow.\n */\n query?: string\n}\n\nexport interface FamilyVariant {\n key: string\n description?: string\n}\n\n/**\n * Attributes on the document's root elements, carried verbatim from the source\n * page. Themes hang layout on them — WordPress writes `<body class=\"wp-singular\n * post-template-default single …\">` and scripts add `<html class=\"js wf-…\">`,\n * and the stylesheet's container rules key off exactly those classes. Dropping\n * them costs a correct-content page its entire layout (measured: 36.4 vs 100).\n *\n * Values may carry `@@mark@@` placeholders, so per-page classes (`postid-123`)\n * survive the same filling as chrome.\n *\n * Whether a site needs them is not knowable from one page: the same test that\n * moved 10up 36.4 → 100 moved wptavern 36 → 36, because that theme does not key\n * off body classes. Carry them always.\n */\nexport interface RootAttrs {\n html?: Record<string, string>\n body?: Record<string, string>\n}\n\nexport interface LayoutFamily {\n id: string\n name?: string\n kind?: RouteKind\n chrome?: ChromeChunk[]\n /** `<html>` / `<body>` attributes from the source page — see `RootAttrs`. */\n root_attrs?: RootAttrs\n slots?: SlotBinding[]\n components?: ComponentPlacement[]\n css: {\n strategy: CssStrategy\n /**\n * Stylesheets every page of the family loads.\n *\n * Page-builder sites emit CSS per page (`post-11368.css`,\n * `local/global-11368-frontend-*`), so one member's stylesheet set is not\n * the family's: building from a single template page scored 35.6, while\n * the UNION of the members' stylesheets scored 100. Producers put the\n * union here and the per-page remainder on the page itself.\n */\n files?: string[]\n }\n /** Observed column counts per viewport class (e.g. desktop 4 → mobile 1). */\n columns?: Partial<Record<'desktop' | 'mobile', number>>\n variants?: FamilyVariant[]\n /** Verification trail: which pages formed the family, held-out fidelity score. */\n evidence?: {\n pages?: string[]\n holdout_score?: number\n }\n}\n\n/**\n * Component semantic vocabulary. `source` records where its content can come\n * from: `rest` (derivable from content data — author box, term list),\n * `runtime` (needs a live service — comments form, search), `chrome` (carried\n * as rendered markup — nav, ads).\n */\nexport const COMPONENT_TYPES = [\n 'nav',\n 'related',\n 'comments',\n 'form',\n 'ads',\n 'chrome',\n 'taxonomy',\n 'author',\n 'share',\n 'breadcrumb',\n 'meta',\n 'pagination',\n 'card',\n 'custom',\n] as const\n\nexport type ComponentType = (typeof COMPONENT_TYPES)[number]\n\nexport type ComponentSource = 'rest' | 'runtime' | 'chrome'\n\nexport interface ComponentVariantDef {\n key: string\n conditions?: RouteVariantRule[]\n description?: string\n}\n\nexport interface ComponentDef {\n id: string\n type: ComponentType\n source: ComponentSource\n name?: string\n selector?: string\n /**\n * Content model a runtime component talks to — the collection a `form`\n * submits to. A `comments` component needs none: its thread is addressed by\n * the entry of the page it is mounted on.\n */\n model?: string\n /** e.g. a card component with `news`, `compact`, `featured` variants. */\n variants?: ComponentVariantDef[]\n}\n\nexport type ExcerptSource = 'excerpt' | 'content_first_paragraph' | 'none'\nexport type PaginationKind = 'numbered' | 'infinite' | 'none'\n\n/**\n * The query behind a rendered list: which content, filtered how, in what\n * order, how many. Inferred by matching rendered items against candidate\n * queries — order matters, so the sequence is part of the contract.\n */\nexport interface QueryBinding {\n id: string\n /** Content source: \"posts\", \"pages\", a CPT slug, or a content-model id. */\n source: string\n taxonomy?: {\n taxonomy: string\n /** Fixed term slug, or the route param carrying it. */\n term?: string\n term_param?: string\n }\n author?: {\n slug?: string\n param?: string\n }\n order: {\n by: 'date' | 'title' | 'menu_order' | 'custom'\n direction: 'asc' | 'desc'\n }\n per_page: number | null\n pagination: PaginationKind\n excerpt_source?: ExcerptSource\n /** WordPress image size name the list renders (e.g. \"medium_large\"). */\n image_size?: string\n /** Locale this query is filtered to, on a multilingual site. */\n locale?: string\n}\n\n/**\n * - `responsive`: one build, CSS handles viewports.\n * - `split`: desktop and mobile variants are produced separately and served\n * by device — for sites whose mobile DOM differs beyond CSS.\n */\nexport type ViewportStrategy = 'responsive' | 'split'\n\nexport interface ProjectIR {\n version: number\n site: {\n url: string\n title?: string\n locales?: string[]\n }\n routes: RouteModel[]\n families: LayoutFamily[]\n components?: ComponentDef[]\n queries?: QueryBinding[]\n tokens?: DesignTokens\n /** Default CSS packaging for families that don't override it. */\n css_default: CssStrategy\n viewport_strategy?: ViewportStrategy\n /** Content-model ids this project's queries and slots refer to. */\n content_models?: string[]\n}\n\n// ─── Comments export ───\n//\n// Comments cross one more boundary than the rest of RawIR: they leave the\n// generated static site entirely and land in a live service's database,\n// addressed by content entry — not by WordPress post id. This export is that\n// bridge: the source map translates WP ids to entry addresses, and the\n// comments ride along unmodified.\n\nexport const COMMENTS_EXPORT_FORMAT = 'contentrain-comments@1'\n\n/** Where one WordPress post's content ended up. */\nexport interface EntrySourceRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\n/**\n * WordPress post id (stringified) → content entry address. Only the tool that\n * wrote the content store can produce this; nothing downstream can recover it.\n */\nexport type EntrySourceMap = Record<string, EntrySourceRef>\n\nexport interface CommentsExport {\n version: number\n format: typeof COMMENTS_EXPORT_FORMAT\n source: RawProvenance\n site_url?: string\n /** ISO 8601 UTC. */\n generated_at: string\n entries: EntrySourceMap\n /** WP post ids whose comment form was closed — the receiving side opens those threads closed. */\n threads_closed?: number[]\n /** Verbatim `RawComment`s; parents are re-linked via `RawComment.parent` in a second pass. */\n comments: RawComment[]\n}\n\n/** Comments summary + payload pointer on the handoff (capability counts are not enough for intake). */\nexport interface HandoffComments {\n total: number\n by_status?: Record<string, number>\n types?: Record<string, number>\n export?: {\n format: typeof COMMENTS_EXPORT_FORMAT\n /** Where the full export can be fetched… */\n url?: string\n /** …or the export itself, inline, for small sites. */\n inline?: CommentsExport\n }\n threads_closed?: number[]\n unresolved?: Array<{ comment_id: number; post: number; reason: string }>\n}\n\n// ─── Runtime binding ───\n//\n// A runtime component (comments, forms) talks to a live service. The static\n// site only needs two facts to do that: where the public API lives and which\n// project it belongs to. Everything else — the model, the entry, the locale —\n// is already on the page that mounts the component.\n\n/**\n * Public API root of the runtime provider plus the project id. `base_url` is\n * the origin (`https://studio.contentrain.io`); components append their own\n * `/api/{forms,comments}/v1/{project_id}/…` paths. No credential travels with\n * it: the public endpoints are unauthenticated by design, and a browser page\n * cannot keep a secret.\n */\nexport interface RuntimeBinding {\n base_url: string\n project_id: string\n}\n\n// ─── MigrationHandoff ───\n//\n// What the migration hands the user: where the generated project lives, what\n// happened to each detected capability, and — for capabilities that need a\n// runtime — the offers. Offering is this document's job; fulfilling an offer\n// is the receiving product's. The migration itself is complete without any\n// offer being accepted.\n\nexport type CapabilityDisposition =\n | 'migrated_static'\n | 'archived'\n | 'needs_runtime'\n | 'external_adapter'\n | 'kept_on_wordpress'\n | 'dropped'\n\nexport interface HandoffCapability {\n key: CapabilityKey\n disposition: CapabilityDisposition\n detail?: string\n /** e.g. comments: { migrated: 412, spam_skipped: 60 } */\n counts?: Record<string, number>\n}\n\nexport type OfferProvider = 'studio_managed' | 'adapter' | 'keep_wordpress'\n\nexport interface CostEstimate {\n currency: string\n monthly: number\n assumptions?: string[]\n}\n\n/**\n * The comparison shown when a runtime capability is offered: what running the\n * old server costs (maintenance, security, hosting) against the managed\n * option — so declining is an informed choice, not a default.\n */\nexport interface CostComparison {\n self_host?: CostEstimate\n managed?: CostEstimate\n}\n\nexport interface HandoffOffer {\n capability: CapabilityKey\n provider: OfferProvider\n /** Adapter identifier when `provider` is `adapter`. */\n adapter?: string\n cost_comparison?: CostComparison\n /** e.g. \"keeping comments on WordPress means the WordPress server stays live\". */\n warning?: string\n}\n\n/**\n * What a standalone page's body is, as far as the migration can carry it.\n *\n * - `html` — markup written in the editor. It travels as content and stays\n * editable.\n * - `builder_html` — markup a page builder rendered (the builder's own data\n * is not HTML). It travels as it was rendered: the page looks right, but its\n * layout is no longer edited in the builder that made it.\n * - `none` — no body; the theme renders the page from other data. Nothing to\n * carry as content.\n *\n * Counted because they promise different things to the person receiving the\n * site, and a single \"pages migrated\" number hides which promise was made.\n */\nexport const PAGE_BODY_KINDS = ['html', 'builder_html', 'none'] as const\nexport type PageBodyKind = (typeof PAGE_BODY_KINDS)[number]\n\nexport interface MigrationHandoff {\n version: number\n site_url: string\n /** ISO 8601 UTC. */\n generated_at: string\n repository?: {\n provider: 'github' | 'gitlab'\n owner: string\n name: string\n default_branch: string\n }\n preview_url?: string\n content_summary?: {\n models: number\n entries: number\n locales?: string[]\n /**\n * Standalone pages by body kind — see `PageBodyKind`. Every kind is present\n * when the field is, so a zero is a count, not an omission.\n */\n body_kinds?: Record<PageBodyKind, number>\n }\n capabilities: HandoffCapability[]\n /** Present whenever the source had comments — see `HandoffComments`. */\n comments?: HandoffComments\n offers?: HandoffOffer[]\n /** Where the generated site's runtime components were bound, when an offer was fulfilled. */\n runtime?: RuntimeBinding\n notes?: string[]\n}\n","// ─── Execution, Approval and Source-Delta Contracts ───\n//\n// Shared, MIT-licensed shapes for operations that *do* something: an agent run,\n// a bulk edit, a deploy, a cutover. `migration.ts` describes a site; this file\n// describes an act upon one.\n//\n// Three repositories meet here and none of them may define these shapes for\n// itself. The migration engine (private) produces plans and receipts. Studio\n// (AGPL) renders the plan card, collects approvals and stores the run. The MCP\n// server (MIT) is where a plan's steps actually execute. If each wrote its own\n// `RiskClass`, a \"destructive\" operation would mean three different things and\n// the approval that guards it would be theatre.\n//\n// Everything here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository and license\n// boundaries, and because a plan must survive being written to disk, approved\n// hours later, and executed by a different process than the one that built it.\n\nimport { canonicalStringify } from './canonical.js'\n\n/**\n * Version stamped into every execution document (`version` field on each root).\n * A reader that sees a higher version than it knows should refuse rather than\n * guess — a misread plan is a plan executed with the wrong scope.\n */\nexport const EXECUTION_CONTRACT_VERSION = 1\n\n// ─── Actors ───\n\n/**\n * Who is acting. `kind` is load-bearing for approval: an agent may never\n * approve its own work, so the evaluator has to be able to tell an agent\n * from a person without consulting Studio's user table.\n */\nexport interface ActorRef {\n kind: 'human' | 'agent' | 'system'\n /** Stable identifier in the issuing system (Studio user id, agent name). */\n id: string\n /** Display name at the time of acting; advisory, never matched against. */\n name?: string\n /** Project role held at the time of acting (e.g. \"owner\", \"admin\", \"editor\"). */\n role?: string\n}\n\n// ─── Risk ───\n\n/**\n * What is at stake in an operation, in ascending severity.\n *\n * The order is the contract, not a convenience: `RISK_CLASSES` is a ladder and\n * a policy written for `bulk_content` is expected to also cover everything\n * above it. The seven rungs come from the approval model — read-only work is\n * automatic, content edits follow project policy, and anything that leaves the\n * repository (money, third parties, production) is a separate decision from\n * whether the content is correct.\n */\nexport const RISK_CLASSES = [\n /** Reads only; no file, no side effect. Automatic by default. */\n 'read_only',\n /** A bounded content edit — one entry, one locale. Project policy decides. */\n 'low_risk_content',\n /** Many entries at once: bulk status changes, mass field edits, imports. */\n 'bulk_content',\n /** Deletes content or changes a model's shape. Both plan and change approval. */\n 'destructive_schema',\n /** Reaches a third party: email, CRM, webhook, provider API. */\n 'external_effect',\n /** Spends money in a way worth a budget decision. */\n 'financially_material',\n /** Publishes, deploys, cuts over, or rewrites redirects. Release approval. */\n 'deployment',\n] as const\n\nexport type RiskClass = (typeof RISK_CLASSES)[number]\n\n/** Position on the ladder; higher is more severe. */\nexport function riskRank(risk: RiskClass): number {\n return RISK_CLASSES.indexOf(risk)\n}\n\n/**\n * The most severe of the given classes — the class a multi-step plan is\n * evaluated against. A plan is exactly as safe as its worst step, so a\n * read-only survey that ends in a deploy is a deploy.\n */\nexport function highestRisk(risks: readonly RiskClass[]): RiskClass {\n let worst: RiskClass = 'read_only'\n for (const risk of risks) {\n if (riskRank(risk) > riskRank(worst)) worst = risk\n }\n return worst\n}\n\n// ─── Approval ───\n\n/**\n * The three moments at which a decision is asked for. They are distinct\n * questions and collapsing them is the mistake this type exists to prevent:\n * approving *what will be done* is not approving *what was produced*, and\n * neither is permission to put it in front of the public.\n */\nexport const APPROVAL_GATES = [\n /** Before the work starts: scope, cost, risk, tools, affected resources. */\n 'plan',\n /** After the branch exists: semantic diff, schema change, validation output. */\n 'change',\n /** Before production effect: publish, deploy, cutover, redirects, bulk delete. */\n 'release',\n] as const\n\nexport type ApprovalGate = (typeof APPROVAL_GATES)[number]\n\n/**\n * How many people, and which, must say yes. Deliberately three modes: a\n * generic workflow canvas was considered and rejected — policy-based approval\n * covers every case the product actually has.\n */\nexport const APPROVAL_MODES = [\n /** No human decision needed. */\n 'auto',\n /** One authorised reviewer is enough. */\n 'single',\n /** A count, or a role combination, must be met. */\n 'quorum',\n] as const\n\nexport type ApprovalMode = (typeof APPROVAL_MODES)[number]\n\n/**\n * One rule in `.contentrain/approval-policies.json`: for operations at or\n * above `risk`, this gate is required in this mode.\n *\n * Rules are additive. Every matching rule contributes a requirement; nothing a\n * rule says can remove a requirement another rule created. A policy file can\n * therefore only ever make a project stricter, which is what makes it safe to\n * merge one from a template.\n */\nexport interface ApprovalRule {\n /** Applies to this class and — because the classes are a ladder — everything above it. */\n risk: RiskClass\n gate: ApprovalGate\n mode: ApprovalMode\n /** Quorum only: how many distinct approvers are needed. Ignored otherwise. */\n min_approvals?: number\n /** Roles that may satisfy this rule. Empty/absent = any authorised reviewer. */\n roles?: string[]\n /** Narrow the rule to certain models. Absent = every model. */\n models?: string[]\n /** Narrow the rule to certain locales. Absent = every locale. */\n locales?: string[]\n}\n\n/**\n * `.contentrain/approval-policies.json`. Lives in git, beside the content it\n * governs, so that the policy in force is the policy on the branch — a policy\n * stored only in a database can be changed after a plan is built and before it\n * is approved.\n */\nexport interface ApprovalPolicyFile {\n version: number\n /**\n * Applied at the `change` gate when no rule matches at all. Absent means no\n * rule matched and therefore nothing is required — the policy's silence is\n * taken literally rather than filled in with each consumer's idea of a\n * sensible default.\n */\n default_mode?: ApprovalMode\n rules: ApprovalRule[]\n /**\n * Whether an actor may approve a plan they themselves created. Defaults to\n * false; an agent can never self-approve regardless of this setting.\n */\n allow_self_approval?: boolean\n}\n\n/**\n * An outstanding or satisfied demand for a decision, derived from a rule.\n * `because` carries the rule's risk class so a UI can say *why* a gate\n * appeared without re-running the policy.\n */\nexport interface ApprovalRequirement {\n gate: ApprovalGate\n mode: ApprovalMode\n /** Distinct approvers still needed; 0 once satisfied. */\n min_approvals: number\n roles?: string[]\n /** The risk class of the rule that produced this requirement. */\n because: RiskClass\n}\n\n/**\n * A decision that was actually given.\n *\n * A grant is bound to an exact `plan_hash`: if the plan changes, its grants no\n * longer apply and the gate reopens. That binding is the whole point — an\n * approval of \"publish these 12 posts\" must not carry over to a plan that\n * publishes 400.\n */\nexport interface ApprovalGrant {\n gate: ApprovalGate\n /** The plan this decision was given for. */\n plan_hash: string\n /** The branch tip reviewed, for `change` grants on a produced diff. */\n commit_sha?: string\n approver: ActorRef\n /** ISO 8601 UTC. */\n approved_at: string\n /** ISO 8601 UTC; a grant past this instant does not count. */\n expires_at?: string\n note?: string\n}\n\n// ─── Execution scope ───\n\n/**\n * What an operation touches. Used twice with the same shape: on a plan it is\n * the prediction, on a receipt it is what actually happened. Keeping one shape\n * for both is deliberate — the diff between them is the most useful thing a\n * receipt can show, and it is only computable if the two are comparable.\n *\n * Every field is optional and an absent field means \"none\", not \"unknown\": a\n * plan that cannot enumerate what it will touch is not a plan that should be\n * approved.\n */\nexport interface ExecutionScope {\n models?: string[]\n locales?: string[]\n /** Entry ids, or `model:id` where the model is not implied by `models`. */\n entries?: string[]\n /** Routes/URLs affected — cutovers and redirect rewrites report these. */\n routes?: string[]\n /** Repository-relative file paths. */\n files?: string[]\n /** Asset ids or paths. */\n assets?: string[]\n /** Provider identifiers reached (deployment, email, CRM). */\n providers?: string[]\n /** Hosts contacted outside the project — the evidence behind `external_effect`. */\n external_domains?: string[]\n}\n\n/** One tool invocation inside a plan. */\nexport interface ExecutionStep {\n id: string\n /** Tool name as the runner knows it (e.g. \"contentrain_bulk\"). */\n tool: string\n /** One line, written for the person approving, not for the log. */\n summary: string\n risk: RiskClass\n /** What this step alone touches; the plan's scope is the union. */\n scope?: ExecutionScope\n /** Opaque tool arguments. Hashed with the rest of the plan. */\n input?: Record<string, unknown>\n}\n\n/**\n * Predicted cost. Currency is ISO 4217; absent means the cost is not monetary.\n *\n * The non-monetary fields exist because cost is metered before it is priced:\n * a run's bill is assembled from tokens, compute time, bytes stored and bytes\n * transferred, and those are what a runner can actually measure at the moment\n * it finishes a step. `cost` is the conversion, and it is often computed later\n * from these.\n */\nexport interface ExecutionEstimate {\n currency?: string\n /** Money, in major units. */\n cost?: number\n /** Model tokens, when the step runs an agent. */\n tokens?: number\n duration_ms?: number\n /**\n * Items the operation expects to write — entries, files, assets. A count,\n * not a size: use `bytes_stored` for how much was written.\n */\n items?: number\n /**\n * Bytes the operation leaves at rest — a built artefact, an uploaded asset.\n * Object storage bills for this per unit time, so it is the field a storage\n * line on an invoice traces back to.\n */\n bytes_stored?: number\n /**\n * Bytes the operation moves across the network — egress, and fetches made on\n * the way. Billed separately from storage and by a different rate, which is\n * why it is a separate field rather than one `bytes`: a run that stores 40 MB\n * once and serves it a thousand times has one storage figure and a very\n * different transfer figure.\n */\n bytes_out?: number\n}\n\n/** Cost as measured. Same shape as the estimate so the two can be subtracted. */\nexport type ExecutionCost = ExecutionEstimate\n\n/**\n * How to undo the operation. A command, not a promise: if the runner cannot\n * state the undo, `available` is false and the approver learns that *before*\n * deciding rather than after failing.\n */\nexport interface RollbackPlan {\n available: boolean\n /** Shell command or tool call that reverses the effect. */\n command?: string\n /** Commit to reset the content branch to. */\n revert_to?: string\n /** Why it cannot be undone, when `available` is false. */\n reason?: string\n}\n\n/** The repository state a plan was built against. */\nexport interface ProjectRef {\n branch?: string\n commit_sha?: string\n /** Content branch tip, when it differs from the base branch. */\n content_commit_sha?: string\n}\n\n/**\n * An operation, fully described, before it runs.\n *\n * `plan_hash` is the identity of *what will happen*. It covers the steps,\n * scope, risk, estimate and rollback, and excludes who built the plan and\n * when — regenerating the same operation must produce the same hash, or\n * idempotency and approval binding both break. Compute it with\n * {@link computePlanHash}; never hand-roll it, because a hash computed two\n * different ways is a gate that silently opens.\n */\nexport interface ExecutionPlan {\n version: number\n id: string\n /** SHA-256 of the plan's semantic payload — see {@link computePlanHash}. */\n plan_hash: string\n /** What this achieves, in the requester's words. */\n intent: string\n /** The plan's class: the highest risk among its steps. */\n risk: RiskClass\n steps: ExecutionStep[]\n /** Union of the steps' scopes, stated once so an approver reads one list. */\n scope: ExecutionScope\n estimate?: ExecutionEstimate\n rollback?: RollbackPlan\n /** Repository state the plan assumes; re-checked at execution time. */\n project?: ProjectRef\n /** ISO 8601 UTC. Excluded from the hash. */\n created_at?: string\n /** Excluded from the hash, but checked for self-approval. */\n created_by?: ActorRef\n /** ISO 8601 UTC; a plan past this instant must be rebuilt, not run. */\n expires_at?: string\n /**\n * Caller-supplied key that makes a re-submission a no-op rather than a\n * second run. Excluded from the hash: two runs of the same work differ by\n * this key alone and must still share a plan hash.\n */\n idempotency_key?: string\n}\n\n// ─── Receipt ───\n\n/**\n * Run lifecycle. The main line is `draft → planned → awaiting_approval →\n * approved → scheduled → queued → running → verifying → completed`; the rest\n * are the states a run can sit in when something interrupts it.\n */\nexport const RUN_STATUSES = [\n 'draft',\n 'planned',\n 'awaiting_approval',\n 'approved',\n 'scheduled',\n 'queued',\n 'running',\n 'verifying',\n 'completed',\n 'paused',\n 'needs_attention',\n 'retrying',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const\n\nexport type RunStatus = (typeof RUN_STATUSES)[number]\n\n/** Statuses from which a run will not proceed on its own. */\nexport const TERMINAL_RUN_STATUSES = [\n 'completed',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const satisfies readonly RunStatus[]\n\nexport function isTerminalRunStatus(status: RunStatus): boolean {\n return (TERMINAL_RUN_STATUSES as readonly RunStatus[]).includes(status)\n}\n\n/**\n * A step boundary the runner survived. Checkpoints are what make a resumed\n * run skip work it already did instead of repeating it.\n */\nexport interface Checkpoint {\n step_id: string\n status: 'completed' | 'skipped' | 'failed'\n /** ISO 8601 UTC. */\n at: string\n /** What this step actually touched. */\n scope?: ExecutionScope\n /** Commit written by this step, when it wrote one. */\n commit_sha?: string\n message?: string\n}\n\n/** One post-run assertion — a build, a link check, a parity score. */\nexport interface VerificationCheck {\n id: string\n /** One line naming what was asserted. */\n name: string\n passed: boolean\n /** Measured value, when the check produced one. */\n value?: number | string\n /** Threshold the value was compared against. */\n expected?: number | string\n detail?: string\n}\n\n/**\n * What happened, after the fact. The receipt is the durable record: it\n * survives the run, is exported for audit, and is the only artefact that can\n * answer \"who allowed this and what did it actually change?\".\n *\n * Release approvers are the entries of `approvals` whose `gate` is `release`\n * — there is deliberately no separate `release_approved_by` field, because two\n * places recording the same fact is two places that can disagree. Read them\n * with {@link approversFor}.\n */\nexport interface ExecutionReceipt {\n version: number\n id: string\n plan_id: string\n /** The plan that ran. Must match the approvals' `plan_hash`. */\n plan_hash: string\n status: RunStatus\n actor: ActorRef\n /** Every decision that permitted this run, plan/change/release alike. */\n approvals?: ApprovalGrant[]\n /** ISO 8601 UTC. */\n started_at: string\n finished_at?: string\n /** What was actually touched — compare against the plan's `scope`. */\n applied: ExecutionScope\n checkpoints?: Checkpoint[]\n verification?: VerificationCheck[]\n /** Measured cost — compare against the plan's `estimate`. */\n cost?: ExecutionCost\n /** The undo, as it stands now that the run has happened. */\n rollback?: RollbackPlan\n /** Repository state the run left behind. */\n project?: ProjectRef\n /** Set when `status` is `failed` or `needs_attention`. */\n error?: { code: string, message: string, step_id?: string }\n}\n\n/** Approvers recorded on a receipt for one gate, in grant order. */\nexport function approversFor(receipt: ExecutionReceipt, gate: ApprovalGate): ActorRef[] {\n return (receipt.approvals ?? []).filter(grant => grant.gate === gate).map(grant => grant.approver)\n}\n\n// ─── Deployment ───\n\n/**\n * Where a build is published. Carries a *reference* to a secret, never a\n * secret: this document is written to git, and a token in git is a token\n * leaked. The reference is resolved at execution time by whoever holds the\n * vault.\n */\nexport interface DeploymentTarget {\n id: string\n /** Provider identity (e.g. \"netlify\", \"vercel\", \"cloudflare-pages\"). */\n provider: string\n /** Provider-side site/project identifier. */\n site_id?: string\n branch?: string\n /** Key naming the credential in the executing side's secret store. */\n secret_ref?: string\n /** Non-secret provider settings. */\n config?: Record<string, string>\n}\n\n// ─── Automation (type reservation) ───\n//\n// Reserved now so that the file `.contentrain/automations.json` has a declared\n// shape before anything writes one. No tool in this repository reads these\n// yet; the shape exists so that the first writer does not invent a fourth\n// vocabulary for schedules.\n\nexport const SCHEDULE_KINDS = [\n 'once',\n 'recurring',\n 'content_event',\n 'git_event',\n 'runtime_event',\n 'external_webhook',\n 'manual',\n] as const\n\nexport type ScheduleKind = (typeof SCHEDULE_KINDS)[number]\n\n/** What to do with an occurrence that was missed while the runner was down. */\nexport type MisfirePolicy = 'skip' | 'run_once' | 'run_all'\n\nexport interface AutomationSchedule {\n kind: ScheduleKind\n /** ISO 8601 UTC, for `once`. */\n at?: string\n /** Cron expression, for `recurring`. */\n cron?: string\n /** IANA zone (e.g. \"Europe/Istanbul\"); cron without one is ambiguous twice a year. */\n timezone?: string\n /** Event name, for the event kinds. */\n event?: string\n misfire?: MisfirePolicy\n}\n\nexport interface AutomationDefinition {\n version: number\n id: string\n name: string\n enabled: boolean\n schedule: AutomationSchedule\n /** What the run should do, in the requester's words. */\n intent: string\n /** Risk the automation declares; the policy is re-evaluated at fire time. */\n risk?: RiskClass\n /** Budget ceiling per occurrence. */\n budget?: ExecutionEstimate\n /** Deduplication key for the occurrence. */\n idempotency_key?: string\n /** Bounded retry; absent means no retry. */\n max_retries?: number\n}\n\n// ─── SourceDeltaPlan ───\n//\n// The WordPress→repo delta layer. This is NOT `contentrain_reconcile`: that\n// tool merges two git branches through their common ancestor and knows\n// nothing about WordPress. A source delta is the other axis — what changed at\n// the origin since the snapshot the repository was built from — and it must be\n// written to the repository *before* reconcile runs on the git side.\n//\n// The trap this shape exists to close: `modified_after` is a filter on changed\n// records, not a deletion feed. A post deleted in WordPress simply stops\n// appearing, and a delta built from \"what changed\" will never notice. Hence\n// explicit tombstones, produced by comparing inventories rather than by\n// reading a feed.\n\n/** The origin state a delta is measured from. */\nexport interface SourceCursor {\n kind: 'rest_modified_after' | 'wxr_export' | 'bridge_inventory'\n /** ISO 8601 UTC, for `rest_modified_after`. */\n modified_after?: string\n /** Export identifier, for `wxr_export`. */\n export_id?: string\n /** Inventory hash, for `bridge_inventory` — the only kind that can prove deletions. */\n inventory_hash?: string\n /** ISO 8601 UTC time the cursor was taken (T0). */\n taken_at: string\n}\n\nexport const SOURCE_DELTA_OPS = [\n /** Present at the origin, absent in the repository. */\n 'created',\n /** Present in both, origin's fingerprint changed. */\n 'updated',\n /** Absent at the origin, present in the repository — a tombstone. */\n 'deleted',\n /** Same record, different slug/permalink. Drives redirect generation. */\n 'moved',\n] as const\n\nexport type SourceDeltaOp = (typeof SOURCE_DELTA_OPS)[number]\n\n/**\n * One record's delta.\n *\n * `conflict` is the case that cannot be resolved mechanically: the same record\n * changed at the origin *and* in the repository since the cursor. Taking\n * either side silently loses an edit, so the plan reports it and a human\n * decides.\n */\nexport interface SourceDeltaEntry {\n op: SourceDeltaOp\n /** Origin identifier (WordPress post/term/attachment id). */\n wp_id: number\n /** Origin object type (e.g. \"post\", \"page\", \"attachment\", \"category\"). */\n wp_type?: string\n /** Where it lives in the store, when it is already mapped. */\n model?: string\n entry_id?: string\n locale?: string\n /** Origin content fingerprint at cursor time and now. */\n fingerprint_before?: string\n fingerprint_after?: string\n /** For `moved`: the slugs, in that order. */\n slug_before?: string\n slug_after?: string\n /**\n * For `moved`: the site-root-relative addresses, in that order. A slug\n * cannot say that a page changed parent, a term base changed or a dated\n * permalink moved; the path can, and redirects are generated from it.\n */\n path_before?: string\n path_after?: string\n /**\n * For `deleted`: `trashed` is still at the origin and can come back;\n * `purged` is gone from it. A draft or private record is `updated`, never\n * `deleted` — unpublishing is the planner's decision.\n */\n deleted_kind?: 'trashed' | 'purged'\n /** True when the repository also changed this record since the cursor. */\n conflict?: boolean\n /**\n * The repository-side write behind `conflict`: the entry's meta when it was\n * last written by something other than the importer (Studio, an agent, a\n * person). `updated_at` is absent when the store never recorded it — an\n * unknown time is still a conflict.\n */\n repo_edit?: { updated_by: string, updated_at?: string, source: string }\n /**\n * Why the planner could not place this record in the store:\n * `not-in-source-map` (a post-type record the source map does not list),\n * `no-model-for-type` (no store model holds this origin type),\n * `entry-not-found` (the model exists, the entry does not).\n */\n unmapped?: 'not-in-source-map' | 'no-model-for-type' | 'entry-not-found'\n /**\n * For `updated` and `moved`: the fields whose stored value differs from the\n * incoming export's, by field name (`body` for a document's body). Computed\n * against the repository as it is now, so a repository-side edit shows here\n * too — which is what `conflict` is for.\n */\n fields_changed?: string[]\n /** For `moved`: the entry id at the new address, when the store derives ids from slugs and the id changes. */\n entry_id_after?: string\n /** Why this entry is here, for the person reading the plan. */\n detail?: string\n}\n\n/**\n * A one-way transfer plan from the WordPress origin to the repository.\n *\n * Not a sync: there is no path back. The plan is produced, written, and only\n * then does `contentrain_reconcile` merge the result on the git side.\n */\nexport interface SourceDeltaPlan {\n version: number\n /** ISO 8601 UTC. */\n generated_at: string\n /** Origin state this delta is measured from. */\n cursor: SourceCursor\n /** Origin state this delta brings the repository up to. */\n next_cursor?: SourceCursor\n entries: SourceDeltaEntry[]\n /**\n * Whether deletions could be determined at all. A `rest_modified_after`\n * cursor cannot see them; a plan that says `false` must not be read as\n * \"nothing was deleted\".\n */\n deletions_detectable: boolean\n /**\n * Origin types whose deletions could not be determined even though the plan\n * as a whole could — a type that left the inventory's scope (its plugin was\n * deactivated) is not a deletion of every record in it.\n */\n deletions_undetectable_types?: string[]\n /** Redirects implied by the `moved` entries. */\n redirects?: { from: string, to: string, status: 301 | 302 | 307 | 308 }[]\n /** Human-facing notes: truncation, skipped types, coverage limits. */\n warnings?: string[]\n}\n\n// ─── Plan hash ───\n\n/**\n * The exact bytes `plan_hash` is computed over. Exposed because a hash is only\n * useful if a disagreement can be investigated: when two sides compute\n * different hashes for the same plan, comparing payloads shows which field\n * differs, and comparing hashes shows nothing.\n *\n * Excluded: `plan_hash` itself (it cannot cover itself), and `id`,\n * `created_at`, `created_by` and `idempotency_key` — who built a plan, when,\n * under which run id, and with which deduplication key do not change what the\n * plan will do. Everything else is covered, so a changed step, a widened\n * scope, a raised estimate or a withdrawn rollback all invalidate every\n * approval the plan had collected.\n */\nexport function planHashPayload(plan: ExecutionPlan): string {\n const { plan_hash: _hash, id: _id, created_at: _at, created_by: _by, idempotency_key: _key, ...semantic } = plan\n return canonicalStringify(semantic)\n}\n\n/**\n * SHA-256 of {@link planHashPayload}, lowercase hex.\n *\n * Async because it uses Web Crypto (`globalThis.crypto.subtle`), which is\n * available in Node 18+, Deno, Bun, workers and browsers alike — this package\n * is consumed in all of them and must not reach for `node:crypto`. A non-\n * cryptographic hash was considered and rejected: approvals are pinned to this\n * value, so a collision is an approval bypass, not a cache miss.\n */\nexport async function computePlanHash(plan: ExecutionPlan): Promise<string> {\n const bytes = new TextEncoder().encode(planHashPayload(plan))\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')\n}\n\n/** One origin record as the inventory saw it. */\nexport interface SourceInventoryRecord {\n /** Origin object type (\"post\", \"page\", \"attachment\", or a taxonomy name for a term). */\n wp_type: string\n /** Origin id — unique only together with `wp_type`: post 5 and category 5 are different records. */\n wp_id: number\n /** Origin status (`publish`, `draft`, `trash`, …); a term has none. */\n status?: string\n /**\n * Password-protected at the origin. The record is inventoried like any other\n * — so removing the password is an `updated`, not a `created` — but has no\n * public address.\n */\n protected?: boolean\n slug?: string\n /** Site-root-relative public address. A change here is a `moved`. */\n path?: string\n /** Origin id of the parent record, when it has one. */\n parent?: number\n /** ISO 8601 UTC, from the origin — a hint only: a meta-only edit does not change it. */\n modified_at?: string\n /**\n * Hash of the record as mapped, volatile fields excluded. `updated` is\n * decided on this, never on `modified_at`.\n */\n fingerprint: string\n /** Slugs the origin remembers for this record (WordPress `_wp_old_slug`). Corroborates a move; never the authority. */\n old_slugs?: string[]\n /** Locale of the record, when the origin is multilingual. */\n locale?: string\n}\n\n/**\n * Every record in scope at one moment — what a `bridge_inventory` cursor's\n * `inventory_hash` identifies.\n *\n * Deletions and moves are proven by comparing two inventories, not by\n * reading a feed; a `modified_after` query only narrows which bodies to fetch\n * again. A type in the earlier inventory's `scope` but not in this one's left\n * the scope — it was not deleted.\n */\nexport interface SourceInventory {\n /** Inventory format identifier, e.g. `contentrain-bridge-inventory@2`. */\n format: string\n /** ISO 8601 UTC time the inventory was taken. */\n taken_at: string\n /** What was enumerated. */\n scope: {\n post_types: string[]\n taxonomies?: string[]\n }\n records: SourceInventoryRecord[]\n /**\n * The value a cursor's `inventory_hash` carries: lowercase hex SHA-256 of the\n * records sorted by `wp_type` (byte order) then `wp_id` (numeric), each\n * serialized as the JSON array `[wp_type, wp_id, fingerprint, path ?? null,\n * status ?? null]` — no spaces, slashes and non-ASCII unescaped (what\n * `JSON.stringify` prints) — joined by `\\n`, no trailing newline. Other fields\n * do not enter it; a change they make shows through `fingerprint`.\n */\n inventory_hash: string\n}\n","// ─── Approval Evaluator ───\n//\n// The decision procedure behind `execution.ts`'s approval types: given a plan,\n// a policy and the decisions collected so far, may this proceed?\n//\n// It replaces a role check. Studio's `shouldAutoMerge` asks \"is this person an\n// owner?\" — which cannot express \"a bulk publish needs a second pair of eyes\n// even from the owner\", and cannot distinguish fixing a typo from cutting over\n// a domain. This asks about the action instead: its risk, its scope, and what\n// the project's own policy says about that combination.\n//\n// Pure and dependency-free, so the same answer comes out in Studio's server,\n// in a CLI, and in a test. Nothing here reads a file, a clock or a user table:\n// every input is passed in, `now` included, because a decision that silently\n// depends on the wall clock cannot be reproduced when someone asks why a run\n// was blocked.\n\nimport type {\n ActorRef,\n ApprovalGrant,\n ApprovalMode,\n ApprovalPolicyFile,\n ApprovalRequirement,\n ApprovalRule,\n ExecutionPlan,\n RiskClass,\n} from './execution.js'\nimport { APPROVAL_GATES, highestRisk, riskRank } from './execution.js'\n\n/**\n * Used when a project has no `.contentrain/approval-policies.json`.\n *\n * Read-only work proceeds; everything else wants one reviewer on the produced\n * diff. Deliberately stated as a value rather than buried in the evaluator so\n * a project can inspect it, extend it, or reject it — and so \"what happens\n * without a policy file\" has one answer instead of one per caller.\n *\n * Note this is a *default*, not the only gate in the system: whether a project\n * consults the evaluator at all is still governed by its `workflow` setting.\n */\nexport const DEFAULT_APPROVAL_POLICY: ApprovalPolicyFile = {\n version: 1,\n rules: [\n { risk: 'low_risk_content', gate: 'change', mode: 'single' },\n ],\n}\n\n/** Ascending strictness — used to keep the stricter of two modes. */\nconst MODE_RANK: Record<ApprovalMode, number> = { auto: 0, single: 1, quorum: 2 }\n\n/** How many distinct approvers a mode asks for. */\nfunction approvalsNeeded(rule: ApprovalRule): number {\n if (rule.mode === 'auto') return 0\n if (rule.mode === 'single') return 1\n return Math.max(1, rule.min_approvals ?? 2)\n}\n\n/**\n * Why a grant did not count. Machine-readable because the useful question is\n * never \"is it blocked\" but \"I approved this, why is it still blocked\".\n */\nexport type GrantRejection =\n /** Given for a different plan — the plan changed after the decision. */\n | 'plan_hash_mismatch'\n /** A `change` grant reviewed a different branch tip than the one presented. */\n | 'commit_mismatch'\n /** Past its `expires_at`. */\n | 'expired'\n /** An agent cannot approve. */\n | 'agent_approver'\n /** The approver authored the plan and the policy forbids self-approval. */\n | 'self_approval'\n /** The approver's role is not one the rule accepts. */\n | 'role_not_permitted'\n /** This approver already counted toward the requirement. */\n | 'duplicate_approver'\n /** No requirement at this gate — nothing for the grant to satisfy. */\n | 'no_matching_requirement'\n\nexport interface RejectedGrant {\n grant: ApprovalGrant\n reason: GrantRejection\n}\n\n/** A requirement plus the decisions that have accumulated against it. */\nexport interface ApprovalStatus extends ApprovalRequirement {\n /** Distinct approvers counted so far, in grant order. */\n approvers: ActorRef[]\n /** Still needed; 0 means satisfied. */\n remaining: number\n}\n\nexport interface ApprovalDecision {\n /** Every requirement is satisfied and the plan has not expired. */\n allowed: boolean\n /** The class the plan was evaluated at — its own, or its worst step's. */\n risk: RiskClass\n /** Every requirement the policy produced, satisfied or not. */\n requirements: ApprovalStatus[]\n /** The subset still needing approvers, in gate order. */\n outstanding: ApprovalStatus[]\n /** Decisions that were supplied but did not count, each with a reason. */\n rejected_grants: RejectedGrant[]\n /** One line per reason the plan cannot proceed; empty when it can. */\n reasons: string[]\n}\n\nexport interface ApprovalEvaluation {\n plan: ExecutionPlan\n /** Absent falls back to {@link DEFAULT_APPROVAL_POLICY}. */\n policy?: ApprovalPolicyFile\n /** Decisions collected so far. */\n grants?: readonly ApprovalGrant[]\n /**\n * The branch tip being presented for a `change` decision. When given, a\n * `change` grant must name this commit: a review of an older tip is not a\n * review of what is about to merge. When absent, `change` grants are matched\n * on `plan_hash` alone.\n */\n commit_sha?: string\n /** ISO 8601 UTC. Passed in, never read from the clock. */\n now?: string\n}\n\n/** Whether a rule's narrowing clauses match what the plan touches. */\nfunction ruleMatchesScope(rule: ApprovalRule, plan: ExecutionPlan): boolean {\n if (rule.models && rule.models.length > 0) {\n const touched = plan.scope.models ?? []\n if (!rule.models.some(model => touched.includes(model))) return false\n }\n if (rule.locales && rule.locales.length > 0) {\n const touched = plan.scope.locales ?? []\n if (!rule.locales.some(locale => touched.includes(locale))) return false\n }\n return true\n}\n\n/**\n * The class a plan is judged at: its declared risk, or its worst step's if\n * that is higher. A plan cannot understate itself — declaring `read_only`\n * while carrying a deploy step would otherwise walk straight past the policy.\n */\nexport function effectiveRisk(plan: ExecutionPlan): RiskClass {\n return highestRisk([plan.risk, ...plan.steps.map(step => step.risk)])\n}\n\n/**\n * The requirements a policy produces for a plan, before any decision is\n * collected. This is what a plan card renders: \"this needs one reviewer on the\n * diff and two on release, because it deploys\".\n *\n * Each matching rule contributes its own requirement and all of them must be\n * met. Merging two rules into one would need a rule for combining their modes,\n * roles and counts — and every such rule has a case where the result is\n * *looser* than one of its inputs. Keeping them separate is what makes the\n * policy additive in fact and not just in intent.\n *\n * `auto` rules are dropped rather than kept as zero-approver requirements: a\n * requirement nobody has to satisfy is noise on the card, and a stricter rule\n * matching the same gate still stands on its own.\n */\nexport function requiredApprovals(plan: ExecutionPlan, policy: ApprovalPolicyFile = DEFAULT_APPROVAL_POLICY): ApprovalRequirement[] {\n const risk = effectiveRisk(plan)\n const requirements: ApprovalRequirement[] = []\n let matched = false\n\n for (const rule of policy.rules) {\n if (!ruleMatchesScope(rule, plan)) continue\n\n // A demanding rule covers its own class and everything above it: the\n // classes are a ladder, so a policy written for `bulk_content` also\n // governs a deploy.\n //\n // An `auto` rule does NOT climb. It says \"this class needs nothing\", and\n // if it inherited upward a single `auto` rule on a low rung would exempt\n // every heavier operation above it — turning the one mode that demands\n // nothing into the only mode that can loosen a policy. Matching its own\n // class alone keeps the file additive in fact and not just in intent.\n const climbs = rule.mode !== 'auto'\n const applies = climbs ? riskRank(risk) >= riskRank(rule.risk) : risk === rule.risk\n if (!applies) continue\n\n matched = true\n const min_approvals = approvalsNeeded(rule)\n // `auto` produces no requirement — but it did match, so the policy's\n // `default_mode` does not step in behind it.\n if (min_approvals === 0) continue\n\n requirements.push({\n gate: rule.gate,\n mode: rule.mode,\n min_approvals,\n ...(rule.roles && rule.roles.length > 0 ? { roles: [...rule.roles] } : {}),\n because: rule.risk,\n })\n }\n\n if (!matched && policy.default_mode && policy.default_mode !== 'auto') {\n requirements.push({\n gate: 'change',\n mode: policy.default_mode,\n min_approvals: approvalsNeeded({ risk, gate: 'change', mode: policy.default_mode }),\n because: risk,\n })\n }\n\n // Gate order, then the strictest first within a gate, so a card reads\n // chronologically and the heaviest demand is not buried.\n return requirements.toSorted((a, b) =>\n APPROVAL_GATES.indexOf(a.gate) - APPROVAL_GATES.indexOf(b.gate)\n || MODE_RANK[b.mode] - MODE_RANK[a.mode]\n || b.min_approvals - a.min_approvals)\n}\n\n/** Whether a grant may be counted at all, independent of any requirement. */\nfunction grantDisqualification(\n grant: ApprovalGrant,\n plan: ExecutionPlan,\n policy: ApprovalPolicyFile,\n commit_sha: string | undefined,\n now: string,\n): GrantRejection | undefined {\n if (grant.plan_hash !== plan.plan_hash) return 'plan_hash_mismatch'\n if (grant.expires_at && grant.expires_at <= now) return 'expired'\n // An agent may never approve — not its own work, not anyone's. The whole\n // point of the gate is that something outside the automation looked.\n if (grant.approver.kind === 'agent') return 'agent_approver'\n if (\n !policy.allow_self_approval\n && plan.created_by\n && grant.approver.id === plan.created_by.id\n && grant.approver.kind === plan.created_by.kind\n ) return 'self_approval'\n // A `change` decision is a decision about a diff. Presented with a different\n // tip than the one reviewed, it says nothing about what is about to merge.\n if (grant.gate === 'change' && commit_sha && grant.commit_sha !== commit_sha) return 'commit_mismatch'\n return undefined\n}\n\n/**\n * May this plan proceed?\n *\n * Answers with the full picture rather than a boolean: which requirements\n * exist, which are met, who met them, and — for every decision that was\n * supplied but did not count — why it did not. The last part is what makes\n * \"I approved this, why is it still blocked?\" answerable without reading the\n * evaluator's source.\n */\nexport function evaluateApproval(input: ApprovalEvaluation): ApprovalDecision {\n const { plan, commit_sha } = input\n const policy = input.policy ?? DEFAULT_APPROVAL_POLICY\n const grants = input.grants ?? []\n const now = input.now ?? new Date().toISOString()\n\n const risk = effectiveRisk(plan)\n // `requiredApprovals` builds fresh objects (roles included) on every call, so\n // completing them in place is safe and costs no second allocation.\n const requirements: ApprovalStatus[] = requiredApprovals(plan, policy).map(requirement =>\n Object.assign(requirement, { approvers: [] as ActorRef[], remaining: requirement.min_approvals }))\n\n const rejected_grants: RejectedGrant[] = []\n\n for (const grant of grants) {\n const disqualified = grantDisqualification(grant, plan, policy, commit_sha, now)\n if (disqualified) {\n rejected_grants.push({ grant, reason: disqualified })\n continue\n }\n\n // One decision can satisfy several requirements at the same gate — it is\n // the same person saying yes to the same thing once.\n let counted = false\n let sawGate = false\n let roleBlocked = false\n let duplicate = false\n\n for (const requirement of requirements) {\n if (requirement.gate !== grant.gate) continue\n sawGate = true\n if (requirement.roles && !requirement.roles.includes(grant.approver.role ?? '')) {\n roleBlocked = true\n continue\n }\n if (requirement.approvers.some(approver => approver.id === grant.approver.id)) {\n duplicate = true\n continue\n }\n requirement.approvers.push(grant.approver)\n requirement.remaining = Math.max(0, requirement.min_approvals - requirement.approvers.length)\n counted = true\n }\n\n if (counted) continue\n if (!sawGate) rejected_grants.push({ grant, reason: 'no_matching_requirement' })\n else if (duplicate) rejected_grants.push({ grant, reason: 'duplicate_approver' })\n else if (roleBlocked) rejected_grants.push({ grant, reason: 'role_not_permitted' })\n }\n\n const outstanding = requirements.filter(requirement => requirement.remaining > 0)\n const reasons: string[] = []\n\n const expired = Boolean(plan.expires_at && plan.expires_at <= now)\n if (expired) {\n reasons.push(`The plan expired at ${plan.expires_at}; rebuild it rather than running it.`)\n }\n\n for (const requirement of outstanding) {\n const who = requirement.roles ? ` from ${requirement.roles.join(' or ')}` : ''\n const have = requirement.approvers.length\n reasons.push(\n `${requirement.gate} approval: ${requirement.remaining} more${who} needed `\n + `(${have}/${requirement.min_approvals}), because the plan is ${requirement.because}.`,\n )\n }\n\n return {\n allowed: !expired && outstanding.length === 0,\n risk,\n requirements,\n outstanding,\n rejected_grants,\n reasons,\n }\n}\n","// ─── Migrate → Studio claim contract ───\n//\n// A paid Migrate order includes a Studio trial. When the migrated site is\n// delivered, Migrate hands the customer to Studio with a signed claim token;\n// Studio verifies it, grants the trial once per order, and opens the delivered\n// repository as a project.\n//\n// The token is a compact JWS, `alg: \"EdDSA\"` (Ed25519). Migrate signs with its\n// private key; Studio verifies with the public key. There is no shared secret.\n// A `kid` header is optional and used for key rotation. This module is the\n// payload both sides agree on. It deliberately does no cryptography — the\n// signature is checked by the consumer, before `validateMigrateStudioClaim`.\n//\n// Pure and dependency-free; every input is passed in, `now` included.\n\nimport type { CapabilityKey } from './migration.js'\nimport { CAPABILITY_KEYS } from './migration.js'\n\n/** Contract version carried in the payload as `v`. */\nexport const MIGRATE_STUDIO_CLAIM_VERSION = 1\n/** JWS `alg` header value. */\nexport const MIGRATE_STUDIO_CLAIM_ALG = 'EdDSA'\n/** `iss` — who signs. */\nexport const MIGRATE_STUDIO_CLAIM_ISSUER = 'contentrain-migrate'\n/** `aud` — who may accept it. */\nexport const MIGRATE_STUDIO_CLAIM_AUDIENCE = 'contentrain-studio'\n/** Upper bound on `exp - iat`: a claim link is short-lived (30 minutes). */\nexport const MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS = 1800\n/** Upper bound on the trial a claim can grant. v1 grants 60. */\nexport const MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS = 90\n/** Clock skew tolerated when checking `iat` / `exp`. */\nexport const MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS = 60\n\nexport const MIGRATE_STUDIO_PLANS = ['starter', 'pro'] as const\nexport type MigrateStudioPlan = (typeof MIGRATE_STUDIO_PLANS)[number]\n\n/**\n * One measurement the plan recommendation rests on, so Studio can say why:\n * \"3 200 comments a month exceed Starter's 500.\" `limit_key` is the Studio\n * plan-feature key the measurement was sized against; `limit` is the value of\n * that key for the recommended plan when the claim was signed. Sizing leaves\n * headroom (limit ≥ 1.2 × measured), so `measured <= limit` is expected.\n */\nexport interface MigrateStudioPlanEvidence {\n /** Studio plan-feature limit key, e.g. `comments.per_month`, `media.storage_gb`. */\n limit_key: string\n /** What discovery measured, in the limit's unit (a count per month, GB, MB). */\n measured: number\n /** The recommended plan's value for `limit_key` at signing time. */\n limit: number\n /** The discovered capability behind the measurement, when there is one. */\n capability?: CapabilityKey\n}\n\nexport interface MigrateStudioRepo {\n provider: 'github'\n owner: string\n name: string\n}\n\nexport interface MigrateStudioCapability {\n key: CapabilityKey\n /** Human-readable scale from discovery, e.g. \"1 240 comments\", \"2.1 GB\". */\n scale?: string | null\n}\n\n/** The claim token's payload. */\nexport interface MigrateStudioClaim {\n // Registered JWT claims\n iss: typeof MIGRATE_STUDIO_CLAIM_ISSUER\n aud: typeof MIGRATE_STUDIO_CLAIM_AUDIENCE\n /** Migrate account (user) id. */\n sub: string\n /** Single-use token id (uuid). Studio refuses a `jti` it has seen. */\n jti: string\n /** Issued at, seconds since the epoch. */\n iat: number\n /** Expires at, seconds since the epoch. `exp - iat` ≤ `MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS`. */\n exp: number\n\n /** Contract version. */\n v: typeof MIGRATE_STUDIO_CLAIM_VERSION\n /** Migrate order id. Studio stores it UNIQUE: one grant per order. */\n order_id: string\n /**\n * Verified at Migrate. Studio shows it and may prefill with it, but does not\n * require a match: the customer's GitHub login email may differ.\n */\n email: string\n /** The plan sized from discovery. */\n plan: MigrateStudioPlan\n /** Why this plan — empty when discovery found nothing to size against (then `starter`). */\n plan_evidence: MigrateStudioPlanEvidence[]\n /** Trial length the grant opens, 1..`MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS`. Never taken from the client. */\n trial_days: number\n /** The delivered repository. */\n repo: MigrateStudioRepo\n /** Discovery summary shown on Studio's claim screen. Optional. */\n capabilities?: MigrateStudioCapability[]\n}\n\nexport type MigrateStudioClaimResult =\n | { ok: true, claim: MigrateStudioClaim }\n | { ok: false, errors: string[] }\n\nconst CAPABILITY_SET: ReadonlySet<string> = new Set(CAPABILITY_KEYS)\nconst isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)\nconst isText = (x: unknown): x is string => typeof x === 'string' && x.trim().length > 0\nconst isSeconds = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x > 0\nconst isFiniteNonNegative = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x) && x >= 0\n\n/**\n * Check a decoded, signature-verified payload against the contract. Pass\n * `now` (seconds since the epoch) to also check the validity window; without\n * it only the shape and the window's own bounds are checked. Returns every\n * problem found, as stable machine-readable strings (`field: problem`).\n */\nexport function validateMigrateStudioClaim(input: unknown, options: { now?: number } = {}): MigrateStudioClaimResult {\n const errors: string[] = []\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const x = input\n\n if (x.iss !== MIGRATE_STUDIO_CLAIM_ISSUER) errors.push('iss: unexpected issuer')\n if (x.aud !== MIGRATE_STUDIO_CLAIM_AUDIENCE) errors.push('aud: unexpected audience')\n if (x.v !== MIGRATE_STUDIO_CLAIM_VERSION) errors.push('v: unsupported version')\n for (const key of ['sub', 'jti', 'order_id'] as const) {\n if (!isText(x[key])) errors.push(`${key}: required`)\n }\n if (!isText(x.email) || !x.email.includes('@')) errors.push('email: invalid')\n\n if (!isSeconds(x.iat)) errors.push('iat: invalid')\n if (!isSeconds(x.exp)) errors.push('exp: invalid')\n if (isSeconds(x.iat) && isSeconds(x.exp)) {\n const ttl = x.exp - x.iat\n if (ttl <= 0) errors.push('exp: not after iat')\n else if (ttl > MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS) errors.push('exp: lifetime too long')\n if (options.now !== undefined) {\n if (options.now >= x.exp + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('exp: expired')\n if (x.iat > options.now + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('iat: in the future')\n }\n }\n\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(x.plan)) errors.push('plan: unknown plan')\n if (!Array.isArray(x.plan_evidence)) {\n errors.push('plan_evidence: required (may be empty)')\n }\n else {\n x.plan_evidence.forEach((e, i) => {\n if (!isObject(e) || !isText(e.limit_key) || !isFiniteNonNegative(e.measured) || !isFiniteNonNegative(e.limit)) {\n errors.push(`plan_evidence[${i}]: invalid`)\n }\n else if (e.capability !== undefined && !CAPABILITY_SET.has(e.capability as string)) {\n errors.push(`plan_evidence[${i}].capability: unknown`)\n }\n })\n }\n\n if (typeof x.trial_days !== 'number' || !Number.isInteger(x.trial_days)\n || x.trial_days < 1 || x.trial_days > MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS) {\n errors.push('trial_days: out of range')\n }\n\n const repo = x.repo\n if (!isObject(repo) || repo.provider !== 'github' || !isText(repo.owner) || !isText(repo.name)\n || repo.owner.includes('/') || repo.name.includes('/')) {\n errors.push('repo: invalid')\n }\n\n if (x.capabilities !== undefined) {\n if (!Array.isArray(x.capabilities)) {\n errors.push('capabilities: not an array')\n }\n else {\n x.capabilities.forEach((c, i) => {\n if (!isObject(c) || !CAPABILITY_SET.has(c.key as string)) errors.push(`capabilities[${i}]: unknown capability`)\n else if (c.scale !== undefined && c.scale !== null && typeof c.scale !== 'string') errors.push(`capabilities[${i}].scale: invalid`)\n })\n }\n }\n\n return errors.length === 0 ? { ok: true, claim: x as unknown as MigrateStudioClaim } : { ok: false, errors }\n}\n\n/** Shape-only type guard (no clock check). The signature is the consumer's job. */\nexport function isMigrateStudioClaim(input: unknown): input is MigrateStudioClaim {\n return validateMigrateStudioClaim(input).ok\n}\n","import type { SyncResult } from './index.js'\n\n// ─── Repository Provider Contracts ───\n//\n// Shared interfaces for the provider-agnostic content repository model used\n// by @contentrain/mcp. They live in @contentrain/types so third-party tools\n// can implement a custom RepoProvider (e.g. for a private git host, an\n// internal service, a mock in a test suite) without depending on MCP\n// internals.\n//\n// @contentrain/mcp re-exports every symbol here from\n// @contentrain/mcp/core/contracts so existing consumers do not have to\n// migrate imports.\n\n// ─── File change ───\n\n/**\n * A single file change within a plan.\n *\n * - `content: string` — write or overwrite the file with this UTF-8 content.\n * - `content: null` — delete the file.\n *\n * Paths are content-root relative, use forward slashes, and must not contain\n * `..` segments or absolute anchors. Providers are responsible for resolving\n * paths against their backing store (worktree, git tree, etc.).\n */\nexport interface FileChange {\n path: string\n content: string | null\n}\n\n// ─── Capabilities ───\n\n/**\n * Capabilities describe what a provider can and cannot do. Operations check\n * required capabilities before running. Normalize extract needs `sourceRead`;\n * normalize reuse needs `sourceWrite`; submit needs `pushRemote`; AST scans\n * need `astScan` (which implies a local working tree).\n *\n * The consistent position is: all git hosts are commodity MIT providers; the\n * distinction between providers is operational (how they read/write), not\n * commercial. Enterprise features live in Studio, not in capability gates.\n */\nexport interface ProviderCapabilities {\n /** Provider backs onto a local worktree and can selectively sync changes into the developer's working tree. */\n localWorktree: boolean\n /** Provider can read arbitrary source files outside `.contentrain/`. Required for normalize extract. */\n sourceRead: boolean\n /** Provider can write arbitrary source files outside `.contentrain/`. Required for normalize reuse. */\n sourceWrite: boolean\n /** Provider can push commits to a remote. Required for submit. */\n pushRemote: boolean\n /** Provider detects branch protection rules on the remote. */\n branchProtection: boolean\n /** Provider can open a pull request as a merge fallback when branch protection blocks direct merge. */\n pullRequestFallback: boolean\n /** Provider can execute AST scanners against source files. Implies local disk access. */\n astScan: boolean\n /**\n * Provider can write a two-parent merge commit (see\n * `RepoProvider.createMergeCommit`). Absent = false. Optional so that\n * existing external `implements` code keeps compiling: a required member\n * here would be a breaking change for every custom provider.\n */\n mergeCommit?: boolean\n}\n\n/** Capability set for the LocalProvider (simple-git + worktree). */\nexport const LOCAL_CAPABILITIES: ProviderCapabilities = {\n localWorktree: true,\n sourceRead: true,\n sourceWrite: true,\n pushRemote: true,\n branchProtection: false,\n pullRequestFallback: false,\n astScan: true,\n mergeCommit: true,\n}\n\n// ─── Reader ───\n\n/**\n * Read-only interface to a content repository.\n *\n * Paths are relative to the repository's content root (e.g.\n * `.contentrain/config.json`). The `ref` parameter is a branch name, tag,\n * or commit SHA. Providers that operate on a single working tree\n * (LocalReader) ignore `ref`; API-backed providers use it to resolve the\n * correct revision.\n *\n * `readFile` and `listDirectory` deliberately have different error semantics:\n * - `readFile` THROWS when the file is missing so callers must opt into\n * tolerance explicitly (typically with a try/catch returning a default).\n * - `listDirectory` returns `[]` for a missing directory because the empty\n * case is the common, uninteresting one.\n */\nexport interface RepoReader {\n /**\n * Read a file's contents as UTF-8.\n * @throws when the file does not exist or cannot be read.\n */\n readFile(path: string, ref?: string): Promise<string>\n\n /**\n * List file and directory names directly under `path`. Does not recurse.\n * Returns an empty array when the directory does not exist.\n */\n listDirectory(path: string, ref?: string): Promise<string[]>\n\n /** Check whether a file or directory exists at `path`. */\n fileExists(path: string, ref?: string): Promise<boolean>\n}\n\n// ─── Writer ───\n\nexport interface CommitAuthor {\n name: string\n email: string\n}\n\nexport interface Commit {\n sha: string\n message: string\n author: CommitAuthor\n timestamp: string\n /**\n * What the provider did with the branch. Providers that own the merge\n * decision (a local worktree honouring the project's `workflow` setting)\n * report it here; providers that hand the branch off to an external\n * orchestrator leave it undefined, and the caller treats that as\n * `pending-review`.\n */\n workflowAction?: 'auto-merged' | 'pending-review'\n /**\n * Selective-sync bookkeeping — which files were copied into the developer's\n * working tree and which were skipped because of local edits. Only\n * providers backed by a local worktree populate it.\n */\n sync?: SyncResult\n /** Non-fatal problem encountered while completing the write. */\n warning?: string\n /**\n * Whether the base branch advanced to the contentrain tip after an\n * auto-merge. Only providers that own the base advance (a local worktree)\n * report it. `blocked_diverged` is a partial success: the content is on\n * the contentrain branch; only the fast-forward is pending.\n */\n base_advance?: BaseAdvance\n /** Outcome of pushing the contentrain branch, when the provider pushes. */\n remote_push?: RemotePush\n}\n\n/**\n * Input to `applyPlan`. Represents a single atomic commit: all changes land\n * in one commit on `branch`, created from `base` if it does not yet exist.\n */\nexport interface ApplyPlanInput {\n /** Branch name to commit to. Created from `base` if missing. */\n branch: string\n /** File additions, modifications and deletions to apply in a single commit. */\n changes: FileChange[]\n /** Commit message. */\n message: string\n /** Commit author. */\n author: CommitAuthor\n /**\n * Optional base branch. Defaults to the Contentrain content-tracking\n * branch (`CONTENTRAIN_BRANCH` — the `contentrain` ref) — NOT the\n * repository's default branch. This is the single source of truth for\n * content state; every feature branch forks from it. Pass an explicit\n * `base` only when you know you want to bypass the invariant.\n *\n * The GitHub provider also takes a full 40-hex commit SHA here, as a\n * compare-and-set: a missing `branch` forks from that commit, and an\n * existing one must still point at it or the write is refused with a\n * 409 (`PROVIDER_CONFLICT`).\n */\n base?: string\n /**\n * Describes the operation that produced these changes. A provider that\n * regenerates `.contentrain/context.json` itself (the local worktree flow\n * does, after merge, single-threaded) uses it; others ignore it. It is\n * never committed on the feature branch — context.json embeds timestamps,\n * so two branches forked from the same commit would always conflict on it.\n */\n context?: { tool: string; model: string; locale?: string; entries?: string[] }\n /** Override the project's configured workflow for this write. */\n workflowOverride?: 'auto-merge' | 'review'\n}\n\n/**\n * Write-side interface. Providers implement this to persist a set of file\n * changes as a single atomic commit. LocalProvider writes through a worktree\n * and `git commit`; API-backed providers post to the Git Data API or\n * equivalent.\n */\nexport interface RepoWriter {\n applyPlan(input: ApplyPlanInput): Promise<Commit>\n}\n\n// ─── Branch / diff / merge ───\n\nexport interface Branch {\n name: string\n sha: string\n protected?: boolean\n}\n\nexport interface FileDiff {\n path: string\n status: 'added' | 'modified' | 'removed'\n before: string | null\n after: string | null\n}\n\nexport interface MergeResult {\n merged: boolean\n sha: string | null\n pullRequestUrl: string | null\n /**\n * Selective-sync bookkeeping — only populated by providers that back onto\n * a local worktree (LocalProvider). Remote-API providers (GitHub, GitLab,\n * etc.) omit it because they do not touch a developer's working tree.\n */\n sync?: SyncResult\n /**\n * Outcome of the post-merge source-branch cleanup on the remote.\n * `deleted: false` with `skipped` is an expected no-op (cleanup disabled,\n * no remote, ref already gone); `warning` is a real failure (offline,\n * auth) that did NOT fail the merge itself.\n */\n remote?: {\n deleted: boolean\n skipped?: string\n warning?: string\n }\n}\n\n// ─── Base-branch advance / remote push outcomes ───\n\n/**\n * What happened to the base branch after content landed on the\n * content-tracking branch.\n *\n * `advanced` — the base branch fast-forwarded to the contentrain tip.\n * `blocked_diverged` — the base branch holds commits that are not in\n * contentrain, so fast-forwarding was impossible. The content itself is\n * safe on the contentrain branch; only the advance is pending until the\n * branches are reconciled.\n *\n * These two values are shared vocabulary across the ecosystem — Studio\n * reports the same enum for its main-advance state. A pull request is an\n * attachment (`pullRequestUrl`), never a third state: \"PR opened\" is\n * `blocked_diverged` with a non-null URL.\n */\nexport type BaseAdvance = 'advanced' | 'blocked_diverged'\n\n/**\n * Outcome of pushing the content-tracking branch to the configured remote.\n *\n * `pushed` — the remote accepted the push (possibly after one\n * fetch-merge-retry). `rejected` — the push failed even after the retry;\n * local and remote have diverged and need reconciling. `no-remote` — the\n * repository has no configured remote, so nothing was attempted.\n */\nexport type RemotePush = 'pushed' | 'rejected' | 'no-remote'\n\n// ─── Reconcile / conflicts ───\n\n/**\n * Machine-readable conflict kinds produced by the content-aware three-way\n * reconcile planner. CLOSED SET: consumers (Studio) key their localized\n * editor questions on these values, so a free string would drift. Adding a\n * value here is a minor version bump and MUST carry a changelog entry;\n * renaming or removing one is breaking.\n */\nexport type ConflictCode =\n | 'field_value_conflict' // collection/singleton: same field changed differently on both sides\n | 'dictionary_value_conflict' // same dictionary key, two different values\n | 'vocabulary_value_conflict' // same term + locale, two different translations\n | 'model_key_conflict' // same model schema key changed differently\n | 'meta_status_conflict' // both sides moved an entry's publish status differently\n | 'document_body_conflict' // both sides edited a document body (never text-merged)\n | 'frontmatter_value_conflict' // same frontmatter key, two different values\n | 'delete_edit_conflict' // one side deleted what the other edited\n | 'file_conflict' // unrecognized file changed on both sides — resolved by choosing a side\n\n/**\n * One surviving conflict from a three-way reconcile: base (merge-base),\n * ours (content branch), theirs (base branch) disagree in a way the policy\n * table cannot resolve mechanically. Everything mechanical has already been\n * merged; only these items need a decision from an editor or agent.\n *\n * `id` is derived from the position AND the three values (see\n * `conflictId`), so a resolution made against a stale dry-run no longer\n * matches once any side changes — compare-and-set for free.\n */\nexport interface ConflictItem {\n id: string\n /** Content-root-relative path of the file the conflict lives in. */\n path: string\n model?: string\n kind: 'collection' | 'singleton' | 'document' | 'dictionary'\n | 'vocabulary' | 'model' | 'meta' | 'file'\n /** Entry ID, dictionary key, vocabulary term, or model schema key. */\n key?: string\n /** Field inside the entry / frontmatter key, when the conflict is field-level. */\n field?: string\n locale?: string\n /** The three values in question — for display; omitted for `kind: 'file'`. */\n base?: unknown\n ours?: unknown\n theirs?: unknown\n code: ConflictCode\n /** Ready-to-display English sentence naming the conflict. */\n message: string\n /**\n * Policy-table suggestion, never auto-applied. Present only where the\n * approved policy names a preferred side (model schema keys → `theirs`).\n */\n suggested?: 'ours' | 'theirs'\n}\n\n/**\n * A decision for one conflict, keyed by `ConflictItem.id`. `choose` picks a\n * side (absence of the item on that side means deletion); `value` supplies\n * a hand-authored replacement. A resolution whose `id` no longer matches a\n * live conflict is dropped and the conflict re-reported with a fresh id.\n */\nexport type ConflictResolution =\n | { id: string; choose: 'ours' | 'theirs' }\n | { id: string; value: unknown }\n\n// ─── Media (optional provider facet) ───\n\n/**\n * One media asset as the provider's media stack reports it. `path` is the\n * repo-relative storage path content fields reference (`media/...`); `url`\n * is the absolute delivery URL when the provider can resolve one (see\n * `RepoProvider.mediaBaseUrl`). `meta` carries provider-defined extras\n * (dimensions, blurhash, variants) without widening this contract.\n */\nexport interface MediaAsset {\n id: string\n path: string\n url?: string\n mime?: string\n size?: number\n alt?: string\n tags?: string[]\n createdAt?: string\n meta?: Record<string, unknown>\n}\n\nexport interface MediaListOptions {\n /** Substring match on filename/path/alt. */\n search?: string\n /** Filter to assets carrying this tag. */\n tag?: string\n /** Page size — implementations may clamp. */\n limit?: number\n /** Opaque cursor from a previous `MediaListResult.nextCursor`. */\n cursor?: string\n}\n\nexport interface MediaListResult {\n assets: MediaAsset[]\n nextCursor?: string\n total?: number\n}\n\n/**\n * URL-based ingest — MCP has no binary channel, so the source is always a\n * URL the provider fetches server-side. Implementations MUST enforce their\n * own SSRF, MIME, and size policies before fetching; MCP passes the input\n * through verbatim and never fetches the URL itself.\n */\nexport interface MediaIngestInput {\n url: string\n filename?: string\n alt?: string\n tags?: string[]\n}\n\nexport interface MediaUpdateInput {\n alt?: string\n tags?: string[]\n filename?: string\n}\n\n/**\n * Optional media facet of a provider. When present on `RepoProvider.media`,\n * the MCP server registers the `contentrain_media_*` tools; when absent\n * (LocalProvider, plain GitHub/GitLab providers), those tools are not\n * listed at all. Hosted providers (Studio MCP Cloud) implement this by\n * delegating to their media stack — deterministic passthrough, no content\n * decisions in MCP.\n */\nexport interface MediaProvider {\n list(opts?: MediaListOptions): Promise<MediaListResult>\n get(id: string): Promise<MediaAsset | null>\n ingest(input: MediaIngestInput): Promise<MediaAsset>\n update(id: string, patch: MediaUpdateInput): Promise<MediaAsset>\n delete(id: string): Promise<void>\n}\n\n// ─── Provider (full surface) ───\n\n/**\n * A content repository provider — the unified surface that MCP tools drive.\n *\n * Implementations wrap a git backend:\n *\n * - `LocalProvider` — simple-git + temp worktree + selective sync\n * - `GitHubProvider` — Octokit Git Data API (no clone)\n * - `GitLabProvider` — gitbeaker REST client (no clone)\n * - `BitbucketProvider` — planned, coming soon\n *\n * Providers are commodity and all live in MIT.\n */\n/** Result of {@link RepoProvider.checkWriteReadiness}. */\nexport interface WriteReadiness {\n blocked: boolean\n /** Human-readable reason, present when `blocked`. */\n message?: string\n}\n\nexport interface RepoProvider extends RepoReader, RepoWriter {\n readonly capabilities: ProviderCapabilities\n\n /**\n * Per-project public media delivery base, e.g.\n * `https://studio.example/api/cdn/v1/{projectId}` — already includes the\n * project segment, so the content-write path only joins `{base}/{path}`.\n *\n * Set by hosted providers (Studio's MCP Cloud loopback resolves it from a\n * request header and attaches it here). When present, content writes\n * normalize relative `media/...` references — in media/image/file fields and\n * markdown bodies — to absolute delivery URLs, so external-agent writes\n * render anywhere without an SDK. Undefined for local/CLI providers, where\n * media stays a relative path (the OSS file model). Never affects reads.\n */\n readonly mediaBaseUrl?: string\n\n /**\n * Optional media facet. Present only on providers whose backend exposes a\n * media stack (Studio MCP Cloud); drives the `contentrain_media_*` MCP\n * tools, which are not registered when this is absent.\n */\n readonly media?: MediaProvider\n\n /**\n * Whether the provider will accept a write right now.\n *\n * A local worktree accumulates unmerged `cr/*` branches and eventually has\n * to refuse new ones; a hosted provider manages that server-side. Asking the\n * provider keeps the tool layer out of the business of counting git branches\n * — it previously reached through an `instanceof` check to a `projectRoot`\n * only one implementation has.\n *\n * Optional: a provider that omits it is always ready.\n */\n checkWriteReadiness?(): Promise<WriteReadiness>\n\n listBranches(prefix?: string): Promise<Branch[]>\n createBranch(name: string, fromRef?: string): Promise<void>\n deleteBranch(name: string): Promise<void>\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]>\n /**\n * Merge `branch` into `into`. Like `git merge` and the platform merge APIs,\n * the source branch is left in place by default. Pass\n * `opts.removeSourceBranch: true` to also delete it after a successful merge\n * (best-effort — reported via `MergeResult.remote`, never a thrown error).\n * Even when opted in, a long-lived branch is never deleted: not `into`, not\n * the `contentrain` content branch, and not the repo's default branch.\n * LocalProvider ignores the option: its cleanup is governed by\n * `config.remoteBranchCleanup` and its own `cr/*`-only guard.\n */\n mergeBranch(branch: string, into: string, opts?: { removeSourceBranch?: boolean }): Promise<MergeResult>\n isMerged(branch: string, into?: string): Promise<boolean>\n getDefaultBranch(): Promise<string>\n\n /**\n * The merge-base commit of two refs — the `base` input of a three-way\n * reconcile.\n *\n * Reconciling a diverged content branch needs the last common ancestor,\n * and every backend can name it (git `merge-base`, GitHub's compare API,\n * GitLab's merge_base endpoint) — but not every existing implementation\n * does yet, and demanding it would break external `implements` code.\n *\n * Optional: a provider that omits it cannot drive a reconcile; the caller\n * falls back to a pull-request flow. Returns `null` when the refs share\n * no history.\n */\n getMergeBase?(refA: string, refB: string): Promise<string | null>\n\n /**\n * Write a set of resolved changes as a TWO-PARENT merge commit on\n * `branch`, joining `ours` and `theirs` so that git history records the\n * reconcile — after it, `theirs` is an ancestor of `branch` and a plain\n * fast-forward advance works again. `changes` are applied on top of the\n * `ours` tree; for content-owned paths the planner's output is\n * authoritative, never a textual auto-merge.\n *\n * Optional, mirrored by `capabilities.mergeCommit`: a backend whose\n * commits API cannot express two parents (GitLab) omits it and the\n * caller falls back to a merge-request flow.\n */\n createMergeCommit?(input: {\n /** Branch whose tip becomes the merge commit (the content branch). */\n branch: string\n /** First parent — the current tip of `branch`. */\n ours: string\n /** Second parent — the ref being reconciled in. */\n theirs: string\n changes: FileChange[]\n message: string\n author: CommitAuthor\n }): Promise<Commit>\n}\n","import type { FieldDef, FieldType } from './index.js'\n\n/**\n * Media path → delivery URL rewriting. The single source of truth for how a\n * stored `media/...` reference becomes an absolute delivery URL — every\n * reader of Contentrain content that resolves this at read or write time\n * shares this module, so the rules can only be defined once.\n *\n * Two independent consumers need the exact same answer: `@contentrain/mcp`'s\n * `planContentSave` normalizes on write, for hosted/cloud mode, so a\n * git-committed value is ready to use with no SDK; `@contentrain/query`\n * resolves the same relative paths at read time, for the local-file model\n * (the generated client, the Astro loader, raw markdown) where nothing\n * upstream rewrote them. A second, hand-rolled implementation on either side\n * drifts the moment one of the rules below changes — titled markdown embeds,\n * an HTML `src`/`href`, or a media field nested inside an `object`/`array`\n * were all missed by an earlier, separate read-side implementation before\n * this module existed.\n *\n * Only image/video/file fields (resolved via the model schema, including those\n * nested in object/array fields) and `media/...` src/href targets in\n * markdown/HTML are rewritten. External URLs (`http(s)://`, `//`, `data:`) and\n * already-absolute delivery URLs pass through untouched, so every entry point\n * is idempotent and safe to call repeatedly. No base means no-op — the\n * relative path is kept verbatim, which is the OSS local-file model's default.\n */\n\nconst MEDIA_FIELD_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\n/**\n * Whether a stored field value is a relative media-storage path (`media/...`)\n * rather than an already-absolute URL or external link. Only these are\n * rewritten to delivery URLs — `http(s)://`, `//`, and `data:` are left as-is.\n */\nexport function isStoredMediaPath(value: unknown): value is string {\n return typeof value === 'string' && value.startsWith('media/')\n}\n\n/** Join a relative media path onto the per-project delivery base. */\nexport function toDeliveryUrl(base: string, path: string): string {\n return `${base.replace(/\\/+$/, '')}/${path}`\n}\n\n/**\n * Rewrite a stored media path to its absolute delivery URL. Non-media values\n * (external URLs, empty, non-strings) pass through untouched, so this is safe\n * to call on any field value.\n */\nexport function rewriteMediaUrl(base: string, value: unknown): unknown {\n return isStoredMediaPath(value) ? toDeliveryUrl(base, value) : value\n}\n\n/**\n * Rewrite media paths within a single field value, guided by its FieldDef so\n * only media fields — including those nested inside object/array fields — are\n * touched. Non-media values pass through.\n */\nexport function rewriteFieldMedia(value: unknown, field: FieldDef, base: string): unknown {\n if (value == null) return value\n\n if (MEDIA_FIELD_TYPES.has(field.type))\n return rewriteMediaUrl(base, value)\n\n if (field.type === 'object' && field.fields && typeof value === 'object' && !Array.isArray(value))\n return rewriteEntryMedia(value as Record<string, unknown>, field.fields, base)\n\n if (field.type === 'array' && Array.isArray(value)) {\n const itemDef: FieldDef | null = typeof field.items === 'string'\n ? { type: field.items as FieldType }\n : field.items ?? null\n if (!itemDef) return value\n return value.map(v => rewriteFieldMedia(v, itemDef, base))\n }\n\n return value\n}\n\n/**\n * Rewrite media paths across one entry/frontmatter object using a field map.\n * Returns a shallow copy (the input is never mutated); a no-op when the object\n * has no media fields.\n */\nexport function rewriteEntryMedia(\n entry: Record<string, unknown>,\n fields: Record<string, FieldDef>,\n base: string,\n): Record<string, unknown> {\n const out = { ...entry }\n for (const [fieldId, field] of Object.entries(fields)) {\n if (fieldId in out)\n out[fieldId] = rewriteFieldMedia(out[fieldId], field, base)\n }\n return out\n}\n\n/** Rewrite `media/...` markdown image/link targets and inline src/href attrs. */\nexport function rewriteMarkdownMedia(body: string, base: string): string {\n return body\n // markdown image/link target: ](media/...) — stops at whitespace, ) or \"\n .replace(/(\\]\\()(media\\/[^)\\s\"]+)/g, (_m, open, p) => `${open}${toDeliveryUrl(base, p)}`)\n // inline HTML src=/href=\"media/...\"\n .replace(/(\\s(?:src|href)=)([\"'])(media\\/[^\"']+)\\2/gi,\n (_m, attr, quote, p) => `${attr}${quote}${toDeliveryUrl(base, p)}${quote}`)\n}\n","import { sortKeys } from './canonical.js'\n\n// ─── Type System ───\n\nexport type FieldType =\n | 'string' | 'text' | 'email' | 'url' | 'slug' | 'color' | 'phone' | 'code' | 'icon'\n | 'markdown' | 'richtext'\n | 'number' | 'integer' | 'decimal' | 'percent' | 'rating'\n | 'boolean'\n | 'date' | 'datetime'\n | 'image' | 'video' | 'file'\n | 'relation' | 'relations'\n | 'select' | 'array' | 'object'\n\nexport type ModelKind = 'singleton' | 'collection' | 'document' | 'dictionary'\nexport type ContentStatus = 'draft' | 'in_review' | 'published' | 'rejected' | 'archived'\nexport type ContentSource = 'agent' | 'human' | 'import'\nexport type WorkflowMode = 'auto-merge' | 'review'\n// Meta-frameworks\nexport type StackType =\n | 'nuxt' | 'next' | 'astro' | 'sveltekit' | 'remix' | 'analog'\n // Plain frameworks\n | 'vue' | 'react' | 'svelte' | 'solid' | 'angular'\n // Mobile\n | 'react-native' | 'expo' | 'flutter'\n // Backend\n | 'node' | 'express' | 'fastify' | 'nestjs' | 'django' | 'rails' | 'laravel' | 'go' | 'rust' | 'dotnet'\n // Static site generators\n | 'hugo' | 'jekyll' | 'eleventy'\n // Desktop\n | 'electron' | 'tauri'\n // Catch-all\n | 'other'\n\nexport type Platform = 'web' | 'mobile' | 'api' | 'desktop' | 'static' | 'other'\nexport type ContextSource = 'mcp-local' | 'mcp-studio' | 'studio-ui'\nexport type CollectionRuntimeFormat = 'map' | 'array'\n\n// ─── Field Definition ───\n\nexport interface FieldDef {\n type: FieldType\n required?: boolean\n unique?: boolean\n default?: unknown\n min?: number\n max?: number\n pattern?: string\n options?: string[]\n model?: string | string[]\n items?: string | FieldDef\n fields?: Record<string, FieldDef>\n accept?: string\n maxSize?: number\n description?: string\n /**\n * Human-readable name for this field in an editing UI.\n *\n * A plain string is one label for every locale; an object keyed by locale\n * carries a translation per locale. The union exists now rather than later\n * because widening `string` to it afterwards would break every consumer\n * that treats the value as a string.\n *\n * Absent means the field name itself is the label — which is why an editor\n * shows `body_public` and `is_category_hero` today.\n */\n label?: string | Record<string, string>\n /**\n * Display position in an editing UI, ascending.\n *\n * Fields are stored in canonical alphabetical order, so without this an\n * editor shows them alphabetically: on a 16-field article model `author`\n * comes first and `title` fifteenth. Fractional values are allowed so a\n * field can be inserted between two others without renumbering the rest.\n *\n * Fields without one sort after every field that has one, alphabetically\n * among themselves — so a model that has not been given an order behaves\n * exactly as it does today.\n */\n order?: number\n}\n\n// ─── Model Definition ───\n\nexport type LocaleStrategy = 'file' | 'suffix' | 'directory' | 'none'\n\nexport interface ModelDefinition {\n id: string\n name: string\n kind: ModelKind\n domain: string\n i18n: boolean\n /**\n * The project locales this model's content actually covers — a subset of\n * `config.locales.supported`.\n *\n * Absent means every project locale, which is the behaviour every model had\n * before this field existed: parity is checked against the whole supported\n * list. Present, it narrows that check to the locales named here, so a\n * partially-translated site can declare the truth instead of failing.\n *\n * A locale not in `config.locales.supported` is a validation error — see\n * {@link validateModelLocales}. Meaningless on a non-i18n model, which has\n * only the default locale.\n *\n * It narrows validation; it never grants a locale. Writing content in a\n * locale outside this list is still governed by `config.locales.supported`.\n */\n locales?: string[]\n /**\n * Name of the field whose value is shown as an entry's title — in a Studio row,\n * a picker, a relation reference. Declared, never guessed: a consumer that infers\n * the title from field order or length picks the icon over the headline.\n *\n * Must name a field that exists on this model and whose type can render as text\n * (see `isTitleFieldType`). Dictionary models have no fields, so their only legal\n * value is `DICTIONARY_TITLE_FIELD` — the entry key is the title.\n */\n title_field: string\n description?: string\n fields?: Record<string, FieldDef>\n /** Framework-relative path for content files (e.g. \"content/blog\", \"locales\"). When set, content is written here instead of .contentrain/content/ */\n content_path?: string\n /** How locale is encoded in file names. Default: \"file\" ({dir}/{locale}.json or {dir}/{slug}/{locale}.md) */\n locale_strategy?: LocaleStrategy\n /**\n * Public form configuration for this model — which fields a visitor-facing\n * form exposes, honeypot, captcha, auto-approve. Owned and interpreted by the\n * runtime provider (Studio writes it through its model PATCH); the content\n * engine carries it verbatim and never reads it. A model edit that rebuilds\n * the definition must keep it, or the site's form silently stops working.\n */\n form?: Record<string, unknown>\n /** Public comments configuration for this model — same ownership and handling as `form`. */\n comments?: Record<string, unknown>\n}\n\n/** Model keys the content engine carries verbatim for the runtime provider — see `ModelDefinition.form`. */\nexport const MODEL_EXTENSION_KEYS = ['form', 'comments'] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Config ───\n\nexport interface ContentrainConfig {\n version: number\n platform?: Platform\n stack: StackType\n workflow: WorkflowMode\n repository?: {\n /**\n * Git host backing this project's remote. Widens as new providers ship\n * in @contentrain/mcp — `github` and `gitlab` are currently supported.\n * The value is informational for tooling: the concrete provider the\n * MCP server talks to is chosen at server construction time, not from\n * this config.\n */\n provider: 'github' | 'gitlab'\n owner: string\n name: string\n /**\n * Not informational: local writes advance this branch. A local\n * auto-merge merges it into `contentrain` before each write, then\n * fast-forwards it to the `contentrain` tip and pushes it. The\n * `CONTENTRAIN_BRANCH` env overrides it; without either, the base is\n * the remote's default branch, then `main`, then `master` — never\n * merely the checked-out branch.\n */\n default_branch: string\n }\n locales: {\n default: string\n supported: string[]\n }\n domains: string[]\n assets_path?: string\n /**\n * Public media delivery base for resolving relative `media/...` references to\n * absolute URLs (e.g. a deployed CDN delivery endpoint). When set,\n * `@contentrain/query`'s `generate` bakes a `media()` resolver into the\n * generated client. The base may already include any project segment — the\n * resolver only joins `{cdn.url}/{path}`. Distinct from `assets_path` (the\n * local on-disk asset directory).\n */\n cdn?: {\n url?: string\n }\n /** Days a merged `cr/*` branch is kept before lazy cleanup. Default: 30. */\n branchRetention?: number\n /** Unmerged `cr/*` branch count that triggers a warning. Default: 50. */\n branchWarnLimit?: number\n /** Unmerged `cr/*` branch count that blocks new writes. Default: 80. */\n branchBlockLimit?: number\n /**\n * Delete the remote copy of a `cr/*` branch when it is merged or deleted\n * locally, and allow lazy pruning of already-merged remote branches.\n * Read-only remote visibility (doctor, branch listing) is not affected.\n * Default: true.\n */\n remoteBranchCleanup?: boolean\n}\n\n// ─── Vocabulary ───\n\nexport interface Vocabulary {\n version: number\n terms: Record<string, Record<string, string>>\n}\n\n// ─── Metadata ───\n\nexport interface EntryMeta {\n status: ContentStatus\n source: ContentSource\n updated_by: string\n /**\n * When this entry was last written, ISO 8601 UTC.\n *\n * Optional, and deliberately not backfilled. It is a fact about the past,\n * and an entry written before the field existed has no recoverable value —\n * a fabricated timestamp would be worse than an absent one, because it\n * would sort. Absent means unknown; the first write mints the real value.\n */\n updated_at?: string\n approved_by?: string | null\n version?: string\n publish_at?: string\n expire_at?: string\n}\n\nexport type SingletonMeta = EntryMeta\nexport type CollectionMeta = Record<string, EntryMeta>\nexport type DocumentMeta = EntryMeta\nexport type DictionaryMeta = EntryMeta\n\n// ─── Asset ───\n\nexport interface AssetEntry {\n path: string\n type: string\n size: number\n alt?: string\n}\n\n// ─── Validation ───\n\nexport interface ValidationError {\n severity: 'error' | 'warning' | 'notice'\n model?: string\n locale?: string\n entry?: string\n slug?: string\n field?: string\n message: string\n}\n\nexport interface ValidationResult {\n valid: boolean\n errors: ValidationError[]\n}\n\n// ─── Content Types (Storage vs Output) ───\n\n// Storage formats — how content is written to disk (canonical JSON)\nexport type SingletonContentFile = Record<string, unknown>\nexport type CollectionContentFile = Record<string, Record<string, unknown>>\nexport type DictionaryContentFile = Record<string, string>\n// Document storage = markdown file (frontmatter + body), not JSON\n\n// Output formats — how MCP/SDK returns content to consumers\nexport type CollectionEntry = { id: string } & Record<string, unknown>\nexport type CollectionContentOutput = CollectionEntry[]\n\n/** Document entry as returned by MCP/SDK — parsed markdown with frontmatter */\nexport interface DocumentEntry {\n slug: string\n frontmatter: Record<string, unknown>\n body: string\n}\n\nexport type DocumentContentOutput = DocumentEntry[]\n\n/** Polymorphic relation storage — used when model field is string[] (multiple targets) */\nexport interface PolymorphicRelationRef {\n model: string\n ref: string\n}\n\n// ─── Model Summary ───\n\n/** Lightweight model info for listing and status operations */\nexport interface ModelSummary {\n id: string\n kind: ModelKind\n domain: string\n i18n: boolean\n fields: number\n}\n\n// ─── File Framework ───\n\n/** Source file framework — detected from extension for normalize/apply operations */\nexport type FileFramework = 'vue' | 'svelte' | 'jsx' | 'astro' | 'script'\n\n// ─── Path Conventions ───\n\n/** Root directory name for all Contentrain data */\nexport const CONTENTRAIN_DIR = '.contentrain' as const\n\n/**\n * Default path patterns for Contentrain projects.\n * Variables: {domain}, {modelId}, {locale}, {slug}\n *\n * When a model has `content_path` set, content files are written to\n * `{projectRoot}/{content_path}/` instead — but meta files always\n * remain under `.contentrain/meta/`.\n */\n/**\n * Where each kind of file lives. These are the paths a reader can rely on and\n * a writer must produce; `@contentrain/mcp` resolves exactly these, and a\n * parity test there holds the two together.\n *\n * They show the default `locale_strategy: 'file'`. The `suffix`, `directory`\n * and `none` strategies move the locale within the same directory — see\n * `LocaleStrategy` — and `content_path` replaces the\n * `.contentrain/content/{domain}/{modelId}` prefix entirely.\n *\n * Meta always carries a locale in its file name: a non-i18n model keeps one\n * meta record, pinned to the project's default locale.\n */\nexport const PATH_PATTERNS = {\n config: '.contentrain/config.json',\n context: '.contentrain/context.json',\n vocabulary: '.contentrain/vocabulary.json',\n model: '.contentrain/models/{modelId}.json',\n /** Detected source capabilities and the provider chosen for each — `CapabilityManifest`. */\n capabilities: '.contentrain/capabilities.json',\n /** Scheduled and event-driven agent work — `AutomationDefinition[]`. */\n automations: '.contentrain/automations.json',\n /** Which operations need whose approval — `ApprovalPolicyFile`. */\n approvalPolicies: '.contentrain/approval-policies.json',\n /** Source→destination URL map, the output of a migration's `moved` entries. */\n redirects: '.contentrain/redirects.json',\n content: {\n singleton: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n collection: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n document: '.contentrain/content/{domain}/{modelId}/{slug}/{locale}.md',\n dictionary: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n /** Non-i18n JSON content (i18n: false) */\n noLocale: '.contentrain/content/{domain}/{modelId}/data.json',\n /** Non-i18n document (i18n: false) */\n noLocaleDocument: '.contentrain/content/{domain}/{modelId}/{slug}.md',\n },\n meta: {\n singleton: '.contentrain/meta/{modelId}/{locale}.json',\n collection: '.contentrain/meta/{modelId}/{locale}.json',\n document: '.contentrain/meta/{modelId}/{slug}/{locale}.json',\n dictionary: '.contentrain/meta/{modelId}/{locale}.json',\n },\n} as const\n\n/**\n * Paths this repository reserves but does not yet write.\n *\n * Reserving them is the point. `.contentrain/` is a shared namespace: the\n * migration engine, Studio and a customer's own tooling all write into it. A\n * name claimed here cannot later be taken for something else, and — more\n * usefully — the tools that walk the directory know these four are *expected*\n * rather than stray.\n *\n * The contract for them is deliberately narrow and must stay that way until a\n * tool actually owns one:\n *\n * - `contentrain_doctor` and `contentrain_validate` ignore them. They are not\n * orphans, not broken content, and not the validator's business.\n * - `contentrain_reconcile` treats each as one opaque file: it takes the side\n * that changed, and reports `file_conflict` when both sides did. It never\n * merges their interiors, because it does not know their interiors.\n *\n * Both behaviours are pinned by tests in `@contentrain/mcp`, so a future\n * change to directory walking cannot quietly start flagging or auto-merging\n * these files.\n */\nexport const RESERVED_PATHS = [\n PATH_PATTERNS.capabilities,\n PATH_PATTERNS.automations,\n PATH_PATTERNS.approvalPolicies,\n PATH_PATTERNS.redirects,\n] as const\n\nexport function isReservedPath(path: string): boolean {\n return (RESERVED_PATHS as readonly string[]).includes(path)\n}\n\n// ─── Validation Patterns ───\n\n/** Slug: lowercase alphanumeric with hyphens */\nexport const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n/** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */\nexport const ENTRY_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/\n/** Locale: ISO 639-1 with optional region code */\nexport const LOCALE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2})?$/\n\n// ─── Canonical Serialization ───\n\n/** Rules for deterministic JSON output — ensures stable git diffs */\nexport const CANONICAL_JSON = {\n indent: 2,\n encoding: 'utf-8',\n trailingNewline: true,\n omitNull: true,\n omitDefaults: true,\n sortKeys: true,\n} as const\n\n/**\n * Key order for canonical model JSON — the required keys first, then the optional\n * ones, then `fields` last so a reader sees the model's identity before scrolling\n * past its schema.\n *\n * This lives here, and only here, because `canonicalStringify` falls back to\n * alphabetical for any key absent from the order: a forgotten entry does not error,\n * it silently relocates. `satisfies` catches a key that is not on `ModelDefinition`;\n * the `keyof` assertion in index.test.ts catches a `ModelDefinition` key missing\n * from this array. Between them, drift fails `tsc`, which runs on every commit.\n */\nexport const MODEL_FIELD_ORDER = [\n 'id',\n 'name',\n 'kind',\n 'domain',\n 'i18n',\n 'locales',\n 'title_field',\n 'description',\n 'content_path',\n 'locale_strategy',\n 'fields',\n 'form',\n 'comments',\n] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Scaffold ───\n\nexport interface ScaffoldTemplate {\n id: string\n models: ModelDefinition[]\n sample_content?: Record<string, Record<string, unknown>>\n vocabulary?: Record<string, Record<string, string>>\n}\n\n// ─── Scan Types ───\n\nexport type StringContext =\n | 'jsx_text' | 'jsx_attribute'\n | 'template_text' | 'template_attribute'\n | 'variable_assignment' | 'function_argument'\n | 'object_value' | 'other'\n\nexport type FileCategory = 'page' | 'component' | 'layout' | 'other'\n\nexport interface ScanCandidate {\n file: string\n line: number\n column: number\n value: string\n context: StringContext\n surrounding: string\n /** Content confidence score (0-1). Higher = more likely user-visible content. */\n contentScore: number\n /** All locations where this value appears (populated when dedup is enabled). */\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface DuplicateGroup {\n value: string\n count: number\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface GraphNode {\n file: string\n category: FileCategory\n components?: string[]\n imports: string[]\n used_by: string[]\n strings: number\n}\n\nexport interface ProjectGraph {\n pages: GraphNode[]\n components: GraphNode[]\n layouts: GraphNode[]\n orphan_files: string[]\n stats: {\n total_files: number\n total_components: number\n total_pages: number\n total_strings_estimate: number\n }\n}\n\nexport interface ScanCandidatesResult {\n candidates: ScanCandidate[]\n duplicates: DuplicateGroup[]\n stats: {\n files_scanned: number\n raw_strings_found: number\n skipped: number\n low_confidence: number\n unique_candidates: number\n candidates_returned: number\n has_more: boolean\n skip_reasons: Record<string, number>\n }\n}\n\nexport interface ScanSummaryResult {\n total_files: number\n total_candidates_estimate: number\n by_directory: Record<string, { files: number; candidates: number }>\n top_repeated: Array<{ value: string; count: number }>\n sampling_note?: string\n file_types: Record<string, number>\n}\n\n// ─── Normalize Plan ───\n\nexport interface NormalizePlan {\n version: number\n status: 'pending' | 'approved' | 'rejected'\n created_at: string\n approved_at?: string\n agent: string\n scan_stats: {\n files_scanned: number\n raw_strings: number\n candidates_sent: number\n extracted: number\n skipped: number\n }\n models: NormalizePlanModel[]\n extractions: NormalizePlanExtraction[]\n patches: NormalizePlanPatch[]\n approved_models?: string[]\n}\n\nexport interface NormalizePlanModel {\n id: string\n kind: ModelKind\n domain: string\n i18n?: boolean\n fields: Record<string, FieldDef>\n}\n\nexport interface NormalizePlanExtraction {\n value: string\n file: string\n line: number\n model: string\n field: string\n locale?: string\n}\n\nexport interface NormalizePlanPatch {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\n// ─── Context ───\n\nexport interface ContextJson {\n version: string\n lastOperation: {\n tool: string\n model: string\n locale: string\n entries?: string[]\n timestamp: string\n source: ContextSource\n }\n stats: {\n models: number\n entries: number\n locales: string[]\n lastSync: string\n }\n}\n\n// ─── Git Transaction Types ───\n\n/** Standard name for the dedicated content branch */\nexport const CONTENTRAIN_BRANCH = 'contentrain' as const\n\n/** Result of selective file sync to developer's working tree */\nexport interface SyncResult {\n /** Files successfully synced to working tree */\n synced: string[]\n /** Files skipped due to uncommitted local changes */\n skipped: string[]\n /** Human-readable warning if files were skipped */\n warning?: string\n}\n\n/** Structured error for git operations with actionable guidance */\nexport interface ContentrainError {\n /** Machine-readable error code */\n code: string\n /** Human-readable error message */\n message: string\n /** Guidance for AI agents on how to handle this error */\n agent_hint: string\n /** Command or action the developer should take */\n developer_action: string\n}\n\n// ─── Validate: Pure Functions ───\n\n/**\n * Patterns for detecting a credential that has been pasted into content.\n *\n * Each one matches a credential-shaped *value*, not prose that mentions\n * credentials. That distinction is the whole design: the earlier set matched\n * bare prefixes, so `task_id` and `risk_level` tripped `sk_`, every page\n * documenting `Authorization: Bearer <token>` tripped `Bearer `, and\n * `postgres://localhost:5432/mydb` in a getting-started guide tripped the\n * connection-string rule. On this repository — which stores its own\n * documentation as content — that was 7 of 12 validation errors, and the real\n * findings were buried under them.\n *\n * A check that fires on the wrong thing is not a strict check; it is a check\n * people learn to scroll past.\n *\n * Every pattern here carries a provider-specific shape — a fixed prefix, a\n * credential-only URL form, a PEM header — and is trusted on its own. The one\n * generic rule, `api_key = …`, has no such shape and lives in\n * {@link API_KEY_ASSIGNMENT}, where what it captures is checked before it fires.\n */\nexport const SECRET_PATTERNS: ReadonlyArray<RegExp> = [\n // Stripe-style keys: the prefix must start a word and carry a long body,\n // so `task_id` and `risk_level` no longer match.\n /\\b[sp]k_(?:live|test)_[A-Za-z0-9]{6,}/,\n // GitHub tokens are a fixed prefix plus a long body.\n /\\bgh[pousr]_[A-Za-z0-9]{10,}/,\n // A bearer token, not the word. Placeholders — <token>, $TOKEN, {{token}},\n // YOUR_TOKEN — are how documentation writes it, and are not secrets.\n /\\bBearer\\s+(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})[A-Za-z0-9._~+/-]{20,}/,\n /\\baws_secret[_a-z]*\\s*[:=]\\s*['\"`]?[A-Za-z0-9/+=]{20,}/i,\n // AWS access key IDs have a fixed shape.\n /\\bAKIA[0-9A-Z]{16}\\b/,\n // A connection string only carries a secret when it carries credentials;\n // `postgres://localhost:5432/mydb` in a guide does not.\n /\\b(?:postgres(?:ql)?|mysql|mongodb)(?:\\+srv)?:\\/\\/[^\\s:@/]+:[^\\s@/]+@/,\n // A pasted private key is unambiguous.\n /-----BEGIN [A-Z ]*PRIVATE KEY-----/,\n]\n\n/** Validate a slug — returns error message or null if valid */\nexport function validateSlug(slug: string): string | null {\n if (!slug) return 'Slug is required'\n if (!SLUG_PATTERN.test(slug)) return `Invalid slug \"${slug}\": must be kebab-case alphanumeric (a-z, 0-9, hyphens)`\n if (slug.startsWith('.') || slug.includes('..')) return `Invalid slug \"${slug}\": path traversal not allowed`\n return null\n}\n\n/** Validate an entry ID — returns error message or null if valid */\nexport function validateEntryId(id: string): string | null {\n if (!ENTRY_ID_PATTERN.test(id)) return `Invalid entry ID \"${id}\": must be 1-40 alphanumeric characters (hyphens and underscores allowed)`\n return null\n}\n\n/** Validate a locale code against pattern and config — returns error message or null if valid */\nexport function validateLocale(locale: string, config: ContentrainConfig): string | null {\n if (!LOCALE_PATTERN.test(locale)) return `Invalid locale \"${locale}\": must be ISO 639-1 format (e.g. \"en\", \"tr\", \"pt-BR\")`\n if (!config.locales.supported.includes(locale)) {\n return `Locale \"${locale}\" is not in supported locales [${config.locales.supported.join(', ')}]. Update config first.`\n }\n return null\n}\n\n/**\n * The locales one model's content is checked against, and where that list came\n * from — `'model'` when the model narrowed it with `locales`, `'project'` when\n * it is the project-wide list (or, for a non-i18n model, the default locale).\n *\n * The provenance travels with the list because every message derived from it\n * has to say which list it evaluated: \"missing tr\" means something different\n * when tr is one of four project locales than when the model declares it.\n */\nexport interface ModelLocaleScope {\n locales: string[]\n source: 'model' | 'project'\n}\n\n/**\n * The locales a model's content is expected to cover.\n *\n * A non-i18n model stores content locale-agnostically, so its scope is the\n * default locale alone — unchanged, and `locales` never applies to it. An i18n\n * model narrows to `model.locales` when it declares one, otherwise it covers\n * every supported locale, which is what every model did before the field\n * existed.\n *\n * Locales are returned in `config.locales.supported` order, not the order the\n * model happened to list them: a message that names the list should read the\n * same for two models that declare the same locales differently. Anything the\n * model names that the project does not support is dropped here and reported\n * by {@link validateModelLocales} — resolution never invents coverage.\n */\nexport function resolveModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ModelLocaleScope {\n if (!model.i18n) return { locales: [config.locales.default], source: 'project' }\n const declared = model.locales\n if (!Array.isArray(declared) || declared.length === 0) {\n return { locales: [...config.locales.supported], source: 'project' }\n }\n const narrowed = config.locales.supported.filter(l => declared.includes(l))\n if (narrowed.length === 0) return { locales: [...config.locales.supported], source: 'project' }\n return { locales: narrowed, source: 'model' }\n}\n\n/** Name a locale scope the way a validation message should quote it. */\nexport function describeModelLocaleScope(scope: ModelLocaleScope): string {\n const list = `[${scope.locales.join(', ')}]`\n return scope.source === 'model'\n ? `the model's own locales ${list}`\n : `the project's supported locales ${list}`\n}\n\n/**\n * Check a model's `locales` declaration against the project config.\n *\n * Absent is valid and means every supported locale, so this returns nothing for\n * the models that predate the field. What it rejects is a declaration that\n * cannot be honoured: a locale the project does not support (the coverage would\n * be unreachable), an empty list (a model that covers nothing is a deleted\n * model, not a narrowed one), a duplicate, or a non-string entry.\n *\n * Declaring `locales` on a non-i18n model is a warning, not an error: the field\n * has no effect there, but the model is still readable and its content still\n * validates, so failing the project over it would be out of proportion.\n */\nexport function validateModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ValidationError[] {\n if (model.locales === undefined) return []\n\n const errors: ValidationError[] = []\n const err = (message: string): void => { errors.push({ severity: 'error', field: 'locales', message }) }\n\n if (!Array.isArray(model.locales)) {\n err(`Invalid \"locales\": must be an array of locale codes, got ${typeof model.locales}.`)\n return errors\n }\n if (model.locales.length === 0) {\n err('Invalid \"locales\": an empty list covers nothing. Omit the field to cover every supported locale.')\n return errors\n }\n\n const seen = new Set<string>()\n for (const locale of model.locales) {\n if (typeof locale !== 'string') {\n err(`Invalid \"locales\": every entry must be a locale code, got ${typeof locale}.`)\n continue\n }\n if (seen.has(locale)) {\n err(`Invalid \"locales\": locale \"${locale}\" is listed more than once.`)\n continue\n }\n seen.add(locale)\n if (!config.locales.supported.includes(locale)) {\n err(\n `Invalid \"locales\": locale \"${locale}\" is not in the project's supported locales `\n + `[${config.locales.supported.join(', ')}]. A model's locales must be a subset — `\n + `add it to config.json first, or drop it here.`,\n )\n }\n }\n\n if (errors.length === 0 && !model.i18n) {\n errors.push({\n severity: 'warning',\n field: 'locales',\n message: '\"locales\" has no effect on a model with i18n:false — such a model stores one locale-agnostic copy. Remove it, or set i18n:true.',\n })\n }\n\n return errors\n}\n\n/**\n * An API key being assigned a value: `api_key = <tail>`.\n *\n * Generic by nature — there is no provider prefix, so the word plus a separator\n * is the only structural signal — and therefore the one rule that also has to\n * look at what it captured before it fires; see {@link looksLikeCredential}.\n * Without that, `api_key = your_project_api_key_here` in a setup guide was a\n * blocking error, and once `contentrain validate` exited non-zero, a permanent\n * CI failure for any project whose content discusses API keys — which, for a\n * CMS, is ordinary content. Placeholders documentation uses (`<key>`, `$KEY`,\n * `{{key}}`, `YOUR_KEY`, `xxx`, `…`) are excluded up front, as for `Bearer`.\n */\nconst API_KEY_ASSIGNMENT = /\\bapi[_-]?key\\s*[:=]\\s*['\"`]?(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})([A-Za-z0-9_-]{16,})/gi\n\n/**\n * Does a value assigned to an API-key setting look like a credential rather\n * than prose about one?\n *\n * Two shapes are documentation, not secrets: a tail with no digit at all\n * (`your_project_api_key_here`, `xxxxxxxxxxxxxxxxxxxx`), and a tail whose\n * `_`/`-` separated tokens are each purely letters or purely digits\n * (`my_api_key_2024`, `REPLACE-WITH-KEY-1`) — words with a version number. An\n * issued key mixes letters and digits inside one token (`8f14e45fceea167a`,\n * `k1p8-q7x2m9v4z3w6`), which no sentence does by accident.\n */\nexport function looksLikeCredential(tail: string): boolean {\n if (!/\\d/.test(tail)) return false\n const tokens = tail.split(/[_-]+/).filter(Boolean)\n if (tokens.length > 1 && tokens.every(t => /^[A-Za-z]+$/.test(t) || /^\\d+$/.test(t))) return false\n return true\n}\n\n/** Detect potential secrets in a value — returns validation errors if found */\nexport function detectSecrets(value: unknown): ValidationError[] {\n if (typeof value !== 'string') return []\n const found = SECRET_PATTERNS.some(p => p.test(value))\n || [...value.matchAll(API_KEY_ASSIGNMENT)].some(m => looksLikeCredential(m[1]!))\n if (found) {\n return [{ severity: 'error', message: 'Potential secret detected in value' }]\n }\n return []\n}\n\n/** The models a relation field may point into. */\nfunction relationTargets(fieldDef: FieldDef): string[] {\n if (Array.isArray(fieldDef.model)) return fieldDef.model\n return fieldDef.model ? [fieldDef.model] : []\n}\n\n/**\n * A reference into one of several models: `{ model, ref }`.\n *\n * A relation with more than one target model cannot store a bare id — the id\n * alone does not say which model's entry it is — so it stores the pair. With a\n * single target the id is enough and stays a string. A one-element `model`\n * array is a single target. This is the storage rule the schema documents and\n * the MCP entry validator enforces; a type check that knew only the string form\n * rejected every correctly stored polymorphic reference.\n */\nfunction isPolymorphicRef(value: unknown): value is { model: string, ref: string } {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n && typeof (value as { model?: unknown }).model === 'string'\n && typeof (value as { ref?: unknown }).ref === 'string'\n}\n\nfunction fieldTypeMatches(value: unknown, fieldDef: FieldDef): boolean {\n if (value === null || value === undefined) return true\n\n switch (fieldDef.type) {\n case 'string':\n case 'text':\n case 'email':\n case 'url':\n case 'slug':\n case 'color':\n case 'phone':\n case 'code':\n case 'icon':\n case 'markdown':\n case 'richtext':\n case 'date':\n case 'datetime':\n case 'image':\n case 'video':\n case 'file':\n case 'select':\n return typeof value === 'string'\n\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return typeof value === 'number'\n\n case 'boolean':\n return typeof value === 'boolean'\n\n case 'relation':\n return relationTargets(fieldDef).length > 1 ? isPolymorphicRef(value) : typeof value === 'string'\n\n case 'relations':\n case 'array':\n return Array.isArray(value)\n\n case 'object':\n return typeof value === 'object' && !Array.isArray(value)\n\n default:\n return true\n }\n}\n\nfunction checkMinMax(value: unknown, fieldDef: FieldDef): string | null {\n if (fieldDef.min !== undefined) {\n if (typeof value === 'number' && value < fieldDef.min) {\n return `Value ${value} is below minimum ${fieldDef.min}`\n }\n if (typeof value === 'string' && value.length < fieldDef.min) {\n return `String length ${value.length} is below minimum ${fieldDef.min}`\n }\n if (Array.isArray(value) && value.length < fieldDef.min) {\n return `Array length ${value.length} is below minimum ${fieldDef.min}`\n }\n }\n if (fieldDef.max !== undefined) {\n if (typeof value === 'number' && value > fieldDef.max) {\n return `Value ${value} is above maximum ${fieldDef.max}`\n }\n if (typeof value === 'string' && value.length > fieldDef.max) {\n return `String length ${value.length} is above maximum ${fieldDef.max}`\n }\n if (Array.isArray(value) && value.length > fieldDef.max) {\n return `Array length ${value.length} is above maximum ${fieldDef.max}`\n }\n }\n return null\n}\n\n// ─── Semantic type validation ───\n\n/** Hex (#rgb/#rrggbb/#rrggbbaa), rgb()/rgba(), hsl()/hsla(), or a bare CSS keyword. */\nconst COLOR_PATTERN = /^(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|hsl)a?\\([^)]*\\)|[a-zA-Z]+)$/\n/** Deliberately loose: digits, spaces, and the usual separators, 4-20 digits total. */\nconst PHONE_PATTERN = /^\\+?[\\d\\s().-]{4,}$/\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$/\n\nfunction isParsableDate(value: string): boolean {\n return !Number.isNaN(new Date(value).getTime())\n}\n\n/**\n * Rules a field type implies beyond its runtime `typeof`.\n *\n * Severity follows one rule: a check that is *definitional* is an error — a `slug`\n * that does not match `SLUG_PATTERN` is not a slug. A check that is a *heuristic* is\n * a warning, because the pattern is an approximation and a legitimate value may sit\n * outside it (international phone formats, CSS colour keywords).\n *\n * `rating` is deliberately absent: its scale is never declared, so any range here\n * would be invented. Declare `min`/`max` on the field instead.\n *\n * Returns `[]` for types whose only contract is their `typeof` (`string`, `text`,\n * `code`, `icon`, `markdown`, `richtext`, `number`, `decimal`, `boolean`, media).\n */\nexport function validateSemanticType(value: unknown, type: FieldType): ValidationError[] {\n if (typeof value === 'number') {\n if (type === 'integer' && !Number.isInteger(value)) {\n return [{ severity: 'error', message: `Value ${value} is not an integer` }]\n }\n if (type === 'percent' && (value < 0 || value > 100)) {\n return [{ severity: 'error', message: `Percent ${value} is outside 0-100` }]\n }\n return []\n }\n\n if (typeof value !== 'string' || value === '') return []\n\n switch (type) {\n case 'slug': {\n const err = validateSlug(value)\n return err ? [{ severity: 'error', message: err }] : []\n }\n case 'date':\n return DATE_PATTERN.test(value) && isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid date \"${value}\": must be YYYY-MM-DD` }]\n case 'datetime':\n return isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid datetime \"${value}\": must be an ISO 8601 date-time` }]\n case 'email':\n return EMAIL_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid email` }]\n case 'url':\n return /^https?:\\/\\/.+/.test(value) || value.startsWith('/')\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid URL` }]\n case 'color':\n return COLOR_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid color (expected hex, rgb(), hsl() or a keyword)` }]\n case 'phone':\n return PHONE_PATTERN.test(value) && (value.match(/\\d/g)?.length ?? 0) >= 4\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid phone number` }]\n default:\n return []\n }\n}\n\n/** Media field types — the ones whose value is a path/URL to an asset. */\nconst MEDIA_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\nexport function isMediaType(type: FieldType): boolean {\n return MEDIA_TYPES.has(type)\n}\n\n// ─── Field presentation ───\n\n/**\n * Resolve a field's label for a locale.\n *\n * Falls back in the order a reader would expect: the requested locale, the\n * default locale, any locale present, then the field name. A field with no\n * label resolves to its own name, which is what an editor shows today.\n */\nexport function resolveFieldLabel(\n fieldName: string,\n field: Pick<FieldDef, 'label'>,\n locale?: string,\n defaultLocale?: string,\n): string {\n const { label } = field\n if (typeof label === 'string') return label\n if (label && typeof label === 'object') {\n const byLocale = (locale && label[locale])\n ?? (defaultLocale && label[defaultLocale])\n ?? Object.values(label)[0]\n if (byLocale) return byLocale\n }\n return fieldName\n}\n\n/**\n * Field names in display order.\n *\n * Exported so every consumer orders identically instead of each reimplementing\n * the fallback. Ascending `order`; fields without one come last, alphabetically\n * among themselves, which is the behaviour a model that has no `order` already\n * has.\n */\nexport function orderedFieldNames(fields: Record<string, FieldDef> | undefined): string[] {\n if (!fields) return []\n return Object.keys(fields).toSorted((a, b) => {\n const oa = fields[a]?.order\n const ob = fields[b]?.order\n if (oa !== undefined && ob !== undefined && oa !== ob) return oa - ob\n if (oa !== undefined && ob === undefined) return -1\n if (oa === undefined && ob !== undefined) return 1\n return a.localeCompare(b, 'en')\n })\n}\n\n// ─── Title field ───\n\n/**\n * The reserved `title_field` value for dictionary models. A dictionary has no\n * fields — it is a flat key→string map — so there is nothing to point at. Its key\n * *is* the title, and this names that fact rather than leaving the property blank.\n * Only dictionaries may use it; on any other kind `title_field` must name a real field.\n */\nexport const DICTIONARY_TITLE_FIELD = 'key'\n\n/**\n * Field types whose value can stand in as a human-readable title.\n *\n * Narrower than \"types that hold a string\": `color`, `phone`, `icon`, `select`,\n * `date`, `datetime`, `relation` and the media types all store strings, but a row\n * labelled `#ff0000` or `f3a81c09d24e` is exactly the failure this contract exists\n * to prevent.\n */\nexport const TITLE_FIELD_TYPES = [\n 'string', 'text', 'slug', 'email', 'url', 'code', 'markdown', 'richtext',\n] as const satisfies readonly FieldType[]\n\nconst TITLE_FIELD_TYPE_SET = new Set<FieldType>(TITLE_FIELD_TYPES)\n\n/** Whether a field of this type may be named as a model's `title_field`. */\nexport function isTitleFieldType(type: FieldType): boolean {\n return TITLE_FIELD_TYPE_SET.has(type)\n}\n\n/** Extensions we can name a MIME type for. Deliberately small — this is a sniff. */\nconst EXTENSION_MIME: Record<string, string> = {\n jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',\n webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', ico: 'image/x-icon',\n mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',\n pdf: 'application/pdf', zip: 'application/zip', json: 'application/json',\n csv: 'text/csv', txt: 'text/plain',\n}\n\n/**\n * Check a media value's file extension against an `accept` list.\n *\n * This is an approximation and says so: the value is a path or URL, so the real\n * MIME type is only knowable where the bytes are — the provider, at ingest\n * (`provider.ts:MediaProvider.ingest` owns that policy). An extension can lie, and\n * an unknown extension is not evidence of a violation. Hence: warning, never error,\n * and silence when we cannot tell.\n *\n * `accept` follows the HTML input syntax: `image/jpeg`, `image/*`, `.jpg`, comma-separated.\n */\nexport function validateAccept(value: string, accept: string): ValidationError[] {\n const path = value.split('?')[0]?.split('#')[0] ?? value\n const ext = path.split('.').pop()?.toLowerCase()\n if (!ext || ext === path.toLowerCase()) return []\n\n const mime = EXTENSION_MIME[ext]\n const patterns = accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean)\n if (patterns.length === 0) return []\n\n const matches = patterns.some((p) => {\n if (p.startsWith('.')) return p.slice(1) === ext\n if (p.endsWith('/*')) return mime ? mime.startsWith(p.slice(0, -1)) : false\n return mime === p\n })\n if (matches) return []\n\n // An extension we have no MIME for is not evidence of a violation.\n if (!mime && !patterns.some(p => p.startsWith('.'))) return []\n\n return [{\n severity: 'warning',\n message: `File extension \".${ext}\" does not match accept \"${accept}\" (extension check — the provider enforces the real MIME type at ingest)`,\n }]\n}\n\n/**\n * Validate a field value against its FieldDef schema.\n * Checks: required, type match, semantic type rules, min/max, pattern, select\n * options, and `accept` on media fields (by extension).\n * Does NOT check: secrets (use detectSecrets), uniqueness (stateful), relations (I/O),\n * or `maxSize` — byte length is not knowable from a stored path.\n */\nexport function validateFieldValue(value: unknown, fieldDef: FieldDef): ValidationError[] {\n const issues: ValidationError[] = []\n\n if (fieldDef.required && (value === undefined || value === null || value === '')) {\n issues.push({ severity: 'error', message: 'Required field is missing or empty' })\n return issues\n }\n\n if (value === undefined || value === null) return issues\n\n if (!fieldTypeMatches(value, fieldDef)) {\n issues.push({ severity: 'error', message: `Type mismatch: expected ${fieldDef.type}, got ${typeof value}` })\n return issues\n }\n\n issues.push(...validateSemanticType(value, fieldDef.type))\n\n if (fieldDef.type === 'relation' && isPolymorphicRef(value)) {\n const targets = relationTargets(fieldDef)\n if (!targets.includes(value.model)) {\n issues.push({ severity: 'error', message: `Relation target model \"${value.model}\" is not one of: ${targets.join(', ')}` })\n }\n }\n\n if (fieldDef.accept && isMediaType(fieldDef.type) && typeof value === 'string') {\n issues.push(...validateAccept(value, fieldDef.accept))\n }\n\n const minMaxErr = checkMinMax(value, fieldDef)\n if (minMaxErr) {\n issues.push({ severity: 'error', message: minMaxErr })\n }\n\n if (fieldDef.pattern && typeof value === 'string') {\n try {\n const regex = new RegExp(fieldDef.pattern)\n if (!regex.test(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" does not match pattern /${fieldDef.pattern}/` })\n }\n } catch {\n issues.push({ severity: 'warning', message: `Invalid pattern regex: ${fieldDef.pattern}` })\n }\n }\n\n if (fieldDef.type === 'select' && fieldDef.options && typeof value === 'string') {\n if (!fieldDef.options.includes(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" is not in allowed options: [${fieldDef.options.join(', ')}]` })\n }\n }\n\n return issues\n}\n\n// ─── Serialize: Pure Functions ───\n//\n// Implemented in `canonical.ts` and re-exported here: `execution.ts` hashes\n// plans with `canonicalStringify`, and this module re-exports `execution.ts`,\n// so the implementation has to sit outside the cycle.\n\nexport { canonicalStringify, sortKeys } from './canonical.js'\n\n/** Generate a 12-character hex entry ID */\nexport function generateEntryId(): string {\n const hex = '0123456789abcdef'\n let id = ''\n for (let i = 0; i < 12; i++) {\n id += hex[Math.floor(Math.random() * 16)]\n }\n return id\n}\n\n// ─── Reconcile: conflict id derivation ───\n\n/**\n * FNV-1a 64-bit over the input's UTF-8 code points, returned as 16 hex\n * chars. Deliberately dependency-free (no `node:crypto` — this package has\n * zero runtime imports and runs anywhere). Not cryptographic; the use case\n * is change detection, not tamper resistance.\n */\nexport function stableHash(input: string): string {\n const PRIME = 0x100000001B3n\n const MASK = 0xFFFFFFFFFFFFFFFFn\n let hash = 0xCBF29CE484222325n\n const bytes = new TextEncoder().encode(input)\n for (const byte of bytes) {\n hash ^= BigInt(byte)\n hash = (hash * PRIME) & MASK\n }\n return hash.toString(16).padStart(16, '0')\n}\n\n/**\n * Identity of a reconcile conflict: position AND values. Hashing the three\n * values into the id makes every resolution a compare-and-set — if any side\n * changes between the dry-run that produced the conflict and the apply that\n * carries the decision, the id no longer matches, the stale resolution is\n * dropped, and the conflict is re-reported with a fresh id. Values are\n * normalized through `sortKeys` first so semantically equal objects hash\n * equal regardless of key order; the positional array keeps absent and\n * null distinguishable per slot.\n */\nexport function conflictId(conflict: {\n path: string\n key?: string\n field?: string\n locale?: string\n base?: unknown\n ours?: unknown\n theirs?: unknown\n}): string {\n return stableHash(JSON.stringify([\n conflict.path,\n conflict.key ?? null,\n conflict.field ?? null,\n conflict.locale ?? null,\n sortKeys(conflict.base) ?? null,\n sortKeys(conflict.ours) ?? null,\n sortKeys(conflict.theirs) ?? null,\n ]))\n}\n\n// ─── Markdown Frontmatter ───\n\n/**\n * Decode the interior of a double-quoted scalar, or `undefined` when the text\n * is not one scalar after all — an unescaped `\"` inside it, or a backslash with\n * nothing after it.\n *\n * Returning `undefined` rather than guessing matters: `\"a\" and \"b\"` starts and\n * ends with a quote without being a quoted scalar, and slicing the ends off it\n * would silently corrupt the value.\n *\n * An unrecognised escape keeps its backslash instead of erroring. Our own\n * serializer only ever emits `\\\\`, `\\\"`, `\\n`, `\\r` and `\\t`, so round-tripping is\n * unaffected either way — but hand-written frontmatter says `\"C:\\Users\"` and\n * losing that to strictness would be a worse trade than keeping the bytes.\n */\nfunction decodeDoubleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char === '\"') return undefined\n if (char !== '\\\\') {\n out += char\n continue\n }\n const next = inner[i + 1]\n if (next === undefined) return undefined\n i += 1\n if (next === 'n') out += '\\n'\n else if (next === 'r') out += '\\r'\n else if (next === 't') out += '\\t'\n else if (next === '\\\\' || next === '\"' || next === '/') out += next\n else if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 1, i + 5))) {\n out += String.fromCharCode(parseInt(inner.slice(i + 1, i + 5), 16))\n i += 4\n } else out += `\\\\${next}`\n }\n return out\n}\n\n/** Decode a single-quoted scalar, where YAML escapes a quote by doubling it. */\nfunction decodeSingleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char !== \"'\") {\n out += char\n continue\n }\n if (inner[i + 1] !== \"'\") return undefined\n out += \"'\"\n i += 1\n }\n return out\n}\n\n/**\n * Read one frontmatter scalar: booleans, null, numbers, quoted strings with\n * their escapes decoded, or the text as written.\n *\n * Exported because it is a **contract**, not a utility. Two readers open these\n * documents — this package (through `parseMarkdownFrontmatter`, which\n * `@contentrain/mcp` re-exports) and `@contentrain/query`'s generator and Astro\n * loader. When each had its own copy they disagreed about what `\"He said\n * \\\"Hi\\\"\"` says, which means the generated client and the content engine\n * disagreed about the same file. One implementation, imported by both.\n */\nexport function parseFrontmatterScalar(raw: string): unknown {\n if (raw === 'true') return true\n if (raw === 'false') return false\n if (raw === 'null') return null\n if (/^-?\\d+$/.test(raw)) return parseInt(raw, 10)\n if (/^-?\\d+\\.\\d+$/.test(raw)) return parseFloat(raw)\n // Quoted scalars carry escapes. Stripping the quotes without decoding them is\n // how `He said \\\"Hi\\\"` came back with its backslashes, and how a value with a\n // backslash in it doubled them on every export.\n if (raw.length >= 2 && raw.startsWith('\"') && raw.endsWith('\"')) {\n return decodeDoubleQuoted(raw.slice(1, -1)) ?? raw\n }\n if (raw.length >= 2 && raw.startsWith(\"'\") && raw.endsWith(\"'\")) {\n return decodeSingleQuoted(raw.slice(1, -1)) ?? raw\n }\n return raw\n}\n\n/**\n * A scalar read as a string: quoting and escapes are resolved, but a value that\n * merely looks like a number or a boolean stays the text it was. Array items\n * use this, and so does any reader that knows from the model that a field is\n * string-typed — a SKU of `\"007\"` must not become the number 7.\n */\nexport function parseFrontmatterScalarString(raw: string): string {\n const value = parseFrontmatterScalar(raw)\n return typeof value === 'string' ? value : raw\n}\n\n/**\n * Split an inline array's interior on commas that are not inside a quoted\n * item — `[\"a, b\", c]` is two items, not three. Exported for the same reason\n * as {@link parseFrontmatterScalar}: both readers must split it the same way.\n */\nexport function splitFrontmatterList(inner: string): string[] {\n const items: string[] = []\n let current = ''\n let quote: '\"' | \"'\" | undefined\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (quote) {\n if (char === '\\\\' && quote === '\"' && i + 1 < inner.length) {\n current += char + inner[i + 1]!\n i += 1\n continue\n }\n current += char\n if (char === quote) quote = undefined\n continue\n }\n if (char === '\"' || char === \"'\") {\n quote = char\n current += char\n continue\n }\n if (char === ',') {\n items.push(current)\n current = ''\n continue\n }\n current += char\n }\n items.push(current)\n return items\n}\n\nfunction yamlScalar(value: unknown): string {\n const str = String(value)\n // A newline, a tab or edge whitespace cannot survive a bare scalar: the\n // second line of a multi-line value was being written straight into the\n // frontmatter, where the reader saw a line that is not `key: value` and\n // skipped it — the value was silently truncated at its first newline. Edge\n // whitespace was lost to the reader's `trim()` for the same reason.\n if (str.includes(':') || str.includes('#') || str.includes('{') || str.includes('}')\n || str.includes('[') || str.includes(']') || str.includes('*') || str.includes('&')\n || str.includes('!') || str.includes('|') || str.includes('>') || str.includes(\"'\")\n || str.includes('\"') || str.includes('%') || str.includes('@') || str.includes('`')\n || str.includes('\\\\') || str.includes('\\n') || str.includes('\\r') || str.includes('\\t')\n || str.startsWith('-') || str.startsWith('?') || str !== str.trim()\n || str === ''\n // A string whose bare form would read back as something else. `'42'` was\n // written bare and returned as the number 42 — a slug, a version or a title\n // that happens to be digits changed type on every save. Asking the reader\n // is exact where a list of shapes drifts away from it.\n //\n // The type check is the other half. The old rule quoted anything spelled\n // `true`, so a real boolean was written as `\"true\"` and came back a string.\n // Only a *string* that would be misread needs the quotes; a boolean, a\n // number or a null is written bare precisely so it reads back as itself.\n // `yes`/`no` are not keywords to this reader but are to others, so a string\n // spelled that way is quoted for the benefit of whoever else opens the file.\n || (typeof value === 'string' && (parseFrontmatterScalar(str) !== str || str === 'yes' || str === 'no'))) {\n return `\"${str\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/\\t/g, '\\\\t')}\"`\n }\n return str\n}\n\nfunction serializeYamlValue(value: unknown, indent: number): string[] {\n const pad = ' '.repeat(indent)\n if (value === null || value === undefined) return []\n if (typeof value === 'object' && !Array.isArray(value)) {\n const lines: string[] = []\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n if (v === null || v === undefined) continue\n if (typeof v === 'object' && !Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n lines.push(...serializeYamlValue(v, indent + 1))\n } else if (Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n for (const item of v) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 2)\n if (nested.length > 0) {\n lines.push(`${pad} - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad} - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${pad}${k}: ${yamlScalar(v)}`)\n }\n }\n return lines\n }\n if (Array.isArray(value)) {\n const lines: string[] = []\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 1)\n if (nested.length > 0) {\n lines.push(`${pad}- ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad}- ${yamlScalar(item)}`)\n }\n }\n return lines\n }\n return [`${pad}${yamlScalar(value)}`]\n}\n\nfunction serializeYamlFields(data: Record<string, unknown>): string[] {\n const lines: string[] = []\n for (const [key, value] of Object.entries(data)) {\n if (key === 'body') continue\n if (value === null || value === undefined) continue\n if (typeof value === 'object' && !Array.isArray(value)) {\n lines.push(`${key}:`)\n lines.push(...serializeYamlValue(value, 1))\n } else if (Array.isArray(value)) {\n // A bare `key:` is genuinely ambiguous — empty array, empty object, or\n // null — and two readers guessed it differently. Writing `[]` says which\n // one it is instead of asking anyone to infer it from the next line.\n if (value.length === 0) {\n lines.push(`${key}: []`)\n continue\n }\n lines.push(`${key}:`)\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, 2)\n if (nested.length > 0) {\n lines.push(` - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(` - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${key}: ${yamlScalar(value)}`)\n }\n }\n return lines\n}\n\n/** Parse markdown frontmatter — extracts YAML frontmatter and body from a markdown string */\nexport function parseMarkdownFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {\n const normalized = content.replace(/\\r\\n/g, '\\n')\n const match = normalized.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/)\n if (!match) return { frontmatter: {}, body: normalized }\n\n const frontmatterStr = match[1]!\n const body = match[2]!.trim()\n const frontmatter: Record<string, unknown> = {}\n\n let currentKey: string | null = null\n let currentArray: string[] | null = null\n\n for (const line of frontmatterStr.split('\\n')) {\n if (/^\\s+-\\s+/.test(line) && currentKey) {\n const value = parseFrontmatterScalarString(line.replace(/^\\s+-\\s+/, '').trim())\n if (!currentArray) currentArray = []\n currentArray.push(value)\n continue\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n currentKey = null\n currentArray = null\n }\n\n const kvMatch = line.match(/^([\\w][\\w.-]*)\\s*:\\s*(.*)$/)\n if (!kvMatch) continue\n\n const key = kvMatch[1]!\n const rawValue = kvMatch[2]!.trim()\n\n if (rawValue === '') {\n currentKey = key\n currentArray = []\n continue\n }\n\n if (rawValue.startsWith('[') && rawValue.endsWith(']')) {\n const inner = rawValue.slice(1, -1).trim()\n frontmatter[key] = inner === '' ? [] : splitFrontmatterList(inner).map(item => parseFrontmatterScalarString(item.trim()))\n continue\n }\n\n frontmatter[key] = parseFrontmatterScalar(rawValue)\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n }\n\n return { frontmatter, body }\n}\n\n/** Serialize data + body into markdown frontmatter format */\nexport function serializeMarkdownFrontmatter(data: Record<string, unknown>, body: string): string {\n const trimmedBody = body.trimStart()\n if (trimmedBody.startsWith('---')) {\n const endIdx = trimmedBody.indexOf('---', 3)\n if (endIdx > 0) {\n const bodyFmContent = trimmedBody.slice(3, endIdx).trim()\n const afterFm = trimmedBody.slice(endIdx + 3)\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n if (bodyFmContent) {\n const modelKeys = new Set(Object.keys(data))\n for (const line of bodyFmContent.split('\\n')) {\n if (line.length > 0 && line[0] !== ' ' && line[0] !== '-') {\n const colonIdx = line.indexOf(':')\n const lineKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : ''\n if (lineKey && modelKeys.has(lineKey)) continue\n }\n lines.push(line)\n }\n }\n lines.push('---')\n if (afterFm.trim()) {\n lines.push(afterFm.trimStart())\n }\n lines.push('')\n return lines.join('\\n')\n }\n }\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n lines.push('---')\n lines.push('')\n if (body) {\n lines.push(body)\n lines.push('')\n }\n return lines.join('\\n')\n}\n\n// ─── Migration contracts ───\n\nexport * from './migration.js'\n\n// ─── Execution, approval and source-delta contracts ───\n\nexport * from './execution.js'\n\n// ─── Approval evaluator ───\n//\n// The decision procedure over the contracts above: risk + scope + policy →\n// what must be approved, and whether it has been. Pure and dependency-free, so\n// Studio, a CLI and a test all reach the same answer.\n\nexport * from './approval.js'\n\n// ─── Migrate → Studio claim ───\n//\n// The signed hand-off from a paid Migrate order to a Studio trial: payload,\n// constants and a pure validator. Signing and verification (EdDSA) live in\n// the two apps; both bind to this one shape.\n\nexport * from './migrate-claim.js'\n\n// ─── Repository provider contracts ───\n//\n// Provider-agnostic engine contracts used by @contentrain/mcp. Exposed from\n// @contentrain/types so third-party tools can implement a custom\n// RepoProvider without taking a dependency on @contentrain/mcp.\nexport type {\n ApplyPlanInput,\n BaseAdvance,\n Branch,\n Commit,\n CommitAuthor,\n ConflictCode,\n ConflictItem,\n ConflictResolution,\n FileChange,\n FileDiff,\n MediaAsset,\n MediaIngestInput,\n MediaListOptions,\n MediaListResult,\n MediaProvider,\n MediaUpdateInput,\n MergeResult,\n ProviderCapabilities,\n RemotePush,\n RepoProvider,\n RepoReader,\n RepoWriter,\n WriteReadiness,\n} from './provider.js'\nexport { LOCAL_CAPABILITIES } from './provider.js'\n\n// Implemented in `media.ts` and re-exported here: both `@contentrain/mcp`\n// (write-time normalization) and `@contentrain/query` (read-time resolution)\n// share this single rewrite engine, so the rules can only be defined once.\nexport {\n isStoredMediaPath,\n rewriteEntryMedia,\n rewriteFieldMedia,\n rewriteMarkdownMedia,\n rewriteMediaUrl,\n toDeliveryUrl,\n} from './media.js'\n"],"mappings":"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"}
|