🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@contentrain/mcp

Package Overview
Dependencies
Maintainers
1
Versions
35
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@contentrain/mcp - npm Package Compare versions

Comparing version
1.11.0
to
2.0.0
+756
dist/apply-manager-BpKGvXmn.mjs
import { c as writeText, o as readText, r as pathExists } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { S as writeContent, _ as resolveMdFilePath, c as validateModelDefinition, h as resolveJsonFilePath, l as writeModel, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-Rcj5Wzct.mjs";
import { r as writeContext } from "./context-Cppz4R65.mjs";
import { n as checkBranchHealth } from "./branch-lifecycle-BAfgSQBv.mjs";
import { n as createTransaction, t as buildBranchName } from "./transaction-C1P-WnVo.mjs";
import { extname, join } from "node:path";
//#region src/core/apply-manager.ts
const MAX_PATCHES = 100;
/** File extensions allowed for patching — scannable source files only */
const PATCHABLE_EXTENSIONS = new Set([
".vue",
".tsx",
".jsx",
".ts",
".js",
".mjs",
".astro",
".svelte"
]);
/** Directories that must never be patched */
const FORBIDDEN_PATH_SEGMENTS = new Set([
".contentrain",
"node_modules",
".git",
"dist",
"build",
".next",
".nuxt"
]);
function detectFileFramework(filePath) {
switch (extname(filePath).toLowerCase()) {
case ".vue": return "vue";
case ".svelte": return "svelte";
case ".tsx":
case ".jsx": return "jsx";
case ".astro": return "astro";
case ".ts":
case ".js":
case ".mjs": return "script";
default: return "script";
}
}
/**
* Validate that a replacement expression uses the correct template syntax
* for the target file's framework. Returns a warning string or null.
*/
function validateFrameworkExpression(filePath, newExpression, context) {
if (context !== "tag_text") return null;
switch (detectFileFramework(filePath)) {
case "vue":
if (!newExpression.includes("{{")) return `Vue file "${filePath}": tag text expression "${newExpression}" does not contain "{{" — expected Vue template syntax like {{ $t('key') }}`;
break;
case "jsx":
if (!newExpression.includes("{")) return `JSX file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected JSX syntax like {t('key')}`;
break;
case "svelte":
if (!newExpression.includes("{")) return `Svelte file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Svelte syntax like {$t('key')}`;
break;
case "astro":
if (!newExpression.includes("{")) return `Astro file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Astro syntax like {t('key')}`;
break;
case "script": return `Script file "${filePath}": tag text replacement not applicable for .ts/.js files`;
}
return null;
}
/**
* Validate that a patch file path is safe and within allowed scope.
* Returns an error message or null if valid.
*/
function validatePatchPath(filePath) {
const normalizedPath = filePath.replace(/\\/g, "/");
if (normalizedPath.includes("..")) return `Path traversal detected: "${filePath}"`;
if (normalizedPath.startsWith("/")) return `Absolute path not allowed: "${filePath}"`;
const segments = normalizedPath.split("/");
for (const seg of segments) if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return `Patching files inside "${seg}/" is not allowed: "${filePath}"`;
const ext = extname(filePath).toLowerCase();
if (!PATCHABLE_EXTENSIONS.has(ext)) return `File extension "${ext}" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(", ")}. Path: "${filePath}"`;
return null;
}
/**
* Perform a basic syntax check on a patched file.
* Returns an error message or null if syntax appears valid.
*/
function checkSyntax(filePath, content) {
switch (extname(filePath).toLowerCase()) {
case ".ts":
case ".tsx":
case ".js":
case ".jsx":
case ".mjs": return checkJsSyntax(content);
case ".vue": return checkVueSyntax(content);
case ".svelte":
case ".astro": return checkTagBalance(content);
default: return null;
}
}
/**
* Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.
* This is intentionally conservative — it catches obvious breakage without
* requiring a full parser.
*/
function checkJsSyntax(content) {
const stack = [];
const pairs = {
")": "(",
"]": "[",
"}": "{"
};
let inString = null;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < content.length; i++) {
const ch = content[i];
const next = content[i + 1];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\" && inString !== null) {
escaped = true;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "/") {
inLineComment = true;
continue;
}
if (inLineComment) {
if (ch === "\n") inLineComment = false;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString !== null) {
if (ch === inString) inString = null;
else if (inString !== "`" && ch === "\n") return `Unterminated string literal near offset ${i}`;
continue;
}
if (ch === "\"" || ch === "'" || ch === "`") {
inString = ch;
continue;
}
if (ch === "(" || ch === "[" || ch === "{") stack.push(ch);
else if (ch === ")" || ch === "]" || ch === "}") {
const expected = pairs[ch];
if (stack.length === 0) return `Unmatched closing "${ch}" near offset ${i}`;
const top = stack.pop();
if (top !== expected) return `Mismatched bracket: expected closing for "${top}" but found "${ch}" near offset ${i}`;
}
}
if (inString !== null) return `Unterminated string literal (opened with ${inString})`;
if (stack.length > 0) return `Unclosed bracket "${stack[stack.length - 1]}" — ${stack.length} unclosed bracket(s)`;
return null;
}
/**
* Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.
*/
function checkVueSyntax(content) {
const tagBalance = checkTagBalance(content);
if (tagBalance) return tagBalance;
for (const tag of ["template", "script"]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
/**
* Basic tag balance check for HTML-like files.
* Checks that self-closing tags are handled and major structural tags are balanced.
*/
function checkTagBalance(content) {
for (const tag of [
"div",
"section",
"main",
"header",
"footer",
"nav",
"article",
"aside",
"ul",
"ol",
"table"
]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
async function applyExtract(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { extractions, dry_run } = input;
const existingModels = await listModels(projectRoot);
const existingIds = new Set(existingModels.map((m) => m.id));
const modelsToCreate = [];
const modelsToUpdate = [];
const contentFiles = [];
let totalEntries = 0;
const validationErrors = [];
for (const ext of extractions) {
const modelErrors = validateModelDefinition({
id: ext.model,
kind: ext.kind,
fields: ext.fields
});
if (modelErrors.errors.length > 0) validationErrors.push(...modelErrors.errors.map((e) => `[${ext.model}] ${e}`));
for (const entry of ext.entries) if (ext.kind === "dictionary") {
if (entry.data["id"] !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`);
for (const [key, val] of Object.entries(entry.data)) if (typeof val !== "string") validationErrors.push(`[${ext.model}] Dictionary entry value for key "${key}" must be a string, got ${typeof val}`);
} else if (ext.kind === "document") {
if (!entry.slug && !entry.data["slug"]) validationErrors.push(`[${ext.model}] Document entries must have a slug`);
} else if (ext.kind === "collection") {
if (entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Collection entries should not have slug`);
} else if (ext.kind === "singleton") {
if (entry.data["id"] !== void 0 || entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`);
}
if (existingIds.has(ext.model)) modelsToUpdate.push(ext.model);
else modelsToCreate.push(ext.model);
totalEntries += ext.entries.length;
let previewModel;
if (existingIds.has(ext.model)) {
const real = await readModel(projectRoot, ext.model);
if (real) previewModel = real;
else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
} else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
const cDir = resolveContentDir(projectRoot, previewModel);
for (const entry of ext.entries) {
const locale = entry.locale ?? config.locales.default;
if (ext.kind === "document" && entry.slug) contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug));
else contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale));
}
}
const preview = {
models_to_create: modelsToCreate,
models_to_update: modelsToUpdate,
total_entries: totalEntries,
content_files: [...new Set(contentFiles)]
};
if (dry_run !== false) return {
dry_run: true,
preview,
...validationErrors.length > 0 ? { validation_errors: validationErrors } : {},
next_steps: [
...validationErrors.length > 0 ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`] : [],
"Review the preview above",
"Call contentrain_apply with mode:extract and dry_run:false to execute"
]
};
if (validationErrors.length > 0) return {
dry_run: false,
error: "Model validation failed — cannot execute extract with invalid model definitions",
validation_errors: validationErrors,
next_steps: ["Fix the validation errors and retry"]
};
const health = await checkBranchHealth(projectRoot);
if (health.blocked) return {
error: health.message,
action: "blocked",
hint: "Merge or delete old contentrain/* branches before executing normalize."
};
const branchName = buildBranchName("normalize", "extract");
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const sourceMap = [];
const modelsCreated = [];
const modelsUpdated = [];
let entriesWritten = 0;
try {
await tx.write(async (wt) => {
for (const ext of extractions) {
const existing = await readModel(wt, ext.model);
if (existing) {
if (ext.fields) {
const merged = {
...existing.fields,
...ext.fields
};
if (Object.keys(ext.fields).filter((k) => !(k in (existing.fields ?? {}))).length > 0) {
existing.fields = merged;
await writeModel(wt, existing);
modelsUpdated.push(ext.model);
}
}
} else {
await writeModel(wt, {
id: ext.model,
name: ext.model.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true,
fields: ext.fields
});
modelsCreated.push(ext.model);
}
const model = await readModel(wt, ext.model);
const entries = ext.entries.map((e) => ({
locale: e.locale,
slug: e.slug,
data: e.data
}));
await writeContent(wt, model, entries, await readConfig(wt) ?? config);
entriesWritten += entries.length;
for (const entry of ext.entries) if (ext.kind === "dictionary" && entry.sources) for (const s of entry.sources) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: s.value,
file: s.file,
line: s.line
});
else if (entry.source) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: entry.source.value,
file: entry.source.file,
line: entry.source.line
});
}
if (sourceMap.length > 0) {
const sourcesByModel = {};
for (const s of sourceMap) {
if (!sourcesByModel[s.model]) sourcesByModel[s.model] = {
source_files: [],
entry_count: 0
};
const modelEntry = sourcesByModel[s.model];
if (!modelEntry.source_files.includes(s.file)) modelEntry.source_files.push(s.file);
modelEntry.entry_count++;
}
const sourcesJson = JSON.stringify({
version: 1,
created_at: (/* @__PURE__ */ new Date()).toISOString(),
models: sourcesByModel
}, null, 2) + "\n";
await writeText(join(wt, ".contentrain", "normalize-sources.json"), sourcesJson);
}
await writeContext(wt, {
tool: "contentrain_apply",
model: extractions.map((e) => e.model).join(","),
locale: config.locales.default,
entries: extractions.flatMap((e) => e.entries.map((en) => en.slug ?? "entry"))
});
});
const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
models_created: modelsCreated,
models_updated: modelsUpdated,
entries_written: entriesWritten,
source_map: sourceMap
},
git: gitResult,
context_updated: true,
next_steps: [
"Run contentrain_validate to check the extracted content",
"Run contentrain_submit to push the branch for review",
"For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize",
"For terminal workflow: use contentrain_merge to merge the branch locally",
"After merge, run `npx contentrain generate` to update SDK client",
"After review, proceed with mode:reuse to patch source files"
]
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
async function applyReuse(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { scope, patches, dry_run } = input;
if (!scope.model && !scope.domain) throw new Error("Scope required: provide model or domain. Whole-project patching is not allowed.");
if (patches.length > MAX_PATCHES) throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`);
if (scope.model) {
if (!await readModel(projectRoot, scope.model)) throw new Error(`Model "${scope.model}" not found. Run extract phase first.`);
}
const scopeWarnings = [];
for (const patch of patches) {
const pathError = validatePatchPath(patch.file);
if (pathError) throw new Error(`Invalid patch path: ${pathError}`);
}
if (scope.model || scope.domain) {
const models = await listModels(projectRoot);
const scopeModels = scope.model ? models.filter((m) => m.id === scope.model) : scope.domain ? models.filter((m) => m.domain === scope.domain) : models;
if (scopeModels.length === 0) throw new Error(`No models found for scope ${scope.model ? `model="${scope.model}"` : `domain="${scope.domain}"`}`);
const { autoDetectSourceDirs } = await import("./core/scan-config.mjs");
const sourceDirs = await autoDetectSourceDirs(projectRoot);
const allowedPrefixes = sourceDirs.map((d) => d === "." ? "" : d + "/");
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (normalizedPath.startsWith(".contentrain/") || normalizedPath.includes("/.contentrain/")) throw new Error(`Cannot patch content/config files directly: "${patch.file}". Reuse patches source files only.`);
if (sourceDirs.length > 0 && sourceDirs[0] !== ".") {
if (!allowedPrefixes.some((prefix) => prefix === "" || normalizedPath.startsWith(prefix))) throw new Error(`Patch file "${patch.file}" is outside detected source directories (${sourceDirs.join(", ")}). Reuse patches must target source files within the project's source tree.`);
}
}
if (scope.model || scope.domain) {
const sourcesRaw = await readText(join(projectRoot, ".contentrain", "normalize-sources.json"));
if (!sourcesRaw) if (scopeModels.find((m) => m.id === scope.model)?.kind === "dictionary") scopeWarnings.push("normalize-sources.json not found. Dictionary models do not generate per-file source maps. Scope enforcement is based on source-tree locality only.");
else if (dry_run !== false) scopeWarnings.push("normalize-sources.json not found. Semantic scope enforcement is unavailable. Merge the extract branch first, then reuse will have full scope protection.");
else throw new Error("Cannot execute reuse: normalize-sources.json not found on base branch. The extract branch must be merged before reuse can execute. This ensures semantic scope enforcement protects against out-of-scope patching.");
else {
const sourcesData = JSON.parse(sourcesRaw);
let allowedSourceFiles = [];
let scopeLabel = "";
if (scope.model) {
const modelSources = sourcesData.models?.[scope.model]?.source_files;
if (modelSources) allowedSourceFiles = modelSources;
scopeLabel = `model "${scope.model}"`;
} else if (scope.domain) {
const domainModelIds = scopeModels.map((m) => m.id);
for (const modelId of domainModelIds) {
const modelSources = sourcesData.models?.[modelId]?.source_files;
if (modelSources) allowedSourceFiles.push(...modelSources);
}
allowedSourceFiles = [...new Set(allowedSourceFiles)];
scopeLabel = `domain "${scope.domain}"`;
}
if (allowedSourceFiles.length > 0) {
const outOfScopePatches = [];
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (!allowedSourceFiles.includes(normalizedPath)) outOfScopePatches.push(patch.file);
}
if (outOfScopePatches.length > 0) throw new Error(`Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. Out-of-scope files: ${outOfScopePatches.join(", ")}. Known source files: ${allowedSourceFiles.join(", ")}.`);
}
}
}
}
const patchesByFile = /* @__PURE__ */ new Map();
for (const patch of patches) {
if (!patchesByFile.has(patch.file)) patchesByFile.set(patch.file, []);
patchesByFile.get(patch.file).push(patch);
}
const filesToModify = [...patchesByFile.keys()];
const importsToAdd = patches.filter((p) => p.import_statement).length;
if (dry_run !== false) return {
dry_run: true,
preview: {
files_to_modify: filesToModify,
patches_count: patches.length,
imports_to_add: importsToAdd
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
next_steps: [
...scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : [],
"Review the files and patches above",
"Call contentrain_apply with mode:reuse and dry_run:false to execute"
]
};
const reuseHealth = await checkBranchHealth(projectRoot);
if (reuseHealth.blocked) return {
dry_run: false,
error: `Branch blocked: ${reuseHealth.message}`,
next_steps: ["Merge or delete old contentrain/* branches before executing reuse."]
};
const scopeTarget = scope.model ?? scope.domain;
const branchName = buildBranchName("normalize/reuse", scopeTarget);
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const filesModified = [];
let patchesApplied = 0;
let importsAdded = 0;
const patchesSkipped = [];
const frameworkWarnings = [];
const syntaxErrors = [];
try {
await tx.write(async (wt) => {
for (const [relFile, filePatches] of patchesByFile) {
const absPath = join(wt, relFile);
if (!await pathExists(absPath)) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file not found"
});
continue;
}
const content = await readText(absPath);
if (content === null) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file unreadable"
});
continue;
}
const sorted = [...filePatches].toSorted((a, b) => b.line - a.line);
const lines = content.split("\n");
let fileModified = false;
for (const patch of sorted) {
const isTagTextContext = (lines[patch.line - 1] ?? "").includes(`>${patch.old_value}<`);
const replacementContext = isTagTextContext ? "tag_text" : "other";
if (isTagTextContext) {
const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext);
if (fwWarning) frameworkWarnings.push({
file: relFile,
warning: fwWarning
});
}
if (applyPatchToLines(lines, patch)) {
patchesApplied++;
fileModified = true;
} else patchesSkipped.push({
file: relFile,
line: patch.line,
reason: "old_value not found at or near specified line"
});
}
const importStatements = new Set(filePatches.filter((p) => p.import_statement).map((p) => p.import_statement));
if (importStatements.size > 0) {
const added = addImportsToLines(lines, importStatements);
importsAdded += added;
if (added > 0) fileModified = true;
}
if (fileModified) {
const newContent = lines.join("\n");
await writeText(absPath, newContent);
filesModified.push(relFile);
const syntaxError = checkSyntax(relFile, newContent);
if (syntaxError) syntaxErrors.push({
file: relFile,
error: syntaxError
});
}
}
await writeContext(wt, {
tool: "contentrain_apply",
model: scopeTarget,
locale: config.locales.default
});
});
if (filesModified.length === 0) {
await tx.cleanup();
return {
dry_run: false,
results: {
files_modified: [],
patches_applied: 0,
patches_skipped: patchesSkipped,
imports_added: 0,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0
},
next_steps: ["No files were modified. Check patch definitions and try again."]
};
}
const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
files_modified: filesModified,
patches_applied: patchesApplied,
patches_skipped: patchesSkipped,
imports_added: importsAdded,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0,
syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : void 0
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
git: gitResult,
next_steps: [
"Run contentrain_validate to verify the patched files",
patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : "",
syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : "",
scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : "",
"Run contentrain_submit to push the branch for review",
"For review: direct user to http://localhost:3333/branches or use contentrain_merge",
"After all reuse phases complete, run `npx contentrain generate` to update SDK types"
].filter(Boolean)
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
/**
* Apply a single patch to a lines array. Mutates lines in place.
* Uses line hint for proximity matching — searches ±10 lines from hint.
*/
function applyPatchToLines(lines, patch) {
const { line, old_value, new_expression } = patch;
const lineIdx = line - 1;
const searchStart = Math.max(0, lineIdx - 10);
const searchEnd = Math.min(lines.length, lineIdx + 11);
if (lineIdx >= 0 && lineIdx < lines.length) {
const replaced = replaceInLine(lines[lineIdx], old_value, new_expression);
if (replaced !== null) {
lines[lineIdx] = replaced;
return true;
}
}
for (let i = searchStart; i < searchEnd; i++) {
if (i === lineIdx) continue;
const replaced = replaceInLine(lines[i], old_value, new_expression);
if (replaced !== null) {
lines[i] = replaced;
return true;
}
}
return false;
}
/**
* Replace old_value with new_expression in a single line.
* Matches the string literal (quoted or unquoted tag text).
* Returns the modified line, or null if not found.
*
* Guardrail #4: Safer patch matching — word boundary awareness and
* ambiguity rejection for plain text fallback.
*/
function replaceInLine(line, oldValue, newExpression) {
for (const quote of [
"\"",
"'",
"`"
]) {
const quoted = `${quote}${oldValue}${quote}`;
if (line.includes(quoted)) return line.replace(quoted, newExpression);
}
if (line.includes(`>${oldValue}<`)) return line.replace(`>${oldValue}<`, `>${newExpression}<`);
if (line.includes(oldValue)) {
if (countOccurrences(line, oldValue) > 1) return null;
const idx = line.indexOf(oldValue);
const charBefore = idx > 0 ? line[idx - 1] : "";
const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length] : "";
const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]);
const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]);
if (oldStartsWithWord && isWordChar(charBefore)) return null;
if (oldEndsWithWord && isWordChar(charAfter)) return null;
return line.replace(oldValue, newExpression);
}
return null;
}
/** Count non-overlapping occurrences of a substring */
function countOccurrences(str, sub) {
let count = 0;
let pos = 0;
while (pos <= str.length - sub.length) {
const idx = str.indexOf(sub, pos);
if (idx === -1) break;
count++;
pos = idx + sub.length;
}
return count;
}
/** Check if a character is a word character (letter, digit, underscore) */
function isWordChar(ch) {
return /\w/.test(ch);
}
/**
* Add import statements to the top of a file (after existing imports).
* Deduplicates — won't add if the import already exists.
* Returns number of imports actually added.
*/
function addImportsToLines(lines, imports) {
let added = 0;
const existingContent = lines.join("\n");
let lastImportIdx = -1;
let inMultiLineImport = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (inMultiLineImport) {
lastImportIdx = i;
if (trimmed.includes("}")) inMultiLineImport = false;
continue;
}
if (trimmed.startsWith("import ") || trimmed.startsWith("import{")) {
lastImportIdx = i;
if (trimmed.includes("{") && !trimmed.includes("}")) inMultiLineImport = true;
continue;
}
if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) break;
}
let insertAt;
if (lastImportIdx >= 0) insertAt = lastImportIdx + 1;
else {
insertAt = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (i === 0 && trimmed.startsWith("#!")) {
insertAt = i + 1;
continue;
}
if (trimmed === "'use client'" || trimmed === "\"use client\"" || trimmed === "'use server'" || trimmed === "\"use server\"" || trimmed === "'use client';" || trimmed === "\"use client\";" || trimmed === "'use server';" || trimmed === "\"use server\";") {
insertAt = i + 1;
continue;
}
if (insertAt > 0 && trimmed.length > 0) break;
if (insertAt === 0 && trimmed.length > 0) break;
}
}
const toInsert = [];
for (const imp of imports) if (!existingContent.includes(imp)) {
toInsert.push(imp);
added++;
}
if (toInsert.length > 0) lines.splice(insertAt, 0, ...toInsert);
return added;
}
//#endregion
export { detectFileFramework as a, validatePatchPath as c, checkSyntax as i, applyExtract as n, replaceInLine as o, applyReuse as r, validateFrameworkExpression as s, PATCHABLE_EXTENSIONS as t };
//# sourceMappingURL=apply-manager-BpKGvXmn.mjs.map
{"version":3,"file":"apply-manager-BpKGvXmn.mjs","names":[],"sources":["../src/core/apply-manager.ts"],"sourcesContent":["import type { ModelDefinition, FieldDef, FileFramework } from '@contentrain/types'\nimport { join, extname } from 'node:path'\nimport { readText, writeText, pathExists } from '../util/fs.js'\nimport { readModel, writeModel, listModels, validateModelDefinition } from './model-manager.js'\nimport { writeContent, resolveContentDir, resolveJsonFilePath, resolveMdFilePath, type ContentEntry } from './content-manager.js'\nimport { readConfig } from './config.js'\nimport { writeContext } from './context.js'\nimport { createTransaction, buildBranchName } from '../git/transaction.js'\nimport { checkBranchHealth } from '../git/branch-lifecycle.js'\n\n// ─── Types ───\n\nexport interface ExtractionEntry {\n model: string\n kind: 'singleton' | 'collection' | 'dictionary' | 'document'\n domain: string\n i18n?: boolean\n fields?: Record<string, FieldDef>\n entries: Array<{\n locale?: string\n slug?: string\n data: Record<string, unknown>\n source?: { file: string; line: number; value: string }\n sources?: Array<{ file: string; line: number; key: string; value: string }>\n }>\n}\n\nexport interface ExtractionInput {\n extractions: ExtractionEntry[]\n dry_run?: boolean\n}\n\nexport interface ExtractionPreview {\n models_to_create: string[]\n models_to_update: string[]\n total_entries: number\n content_files: string[]\n}\n\nexport interface ExtractionResult {\n dry_run: boolean\n preview?: ExtractionPreview\n error?: string\n validation_errors?: string[]\n results?: {\n models_created: string[]\n models_updated: string[]\n entries_written: number\n source_map: Array<{ model: string; locale: string; value: string; file: string; line: number }>\n }\n git?: { branch: string; action: string; commit: string }\n context_updated?: boolean\n next_steps: string[]\n}\n\nexport interface PatchEntry {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\nexport interface ReuseInput {\n scope: { model?: string; domain?: string }\n patches: PatchEntry[]\n dry_run?: boolean\n}\n\nexport interface SyntaxError {\n file: string\n error: string\n}\n\nexport interface ReuseResult {\n dry_run: boolean\n error?: string\n scope_warnings?: string[]\n preview?: {\n files_to_modify: string[]\n patches_count: number\n imports_to_add: number\n }\n results?: {\n files_modified: string[]\n patches_applied: number\n patches_skipped: Array<{ file: string; line: number; reason: string }>\n imports_added: number\n framework_warnings?: Array<{ file: string; warning: string }>\n syntax_errors?: SyntaxError[]\n }\n git?: { branch: string; action: string; commit: string }\n next_steps: string[]\n}\n\n// ─── Constants ───\n\nconst MAX_PATCHES = 100\n\n/** File extensions allowed for patching — scannable source files only */\nexport const PATCHABLE_EXTENSIONS = new Set([\n '.vue', '.tsx', '.jsx', '.ts', '.js', '.mjs', '.astro', '.svelte',\n])\n\n/** Directories that must never be patched */\nconst FORBIDDEN_PATH_SEGMENTS = new Set([\n '.contentrain', 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',\n])\n\n// ─── Framework Detection (Guardrail #2) ───\n\nexport type { FileFramework } from '@contentrain/types'\n\nexport function detectFileFramework(filePath: string): FileFramework {\n const ext = extname(filePath).toLowerCase()\n switch (ext) {\n case '.vue': return 'vue'\n case '.svelte': return 'svelte'\n case '.tsx':\n case '.jsx': return 'jsx'\n case '.astro': return 'astro'\n case '.ts':\n case '.js':\n case '.mjs': return 'script'\n default: return 'script'\n }\n}\n\n/**\n * Validate that a replacement expression uses the correct template syntax\n * for the target file's framework. Returns a warning string or null.\n */\nexport function validateFrameworkExpression(\n filePath: string,\n newExpression: string,\n context: 'tag_text' | 'other',\n): string | null {\n if (context !== 'tag_text') return null\n\n const framework = detectFileFramework(filePath)\n\n switch (framework) {\n case 'vue':\n if (!newExpression.includes('{{')) {\n return `Vue file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{{\" — expected Vue template syntax like {{ $t('key') }}`\n }\n break\n case 'jsx':\n if (!newExpression.includes('{')) {\n return `JSX file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected JSX syntax like {t('key')}`\n }\n break\n case 'svelte':\n if (!newExpression.includes('{')) {\n return `Svelte file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Svelte syntax like {$t('key')}`\n }\n break\n case 'astro':\n if (!newExpression.includes('{')) {\n return `Astro file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Astro syntax like {t('key')}`\n }\n break\n case 'script':\n // Script files don't have template interpolation — warn if attempting tag text replacement\n return `Script file \"${filePath}\": tag text replacement not applicable for .ts/.js files`\n }\n\n return null\n}\n\n// ─── Scope Validation (Guardrail #1) ───\n\n/**\n * Validate that a patch file path is safe and within allowed scope.\n * Returns an error message or null if valid.\n */\nexport function validatePatchPath(filePath: string): string | null {\n const normalizedPath = filePath.replace(/\\\\/g, '/')\n\n // Reject path traversal\n if (normalizedPath.includes('..')) {\n return `Path traversal detected: \"${filePath}\"`\n }\n\n // Reject absolute paths\n if (normalizedPath.startsWith('/')) {\n return `Absolute path not allowed: \"${filePath}\"`\n }\n\n // Reject forbidden directories\n const segments = normalizedPath.split('/')\n for (const seg of segments) {\n if (FORBIDDEN_PATH_SEGMENTS.has(seg)) {\n return `Patching files inside \"${seg}/\" is not allowed: \"${filePath}\"`\n }\n }\n\n // Reject non-scannable extensions\n const ext = extname(filePath).toLowerCase()\n if (!PATCHABLE_EXTENSIONS.has(ext)) {\n return `File extension \"${ext}\" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(', ')}. Path: \"${filePath}\"`\n }\n\n return null\n}\n\n// ─── Syntax Check (Guardrail #5) ───\n\n/**\n * Perform a basic syntax check on a patched file.\n * Returns an error message or null if syntax appears valid.\n */\nexport function checkSyntax(filePath: string, content: string): string | null {\n const ext = extname(filePath).toLowerCase()\n\n switch (ext) {\n case '.ts':\n case '.tsx':\n case '.js':\n case '.jsx':\n case '.mjs':\n return checkJsSyntax(content)\n case '.vue':\n return checkVueSyntax(content)\n case '.svelte':\n case '.astro':\n return checkTagBalance(content)\n default:\n return null\n }\n}\n\n/**\n * Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.\n * This is intentionally conservative — it catches obvious breakage without\n * requiring a full parser.\n */\nfunction checkJsSyntax(content: string): string | null {\n const stack: string[] = []\n const pairs: Record<string, string> = { ')': '(', ']': '[', '}': '{' }\n let inString: string | null = null\n let escaped = false\n let inLineComment = false\n let inBlockComment = false\n\n for (let i = 0; i < content.length; i++) {\n const ch = content[i]!\n const next = content[i + 1]\n\n // Handle escape sequences inside strings\n if (escaped) {\n escaped = false\n continue\n }\n\n if (ch === '\\\\' && inString !== null) {\n escaped = true\n continue\n }\n\n // Line comment\n if (!inString && !inBlockComment && ch === '/' && next === '/') {\n inLineComment = true\n continue\n }\n if (inLineComment) {\n if (ch === '\\n') inLineComment = false\n continue\n }\n\n // Block comment\n if (!inString && !inBlockComment && ch === '/' && next === '*') {\n inBlockComment = true\n i++ // skip *\n continue\n }\n if (inBlockComment) {\n if (ch === '*' && next === '/') {\n inBlockComment = false\n i++ // skip /\n }\n continue\n }\n\n // String handling\n if (inString !== null) {\n if (ch === inString) {\n // Template literal allows multi-line, others don't\n inString = null\n } else if (inString !== '`' && ch === '\\n') {\n return `Unterminated string literal near offset ${i}`\n }\n continue\n }\n\n if (ch === '\"' || ch === \"'\" || ch === '`') {\n inString = ch\n continue\n }\n\n // Bracket matching\n if (ch === '(' || ch === '[' || ch === '{') {\n stack.push(ch)\n } else if (ch === ')' || ch === ']' || ch === '}') {\n const expected = pairs[ch]!\n if (stack.length === 0) {\n return `Unmatched closing \"${ch}\" near offset ${i}`\n }\n const top = stack.pop()!\n if (top !== expected) {\n return `Mismatched bracket: expected closing for \"${top}\" but found \"${ch}\" near offset ${i}`\n }\n }\n }\n\n if (inString !== null) {\n return `Unterminated string literal (opened with ${inString})`\n }\n\n if (stack.length > 0) {\n return `Unclosed bracket \"${stack[stack.length - 1]}\" — ${stack.length} unclosed bracket(s)`\n }\n\n return null\n}\n\n/**\n * Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.\n */\nfunction checkVueSyntax(content: string): string | null {\n const tagBalance = checkTagBalance(content)\n if (tagBalance) return tagBalance\n\n // Vue-specific: check that SFC root tags are present and balanced\n for (const tag of ['template', 'script']) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n/**\n * Basic tag balance check for HTML-like files.\n * Checks that self-closing tags are handled and major structural tags are balanced.\n */\nfunction checkTagBalance(content: string): string | null {\n // Check for common structural tags balance\n const structuralTags = ['div', 'section', 'main', 'header', 'footer', 'nav', 'article', 'aside', 'ul', 'ol', 'table']\n\n for (const tag of structuralTags) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n// ─── Extract Mode ───\n\nexport async function applyExtract(\n projectRoot: string,\n input: ExtractionInput,\n): Promise<ExtractionResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { extractions, dry_run } = input\n\n // Analyze what will happen\n const existingModels = await listModels(projectRoot)\n const existingIds = new Set(existingModels.map(m => m.id))\n\n const modelsToCreate: string[] = []\n const modelsToUpdate: string[] = []\n const contentFiles: string[] = []\n let totalEntries = 0\n\n const validationErrors: string[] = []\n\n for (const ext of extractions) {\n // Validate model definition with same rules as model_save\n const modelErrors = validateModelDefinition({\n id: ext.model,\n kind: ext.kind,\n fields: ext.fields as Record<string, unknown> | undefined,\n })\n if (modelErrors.errors.length > 0) {\n validationErrors.push(...modelErrors.errors.map(e => `[${ext.model}] ${e}`))\n }\n\n for (const entry of ext.entries) {\n if (ext.kind === 'dictionary') {\n if (entry.data['id'] !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`)\n }\n for (const [key, val] of Object.entries(entry.data)) {\n if (typeof val !== 'string') {\n validationErrors.push(`[${ext.model}] Dictionary entry value for key \"${key}\" must be a string, got ${typeof val}`)\n }\n }\n } else if (ext.kind === 'document') {\n if (!entry.slug && !entry.data['slug']) {\n validationErrors.push(`[${ext.model}] Document entries must have a slug`)\n }\n } else if (ext.kind === 'collection') {\n if (entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Collection entries should not have slug`)\n }\n } else if (ext.kind === 'singleton') {\n if (entry.data['id'] !== undefined || entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`)\n }\n }\n }\n\n if (existingIds.has(ext.model)) {\n modelsToUpdate.push(ext.model)\n } else {\n modelsToCreate.push(ext.model)\n }\n totalEntries += ext.entries.length\n\n // Guardrail #3: Preview-Execute Parity — use real model metadata if it exists\n let previewModel: ModelDefinition\n if (existingIds.has(ext.model)) {\n const real = await readModel(projectRoot, ext.model)\n if (real) {\n previewModel = real\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n\n const cDir = resolveContentDir(projectRoot, previewModel)\n for (const entry of ext.entries) {\n const locale = entry.locale ?? config.locales.default\n if (ext.kind === 'document' && entry.slug) {\n contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug))\n } else {\n contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale))\n }\n }\n }\n\n const preview: ExtractionPreview = {\n models_to_create: modelsToCreate,\n models_to_update: modelsToUpdate,\n total_entries: totalEntries,\n content_files: [...new Set(contentFiles)],\n }\n\n // Dry run — return preview only (include validation errors if any)\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview,\n ...(validationErrors.length > 0 ? { validation_errors: validationErrors } : {}),\n next_steps: [\n ...(validationErrors.length > 0\n ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`]\n : []),\n 'Review the preview above',\n 'Call contentrain_apply with mode:extract and dry_run:false to execute',\n ],\n }\n }\n\n // Block execute if validation errors exist\n if (validationErrors.length > 0) {\n return {\n dry_run: false,\n error: 'Model validation failed — cannot execute extract with invalid model definitions',\n validation_errors: validationErrors,\n next_steps: ['Fix the validation errors and retry'],\n }\n }\n\n // Branch health gate\n const health = await checkBranchHealth(projectRoot)\n if (health.blocked) {\n return {\n error: health.message,\n action: 'blocked' as const,\n hint: 'Merge or delete old contentrain/* branches before executing normalize.',\n } as unknown as ExtractionResult\n }\n\n // Execute — git transaction (always review mode for normalize)\n const branchName = buildBranchName('normalize', 'extract')\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n const sourceMap: Array<{ model: string; locale: string; value: string; file: string; line: number }> = []\n const modelsCreated: string[] = []\n const modelsUpdated: string[] = []\n let entriesWritten = 0\n\n try {\n await tx.write(async (wt) => {\n for (const ext of extractions) {\n // Create or merge model\n const existing = await readModel(wt, ext.model)\n if (existing) {\n // Merge fields: add new, keep existing\n if (ext.fields) {\n const merged = { ...existing.fields, ...ext.fields }\n // Only overwrite if new fields were actually added\n const newFieldNames = Object.keys(ext.fields).filter(k => !(k in (existing.fields ?? {})))\n if (newFieldNames.length > 0) {\n existing.fields = merged\n await writeModel(wt, existing)\n modelsUpdated.push(ext.model)\n }\n }\n } else {\n // Create new model\n const newModel: ModelDefinition = {\n id: ext.model,\n name: ext.model.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),\n kind: ext.kind,\n domain: ext.domain,\n i18n: ext.i18n ?? true,\n fields: ext.fields,\n }\n await writeModel(wt, newModel)\n modelsCreated.push(ext.model)\n }\n\n // Write content entries\n const model = (await readModel(wt, ext.model))!\n const entries: ContentEntry[] = ext.entries.map(e => ({\n locale: e.locale,\n slug: e.slug,\n data: e.data,\n }))\n\n const wtConfig = await readConfig(wt) ?? config\n await writeContent(wt, model, entries, wtConfig)\n entriesWritten += entries.length\n\n // Track source map\n for (const entry of ext.entries) {\n // For dictionary entries with per-key source tracking\n if (ext.kind === 'dictionary' && entry.sources) {\n for (const s of entry.sources) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: s.value,\n file: s.file,\n line: s.line,\n })\n }\n } else if (entry.source) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: entry.source.value,\n file: entry.source.file,\n line: entry.source.line,\n })\n }\n }\n }\n\n // Write source map for reuse scope enforcement\n if (sourceMap.length > 0) {\n const sourcesByModel: Record<string, { source_files: string[]; entry_count: number }> = {}\n for (const s of sourceMap) {\n if (!sourcesByModel[s.model]) {\n sourcesByModel[s.model] = { source_files: [], entry_count: 0 }\n }\n const modelEntry = sourcesByModel[s.model]!\n if (!modelEntry.source_files.includes(s.file)) {\n modelEntry.source_files.push(s.file)\n }\n modelEntry.entry_count++\n }\n const sourcesJson = JSON.stringify({\n version: 1,\n created_at: new Date().toISOString(),\n models: sourcesByModel,\n }, null, 2) + '\\n'\n // Write to worktree only — merge brings it to main\n // Phase 2 (reuse) must wait for extract branch to be merged first\n await writeText(join(wt, '.contentrain', 'normalize-sources.json'), sourcesJson)\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: extractions.map(e => e.model).join(','),\n locale: config.locales.default,\n entries: extractions.flatMap(e => e.entries.map(en => en.slug ?? 'entry')),\n })\n })\n\n const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n models_created: modelsCreated,\n models_updated: modelsUpdated,\n entries_written: entriesWritten,\n source_map: sourceMap,\n },\n git: gitResult,\n context_updated: true,\n next_steps: [\n 'Run contentrain_validate to check the extracted content',\n 'Run contentrain_submit to push the branch for review',\n 'For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize',\n 'For terminal workflow: use contentrain_merge to merge the branch locally',\n 'After merge, run `npx contentrain generate` to update SDK client',\n 'After review, proceed with mode:reuse to patch source files',\n ],\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Reuse Mode ───\n\nexport async function applyReuse(\n projectRoot: string,\n input: ReuseInput,\n): Promise<ReuseResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { scope, patches, dry_run } = input\n\n // Validate scope\n if (!scope.model && !scope.domain) {\n throw new Error('Scope required: provide model or domain. Whole-project patching is not allowed.')\n }\n\n // Validate patch count\n if (patches.length > MAX_PATCHES) {\n throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`)\n }\n\n // Check content exists for scope (soft warning)\n if (scope.model) {\n const model = await readModel(projectRoot, scope.model)\n if (!model) {\n throw new Error(`Model \"${scope.model}\" not found. Run extract phase first.`)\n }\n }\n\n const scopeWarnings: string[] = []\n\n // Guardrail #1: Scope Real Enforcement\n // Step 1: Path safety — every patch must target a valid, patchable source file\n for (const patch of patches) {\n const pathError = validatePatchPath(patch.file)\n if (pathError) {\n throw new Error(`Invalid patch path: ${pathError}`)\n }\n }\n\n // Step 2: Semantic scope — verify scope model/domain exists and cross-check patch files\n if (scope.model || scope.domain) {\n const models = await listModels(projectRoot)\n const scopeModels = scope.model\n ? models.filter(m => m.id === scope.model)\n : scope.domain\n ? models.filter(m => m.domain === scope.domain)\n : models\n\n if (scopeModels.length === 0) {\n throw new Error(`No models found for scope ${scope.model ? `model=\"${scope.model}\"` : `domain=\"${scope.domain}\"`}`)\n }\n\n // Step 3: Verify patch files are source files (not content/config/meta files)\n // and belong to detectable source directories (not random locations)\n const { autoDetectSourceDirs } = await import('./scan-config.js')\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n\n // Build allowed file prefixes from source dirs\n const allowedPrefixes = sourceDirs.map(d => d === '.' ? '' : d + '/')\n\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n\n // Reject patches targeting .contentrain/ directory (content files should never be patched by reuse)\n if (normalizedPath.startsWith('.contentrain/') || normalizedPath.includes('/.contentrain/')) {\n throw new Error(`Cannot patch content/config files directly: \"${patch.file}\". Reuse patches source files only.`)\n }\n\n // If source dirs were detected (not just \".\"), verify patch files are within them\n if (sourceDirs.length > 0 && sourceDirs[0] !== '.') {\n const inSourceDir = allowedPrefixes.some(prefix =>\n prefix === '' || normalizedPath.startsWith(prefix),\n )\n if (!inSourceDir) {\n throw new Error(\n `Patch file \"${patch.file}\" is outside detected source directories (${sourceDirs.join(', ')}). ` +\n `Reuse patches must target source files within the project's source tree.`,\n )\n }\n }\n }\n\n // Step 4: Semantic source→model cross-check via normalize-sources.json\n // Source map is written by extract into the review branch worktree.\n // It only exists on base after extract branch is merged.\n // If missing: dry_run proceeds with warning, execute is blocked.\n if (scope.model || scope.domain) {\n const sourcesPath = join(projectRoot, '.contentrain', 'normalize-sources.json')\n const sourcesRaw = await readText(sourcesPath)\n\n if (!sourcesRaw) {\n // Source map not found — check if scoped model is a dictionary\n // Dictionaries store all keys in one entry, so per-file source tracking\n // is not available. Allow reuse with a warning instead of blocking.\n const scopedModel = scopeModels.find(m => m.id === scope.model)\n const isDictionary = scopedModel?.kind === 'dictionary'\n\n if (isDictionary) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Dictionary models do not generate per-file source maps. ' +\n 'Scope enforcement is based on source-tree locality only.',\n )\n } else if (dry_run !== false) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Semantic scope enforcement is unavailable. ' +\n 'Merge the extract branch first, then reuse will have full scope protection.',\n )\n } else {\n throw new Error(\n 'Cannot execute reuse: normalize-sources.json not found on base branch. ' +\n 'The extract branch must be merged before reuse can execute. ' +\n 'This ensures semantic scope enforcement protects against out-of-scope patching.',\n )\n }\n } else {\n const sourcesData = JSON.parse(sourcesRaw) as {\n models?: Record<string, { source_files?: string[] }>\n }\n\n // Collect all allowed source files for the scope\n let allowedSourceFiles: string[] = []\n let scopeLabel = ''\n\n if (scope.model) {\n const modelSources = sourcesData.models?.[scope.model]?.source_files\n if (modelSources) allowedSourceFiles = modelSources\n scopeLabel = `model \"${scope.model}\"`\n } else if (scope.domain) {\n const domainModelIds = scopeModels.map(m => m.id)\n for (const modelId of domainModelIds) {\n const modelSources = sourcesData.models?.[modelId]?.source_files\n if (modelSources) allowedSourceFiles.push(...modelSources)\n }\n allowedSourceFiles = [...new Set(allowedSourceFiles)]\n scopeLabel = `domain \"${scope.domain}\"`\n }\n\n if (allowedSourceFiles.length > 0) {\n const outOfScopePatches: string[] = []\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n if (!allowedSourceFiles.includes(normalizedPath)) {\n outOfScopePatches.push(patch.file)\n }\n }\n if (outOfScopePatches.length > 0) {\n throw new Error(\n `Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. ` +\n `Out-of-scope files: ${outOfScopePatches.join(', ')}. ` +\n `Known source files: ${allowedSourceFiles.join(', ')}.`,\n )\n }\n }\n }\n }\n }\n\n // Group patches by file\n const patchesByFile = new Map<string, PatchEntry[]>()\n for (const patch of patches) {\n if (!patchesByFile.has(patch.file)) {\n patchesByFile.set(patch.file, [])\n }\n patchesByFile.get(patch.file)!.push(patch)\n }\n\n const filesToModify = [...patchesByFile.keys()]\n const importsToAdd = patches.filter(p => p.import_statement).length\n\n // Dry run — return preview only\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview: {\n files_to_modify: filesToModify,\n patches_count: patches.length,\n imports_to_add: importsToAdd,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n next_steps: [\n ...(scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : []),\n 'Review the files and patches above',\n 'Call contentrain_apply with mode:reuse and dry_run:false to execute',\n ],\n }\n }\n\n // Branch health gate\n const reuseHealth = await checkBranchHealth(projectRoot)\n if (reuseHealth.blocked) {\n return {\n dry_run: false,\n error: `Branch blocked: ${reuseHealth.message}`,\n next_steps: ['Merge or delete old contentrain/* branches before executing reuse.'],\n }\n }\n\n // Execute — git transaction\n const scopeTarget = scope.model ?? scope.domain!\n const branchName = buildBranchName('normalize/reuse', scopeTarget)\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n\n const filesModified: string[] = []\n let patchesApplied = 0\n let importsAdded = 0\n const patchesSkipped: Array<{ file: string; line: number; reason: string }> = []\n const frameworkWarnings: Array<{ file: string; warning: string }> = []\n const syntaxErrors: SyntaxError[] = []\n\n try {\n await tx.write(async (wt) => {\n for (const [relFile, filePatches] of patchesByFile) {\n const absPath = join(wt, relFile)\n\n if (!(await pathExists(absPath))) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file not found' })\n }\n continue\n }\n\n const content = await readText(absPath)\n if (content === null) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file unreadable' })\n }\n continue\n }\n\n // Sort patches by line DESC (bottom-up to avoid line shifts)\n const sorted = [...filePatches].toSorted((a, b) => b.line - a.line)\n\n const lines = content.split('\\n')\n let fileModified = false\n\n for (const patch of sorted) {\n // Determine replacement context before applying\n const targetLine = lines[patch.line - 1] ?? ''\n const isTagTextContext = targetLine.includes(`>${patch.old_value}<`)\n const replacementContext = isTagTextContext ? 'tag_text' as const : 'other' as const\n\n // Guardrail #2: Framework-aware validation — only warn for tag text replacements\n if (isTagTextContext) {\n const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext)\n if (fwWarning) {\n frameworkWarnings.push({ file: relFile, warning: fwWarning })\n }\n }\n\n const applied = applyPatchToLines(lines, patch)\n if (applied) {\n patchesApplied++\n fileModified = true\n } else {\n patchesSkipped.push({ file: relFile, line: patch.line, reason: 'old_value not found at or near specified line' })\n }\n }\n\n // Add imports (deduplicate)\n const importStatements = new Set(\n filePatches\n .filter(p => p.import_statement)\n .map(p => p.import_statement!),\n )\n\n if (importStatements.size > 0) {\n const added = addImportsToLines(lines, importStatements)\n importsAdded += added\n if (added > 0) fileModified = true\n }\n\n if (fileModified) {\n const newContent = lines.join('\\n')\n await writeText(absPath, newContent)\n filesModified.push(relFile)\n\n // Guardrail #5: Syntax check after patching\n const syntaxError = checkSyntax(relFile, newContent)\n if (syntaxError) {\n syntaxErrors.push({ file: relFile, error: syntaxError })\n }\n }\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: scopeTarget,\n locale: config.locales.default,\n })\n })\n\n if (filesModified.length === 0) {\n await tx.cleanup()\n return {\n dry_run: false,\n results: {\n files_modified: [],\n patches_applied: 0,\n patches_skipped: patchesSkipped,\n imports_added: 0,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n },\n next_steps: ['No files were modified. Check patch definitions and try again.'],\n }\n }\n\n const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n files_modified: filesModified,\n patches_applied: patchesApplied,\n patches_skipped: patchesSkipped,\n imports_added: importsAdded,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : undefined,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n git: gitResult,\n next_steps: [\n 'Run contentrain_validate to verify the patched files',\n patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : '',\n syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : '',\n scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : '',\n 'Run contentrain_submit to push the branch for review',\n 'For review: direct user to http://localhost:3333/branches or use contentrain_merge',\n 'After all reuse phases complete, run `npx contentrain generate` to update SDK types',\n ].filter(Boolean),\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Patch Helpers ───\n\n/**\n * Apply a single patch to a lines array. Mutates lines in place.\n * Uses line hint for proximity matching — searches ±10 lines from hint.\n */\nfunction applyPatchToLines(lines: string[], patch: PatchEntry): boolean {\n const { line, old_value, new_expression } = patch\n const lineIdx = line - 1 // 0-based\n\n // Search range: ±10 lines from hint\n const searchStart = Math.max(0, lineIdx - 10)\n const searchEnd = Math.min(lines.length, lineIdx + 11)\n\n // First pass: exact line match\n if (lineIdx >= 0 && lineIdx < lines.length) {\n const replaced = replaceInLine(lines[lineIdx]!, old_value, new_expression)\n if (replaced !== null) {\n lines[lineIdx] = replaced\n return true\n }\n }\n\n // Second pass: proximity search\n for (let i = searchStart; i < searchEnd; i++) {\n if (i === lineIdx) continue // already tried\n const replaced = replaceInLine(lines[i]!, old_value, new_expression)\n if (replaced !== null) {\n lines[i] = replaced\n return true\n }\n }\n\n return false\n}\n\n/**\n * Replace old_value with new_expression in a single line.\n * Matches the string literal (quoted or unquoted tag text).\n * Returns the modified line, or null if not found.\n *\n * Guardrail #4: Safer patch matching — word boundary awareness and\n * ambiguity rejection for plain text fallback.\n */\nexport function replaceInLine(line: string, oldValue: string, newExpression: string): string | null {\n // Try exact match of the value in quoted strings\n // Match: \"old_value\", 'old_value', `old_value`\n for (const quote of ['\"', \"'\", '`']) {\n const quoted = `${quote}${oldValue}${quote}`\n if (line.includes(quoted)) {\n return line.replace(quoted, newExpression)\n }\n }\n\n // Try unquoted tag text match: >old_value<\n // The agent provides the complete new_expression with correct framework syntax:\n // JSX: {t('key')}\n // Vue: {{ $t('key') }}\n // Svelte: {$t('key')}\n // So we insert the expression as-is between > and <, without wrapping in braces.\n if (line.includes(`>${oldValue}<`)) {\n return line.replace(`>${oldValue}<`, `>${newExpression}<`)\n }\n\n // Guardrail #4: Safer plain text fallback\n // Only match if old_value appears at a word boundary and is unambiguous\n if (line.includes(oldValue)) {\n // Count occurrences — if multiple, it's ambiguous\n const occurrences = countOccurrences(line, oldValue)\n if (occurrences > 1) {\n return null // ambiguous — let proximity search handle it\n }\n\n // Word boundary check: don't replace \"Submit\" inside \"SubmitButton\"\n // Reject if the old_value is a substring of a larger word — check if\n // adjacent characters are word characters that extend the token.\n const idx = line.indexOf(oldValue)\n const charBefore = idx > 0 ? line[idx - 1]! : ''\n const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length]! : ''\n\n const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]!)\n const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]!)\n\n // If old_value starts with a word char and char before is also word char, it's a partial match\n if (oldStartsWithWord && isWordChar(charBefore)) {\n return null\n }\n // If old_value ends with a word char and char after is also word char, it's a partial match\n if (oldEndsWithWord && isWordChar(charAfter)) {\n return null\n }\n\n return line.replace(oldValue, newExpression)\n }\n\n return null\n}\n\n/** Count non-overlapping occurrences of a substring */\nfunction countOccurrences(str: string, sub: string): number {\n let count = 0\n let pos = 0\n while (pos <= str.length - sub.length) {\n const idx = str.indexOf(sub, pos)\n if (idx === -1) break\n count++\n pos = idx + sub.length\n }\n return count\n}\n\n/** Check if a character is a word character (letter, digit, underscore) */\nfunction isWordChar(ch: string): boolean {\n return /\\w/.test(ch)\n}\n\n/**\n * Add import statements to the top of a file (after existing imports).\n * Deduplicates — won't add if the import already exists.\n * Returns number of imports actually added.\n */\nfunction addImportsToLines(lines: string[], imports: Set<string>): number {\n let added = 0\n const existingContent = lines.join('\\n')\n\n // Find the last import line position (handles multi-line imports)\n let lastImportIdx = -1\n let inMultiLineImport = false\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (inMultiLineImport) {\n lastImportIdx = i\n if (trimmed.includes('}')) inMultiLineImport = false\n continue\n }\n if (trimmed.startsWith('import ') || trimmed.startsWith('import{')) {\n lastImportIdx = i\n // Check if this is a multi-line import (has { but no closing })\n if (trimmed.includes('{') && !trimmed.includes('}')) {\n inMultiLineImport = true\n }\n continue\n }\n // Stop searching after a non-import, non-empty, non-comment line following imports\n if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*')) {\n break\n }\n }\n\n let insertAt: number\n if (lastImportIdx >= 0) {\n insertAt = lastImportIdx + 1\n } else {\n // No existing imports found — insert after shebang and directive lines\n insertAt = 0\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (i === 0 && trimmed.startsWith('#!')) {\n insertAt = i + 1\n continue\n }\n if (trimmed === \"'use client'\" || trimmed === '\"use client\"'\n || trimmed === \"'use server'\" || trimmed === '\"use server\"'\n || trimmed === \"'use client';\" || trimmed === '\"use client\";'\n || trimmed === \"'use server';\" || trimmed === '\"use server\";') {\n insertAt = i + 1\n continue\n }\n if (insertAt > 0 && trimmed.length > 0) break\n if (insertAt === 0 && trimmed.length > 0) break\n }\n }\n const toInsert: string[] = []\n\n for (const imp of imports) {\n // Check if this import already exists (by checking the from clause)\n if (!existingContent.includes(imp)) {\n toInsert.push(imp)\n added++\n }\n }\n\n if (toInsert.length > 0) {\n lines.splice(insertAt, 0, ...toInsert)\n }\n\n return added\n}\n"],"mappings":"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"}
import { n as canonicalStringify } from "./serialization-B1CEzR4H.mjs";
import { a as readJson, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { o as listModels, r as countEntries, s as readModel } from "./model-manager-Rcj5Wzct.mjs";
import { join } from "node:path";
//#region src/core/context.ts
const CONTEXT_PATH = ".contentrain/context.json";
async function readContext(input) {
if (typeof input === "string") return readJson(join(contentrainDir(input), "context.json"));
try {
const raw = await input.readFile(CONTEXT_PATH);
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveSource(explicit) {
if (explicit) return explicit;
return process.env["CONTENTRAIN_SOURCE"] === "mcp-studio" ? "mcp-studio" : "mcp-local";
}
async function computeEntriesCount(projectRoot) {
try {
const models = await listModels(projectRoot);
const fullModels = await Promise.all(models.map((m) => readModel(projectRoot, m.id)));
return (await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(projectRoot, m)))).reduce((acc, c) => acc + c.total, 0);
} catch {
return null;
}
}
async function writeContext(projectRoot, operation) {
const models = await listModels(projectRoot);
const config = await readConfig(projectRoot);
const locales = config?.locales.supported ?? ["en"];
const totalEntries = await computeEntriesCount(projectRoot);
const source = resolveSource();
const context = {
version: "1",
lastOperation: {
tool: operation.tool,
model: operation.model,
locale: operation.locale ?? config?.locales.default ?? "en",
entries: operation.entries,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
source
},
stats: {
models: models.length,
entries: totalEntries,
locales,
lastSync: (/* @__PURE__ */ new Date()).toISOString()
}
};
await writeJson(join(contentrainDir(projectRoot), "context.json"), context);
}
/**
* Build a FileChange for `.contentrain/context.json` that remote providers
* (GitHubProvider over HTTP, for example) can slot into their plan. The
* local write path still uses {@link writeContext} directly because its
* transaction layer writes into the post-apply worktree with real stats.
*
* Remote providers (Phase 5.5+) now also get accurate entry counts: the
* reader-based {@link countEntries} walks each model over the provider's
* read surface. GitHubProvider pays an extra round trip per model; the
* payoff is a context.json that matches what the local write path emits,
* so cross-provider merges stay deterministic.
*
* A caller that already knows the model/entry counts (e.g. Studio deriving
* them from its own index) can pass `opts.stats` to skip that O(models·
* locales) scan entirely — only `readConfig` remains, for the locale list
* and the `lastOperation.locale` default. The emitted context.json is
* byte-identical to the scanned variant for the same logical state.
*/
async function buildContextChange(reader, operation, source, opts) {
const config = await readConfig(reader);
const locales = config?.locales.supported ?? ["en"];
let modelCount;
let totalEntries;
if (opts?.stats) {
modelCount = opts.stats.models;
totalEntries = opts.stats.entries;
} else {
const models = await listModels(reader);
modelCount = models.length;
try {
const fullModels = await Promise.all(models.map((m) => readModel(reader, m.id)));
totalEntries = (await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(reader, m)))).reduce((acc, c) => acc + c.total, 0);
} catch {
totalEntries = null;
}
}
return {
path: CONTEXT_PATH,
content: canonicalStringify({
version: "1",
lastOperation: {
tool: operation.tool,
model: operation.model,
locale: operation.locale ?? config?.locales.default ?? "en",
entries: operation.entries,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
source: resolveSource(source)
},
stats: {
models: modelCount,
entries: totalEntries,
locales,
lastSync: (/* @__PURE__ */ new Date()).toISOString()
}
})
};
}
//#endregion
export { readContext as n, writeContext as r, buildContextChange as t };
//# sourceMappingURL=context-Cppz4R65.mjs.map
{"version":3,"file":"context-Cppz4R65.mjs","names":[],"sources":["../src/core/context.ts"],"sourcesContent":["import type { ContextJson, ContextSource } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { contentrainDir, readJson, writeJson } from '../util/fs.js'\nimport type { FileChange, RepoReader } from './contracts/index.js'\nimport { canonicalStringify } from './serialization/index.js'\nimport { listModels, readModel, countEntries } from './model-manager.js'\nimport { readConfig } from './config.js'\n\nconst CONTEXT_PATH = '.contentrain/context.json'\n\n/**\n * Pre-computed stats a caller can pass to {@link buildContextChange} to skip\n * the model/entry scan. `entries` is nullable — a `null` is dropped from the\n * emitted context.json by {@link canonicalStringify}, exactly as a failed\n * scan is today.\n */\nexport interface ContextStats {\n models: number\n entries: number | null\n}\n\n/**\n * Read the committed `.contentrain/context.json` payload written by the\n * most recent content or model operation. Returns `null` when the file\n * does not exist (fresh project, or reader lookup failure).\n *\n * Dual signature — the local flow passes a `projectRoot` string, remote\n * flows (GitHubProvider, GitLabProvider, any custom `RepoReader`) pass\n * the reader directly so `contentrain_status` over HTTP can still\n * report the last operation + stats.\n */\nexport function readContext(projectRoot: string): Promise<ContextJson | null>\nexport function readContext(reader: RepoReader): Promise<ContextJson | null>\nexport async function readContext(input: string | RepoReader): Promise<ContextJson | null> {\n if (typeof input === 'string') {\n return readJson<ContextJson>(join(contentrainDir(input), 'context.json'))\n }\n try {\n const raw = await input.readFile(CONTEXT_PATH)\n return JSON.parse(raw) as ContextJson\n } catch {\n return null\n }\n}\n\nfunction resolveSource(explicit?: ContextSource): ContextSource {\n if (explicit) return explicit\n return process.env['CONTENTRAIN_SOURCE'] === 'mcp-studio' ? 'mcp-studio' : 'mcp-local'\n}\n\nasync function computeEntriesCount(projectRoot: string): Promise<number | null> {\n try {\n const models = await listModels(projectRoot)\n const fullModels = await Promise.all(models.map(m => readModel(projectRoot, m.id)))\n const counts = await Promise.all(\n fullModels\n .filter((m): m is NonNullable<typeof m> => m !== null)\n .map(m => countEntries(projectRoot, m)),\n )\n return counts.reduce((acc, c) => acc + c.total, 0)\n } catch {\n return null\n }\n}\n\nexport async function writeContext(\n projectRoot: string,\n operation: { tool: string, model: string, locale?: string, entries?: string[] },\n): Promise<void> {\n const models = await listModels(projectRoot)\n const config = await readConfig(projectRoot)\n const locales = config?.locales.supported ?? ['en']\n const totalEntries = await computeEntriesCount(projectRoot)\n const source = resolveSource()\n\n const context: ContextJson = {\n version: '1',\n lastOperation: {\n tool: operation.tool,\n model: operation.model,\n locale: operation.locale ?? config?.locales.default ?? 'en',\n entries: operation.entries,\n timestamp: new Date().toISOString(),\n source,\n },\n stats: {\n models: models.length,\n entries: totalEntries as number,\n locales,\n lastSync: new Date().toISOString(),\n },\n }\n\n await writeJson(join(contentrainDir(projectRoot), 'context.json'), context)\n}\n\n/**\n * Build a FileChange for `.contentrain/context.json` that remote providers\n * (GitHubProvider over HTTP, for example) can slot into their plan. The\n * local write path still uses {@link writeContext} directly because its\n * transaction layer writes into the post-apply worktree with real stats.\n *\n * Remote providers (Phase 5.5+) now also get accurate entry counts: the\n * reader-based {@link countEntries} walks each model over the provider's\n * read surface. GitHubProvider pays an extra round trip per model; the\n * payoff is a context.json that matches what the local write path emits,\n * so cross-provider merges stay deterministic.\n *\n * A caller that already knows the model/entry counts (e.g. Studio deriving\n * them from its own index) can pass `opts.stats` to skip that O(models·\n * locales) scan entirely — only `readConfig` remains, for the locale list\n * and the `lastOperation.locale` default. The emitted context.json is\n * byte-identical to the scanned variant for the same logical state.\n */\nexport async function buildContextChange(\n reader: RepoReader,\n operation: { tool: string, model: string, locale?: string, entries?: string[] },\n source?: ContextSource,\n opts?: { stats?: ContextStats },\n): Promise<FileChange> {\n const config = await readConfig(reader)\n const locales = config?.locales.supported ?? ['en']\n\n let modelCount: number\n let totalEntries: number | null\n if (opts?.stats) {\n modelCount = opts.stats.models\n totalEntries = opts.stats.entries\n } else {\n const models = await listModels(reader)\n modelCount = models.length\n try {\n const fullModels = await Promise.all(models.map(m => readModel(reader, m.id)))\n const counts = await Promise.all(\n fullModels\n .filter((m): m is NonNullable<typeof m> => m !== null)\n .map(m => countEntries(reader, m)),\n )\n totalEntries = counts.reduce((acc, c) => acc + c.total, 0)\n } catch {\n totalEntries = null\n }\n }\n\n const context: ContextJson = {\n version: '1',\n lastOperation: {\n tool: operation.tool,\n model: operation.model,\n locale: operation.locale ?? config?.locales.default ?? 'en',\n entries: operation.entries,\n timestamp: new Date().toISOString(),\n source: resolveSource(source),\n },\n stats: {\n models: modelCount,\n entries: totalEntries as number,\n locales,\n lastSync: new Date().toISOString(),\n },\n }\n\n return {\n path: CONTEXT_PATH,\n content: canonicalStringify(context),\n }\n}\n"],"mappings":";;;;;;AAQA,MAAM,eAAe;AAyBrB,eAAsB,YAAY,OAAyD;AACzF,KAAI,OAAO,UAAU,SACnB,QAAO,SAAsB,KAAK,eAAe,MAAM,EAAE,eAAe,CAAC;AAE3E,KAAI;EACF,MAAM,MAAM,MAAM,MAAM,SAAS,aAAa;AAC9C,SAAO,KAAK,MAAM,IAAI;SAChB;AACN,SAAO;;;AAIX,SAAS,cAAc,UAAyC;AAC9D,KAAI,SAAU,QAAO;AACrB,QAAO,QAAQ,IAAI,0BAA0B,eAAe,eAAe;;AAG7E,eAAe,oBAAoB,aAA6C;AAC9E,KAAI;EACF,MAAM,SAAS,MAAM,WAAW,YAAY;EAC5C,MAAM,aAAa,MAAM,QAAQ,IAAI,OAAO,KAAI,MAAK,UAAU,aAAa,EAAE,GAAG,CAAC,CAAC;AAMnF,UALe,MAAM,QAAQ,IAC3B,WACG,QAAQ,MAAkC,MAAM,KAAK,CACrD,KAAI,MAAK,aAAa,aAAa,EAAE,CAAC,CAC1C,EACa,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,EAAE;SAC5C;AACN,SAAO;;;AAIX,eAAsB,aACpB,aACA,WACe;CACf,MAAM,SAAS,MAAM,WAAW,YAAY;CAC5C,MAAM,SAAS,MAAM,WAAW,YAAY;CAC5C,MAAM,UAAU,QAAQ,QAAQ,aAAa,CAAC,KAAK;CACnD,MAAM,eAAe,MAAM,oBAAoB,YAAY;CAC3D,MAAM,SAAS,eAAe;CAE9B,MAAM,UAAuB;EAC3B,SAAS;EACT,eAAe;GACb,MAAM,UAAU;GAChB,OAAO,UAAU;GACjB,QAAQ,UAAU,UAAU,QAAQ,QAAQ,WAAW;GACvD,SAAS,UAAU;GACnB,4BAAW,IAAI,MAAM,EAAC,aAAa;GACnC;GACD;EACD,OAAO;GACL,QAAQ,OAAO;GACf,SAAS;GACT;GACA,2BAAU,IAAI,MAAM,EAAC,aAAa;GACnC;EACF;AAED,OAAM,UAAU,KAAK,eAAe,YAAY,EAAE,eAAe,EAAE,QAAQ;;;;;;;;;;;;;;;;;;;;AAqB7E,eAAsB,mBACpB,QACA,WACA,QACA,MACqB;CACrB,MAAM,SAAS,MAAM,WAAW,OAAO;CACvC,MAAM,UAAU,QAAQ,QAAQ,aAAa,CAAC,KAAK;CAEnD,IAAI;CACJ,IAAI;AACJ,KAAI,MAAM,OAAO;AACf,eAAa,KAAK,MAAM;AACxB,iBAAe,KAAK,MAAM;QACrB;EACL,MAAM,SAAS,MAAM,WAAW,OAAO;AACvC,eAAa,OAAO;AACpB,MAAI;GACF,MAAM,aAAa,MAAM,QAAQ,IAAI,OAAO,KAAI,MAAK,UAAU,QAAQ,EAAE,GAAG,CAAC,CAAC;AAM9E,mBALe,MAAM,QAAQ,IAC3B,WACG,QAAQ,MAAkC,MAAM,KAAK,CACrD,KAAI,MAAK,aAAa,QAAQ,EAAE,CAAC,CACrC,EACqB,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,EAAE;UACpD;AACN,kBAAe;;;AAsBnB,QAAO;EACL,MAAM;EACN,SAAS,mBApBkB;GAC3B,SAAS;GACT,eAAe;IACb,MAAM,UAAU;IAChB,OAAO,UAAU;IACjB,QAAQ,UAAU,UAAU,QAAQ,QAAQ,WAAW;IACvD,SAAS,UAAU;IACnB,4BAAW,IAAI,MAAM,EAAC,aAAa;IACnC,QAAQ,cAAc,OAAO;IAC9B;GACD,OAAO;IACL,QAAQ;IACR,SAAS;IACT;IACA,2BAAU,IAAI,MAAM,EAAC,aAAa;IACnC;GACF,CAIqC;EACrC"}
import { a as readJson, i as readDir, o as readText, r as pathExists, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { g as resolveLocaleStrategy, h as resolveJsonFilePath, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-Rcj5Wzct.mjs";
import { n as checkBranchHealth, s as listRemoteCrBranches } from "./branch-lifecycle-BAfgSQBv.mjs";
import { i as autoDetectSourceDirs, o as discoverFiles } from "./scan-config-BGUflS8t.mjs";
import { join } from "node:path";
import { readdir, stat } from "node:fs/promises";
import { simpleGit } from "simple-git";
//#region src/core/doctor.ts
async function runDoctor(projectRoot, options = {}) {
const checks = [];
try {
const version = await simpleGit(projectRoot).version();
checks.push({
name: "Git",
pass: true,
detail: `v${version.major}.${version.minor}.${version.patch}`
});
} catch {
checks.push({
name: "Git",
pass: false,
detail: "Not installed or not in PATH",
severity: "error"
});
}
const hasGit = await pathExists(join(projectRoot, ".git"));
checks.push({
name: "Git repository",
pass: hasGit,
detail: hasGit ? projectRoot : "No .git directory found",
severity: hasGit ? void 0 : "error"
});
const nodeVersion = process.versions.node;
const [major] = nodeVersion.split(".").map(Number);
const nodePass = (major ?? 0) >= 22;
checks.push({
name: "Node.js",
pass: nodePass,
detail: `v${nodeVersion}${nodePass ? "" : " (requires ≥22)"}`,
severity: nodePass ? void 0 : "error"
});
const crDir = contentrainDir(projectRoot);
const hasCrDir = await pathExists(crDir);
const hasConfig = await pathExists(join(crDir, "config.json"));
const hasModels = await pathExists(join(crDir, "models"));
const hasContent = await pathExists(join(crDir, "content"));
const structurePass = hasCrDir && hasConfig && hasModels && hasContent;
checks.push({
name: ".contentrain/ structure",
pass: structurePass,
detail: !hasCrDir ? "Not initialized — run `contentrain init`" : [
hasConfig ? null : "missing config.json",
hasModels ? null : "missing models/",
hasContent ? null : "missing content/"
].filter(Boolean).join(", ") || "OK",
severity: structurePass ? void 0 : "error"
});
let config = null;
if (hasConfig) {
config = await readConfig(projectRoot);
checks.push({
name: "Config",
pass: config !== null,
detail: config ? `stack: ${config.stack}, locales: ${config.locales.supported.join(", ")}` : "Failed to parse config.json",
severity: config ? void 0 : "error"
});
}
if (hasCrDir) try {
const models = await listModels(projectRoot);
const allParseable = (await Promise.all(models.map((m) => readModel(projectRoot, m.id)))).every((r) => r !== null);
checks.push({
name: "Models",
pass: allParseable,
detail: `${models.length} model(s)${allParseable ? ", all valid" : ", some failed to parse"}`,
severity: allParseable ? void 0 : "error"
});
} catch {
checks.push({
name: "Models",
pass: false,
detail: "Failed to read models",
severity: "error"
});
}
if (hasCrDir) {
const orphans = await findOrphanContent(projectRoot);
checks.push({
name: "Orphan content",
pass: orphans.length === 0,
detail: orphans.length === 0 ? "None" : `Found: ${orphans.join(", ")}`,
severity: orphans.length === 0 ? void 0 : "warning"
});
}
if (hasGit) {
try {
const health = await checkBranchHealth(projectRoot);
checks.push({
name: "Pending branches",
pass: !health.blocked && !health.warning,
detail: health.message ?? (health.unmerged === 0 ? "None" : `${health.unmerged} active cr/* branch(es)`),
severity: health.blocked ? "error" : health.warning ? "warning" : void 0
});
} catch {
checks.push({
name: "Pending branches",
pass: true,
detail: "Could not check"
});
}
const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5e3 });
if (remoteList) if (remoteList.error) checks.push({
name: "Remote branches",
pass: true,
detail: `Could not check ${remoteList.remote} (offline?)`,
severity: "info"
});
else {
const count = remoteList.branches.length;
const warn = count >= (config?.branchWarnLimit ?? 50);
checks.push({
name: "Remote branches",
pass: !warn,
detail: count === 0 ? `None on ${remoteList.remote}` : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? " — run `contentrain prune` to remove merged leftovers" : ""}`,
severity: warn ? "warning" : void 0
});
}
}
const clientDir = join(crDir, "client");
const modelsDir = join(crDir, "models");
if (await pathExists(clientDir) && await pathExists(modelsDir)) try {
const [clientMtime, modelsMtime] = await Promise.all([newestFileMtime(clientDir), newestFileMtime(modelsDir)]);
if (clientMtime === null || modelsMtime === null) checks.push({
name: "SDK client",
pass: true,
detail: "Could not check"
});
else {
const fresh = clientMtime >= modelsMtime;
checks.push({
name: "SDK client",
pass: fresh,
detail: fresh ? "Up to date" : "Stale — run `contentrain generate`",
severity: fresh ? void 0 : "warning"
});
}
} catch {
checks.push({
name: "SDK client",
pass: true,
detail: "Could not check"
});
}
let usage;
if (options.usage && hasCrDir && config) {
const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([
analyzeUnusedKeys(projectRoot, config),
analyzeDuplicateValues(projectRoot, config),
analyzeMissingLocaleKeys(projectRoot, config)
]);
usage = {
unusedKeys,
duplicateValues,
missingLocaleKeys
};
checks.push({
name: "Unused content keys",
pass: unusedKeys.length === 0,
detail: unusedKeys.length === 0 ? "All keys referenced in source" : `${unusedKeys.length} key(s) not referenced in source code`,
severity: unusedKeys.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Duplicate dictionary values",
pass: duplicateValues.length === 0,
detail: duplicateValues.length === 0 ? "No duplicate values" : `${duplicateValues.length} value(s) mapped to multiple keys`,
severity: duplicateValues.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Locale key coverage",
pass: missingLocaleKeys.length === 0,
detail: missingLocaleKeys.length === 0 ? "All locales have matching keys" : `${missingLocaleKeys.length} key(s) missing in some locales`,
severity: missingLocaleKeys.length === 0 ? void 0 : "warning"
});
}
const passed = checks.filter((c) => c.pass).length;
const failed = checks.length - passed;
const warnings = checks.filter((c) => !c.pass && c.severity === "warning").length;
const report = {
checks,
summary: {
total: checks.length,
passed,
failed,
warnings
}
};
if (usage) report.usage = usage;
return report;
}
/**
* Newest mtime among the files under `dir`, recursively — null if it holds none.
*
* Stat the files, never the directory. A directory's mtime only moves when an
* entry is added, removed, or renamed inside it: `generate` rewrites the client
* files in place, so `.contentrain/client` never moves after the first run,
* while a selective sync recreates model files via `git checkout`, which does
* move `.contentrain/models`. Comparing the two directories therefore reported
* "stale" permanently after any model save.
*/
async function newestFileMtime(dir) {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
const known = (await Promise.all(entries.map(async (entry) => {
const full = join(dir, entry.name);
if (entry.isDirectory()) return newestFileMtime(full);
return stat(full).then((s) => s.mtimeMs, () => null);
}))).filter((m) => m !== null);
return known.length > 0 ? Math.max(...known) : null;
}
async function findOrphanContent(projectRoot) {
const crDir = contentrainDir(projectRoot);
const models = await listModels(projectRoot);
const orphans = [];
const knownContentDirs = /* @__PURE__ */ new Set();
for (const m of models) {
const full = await readModel(projectRoot, m.id);
const modelForPath = full ? {
...full,
content_path: full.content_path ?? m.content_path
} : {
id: m.id,
name: m.id,
kind: m.kind,
domain: m.domain,
i18n: m.i18n,
fields: {},
content_path: m.content_path
};
knownContentDirs.add(resolveContentDir(projectRoot, modelForPath));
}
const contentDir = join(crDir, "content");
if (await pathExists(contentDir)) {
const domains = await readDir(contentDir);
for (const domain of domains) {
const domainDir = join(contentDir, domain);
const entries = await readDir(domainDir);
for (const entry of entries) {
if (entry === ".gitkeep") continue;
const entryDir = join(domainDir, entry);
if (!knownContentDirs.has(entryDir)) orphans.push(`${domain}/${entry}`);
}
}
}
for (const dir of knownContentDirs) {
if (dir.startsWith(contentDir)) continue;
if (!await pathExists(dir)) {
orphans.push(`(missing custom path) ${dir}`);
continue;
}
await readDir(dir);
}
return orphans;
}
async function analyzeUnusedKeys(projectRoot, config) {
const files = await discoverFiles(projectRoot, { paths: await autoDetectSourceDirs(projectRoot) });
if (files.length === 0) return [];
const allSource = (await Promise.all(files.map(async (relPath) => {
return await readText(join(projectRoot, relPath)) ?? "";
}))).join("\n");
const models = await listModels(projectRoot);
const defaultLocale = config.locales.default;
const unused = [];
for (const m of models) {
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale);
for (const key of keys) if (!allSource.includes(key)) unused.push({
model: m.id,
kind: m.kind,
key,
locale: defaultLocale
});
}
return unused;
}
async function extractContentKeys(projectRoot, model, locale) {
const cDir = resolveContentDir(projectRoot, model);
if (!await pathExists(cDir)) return [];
switch (model.kind) {
case "dictionary": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "collection": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "document": {
const strategy = resolveLocaleStrategy(model);
const slugs = [];
if (!model.i18n) {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
} else if (strategy === "file") {
const dirs = await readDir(cDir);
for (const d of dirs) if (!d.startsWith(".")) slugs.push(d);
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length));
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
if (await pathExists(localeDir)) {
const files = await readDir(localeDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
} else {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
return slugs;
}
case "singleton": return [];
default: return [];
}
}
async function analyzeDuplicateValues(projectRoot, config) {
const models = await listModels(projectRoot);
const result = [];
for (const m of models) {
if (m.kind !== "dictionary") continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
for (const locale of config.locales.supported) {
const data = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
if (!data) continue;
const valueToKeys = /* @__PURE__ */ new Map();
for (const [key, value] of Object.entries(data)) {
const arr = valueToKeys.get(value);
if (arr) arr.push(key);
else valueToKeys.set(value, [key]);
}
for (const [value, keys] of valueToKeys) if (keys.length > 1) result.push({
model: m.id,
locale,
value,
keys
});
}
}
return result;
}
async function analyzeMissingLocaleKeys(projectRoot, config) {
if (config.locales.supported.length < 2) return [];
const models = await listModels(projectRoot);
const result = [];
const defaultLocale = config.locales.default;
const otherLocales = config.locales.supported.filter((l) => l !== defaultLocale);
for (const m of models) {
if (m.kind !== "dictionary" && m.kind !== "collection") continue;
if (!m.i18n) continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
const defaultData = await readJson(resolveJsonFilePath(cDir, fullModel, defaultLocale));
if (!defaultData) continue;
const defaultKeys = new Set(Object.keys(defaultData));
for (const locale of otherLocales) {
const localeData = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
const localeKeys = localeData ? new Set(Object.keys(localeData)) : /* @__PURE__ */ new Set();
for (const key of defaultKeys) if (!localeKeys.has(key)) result.push({
model: m.id,
key,
missingIn: locale
});
}
}
return result;
}
//#endregion
export { runDoctor as t };
//# sourceMappingURL=doctor-D3usxlom.mjs.map
{"version":3,"file":"doctor-D3usxlom.mjs","names":[],"sources":["../src/core/doctor.ts"],"sourcesContent":["import { join } from 'node:path'\nimport { readdir, stat } from 'node:fs/promises'\nimport type { Dirent } from 'node:fs'\nimport { simpleGit } from 'simple-git'\nimport type { ContentrainConfig, ModelDefinition } from '@contentrain/types'\nimport { readConfig } from './config.js'\nimport { listModels, readModel } from './model-manager.js'\nimport { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from './content-manager.js'\nimport { autoDetectSourceDirs, discoverFiles } from './scan-config.js'\nimport { checkBranchHealth, listRemoteCrBranches } from '../git/branch-lifecycle.js'\nimport { contentrainDir, pathExists, readDir, readJson, readText } from '../util/fs.js'\n\n/**\n * Doctor — project health report.\n *\n * The public entry point is `runDoctor(projectRoot, { usage? })`. It is\n * inherently local-filesystem work (Node version, git install, file\n * mtimes, orphan directory detection), so the MCP tool surface gates\n * it behind the `localWorktree` capability — same pattern as\n * `contentrain_setup` and normalize.\n *\n * The report is structured JSON so three consumers can share it:\n *\n * - The `contentrain doctor` CLI command pretty-prints the checks.\n * - The Serve UI `/api/doctor` route returns the report to the\n * Dashboard's Doctor panel.\n * - Automation (CI, Studio) gets a deterministic JSON shape it can\n * assert against.\n *\n * Usage analysis (`--usage`) is a heavier, opt-in branch — it scans\n * every source file in the repo for content-key references. Kept\n * behind the flag so the default doctor run stays fast.\n */\n\nexport type CheckSeverity = 'error' | 'warning' | 'info'\n\nexport interface DoctorCheck {\n name: string\n pass: boolean\n detail: string\n /**\n * `error` — default for failing checks. Blocks a clean bill of health.\n * `warning` — failing-but-not-blocking (e.g. pending branches above\n * threshold, stale SDK client).\n * `info` — passed check; pure informational.\n */\n severity?: CheckSeverity\n}\n\nexport interface UnusedKeyEntry {\n model: string\n kind: string\n key: string\n locale: string\n}\n\nexport interface DuplicateValueEntry {\n model: string\n locale: string\n value: string\n keys: string[]\n}\n\nexport interface MissingLocaleEntry {\n model: string\n key: string\n missingIn: string\n}\n\nexport interface DoctorUsageAnalysis {\n unusedKeys: UnusedKeyEntry[]\n duplicateValues: DuplicateValueEntry[]\n missingLocaleKeys: MissingLocaleEntry[]\n}\n\nexport interface DoctorReport {\n checks: DoctorCheck[]\n summary: {\n total: number\n passed: number\n failed: number\n warnings: number\n }\n /** Present only when `options.usage === true`. */\n usage?: DoctorUsageAnalysis\n}\n\nexport interface RunDoctorOptions {\n /** Run heavier `--usage` analysis (unused keys, duplicates, locale gaps). */\n usage?: boolean\n}\n\nexport async function runDoctor(\n projectRoot: string,\n options: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n const checks: DoctorCheck[] = []\n\n // ─── 1. Git installed ───\n try {\n const git = simpleGit(projectRoot)\n const version = await git.version()\n checks.push({\n name: 'Git',\n pass: true,\n detail: `v${version.major}.${version.minor}.${version.patch}`,\n })\n } catch {\n checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH', severity: 'error' })\n }\n\n // ─── 2. Git repo initialized ───\n const hasGit = await pathExists(join(projectRoot, '.git'))\n checks.push({\n name: 'Git repository',\n pass: hasGit,\n detail: hasGit ? projectRoot : 'No .git directory found',\n severity: hasGit ? undefined : 'error',\n })\n\n // ─── 3. Node version ───\n const nodeVersion = process.versions.node\n const [major] = nodeVersion.split('.').map(Number)\n const nodePass = (major ?? 0) >= 22\n checks.push({\n name: 'Node.js',\n pass: nodePass,\n detail: `v${nodeVersion}${nodePass ? '' : ' (requires ≥22)'}`,\n severity: nodePass ? undefined : 'error',\n })\n\n // ─── 4. .contentrain/ structure ───\n const crDir = contentrainDir(projectRoot)\n const hasCrDir = await pathExists(crDir)\n const hasConfig = await pathExists(join(crDir, 'config.json'))\n const hasModels = await pathExists(join(crDir, 'models'))\n const hasContent = await pathExists(join(crDir, 'content'))\n const structurePass = hasCrDir && hasConfig && hasModels && hasContent\n\n checks.push({\n name: '.contentrain/ structure',\n pass: structurePass,\n detail: !hasCrDir\n ? 'Not initialized — run `contentrain init`'\n : [\n hasConfig ? null : 'missing config.json',\n hasModels ? null : 'missing models/',\n hasContent ? null : 'missing content/',\n ].filter(Boolean).join(', ') || 'OK',\n severity: structurePass ? undefined : 'error',\n })\n\n // ─── 5. Config parseable ───\n let config: ContentrainConfig | null = null\n if (hasConfig) {\n config = await readConfig(projectRoot)\n checks.push({\n name: 'Config',\n pass: config !== null,\n detail: config\n ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}`\n : 'Failed to parse config.json',\n severity: config ? undefined : 'error',\n })\n }\n\n // ─── 6. Models all parseable ───\n if (hasCrDir) {\n try {\n const models = await listModels(projectRoot)\n const parseResults = await Promise.all(models.map(m => readModel(projectRoot, m.id)))\n const allParseable = parseResults.every(r => r !== null)\n checks.push({\n name: 'Models',\n pass: allParseable,\n detail: `${models.length} model(s)${allParseable ? ', all valid' : ', some failed to parse'}`,\n severity: allParseable ? undefined : 'error',\n })\n } catch {\n checks.push({ name: 'Models', pass: false, detail: 'Failed to read models', severity: 'error' })\n }\n }\n\n // ─── 7. Orphan content ───\n if (hasCrDir) {\n const orphans = await findOrphanContent(projectRoot)\n checks.push({\n name: 'Orphan content',\n pass: orphans.length === 0,\n detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`,\n severity: orphans.length === 0 ? undefined : 'warning',\n })\n }\n\n // ─── 8. Stale contentrain branches ───\n if (hasGit) {\n try {\n const health = await checkBranchHealth(projectRoot)\n checks.push({\n name: 'Pending branches',\n pass: !health.blocked && !health.warning,\n detail: health.message\n ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`),\n severity: health.blocked ? 'error' : health.warning ? 'warning' : undefined,\n })\n } catch {\n checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' })\n }\n\n // ─── 8b. Remote cr/* branches ───\n // Authoritative ls-remote count (local branch pressure cannot see the\n // remote pile). Best-effort: skipped entirely without a remote, and an\n // unreachable remote is informational — doctor never fails offline.\n const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5000 })\n if (remoteList) {\n if (remoteList.error) {\n checks.push({\n name: 'Remote branches',\n pass: true,\n detail: `Could not check ${remoteList.remote} (offline?)`,\n severity: 'info',\n })\n } else {\n const count = remoteList.branches.length\n const warnLimit = config?.branchWarnLimit ?? 50\n const warn = count >= warnLimit\n checks.push({\n name: 'Remote branches',\n pass: !warn,\n detail: count === 0\n ? `None on ${remoteList.remote}`\n : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? ' — run `contentrain prune` to remove merged leftovers' : ''}`,\n severity: warn ? 'warning' : undefined,\n })\n }\n }\n }\n\n // ─── 9. SDK client freshness ───\n const clientDir = join(crDir, 'client')\n const modelsDir = join(crDir, 'models')\n if (await pathExists(clientDir) && await pathExists(modelsDir)) {\n try {\n const [clientMtime, modelsMtime] = await Promise.all([\n newestFileMtime(clientDir),\n newestFileMtime(modelsDir),\n ])\n if (clientMtime === null || modelsMtime === null) {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n } else {\n const fresh = clientMtime >= modelsMtime\n checks.push({\n name: 'SDK client',\n pass: fresh,\n detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`',\n severity: fresh ? undefined : 'warning',\n })\n }\n } catch {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n }\n }\n\n // ─── 10–12. Usage analysis (optional) ───\n let usage: DoctorUsageAnalysis | undefined\n if (options.usage && hasCrDir && config) {\n const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([\n analyzeUnusedKeys(projectRoot, config),\n analyzeDuplicateValues(projectRoot, config),\n analyzeMissingLocaleKeys(projectRoot, config),\n ])\n usage = { unusedKeys, duplicateValues, missingLocaleKeys }\n\n checks.push({\n name: 'Unused content keys',\n pass: unusedKeys.length === 0,\n detail: unusedKeys.length === 0\n ? 'All keys referenced in source'\n : `${unusedKeys.length} key(s) not referenced in source code`,\n severity: unusedKeys.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Duplicate dictionary values',\n pass: duplicateValues.length === 0,\n detail: duplicateValues.length === 0\n ? 'No duplicate values'\n : `${duplicateValues.length} value(s) mapped to multiple keys`,\n severity: duplicateValues.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Locale key coverage',\n pass: missingLocaleKeys.length === 0,\n detail: missingLocaleKeys.length === 0\n ? 'All locales have matching keys'\n : `${missingLocaleKeys.length} key(s) missing in some locales`,\n severity: missingLocaleKeys.length === 0 ? undefined : 'warning',\n })\n }\n\n const passed = checks.filter(c => c.pass).length\n const failed = checks.length - passed\n const warnings = checks.filter(c => !c.pass && c.severity === 'warning').length\n\n const report: DoctorReport = {\n checks,\n summary: { total: checks.length, passed, failed, warnings },\n }\n if (usage) report.usage = usage\n return report\n}\n\n/**\n * Newest mtime among the files under `dir`, recursively — null if it holds none.\n *\n * Stat the files, never the directory. A directory's mtime only moves when an\n * entry is added, removed, or renamed inside it: `generate` rewrites the client\n * files in place, so `.contentrain/client` never moves after the first run,\n * while a selective sync recreates model files via `git checkout`, which does\n * move `.contentrain/models`. Comparing the two directories therefore reported\n * \"stale\" permanently after any model save.\n */\nasync function newestFileMtime(dir: string): Promise<number | null> {\n const entries: Dirent[] = await readdir(dir, { withFileTypes: true }).catch(() => [])\n const mtimes = await Promise.all(entries.map(async (entry) => {\n const full = join(dir, entry.name)\n if (entry.isDirectory()) return newestFileMtime(full)\n return stat(full).then(s => s.mtimeMs, () => null)\n }))\n const known = mtimes.filter((m): m is number => m !== null)\n return known.length > 0 ? Math.max(...known) : null\n}\n\nasync function findOrphanContent(projectRoot: string): Promise<string[]> {\n const crDir = contentrainDir(projectRoot)\n const models = await listModels(projectRoot)\n const orphans: string[] = []\n\n const knownContentDirs = new Set<string>()\n for (const m of models) {\n const full = await readModel(projectRoot, m.id)\n const modelForPath = full\n ? {\n ...full,\n content_path: full.content_path ?? (m as { content_path?: string }).content_path,\n }\n : {\n id: m.id,\n name: m.id,\n kind: m.kind,\n domain: m.domain,\n i18n: m.i18n,\n fields: {},\n content_path: (m as { content_path?: string }).content_path,\n }\n knownContentDirs.add(resolveContentDir(projectRoot, modelForPath))\n }\n\n const contentDir = join(crDir, 'content')\n if (await pathExists(contentDir)) {\n const domains = await readDir(contentDir)\n for (const domain of domains) {\n const domainDir = join(contentDir, domain)\n const entries = await readDir(domainDir)\n for (const entry of entries) {\n if (entry === '.gitkeep') continue\n const entryDir = join(domainDir, entry)\n if (!knownContentDirs.has(entryDir)) {\n orphans.push(`${domain}/${entry}`)\n }\n }\n }\n }\n\n for (const dir of knownContentDirs) {\n if (dir.startsWith(contentDir)) continue\n if (!await pathExists(dir)) {\n orphans.push(`(missing custom path) ${dir}`)\n continue\n }\n await readDir(dir)\n }\n\n return orphans\n}\n\nasync function analyzeUnusedKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<UnusedKeyEntry[]> {\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, { paths: sourceDirs })\n if (files.length === 0) return []\n\n const chunks = await Promise.all(\n files.map(async (relPath) => {\n const content = await readText(join(projectRoot, relPath))\n return content ?? ''\n }),\n )\n const allSource = chunks.join('\\n')\n\n const models = await listModels(projectRoot)\n const defaultLocale = config.locales.default\n const unused: UnusedKeyEntry[] = []\n\n for (const m of models) {\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale)\n for (const key of keys) {\n if (!allSource.includes(key)) {\n unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale })\n }\n }\n }\n\n return unused\n}\n\nasync function extractContentKeys(\n projectRoot: string,\n model: ModelDefinition,\n locale: string,\n): Promise<string[]> {\n const cDir = resolveContentDir(projectRoot, model)\n if (!await pathExists(cDir)) return []\n\n switch (model.kind) {\n case 'dictionary': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, string>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'collection': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, Record<string, unknown>>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'document': {\n const strategy = resolveLocaleStrategy(model)\n const slugs: string[] = []\n if (!model.i18n) {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n } else if (strategy === 'file') {\n const dirs = await readDir(cDir)\n for (const d of dirs) if (!d.startsWith('.')) slugs.push(d)\n } else if (strategy === 'suffix') {\n const files = await readDir(cDir)\n const suffix = `.${locale}.md`\n for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length))\n } else if (strategy === 'directory') {\n const localeDir = join(cDir, locale)\n if (await pathExists(localeDir)) {\n const files = await readDir(localeDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n } else {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n return slugs\n }\n case 'singleton':\n return []\n default:\n return []\n }\n}\n\nasync function analyzeDuplicateValues(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<DuplicateValueEntry[]> {\n const models = await listModels(projectRoot)\n const result: DuplicateValueEntry[] = []\n\n for (const m of models) {\n if (m.kind !== 'dictionary') continue\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n for (const locale of config.locales.supported) {\n const filePath = resolveJsonFilePath(cDir, fullModel, locale)\n const data = await readJson<Record<string, string>>(filePath)\n if (!data) continue\n\n const valueToKeys = new Map<string, string[]>()\n for (const [key, value] of Object.entries(data)) {\n const arr = valueToKeys.get(value)\n if (arr) arr.push(key)\n else valueToKeys.set(value, [key])\n }\n\n for (const [value, keys] of valueToKeys) {\n if (keys.length > 1) {\n result.push({ model: m.id, locale, value, keys })\n }\n }\n }\n }\n\n return result\n}\n\nasync function analyzeMissingLocaleKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<MissingLocaleEntry[]> {\n if (config.locales.supported.length < 2) return []\n\n const models = await listModels(projectRoot)\n const result: MissingLocaleEntry[] = []\n const defaultLocale = config.locales.default\n const otherLocales = config.locales.supported.filter(l => l !== defaultLocale)\n\n for (const m of models) {\n if (m.kind !== 'dictionary' && m.kind !== 'collection') continue\n if (!m.i18n) continue\n\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale)\n const defaultData = await readJson<Record<string, unknown>>(defaultPath)\n if (!defaultData) continue\n const defaultKeys = new Set(Object.keys(defaultData))\n\n for (const locale of otherLocales) {\n const localePath = resolveJsonFilePath(cDir, fullModel, locale)\n const localeData = await readJson<Record<string, unknown>>(localePath)\n const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set<string>()\n\n for (const key of defaultKeys) {\n if (!localeKeys.has(key)) {\n result.push({ model: m.id, key, missingIn: locale })\n }\n }\n }\n }\n\n return result\n}\n"],"mappings":"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"}
import { t as LOCAL_CAPABILITIES } from "./contracts-DfL0BfrD.mjs";
import { a as applyChangesToWorktree } from "./ops-B422KP_S.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { C as LocalReader } from "./model-manager-Rcj5Wzct.mjs";
import { a as deleteRemoteBranch, r as classifyMergedBranches } from "./branch-lifecycle-BAfgSQBv.mjs";
import { i as mergeBranch$1, n as createTransaction } from "./transaction-C1P-WnVo.mjs";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { simpleGit } from "simple-git";
//#region src/providers/local/branch-ops.ts
/**
* Branch/merge/diff helpers backed by the local simple-git worktree flow.
*
* Pure functions composed into `LocalProvider` — mirroring the shape of
* `providers/github/branch-ops.ts` so the two providers share the same
* surface at the `RepoProvider` boundary.
*/
async function getDefaultBranch(projectRoot) {
const config = await readConfig(projectRoot);
if (config?.repository?.default_branch) return config.repository.default_branch;
const envBranch = process.env["CONTENTRAIN_BRANCH"];
if (envBranch) return envBranch;
return (await simpleGit(projectRoot).raw(["branch", "--show-current"])).trim() || "main";
}
async function listBranches(projectRoot, prefix) {
const summary = await simpleGit(projectRoot).branchLocal();
const names = prefix ? summary.all.filter((n) => n.startsWith(prefix)) : summary.all;
const branches = [];
for (const name of names) {
const info = summary.branches[name];
branches.push({
name,
sha: info?.commit ?? ""
});
}
return branches;
}
async function createBranch(projectRoot, name, fromRef) {
await simpleGit(projectRoot).raw([
"branch",
name,
fromRef
]);
}
async function deleteBranch(projectRoot, name) {
await simpleGit(projectRoot).deleteLocalBranch(name, true);
await deleteRemoteBranch(projectRoot, name);
}
async function getBranchDiff(projectRoot, branch, base) {
const raw = await simpleGit(projectRoot).raw([
"diff",
"--name-status",
`${base}...${branch}`
]);
const diffs = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
const [code, ...pathParts] = line.split(" ");
const path = pathParts[pathParts.length - 1];
if (!code || !path) continue;
const status = code.startsWith("A") ? "added" : code.startsWith("D") ? "removed" : "modified";
diffs.push({
path,
status,
before: null,
after: null
});
}
return diffs;
}
async function mergeBranch(projectRoot, branch, into) {
if (into !== CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`LocalProvider.mergeBranch only supports merging into "${CONTENTRAIN_BRANCH}" (got "${into}"). The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`), {
code: "UNSUPPORTED_MERGE_TARGET",
agent_hint: `Pass "${CONTENTRAIN_BRANCH}" as the merge target, or use a non-local provider that supports arbitrary targets.`,
developer_action: `Merge "${branch}" into "${CONTENTRAIN_BRANCH}" instead.`
});
const result = await mergeBranch$1(projectRoot, branch);
return {
merged: true,
sha: result.commit,
pullRequestUrl: null,
sync: result.sync,
...result.remote ? { remote: result.remote } : {}
};
}
async function isMerged(projectRoot, branch, into) {
try {
return (await classifyMergedBranches(projectRoot, [branch], into)).has(branch);
} catch {
return false;
}
}
//#endregion
//#region src/providers/local/provider.ts
const DEFAULT_AUTHOR_NAME = "Contentrain";
const DEFAULT_AUTHOR_EMAIL = "ai@contentrain.io";
/**
* LocalProvider — the local-filesystem, worktree-backed content provider.
*
* Implements the full `RepoProvider` surface:
* - Reader methods delegate to `LocalReader`.
* - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`
* (a superset of `Commit` carrying workflow action + selective sync).
* - Branch ops mirror `GitHubProvider` — thin wrappers over the local
* simple-git helpers in `./branch-ops.ts`.
*
* `mergeBranch` only supports merging into the singleton
* `CONTENTRAIN_BRANCH`; the local flow advances the base branch via
* `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets
* would bypass that invariant.
*/
var LocalProvider = class {
capabilities = LOCAL_CAPABILITIES;
reader;
constructor(projectRoot) {
this.projectRoot = projectRoot;
this.reader = new LocalReader(projectRoot);
}
readFile(path, ref) {
return this.reader.readFile(path, ref);
}
listDirectory(path, ref) {
return this.reader.listDirectory(path, ref);
}
fileExists(path, ref) {
return this.reader.fileExists(path, ref);
}
async applyPlan(input) {
const tx = await createTransaction(this.projectRoot, input.branch, { workflowOverride: input.workflowOverride });
try {
await tx.write(async (wt) => {
await applyChangesToWorktree(wt, input.changes);
});
await tx.commit(input.message, input.context);
const gitResult = await tx.complete();
return {
sha: gitResult.commit,
message: input.message,
author: {
name: process.env["CONTENTRAIN_AUTHOR_NAME"] ?? DEFAULT_AUTHOR_NAME,
email: process.env["CONTENTRAIN_AUTHOR_EMAIL"] ?? DEFAULT_AUTHOR_EMAIL
},
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
workflowAction: gitResult.action,
sync: gitResult.sync,
warning: gitResult.warning
};
} finally {
await tx.cleanup();
}
}
listBranches(prefix) {
return listBranches(this.projectRoot, prefix);
}
async createBranch(name, fromRef) {
const resolved = fromRef ?? CONTENTRAIN_BRANCH;
await createBranch(this.projectRoot, name, resolved);
}
deleteBranch(name) {
return deleteBranch(this.projectRoot, name);
}
getBranchDiff(branch, base) {
const resolved = base ?? CONTENTRAIN_BRANCH;
return getBranchDiff(this.projectRoot, branch, resolved);
}
mergeBranch(branch, into) {
return mergeBranch(this.projectRoot, branch, into);
}
isMerged(branch, into) {
const resolved = into ?? CONTENTRAIN_BRANCH;
return isMerged(this.projectRoot, branch, resolved);
}
getDefaultBranch() {
return getDefaultBranch(this.projectRoot);
}
};
//#endregion
export { isMerged as n, LocalProvider as t };
//# sourceMappingURL=local-CUOVOu5U.mjs.map
{"version":3,"file":"local-CUOVOu5U.mjs","names":["mergeBranchOp","listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../src/providers/local/branch-ops.ts","../src/providers/local/provider.ts"],"sourcesContent":["import { simpleGit } from 'simple-git'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport { readConfig } from '../../core/config.js'\nimport { classifyMergedBranches, deleteRemoteBranch } from '../../git/branch-lifecycle.js'\nimport { mergeBranch as mergeBranchOp } from '../../git/transaction.js'\n\n/**\n * Branch/merge/diff helpers backed by the local simple-git worktree flow.\n *\n * Pure functions composed into `LocalProvider` — mirroring the shape of\n * `providers/github/branch-ops.ts` so the two providers share the same\n * surface at the `RepoProvider` boundary.\n */\n\nexport async function getDefaultBranch(projectRoot: string): Promise<string> {\n const config = await readConfig(projectRoot)\n if (config?.repository?.default_branch) return config.repository.default_branch\n const envBranch = process.env['CONTENTRAIN_BRANCH']\n if (envBranch) return envBranch\n const git = simpleGit(projectRoot)\n const current = (await git.raw(['branch', '--show-current'])).trim()\n return current || 'main'\n}\n\nexport async function listBranches(\n projectRoot: string,\n prefix?: string,\n): Promise<Branch[]> {\n const git = simpleGit(projectRoot)\n const summary = await git.branchLocal()\n const names = prefix\n ? summary.all.filter(n => n.startsWith(prefix))\n : summary.all\n const branches: Branch[] = []\n for (const name of names) {\n const info = summary.branches[name]\n branches.push({ name, sha: info?.commit ?? '' })\n }\n return branches\n}\n\nexport async function createBranch(\n projectRoot: string,\n name: string,\n fromRef: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.raw(['branch', name, fromRef])\n}\n\nexport async function deleteBranch(\n projectRoot: string,\n name: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.deleteLocalBranch(name, true)\n // Parity with the remote-API providers, whose deleteBranch removes the\n // remote ref: best-effort, config-gated inside the helper, never throws.\n await deleteRemoteBranch(projectRoot, name)\n}\n\nexport async function getBranchDiff(\n projectRoot: string,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const git = simpleGit(projectRoot)\n const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`])\n const diffs: FileDiff[] = []\n for (const line of raw.split('\\n')) {\n if (!line.trim()) continue\n const [code, ...pathParts] = line.split('\\t')\n const path = pathParts[pathParts.length - 1]\n if (!code || !path) continue\n const status: FileDiff['status'] = code.startsWith('A')\n ? 'added'\n : code.startsWith('D')\n ? 'removed'\n : 'modified'\n diffs.push({ path, status, before: null, after: null })\n }\n return diffs\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n if (into !== CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `LocalProvider.mergeBranch only supports merging into \"${CONTENTRAIN_BRANCH}\" (got \"${into}\"). `\n + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`,\n ), {\n code: 'UNSUPPORTED_MERGE_TARGET',\n agent_hint: `Pass \"${CONTENTRAIN_BRANCH}\" as the merge target, or use a non-local provider that supports arbitrary targets.`,\n developer_action: `Merge \"${branch}\" into \"${CONTENTRAIN_BRANCH}\" instead.`,\n })\n }\n const result = await mergeBranchOp(projectRoot, branch)\n return {\n merged: true,\n sha: result.commit,\n pullRequestUrl: null,\n sync: result.sync,\n ...(result.remote ? { remote: result.remote } : {}),\n }\n}\n\nexport async function isMerged(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<boolean> {\n try {\n const merged = await classifyMergedBranches(projectRoot, [branch], into)\n return merged.has(branch)\n } catch {\n return false\n }\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type {\n Branch,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { LOCAL_CAPABILITIES } from '../../core/contracts/index.js'\nimport { applyChangesToWorktree } from '../../core/ops/index.js'\nimport { createTransaction } from '../../git/transaction.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { LocalReader } from './reader.js'\nimport type { LocalApplyPlanInput, LocalApplyResult } from './types.js'\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * LocalProvider — the local-filesystem, worktree-backed content provider.\n *\n * Implements the full `RepoProvider` surface:\n * - Reader methods delegate to `LocalReader`.\n * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`\n * (a superset of `Commit` carrying workflow action + selective sync).\n * - Branch ops mirror `GitHubProvider` — thin wrappers over the local\n * simple-git helpers in `./branch-ops.ts`.\n *\n * `mergeBranch` only supports merging into the singleton\n * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via\n * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets\n * would bypass that invariant.\n */\nexport class LocalProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES\n private readonly reader: LocalReader\n\n constructor(public readonly projectRoot: string) {\n this.reader = new LocalReader(projectRoot)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n async applyPlan(input: LocalApplyPlanInput): Promise<LocalApplyResult> {\n const tx = await createTransaction(this.projectRoot, input.branch, {\n workflowOverride: input.workflowOverride,\n })\n try {\n await tx.write(async (wt) => {\n await applyChangesToWorktree(wt, input.changes)\n })\n await tx.commit(input.message, input.context)\n const gitResult = await tx.complete()\n return {\n sha: gitResult.commit,\n message: input.message,\n author: {\n name: process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME,\n email: process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL,\n },\n timestamp: new Date().toISOString(),\n workflowAction: gitResult.action,\n sync: gitResult.sync,\n warning: gitResult.warning,\n }\n } finally {\n await tx.cleanup()\n }\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.projectRoot, prefix)\n }\n\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? CONTENTRAIN_BRANCH\n await createBranchOp(this.projectRoot, name, resolved)\n }\n\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.projectRoot, name)\n }\n\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? CONTENTRAIN_BRANCH\n return getBranchDiffOp(this.projectRoot, branch, resolved)\n }\n\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.projectRoot, branch, into)\n }\n\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? CONTENTRAIN_BRANCH\n return isMergedOp(this.projectRoot, branch, resolved)\n }\n\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.projectRoot)\n }\n}\n"],"mappings":"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"}
import { n as contentFilePath, r as documentFilePath, t as contentDirPath } from "./paths-CmVw5Cw2.mjs";
import { a as readJson, c as writeText, i as readDir, n as ensureDir, o as readText, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { i as writeMeta, n as mergeEntryMeta, r as readMeta, t as deleteMeta } from "./meta-manager-CJUiTgP2.mjs";
import { join, resolve } from "node:path";
import { generateEntryId, parseMarkdownFrontmatter, parseMarkdownFrontmatter as parseFrontmatter, serializeMarkdownFrontmatter, serializeMarkdownFrontmatter as serializeFrontmatter, validateEntryId as validateEntryId$1, validateLocale as validateLocale$1, validateSlug as validateSlug$1 } from "@contentrain/types";
import { access, readFile, readdir, rm } from "node:fs/promises";
import { z } from "zod";
//#region src/providers/local/reader.ts
/**
* LocalReader — `RepoReader` backed by the local filesystem.
*
* Paths may be absolute or relative to `projectRoot` (`node:path/resolve`
* handles both). The `ref` parameter is accepted for interface compatibility
* but ignored because LocalReader always reads from the working tree.
*
* Phase 1: provided as plumbing; core ops still use direct `fs` calls.
* Phase 2 routes ops through this reader (and through GitHubProvider's reader
* in Phase 5) so the same op surface works on every backing store.
*/
var LocalReader = class {
constructor(projectRoot) {
this.projectRoot = projectRoot;
}
async readFile(path, _ref) {
return readFile(resolve(this.projectRoot, path), "utf-8");
}
async listDirectory(path, _ref) {
try {
return await readdir(resolve(this.projectRoot, path));
} catch {
return [];
}
}
async fileExists(path, _ref) {
try {
await access(resolve(this.projectRoot, path));
return true;
} catch {
return false;
}
}
};
//#endregion
//#region src/core/content-manager.ts
function resolveContentDir(projectRoot, model) {
if (model.content_path) return join(projectRoot, model.content_path);
return join(contentrainDir(projectRoot), "content", model.domain, model.id);
}
function resolveLocaleStrategy(model) {
return model.locale_strategy ?? "file";
}
/** Build the file path for a JSON content file (singleton/collection/dictionary) */
function resolveJsonFilePath(dir, model, locale) {
if (!model.i18n) return join(dir, "data.json");
switch (resolveLocaleStrategy(model)) {
case "suffix": return join(dir, `${model.id}.${locale}.json`);
case "directory": return join(dir, locale, `${model.id}.json`);
case "none": return join(dir, `${model.id}.json`);
default: return join(dir, `${locale}.json`);
}
}
/** Build the file path for a markdown document */
function resolveMdFilePath(dir, model, locale, slug) {
if (!model.i18n) return join(dir, `${slug}.md`);
switch (resolveLocaleStrategy(model)) {
case "suffix": return join(dir, `${slug}.${locale}.md`);
case "directory": return join(dir, locale, `${slug}.md`);
case "none": return join(dir, `${slug}.md`);
default: return join(dir, slug, `${locale}.md`);
}
}
async function writeContent(projectRoot, model, entries, config, vocabulary) {
const results = [];
const defaultLocale = config.locales.default;
for (const entry of entries) {
const locale = entry.locale ?? defaultLocale;
const localeErr = validateLocale$1(locale, config);
if (localeErr) throw new Error(localeErr);
if (entry.id) {
const idErr = validateEntryId$1(entry.id);
if (idErr) throw new Error(idErr);
}
if (entry.slug) {
const slugErr = validateSlug$1(entry.slug);
if (slugErr) throw new Error(slugErr);
}
switch (model.kind) {
case "singleton": {
await writeJson(resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale), entry.data);
const prevMeta = await readMeta(projectRoot, model, {
locale,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
defaultLocale
}, mergeEntryMeta(prevMeta ?? void 0, entry.data));
results.push({
action: "updated",
locale
});
break;
}
case "collection": {
const isNew = !entry.id;
const id = entry.id ?? generateEntryId();
const filePath = resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale);
const existing = await readJson(filePath) ?? {};
const action = isNew || !(id in existing) ? "created" : "updated";
existing[id] = entry.data;
const sorted = {};
for (const key of Object.keys(existing).toSorted()) sorted[key] = existing[key];
await writeJson(filePath, sorted);
const prevMetaMap = await readMeta(projectRoot, model, {
locale,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
entryId: id,
defaultLocale
}, mergeEntryMeta(prevMetaMap?.[id], entry.data));
results.push({
action,
id,
locale
});
break;
}
case "document": {
const slug = entry.slug ?? entry.data["slug"];
if (!slug) throw new Error("Document entries require a slug");
const slugErr = validateSlug$1(slug);
if (slugErr) throw new Error(slugErr);
const bodyContent = entry.data["body"] ?? "";
const fmData = { ...entry.data };
delete fmData["body"];
if (!fmData["slug"]) fmData["slug"] = slug;
const docPath = resolveMdFilePath(resolveContentDir(projectRoot, model), model, locale, slug);
const action = await readText(docPath) ? "updated" : "created";
await writeText(docPath, serializeMarkdownFrontmatter(fmData, bodyContent));
const prevMeta = await readMeta(projectRoot, model, {
locale,
slug,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
slug,
defaultLocale
}, mergeEntryMeta(prevMeta ?? void 0, entry.data));
results.push({
action,
slug,
locale
});
break;
}
case "dictionary": {
const filePath = resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale);
const existing = await readJson(filePath) ?? {};
const collisions = [];
for (const key of Object.keys(entry.data)) if (key in existing && existing[key] !== entry.data[key]) collisions.push(key);
if (collisions.length > 0) throw new Error(`Dictionary "${model.id}" (${locale}): ${collisions.length} key collision(s) — [${collisions.join(", ")}] already exist with different values. Read existing keys with contentrain_content_list first, or include all keys in a single save call.`);
const advisories = [];
const reverseMap = /* @__PURE__ */ new Map();
for (const [k, v] of Object.entries(existing)) reverseMap.set(v, k);
for (const [newKey, newValue] of Object.entries(entry.data)) {
if (newKey in existing) continue;
const existingKey = reverseMap.get(newValue);
if (existingKey && existingKey !== newKey) advisories.push(`Value "${newValue}" already exists as key "${existingKey}". Consider reusing instead of creating "${newKey}".`);
}
if (vocabulary && Object.keys(vocabulary.terms).length > 0) for (const [newKey, newValue] of Object.entries(entry.data)) {
if (newKey in existing) continue;
for (const [, translations] of Object.entries(vocabulary.terms)) if (Object.values(translations).includes(newValue)) {
advisories.push(`Value "${newValue}" matches a vocabulary term. Use the canonical form for consistency.`);
break;
}
}
await writeJson(filePath, {
...existing,
...entry.data
});
const prevMeta = await readMeta(projectRoot, model, {
locale,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
defaultLocale
}, mergeEntryMeta(prevMeta ?? void 0, entry.data));
results.push({
action: "updated",
locale,
...advisories.length > 0 ? { advisories } : {}
});
break;
}
}
}
return results;
}
async function deleteContent(projectRoot, model, opts) {
const removed = [];
const cDir = resolveContentDir(projectRoot, model);
switch (model.kind) {
case "collection": {
if (!opts.id) throw new Error("Collection delete requires an entry ID");
const locales = opts.locale ? [opts.locale] : (await readDir(cDir)).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", "").replace(`${model.id}.`, ""));
for (const loc of locales) {
const filePath = resolveJsonFilePath(cDir, model, loc);
const data = await readJson(filePath);
if (data && opts.id in data) {
delete data[opts.id];
await writeJson(filePath, data);
removed.push(`content/${model.domain}/${model.id}/${loc}.json#${opts.id}`);
}
}
for (const loc of locales) await deleteMeta(projectRoot, model, {
locale: loc,
entryId: opts.id,
defaultLocale: opts.defaultLocale
});
break;
}
case "document": {
if (!opts.slug) throw new Error("Document delete requires a slug");
const slugDelErr = validateSlug$1(opts.slug);
if (slugDelErr) throw new Error(slugDelErr);
const strategy = resolveLocaleStrategy(model);
if (!model.i18n) await rm(join(cDir, `${opts.slug}.md`), { force: true });
else if (strategy === "file") await rm(join(cDir, opts.slug), {
recursive: true,
force: true
});
else if (opts.locale) await rm(resolveMdFilePath(cDir, model, opts.locale, opts.slug), { force: true });
else {
const files = await readDir(strategy === "directory" ? cDir : cDir);
for (const f of files) if (strategy === "suffix" && f.startsWith(`${opts.slug}.`) && f.endsWith(".md")) await rm(join(cDir, f), { force: true });
else if (strategy === "directory") await rm(join(cDir, f, `${opts.slug}.md`), { force: true });
else if (strategy === "none") {
if (f === `${opts.slug}.md`) await rm(join(cDir, f), { force: true });
}
}
removed.push(`${model.content_path ?? `content/${model.domain}/${model.id}`}/${opts.slug}`);
await deleteMeta(projectRoot, model, {
slug: opts.slug,
locale: opts.locale,
defaultLocale: opts.defaultLocale
});
break;
}
case "singleton": {
if (model.i18n && !opts.locale) throw new Error("Singleton delete requires a locale when i18n is enabled");
const locale = opts.locale ?? "data";
await rm(resolveJsonFilePath(cDir, model, locale), { force: true });
removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
await deleteMeta(projectRoot, model, {
locale: model.i18n ? locale : void 0,
defaultLocale: opts.defaultLocale
});
break;
}
case "dictionary": {
if (model.i18n && !opts.locale) throw new Error("Dictionary delete requires a locale when i18n is enabled");
const locale = opts.locale ?? "data";
const filePath = resolveJsonFilePath(cDir, model, locale);
if (opts.keys?.length) {
const existing = await readJson(filePath) ?? {};
const notFound = [];
for (const key of opts.keys) if (key in existing) delete existing[key];
else notFound.push(key);
if (notFound.length > 0) throw new Error(`Dictionary "${model.id}" (${locale}): keys not found — [${notFound.join(", ")}]`);
await writeJson(filePath, existing);
removed.push(...opts.keys.map((k) => `${model.id}/${locale}:${k}`));
} else {
await rm(filePath, { force: true });
removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
await deleteMeta(projectRoot, model, {
locale: model.i18n ? locale : void 0,
defaultLocale: opts.defaultLocale
});
}
break;
}
}
return removed;
}
async function listContent(input, model, opts, config) {
if (typeof input !== "string") return listContentViaReader(input, model, opts, config);
return listContentLocal(input, model, opts, config);
}
async function listContentLocal(projectRoot, model, opts, config) {
const cDir = resolveContentDir(projectRoot, model);
const locale = opts.locale ?? config.locales.default;
switch (model.kind) {
case "singleton": return {
kind: "singleton",
data: await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {},
locale
};
case "collection": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {};
let entries = Object.entries(data).map(([id, fields]) => {
const entry = { id };
Object.assign(entry, fields);
return entry;
});
if (opts.filter) entries = entries.filter((entry) => {
for (const [key, value] of Object.entries(opts.filter)) if (entry[key] !== value) return false;
return true;
});
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
entries = entries.slice(offset, offset + limit);
if (opts.resolve && model.fields) entries = await resolveRelations(projectRoot, model, entries, locale);
return {
kind: "collection",
data: entries,
total,
locale,
offset,
limit
};
}
case "document": {
const entries = [];
const strategy = resolveLocaleStrategy(model);
if (!model.i18n) {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else if (strategy === "file") {
const slugDirs = await readDir(cDir);
for (const slug of slugDirs) {
const raw = await readText(join(cDir, slug, `${locale}.md`));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) {
if (!f.endsWith(suffix)) continue;
const slug = f.slice(0, -suffix.length);
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
const files = await readDir(localeDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(localeDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
}
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
return {
kind: "document",
data: entries.slice(offset, offset + limit),
total,
locale,
offset,
limit
};
}
case "dictionary": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {};
return {
kind: "dictionary",
data,
total_keys: Object.keys(data).length,
locale
};
}
}
}
async function tryReadJsonViaReader$1(reader, path) {
try {
return JSON.parse(await reader.readFile(path));
} catch {
return null;
}
}
async function tryReadTextViaReader(reader, path) {
try {
return await reader.readFile(path);
} catch {
return null;
}
}
async function listContentViaReader(reader, model, opts, config) {
if (opts.resolve) throw new Error("contentrain_content_list with resolve:true requires local filesystem access. Use a LocalProvider (stdio or HTTP+LocalProvider) or omit resolve:true.");
const cDir = contentDirPath(model);
const locale = opts.locale ?? config.locales.default;
switch (model.kind) {
case "singleton": return {
kind: "singleton",
data: await tryReadJsonViaReader$1(reader, contentFilePath(model, locale)) ?? {},
locale
};
case "collection": {
const data = await tryReadJsonViaReader$1(reader, contentFilePath(model, locale)) ?? {};
let entries = Object.entries(data).map(([id, fields]) => {
const entry = { id };
Object.assign(entry, fields);
return entry;
});
if (opts.filter) entries = entries.filter((entry) => {
for (const [key, value] of Object.entries(opts.filter)) if (entry[key] !== value) return false;
return true;
});
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
entries = entries.slice(offset, offset + limit);
return {
kind: "collection",
data: entries,
total,
locale,
offset,
limit
};
}
case "document": {
const entries = [];
const strategy = resolveLocaleStrategy(model);
const collectEntry = async (relPath, slug) => {
const raw = await tryReadTextViaReader(reader, relPath);
if (!raw) return;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
};
if (!model.i18n) {
const files = await reader.listDirectory(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
await collectEntry(documentFilePath(model, locale, f.replace(/\.md$/u, "")), f.replace(/\.md$/u, ""));
}
} else if (strategy === "file") {
const slugDirs = await reader.listDirectory(cDir);
for (const slug of slugDirs) await collectEntry(documentFilePath(model, locale, slug), slug);
} else if (strategy === "suffix") {
const files = await reader.listDirectory(cDir);
const suffix = `.${locale}.md`;
for (const f of files) {
if (!f.endsWith(suffix)) continue;
const slug = f.slice(0, -suffix.length);
await collectEntry(documentFilePath(model, locale, slug), slug);
}
} else if (strategy === "directory") {
const files = await reader.listDirectory(`${cDir}/${locale}`);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(/\.md$/u, "");
await collectEntry(documentFilePath(model, locale, slug), slug);
}
} else {
const files = await reader.listDirectory(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(/\.md$/u, "");
await collectEntry(documentFilePath(model, locale, slug), slug);
}
}
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
return {
kind: "document",
data: entries.slice(offset, offset + limit),
total,
locale,
offset,
limit
};
}
case "dictionary": {
const data = await tryReadJsonViaReader$1(reader, contentFilePath(model, locale)) ?? {};
return {
kind: "dictionary",
data,
total_keys: Object.keys(data).length,
locale
};
}
}
}
async function readContent(projectRoot, model, opts) {
const cDir = resolveContentDir(projectRoot, model);
switch (model.kind) {
case "singleton": return readJson(resolveJsonFilePath(cDir, model, opts.locale));
case "collection": {
if (!opts.entryId) return null;
const data = await readJson(resolveJsonFilePath(cDir, model, opts.locale));
return data?.[opts.entryId] ? {
id: opts.entryId,
...data[opts.entryId]
} : null;
}
case "document": {
if (!opts.slug) return null;
const raw = await readText(resolveMdFilePath(cDir, model, opts.locale, opts.slug));
if (!raw) return null;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
return {
slug: opts.slug,
...frontmatter,
body
};
}
case "dictionary": return readJson(resolveJsonFilePath(cDir, model, opts.locale));
}
}
async function resolveRelations(projectRoot, model, entries, locale) {
if (!model.fields) return entries;
const relationFields = [];
for (const [name, field] of Object.entries(model.fields)) if (field.type === "relation" || field.type === "relations") {
const targets = Array.isArray(field.model) ? field.model : field.model ? [field.model] : [];
relationFields.push({
name,
targetModels: targets,
multi: field.type === "relations"
});
}
if (relationFields.length === 0) return entries;
const targetCache = {};
const visited = new Set([model.id]);
for (const rf of relationFields) for (const targetModelId of rf.targetModels) {
if (targetCache[targetModelId] || visited.has(targetModelId)) continue;
visited.add(targetModelId);
const targetModel = await readModel(projectRoot, targetModelId);
if (!targetModel) continue;
if (targetModel.kind === "collection") targetCache[targetModelId] = await readJson(resolveJsonFilePath(resolveContentDir(projectRoot, targetModel), targetModel, locale)) ?? {};
else if (targetModel.kind === "document") {
const docCache = {};
const cDir = resolveContentDir(projectRoot, targetModel);
const strategy = resolveLocaleStrategy(targetModel);
if (!targetModel.i18n) {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else if (strategy === "file") {
const slugDirs = await readDir(cDir);
for (const slug of slugDirs) {
const raw = await readText(join(cDir, slug, `${locale}.md`));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) {
if (!f.endsWith(suffix)) continue;
const slug = f.slice(0, -suffix.length);
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
const files = await readDir(localeDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(localeDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
}
targetCache[targetModelId] = docCache;
}
}
return entries.map((entry) => {
const resolved = { ...entry };
for (const rf of relationFields) {
const value = resolved[rf.name];
if (!value) continue;
if (rf.multi && Array.isArray(value)) resolved[rf.name] = value.map((id) => {
for (const targetModelId of rf.targetModels) {
const cached = targetCache[targetModelId]?.[id];
if (cached) return {
id,
...cached
};
}
return id;
});
else if (typeof value === "string") for (const targetModelId of rf.targetModels) {
const cached = targetCache[targetModelId]?.[value];
if (cached) {
resolved[rf.name] = {
id: value,
...cached
};
break;
}
}
}
return resolved;
});
}
//#endregion
//#region src/core/model-manager.ts
const MODELS_DIR_PATH = ".contentrain/models";
async function tryReadJsonViaReader(reader, path) {
try {
return JSON.parse(await reader.readFile(path));
} catch {
return null;
}
}
async function listModels(input) {
let files;
let load;
if (typeof input === "string") {
const modelsDir = join(contentrainDir(input), "models");
files = await readDir(modelsDir);
load = (file) => readJson(join(modelsDir, file));
} else {
files = await input.listDirectory(MODELS_DIR_PATH);
load = (file) => tryReadJsonViaReader(input, `${MODELS_DIR_PATH}/${file}`);
}
const jsonFiles = files.filter((f) => f.endsWith(".json"));
return (await Promise.all(jsonFiles.map(load))).filter((m) => m !== null && !!m.id).map((model) => ({
id: model.id,
kind: model.kind,
domain: model.domain,
i18n: model.i18n,
fields: model.fields ? Object.keys(model.fields).length : 0
})).toSorted((a, b) => a.id.localeCompare(b.id, "en"));
}
async function readModel(input, modelId) {
if (typeof input === "string") return readJson(join(contentrainDir(input), "models", `${modelId}.json`));
return tryReadJsonViaReader(input, `${MODELS_DIR_PATH}/${modelId}.json`);
}
async function countDocumentFileStrategy(reader, contentDir, entries) {
const locales = {};
let total = 0;
const results = await Promise.all(entries.map(async (entry) => {
const localeFiles = await reader.listDirectory(`${contentDir}/${entry}`);
return localeFiles.map((lf) => lf.replace(/\.(json|md|mdx)$/, "")).filter((locale, i) => locale !== localeFiles[i]);
}));
for (const entryLocales of results) for (const locale of entryLocales) {
locales[locale] = (locales[locale] ?? 0) + 1;
total++;
}
return {
total,
locales
};
}
async function countDocumentSuffixStrategy(_reader, _contentDir, files) {
const locales = {};
const slugsByLocale = {};
for (const f of files) {
if (!f.endsWith(".md")) continue;
const match = f.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)\.md$/);
if (!match) continue;
const locale = match[2];
if (!slugsByLocale[locale]) slugsByLocale[locale] = /* @__PURE__ */ new Set();
slugsByLocale[locale].add(match[1]);
}
let total = 0;
for (const [locale, slugs] of Object.entries(slugsByLocale)) {
locales[locale] = slugs.size;
total += slugs.size;
}
return {
total,
locales
};
}
async function countDocumentDirectoryStrategy(reader, contentDir, localeDirs) {
const locales = {};
let total = 0;
const results = await Promise.all(localeDirs.map(async (localeDir) => {
return {
locale: localeDir,
count: (await reader.listDirectory(`${contentDir}/${localeDir}`)).filter((f) => f.endsWith(".md")).length
};
}));
for (const { locale, count } of results) {
locales[locale] = count;
total += count;
}
return {
total,
locales
};
}
async function countDocumentNoneStrategy(reader, modelId, files, i18n) {
const mdFiles = files.filter((f) => f.endsWith(".md"));
if (!i18n) return {
total: mdFiles.length,
locales: { _: mdFiles.length }
};
const metaDir = `.contentrain/meta/${modelId}`;
const locales = {};
let total = 0;
const slugs = mdFiles.map((f) => f.replace(".md", ""));
const results = await Promise.all(slugs.map(async (slug) => {
return (await reader.listDirectory(`${metaDir}/${slug}`)).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
}));
for (const slugLocales of results) for (const locale of slugLocales) {
locales[locale] = (locales[locale] ?? 0) + 1;
total++;
}
return {
total,
locales
};
}
async function countCollectionEntries(reader, contentDir, jsonFiles) {
const locales = {};
let total = 0;
const results = await Promise.all(jsonFiles.map(async (file) => {
const locale = file.replace(/\.json$/, "");
const data = await tryReadJsonViaReader(reader, `${contentDir}/${file}`);
return {
locale,
count: data ? Object.keys(data).length : 0
};
}));
for (const { locale, count } of results) {
locales[locale] = count;
total += count;
}
return {
total,
locales
};
}
async function countEntries(input, model) {
const reader = typeof input === "string" ? new LocalReader(input) : input;
const cDir = contentDirPath(model);
const strategy = resolveLocaleStrategy(model);
const files = await reader.listDirectory(cDir);
if (model.kind === "document") {
if (!model.i18n) return countDocumentNoneStrategy(reader, model.id, files, false);
switch (strategy) {
case "file": return countDocumentFileStrategy(reader, cDir, files);
case "suffix": return countDocumentSuffixStrategy(reader, cDir, files);
case "directory": return countDocumentDirectoryStrategy(reader, cDir, files);
case "none": return countDocumentNoneStrategy(reader, model.id, files, true);
}
}
if (model.kind === "collection") {
if (!model.i18n) return countCollectionEntries(reader, cDir, files.filter((f) => f.endsWith(".json")));
switch (strategy) {
case "suffix": {
const jsonFiles = files.filter((f) => f.endsWith(".json"));
const locales = {};
for (const f of jsonFiles) {
const match = f.match(/^.+\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
if (match) {
const data = await tryReadJsonViaReader(reader, `${cDir}/${f}`);
locales[match[1]] = data ? Object.keys(data).length : 0;
}
}
return {
total: Object.values(locales).reduce((a, b) => a + b, 0),
locales
};
}
case "directory": {
const locales = {};
let total = 0;
for (const localeDir of files) {
const jsonFile = (await reader.listDirectory(`${cDir}/${localeDir}`)).find((f) => f.endsWith(".json"));
if (jsonFile) {
const data = await tryReadJsonViaReader(reader, `${cDir}/${localeDir}/${jsonFile}`);
const count = data ? Object.keys(data).length : 0;
locales[localeDir] = count;
total += count;
}
}
return {
total,
locales
};
}
case "none": {
const noneFile = files.find((f) => f === `${model.id}.json`);
if (!noneFile) return {
total: 0,
locales: {}
};
const data = await tryReadJsonViaReader(reader, `${cDir}/${noneFile}`);
const count = data ? Object.keys(data).length : 0;
return {
total: count,
locales: { _: count }
};
}
default: return countCollectionEntries(reader, cDir, files.filter((f) => f.endsWith(".json")));
}
}
if (!model.i18n) return {
total: files.some((f) => f === "data.json") ? 1 : 0,
locales: {}
};
switch (strategy) {
case "suffix": {
const locales = {};
for (const f of files) {
const match = f.match(/^.+\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
if (match) locales[match[1]] = 1;
}
return {
total: Object.keys(locales).length,
locales
};
}
case "directory": {
const locales = {};
for (const localeDir of files) if ((await reader.listDirectory(`${cDir}/${localeDir}`)).some((f) => f.endsWith(".json"))) locales[localeDir] = 1;
return {
total: Object.keys(locales).length,
locales
};
}
case "none": return {
total: files.some((f) => f === `${model.id}.json`) ? 1 : 0,
locales: {}
};
default: {
const jsonFiles = files.filter((f) => f.endsWith(".json"));
const locales = {};
for (const file of jsonFiles) locales[file.replace(/\.json$/, "")] = 1;
return {
total: jsonFiles.length,
locales
};
}
}
}
const MODEL_FIELD_ORDER = [
"id",
"name",
"kind",
"domain",
"i18n",
"description",
"content_path",
"locale_strategy",
"fields"
];
async function writeModel(projectRoot, model) {
await writeJson(join(contentrainDir(projectRoot), "models", `${model.id}.json`), model, MODEL_FIELD_ORDER);
await ensureDir(resolveContentDir(projectRoot, model));
await ensureDir(join(contentrainDir(projectRoot), "meta", model.id));
}
async function deleteModel(projectRoot, modelId) {
const model = await readModel(projectRoot, modelId);
if (!model) return [];
const crDir = contentrainDir(projectRoot);
const removed = [];
const modelPath = join(crDir, "models", `${modelId}.json`);
const contentPath = resolveContentDir(projectRoot, model);
const metaPath = join(crDir, "meta", modelId);
await rm(modelPath, { force: true });
removed.push(`models/${modelId}.json`);
try {
await rm(contentPath, {
recursive: true,
force: true
});
removed.push(model.content_path ?? `content/${model.domain}/${modelId}/`);
} catch {}
try {
await rm(metaPath, {
recursive: true,
force: true
});
removed.push(`meta/${modelId}/`);
} catch {}
return removed;
}
async function checkReferences(input, modelId) {
const others = (typeof input === "string" ? await listModels(input) : await listModels(input)).filter((s) => s.id !== modelId);
const models = await Promise.all(others.map((s) => typeof input === "string" ? readModel(input, s.id) : readModel(input, s.id)));
const refs = [];
for (const model of models) {
if (!model?.fields) continue;
for (const [fieldName, fieldDef] of Object.entries(model.fields)) {
if (fieldDef.type !== "relation" && fieldDef.type !== "relations") continue;
if ((Array.isArray(fieldDef.model) ? fieldDef.model : [fieldDef.model]).includes(modelId)) refs.push({
model: model.id,
field: fieldName,
type: fieldDef.type
});
}
}
return refs;
}
const FIELD_TYPE_ENUM = [
"string",
"text",
"email",
"url",
"slug",
"color",
"phone",
"code",
"icon",
"markdown",
"richtext",
"number",
"integer",
"decimal",
"percent",
"rating",
"boolean",
"date",
"datetime",
"image",
"video",
"file",
"relation",
"relations",
"select",
"array",
"object"
];
/**
* Shared Zod schema for field definitions.
* Used by both model_save and normalize extract for full parity.
*
* `.strict()` is load-bearing: the default `z.object` *strips* unknown keys, so a
* typo'd constraint (`requird: true`) used to vanish without a word and the field
* silently lost the rule its author thought they had declared.
*/
const fieldDefZodSchema = z.record(z.string(), z.object({
type: z.enum(FIELD_TYPE_ENUM).describe("Field type from the 27-type catalog"),
required: z.boolean().optional(),
unique: z.boolean().optional(),
default: z.unknown().optional(),
min: z.number().optional(),
max: z.number().optional(),
pattern: z.string().optional(),
options: z.array(z.string()).optional(),
model: z.union([z.string(), z.array(z.string())]).optional(),
items: z.union([z.string(), z.lazy(() => z.record(z.string(), z.unknown()))]).optional(),
fields: z.lazy(() => z.record(z.string(), z.unknown())).optional(),
accept: z.string().optional(),
maxSize: z.number().optional(),
description: z.string().optional()
}).strict().refine((f) => {
if ((f.type === "relation" || f.type === "relations") && !f.model) return false;
if (f.type === "select" && (!f.options || f.options.length === 0)) return false;
return true;
}, { message: "relation/relations requires \"model\", select requires non-empty \"options\"" }));
const VALID_FIELD_TYPES = new Set(FIELD_TYPE_ENUM);
/** Field types whose value is a path/URL to a media asset. */
const MEDIA_FIELD_TYPES = new Set([
"image",
"video",
"file"
]);
/** Bounds `items`/`fields` nesting; far above any real schema. */
const MAX_SCHEMA_DEPTH = 10;
/**
* Check one field definition, recursing into `fields` and `items`.
*
* The governing rule: **do not accept a constraint that will not be enforced.**
* A constraint that silently does nothing is worse than no constraint, because
* the author stops looking. So a property declared where it cannot apply is an
* error, and a property we genuinely cannot enforce says so out loud.
*/
function checkFieldDef(raw, path, modelKind, errors, warnings, depth) {
if (typeof raw !== "object" || raw === null) {
errors.push(`Field "${path}": must be an object`);
return;
}
if (depth > MAX_SCHEMA_DEPTH) {
errors.push(`Field "${path}": exceeds the maximum nesting depth of ${MAX_SCHEMA_DEPTH}`);
return;
}
const def = raw;
const type = def.type;
if (!type || !VALID_FIELD_TYPES.has(type)) {
errors.push(`Field "${path}": invalid type "${type}"`);
return;
}
if ((type === "relation" || type === "relations") && !def.model) errors.push(`Field "${path}": ${type} type requires "model" property`);
if (type === "select" && (!Array.isArray(def.options) || def.options.length === 0)) errors.push(`Field "${path}": select type requires non-empty "options" array`);
if (def.options !== void 0 && type !== "select") errors.push(`Field "${path}": "options" only applies to select fields — it is ignored on "${type}"`);
if (def.items !== void 0 && type !== "array") errors.push(`Field "${path}": "items" only applies to array fields — it is ignored on "${type}"`);
if (def.fields !== void 0 && type !== "object") errors.push(`Field "${path}": "fields" only applies to object fields — it is ignored on "${type}"`);
if (def.accept !== void 0 && !MEDIA_FIELD_TYPES.has(type)) errors.push(`Field "${path}": "accept" only applies to image/video/file fields — it is ignored on "${type}"`);
if (def.maxSize !== void 0 && !MEDIA_FIELD_TYPES.has(type)) errors.push(`Field "${path}": "maxSize" only applies to image/video/file fields — it is ignored on "${type}"`);
if (def.unique === true && modelKind === "singleton") errors.push(`Field "${path}": "unique" has no meaning on a singleton — the model holds a single record per locale`);
if (typeof def.min === "number" && typeof def.max === "number" && def.min > def.max) errors.push(`Field "${path}": min (${def.min}) is greater than max (${def.max})`);
if (typeof def.pattern === "string") try {
new RegExp(def.pattern);
} catch {
errors.push(`Field "${path}": "pattern" is not a valid regular expression — /${def.pattern}/`);
}
if (def.default !== void 0) checkDefaultCoherence(def, type, path, errors);
if (typeof def.max === "number" && MEDIA_FIELD_TYPES.has(type)) warnings.push(`Field "${path}": "max" on a ${type} field limits the length of the stored path string, not the file size. Use "maxSize" for bytes.`);
if (def.maxSize !== void 0 && MEDIA_FIELD_TYPES.has(type)) warnings.push(`Field "${path}": "maxSize" is not enforced by MCP — it has no access to the file. Your media provider enforces it when the asset is ingested.`);
if (def.fields !== void 0 && typeof def.fields === "object" && def.fields !== null) for (const [nested, nestedDef] of Object.entries(def.fields)) {
if (!/^[a-z][a-z0-9_]*$/.test(nested)) errors.push(`Field "${path}.${nested}": invalid name — must be snake_case starting with letter`);
checkFieldDef(nestedDef, `${path}.${nested}`, modelKind, errors, warnings, depth + 1);
}
if (typeof def.items === "string") {
if (!VALID_FIELD_TYPES.has(def.items)) errors.push(`Field "${path}.items": invalid type "${def.items}"`);
} else if (def.items !== void 0) checkFieldDef(def.items, `${path}.items`, modelKind, errors, warnings, depth + 1);
}
/** A default that its own field would reject is a schema bug, not a content one. */
function checkDefaultCoherence(def, type, path, errors) {
const value = def.default;
const isString = typeof value === "string";
const isNumber = typeof value === "number";
if (type === "select" && Array.isArray(def.options) && isString && !def.options.includes(value)) {
errors.push(`Field "${path}": default "${value}" is not one of its own options [${def.options.join(", ")}]`);
return;
}
const wantsNumber = [
"number",
"integer",
"decimal",
"percent",
"rating"
].includes(type);
const wantsBoolean = type === "boolean";
const wantsArray = type === "array";
if (wantsNumber && !isNumber) errors.push(`Field "${path}": default must be a number for type "${type}"`);
else if (wantsBoolean && typeof value !== "boolean") errors.push(`Field "${path}": default must be a boolean for type "${type}"`);
else if (wantsArray && !Array.isArray(value)) errors.push(`Field "${path}": default must be an array for type "${type}"`);
}
/**
* Validate a model definition before writing.
* Used by both the model_save tool and normalize extract.
*/
function validateModelDefinition(input) {
const errors = [];
const warnings = [];
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.id)) errors.push(`Invalid model ID "${input.id}": must be kebab-case`);
if (input.kind === "dictionary" && input.fields && Object.keys(input.fields).length > 0) errors.push("Dictionary models cannot have fields. Dictionaries store flat key-value pairs.");
if (input.fields) for (const [fieldName, fieldDef] of Object.entries(input.fields)) {
if (!/^[a-z][a-z0-9_]*$/.test(fieldName)) errors.push(`Field "${fieldName}": invalid name — must be snake_case starting with letter`);
checkFieldDef(fieldDef, fieldName, input.kind, errors, warnings, 0);
}
return {
errors,
warnings
};
}
//#endregion
export { LocalReader as C, writeContent as S, resolveMdFilePath as _, fieldDefZodSchema as a, validateLocale$1 as b, validateModelDefinition as c, listContent as d, parseFrontmatter as f, resolveLocaleStrategy as g, resolveJsonFilePath as h, deleteModel as i, writeModel as l, resolveContentDir as m, checkReferences as n, listModels as o, readContent as p, countEntries as r, readModel as s, FIELD_TYPE_ENUM as t, deleteContent as u, serializeFrontmatter as v, validateSlug$1 as x, validateEntryId$1 as y };
//# sourceMappingURL=model-manager-Rcj5Wzct.mjs.map

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

import { t as readConfig } from "./config-oxxgznz7.mjs";
import { r as writeContext } from "./context-Cppz4R65.mjs";
import { a as deleteRemoteBranch, l as authorConfig } from "./branch-lifecycle-BAfgSQBv.mjs";
import { t as branchTimestamp } from "./id-DV_T9Ic8.mjs";
import { join } from "node:path";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { rm } from "node:fs/promises";
import { simpleGit } from "simple-git";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
//#region src/providers/local/migration.ts
/**
* Migration: the first MCP release used `contentrain/*` feature branches.
* Once we introduced the singleton `contentrain` branch (tracking the
* committed content state), those old feature branches became a ref-
* namespace conflict — git cannot hold both `contentrain` (a leaf ref)
* and `contentrain/foo` (implying a directory) simultaneously.
*
* `migrateLegacyBranches` removes the old-prefix branches so the
* singleton `contentrain` ref can be created. It is idempotent and
* safe to call before every `ensureContentBranch` run.
*
* Strategy:
* 1. Delete merged `contentrain/*` branches first (`-d`). Their commits
* are already on the base branch via the old auto-merge flow.
* 2. Force-delete whatever remains (`-D`). Any unmerged leftover is
* from an abandoned or partially-committed legacy branch — content
* on `main` always wins, and the singleton `contentrain` branch is
* about to be created from `main`/`baseBranch` anyway.
*
* Returns the number of branches that were deleted. Callers may log it;
* the git transaction layer does not need the count for correctness.
*/
async function migrateLegacyBranches(git, baseBranch) {
if ((await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/")).length === 0) return 0;
let deleted = 0;
let mergedLegacy = [];
try {
mergedLegacy = (await git.raw([
"branch",
"--merged",
baseBranch
])).split("\n").map((b) => b.trim().replace(/^\*\s*/, "")).filter((b) => b.startsWith("contentrain/"));
} catch {}
for (const b of mergedLegacy) try {
await git.raw([
"branch",
"-d",
b
]);
deleted++;
} catch {}
const remaining = (await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/"));
for (const b of remaining) try {
await git.raw([
"branch",
"-D",
b
]);
deleted++;
} catch {}
return deleted;
}
//#endregion
//#region src/git/transaction.ts
async function ensureContentBranch(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
if ((await git.branchLocal()).all.includes(CONTENTRAIN_BRANCH)) return;
const baseBranch = config?.repository?.default_branch || (await git.raw(["branch", "--show-current"])).trim() || "main";
await migrateLegacyBranches(git, baseBranch);
await git.branch([CONTENTRAIN_BRANCH, baseBranch]);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
try {
if ((await git.getRemotes()).some((r) => r.name === remoteName)) await git.push([
"-u",
remoteName,
CONTENTRAIN_BRANCH
]);
} catch {}
}
async function selectiveSync(projectRoot, _worktreePath, contentrainTip, _previousBaseRef, dirtyFilesBeforeUpdate) {
const git = simpleGit(projectRoot);
const synced = [];
const skipped = [];
const compareRef = _previousBaseRef ?? contentrainTip;
let changedFiles = [];
try {
changedFiles = (await git.raw([
"diff-tree",
"--name-only",
"-r",
"--no-commit-id",
compareRef,
contentrainTip
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
try {
changedFiles = (await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
".contentrain/"
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
return {
synced,
skipped
};
}
}
if (changedFiles.length === 0) return {
synced,
skipped
};
const dirtyFiles = dirtyFilesBeforeUpdate ?? /* @__PURE__ */ new Set();
const filesInTip = /* @__PURE__ */ new Set();
try {
const lsOutput = await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
"--",
...changedFiles
]);
for (const f of lsOutput.split("\n")) {
const trimmed = f.trim();
if (trimmed) filesInTip.add(trimmed);
}
} catch {
for (const file of changedFiles) try {
await git.raw([
"cat-file",
"-e",
`${contentrainTip}:${file}`
]);
filesInTip.add(file);
} catch {}
}
const toCheckout = [];
const toRemove = [];
for (const file of changedFiles) if (dirtyFiles.has(file)) skipped.push(file);
else if (filesInTip.has(file)) toCheckout.push(file);
else toRemove.push(file);
if (toCheckout.length > 0) try {
await git.checkout([
"HEAD",
"--",
...toCheckout
]);
synced.push(...toCheckout);
} catch {
for (const file of toCheckout) try {
await git.checkout([
"HEAD",
"--",
file
]);
synced.push(file);
} catch {
skipped.push(file);
}
}
await Promise.all(toRemove.map(async (file) => {
try {
await rm(join(projectRoot, file), { force: true });
synced.push(file);
} catch {
skipped.push(file);
}
}));
return {
synced,
skipped,
warning: skipped.length > 0 ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(", ")}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(" ")}` : void 0
};
}
async function createTransaction(projectRoot, branchName, options) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const workflow = options?.workflowOverride ?? config?.workflow ?? "auto-merge";
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
let baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? "";
let currentBranch = "";
let hasRemote = false;
const [branchResult, remotes] = await Promise.all([git.raw(["branch", "--show-current"]).catch(() => ""), git.getRemotes().catch(() => [])]);
currentBranch = branchResult.trim();
if (!baseBranch) baseBranch = currentBranch || "main";
hasRemote = remotes.some((r) => r.name === remoteName);
if (currentBranch === CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. Contentrain manages this branch automatically. Switch to your working branch and retry.`), {
code: "CONTENT_BRANCH_CHECKED_OUT",
agent_hint: "Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.",
developer_action: `git checkout ${baseBranch}`
});
await ensureContentBranch(projectRoot);
if (hasRemote) await Promise.all([git.fetch(remoteName, baseBranch).catch(() => {}), git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {})]);
const worktreePath = join(tmpdir(), `cr-${randomUUID()}`);
const branch = branchName;
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath, { config: authorConfig() });
try {
await wtGit.merge([baseBranch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${baseBranch}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
await wtGit.checkout(["-b", branch]);
let commitHash = "";
let pendingReview = false;
let savedContextUpdate;
return {
worktree: worktreePath,
branch,
async write(callback) {
await callback(worktreePath);
},
async commit(message, contextUpdate) {
savedContextUpdate = contextUpdate;
await wtGit.add(".");
commitHash = (await wtGit.commit(message, {
"--allow-empty": null,
"--no-verify": null
})).commit || "";
return commitHash;
},
async complete() {
if (workflow === "review") {
if (hasRemote) await git.push(remoteName, branch);
pendingReview = true;
return {
action: "pending-review",
commit: commitHash
};
}
await wtGit.checkout(CONTENTRAIN_BRANCH);
try {
await wtGit.merge([branch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branch}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`
});
}
if (savedContextUpdate) await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate);
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
return {
action: "auto-merged",
commit: commitHash,
sync,
...sync.warning ? { warning: sync.warning } : {}
};
},
async cleanup() {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
if (!pendingReview) await safeDeleteBranch(git, branch);
}
};
}
async function mergeBranch(projectRoot, branchName) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
const baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
await ensureContentBranch(projectRoot);
let hasRemote = false;
try {
hasRemote = (await git.getRemotes()).some((r) => r.name === remoteName);
} catch {
hasRemote = false;
}
const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`);
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath, { config: authorConfig() });
try {
try {
await wtGit.merge([branchName, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branchName}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`
});
}
await regenerateContextOnContentrain(wtGit, worktreePath, {
tool: "contentrain_merge",
model: "*"
});
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
await safeDeleteBranch(git, branchName);
let remote;
if (hasRemote) remote = await deleteRemoteBranch(projectRoot, branchName, { config });
return {
action: "merged",
commit: contentrainTip,
sync,
...remote ? { remote } : {}
};
} finally {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
}
}
function buildBranchName(scope, target, locale) {
const ts = branchTimestamp();
const parts = [
"cr",
scope,
target
];
if (locale) parts.push(locale);
parts.push(ts);
return parts.join("/");
}
/**
* True when `ancestor` is an ancestor of (or equal to) `descendant`.
* Implemented with `rev-list --count` because `merge-base --is-ancestor`
* signals via exit code with empty stderr — simple-git reports that as
* success, so it cannot express a negative verdict.
*/
async function isAncestor(git, ancestor, descendant) {
try {
return Number((await git.raw([
"rev-list",
"--count",
ancestor,
`^${descendant}`
])).trim()) === 0;
} catch {
return false;
}
}
/**
* Force-delete a local branch, swallowing all errors. Never deletes the
* singleton `contentrain` branch. Used to prune feature branches after they
* are merged (auto-merge / contentrain_merge) or when a transaction fails
* before completing — so failed/merged `cr/*` refs do not accumulate.
*/
async function safeDeleteBranch(git, branch) {
if (!branch || branch === CONTENTRAIN_BRANCH) return;
try {
await git.raw([
"branch",
"-D",
branch
]);
} catch {}
}
/**
* Regenerate `.contentrain/context.json` deterministically inside a worktree
* that is currently on the `contentrain` branch, then commit it (hooks
* bypassed). Called AFTER a feature branch is merged so context.json is only
* ever written on `contentrain`, single-threaded — eliminating the per-branch
* merge conflicts that came from committing it on every feature branch.
*/
async function regenerateContextOnContentrain(wtGit, worktreePath, contextUpdate) {
await writeContext(worktreePath, contextUpdate);
await wtGit.add(".contentrain/context.json");
try {
await wtGit.commit("[contentrain] context: update", { "--no-verify": null });
} catch {}
}
//#endregion
export { mergeBranch as i, createTransaction as n, ensureContentBranch as r, buildBranchName as t };
//# sourceMappingURL=transaction-C1P-WnVo.mjs.map
{"version":3,"file":"transaction-C1P-WnVo.mjs","names":["removeFile"],"sources":["../src/providers/local/migration.ts","../src/git/transaction.ts"],"sourcesContent":["import type { SimpleGit } from 'simple-git'\n\n/**\n * Migration: the first MCP release used `contentrain/*` feature branches.\n * Once we introduced the singleton `contentrain` branch (tracking the\n * committed content state), those old feature branches became a ref-\n * namespace conflict — git cannot hold both `contentrain` (a leaf ref)\n * and `contentrain/foo` (implying a directory) simultaneously.\n *\n * `migrateLegacyBranches` removes the old-prefix branches so the\n * singleton `contentrain` ref can be created. It is idempotent and\n * safe to call before every `ensureContentBranch` run.\n *\n * Strategy:\n * 1. Delete merged `contentrain/*` branches first (`-d`). Their commits\n * are already on the base branch via the old auto-merge flow.\n * 2. Force-delete whatever remains (`-D`). Any unmerged leftover is\n * from an abandoned or partially-committed legacy branch — content\n * on `main` always wins, and the singleton `contentrain` branch is\n * about to be created from `main`/`baseBranch` anyway.\n *\n * Returns the number of branches that were deleted. Callers may log it;\n * the git transaction layer does not need the count for correctness.\n */\nexport async function migrateLegacyBranches(\n git: SimpleGit,\n baseBranch: string,\n): Promise<number> {\n const branches = await git.branchLocal()\n const oldPrefixBranches = branches.all.filter(b => b.startsWith('contentrain/'))\n if (oldPrefixBranches.length === 0) return 0\n\n let deleted = 0\n\n // 1) Delete merged legacy branches first — the safe path.\n let mergedLegacy: string[] = []\n try {\n const mergedOutput = await git.raw(['branch', '--merged', baseBranch])\n mergedLegacy = mergedOutput.split('\\n')\n .map(b => b.trim().replace(/^\\*\\s*/, ''))\n .filter(b => b.startsWith('contentrain/'))\n } catch {\n // `branch --merged` fails before baseBranch exists — fall through.\n }\n\n for (const b of mergedLegacy) {\n try {\n await git.raw(['branch', '-d', b])\n deleted++\n } catch {\n // Branch may be protected or already gone — safe to skip.\n }\n }\n\n // 2) Force-delete any unmerged legacy branches still present.\n const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))\n for (const b of remaining) {\n try {\n await git.raw(['branch', '-D', b])\n deleted++\n } catch {\n // Skip — best-effort cleanup.\n }\n }\n\n return deleted\n}\n","import { simpleGit, type SimpleGit } from 'simple-git'\nimport { join } from 'node:path'\nimport { rm as removeFile } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { readConfig } from '../core/config.js'\nimport { writeContext } from '../core/context.js'\nimport { deleteRemoteBranch, type RemoteDeleteResult } from './branch-lifecycle.js'\nimport { authorConfig } from './identity.js'\nimport { branchTimestamp } from '../util/id.js'\nimport { migrateLegacyBranches } from '../providers/local/migration.js'\nimport type { SyncResult, WorkflowMode } from '@contentrain/types'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\n\nexport interface ContextUpdate {\n tool: string\n model: string\n locale?: string\n entries?: string[]\n}\n\nexport interface GitTransaction {\n worktree: string\n branch: string\n write(callback: (worktreePath: string) => Promise<void>): Promise<void>\n commit(message: string, contextUpdate?: ContextUpdate): Promise<string>\n complete(): Promise<{ action: 'auto-merged' | 'pending-review'; commit: string; sync?: SyncResult; warning?: string }>\n cleanup(): Promise<void>\n}\n\nexport async function ensureContentBranch(projectRoot: string): Promise<void> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n\n // Check if contentrain branch exists locally\n const branches = await git.branchLocal()\n if (branches.all.includes(CONTENTRAIN_BRANCH)) return\n\n // Detect base branch\n const baseBranch = config?.repository?.default_branch\n || (await git.raw(['branch', '--show-current'])).trim()\n || 'main'\n\n // Clean up legacy `contentrain/*` feature branches so the singleton\n // `contentrain` ref can be created. Idempotent — safe to call even\n // when no legacy branches exist.\n await migrateLegacyBranches(git, baseBranch)\n\n // Create contentrain branch from base\n await git.branch([CONTENTRAIN_BRANCH, baseBranch])\n\n // Push to remote if exists\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n try {\n const remotes = await git.getRemotes()\n if (remotes.some(r => r.name === remoteName)) {\n await git.push(['-u', remoteName, CONTENTRAIN_BRANCH])\n }\n } catch {\n // Remote push is best-effort\n }\n}\n\nasync function selectiveSync(\n projectRoot: string,\n _worktreePath: string,\n contentrainTip: string,\n _previousBaseRef?: string,\n dirtyFilesBeforeUpdate?: Set<string>,\n): Promise<SyncResult> {\n const git = simpleGit(projectRoot)\n const synced: string[] = []\n const skipped: string[] = []\n\n // Use git plumbing to find ALL files that differ between old and new commits.\n // diff-tree is fast and ignores working tree / index state entirely.\n // Not limited to .contentrain/ — some ops also modify .gitignore, etc.\n const compareRef = _previousBaseRef ?? contentrainTip\n let changedFiles: string[] = []\n try {\n const diffOutput = await git.raw([\n 'diff-tree', '--name-only', '-r', '--no-commit-id',\n compareRef, contentrainTip,\n ])\n changedFiles = diffOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n // Fallback: list .contentrain/ files from the contentrainTip commit\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '.contentrain/'])\n changedFiles = lsOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n return { synced, skipped }\n }\n }\n\n if (changedFiles.length === 0) return { synced, skipped }\n\n // Use pre-captured dirty files (before update-ref) to avoid false positives.\n // After update-ref, files appear as \"modified\" in status even though the developer\n // didn't touch them. We use the pre-update state to know what was truly dirty.\n const dirtyFiles = dirtyFilesBeforeUpdate ?? new Set<string>()\n\n // Which changed files still exist in contentrainTip (HEAD after update-ref)?\n // ONE `ls-tree` over the changed paths lists exactly the survivors, instead\n // of a `cat-file -e` spawn per file. Falls back to per-file probing if\n // ls-tree fails so behavior is preserved on any edge.\n const filesInTip = new Set<string>()\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '--', ...changedFiles])\n for (const f of lsOutput.split('\\n')) {\n const trimmed = f.trim()\n if (trimmed) filesInTip.add(trimmed)\n }\n } catch {\n for (const file of changedFiles) {\n try {\n await git.raw(['cat-file', '-e', `${contentrainTip}:${file}`])\n filesInTip.add(file)\n } catch {\n // File does not exist in tip (was deleted)\n }\n }\n }\n\n // Partition: dirty developer files are skipped; survivors get checked out\n // from HEAD; the rest were deleted in the new HEAD and are removed on disk.\n const toCheckout: string[] = []\n const toRemove: string[] = []\n for (const file of changedFiles) {\n if (dirtyFiles.has(file)) skipped.push(file)\n else if (filesInTip.has(file)) toCheckout.push(file)\n else toRemove.push(file)\n }\n\n // ONE `git checkout HEAD -- f1 f2 …` restores every clean survivor at once.\n // On failure, fall back to per-file so a single unresolvable path still\n // yields precise skip accounting (dirty files were already excluded).\n if (toCheckout.length > 0) {\n try {\n await git.checkout(['HEAD', '--', ...toCheckout])\n synced.push(...toCheckout)\n } catch {\n for (const file of toCheckout) {\n try {\n await git.checkout(['HEAD', '--', file])\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }\n }\n }\n\n // Deletions are working-tree fs removals — no git spawn, safe to parallelize.\n await Promise.all(toRemove.map(async (file) => {\n try {\n await removeFile(join(projectRoot, file), { force: true })\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }))\n\n const warning = skipped.length > 0\n ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(', ')}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(' ')}`\n : undefined\n\n return { synced, skipped, warning }\n}\n\nexport async function createTransaction(\n projectRoot: string,\n branchName: string,\n options?: { workflowOverride?: WorkflowMode },\n): Promise<GitTransaction> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const workflow = options?.workflowOverride ?? config?.workflow ?? 'auto-merge'\n\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch + current branch + remote in ONE batch\n // (reduces subprocess spawns from 4 to 2)\n let baseBranch = process.env['CONTENTRAIN_BRANCH'] ?? config?.repository?.default_branch ?? ''\n let currentBranch = ''\n let hasRemote = false\n\n const [branchResult, remotes] = await Promise.all([\n git.raw(['branch', '--show-current']).catch(() => ''),\n git.getRemotes().catch(() => []),\n ])\n currentBranch = branchResult.trim()\n if (!baseBranch) baseBranch = currentBranch || 'main'\n hasRemote = (remotes as { name: string }[]).some(r => r.name === remoteName)\n\n // Check if developer is on contentrain branch\n if (currentBranch === CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. `\n + `Contentrain manages this branch automatically. `\n + `Switch to your working branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_CHECKED_OUT',\n agent_hint: 'Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.',\n developer_action: `git checkout ${baseBranch}`,\n })\n }\n\n // Ensure contentrain branch exists (with migration for old contentrain/* branches)\n await ensureContentBranch(projectRoot)\n\n // Fetch latest from remote (parallel fetch for both branches)\n if (hasRemote) {\n await Promise.all([\n git.fetch(remoteName, baseBranch).catch(() => {}),\n git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {}),\n ])\n }\n\n const worktreePath = join(tmpdir(), `cr-${randomUUID()}`)\n const branch = branchName\n\n // Create worktree on contentrain branch\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity comes from `-c user.*` config (see authorConfig) — passed\n // as args, never via `.env()`, so simple-git's block-unsafe guard is never\n // triggered by an inherited EDITOR/GIT_ASKPASS/etc.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n // Sync contentrain with base branch (bring main changes into contentrain)\n try {\n await wtGit.merge([baseBranch, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${baseBranch}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n }\n\n // Sync with remote contentrain if exists\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n\n // Create feature branch from contentrain\n await wtGit.checkout(['-b', branch])\n\n let commitHash = ''\n let pendingReview = false\n let savedContextUpdate: ContextUpdate | undefined\n\n return {\n worktree: worktreePath,\n branch,\n\n async write(callback) {\n await callback(worktreePath)\n },\n\n async commit(message, contextUpdate?) {\n // context.json is intentionally NOT committed on the feature branch — it\n // is regenerated on the contentrain branch after the merge (see\n // complete()). Committing it per-branch caused cross-branch merge\n // conflicts on a single mutable file. `--no-verify` keeps the repo's\n // commit-msg / pre-commit hooks (commitlint, lefthook, husky) from\n // rejecting these machine-generated infra commits.\n savedContextUpdate = contextUpdate\n await wtGit.add('.')\n const result = await wtGit.commit(message, { '--allow-empty': null, '--no-verify': null })\n commitHash = result.commit || ''\n return commitHash\n },\n\n async complete() {\n if (workflow === 'review') {\n if (hasRemote) {\n await git.push(remoteName, branch)\n }\n // Pending-review branches must survive for a later contentrain_merge.\n pendingReview = true\n return { action: 'pending-review', commit: commitHash }\n }\n\n // auto-merge: merge feature branch into contentrain, then advance base\n\n // Switch to contentrain branch in worktree\n await wtGit.checkout(CONTENTRAIN_BRANCH)\n\n // Merge feature branch into contentrain\n try {\n await wtGit.merge([branch, '--no-edit'])\n } catch {\n try {\n await wtGit.merge(['--abort'])\n } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branch}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`,\n })\n }\n\n // Regenerate context.json on the contentrain branch (post-merge,\n // single-threaded) and fold it into the tip before advancing the base.\n if (savedContextUpdate) {\n await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate)\n }\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip\n // (guaranteed by the merge above, but verify for safety).\n // `rev-list --count` instead of `merge-base --is-ancestor`: the latter\n // signals via exit code with empty stderr, which simple-git reports as\n // success — the guard would silently pass on divergence.\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD.\n // update-ref moves the branch pointer but leaves the index stale.\n // read-tree updates the index to match HEAD without touching the working tree.\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n // fallback: try reset for older git versions\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort with retry)\n if (hasRemote) {\n // Push contentrain branch\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Retry: fetch, merge, push\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n // Push base branch\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n return {\n action: 'auto-merged' as const,\n commit: commitHash,\n sync,\n ...(sync.warning ? { warning: sync.warning } : {}),\n }\n },\n\n async cleanup() {\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n // Prune the feature branch unless it is a pending-review branch that must\n // survive for a later contentrain_merge. Auto-merged branches (already in\n // contentrain) and failed/empty branches are both safe to delete, so\n // failed saves and merged saves no longer leak dangling cr/* refs.\n if (!pendingReview) {\n await safeDeleteBranch(git, branch)\n }\n },\n }\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branchName: string,\n): Promise<{ action: 'merged'; commit: string; sync: SyncResult; remote?: RemoteDeleteResult }> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch\n const baseBranch = process.env['CONTENTRAIN_BRANCH']\n ?? config?.repository?.default_branch\n ?? ((await git.raw(['branch', '--show-current'])).trim() || 'main')\n\n // Ensure contentrain branch exists\n await ensureContentBranch(projectRoot)\n\n // Check remote\n let hasRemote = false\n try {\n const remotes = await git.getRemotes()\n hasRemote = remotes.some(r => r.name === remoteName)\n } catch {\n hasRemote = false\n }\n\n // Create temp worktree on contentrain branch\n const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`)\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity from `-c user.*` config (see authorConfig) — guard-safe,\n // no `.env()` spread.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n try {\n // Merge the feature branch into contentrain\n try {\n await wtGit.merge([branchName, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branchName}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`,\n })\n }\n\n // Regenerate context.json on contentrain post-merge (deterministic,\n // single-threaded) so review-mode branches — which carry no context.json —\n // still produce up-to-date stats once landed.\n await regenerateContextOnContentrain(wtGit, worktreePath, { tool: 'contentrain_merge', model: '*' })\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip.\n // (See complete() — merge-base --is-ancestor is unusable via simple-git.)\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort)\n if (hasRemote) {\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n // Prune the now-merged feature branch so merged cr/* refs don't accumulate.\n await safeDeleteBranch(git, branchName)\n\n // Delete the remote copy too (review-mode branches were pushed on save).\n // Best-effort and config-gated inside the helper: a failure surfaces as\n // `remote.warning`, never as a failed merge.\n let remote: RemoteDeleteResult | undefined\n if (hasRemote) {\n remote = await deleteRemoteBranch(projectRoot, branchName, { config })\n }\n\n return {\n action: 'merged' as const,\n commit: contentrainTip,\n sync,\n ...(remote ? { remote } : {}),\n }\n } finally {\n // Cleanup worktree\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n }\n}\n\nexport function buildBranchName(scope: string, target: string, locale?: string): string {\n const ts = branchTimestamp()\n const parts = ['cr', scope, target]\n if (locale) parts.push(locale)\n parts.push(ts)\n return parts.join('/')\n}\n\n/**\n * True when `ancestor` is an ancestor of (or equal to) `descendant`.\n * Implemented with `rev-list --count` because `merge-base --is-ancestor`\n * signals via exit code with empty stderr — simple-git reports that as\n * success, so it cannot express a negative verdict.\n */\nasync function isAncestor(git: SimpleGit, ancestor: string, descendant: string): Promise<boolean> {\n try {\n const count = Number((await git.raw(['rev-list', '--count', ancestor, `^${descendant}`])).trim())\n return count === 0\n } catch {\n return false\n }\n}\n\n/**\n * Force-delete a local branch, swallowing all errors. Never deletes the\n * singleton `contentrain` branch. Used to prune feature branches after they\n * are merged (auto-merge / contentrain_merge) or when a transaction fails\n * before completing — so failed/merged `cr/*` refs do not accumulate.\n */\nasync function safeDeleteBranch(git: SimpleGit, branch: string): Promise<void> {\n if (!branch || branch === CONTENTRAIN_BRANCH) return\n try {\n await git.raw(['branch', '-D', branch])\n } catch {\n // Branch may not exist, be checked out, or already be deleted — ignore.\n }\n}\n\n/**\n * Regenerate `.contentrain/context.json` deterministically inside a worktree\n * that is currently on the `contentrain` branch, then commit it (hooks\n * bypassed). Called AFTER a feature branch is merged so context.json is only\n * ever written on `contentrain`, single-threaded — eliminating the per-branch\n * merge conflicts that came from committing it on every feature branch.\n */\nasync function regenerateContextOnContentrain(\n wtGit: SimpleGit,\n worktreePath: string,\n contextUpdate: ContextUpdate,\n): Promise<void> {\n await writeContext(worktreePath, contextUpdate)\n await wtGit.add('.contentrain/context.json')\n try {\n await wtGit.commit('[contentrain] context: update', { '--no-verify': null })\n } catch {\n // Nothing staged (context.json unchanged) — fine.\n }\n}\n"],"mappings":"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"}
import { i as metaFilePath, n as contentFilePath, r as documentFilePath, t as contentDirPath } from "./paths-CmVw5Cw2.mjs";
import { c as writeText, s as writeJson } from "./fs-DLbVB-Ek.mjs";
import { i as writeMeta } from "./meta-manager-CJUiTgP2.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { C as LocalReader, f as parseFrontmatter, g as resolveLocaleStrategy, o as listModels, s as readModel } from "./model-manager-Rcj5Wzct.mjs";
import { join } from "node:path";
import { detectSecrets, validateFieldValue } from "@contentrain/types";
import { rm } from "node:fs/promises";
//#region src/core/validator/entry.ts
/**
* Validate a single content entry against its model's field schema.
*
* Merges the rule sets from MCP's legacy `validator.ts` (secret detection,
* schema validation, unique constraints) with Studio's `content-validation.ts`
* (email/url heuristics, polymorphic relation structure, nested object and
* array-of-object recursion). The union is the authoritative per-entry
* validator — both MCP's project validator and Studio's save path should
* converge on this function over time.
*
* Asynchronous relation-integrity checks (does the referenced entry exist?)
* live in `relation-integrity.ts` because they require I/O.
*/
function validateContent(data, fields, modelId, locale, entryId, ctx) {
const errors = [];
for (const [fieldId, def] of Object.entries(fields)) {
const value = data[fieldId];
errors.push(...validateField(value, def, modelId, locale, entryId, fieldId, ctx));
}
return {
valid: errors.filter((e) => e.severity === "error").length === 0,
errors
};
}
/** Bounds `items`-inside-`items` nesting; far above any real schema. */
const MAX_FIELD_DEPTH = 10;
function validateField(value, def, modelId, locale, entryId, fieldId, ctx, depth = 0) {
const errors = [];
const errCtx = {
model: modelId,
locale,
entry: entryId,
field: fieldId
};
if (depth > MAX_FIELD_DEPTH) return [{
severity: "error",
...errCtx,
message: `${fieldId} exceeds the maximum nesting depth of ${MAX_FIELD_DEPTH}`
}];
if (value !== null && value !== void 0 && value !== "") {
const secretErrors = detectSecrets(value);
for (const e of secretErrors) errors.push({
...e,
...errCtx
});
}
if (!(def.type === "relation" || def.type === "relations")) {
const fieldErrors = validateFieldValue(value, def);
if (fieldErrors.length > 0) {
for (const e of fieldErrors) errors.push({
...e,
...errCtx
});
if (fieldErrors.some((e) => e.severity === "error")) return errors;
}
} else if (def.required && (value === null || value === void 0 || value === "")) {
errors.push({
severity: "error",
...errCtx,
message: `${fieldId} is required`
});
return errors;
}
if (value === null || value === void 0) return errors;
if (def.unique && ctx?.allEntries) {
const valueKey = String(value);
for (const [otherId, otherEntry] of Object.entries(ctx.allEntries)) {
if (otherId === ctx.currentEntryId) continue;
const otherValue = otherEntry[fieldId];
if (otherValue !== null && otherValue !== void 0 && String(otherValue) === valueKey) {
errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be unique — "${String(value)}" already exists in entry ${otherId}`
});
break;
}
}
}
if (def.type === "relation" && def.model) {
const targets = Array.isArray(def.model) ? def.model : [def.model];
if (targets.length > 1) if (typeof value !== "object" || value === null || !("model" in value) || !("ref" in value)) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be { model, ref } for polymorphic relation`
});
else {
const polyVal = value;
if (!targets.includes(polyVal.model)) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} target model "${polyVal.model}" must be one of: ${targets.join(", ")}`
});
}
else if (typeof value !== "string") errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be a string (entry ID or slug)`
});
}
if (def.type === "relations") if (!Array.isArray(value)) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be an array`
});
else {
if (def.min !== void 0 && value.length < def.min) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must have at least ${def.min} items`
});
if (def.max !== void 0 && value.length > def.max) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must have at most ${def.max} items`
});
const targets = Array.isArray(def.model) ? def.model : def.model ? [def.model] : [];
const polymorphic = targets.length > 1;
for (let i = 0; i < value.length; i++) {
const item = value[i];
const itemCtx = {
...errCtx,
field: `${fieldId}[${i}]`
};
if (polymorphic) if (typeof item !== "object" || item === null || !("model" in item) || !("ref" in item)) errors.push({
severity: "error",
...itemCtx,
message: `${fieldId}[${i}] must be { model, ref } for polymorphic relations`
});
else {
const polyItem = item;
if (!targets.includes(polyItem.model)) errors.push({
severity: "error",
...itemCtx,
message: `${fieldId}[${i}] target model "${polyItem.model}" must be one of: ${targets.join(", ")}`
});
}
else if (typeof item !== "string") errors.push({
severity: "error",
...itemCtx,
message: `${fieldId}[${i}] must be a string (entry ID or slug)`
});
}
}
if (def.type === "array" && Array.isArray(value) && def.items) {
const itemDef = typeof def.items === "string" ? { type: def.items } : def.items;
for (let i = 0; i < value.length; i++) errors.push(...validateField(value[i], itemDef, modelId, locale, entryId, `${fieldId}[${i}]`, void 0, depth + 1));
}
if (def.type === "object" && def.fields && typeof value === "object" && value !== null && !Array.isArray(value)) {
const nested = validateContent(value, def.fields, modelId, locale, entryId, ctx);
for (const e of nested.errors) errors.push({
...e,
field: e.field ? `${fieldId}.${e.field}` : fieldId
});
}
return errors;
}
//#endregion
//#region src/core/validator/relation-integrity.ts
/**
* Verify that relation and relations fields reference targets that actually
* exist. Two severities are supported — Studio's per-save flow emits
* `warning` (the referenced entry may still be drafted), while MCP's
* project-wide validator emits `error` and additionally flags missing
* target models via `resolveTarget`.
*
* The loader abstraction keeps this function I/O-agnostic: MCP wires it
* to filesystem reads through LocalReader, Studio wires it to a
* `GitProvider.readFile`-backed loader, mocks pass in-memory maps.
*/
async function checkRelationIntegrity(data, fields, modelId, locale, entryId, loadContent, opts = {}) {
const errors = [];
const severity = opts.severity ?? "warning";
const resolve = opts.resolveTarget ?? (async (id, loc) => {
return {
exists: true,
content: await loadContent(id, loc)
};
});
for (const [fieldId, def] of Object.entries(fields)) {
const value = data[fieldId];
if (value === null || value === void 0) continue;
if (def.type === "relation" && def.model) {
const targets = Array.isArray(def.model) ? def.model : [def.model];
if (targets.length > 1 && typeof value === "object" && value !== null) {
const polyVal = value;
if (polyVal.model && polyVal.ref) {
const resolved = await resolve(polyVal.model, locale);
if (!resolved.exists) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: target model "${polyVal.model}" not found`
});
else if (resolved.content && !(polyVal.ref in resolved.content)) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: "${polyVal.ref}" not found in ${polyVal.model}`
});
}
} else if (typeof value === "string" && targets[0]) {
const resolved = await resolve(targets[0], locale);
if (!resolved.exists) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: target model "${targets[0]}" not found`
});
else if (resolved.content && !(value in resolved.content)) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: "${value}" not found in ${targets[0]}`
});
}
}
if (def.type === "relations" && def.model && Array.isArray(value)) {
const target = Array.isArray(def.model) ? def.model[0] : def.model;
if (target) {
const resolved = await resolve(target, locale);
if (!resolved.exists) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: target model "${target}" not found`
});
else if (resolved.content) {
for (const ref of value) if (typeof ref === "string" && !(ref in resolved.content)) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: "${ref}" not found in ${target}`
});
}
}
}
}
return errors;
}
//#endregion
//#region src/core/validator/schedule.ts
/**
* Validate `publish_at` and `expire_at` meta fields.
*
* Rules (matches legacy `validator.ts:validateScheduleFields`):
* - `publish_at` must parse as a valid Date
* - `expire_at` must parse as a valid Date
* - When both are present, `expire_at` must be strictly after `publish_at`
*/
function validateScheduleFields(meta, ctx, issues) {
if (meta.publish_at !== void 0) {
const d = new Date(meta.publish_at);
if (Number.isNaN(d.getTime())) issues.push({
severity: "error",
...ctx,
message: `Invalid publish_at date: "${meta.publish_at}". Must be a valid ISO 8601 date string.`
});
}
if (meta.expire_at !== void 0) {
const d = new Date(meta.expire_at);
if (Number.isNaN(d.getTime())) issues.push({
severity: "error",
...ctx,
message: `Invalid expire_at date: "${meta.expire_at}". Must be a valid ISO 8601 date string.`
});
}
if (meta.publish_at !== void 0 && meta.expire_at !== void 0) {
const pubDate = new Date(meta.publish_at);
const expDate = new Date(meta.expire_at);
if (!Number.isNaN(pubDate.getTime()) && !Number.isNaN(expDate.getTime()) && expDate <= pubDate) issues.push({
severity: "error",
...ctx,
message: `expire_at ("${meta.expire_at}") must be after publish_at ("${meta.publish_at}").`
});
}
}
//#endregion
//#region src/core/validator/project.ts
async function readJsonViaReader(reader, path) {
try {
return JSON.parse(await reader.readFile(path));
} catch {
return null;
}
}
async function readTextViaReader(reader, path) {
try {
return await reader.readFile(path);
} catch {
return null;
}
}
/**
* Build a `resolveTarget` adapter for `checkRelationIntegrity` that walks
* the project's content store via the shared {@link RepoReader}. Mirrors
* the target-resolution shape the legacy `checkRelation` used — collection
* targets return their entry object-map, documents return a "slug exists"
* marker map, singletons and dictionaries return null content so the
* checker skips key enforcement for them.
*/
function buildProjectTargetResolver(reader, config) {
return async (targetModelId, targetLocale) => {
const targetModel = await readModel(reader, targetModelId);
if (!targetModel) return { exists: false };
if (targetModel.kind === "document") {
const slugs = await discoverDocumentSlugs(reader, contentDirPath(targetModel), targetModel);
return {
exists: true,
content: Object.fromEntries(slugs.map((s) => [s, true]))
};
}
if (targetModel.kind === "singleton" || targetModel.kind === "dictionary") return {
exists: true,
content: null
};
const merged = {};
const primary = await readJsonViaReader(reader, contentFilePath(targetModel, targetLocale));
if (primary) Object.assign(merged, primary);
if (targetModel.i18n && targetLocale !== config.locales.default) {
const fallback = await readJsonViaReader(reader, contentFilePath(targetModel, config.locales.default));
if (fallback) Object.assign(merged, fallback);
}
return {
exists: true,
content: merged
};
};
}
/**
* Scan an entry's data fields for detected secrets in UNDECLARED keys —
* the legacy validator also flagged stray/rogue fields that were not in
* `model.fields`. `validateContent` only knows about declared fields, so
* this complementary pass preserves that coverage.
*/
function scanUndeclaredFieldsForSecrets(data, declared, ctx, issues) {
for (const [fieldName, value] of Object.entries(data)) {
if (declared && fieldName in declared) continue;
if (detectSecrets(value).length > 0) issues.push({
severity: "error",
...ctx,
field: fieldName,
message: `Potential secret detected in field "${fieldName}"`
});
}
}
async function validateCollectionModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
const locales = model.i18n ? config.locales.supported : [config.locales.default];
const localeEntryIds = {};
const allEntryIds = /* @__PURE__ */ new Set();
const resolveTarget = buildProjectTargetResolver(reader, config);
for (const locale of locales) {
const filePath = contentFilePath(model, locale);
const data = await readJsonViaReader(reader, filePath);
if (!data) {
if (model.i18n) {
issues.push({
severity: "error",
model: model.id,
locale,
message: `Locale file missing: ${locale}.json`
});
if (fix && projectRoot) {
await writeJson(join(projectRoot, filePath), {});
fixed++;
}
}
continue;
}
const ids = new Set(Object.keys(data));
localeEntryIds[locale] = ids;
for (const id of ids) allEntryIds.add(id);
const keys = Object.keys(data);
const sorted = [...keys].toSorted();
if (keys.join(",") !== sorted.join(",")) {
issues.push({
severity: "warning",
model: model.id,
locale,
message: "Content file keys not in canonical order"
});
if (fix && projectRoot) {
const resorted = {};
for (const key of sorted) resorted[key] = data[key];
await writeJson(join(projectRoot, filePath), resorted);
fixed++;
}
}
for (const [entryId, fields] of Object.entries(data)) {
entriesChecked++;
scanUndeclaredFieldsForSecrets(fields, model.fields, {
model: model.id,
locale,
entry: entryId
}, issues);
if (!model.fields) continue;
const entryResult = validateContent(fields, model.fields, model.id, locale, entryId, {
allEntries: data,
currentEntryId: entryId
});
issues.push(...entryResult.errors);
const relationErrors = await checkRelationIntegrity(fields, model.fields, model.id, locale, entryId, async () => null, {
severity: "error",
resolveTarget
});
issues.push(...relationErrors);
}
}
if (model.i18n && Object.keys(localeEntryIds).length > 1) {
const localeKeys = Object.keys(localeEntryIds);
for (let i = 1; i < localeKeys.length; i++) {
const locA = localeKeys[0];
const locB = localeKeys[i];
const idsA = localeEntryIds[locA];
const idsB = localeEntryIds[locB];
for (const id of idsA) if (!idsB.has(id)) issues.push({
severity: "error",
model: model.id,
locale: locB,
entry: id,
message: `Entry parity: entry "${id}" exists in ${locA} but missing in ${locB}`
});
for (const id of idsB) if (!idsA.has(id)) issues.push({
severity: "error",
model: model.id,
locale: locA,
entry: id,
message: `Entry parity: entry "${id}" exists in ${locB} but missing in ${locA}`
});
}
}
for (const locale of locales) {
const metaRelPath = metaFilePath(model, locale, config.locales.default);
const metaData = await readJsonViaReader(reader, metaRelPath);
const contentData = await readJsonViaReader(reader, contentFilePath(model, locale)) ?? {};
if (metaData) for (const metaEntryId of Object.keys(metaData)) {
validateScheduleFields(metaData[metaEntryId], {
model: model.id,
locale,
entry: metaEntryId
}, issues);
if (!(metaEntryId in contentData)) {
issues.push({
severity: "warning",
model: model.id,
locale,
entry: metaEntryId,
message: `Orphan meta: meta entry "${metaEntryId}" exists but content entry missing`
});
if (fix && projectRoot) {
delete metaData[metaEntryId];
const metaAbs = join(projectRoot, metaRelPath);
if (Object.keys(metaData).length > 0) await writeJson(metaAbs, metaData);
else await rm(metaAbs, { force: true });
fixed++;
}
}
}
for (const entryId of Object.keys(contentData)) if (!metaData || !(entryId in metaData)) {
issues.push({
severity: "warning",
model: model.id,
locale,
entry: entryId,
message: `Orphan content: entry "${entryId}" has no metadata`
});
if (fix && projectRoot) {
await writeMeta(projectRoot, model, {
locale,
entryId,
defaultLocale: config.locales.default
}, {
status: "draft",
source: "import",
updated_by: "contentrain-mcp"
});
fixed++;
}
}
if (metaData) {
const entries = Object.entries(metaData);
const draftIds = entries.filter(([, m]) => m.status === "draft").map(([id]) => id);
const publishedCount = entries.filter(([, m]) => m.status === "published").length;
if (publishedCount > 0 && draftIds.length > 0) issues.push({
severity: "notice",
model: model.id,
locale,
message: `Publish-state drift: ${draftIds.length} draft entr${draftIds.length === 1 ? "y" : "ies"} alongside ${publishedCount} published in the same collection — [${draftIds.join(", ")}]. If these were published before, restore them with contentrain_bulk update_status.`
});
}
}
await checkStrayNonI18nMeta(reader, model, config, issues);
return {
entries: entriesChecked,
fixed
};
}
/**
* Flag meta files a non-i18n model should not have.
*
* Such a model keeps all content in one `data.json` and therefore exactly one
* meta record, at the default locale. Earlier writes derived the meta path from
* the caller's locale, so saving under a non-default locale left a second meta
* file — and readers disagreed about which was authoritative. Reported rather
* than auto-removed: the stray may hold the only `published` status in the
* project, so deleting it silently could unpublish content.
*/
async function checkStrayNonI18nMeta(reader, model, config, issues) {
if (model.i18n) return;
const expected = `${config.locales.default}.json`;
let files;
try {
files = await reader.listDirectory(`.contentrain/meta/${model.id}`);
} catch {
return;
}
const strays = files.filter((f) => f.endsWith(".json") && f !== expected);
if (strays.length === 0) return;
issues.push({
severity: "warning",
model: model.id,
message: `Meta layout mismatch: "${model.id}" has i18n disabled, so its content lives in a single data.json and its meta belongs at ${expected} alone — but [${strays.join(", ")}] also exist. Readers may disagree about which file is authoritative. Merge any status you want to keep into ${expected}, then remove the extras.`
});
}
async function validateSingletonModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
for (const locale of model.i18n ? config.locales.supported : [config.locales.default]) {
const filePath = contentFilePath(model, locale);
const data = await readJsonViaReader(reader, filePath);
if (!data) {
if (model.i18n) {
issues.push({
severity: "error",
model: model.id,
locale,
message: `Locale file missing: ${locale}.json`
});
if (fix && projectRoot) {
await writeJson(join(projectRoot, filePath), {});
fixed++;
}
}
continue;
}
entriesChecked++;
scanUndeclaredFieldsForSecrets(data, model.fields, {
model: model.id,
locale
}, issues);
if (model.fields) {
const entryResult = validateContent(data, model.fields, model.id, locale);
issues.push(...entryResult.errors);
const resolveTarget = buildProjectTargetResolver(reader, config);
const relationErrors = await checkRelationIntegrity(data, model.fields, model.id, locale, void 0, async () => null, {
severity: "error",
resolveTarget
});
issues.push(...relationErrors);
}
const keys = Object.keys(data);
const sorted = [...keys].toSorted();
if (keys.join(",") !== sorted.join(",")) {
issues.push({
severity: "warning",
model: model.id,
locale,
message: "Content file keys not in canonical order"
});
if (fix && projectRoot) {
const resorted = {};
for (const key of sorted) resorted[key] = data[key];
await writeJson(join(projectRoot, filePath), resorted);
fixed++;
}
}
const singletonMetaData = await readJsonViaReader(reader, metaFilePath(model, locale, config.locales.default));
if (singletonMetaData) validateScheduleFields(singletonMetaData, {
model: model.id,
locale
}, issues);
}
return {
entries: entriesChecked,
fixed
};
}
async function validateDictionaryModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
const localeKeys = {};
for (const locale of model.i18n ? config.locales.supported : [config.locales.default]) {
const filePath = contentFilePath(model, locale);
const data = await readJsonViaReader(reader, filePath);
if (!data) {
if (model.i18n) {
issues.push({
severity: "error",
model: model.id,
locale,
message: `Locale file missing: ${locale}.json`
});
if (fix && projectRoot) {
await writeJson(join(projectRoot, filePath), {});
fixed++;
}
}
continue;
}
entriesChecked++;
localeKeys[locale] = new Set(Object.keys(data));
for (const [key, value] of Object.entries(data)) if (detectSecrets(value).length > 0) issues.push({
severity: "error",
model: model.id,
locale,
field: key,
message: `Potential secret detected in key "${key}"`
});
const valueToKeys = /* @__PURE__ */ new Map();
for (const [key, value] of Object.entries(data)) {
const arr = valueToKeys.get(value);
if (arr) arr.push(key);
else valueToKeys.set(value, [key]);
}
for (const [value, dupeKeys] of valueToKeys) if (dupeKeys.length > 1) {
const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
issues.push({
severity: "warning",
model: model.id,
locale,
message: `Duplicate value "${truncated}" mapped to ${dupeKeys.length} keys: [${dupeKeys.join(", ")}]`
});
}
const keys = Object.keys(data);
const sorted = [...keys].toSorted();
if (keys.join(",") !== sorted.join(",")) {
issues.push({
severity: "warning",
model: model.id,
locale,
message: "Content file keys not in canonical order"
});
if (fix && projectRoot) {
const resorted = {};
for (const key of sorted) resorted[key] = data[key];
await writeJson(join(projectRoot, filePath), resorted);
fixed++;
}
}
}
if (model.i18n && Object.keys(localeKeys).length > 1) {
const localeNames = Object.keys(localeKeys);
for (let i = 1; i < localeNames.length; i++) {
const locA = localeNames[0];
const locB = localeNames[i];
const keysA = localeKeys[locA];
const keysB = localeKeys[locB];
for (const k of keysA) if (!keysB.has(k)) issues.push({
severity: "warning",
model: model.id,
locale: locB,
field: k,
message: `Key parity: key "${k}" exists in ${locA} but missing in ${locB}`
});
for (const k of keysB) if (!keysA.has(k)) issues.push({
severity: "warning",
model: model.id,
locale: locA,
field: k,
message: `Key parity: key "${k}" exists in ${locB} but missing in ${locA}`
});
}
}
return {
entries: entriesChecked,
fixed
};
}
async function discoverDocumentSlugs(reader, cDir, model) {
const strategy = resolveLocaleStrategy(model);
const entries = await reader.listDirectory(cDir);
if (!model.i18n) return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
if (strategy === "file") return entries.filter((e) => !e.startsWith("."));
if (strategy === "suffix") {
const slugs = /* @__PURE__ */ new Set();
for (const f of entries) {
if (!f.endsWith(".md")) continue;
const parts = f.replace(/\.md$/, "").split(".");
if (parts.length >= 2) {
parts.pop();
slugs.add(parts.join("."));
}
}
return [...slugs];
}
if (strategy === "directory") {
const slugs = /* @__PURE__ */ new Set();
const localeLists = await Promise.all(entries.filter((localeDir) => !localeDir.startsWith(".")).map((localeDir) => reader.listDirectory(`${cDir}/${localeDir}`)));
for (const files of localeLists) for (const f of files) if (f.endsWith(".md")) slugs.add(f.replace(/\.md$/, ""));
return [...slugs];
}
return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
}
async function validateDocumentModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
const cDir = contentDirPath(model);
if (!await reader.fileExists(cDir)) return {
entries: 0,
fixed: 0
};
const slugs = await discoverDocumentSlugs(reader, cDir, model);
const locales = model.i18n ? config.locales.supported : [config.locales.default];
const rawByKey = /* @__PURE__ */ new Map();
const frontmatterByLocale = {};
for (const slug of slugs) {
if (slug.startsWith(".")) continue;
for (const locale of locales) {
const raw = await readTextViaReader(reader, documentFilePath(model, locale, slug));
if (!raw) continue;
rawByKey.set(`${slug}${locale}`, raw);
const { frontmatter } = parseFrontmatter(raw);
frontmatterByLocale[locale] ??= {};
frontmatterByLocale[locale][slug] = frontmatter;
}
}
for (const slug of slugs) {
if (slug.startsWith(".")) continue;
for (const locale of locales) {
const filePath = documentFilePath(model, locale, slug);
const raw = rawByKey.get(`${slug}${locale}`) ?? null;
if (!raw) {
if (model.i18n) {
issues.push({
severity: "warning",
model: model.id,
locale,
slug,
message: `Missing translation: document "${slug}" missing ${locale} locale file`
});
if (fix && projectRoot) {
const template = `---\nslug: ${slug}\n---\n`;
await writeText(join(projectRoot, filePath), template);
fixed++;
}
}
continue;
}
entriesChecked++;
const { frontmatter, body } = parseFrontmatter(raw);
scanUndeclaredFieldsForSecrets(frontmatter, {
...model.fields,
body: true
}, {
model: model.id,
locale,
slug
}, issues);
if (detectSecrets(body).length > 0) issues.push({
severity: "error",
model: model.id,
locale,
slug,
field: "body",
message: "Potential secret detected in document body"
});
if (model.fields) {
const fieldsWithoutBody = Object.fromEntries(Object.entries(model.fields).filter(([name]) => name !== "body"));
const entryResult = validateContent(frontmatter, fieldsWithoutBody, model.id, locale, void 0, {
allEntries: frontmatterByLocale[locale] ?? {},
currentEntryId: slug
});
for (const err of entryResult.errors) issues.push({
...err,
slug
});
const resolveTarget = buildProjectTargetResolver(reader, config);
const relationErrors = await checkRelationIntegrity(frontmatter, fieldsWithoutBody, model.id, locale, void 0, async () => null, {
severity: "error",
resolveTarget
});
for (const err of relationErrors) issues.push({
...err,
slug
});
}
}
}
return {
entries: entriesChecked,
fixed
};
}
async function checkOrphanContent(reader, validModelIds, issues, _fix) {
const fixed = 0;
const contentBase = ".contentrain/content";
const domains = await reader.listDirectory(contentBase);
const modelLists = await Promise.all(domains.filter((d) => !d.startsWith(".")).map(async (d) => ({
domain: d,
dirs: await reader.listDirectory(`${contentBase}/${d}`)
})));
for (const { dirs } of modelLists) for (const modelDir of dirs) {
if (modelDir.startsWith(".")) continue;
if (!validModelIds.has(modelDir)) issues.push({
severity: "warning",
model: modelDir,
message: `Orphan content: content directory exists for deleted model "${modelDir}"`
});
}
return fixed;
}
async function validateProject(input, options) {
const reader = typeof input === "string" ? new LocalReader(input) : input;
const projectRoot = typeof input === "string" ? input : void 0;
const fix = Boolean(options?.fix) && projectRoot !== void 0;
const issues = [];
let totalEntries = 0;
let totalFixed = 0;
let modelsChecked = 0;
const config = await readConfig(reader);
if (!config) return {
valid: false,
summary: {
errors: 1,
warnings: 0,
notices: 0,
models_checked: 0,
entries_checked: 0
},
issues: [{
severity: "error",
message: "Project not initialized: config.json missing"
}],
fixed: 0
};
const modelSummaries = await listModels(reader);
const validModelIds = new Set(modelSummaries.map((m) => m.id));
const modelsToCheck = options?.model ? modelSummaries.filter((m) => m.id === options.model) : modelSummaries;
for (const summary of modelsToCheck) {
const model = await readModel(reader, summary.id);
if (!model) continue;
modelsChecked++;
let result;
switch (model.kind) {
case "collection":
result = await validateCollectionModel(reader, projectRoot, model, config, issues, fix);
break;
case "singleton":
result = await validateSingletonModel(reader, projectRoot, model, config, issues, fix);
break;
case "dictionary":
result = await validateDictionaryModel(reader, projectRoot, model, config, issues, fix);
break;
case "document":
result = await validateDocumentModel(reader, projectRoot, model, config, issues, fix);
break;
default: result = {
entries: 0,
fixed: 0
};
}
totalEntries += result.entries;
totalFixed += result.fixed;
}
if (!options?.model) totalFixed += await checkOrphanContent(reader, validModelIds, issues, fix);
if (!options?.model) {
const dictModels = modelsToCheck.filter((m) => m.kind === "dictionary");
if (dictModels.length > 1) {
const globalValueMap = {};
for (const summary of dictModels) {
const model = await readModel(reader, summary.id);
if (!model) continue;
for (const locale of model.i18n ? config.locales.supported : [config.locales.default]) {
if (!globalValueMap[locale]) globalValueMap[locale] = /* @__PURE__ */ new Map();
const data = await readJsonViaReader(reader, contentFilePath(model, locale));
if (!data) continue;
for (const [key, value] of Object.entries(data)) {
const refs = globalValueMap[locale].get(value);
if (refs) refs.push({
model: model.id,
key
});
else globalValueMap[locale].set(value, [{
model: model.id,
key
}]);
}
}
}
for (const [locale, valueMap] of Object.entries(globalValueMap)) for (const [value, refs] of valueMap) if (new Set(refs.map((r) => r.model)).size > 1) {
const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
issues.push({
severity: "notice",
locale,
message: `Cross-model duplicate value "${truncated}" in ${refs.map((r) => `${r.model}/${r.key}`).join(", ")}`
});
}
}
}
const errors = issues.filter((i) => i.severity === "error").length;
const warnings = issues.filter((i) => i.severity === "warning").length;
const notices = issues.filter((i) => i.severity === "notice").length;
return {
valid: errors === 0,
summary: {
errors,
warnings,
notices,
models_checked: modelsChecked,
entries_checked: totalEntries
},
issues,
fixed: totalFixed
};
}
//#endregion
export { validateContent as i, validateScheduleFields as n, checkRelationIntegrity as r, validateProject as t };
//# sourceMappingURL=validator-DLmW9MVF.mjs.map

Sorry, the diff of this file is too big to display

+4
-4

@@ -5,8 +5,8 @@ import "../serialization-B1CEzR4H.mjs";

import "../config-oxxgznz7.mjs";
import "../model-manager-SiesTJrS.mjs";
import "../context-CrS-IvVm.mjs";
import "../model-manager-Rcj5Wzct.mjs";
import "../context-Cppz4R65.mjs";
import "../branch-lifecycle-BAfgSQBv.mjs";
import "../id-DV_T9Ic8.mjs";
import "../transaction-BHOsiEn1.mjs";
import { a as detectFileFramework, c as validatePatchPath, i as checkSyntax, n as applyExtract, o as replaceInLine, r as applyReuse, s as validateFrameworkExpression, t as PATCHABLE_EXTENSIONS } from "../apply-manager-tRqDkzBV.mjs";
import "../transaction-C1P-WnVo.mjs";
import { a as detectFileFramework, c as validatePatchPath, i as checkSyntax, n as applyExtract, o as replaceInLine, r as applyReuse, s as validateFrameworkExpression, t as PATCHABLE_EXTENSIONS } from "../apply-manager-BpKGvXmn.mjs";
export { PATCHABLE_EXTENSIONS, applyExtract, applyReuse, checkSyntax, detectFileFramework, replaceInLine, validateFrameworkExpression, validatePatchPath };
import "../fs-DLbVB-Ek.mjs";
import "../meta-manager-CJUiTgP2.mjs";
import { S as writeContent, _ as resolveMdFilePath, b as validateLocale, d as listContent, f as parseFrontmatter, g as resolveLocaleStrategy, h as resolveJsonFilePath, m as resolveContentDir, p as readContent, u as deleteContent, v as serializeFrontmatter, x as validateSlug, y as validateEntryId } from "../model-manager-SiesTJrS.mjs";
import { S as writeContent, _ as resolveMdFilePath, b as validateLocale, d as listContent, f as parseFrontmatter, g as resolveLocaleStrategy, h as resolveJsonFilePath, m as resolveContentDir, p as readContent, u as deleteContent, v as serializeFrontmatter, x as validateSlug, y as validateEntryId } from "../model-manager-Rcj5Wzct.mjs";
export { deleteContent, listContent, parseFrontmatter, readContent, resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy, resolveMdFilePath, serializeFrontmatter, validateEntryId, validateLocale, validateSlug, writeContent };

@@ -5,4 +5,4 @@ import "../serialization-B1CEzR4H.mjs";

import "../config-oxxgznz7.mjs";
import "../model-manager-SiesTJrS.mjs";
import { n as readContext, r as writeContext, t as buildContextChange } from "../context-CrS-IvVm.mjs";
import "../model-manager-Rcj5Wzct.mjs";
import { n as readContext, r as writeContext, t as buildContextChange } from "../context-Cppz4R65.mjs";
export { buildContextChange, readContext, writeContext };
import "../fs-DLbVB-Ek.mjs";
import "../meta-manager-CJUiTgP2.mjs";
import "../config-oxxgznz7.mjs";
import "../model-manager-SiesTJrS.mjs";
import "../model-manager-Rcj5Wzct.mjs";
import "../branch-lifecycle-BAfgSQBv.mjs";
import "../scan-config-BGUflS8t.mjs";
import { t as runDoctor } from "../doctor-BRPXxKhx.mjs";
import { t as runDoctor } from "../doctor-D3usxlom.mjs";
export { runDoctor };

@@ -56,8 +56,17 @@ import { _ as RepoReader } from "../index-DDX-qYNw.mjs";

* Used by both model_save and normalize extract for full parity.
*
* `.strict()` is load-bearing: the default `z.object` *strips* unknown keys, so a
* typo'd constraint (`requird: true`) used to vanish without a word and the field
* silently lost the rule its author thought they had declared.
*/
declare const fieldDefZodSchema: z.ZodType<Record<string, unknown>>;
interface ModelDefinitionIssues {
/** Block the write. */
errors: string[];
/** Surface to the caller; the write proceeds. */
warnings: string[];
}
/**
* Validate a model definition before writing.
* Returns array of error messages (empty = valid).
* Used by both model_save tool and normalize extract.
* Used by both the model_save tool and normalize extract.
*/

@@ -68,5 +77,5 @@ declare function validateModelDefinition(input: {

fields?: Record<string, unknown>;
}): string[];
}): ModelDefinitionIssues;
//#endregion
export { FIELD_TYPE_ENUM, ModelReference, type ModelSummary, checkReferences, countEntries, deleteModel, fieldDefZodSchema, listModels, readModel, validateModelDefinition, writeModel };
export { FIELD_TYPE_ENUM, ModelDefinitionIssues, ModelReference, type ModelSummary, checkReferences, countEntries, deleteModel, fieldDefZodSchema, listModels, readModel, validateModelDefinition, writeModel };
//# sourceMappingURL=model-manager.d.mts.map

@@ -1,1 +0,1 @@

{"version":3,"file":"model-manager.d.mts","names":[],"sources":["../../src/core/model-manager.ts"],"mappings":";;;;;;AA6BA;;;;;;iBAAgB,UAAA,CAAW,WAAA,WAAsB,OAAA,CAAQ,cAAA;AAAA,iBACzC,UAAA,CAAW,MAAA,EAAQ,UAAA,GAAa,OAAA,CAAQ,cAAA;;AAAxD;;iBAgCgB,SAAA,CAAU,WAAA,UAAqB,OAAA,WAAkB,OAAA,CAAQ,eAAA;AAAA,iBACzD,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,OAAA,WAAkB,OAAA,CAAQ,eAAA;;;;;;;;iBA4JxD,YAAA,CACd,WAAA,UACA,KAAA,EAAO,eAAA,GACN,OAAA;EAAU,KAAA;EAAe,OAAA,EAAS,MAAA;AAAA;AAAA,iBACrB,YAAA,CACd,MAAA,EAAQ,UAAA,EACR,KAAA,EAAO,eAAA,GACN,OAAA;EAAU,KAAA;EAAe,OAAA,EAAS,MAAA;AAAA;AAAA,iBAgIf,UAAA,CAAW,WAAA,UAAqB,KAAA,EAAO,eAAA,GAAkB,OAAA;AAAA,iBASzD,WAAA,CAAY,WAAA,UAAqB,OAAA,WAAkB,OAAA;AAAA,UA+BxD,cAAA;EACf,KAAA;EACA,KAAA;EACA,IAAA;AAAA;;;;;;;;;iBAWc,eAAA,CAAgB,WAAA,UAAqB,OAAA,WAAkB,OAAA,CAAQ,cAAA;AAAA,iBAC/D,eAAA,CAAgB,MAAA,EAAQ,UAAA,EAAY,OAAA,WAAkB,OAAA,CAAQ,cAAA;AAAA,cA8BjE,eAAA;;;AA5Nb;;cA0Oa,iBAAA,EAAmB,CAAA,CAAE,OAAA,CAAQ,MAAA;;;;;;iBAiC1B,uBAAA,CAAwB,KAAA;EAAS,EAAA;EAAY,IAAA;EAAc,MAAA,GAAS,MAAA;AAAA"}
{"version":3,"file":"model-manager.d.mts","names":[],"sources":["../../src/core/model-manager.ts"],"mappings":";;;;;;AA6BA;;;;;;iBAAgB,UAAA,CAAW,WAAA,WAAsB,OAAA,CAAQ,cAAA;AAAA,iBACzC,UAAA,CAAW,MAAA,EAAQ,UAAA,GAAa,OAAA,CAAQ,cAAA;;AAAxD;;iBAgCgB,SAAA,CAAU,WAAA,UAAqB,OAAA,WAAkB,OAAA,CAAQ,eAAA;AAAA,iBACzD,SAAA,CAAU,MAAA,EAAQ,UAAA,EAAY,OAAA,WAAkB,OAAA,CAAQ,eAAA;;;;;;;;iBA4JxD,YAAA,CACd,WAAA,UACA,KAAA,EAAO,eAAA,GACN,OAAA;EAAU,KAAA;EAAe,OAAA,EAAS,MAAA;AAAA;AAAA,iBACrB,YAAA,CACd,MAAA,EAAQ,UAAA,EACR,KAAA,EAAO,eAAA,GACN,OAAA;EAAU,KAAA;EAAe,OAAA,EAAS,MAAA;AAAA;AAAA,iBAgIf,UAAA,CAAW,WAAA,UAAqB,KAAA,EAAO,eAAA,GAAkB,OAAA;AAAA,iBASzD,WAAA,CAAY,WAAA,UAAqB,OAAA,WAAkB,OAAA;AAAA,UA+BxD,cAAA;EACf,KAAA;EACA,KAAA;EACA,IAAA;AAAA;;;;;;;;;iBAWc,eAAA,CAAgB,WAAA,UAAqB,OAAA,WAAkB,OAAA,CAAQ,cAAA;AAAA,iBAC/D,eAAA,CAAgB,MAAA,EAAQ,UAAA,EAAY,OAAA,WAAkB,OAAA,CAAQ,cAAA;AAAA,cA8BjE,eAAA;;;AA5Nb;;;;;;cA8Oa,iBAAA,EAAmB,CAAA,CAAE,OAAA,CAAQ,MAAA;AAAA,UAiCzB,qBAAA;EA9Qf;EAgRA,MAAA;EA/QA;EAiRA,QAAA;AAAA;;;;;iBAkKc,uBAAA,CACd,KAAA;EAAS,EAAA;EAAY,IAAA;EAAc,MAAA,GAAS,MAAA;AAAA,IAC3C,qBAAA"}
import "../fs-DLbVB-Ek.mjs";
import "../meta-manager-CJUiTgP2.mjs";
import { a as fieldDefZodSchema, c as validateModelDefinition, i as deleteModel, l as writeModel, n as checkReferences, o as listModels, r as countEntries, s as readModel, t as FIELD_TYPE_ENUM } from "../model-manager-SiesTJrS.mjs";
import { a as fieldDefZodSchema, c as validateModelDefinition, i as deleteModel, l as writeModel, n as checkReferences, o as listModels, r as countEntries, s as readModel, t as FIELD_TYPE_ENUM } from "../model-manager-Rcj5Wzct.mjs";
export { FIELD_TYPE_ENUM, checkReferences, countEntries, deleteModel, fieldDefZodSchema, listModels, readModel, validateModelDefinition, writeModel };

@@ -1,1 +0,1 @@

{"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/core/validator/entry.ts","../../../src/core/validator/relation-integrity.ts","../../../src/core/validator/project.ts","../../../src/core/validator/schedule.ts"],"mappings":";;;;;;;AAUA;;;;UAAiB,iBAAA;EAMN;EAJT,UAAA,GAAa,MAAA,SAAe,MAAA;EAIJ;EAFxB,cAAA;EAFa;EAIb,MAAA,GAAS,eAAA;AAAA;;;;;AAgBX;;;;;;;;;iBAAgB,eAAA,CACd,IAAA,EAAM,MAAA,mBACN,MAAA,EAAQ,MAAA,SAAe,QAAA,GACvB,OAAA,UACA,MAAA,UACA,OAAA,WACA,GAAA,GAAM,iBAAA,GACL,gBAAA;;;;;;AA7BH;;;;;;;;;UCIiB,cAAA;EACf,MAAA;EACA,OAAA,GAAU,MAAA;AAAA;;KAIA,iBAAA,IACV,aAAA,UACA,YAAA,aACG,OAAA,CAAQ,MAAA;AAAA,UAEI,6BAAA;EDOD;ECLd,QAAA;;;;;;EAMA,aAAA,IACE,aAAA,UACA,YAAA,aACG,OAAA,CAAQ,cAAA;AAAA;;;;;;;;;;;;iBAcO,sBAAA,CACpB,IAAA,EAAM,MAAA,mBACN,MAAA,EAAQ,MAAA,SAAe,QAAA,GACvB,OAAA,UACA,MAAA,UACA,OAAA,sBACA,WAAA,EAAa,iBAAA,EACb,IAAA,GAAM,6BAAA,GACL,OAAA,CAAQ,eAAA;;;UCxCM,eAAA;EACf,KAAA;EACA,GAAA;AAAA;AAAA,UAGe,cAAA;EACf,KAAA;EACA,OAAA;IACE,MAAA;IACA,QAAA;IACA,OAAA;IACA,cAAA;IACA,eAAA;EAAA;EAEF,MAAA,EAAQ,eAAA;EACR,KAAA;AAAA;;;AFDF;;;;;;;;;;;;;iBEywBsB,eAAA,CAAgB,WAAA,UAAqB,OAAA,GAAU,eAAA,GAAkB,OAAA,CAAQ,cAAA;AAAA,iBACzE,eAAA,CAAgB,MAAA,EAAQ,UAAA,EAAY,OAAA,GAAU,eAAA,GAAkB,OAAA,CAAQ,cAAA;;;;;;AFhyB9F;;;;;iBGAgB,sBAAA,CACd,IAAA,EAAM,SAAA,EACN,GAAA;EAAO,KAAA;EAAe,MAAA;EAAgB,KAAA;EAAgB,IAAA;AAAA,GACtD,MAAA,EAAQ,eAAA"}
{"version":3,"file":"index.d.mts","names":[],"sources":["../../../src/core/validator/entry.ts","../../../src/core/validator/relation-integrity.ts","../../../src/core/validator/project.ts","../../../src/core/validator/schedule.ts"],"mappings":";;;;;;;AAUA;;;;UAAiB,iBAAA;EAMN;EAJT,UAAA,GAAa,MAAA,SAAe,MAAA;EAIJ;EAFxB,cAAA;EAFa;EAIb,MAAA,GAAS,eAAA;AAAA;;;;;AAgBX;;;;;;;;;iBAAgB,eAAA,CACd,IAAA,EAAM,MAAA,mBACN,MAAA,EAAQ,MAAA,SAAe,QAAA,GACvB,OAAA,UACA,MAAA,UACA,OAAA,WACA,GAAA,GAAM,iBAAA,GACL,gBAAA;;;;;;AA7BH;;;;;;;;;UCIiB,cAAA;EACf,MAAA;EACA,OAAA,GAAU,MAAA;AAAA;;KAIA,iBAAA,IACV,aAAA,UACA,YAAA,aACG,OAAA,CAAQ,MAAA;AAAA,UAEI,6BAAA;EDOD;ECLd,QAAA;;;;;;EAMA,aAAA,IACE,aAAA,UACA,YAAA,aACG,OAAA,CAAQ,cAAA;AAAA;;;;;;;;;;;;iBAcO,sBAAA,CACpB,IAAA,EAAM,MAAA,mBACN,MAAA,EAAQ,MAAA,SAAe,QAAA,GACvB,OAAA,UACA,MAAA,UACA,OAAA,sBACA,WAAA,EAAa,iBAAA,EACb,IAAA,GAAM,6BAAA,GACL,OAAA,CAAQ,eAAA;;;UCxCM,eAAA;EACf,KAAA;EACA,GAAA;AAAA;AAAA,UAGe,cAAA;EACf,KAAA;EACA,OAAA;IACE,MAAA;IACA,QAAA;IACA,OAAA;IACA,cAAA;IACA,eAAA;EAAA;EAEF,MAAA,EAAQ,eAAA;EACR,KAAA;AAAA;;;AFDF;;;;;;;;;;;;;iBE+xBsB,eAAA,CAAgB,WAAA,UAAqB,OAAA,GAAU,eAAA,GAAkB,OAAA,CAAQ,cAAA;AAAA,iBACzE,eAAA,CAAgB,MAAA,EAAQ,UAAA,EAAY,OAAA,GAAU,eAAA,GAAkB,OAAA,CAAQ,cAAA;;;;;;AFtzB9F;;;;;iBGAgB,sBAAA,CACd,IAAA,EAAM,SAAA,EACN,GAAA;EAAO,KAAA;EAAe,MAAA;EAAgB,KAAA;EAAgB,IAAA;AAAA,GACtD,MAAA,EAAQ,eAAA"}
import "../../fs-DLbVB-Ek.mjs";
import "../../meta-manager-CJUiTgP2.mjs";
import "../../config-oxxgznz7.mjs";
import "../../model-manager-SiesTJrS.mjs";
import { i as validateContent, n as validateScheduleFields, r as checkRelationIntegrity, t as validateProject } from "../../validator-Ck0puppQ.mjs";
import "../../model-manager-Rcj5Wzct.mjs";
import { i as validateContent, n as validateScheduleFields, r as checkRelationIntegrity, t as validateProject } from "../../validator-DLmW9MVF.mjs";
export { checkRelationIntegrity, validateContent, validateProject, validateScheduleFields };

@@ -5,7 +5,7 @@ import "../serialization-B1CEzR4H.mjs";

import "../config-oxxgznz7.mjs";
import "../model-manager-SiesTJrS.mjs";
import "../context-CrS-IvVm.mjs";
import "../model-manager-Rcj5Wzct.mjs";
import "../context-Cppz4R65.mjs";
import "../branch-lifecycle-BAfgSQBv.mjs";
import "../id-DV_T9Ic8.mjs";
import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "../transaction-BHOsiEn1.mjs";
import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "../transaction-C1P-WnVo.mjs";
export { buildBranchName, createTransaction, ensureContentBranch, mergeBranch };

@@ -8,12 +8,12 @@ #!/usr/bin/env node

import "./config-oxxgznz7.mjs";
import "./model-manager-SiesTJrS.mjs";
import "./context-CrS-IvVm.mjs";
import "./model-manager-Rcj5Wzct.mjs";
import "./context-Cppz4R65.mjs";
import "./branch-lifecycle-BAfgSQBv.mjs";
import "./id-DV_T9Ic8.mjs";
import "./transaction-BHOsiEn1.mjs";
import "./local-DqmDeNxI.mjs";
import "./transaction-C1P-WnVo.mjs";
import "./local-CUOVOu5U.mjs";
import "./detect-wVSI9VuY.mjs";
import "./annotations-D3tlsF38.mjs";
import { n as createServer } from "./server-DSEl2wzT.mjs";
import "./validator-Ck0puppQ.mjs";
import { n as createServer } from "./server-DT8MN5d1.mjs";
import "./validator-DLmW9MVF.mjs";
import "./scan-config-BGUflS8t.mjs";

@@ -23,4 +23,4 @@ import "./graph-builder-CRUX_8mA.mjs";

import "./tsx-parser-B_aI_C2r.mjs";
import "./apply-manager-tRqDkzBV.mjs";
import "./doctor-BRPXxKhx.mjs";
import "./apply-manager-BpKGvXmn.mjs";
import "./doctor-D3usxlom.mjs";
import { resolve } from "node:path";

@@ -27,0 +27,0 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

@@ -7,8 +7,8 @@ import "../../contracts-DfL0BfrD.mjs";

import "../../config-oxxgznz7.mjs";
import { C as LocalReader } from "../../model-manager-SiesTJrS.mjs";
import "../../context-CrS-IvVm.mjs";
import { C as LocalReader } from "../../model-manager-Rcj5Wzct.mjs";
import "../../context-Cppz4R65.mjs";
import "../../branch-lifecycle-BAfgSQBv.mjs";
import "../../id-DV_T9Ic8.mjs";
import "../../transaction-BHOsiEn1.mjs";
import { t as LocalProvider } from "../../local-DqmDeNxI.mjs";
import "../../transaction-C1P-WnVo.mjs";
import { t as LocalProvider } from "../../local-CUOVOu5U.mjs";
export { LocalProvider, LocalReader };

@@ -7,12 +7,12 @@ import "./contracts-DfL0BfrD.mjs";

import "./config-oxxgznz7.mjs";
import "./model-manager-SiesTJrS.mjs";
import "./context-CrS-IvVm.mjs";
import "./model-manager-Rcj5Wzct.mjs";
import "./context-Cppz4R65.mjs";
import "./branch-lifecycle-BAfgSQBv.mjs";
import "./id-DV_T9Ic8.mjs";
import "./transaction-BHOsiEn1.mjs";
import "./local-DqmDeNxI.mjs";
import "./transaction-C1P-WnVo.mjs";
import "./local-CUOVOu5U.mjs";
import "./detect-wVSI9VuY.mjs";
import "./annotations-D3tlsF38.mjs";
import { n as createServer, t as DEFAULT_INSTRUCTIONS } from "./server-DSEl2wzT.mjs";
import "./validator-Ck0puppQ.mjs";
import { n as createServer, t as DEFAULT_INSTRUCTIONS } from "./server-DT8MN5d1.mjs";
import "./validator-DLmW9MVF.mjs";
import "./scan-config-BGUflS8t.mjs";

@@ -22,4 +22,4 @@ import "./graph-builder-CRUX_8mA.mjs";

import "./tsx-parser-B_aI_C2r.mjs";
import "./apply-manager-tRqDkzBV.mjs";
import "./doctor-BRPXxKhx.mjs";
import "./apply-manager-BpKGvXmn.mjs";
import "./doctor-D3usxlom.mjs";
export { DEFAULT_INSTRUCTIONS, createServer };

@@ -7,12 +7,12 @@ import "../../contracts-DfL0BfrD.mjs";

import "../../config-oxxgznz7.mjs";
import "../../model-manager-SiesTJrS.mjs";
import "../../context-CrS-IvVm.mjs";
import "../../model-manager-Rcj5Wzct.mjs";
import "../../context-Cppz4R65.mjs";
import "../../branch-lifecycle-BAfgSQBv.mjs";
import "../../id-DV_T9Ic8.mjs";
import "../../transaction-BHOsiEn1.mjs";
import "../../local-DqmDeNxI.mjs";
import "../../transaction-C1P-WnVo.mjs";
import "../../local-CUOVOu5U.mjs";
import "../../detect-wVSI9VuY.mjs";
import "../../annotations-D3tlsF38.mjs";
import { n as createServer } from "../../server-DSEl2wzT.mjs";
import "../../validator-Ck0puppQ.mjs";
import { n as createServer } from "../../server-DT8MN5d1.mjs";
import "../../validator-DLmW9MVF.mjs";
import "../../scan-config-BGUflS8t.mjs";

@@ -22,4 +22,4 @@ import "../../graph-builder-CRUX_8mA.mjs";

import "../../tsx-parser-B_aI_C2r.mjs";
import "../../apply-manager-tRqDkzBV.mjs";
import "../../doctor-BRPXxKhx.mjs";
import "../../apply-manager-BpKGvXmn.mjs";
import "../../doctor-D3usxlom.mjs";
import { randomUUID } from "node:crypto";

@@ -26,0 +26,0 @@ import http from "node:http";

{
"name": "@contentrain/mcp",
"version": "1.11.0",
"version": "2.0.0",
"mcpName": "io.github.Contentrain/contentrain",

@@ -161,3 +161,3 @@ "license": "MIT",

"zod": "^3.24.0",
"@contentrain/types": "0.8.0"
"@contentrain/types": "0.9.0"
},

@@ -164,0 +164,0 @@ "optionalDependencies": {

import { c as writeText, o as readText, r as pathExists } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { S as writeContent, _ as resolveMdFilePath, c as validateModelDefinition, h as resolveJsonFilePath, l as writeModel, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-SiesTJrS.mjs";
import { r as writeContext } from "./context-CrS-IvVm.mjs";
import { n as checkBranchHealth } from "./branch-lifecycle-BAfgSQBv.mjs";
import { n as createTransaction, t as buildBranchName } from "./transaction-BHOsiEn1.mjs";
import { extname, join } from "node:path";
//#region src/core/apply-manager.ts
const MAX_PATCHES = 100;
/** File extensions allowed for patching — scannable source files only */
const PATCHABLE_EXTENSIONS = new Set([
".vue",
".tsx",
".jsx",
".ts",
".js",
".mjs",
".astro",
".svelte"
]);
/** Directories that must never be patched */
const FORBIDDEN_PATH_SEGMENTS = new Set([
".contentrain",
"node_modules",
".git",
"dist",
"build",
".next",
".nuxt"
]);
function detectFileFramework(filePath) {
switch (extname(filePath).toLowerCase()) {
case ".vue": return "vue";
case ".svelte": return "svelte";
case ".tsx":
case ".jsx": return "jsx";
case ".astro": return "astro";
case ".ts":
case ".js":
case ".mjs": return "script";
default: return "script";
}
}
/**
* Validate that a replacement expression uses the correct template syntax
* for the target file's framework. Returns a warning string or null.
*/
function validateFrameworkExpression(filePath, newExpression, context) {
if (context !== "tag_text") return null;
switch (detectFileFramework(filePath)) {
case "vue":
if (!newExpression.includes("{{")) return `Vue file "${filePath}": tag text expression "${newExpression}" does not contain "{{" — expected Vue template syntax like {{ $t('key') }}`;
break;
case "jsx":
if (!newExpression.includes("{")) return `JSX file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected JSX syntax like {t('key')}`;
break;
case "svelte":
if (!newExpression.includes("{")) return `Svelte file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Svelte syntax like {$t('key')}`;
break;
case "astro":
if (!newExpression.includes("{")) return `Astro file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Astro syntax like {t('key')}`;
break;
case "script": return `Script file "${filePath}": tag text replacement not applicable for .ts/.js files`;
}
return null;
}
/**
* Validate that a patch file path is safe and within allowed scope.
* Returns an error message or null if valid.
*/
function validatePatchPath(filePath) {
const normalizedPath = filePath.replace(/\\/g, "/");
if (normalizedPath.includes("..")) return `Path traversal detected: "${filePath}"`;
if (normalizedPath.startsWith("/")) return `Absolute path not allowed: "${filePath}"`;
const segments = normalizedPath.split("/");
for (const seg of segments) if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return `Patching files inside "${seg}/" is not allowed: "${filePath}"`;
const ext = extname(filePath).toLowerCase();
if (!PATCHABLE_EXTENSIONS.has(ext)) return `File extension "${ext}" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(", ")}. Path: "${filePath}"`;
return null;
}
/**
* Perform a basic syntax check on a patched file.
* Returns an error message or null if syntax appears valid.
*/
function checkSyntax(filePath, content) {
switch (extname(filePath).toLowerCase()) {
case ".ts":
case ".tsx":
case ".js":
case ".jsx":
case ".mjs": return checkJsSyntax(content);
case ".vue": return checkVueSyntax(content);
case ".svelte":
case ".astro": return checkTagBalance(content);
default: return null;
}
}
/**
* Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.
* This is intentionally conservative — it catches obvious breakage without
* requiring a full parser.
*/
function checkJsSyntax(content) {
const stack = [];
const pairs = {
")": "(",
"]": "[",
"}": "{"
};
let inString = null;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < content.length; i++) {
const ch = content[i];
const next = content[i + 1];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\" && inString !== null) {
escaped = true;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "/") {
inLineComment = true;
continue;
}
if (inLineComment) {
if (ch === "\n") inLineComment = false;
continue;
}
if (!inString && !inBlockComment && ch === "/" && next === "*") {
inBlockComment = true;
i++;
continue;
}
if (inBlockComment) {
if (ch === "*" && next === "/") {
inBlockComment = false;
i++;
}
continue;
}
if (inString !== null) {
if (ch === inString) inString = null;
else if (inString !== "`" && ch === "\n") return `Unterminated string literal near offset ${i}`;
continue;
}
if (ch === "\"" || ch === "'" || ch === "`") {
inString = ch;
continue;
}
if (ch === "(" || ch === "[" || ch === "{") stack.push(ch);
else if (ch === ")" || ch === "]" || ch === "}") {
const expected = pairs[ch];
if (stack.length === 0) return `Unmatched closing "${ch}" near offset ${i}`;
const top = stack.pop();
if (top !== expected) return `Mismatched bracket: expected closing for "${top}" but found "${ch}" near offset ${i}`;
}
}
if (inString !== null) return `Unterminated string literal (opened with ${inString})`;
if (stack.length > 0) return `Unclosed bracket "${stack[stack.length - 1]}" — ${stack.length} unclosed bracket(s)`;
return null;
}
/**
* Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.
*/
function checkVueSyntax(content) {
const tagBalance = checkTagBalance(content);
if (tagBalance) return tagBalance;
for (const tag of ["template", "script"]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
/**
* Basic tag balance check for HTML-like files.
* Checks that self-closing tags are handled and major structural tags are balanced.
*/
function checkTagBalance(content) {
for (const tag of [
"div",
"section",
"main",
"header",
"footer",
"nav",
"article",
"aside",
"ul",
"ol",
"table"
]) {
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
const closeRe = new RegExp(`</${tag}>`, "g");
const opens = content.match(openRe)?.length ?? 0;
const closes = content.match(closeRe)?.length ?? 0;
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
}
return null;
}
async function applyExtract(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { extractions, dry_run } = input;
const existingModels = await listModels(projectRoot);
const existingIds = new Set(existingModels.map((m) => m.id));
const modelsToCreate = [];
const modelsToUpdate = [];
const contentFiles = [];
let totalEntries = 0;
const validationErrors = [];
for (const ext of extractions) {
const modelErrors = validateModelDefinition({
id: ext.model,
kind: ext.kind,
fields: ext.fields
});
if (modelErrors.length > 0) validationErrors.push(...modelErrors.map((e) => `[${ext.model}] ${e}`));
for (const entry of ext.entries) if (ext.kind === "dictionary") {
if (entry.data["id"] !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`);
for (const [key, val] of Object.entries(entry.data)) if (typeof val !== "string") validationErrors.push(`[${ext.model}] Dictionary entry value for key "${key}" must be a string, got ${typeof val}`);
} else if (ext.kind === "document") {
if (!entry.slug && !entry.data["slug"]) validationErrors.push(`[${ext.model}] Document entries must have a slug`);
} else if (ext.kind === "collection") {
if (entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Collection entries should not have slug`);
} else if (ext.kind === "singleton") {
if (entry.data["id"] !== void 0 || entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`);
}
if (existingIds.has(ext.model)) modelsToUpdate.push(ext.model);
else modelsToCreate.push(ext.model);
totalEntries += ext.entries.length;
let previewModel;
if (existingIds.has(ext.model)) {
const real = await readModel(projectRoot, ext.model);
if (real) previewModel = real;
else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
} else previewModel = {
id: ext.model,
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true
};
const cDir = resolveContentDir(projectRoot, previewModel);
for (const entry of ext.entries) {
const locale = entry.locale ?? config.locales.default;
if (ext.kind === "document" && entry.slug) contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug));
else contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale));
}
}
const preview = {
models_to_create: modelsToCreate,
models_to_update: modelsToUpdate,
total_entries: totalEntries,
content_files: [...new Set(contentFiles)]
};
if (dry_run !== false) return {
dry_run: true,
preview,
...validationErrors.length > 0 ? { validation_errors: validationErrors } : {},
next_steps: [
...validationErrors.length > 0 ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`] : [],
"Review the preview above",
"Call contentrain_apply with mode:extract and dry_run:false to execute"
]
};
if (validationErrors.length > 0) return {
dry_run: false,
error: "Model validation failed — cannot execute extract with invalid model definitions",
validation_errors: validationErrors,
next_steps: ["Fix the validation errors and retry"]
};
const health = await checkBranchHealth(projectRoot);
if (health.blocked) return {
error: health.message,
action: "blocked",
hint: "Merge or delete old contentrain/* branches before executing normalize."
};
const branchName = buildBranchName("normalize", "extract");
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const sourceMap = [];
const modelsCreated = [];
const modelsUpdated = [];
let entriesWritten = 0;
try {
await tx.write(async (wt) => {
for (const ext of extractions) {
const existing = await readModel(wt, ext.model);
if (existing) {
if (ext.fields) {
const merged = {
...existing.fields,
...ext.fields
};
if (Object.keys(ext.fields).filter((k) => !(k in (existing.fields ?? {}))).length > 0) {
existing.fields = merged;
await writeModel(wt, existing);
modelsUpdated.push(ext.model);
}
}
} else {
await writeModel(wt, {
id: ext.model,
name: ext.model.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
kind: ext.kind,
domain: ext.domain,
i18n: ext.i18n ?? true,
fields: ext.fields
});
modelsCreated.push(ext.model);
}
const model = await readModel(wt, ext.model);
const entries = ext.entries.map((e) => ({
locale: e.locale,
slug: e.slug,
data: e.data
}));
await writeContent(wt, model, entries, await readConfig(wt) ?? config);
entriesWritten += entries.length;
for (const entry of ext.entries) if (ext.kind === "dictionary" && entry.sources) for (const s of entry.sources) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: s.value,
file: s.file,
line: s.line
});
else if (entry.source) sourceMap.push({
model: ext.model,
locale: entry.locale ?? config.locales.default,
value: entry.source.value,
file: entry.source.file,
line: entry.source.line
});
}
if (sourceMap.length > 0) {
const sourcesByModel = {};
for (const s of sourceMap) {
if (!sourcesByModel[s.model]) sourcesByModel[s.model] = {
source_files: [],
entry_count: 0
};
const modelEntry = sourcesByModel[s.model];
if (!modelEntry.source_files.includes(s.file)) modelEntry.source_files.push(s.file);
modelEntry.entry_count++;
}
const sourcesJson = JSON.stringify({
version: 1,
created_at: (/* @__PURE__ */ new Date()).toISOString(),
models: sourcesByModel
}, null, 2) + "\n";
await writeText(join(wt, ".contentrain", "normalize-sources.json"), sourcesJson);
}
await writeContext(wt, {
tool: "contentrain_apply",
model: extractions.map((e) => e.model).join(","),
locale: config.locales.default,
entries: extractions.flatMap((e) => e.entries.map((en) => en.slug ?? "entry"))
});
});
const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
models_created: modelsCreated,
models_updated: modelsUpdated,
entries_written: entriesWritten,
source_map: sourceMap
},
git: gitResult,
context_updated: true,
next_steps: [
"Run contentrain_validate to check the extracted content",
"Run contentrain_submit to push the branch for review",
"For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize",
"For terminal workflow: use contentrain_merge to merge the branch locally",
"After merge, run `npx contentrain generate` to update SDK client",
"After review, proceed with mode:reuse to patch source files"
]
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
async function applyReuse(projectRoot, input) {
const config = await readConfig(projectRoot);
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
const { scope, patches, dry_run } = input;
if (!scope.model && !scope.domain) throw new Error("Scope required: provide model or domain. Whole-project patching is not allowed.");
if (patches.length > MAX_PATCHES) throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`);
if (scope.model) {
if (!await readModel(projectRoot, scope.model)) throw new Error(`Model "${scope.model}" not found. Run extract phase first.`);
}
const scopeWarnings = [];
for (const patch of patches) {
const pathError = validatePatchPath(patch.file);
if (pathError) throw new Error(`Invalid patch path: ${pathError}`);
}
if (scope.model || scope.domain) {
const models = await listModels(projectRoot);
const scopeModels = scope.model ? models.filter((m) => m.id === scope.model) : scope.domain ? models.filter((m) => m.domain === scope.domain) : models;
if (scopeModels.length === 0) throw new Error(`No models found for scope ${scope.model ? `model="${scope.model}"` : `domain="${scope.domain}"`}`);
const { autoDetectSourceDirs } = await import("./core/scan-config.mjs");
const sourceDirs = await autoDetectSourceDirs(projectRoot);
const allowedPrefixes = sourceDirs.map((d) => d === "." ? "" : d + "/");
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (normalizedPath.startsWith(".contentrain/") || normalizedPath.includes("/.contentrain/")) throw new Error(`Cannot patch content/config files directly: "${patch.file}". Reuse patches source files only.`);
if (sourceDirs.length > 0 && sourceDirs[0] !== ".") {
if (!allowedPrefixes.some((prefix) => prefix === "" || normalizedPath.startsWith(prefix))) throw new Error(`Patch file "${patch.file}" is outside detected source directories (${sourceDirs.join(", ")}). Reuse patches must target source files within the project's source tree.`);
}
}
if (scope.model || scope.domain) {
const sourcesRaw = await readText(join(projectRoot, ".contentrain", "normalize-sources.json"));
if (!sourcesRaw) if (scopeModels.find((m) => m.id === scope.model)?.kind === "dictionary") scopeWarnings.push("normalize-sources.json not found. Dictionary models do not generate per-file source maps. Scope enforcement is based on source-tree locality only.");
else if (dry_run !== false) scopeWarnings.push("normalize-sources.json not found. Semantic scope enforcement is unavailable. Merge the extract branch first, then reuse will have full scope protection.");
else throw new Error("Cannot execute reuse: normalize-sources.json not found on base branch. The extract branch must be merged before reuse can execute. This ensures semantic scope enforcement protects against out-of-scope patching.");
else {
const sourcesData = JSON.parse(sourcesRaw);
let allowedSourceFiles = [];
let scopeLabel = "";
if (scope.model) {
const modelSources = sourcesData.models?.[scope.model]?.source_files;
if (modelSources) allowedSourceFiles = modelSources;
scopeLabel = `model "${scope.model}"`;
} else if (scope.domain) {
const domainModelIds = scopeModels.map((m) => m.id);
for (const modelId of domainModelIds) {
const modelSources = sourcesData.models?.[modelId]?.source_files;
if (modelSources) allowedSourceFiles.push(...modelSources);
}
allowedSourceFiles = [...new Set(allowedSourceFiles)];
scopeLabel = `domain "${scope.domain}"`;
}
if (allowedSourceFiles.length > 0) {
const outOfScopePatches = [];
for (const patch of patches) {
const normalizedPath = patch.file.replace(/\\/g, "/");
if (!allowedSourceFiles.includes(normalizedPath)) outOfScopePatches.push(patch.file);
}
if (outOfScopePatches.length > 0) throw new Error(`Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. Out-of-scope files: ${outOfScopePatches.join(", ")}. Known source files: ${allowedSourceFiles.join(", ")}.`);
}
}
}
}
const patchesByFile = /* @__PURE__ */ new Map();
for (const patch of patches) {
if (!patchesByFile.has(patch.file)) patchesByFile.set(patch.file, []);
patchesByFile.get(patch.file).push(patch);
}
const filesToModify = [...patchesByFile.keys()];
const importsToAdd = patches.filter((p) => p.import_statement).length;
if (dry_run !== false) return {
dry_run: true,
preview: {
files_to_modify: filesToModify,
patches_count: patches.length,
imports_to_add: importsToAdd
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
next_steps: [
...scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : [],
"Review the files and patches above",
"Call contentrain_apply with mode:reuse and dry_run:false to execute"
]
};
const reuseHealth = await checkBranchHealth(projectRoot);
if (reuseHealth.blocked) return {
dry_run: false,
error: `Branch blocked: ${reuseHealth.message}`,
next_steps: ["Merge or delete old contentrain/* branches before executing reuse."]
};
const scopeTarget = scope.model ?? scope.domain;
const branchName = buildBranchName("normalize/reuse", scopeTarget);
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
const filesModified = [];
let patchesApplied = 0;
let importsAdded = 0;
const patchesSkipped = [];
const frameworkWarnings = [];
const syntaxErrors = [];
try {
await tx.write(async (wt) => {
for (const [relFile, filePatches] of patchesByFile) {
const absPath = join(wt, relFile);
if (!await pathExists(absPath)) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file not found"
});
continue;
}
const content = await readText(absPath);
if (content === null) {
for (const p of filePatches) patchesSkipped.push({
file: relFile,
line: p.line,
reason: "file unreadable"
});
continue;
}
const sorted = [...filePatches].toSorted((a, b) => b.line - a.line);
const lines = content.split("\n");
let fileModified = false;
for (const patch of sorted) {
const isTagTextContext = (lines[patch.line - 1] ?? "").includes(`>${patch.old_value}<`);
const replacementContext = isTagTextContext ? "tag_text" : "other";
if (isTagTextContext) {
const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext);
if (fwWarning) frameworkWarnings.push({
file: relFile,
warning: fwWarning
});
}
if (applyPatchToLines(lines, patch)) {
patchesApplied++;
fileModified = true;
} else patchesSkipped.push({
file: relFile,
line: patch.line,
reason: "old_value not found at or near specified line"
});
}
const importStatements = new Set(filePatches.filter((p) => p.import_statement).map((p) => p.import_statement));
if (importStatements.size > 0) {
const added = addImportsToLines(lines, importStatements);
importsAdded += added;
if (added > 0) fileModified = true;
}
if (fileModified) {
const newContent = lines.join("\n");
await writeText(absPath, newContent);
filesModified.push(relFile);
const syntaxError = checkSyntax(relFile, newContent);
if (syntaxError) syntaxErrors.push({
file: relFile,
error: syntaxError
});
}
}
await writeContext(wt, {
tool: "contentrain_apply",
model: scopeTarget,
locale: config.locales.default
});
});
if (filesModified.length === 0) {
await tx.cleanup();
return {
dry_run: false,
results: {
files_modified: [],
patches_applied: 0,
patches_skipped: patchesSkipped,
imports_added: 0,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0
},
next_steps: ["No files were modified. Check patch definitions and try again."]
};
}
const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`;
await tx.commit(commitMsg);
const gitResult = {
branch: branchName,
action: "pending-review",
commit: ""
};
try {
const completed = await tx.complete();
gitResult.action = completed.action;
gitResult.commit = completed.commit;
} catch {
gitResult.action = "pending-review";
} finally {
await tx.cleanup();
}
return {
dry_run: false,
results: {
files_modified: filesModified,
patches_applied: patchesApplied,
patches_skipped: patchesSkipped,
imports_added: importsAdded,
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0,
syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : void 0
},
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
git: gitResult,
next_steps: [
"Run contentrain_validate to verify the patched files",
patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : "",
syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : "",
scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : "",
"Run contentrain_submit to push the branch for review",
"For review: direct user to http://localhost:3333/branches or use contentrain_merge",
"After all reuse phases complete, run `npx contentrain generate` to update SDK types"
].filter(Boolean)
};
} catch (error) {
await tx.cleanup();
throw error;
}
}
/**
* Apply a single patch to a lines array. Mutates lines in place.
* Uses line hint for proximity matching — searches ±10 lines from hint.
*/
function applyPatchToLines(lines, patch) {
const { line, old_value, new_expression } = patch;
const lineIdx = line - 1;
const searchStart = Math.max(0, lineIdx - 10);
const searchEnd = Math.min(lines.length, lineIdx + 11);
if (lineIdx >= 0 && lineIdx < lines.length) {
const replaced = replaceInLine(lines[lineIdx], old_value, new_expression);
if (replaced !== null) {
lines[lineIdx] = replaced;
return true;
}
}
for (let i = searchStart; i < searchEnd; i++) {
if (i === lineIdx) continue;
const replaced = replaceInLine(lines[i], old_value, new_expression);
if (replaced !== null) {
lines[i] = replaced;
return true;
}
}
return false;
}
/**
* Replace old_value with new_expression in a single line.
* Matches the string literal (quoted or unquoted tag text).
* Returns the modified line, or null if not found.
*
* Guardrail #4: Safer patch matching — word boundary awareness and
* ambiguity rejection for plain text fallback.
*/
function replaceInLine(line, oldValue, newExpression) {
for (const quote of [
"\"",
"'",
"`"
]) {
const quoted = `${quote}${oldValue}${quote}`;
if (line.includes(quoted)) return line.replace(quoted, newExpression);
}
if (line.includes(`>${oldValue}<`)) return line.replace(`>${oldValue}<`, `>${newExpression}<`);
if (line.includes(oldValue)) {
if (countOccurrences(line, oldValue) > 1) return null;
const idx = line.indexOf(oldValue);
const charBefore = idx > 0 ? line[idx - 1] : "";
const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length] : "";
const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]);
const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]);
if (oldStartsWithWord && isWordChar(charBefore)) return null;
if (oldEndsWithWord && isWordChar(charAfter)) return null;
return line.replace(oldValue, newExpression);
}
return null;
}
/** Count non-overlapping occurrences of a substring */
function countOccurrences(str, sub) {
let count = 0;
let pos = 0;
while (pos <= str.length - sub.length) {
const idx = str.indexOf(sub, pos);
if (idx === -1) break;
count++;
pos = idx + sub.length;
}
return count;
}
/** Check if a character is a word character (letter, digit, underscore) */
function isWordChar(ch) {
return /\w/.test(ch);
}
/**
* Add import statements to the top of a file (after existing imports).
* Deduplicates — won't add if the import already exists.
* Returns number of imports actually added.
*/
function addImportsToLines(lines, imports) {
let added = 0;
const existingContent = lines.join("\n");
let lastImportIdx = -1;
let inMultiLineImport = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (inMultiLineImport) {
lastImportIdx = i;
if (trimmed.includes("}")) inMultiLineImport = false;
continue;
}
if (trimmed.startsWith("import ") || trimmed.startsWith("import{")) {
lastImportIdx = i;
if (trimmed.includes("{") && !trimmed.includes("}")) inMultiLineImport = true;
continue;
}
if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) break;
}
let insertAt;
if (lastImportIdx >= 0) insertAt = lastImportIdx + 1;
else {
insertAt = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (i === 0 && trimmed.startsWith("#!")) {
insertAt = i + 1;
continue;
}
if (trimmed === "'use client'" || trimmed === "\"use client\"" || trimmed === "'use server'" || trimmed === "\"use server\"" || trimmed === "'use client';" || trimmed === "\"use client\";" || trimmed === "'use server';" || trimmed === "\"use server\";") {
insertAt = i + 1;
continue;
}
if (insertAt > 0 && trimmed.length > 0) break;
if (insertAt === 0 && trimmed.length > 0) break;
}
}
const toInsert = [];
for (const imp of imports) if (!existingContent.includes(imp)) {
toInsert.push(imp);
added++;
}
if (toInsert.length > 0) lines.splice(insertAt, 0, ...toInsert);
return added;
}
//#endregion
export { detectFileFramework as a, validatePatchPath as c, checkSyntax as i, applyExtract as n, replaceInLine as o, applyReuse as r, validateFrameworkExpression as s, PATCHABLE_EXTENSIONS as t };
//# sourceMappingURL=apply-manager-tRqDkzBV.mjs.map
{"version":3,"file":"apply-manager-tRqDkzBV.mjs","names":[],"sources":["../src/core/apply-manager.ts"],"sourcesContent":["import type { ModelDefinition, FieldDef, FileFramework } from '@contentrain/types'\nimport { join, extname } from 'node:path'\nimport { readText, writeText, pathExists } from '../util/fs.js'\nimport { readModel, writeModel, listModels, validateModelDefinition } from './model-manager.js'\nimport { writeContent, resolveContentDir, resolveJsonFilePath, resolveMdFilePath, type ContentEntry } from './content-manager.js'\nimport { readConfig } from './config.js'\nimport { writeContext } from './context.js'\nimport { createTransaction, buildBranchName } from '../git/transaction.js'\nimport { checkBranchHealth } from '../git/branch-lifecycle.js'\n\n// ─── Types ───\n\nexport interface ExtractionEntry {\n model: string\n kind: 'singleton' | 'collection' | 'dictionary' | 'document'\n domain: string\n i18n?: boolean\n fields?: Record<string, FieldDef>\n entries: Array<{\n locale?: string\n slug?: string\n data: Record<string, unknown>\n source?: { file: string; line: number; value: string }\n sources?: Array<{ file: string; line: number; key: string; value: string }>\n }>\n}\n\nexport interface ExtractionInput {\n extractions: ExtractionEntry[]\n dry_run?: boolean\n}\n\nexport interface ExtractionPreview {\n models_to_create: string[]\n models_to_update: string[]\n total_entries: number\n content_files: string[]\n}\n\nexport interface ExtractionResult {\n dry_run: boolean\n preview?: ExtractionPreview\n error?: string\n validation_errors?: string[]\n results?: {\n models_created: string[]\n models_updated: string[]\n entries_written: number\n source_map: Array<{ model: string; locale: string; value: string; file: string; line: number }>\n }\n git?: { branch: string; action: string; commit: string }\n context_updated?: boolean\n next_steps: string[]\n}\n\nexport interface PatchEntry {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\nexport interface ReuseInput {\n scope: { model?: string; domain?: string }\n patches: PatchEntry[]\n dry_run?: boolean\n}\n\nexport interface SyntaxError {\n file: string\n error: string\n}\n\nexport interface ReuseResult {\n dry_run: boolean\n error?: string\n scope_warnings?: string[]\n preview?: {\n files_to_modify: string[]\n patches_count: number\n imports_to_add: number\n }\n results?: {\n files_modified: string[]\n patches_applied: number\n patches_skipped: Array<{ file: string; line: number; reason: string }>\n imports_added: number\n framework_warnings?: Array<{ file: string; warning: string }>\n syntax_errors?: SyntaxError[]\n }\n git?: { branch: string; action: string; commit: string }\n next_steps: string[]\n}\n\n// ─── Constants ───\n\nconst MAX_PATCHES = 100\n\n/** File extensions allowed for patching — scannable source files only */\nexport const PATCHABLE_EXTENSIONS = new Set([\n '.vue', '.tsx', '.jsx', '.ts', '.js', '.mjs', '.astro', '.svelte',\n])\n\n/** Directories that must never be patched */\nconst FORBIDDEN_PATH_SEGMENTS = new Set([\n '.contentrain', 'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',\n])\n\n// ─── Framework Detection (Guardrail #2) ───\n\nexport type { FileFramework } from '@contentrain/types'\n\nexport function detectFileFramework(filePath: string): FileFramework {\n const ext = extname(filePath).toLowerCase()\n switch (ext) {\n case '.vue': return 'vue'\n case '.svelte': return 'svelte'\n case '.tsx':\n case '.jsx': return 'jsx'\n case '.astro': return 'astro'\n case '.ts':\n case '.js':\n case '.mjs': return 'script'\n default: return 'script'\n }\n}\n\n/**\n * Validate that a replacement expression uses the correct template syntax\n * for the target file's framework. Returns a warning string or null.\n */\nexport function validateFrameworkExpression(\n filePath: string,\n newExpression: string,\n context: 'tag_text' | 'other',\n): string | null {\n if (context !== 'tag_text') return null\n\n const framework = detectFileFramework(filePath)\n\n switch (framework) {\n case 'vue':\n if (!newExpression.includes('{{')) {\n return `Vue file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{{\" — expected Vue template syntax like {{ $t('key') }}`\n }\n break\n case 'jsx':\n if (!newExpression.includes('{')) {\n return `JSX file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected JSX syntax like {t('key')}`\n }\n break\n case 'svelte':\n if (!newExpression.includes('{')) {\n return `Svelte file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Svelte syntax like {$t('key')}`\n }\n break\n case 'astro':\n if (!newExpression.includes('{')) {\n return `Astro file \"${filePath}\": tag text expression \"${newExpression}\" does not contain \"{\" — expected Astro syntax like {t('key')}`\n }\n break\n case 'script':\n // Script files don't have template interpolation — warn if attempting tag text replacement\n return `Script file \"${filePath}\": tag text replacement not applicable for .ts/.js files`\n }\n\n return null\n}\n\n// ─── Scope Validation (Guardrail #1) ───\n\n/**\n * Validate that a patch file path is safe and within allowed scope.\n * Returns an error message or null if valid.\n */\nexport function validatePatchPath(filePath: string): string | null {\n const normalizedPath = filePath.replace(/\\\\/g, '/')\n\n // Reject path traversal\n if (normalizedPath.includes('..')) {\n return `Path traversal detected: \"${filePath}\"`\n }\n\n // Reject absolute paths\n if (normalizedPath.startsWith('/')) {\n return `Absolute path not allowed: \"${filePath}\"`\n }\n\n // Reject forbidden directories\n const segments = normalizedPath.split('/')\n for (const seg of segments) {\n if (FORBIDDEN_PATH_SEGMENTS.has(seg)) {\n return `Patching files inside \"${seg}/\" is not allowed: \"${filePath}\"`\n }\n }\n\n // Reject non-scannable extensions\n const ext = extname(filePath).toLowerCase()\n if (!PATCHABLE_EXTENSIONS.has(ext)) {\n return `File extension \"${ext}\" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(', ')}. Path: \"${filePath}\"`\n }\n\n return null\n}\n\n// ─── Syntax Check (Guardrail #5) ───\n\n/**\n * Perform a basic syntax check on a patched file.\n * Returns an error message or null if syntax appears valid.\n */\nexport function checkSyntax(filePath: string, content: string): string | null {\n const ext = extname(filePath).toLowerCase()\n\n switch (ext) {\n case '.ts':\n case '.tsx':\n case '.js':\n case '.jsx':\n case '.mjs':\n return checkJsSyntax(content)\n case '.vue':\n return checkVueSyntax(content)\n case '.svelte':\n case '.astro':\n return checkTagBalance(content)\n default:\n return null\n }\n}\n\n/**\n * Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.\n * This is intentionally conservative — it catches obvious breakage without\n * requiring a full parser.\n */\nfunction checkJsSyntax(content: string): string | null {\n const stack: string[] = []\n const pairs: Record<string, string> = { ')': '(', ']': '[', '}': '{' }\n let inString: string | null = null\n let escaped = false\n let inLineComment = false\n let inBlockComment = false\n\n for (let i = 0; i < content.length; i++) {\n const ch = content[i]!\n const next = content[i + 1]\n\n // Handle escape sequences inside strings\n if (escaped) {\n escaped = false\n continue\n }\n\n if (ch === '\\\\' && inString !== null) {\n escaped = true\n continue\n }\n\n // Line comment\n if (!inString && !inBlockComment && ch === '/' && next === '/') {\n inLineComment = true\n continue\n }\n if (inLineComment) {\n if (ch === '\\n') inLineComment = false\n continue\n }\n\n // Block comment\n if (!inString && !inBlockComment && ch === '/' && next === '*') {\n inBlockComment = true\n i++ // skip *\n continue\n }\n if (inBlockComment) {\n if (ch === '*' && next === '/') {\n inBlockComment = false\n i++ // skip /\n }\n continue\n }\n\n // String handling\n if (inString !== null) {\n if (ch === inString) {\n // Template literal allows multi-line, others don't\n inString = null\n } else if (inString !== '`' && ch === '\\n') {\n return `Unterminated string literal near offset ${i}`\n }\n continue\n }\n\n if (ch === '\"' || ch === \"'\" || ch === '`') {\n inString = ch\n continue\n }\n\n // Bracket matching\n if (ch === '(' || ch === '[' || ch === '{') {\n stack.push(ch)\n } else if (ch === ')' || ch === ']' || ch === '}') {\n const expected = pairs[ch]!\n if (stack.length === 0) {\n return `Unmatched closing \"${ch}\" near offset ${i}`\n }\n const top = stack.pop()!\n if (top !== expected) {\n return `Mismatched bracket: expected closing for \"${top}\" but found \"${ch}\" near offset ${i}`\n }\n }\n }\n\n if (inString !== null) {\n return `Unterminated string literal (opened with ${inString})`\n }\n\n if (stack.length > 0) {\n return `Unclosed bracket \"${stack[stack.length - 1]}\" — ${stack.length} unclosed bracket(s)`\n }\n\n return null\n}\n\n/**\n * Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.\n */\nfunction checkVueSyntax(content: string): string | null {\n const tagBalance = checkTagBalance(content)\n if (tagBalance) return tagBalance\n\n // Vue-specific: check that SFC root tags are present and balanced\n for (const tag of ['template', 'script']) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n/**\n * Basic tag balance check for HTML-like files.\n * Checks that self-closing tags are handled and major structural tags are balanced.\n */\nfunction checkTagBalance(content: string): string | null {\n // Check for common structural tags balance\n const structuralTags = ['div', 'section', 'main', 'header', 'footer', 'nav', 'article', 'aside', 'ul', 'ol', 'table']\n\n for (const tag of structuralTags) {\n const openRe = new RegExp(`<${tag}[\\\\s>]`, 'g')\n const closeRe = new RegExp(`</${tag}>`, 'g')\n const opens = content.match(openRe)?.length ?? 0\n const closes = content.match(closeRe)?.length ?? 0\n if (opens !== closes) {\n return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`\n }\n }\n\n return null\n}\n\n// ─── Extract Mode ───\n\nexport async function applyExtract(\n projectRoot: string,\n input: ExtractionInput,\n): Promise<ExtractionResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { extractions, dry_run } = input\n\n // Analyze what will happen\n const existingModels = await listModels(projectRoot)\n const existingIds = new Set(existingModels.map(m => m.id))\n\n const modelsToCreate: string[] = []\n const modelsToUpdate: string[] = []\n const contentFiles: string[] = []\n let totalEntries = 0\n\n const validationErrors: string[] = []\n\n for (const ext of extractions) {\n // Validate model definition with same rules as model_save\n const modelErrors = validateModelDefinition({\n id: ext.model,\n kind: ext.kind,\n fields: ext.fields as Record<string, unknown> | undefined,\n })\n if (modelErrors.length > 0) {\n validationErrors.push(...modelErrors.map(e => `[${ext.model}] ${e}`))\n }\n\n for (const entry of ext.entries) {\n if (ext.kind === 'dictionary') {\n if (entry.data['id'] !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`)\n }\n for (const [key, val] of Object.entries(entry.data)) {\n if (typeof val !== 'string') {\n validationErrors.push(`[${ext.model}] Dictionary entry value for key \"${key}\" must be a string, got ${typeof val}`)\n }\n }\n } else if (ext.kind === 'document') {\n if (!entry.slug && !entry.data['slug']) {\n validationErrors.push(`[${ext.model}] Document entries must have a slug`)\n }\n } else if (ext.kind === 'collection') {\n if (entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Collection entries should not have slug`)\n }\n } else if (ext.kind === 'singleton') {\n if (entry.data['id'] !== undefined || entry.slug !== undefined || entry.data['slug'] !== undefined) {\n validationErrors.push(`[${ext.model}] Singleton entries should not have id or slug`)\n }\n }\n }\n\n if (existingIds.has(ext.model)) {\n modelsToUpdate.push(ext.model)\n } else {\n modelsToCreate.push(ext.model)\n }\n totalEntries += ext.entries.length\n\n // Guardrail #3: Preview-Execute Parity — use real model metadata if it exists\n let previewModel: ModelDefinition\n if (existingIds.has(ext.model)) {\n const real = await readModel(projectRoot, ext.model)\n if (real) {\n previewModel = real\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n } else {\n previewModel = { id: ext.model, kind: ext.kind, domain: ext.domain, i18n: ext.i18n ?? true } as ModelDefinition\n }\n\n const cDir = resolveContentDir(projectRoot, previewModel)\n for (const entry of ext.entries) {\n const locale = entry.locale ?? config.locales.default\n if (ext.kind === 'document' && entry.slug) {\n contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug))\n } else {\n contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale))\n }\n }\n }\n\n const preview: ExtractionPreview = {\n models_to_create: modelsToCreate,\n models_to_update: modelsToUpdate,\n total_entries: totalEntries,\n content_files: [...new Set(contentFiles)],\n }\n\n // Dry run — return preview only (include validation errors if any)\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview,\n ...(validationErrors.length > 0 ? { validation_errors: validationErrors } : {}),\n next_steps: [\n ...(validationErrors.length > 0\n ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`]\n : []),\n 'Review the preview above',\n 'Call contentrain_apply with mode:extract and dry_run:false to execute',\n ],\n }\n }\n\n // Block execute if validation errors exist\n if (validationErrors.length > 0) {\n return {\n dry_run: false,\n error: 'Model validation failed — cannot execute extract with invalid model definitions',\n validation_errors: validationErrors,\n next_steps: ['Fix the validation errors and retry'],\n }\n }\n\n // Branch health gate\n const health = await checkBranchHealth(projectRoot)\n if (health.blocked) {\n return {\n error: health.message,\n action: 'blocked' as const,\n hint: 'Merge or delete old contentrain/* branches before executing normalize.',\n } as unknown as ExtractionResult\n }\n\n // Execute — git transaction (always review mode for normalize)\n const branchName = buildBranchName('normalize', 'extract')\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n const sourceMap: Array<{ model: string; locale: string; value: string; file: string; line: number }> = []\n const modelsCreated: string[] = []\n const modelsUpdated: string[] = []\n let entriesWritten = 0\n\n try {\n await tx.write(async (wt) => {\n for (const ext of extractions) {\n // Create or merge model\n const existing = await readModel(wt, ext.model)\n if (existing) {\n // Merge fields: add new, keep existing\n if (ext.fields) {\n const merged = { ...existing.fields, ...ext.fields }\n // Only overwrite if new fields were actually added\n const newFieldNames = Object.keys(ext.fields).filter(k => !(k in (existing.fields ?? {})))\n if (newFieldNames.length > 0) {\n existing.fields = merged\n await writeModel(wt, existing)\n modelsUpdated.push(ext.model)\n }\n }\n } else {\n // Create new model\n const newModel: ModelDefinition = {\n id: ext.model,\n name: ext.model.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),\n kind: ext.kind,\n domain: ext.domain,\n i18n: ext.i18n ?? true,\n fields: ext.fields,\n }\n await writeModel(wt, newModel)\n modelsCreated.push(ext.model)\n }\n\n // Write content entries\n const model = (await readModel(wt, ext.model))!\n const entries: ContentEntry[] = ext.entries.map(e => ({\n locale: e.locale,\n slug: e.slug,\n data: e.data,\n }))\n\n const wtConfig = await readConfig(wt) ?? config\n await writeContent(wt, model, entries, wtConfig)\n entriesWritten += entries.length\n\n // Track source map\n for (const entry of ext.entries) {\n // For dictionary entries with per-key source tracking\n if (ext.kind === 'dictionary' && entry.sources) {\n for (const s of entry.sources) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: s.value,\n file: s.file,\n line: s.line,\n })\n }\n } else if (entry.source) {\n sourceMap.push({\n model: ext.model,\n locale: entry.locale ?? config.locales.default,\n value: entry.source.value,\n file: entry.source.file,\n line: entry.source.line,\n })\n }\n }\n }\n\n // Write source map for reuse scope enforcement\n if (sourceMap.length > 0) {\n const sourcesByModel: Record<string, { source_files: string[]; entry_count: number }> = {}\n for (const s of sourceMap) {\n if (!sourcesByModel[s.model]) {\n sourcesByModel[s.model] = { source_files: [], entry_count: 0 }\n }\n const modelEntry = sourcesByModel[s.model]!\n if (!modelEntry.source_files.includes(s.file)) {\n modelEntry.source_files.push(s.file)\n }\n modelEntry.entry_count++\n }\n const sourcesJson = JSON.stringify({\n version: 1,\n created_at: new Date().toISOString(),\n models: sourcesByModel,\n }, null, 2) + '\\n'\n // Write to worktree only — merge brings it to main\n // Phase 2 (reuse) must wait for extract branch to be merged first\n await writeText(join(wt, '.contentrain', 'normalize-sources.json'), sourcesJson)\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: extractions.map(e => e.model).join(','),\n locale: config.locales.default,\n entries: extractions.flatMap(e => e.entries.map(en => en.slug ?? 'entry')),\n })\n })\n\n const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n models_created: modelsCreated,\n models_updated: modelsUpdated,\n entries_written: entriesWritten,\n source_map: sourceMap,\n },\n git: gitResult,\n context_updated: true,\n next_steps: [\n 'Run contentrain_validate to check the extracted content',\n 'Run contentrain_submit to push the branch for review',\n 'For browser-based review: ensure `contentrain serve` is running, direct user to http://localhost:3333/normalize',\n 'For terminal workflow: use contentrain_merge to merge the branch locally',\n 'After merge, run `npx contentrain generate` to update SDK client',\n 'After review, proceed with mode:reuse to patch source files',\n ],\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Reuse Mode ───\n\nexport async function applyReuse(\n projectRoot: string,\n input: ReuseInput,\n): Promise<ReuseResult> {\n const config = await readConfig(projectRoot)\n if (!config) throw new Error('Project not initialized. Run contentrain_init first.')\n\n const { scope, patches, dry_run } = input\n\n // Validate scope\n if (!scope.model && !scope.domain) {\n throw new Error('Scope required: provide model or domain. Whole-project patching is not allowed.')\n }\n\n // Validate patch count\n if (patches.length > MAX_PATCHES) {\n throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`)\n }\n\n // Check content exists for scope (soft warning)\n if (scope.model) {\n const model = await readModel(projectRoot, scope.model)\n if (!model) {\n throw new Error(`Model \"${scope.model}\" not found. Run extract phase first.`)\n }\n }\n\n const scopeWarnings: string[] = []\n\n // Guardrail #1: Scope Real Enforcement\n // Step 1: Path safety — every patch must target a valid, patchable source file\n for (const patch of patches) {\n const pathError = validatePatchPath(patch.file)\n if (pathError) {\n throw new Error(`Invalid patch path: ${pathError}`)\n }\n }\n\n // Step 2: Semantic scope — verify scope model/domain exists and cross-check patch files\n if (scope.model || scope.domain) {\n const models = await listModels(projectRoot)\n const scopeModels = scope.model\n ? models.filter(m => m.id === scope.model)\n : scope.domain\n ? models.filter(m => m.domain === scope.domain)\n : models\n\n if (scopeModels.length === 0) {\n throw new Error(`No models found for scope ${scope.model ? `model=\"${scope.model}\"` : `domain=\"${scope.domain}\"`}`)\n }\n\n // Step 3: Verify patch files are source files (not content/config/meta files)\n // and belong to detectable source directories (not random locations)\n const { autoDetectSourceDirs } = await import('./scan-config.js')\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n\n // Build allowed file prefixes from source dirs\n const allowedPrefixes = sourceDirs.map(d => d === '.' ? '' : d + '/')\n\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n\n // Reject patches targeting .contentrain/ directory (content files should never be patched by reuse)\n if (normalizedPath.startsWith('.contentrain/') || normalizedPath.includes('/.contentrain/')) {\n throw new Error(`Cannot patch content/config files directly: \"${patch.file}\". Reuse patches source files only.`)\n }\n\n // If source dirs were detected (not just \".\"), verify patch files are within them\n if (sourceDirs.length > 0 && sourceDirs[0] !== '.') {\n const inSourceDir = allowedPrefixes.some(prefix =>\n prefix === '' || normalizedPath.startsWith(prefix),\n )\n if (!inSourceDir) {\n throw new Error(\n `Patch file \"${patch.file}\" is outside detected source directories (${sourceDirs.join(', ')}). ` +\n `Reuse patches must target source files within the project's source tree.`,\n )\n }\n }\n }\n\n // Step 4: Semantic source→model cross-check via normalize-sources.json\n // Source map is written by extract into the review branch worktree.\n // It only exists on base after extract branch is merged.\n // If missing: dry_run proceeds with warning, execute is blocked.\n if (scope.model || scope.domain) {\n const sourcesPath = join(projectRoot, '.contentrain', 'normalize-sources.json')\n const sourcesRaw = await readText(sourcesPath)\n\n if (!sourcesRaw) {\n // Source map not found — check if scoped model is a dictionary\n // Dictionaries store all keys in one entry, so per-file source tracking\n // is not available. Allow reuse with a warning instead of blocking.\n const scopedModel = scopeModels.find(m => m.id === scope.model)\n const isDictionary = scopedModel?.kind === 'dictionary'\n\n if (isDictionary) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Dictionary models do not generate per-file source maps. ' +\n 'Scope enforcement is based on source-tree locality only.',\n )\n } else if (dry_run !== false) {\n scopeWarnings.push(\n 'normalize-sources.json not found. Semantic scope enforcement is unavailable. ' +\n 'Merge the extract branch first, then reuse will have full scope protection.',\n )\n } else {\n throw new Error(\n 'Cannot execute reuse: normalize-sources.json not found on base branch. ' +\n 'The extract branch must be merged before reuse can execute. ' +\n 'This ensures semantic scope enforcement protects against out-of-scope patching.',\n )\n }\n } else {\n const sourcesData = JSON.parse(sourcesRaw) as {\n models?: Record<string, { source_files?: string[] }>\n }\n\n // Collect all allowed source files for the scope\n let allowedSourceFiles: string[] = []\n let scopeLabel = ''\n\n if (scope.model) {\n const modelSources = sourcesData.models?.[scope.model]?.source_files\n if (modelSources) allowedSourceFiles = modelSources\n scopeLabel = `model \"${scope.model}\"`\n } else if (scope.domain) {\n const domainModelIds = scopeModels.map(m => m.id)\n for (const modelId of domainModelIds) {\n const modelSources = sourcesData.models?.[modelId]?.source_files\n if (modelSources) allowedSourceFiles.push(...modelSources)\n }\n allowedSourceFiles = [...new Set(allowedSourceFiles)]\n scopeLabel = `domain \"${scope.domain}\"`\n }\n\n if (allowedSourceFiles.length > 0) {\n const outOfScopePatches: string[] = []\n for (const patch of patches) {\n const normalizedPath = patch.file.replace(/\\\\/g, '/')\n if (!allowedSourceFiles.includes(normalizedPath)) {\n outOfScopePatches.push(patch.file)\n }\n }\n if (outOfScopePatches.length > 0) {\n throw new Error(\n `Scope enforcement: ${outOfScopePatches.length} patch file(s) are not associated with ${scopeLabel}. ` +\n `Out-of-scope files: ${outOfScopePatches.join(', ')}. ` +\n `Known source files: ${allowedSourceFiles.join(', ')}.`,\n )\n }\n }\n }\n }\n }\n\n // Group patches by file\n const patchesByFile = new Map<string, PatchEntry[]>()\n for (const patch of patches) {\n if (!patchesByFile.has(patch.file)) {\n patchesByFile.set(patch.file, [])\n }\n patchesByFile.get(patch.file)!.push(patch)\n }\n\n const filesToModify = [...patchesByFile.keys()]\n const importsToAdd = patches.filter(p => p.import_statement).length\n\n // Dry run — return preview only\n if (dry_run !== false) {\n return {\n dry_run: true,\n preview: {\n files_to_modify: filesToModify,\n patches_count: patches.length,\n imports_to_add: importsToAdd,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n next_steps: [\n ...(scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : []),\n 'Review the files and patches above',\n 'Call contentrain_apply with mode:reuse and dry_run:false to execute',\n ],\n }\n }\n\n // Branch health gate\n const reuseHealth = await checkBranchHealth(projectRoot)\n if (reuseHealth.blocked) {\n return {\n dry_run: false,\n error: `Branch blocked: ${reuseHealth.message}`,\n next_steps: ['Merge or delete old contentrain/* branches before executing reuse.'],\n }\n }\n\n // Execute — git transaction\n const scopeTarget = scope.model ?? scope.domain!\n const branchName = buildBranchName('normalize/reuse', scopeTarget)\n const tx = await createTransaction(projectRoot, branchName, { workflowOverride: 'review' })\n\n const filesModified: string[] = []\n let patchesApplied = 0\n let importsAdded = 0\n const patchesSkipped: Array<{ file: string; line: number; reason: string }> = []\n const frameworkWarnings: Array<{ file: string; warning: string }> = []\n const syntaxErrors: SyntaxError[] = []\n\n try {\n await tx.write(async (wt) => {\n for (const [relFile, filePatches] of patchesByFile) {\n const absPath = join(wt, relFile)\n\n if (!(await pathExists(absPath))) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file not found' })\n }\n continue\n }\n\n const content = await readText(absPath)\n if (content === null) {\n for (const p of filePatches) {\n patchesSkipped.push({ file: relFile, line: p.line, reason: 'file unreadable' })\n }\n continue\n }\n\n // Sort patches by line DESC (bottom-up to avoid line shifts)\n const sorted = [...filePatches].toSorted((a, b) => b.line - a.line)\n\n const lines = content.split('\\n')\n let fileModified = false\n\n for (const patch of sorted) {\n // Determine replacement context before applying\n const targetLine = lines[patch.line - 1] ?? ''\n const isTagTextContext = targetLine.includes(`>${patch.old_value}<`)\n const replacementContext = isTagTextContext ? 'tag_text' as const : 'other' as const\n\n // Guardrail #2: Framework-aware validation — only warn for tag text replacements\n if (isTagTextContext) {\n const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext)\n if (fwWarning) {\n frameworkWarnings.push({ file: relFile, warning: fwWarning })\n }\n }\n\n const applied = applyPatchToLines(lines, patch)\n if (applied) {\n patchesApplied++\n fileModified = true\n } else {\n patchesSkipped.push({ file: relFile, line: patch.line, reason: 'old_value not found at or near specified line' })\n }\n }\n\n // Add imports (deduplicate)\n const importStatements = new Set(\n filePatches\n .filter(p => p.import_statement)\n .map(p => p.import_statement!),\n )\n\n if (importStatements.size > 0) {\n const added = addImportsToLines(lines, importStatements)\n importsAdded += added\n if (added > 0) fileModified = true\n }\n\n if (fileModified) {\n const newContent = lines.join('\\n')\n await writeText(absPath, newContent)\n filesModified.push(relFile)\n\n // Guardrail #5: Syntax check after patching\n const syntaxError = checkSyntax(relFile, newContent)\n if (syntaxError) {\n syntaxErrors.push({ file: relFile, error: syntaxError })\n }\n }\n }\n\n // Update context\n await writeContext(wt, {\n tool: 'contentrain_apply',\n model: scopeTarget,\n locale: config.locales.default,\n })\n })\n\n if (filesModified.length === 0) {\n await tx.cleanup()\n return {\n dry_run: false,\n results: {\n files_modified: [],\n patches_applied: 0,\n patches_skipped: patchesSkipped,\n imports_added: 0,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n },\n next_steps: ['No files were modified. Check patch definitions and try again.'],\n }\n }\n\n const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`\n await tx.commit(commitMsg)\n\n const gitResult = { branch: branchName, action: 'pending-review', commit: '' }\n try {\n const completed = await tx.complete()\n gitResult.action = completed.action\n gitResult.commit = completed.commit\n } catch {\n gitResult.action = 'pending-review'\n } finally {\n await tx.cleanup()\n }\n\n return {\n dry_run: false,\n results: {\n files_modified: filesModified,\n patches_applied: patchesApplied,\n patches_skipped: patchesSkipped,\n imports_added: importsAdded,\n framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : undefined,\n syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : undefined,\n },\n ...(scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {}),\n git: gitResult,\n next_steps: [\n 'Run contentrain_validate to verify the patched files',\n patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : '',\n syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : '',\n scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : '',\n 'Run contentrain_submit to push the branch for review',\n 'For review: direct user to http://localhost:3333/branches or use contentrain_merge',\n 'After all reuse phases complete, run `npx contentrain generate` to update SDK types',\n ].filter(Boolean),\n }\n } catch (error) {\n await tx.cleanup()\n throw error\n }\n}\n\n// ─── Patch Helpers ───\n\n/**\n * Apply a single patch to a lines array. Mutates lines in place.\n * Uses line hint for proximity matching — searches ±10 lines from hint.\n */\nfunction applyPatchToLines(lines: string[], patch: PatchEntry): boolean {\n const { line, old_value, new_expression } = patch\n const lineIdx = line - 1 // 0-based\n\n // Search range: ±10 lines from hint\n const searchStart = Math.max(0, lineIdx - 10)\n const searchEnd = Math.min(lines.length, lineIdx + 11)\n\n // First pass: exact line match\n if (lineIdx >= 0 && lineIdx < lines.length) {\n const replaced = replaceInLine(lines[lineIdx]!, old_value, new_expression)\n if (replaced !== null) {\n lines[lineIdx] = replaced\n return true\n }\n }\n\n // Second pass: proximity search\n for (let i = searchStart; i < searchEnd; i++) {\n if (i === lineIdx) continue // already tried\n const replaced = replaceInLine(lines[i]!, old_value, new_expression)\n if (replaced !== null) {\n lines[i] = replaced\n return true\n }\n }\n\n return false\n}\n\n/**\n * Replace old_value with new_expression in a single line.\n * Matches the string literal (quoted or unquoted tag text).\n * Returns the modified line, or null if not found.\n *\n * Guardrail #4: Safer patch matching — word boundary awareness and\n * ambiguity rejection for plain text fallback.\n */\nexport function replaceInLine(line: string, oldValue: string, newExpression: string): string | null {\n // Try exact match of the value in quoted strings\n // Match: \"old_value\", 'old_value', `old_value`\n for (const quote of ['\"', \"'\", '`']) {\n const quoted = `${quote}${oldValue}${quote}`\n if (line.includes(quoted)) {\n return line.replace(quoted, newExpression)\n }\n }\n\n // Try unquoted tag text match: >old_value<\n // The agent provides the complete new_expression with correct framework syntax:\n // JSX: {t('key')}\n // Vue: {{ $t('key') }}\n // Svelte: {$t('key')}\n // So we insert the expression as-is between > and <, without wrapping in braces.\n if (line.includes(`>${oldValue}<`)) {\n return line.replace(`>${oldValue}<`, `>${newExpression}<`)\n }\n\n // Guardrail #4: Safer plain text fallback\n // Only match if old_value appears at a word boundary and is unambiguous\n if (line.includes(oldValue)) {\n // Count occurrences — if multiple, it's ambiguous\n const occurrences = countOccurrences(line, oldValue)\n if (occurrences > 1) {\n return null // ambiguous — let proximity search handle it\n }\n\n // Word boundary check: don't replace \"Submit\" inside \"SubmitButton\"\n // Reject if the old_value is a substring of a larger word — check if\n // adjacent characters are word characters that extend the token.\n const idx = line.indexOf(oldValue)\n const charBefore = idx > 0 ? line[idx - 1]! : ''\n const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length]! : ''\n\n const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]!)\n const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]!)\n\n // If old_value starts with a word char and char before is also word char, it's a partial match\n if (oldStartsWithWord && isWordChar(charBefore)) {\n return null\n }\n // If old_value ends with a word char and char after is also word char, it's a partial match\n if (oldEndsWithWord && isWordChar(charAfter)) {\n return null\n }\n\n return line.replace(oldValue, newExpression)\n }\n\n return null\n}\n\n/** Count non-overlapping occurrences of a substring */\nfunction countOccurrences(str: string, sub: string): number {\n let count = 0\n let pos = 0\n while (pos <= str.length - sub.length) {\n const idx = str.indexOf(sub, pos)\n if (idx === -1) break\n count++\n pos = idx + sub.length\n }\n return count\n}\n\n/** Check if a character is a word character (letter, digit, underscore) */\nfunction isWordChar(ch: string): boolean {\n return /\\w/.test(ch)\n}\n\n/**\n * Add import statements to the top of a file (after existing imports).\n * Deduplicates — won't add if the import already exists.\n * Returns number of imports actually added.\n */\nfunction addImportsToLines(lines: string[], imports: Set<string>): number {\n let added = 0\n const existingContent = lines.join('\\n')\n\n // Find the last import line position (handles multi-line imports)\n let lastImportIdx = -1\n let inMultiLineImport = false\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (inMultiLineImport) {\n lastImportIdx = i\n if (trimmed.includes('}')) inMultiLineImport = false\n continue\n }\n if (trimmed.startsWith('import ') || trimmed.startsWith('import{')) {\n lastImportIdx = i\n // Check if this is a multi-line import (has { but no closing })\n if (trimmed.includes('{') && !trimmed.includes('}')) {\n inMultiLineImport = true\n }\n continue\n }\n // Stop searching after a non-import, non-empty, non-comment line following imports\n if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*')) {\n break\n }\n }\n\n let insertAt: number\n if (lastImportIdx >= 0) {\n insertAt = lastImportIdx + 1\n } else {\n // No existing imports found — insert after shebang and directive lines\n insertAt = 0\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i]!.trim()\n if (i === 0 && trimmed.startsWith('#!')) {\n insertAt = i + 1\n continue\n }\n if (trimmed === \"'use client'\" || trimmed === '\"use client\"'\n || trimmed === \"'use server'\" || trimmed === '\"use server\"'\n || trimmed === \"'use client';\" || trimmed === '\"use client\";'\n || trimmed === \"'use server';\" || trimmed === '\"use server\";') {\n insertAt = i + 1\n continue\n }\n if (insertAt > 0 && trimmed.length > 0) break\n if (insertAt === 0 && trimmed.length > 0) break\n }\n }\n const toInsert: string[] = []\n\n for (const imp of imports) {\n // Check if this import already exists (by checking the from clause)\n if (!existingContent.includes(imp)) {\n toInsert.push(imp)\n added++\n }\n }\n\n if (toInsert.length > 0) {\n lines.splice(insertAt, 0, ...toInsert)\n }\n\n return added\n}\n"],"mappings":"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"}
import { n as canonicalStringify } from "./serialization-B1CEzR4H.mjs";
import { a as readJson, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { o as listModels, r as countEntries, s as readModel } from "./model-manager-SiesTJrS.mjs";
import { join } from "node:path";
//#region src/core/context.ts
const CONTEXT_PATH = ".contentrain/context.json";
async function readContext(input) {
if (typeof input === "string") return readJson(join(contentrainDir(input), "context.json"));
try {
const raw = await input.readFile(CONTEXT_PATH);
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveSource(explicit) {
if (explicit) return explicit;
return process.env["CONTENTRAIN_SOURCE"] === "mcp-studio" ? "mcp-studio" : "mcp-local";
}
async function computeEntriesCount(projectRoot) {
try {
const models = await listModels(projectRoot);
const fullModels = await Promise.all(models.map((m) => readModel(projectRoot, m.id)));
return (await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(projectRoot, m)))).reduce((acc, c) => acc + c.total, 0);
} catch {
return null;
}
}
async function writeContext(projectRoot, operation) {
const models = await listModels(projectRoot);
const config = await readConfig(projectRoot);
const locales = config?.locales.supported ?? ["en"];
const totalEntries = await computeEntriesCount(projectRoot);
const source = resolveSource();
const context = {
version: "1",
lastOperation: {
tool: operation.tool,
model: operation.model,
locale: operation.locale ?? config?.locales.default ?? "en",
entries: operation.entries,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
source
},
stats: {
models: models.length,
entries: totalEntries,
locales,
lastSync: (/* @__PURE__ */ new Date()).toISOString()
}
};
await writeJson(join(contentrainDir(projectRoot), "context.json"), context);
}
/**
* Build a FileChange for `.contentrain/context.json` that remote providers
* (GitHubProvider over HTTP, for example) can slot into their plan. The
* local write path still uses {@link writeContext} directly because its
* transaction layer writes into the post-apply worktree with real stats.
*
* Remote providers (Phase 5.5+) now also get accurate entry counts: the
* reader-based {@link countEntries} walks each model over the provider's
* read surface. GitHubProvider pays an extra round trip per model; the
* payoff is a context.json that matches what the local write path emits,
* so cross-provider merges stay deterministic.
*
* A caller that already knows the model/entry counts (e.g. Studio deriving
* them from its own index) can pass `opts.stats` to skip that O(models·
* locales) scan entirely — only `readConfig` remains, for the locale list
* and the `lastOperation.locale` default. The emitted context.json is
* byte-identical to the scanned variant for the same logical state.
*/
async function buildContextChange(reader, operation, source, opts) {
const config = await readConfig(reader);
const locales = config?.locales.supported ?? ["en"];
let modelCount;
let totalEntries;
if (opts?.stats) {
modelCount = opts.stats.models;
totalEntries = opts.stats.entries;
} else {
const models = await listModels(reader);
modelCount = models.length;
try {
const fullModels = await Promise.all(models.map((m) => readModel(reader, m.id)));
totalEntries = (await Promise.all(fullModels.filter((m) => m !== null).map((m) => countEntries(reader, m)))).reduce((acc, c) => acc + c.total, 0);
} catch {
totalEntries = null;
}
}
return {
path: CONTEXT_PATH,
content: canonicalStringify({
version: "1",
lastOperation: {
tool: operation.tool,
model: operation.model,
locale: operation.locale ?? config?.locales.default ?? "en",
entries: operation.entries,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
source: resolveSource(source)
},
stats: {
models: modelCount,
entries: totalEntries,
locales,
lastSync: (/* @__PURE__ */ new Date()).toISOString()
}
})
};
}
//#endregion
export { readContext as n, writeContext as r, buildContextChange as t };
//# sourceMappingURL=context-CrS-IvVm.mjs.map
{"version":3,"file":"context-CrS-IvVm.mjs","names":[],"sources":["../src/core/context.ts"],"sourcesContent":["import type { ContextJson, ContextSource } from '@contentrain/types'\nimport { join } from 'node:path'\nimport { contentrainDir, readJson, writeJson } from '../util/fs.js'\nimport type { FileChange, RepoReader } from './contracts/index.js'\nimport { canonicalStringify } from './serialization/index.js'\nimport { listModels, readModel, countEntries } from './model-manager.js'\nimport { readConfig } from './config.js'\n\nconst CONTEXT_PATH = '.contentrain/context.json'\n\n/**\n * Pre-computed stats a caller can pass to {@link buildContextChange} to skip\n * the model/entry scan. `entries` is nullable — a `null` is dropped from the\n * emitted context.json by {@link canonicalStringify}, exactly as a failed\n * scan is today.\n */\nexport interface ContextStats {\n models: number\n entries: number | null\n}\n\n/**\n * Read the committed `.contentrain/context.json` payload written by the\n * most recent content or model operation. Returns `null` when the file\n * does not exist (fresh project, or reader lookup failure).\n *\n * Dual signature — the local flow passes a `projectRoot` string, remote\n * flows (GitHubProvider, GitLabProvider, any custom `RepoReader`) pass\n * the reader directly so `contentrain_status` over HTTP can still\n * report the last operation + stats.\n */\nexport function readContext(projectRoot: string): Promise<ContextJson | null>\nexport function readContext(reader: RepoReader): Promise<ContextJson | null>\nexport async function readContext(input: string | RepoReader): Promise<ContextJson | null> {\n if (typeof input === 'string') {\n return readJson<ContextJson>(join(contentrainDir(input), 'context.json'))\n }\n try {\n const raw = await input.readFile(CONTEXT_PATH)\n return JSON.parse(raw) as ContextJson\n } catch {\n return null\n }\n}\n\nfunction resolveSource(explicit?: ContextSource): ContextSource {\n if (explicit) return explicit\n return process.env['CONTENTRAIN_SOURCE'] === 'mcp-studio' ? 'mcp-studio' : 'mcp-local'\n}\n\nasync function computeEntriesCount(projectRoot: string): Promise<number | null> {\n try {\n const models = await listModels(projectRoot)\n const fullModels = await Promise.all(models.map(m => readModel(projectRoot, m.id)))\n const counts = await Promise.all(\n fullModels\n .filter((m): m is NonNullable<typeof m> => m !== null)\n .map(m => countEntries(projectRoot, m)),\n )\n return counts.reduce((acc, c) => acc + c.total, 0)\n } catch {\n return null\n }\n}\n\nexport async function writeContext(\n projectRoot: string,\n operation: { tool: string, model: string, locale?: string, entries?: string[] },\n): Promise<void> {\n const models = await listModels(projectRoot)\n const config = await readConfig(projectRoot)\n const locales = config?.locales.supported ?? ['en']\n const totalEntries = await computeEntriesCount(projectRoot)\n const source = resolveSource()\n\n const context: ContextJson = {\n version: '1',\n lastOperation: {\n tool: operation.tool,\n model: operation.model,\n locale: operation.locale ?? config?.locales.default ?? 'en',\n entries: operation.entries,\n timestamp: new Date().toISOString(),\n source,\n },\n stats: {\n models: models.length,\n entries: totalEntries as number,\n locales,\n lastSync: new Date().toISOString(),\n },\n }\n\n await writeJson(join(contentrainDir(projectRoot), 'context.json'), context)\n}\n\n/**\n * Build a FileChange for `.contentrain/context.json` that remote providers\n * (GitHubProvider over HTTP, for example) can slot into their plan. The\n * local write path still uses {@link writeContext} directly because its\n * transaction layer writes into the post-apply worktree with real stats.\n *\n * Remote providers (Phase 5.5+) now also get accurate entry counts: the\n * reader-based {@link countEntries} walks each model over the provider's\n * read surface. GitHubProvider pays an extra round trip per model; the\n * payoff is a context.json that matches what the local write path emits,\n * so cross-provider merges stay deterministic.\n *\n * A caller that already knows the model/entry counts (e.g. Studio deriving\n * them from its own index) can pass `opts.stats` to skip that O(models·\n * locales) scan entirely — only `readConfig` remains, for the locale list\n * and the `lastOperation.locale` default. The emitted context.json is\n * byte-identical to the scanned variant for the same logical state.\n */\nexport async function buildContextChange(\n reader: RepoReader,\n operation: { tool: string, model: string, locale?: string, entries?: string[] },\n source?: ContextSource,\n opts?: { stats?: ContextStats },\n): Promise<FileChange> {\n const config = await readConfig(reader)\n const locales = config?.locales.supported ?? ['en']\n\n let modelCount: number\n let totalEntries: number | null\n if (opts?.stats) {\n modelCount = opts.stats.models\n totalEntries = opts.stats.entries\n } else {\n const models = await listModels(reader)\n modelCount = models.length\n try {\n const fullModels = await Promise.all(models.map(m => readModel(reader, m.id)))\n const counts = await Promise.all(\n fullModels\n .filter((m): m is NonNullable<typeof m> => m !== null)\n .map(m => countEntries(reader, m)),\n )\n totalEntries = counts.reduce((acc, c) => acc + c.total, 0)\n } catch {\n totalEntries = null\n }\n }\n\n const context: ContextJson = {\n version: '1',\n lastOperation: {\n tool: operation.tool,\n model: operation.model,\n locale: operation.locale ?? config?.locales.default ?? 'en',\n entries: operation.entries,\n timestamp: new Date().toISOString(),\n source: resolveSource(source),\n },\n stats: {\n models: modelCount,\n entries: totalEntries as number,\n locales,\n lastSync: new Date().toISOString(),\n },\n }\n\n return {\n path: CONTEXT_PATH,\n content: canonicalStringify(context),\n }\n}\n"],"mappings":";;;;;;AAQA,MAAM,eAAe;AAyBrB,eAAsB,YAAY,OAAyD;AACzF,KAAI,OAAO,UAAU,SACnB,QAAO,SAAsB,KAAK,eAAe,MAAM,EAAE,eAAe,CAAC;AAE3E,KAAI;EACF,MAAM,MAAM,MAAM,MAAM,SAAS,aAAa;AAC9C,SAAO,KAAK,MAAM,IAAI;SAChB;AACN,SAAO;;;AAIX,SAAS,cAAc,UAAyC;AAC9D,KAAI,SAAU,QAAO;AACrB,QAAO,QAAQ,IAAI,0BAA0B,eAAe,eAAe;;AAG7E,eAAe,oBAAoB,aAA6C;AAC9E,KAAI;EACF,MAAM,SAAS,MAAM,WAAW,YAAY;EAC5C,MAAM,aAAa,MAAM,QAAQ,IAAI,OAAO,KAAI,MAAK,UAAU,aAAa,EAAE,GAAG,CAAC,CAAC;AAMnF,UALe,MAAM,QAAQ,IAC3B,WACG,QAAQ,MAAkC,MAAM,KAAK,CACrD,KAAI,MAAK,aAAa,aAAa,EAAE,CAAC,CAC1C,EACa,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,EAAE;SAC5C;AACN,SAAO;;;AAIX,eAAsB,aACpB,aACA,WACe;CACf,MAAM,SAAS,MAAM,WAAW,YAAY;CAC5C,MAAM,SAAS,MAAM,WAAW,YAAY;CAC5C,MAAM,UAAU,QAAQ,QAAQ,aAAa,CAAC,KAAK;CACnD,MAAM,eAAe,MAAM,oBAAoB,YAAY;CAC3D,MAAM,SAAS,eAAe;CAE9B,MAAM,UAAuB;EAC3B,SAAS;EACT,eAAe;GACb,MAAM,UAAU;GAChB,OAAO,UAAU;GACjB,QAAQ,UAAU,UAAU,QAAQ,QAAQ,WAAW;GACvD,SAAS,UAAU;GACnB,4BAAW,IAAI,MAAM,EAAC,aAAa;GACnC;GACD;EACD,OAAO;GACL,QAAQ,OAAO;GACf,SAAS;GACT;GACA,2BAAU,IAAI,MAAM,EAAC,aAAa;GACnC;EACF;AAED,OAAM,UAAU,KAAK,eAAe,YAAY,EAAE,eAAe,EAAE,QAAQ;;;;;;;;;;;;;;;;;;;;AAqB7E,eAAsB,mBACpB,QACA,WACA,QACA,MACqB;CACrB,MAAM,SAAS,MAAM,WAAW,OAAO;CACvC,MAAM,UAAU,QAAQ,QAAQ,aAAa,CAAC,KAAK;CAEnD,IAAI;CACJ,IAAI;AACJ,KAAI,MAAM,OAAO;AACf,eAAa,KAAK,MAAM;AACxB,iBAAe,KAAK,MAAM;QACrB;EACL,MAAM,SAAS,MAAM,WAAW,OAAO;AACvC,eAAa,OAAO;AACpB,MAAI;GACF,MAAM,aAAa,MAAM,QAAQ,IAAI,OAAO,KAAI,MAAK,UAAU,QAAQ,EAAE,GAAG,CAAC,CAAC;AAM9E,mBALe,MAAM,QAAQ,IAC3B,WACG,QAAQ,MAAkC,MAAM,KAAK,CACrD,KAAI,MAAK,aAAa,QAAQ,EAAE,CAAC,CACrC,EACqB,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,EAAE;UACpD;AACN,kBAAe;;;AAsBnB,QAAO;EACL,MAAM;EACN,SAAS,mBApBkB;GAC3B,SAAS;GACT,eAAe;IACb,MAAM,UAAU;IAChB,OAAO,UAAU;IACjB,QAAQ,UAAU,UAAU,QAAQ,QAAQ,WAAW;IACvD,SAAS,UAAU;IACnB,4BAAW,IAAI,MAAM,EAAC,aAAa;IACnC,QAAQ,cAAc,OAAO;IAC9B;GACD,OAAO;IACL,QAAQ;IACR,SAAS;IACT;IACA,2BAAU,IAAI,MAAM,EAAC,aAAa;IACnC;GACF,CAIqC;EACrC"}
import { a as readJson, i as readDir, o as readText, r as pathExists, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { g as resolveLocaleStrategy, h as resolveJsonFilePath, m as resolveContentDir, o as listModels, s as readModel } from "./model-manager-SiesTJrS.mjs";
import { n as checkBranchHealth, s as listRemoteCrBranches } from "./branch-lifecycle-BAfgSQBv.mjs";
import { i as autoDetectSourceDirs, o as discoverFiles } from "./scan-config-BGUflS8t.mjs";
import { join } from "node:path";
import { readdir, stat } from "node:fs/promises";
import { simpleGit } from "simple-git";
//#region src/core/doctor.ts
async function runDoctor(projectRoot, options = {}) {
const checks = [];
try {
const version = await simpleGit(projectRoot).version();
checks.push({
name: "Git",
pass: true,
detail: `v${version.major}.${version.minor}.${version.patch}`
});
} catch {
checks.push({
name: "Git",
pass: false,
detail: "Not installed or not in PATH",
severity: "error"
});
}
const hasGit = await pathExists(join(projectRoot, ".git"));
checks.push({
name: "Git repository",
pass: hasGit,
detail: hasGit ? projectRoot : "No .git directory found",
severity: hasGit ? void 0 : "error"
});
const nodeVersion = process.versions.node;
const [major] = nodeVersion.split(".").map(Number);
const nodePass = (major ?? 0) >= 22;
checks.push({
name: "Node.js",
pass: nodePass,
detail: `v${nodeVersion}${nodePass ? "" : " (requires ≥22)"}`,
severity: nodePass ? void 0 : "error"
});
const crDir = contentrainDir(projectRoot);
const hasCrDir = await pathExists(crDir);
const hasConfig = await pathExists(join(crDir, "config.json"));
const hasModels = await pathExists(join(crDir, "models"));
const hasContent = await pathExists(join(crDir, "content"));
const structurePass = hasCrDir && hasConfig && hasModels && hasContent;
checks.push({
name: ".contentrain/ structure",
pass: structurePass,
detail: !hasCrDir ? "Not initialized — run `contentrain init`" : [
hasConfig ? null : "missing config.json",
hasModels ? null : "missing models/",
hasContent ? null : "missing content/"
].filter(Boolean).join(", ") || "OK",
severity: structurePass ? void 0 : "error"
});
let config = null;
if (hasConfig) {
config = await readConfig(projectRoot);
checks.push({
name: "Config",
pass: config !== null,
detail: config ? `stack: ${config.stack}, locales: ${config.locales.supported.join(", ")}` : "Failed to parse config.json",
severity: config ? void 0 : "error"
});
}
if (hasCrDir) try {
const models = await listModels(projectRoot);
const allParseable = (await Promise.all(models.map((m) => readModel(projectRoot, m.id)))).every((r) => r !== null);
checks.push({
name: "Models",
pass: allParseable,
detail: `${models.length} model(s)${allParseable ? ", all valid" : ", some failed to parse"}`,
severity: allParseable ? void 0 : "error"
});
} catch {
checks.push({
name: "Models",
pass: false,
detail: "Failed to read models",
severity: "error"
});
}
if (hasCrDir) {
const orphans = await findOrphanContent(projectRoot);
checks.push({
name: "Orphan content",
pass: orphans.length === 0,
detail: orphans.length === 0 ? "None" : `Found: ${orphans.join(", ")}`,
severity: orphans.length === 0 ? void 0 : "warning"
});
}
if (hasGit) {
try {
const health = await checkBranchHealth(projectRoot);
checks.push({
name: "Pending branches",
pass: !health.blocked && !health.warning,
detail: health.message ?? (health.unmerged === 0 ? "None" : `${health.unmerged} active cr/* branch(es)`),
severity: health.blocked ? "error" : health.warning ? "warning" : void 0
});
} catch {
checks.push({
name: "Pending branches",
pass: true,
detail: "Could not check"
});
}
const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5e3 });
if (remoteList) if (remoteList.error) checks.push({
name: "Remote branches",
pass: true,
detail: `Could not check ${remoteList.remote} (offline?)`,
severity: "info"
});
else {
const count = remoteList.branches.length;
const warn = count >= (config?.branchWarnLimit ?? 50);
checks.push({
name: "Remote branches",
pass: !warn,
detail: count === 0 ? `None on ${remoteList.remote}` : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? " — run `contentrain prune` to remove merged leftovers" : ""}`,
severity: warn ? "warning" : void 0
});
}
}
const clientDir = join(crDir, "client");
const modelsDir = join(crDir, "models");
if (await pathExists(clientDir) && await pathExists(modelsDir)) try {
const [clientMtime, modelsMtime] = await Promise.all([newestFileMtime(clientDir), newestFileMtime(modelsDir)]);
if (clientMtime === null || modelsMtime === null) checks.push({
name: "SDK client",
pass: true,
detail: "Could not check"
});
else {
const fresh = clientMtime >= modelsMtime;
checks.push({
name: "SDK client",
pass: fresh,
detail: fresh ? "Up to date" : "Stale — run `contentrain generate`",
severity: fresh ? void 0 : "warning"
});
}
} catch {
checks.push({
name: "SDK client",
pass: true,
detail: "Could not check"
});
}
let usage;
if (options.usage && hasCrDir && config) {
const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([
analyzeUnusedKeys(projectRoot, config),
analyzeDuplicateValues(projectRoot, config),
analyzeMissingLocaleKeys(projectRoot, config)
]);
usage = {
unusedKeys,
duplicateValues,
missingLocaleKeys
};
checks.push({
name: "Unused content keys",
pass: unusedKeys.length === 0,
detail: unusedKeys.length === 0 ? "All keys referenced in source" : `${unusedKeys.length} key(s) not referenced in source code`,
severity: unusedKeys.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Duplicate dictionary values",
pass: duplicateValues.length === 0,
detail: duplicateValues.length === 0 ? "No duplicate values" : `${duplicateValues.length} value(s) mapped to multiple keys`,
severity: duplicateValues.length === 0 ? void 0 : "warning"
});
checks.push({
name: "Locale key coverage",
pass: missingLocaleKeys.length === 0,
detail: missingLocaleKeys.length === 0 ? "All locales have matching keys" : `${missingLocaleKeys.length} key(s) missing in some locales`,
severity: missingLocaleKeys.length === 0 ? void 0 : "warning"
});
}
const passed = checks.filter((c) => c.pass).length;
const failed = checks.length - passed;
const warnings = checks.filter((c) => !c.pass && c.severity === "warning").length;
const report = {
checks,
summary: {
total: checks.length,
passed,
failed,
warnings
}
};
if (usage) report.usage = usage;
return report;
}
/**
* Newest mtime among the files under `dir`, recursively — null if it holds none.
*
* Stat the files, never the directory. A directory's mtime only moves when an
* entry is added, removed, or renamed inside it: `generate` rewrites the client
* files in place, so `.contentrain/client` never moves after the first run,
* while a selective sync recreates model files via `git checkout`, which does
* move `.contentrain/models`. Comparing the two directories therefore reported
* "stale" permanently after any model save.
*/
async function newestFileMtime(dir) {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
const known = (await Promise.all(entries.map(async (entry) => {
const full = join(dir, entry.name);
if (entry.isDirectory()) return newestFileMtime(full);
return stat(full).then((s) => s.mtimeMs, () => null);
}))).filter((m) => m !== null);
return known.length > 0 ? Math.max(...known) : null;
}
async function findOrphanContent(projectRoot) {
const crDir = contentrainDir(projectRoot);
const models = await listModels(projectRoot);
const orphans = [];
const knownContentDirs = /* @__PURE__ */ new Set();
for (const m of models) {
const full = await readModel(projectRoot, m.id);
const modelForPath = full ? {
...full,
content_path: full.content_path ?? m.content_path
} : {
id: m.id,
name: m.id,
kind: m.kind,
domain: m.domain,
i18n: m.i18n,
fields: {},
content_path: m.content_path
};
knownContentDirs.add(resolveContentDir(projectRoot, modelForPath));
}
const contentDir = join(crDir, "content");
if (await pathExists(contentDir)) {
const domains = await readDir(contentDir);
for (const domain of domains) {
const domainDir = join(contentDir, domain);
const entries = await readDir(domainDir);
for (const entry of entries) {
if (entry === ".gitkeep") continue;
const entryDir = join(domainDir, entry);
if (!knownContentDirs.has(entryDir)) orphans.push(`${domain}/${entry}`);
}
}
}
for (const dir of knownContentDirs) {
if (dir.startsWith(contentDir)) continue;
if (!await pathExists(dir)) {
orphans.push(`(missing custom path) ${dir}`);
continue;
}
await readDir(dir);
}
return orphans;
}
async function analyzeUnusedKeys(projectRoot, config) {
const files = await discoverFiles(projectRoot, { paths: await autoDetectSourceDirs(projectRoot) });
if (files.length === 0) return [];
const allSource = (await Promise.all(files.map(async (relPath) => {
return await readText(join(projectRoot, relPath)) ?? "";
}))).join("\n");
const models = await listModels(projectRoot);
const defaultLocale = config.locales.default;
const unused = [];
for (const m of models) {
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale);
for (const key of keys) if (!allSource.includes(key)) unused.push({
model: m.id,
kind: m.kind,
key,
locale: defaultLocale
});
}
return unused;
}
async function extractContentKeys(projectRoot, model, locale) {
const cDir = resolveContentDir(projectRoot, model);
if (!await pathExists(cDir)) return [];
switch (model.kind) {
case "dictionary": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "collection": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale));
return data ? Object.keys(data) : [];
}
case "document": {
const strategy = resolveLocaleStrategy(model);
const slugs = [];
if (!model.i18n) {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
} else if (strategy === "file") {
const dirs = await readDir(cDir);
for (const d of dirs) if (!d.startsWith(".")) slugs.push(d);
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length));
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
if (await pathExists(localeDir)) {
const files = await readDir(localeDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
} else {
const files = await readDir(cDir);
for (const f of files) if (f.endsWith(".md")) slugs.push(f.replace(".md", ""));
}
return slugs;
}
case "singleton": return [];
default: return [];
}
}
async function analyzeDuplicateValues(projectRoot, config) {
const models = await listModels(projectRoot);
const result = [];
for (const m of models) {
if (m.kind !== "dictionary") continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
for (const locale of config.locales.supported) {
const data = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
if (!data) continue;
const valueToKeys = /* @__PURE__ */ new Map();
for (const [key, value] of Object.entries(data)) {
const arr = valueToKeys.get(value);
if (arr) arr.push(key);
else valueToKeys.set(value, [key]);
}
for (const [value, keys] of valueToKeys) if (keys.length > 1) result.push({
model: m.id,
locale,
value,
keys
});
}
}
return result;
}
async function analyzeMissingLocaleKeys(projectRoot, config) {
if (config.locales.supported.length < 2) return [];
const models = await listModels(projectRoot);
const result = [];
const defaultLocale = config.locales.default;
const otherLocales = config.locales.supported.filter((l) => l !== defaultLocale);
for (const m of models) {
if (m.kind !== "dictionary" && m.kind !== "collection") continue;
if (!m.i18n) continue;
const fullModel = await readModel(projectRoot, m.id);
if (!fullModel) continue;
const cDir = resolveContentDir(projectRoot, fullModel);
const defaultData = await readJson(resolveJsonFilePath(cDir, fullModel, defaultLocale));
if (!defaultData) continue;
const defaultKeys = new Set(Object.keys(defaultData));
for (const locale of otherLocales) {
const localeData = await readJson(resolveJsonFilePath(cDir, fullModel, locale));
const localeKeys = localeData ? new Set(Object.keys(localeData)) : /* @__PURE__ */ new Set();
for (const key of defaultKeys) if (!localeKeys.has(key)) result.push({
model: m.id,
key,
missingIn: locale
});
}
}
return result;
}
//#endregion
export { runDoctor as t };
//# sourceMappingURL=doctor-BRPXxKhx.mjs.map
{"version":3,"file":"doctor-BRPXxKhx.mjs","names":[],"sources":["../src/core/doctor.ts"],"sourcesContent":["import { join } from 'node:path'\nimport { readdir, stat } from 'node:fs/promises'\nimport type { Dirent } from 'node:fs'\nimport { simpleGit } from 'simple-git'\nimport type { ContentrainConfig, ModelDefinition } from '@contentrain/types'\nimport { readConfig } from './config.js'\nimport { listModels, readModel } from './model-manager.js'\nimport { resolveContentDir, resolveJsonFilePath, resolveLocaleStrategy } from './content-manager.js'\nimport { autoDetectSourceDirs, discoverFiles } from './scan-config.js'\nimport { checkBranchHealth, listRemoteCrBranches } from '../git/branch-lifecycle.js'\nimport { contentrainDir, pathExists, readDir, readJson, readText } from '../util/fs.js'\n\n/**\n * Doctor — project health report.\n *\n * The public entry point is `runDoctor(projectRoot, { usage? })`. It is\n * inherently local-filesystem work (Node version, git install, file\n * mtimes, orphan directory detection), so the MCP tool surface gates\n * it behind the `localWorktree` capability — same pattern as\n * `contentrain_setup` and normalize.\n *\n * The report is structured JSON so three consumers can share it:\n *\n * - The `contentrain doctor` CLI command pretty-prints the checks.\n * - The Serve UI `/api/doctor` route returns the report to the\n * Dashboard's Doctor panel.\n * - Automation (CI, Studio) gets a deterministic JSON shape it can\n * assert against.\n *\n * Usage analysis (`--usage`) is a heavier, opt-in branch — it scans\n * every source file in the repo for content-key references. Kept\n * behind the flag so the default doctor run stays fast.\n */\n\nexport type CheckSeverity = 'error' | 'warning' | 'info'\n\nexport interface DoctorCheck {\n name: string\n pass: boolean\n detail: string\n /**\n * `error` — default for failing checks. Blocks a clean bill of health.\n * `warning` — failing-but-not-blocking (e.g. pending branches above\n * threshold, stale SDK client).\n * `info` — passed check; pure informational.\n */\n severity?: CheckSeverity\n}\n\nexport interface UnusedKeyEntry {\n model: string\n kind: string\n key: string\n locale: string\n}\n\nexport interface DuplicateValueEntry {\n model: string\n locale: string\n value: string\n keys: string[]\n}\n\nexport interface MissingLocaleEntry {\n model: string\n key: string\n missingIn: string\n}\n\nexport interface DoctorUsageAnalysis {\n unusedKeys: UnusedKeyEntry[]\n duplicateValues: DuplicateValueEntry[]\n missingLocaleKeys: MissingLocaleEntry[]\n}\n\nexport interface DoctorReport {\n checks: DoctorCheck[]\n summary: {\n total: number\n passed: number\n failed: number\n warnings: number\n }\n /** Present only when `options.usage === true`. */\n usage?: DoctorUsageAnalysis\n}\n\nexport interface RunDoctorOptions {\n /** Run heavier `--usage` analysis (unused keys, duplicates, locale gaps). */\n usage?: boolean\n}\n\nexport async function runDoctor(\n projectRoot: string,\n options: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n const checks: DoctorCheck[] = []\n\n // ─── 1. Git installed ───\n try {\n const git = simpleGit(projectRoot)\n const version = await git.version()\n checks.push({\n name: 'Git',\n pass: true,\n detail: `v${version.major}.${version.minor}.${version.patch}`,\n })\n } catch {\n checks.push({ name: 'Git', pass: false, detail: 'Not installed or not in PATH', severity: 'error' })\n }\n\n // ─── 2. Git repo initialized ───\n const hasGit = await pathExists(join(projectRoot, '.git'))\n checks.push({\n name: 'Git repository',\n pass: hasGit,\n detail: hasGit ? projectRoot : 'No .git directory found',\n severity: hasGit ? undefined : 'error',\n })\n\n // ─── 3. Node version ───\n const nodeVersion = process.versions.node\n const [major] = nodeVersion.split('.').map(Number)\n const nodePass = (major ?? 0) >= 22\n checks.push({\n name: 'Node.js',\n pass: nodePass,\n detail: `v${nodeVersion}${nodePass ? '' : ' (requires ≥22)'}`,\n severity: nodePass ? undefined : 'error',\n })\n\n // ─── 4. .contentrain/ structure ───\n const crDir = contentrainDir(projectRoot)\n const hasCrDir = await pathExists(crDir)\n const hasConfig = await pathExists(join(crDir, 'config.json'))\n const hasModels = await pathExists(join(crDir, 'models'))\n const hasContent = await pathExists(join(crDir, 'content'))\n const structurePass = hasCrDir && hasConfig && hasModels && hasContent\n\n checks.push({\n name: '.contentrain/ structure',\n pass: structurePass,\n detail: !hasCrDir\n ? 'Not initialized — run `contentrain init`'\n : [\n hasConfig ? null : 'missing config.json',\n hasModels ? null : 'missing models/',\n hasContent ? null : 'missing content/',\n ].filter(Boolean).join(', ') || 'OK',\n severity: structurePass ? undefined : 'error',\n })\n\n // ─── 5. Config parseable ───\n let config: ContentrainConfig | null = null\n if (hasConfig) {\n config = await readConfig(projectRoot)\n checks.push({\n name: 'Config',\n pass: config !== null,\n detail: config\n ? `stack: ${config.stack}, locales: ${config.locales.supported.join(', ')}`\n : 'Failed to parse config.json',\n severity: config ? undefined : 'error',\n })\n }\n\n // ─── 6. Models all parseable ───\n if (hasCrDir) {\n try {\n const models = await listModels(projectRoot)\n const parseResults = await Promise.all(models.map(m => readModel(projectRoot, m.id)))\n const allParseable = parseResults.every(r => r !== null)\n checks.push({\n name: 'Models',\n pass: allParseable,\n detail: `${models.length} model(s)${allParseable ? ', all valid' : ', some failed to parse'}`,\n severity: allParseable ? undefined : 'error',\n })\n } catch {\n checks.push({ name: 'Models', pass: false, detail: 'Failed to read models', severity: 'error' })\n }\n }\n\n // ─── 7. Orphan content ───\n if (hasCrDir) {\n const orphans = await findOrphanContent(projectRoot)\n checks.push({\n name: 'Orphan content',\n pass: orphans.length === 0,\n detail: orphans.length === 0 ? 'None' : `Found: ${orphans.join(', ')}`,\n severity: orphans.length === 0 ? undefined : 'warning',\n })\n }\n\n // ─── 8. Stale contentrain branches ───\n if (hasGit) {\n try {\n const health = await checkBranchHealth(projectRoot)\n checks.push({\n name: 'Pending branches',\n pass: !health.blocked && !health.warning,\n detail: health.message\n ?? (health.unmerged === 0 ? 'None' : `${health.unmerged} active cr/* branch(es)`),\n severity: health.blocked ? 'error' : health.warning ? 'warning' : undefined,\n })\n } catch {\n checks.push({ name: 'Pending branches', pass: true, detail: 'Could not check' })\n }\n\n // ─── 8b. Remote cr/* branches ───\n // Authoritative ls-remote count (local branch pressure cannot see the\n // remote pile). Best-effort: skipped entirely without a remote, and an\n // unreachable remote is informational — doctor never fails offline.\n const remoteList = await listRemoteCrBranches(projectRoot, { timeoutMs: 5000 })\n if (remoteList) {\n if (remoteList.error) {\n checks.push({\n name: 'Remote branches',\n pass: true,\n detail: `Could not check ${remoteList.remote} (offline?)`,\n severity: 'info',\n })\n } else {\n const count = remoteList.branches.length\n const warnLimit = config?.branchWarnLimit ?? 50\n const warn = count >= warnLimit\n checks.push({\n name: 'Remote branches',\n pass: !warn,\n detail: count === 0\n ? `None on ${remoteList.remote}`\n : `${count} cr/* branch(es) on ${remoteList.remote}${warn ? ' — run `contentrain prune` to remove merged leftovers' : ''}`,\n severity: warn ? 'warning' : undefined,\n })\n }\n }\n }\n\n // ─── 9. SDK client freshness ───\n const clientDir = join(crDir, 'client')\n const modelsDir = join(crDir, 'models')\n if (await pathExists(clientDir) && await pathExists(modelsDir)) {\n try {\n const [clientMtime, modelsMtime] = await Promise.all([\n newestFileMtime(clientDir),\n newestFileMtime(modelsDir),\n ])\n if (clientMtime === null || modelsMtime === null) {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n } else {\n const fresh = clientMtime >= modelsMtime\n checks.push({\n name: 'SDK client',\n pass: fresh,\n detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`',\n severity: fresh ? undefined : 'warning',\n })\n }\n } catch {\n checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' })\n }\n }\n\n // ─── 10–12. Usage analysis (optional) ───\n let usage: DoctorUsageAnalysis | undefined\n if (options.usage && hasCrDir && config) {\n const [unusedKeys, duplicateValues, missingLocaleKeys] = await Promise.all([\n analyzeUnusedKeys(projectRoot, config),\n analyzeDuplicateValues(projectRoot, config),\n analyzeMissingLocaleKeys(projectRoot, config),\n ])\n usage = { unusedKeys, duplicateValues, missingLocaleKeys }\n\n checks.push({\n name: 'Unused content keys',\n pass: unusedKeys.length === 0,\n detail: unusedKeys.length === 0\n ? 'All keys referenced in source'\n : `${unusedKeys.length} key(s) not referenced in source code`,\n severity: unusedKeys.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Duplicate dictionary values',\n pass: duplicateValues.length === 0,\n detail: duplicateValues.length === 0\n ? 'No duplicate values'\n : `${duplicateValues.length} value(s) mapped to multiple keys`,\n severity: duplicateValues.length === 0 ? undefined : 'warning',\n })\n\n checks.push({\n name: 'Locale key coverage',\n pass: missingLocaleKeys.length === 0,\n detail: missingLocaleKeys.length === 0\n ? 'All locales have matching keys'\n : `${missingLocaleKeys.length} key(s) missing in some locales`,\n severity: missingLocaleKeys.length === 0 ? undefined : 'warning',\n })\n }\n\n const passed = checks.filter(c => c.pass).length\n const failed = checks.length - passed\n const warnings = checks.filter(c => !c.pass && c.severity === 'warning').length\n\n const report: DoctorReport = {\n checks,\n summary: { total: checks.length, passed, failed, warnings },\n }\n if (usage) report.usage = usage\n return report\n}\n\n/**\n * Newest mtime among the files under `dir`, recursively — null if it holds none.\n *\n * Stat the files, never the directory. A directory's mtime only moves when an\n * entry is added, removed, or renamed inside it: `generate` rewrites the client\n * files in place, so `.contentrain/client` never moves after the first run,\n * while a selective sync recreates model files via `git checkout`, which does\n * move `.contentrain/models`. Comparing the two directories therefore reported\n * \"stale\" permanently after any model save.\n */\nasync function newestFileMtime(dir: string): Promise<number | null> {\n const entries: Dirent[] = await readdir(dir, { withFileTypes: true }).catch(() => [])\n const mtimes = await Promise.all(entries.map(async (entry) => {\n const full = join(dir, entry.name)\n if (entry.isDirectory()) return newestFileMtime(full)\n return stat(full).then(s => s.mtimeMs, () => null)\n }))\n const known = mtimes.filter((m): m is number => m !== null)\n return known.length > 0 ? Math.max(...known) : null\n}\n\nasync function findOrphanContent(projectRoot: string): Promise<string[]> {\n const crDir = contentrainDir(projectRoot)\n const models = await listModels(projectRoot)\n const orphans: string[] = []\n\n const knownContentDirs = new Set<string>()\n for (const m of models) {\n const full = await readModel(projectRoot, m.id)\n const modelForPath = full\n ? {\n ...full,\n content_path: full.content_path ?? (m as { content_path?: string }).content_path,\n }\n : {\n id: m.id,\n name: m.id,\n kind: m.kind,\n domain: m.domain,\n i18n: m.i18n,\n fields: {},\n content_path: (m as { content_path?: string }).content_path,\n }\n knownContentDirs.add(resolveContentDir(projectRoot, modelForPath))\n }\n\n const contentDir = join(crDir, 'content')\n if (await pathExists(contentDir)) {\n const domains = await readDir(contentDir)\n for (const domain of domains) {\n const domainDir = join(contentDir, domain)\n const entries = await readDir(domainDir)\n for (const entry of entries) {\n if (entry === '.gitkeep') continue\n const entryDir = join(domainDir, entry)\n if (!knownContentDirs.has(entryDir)) {\n orphans.push(`${domain}/${entry}`)\n }\n }\n }\n }\n\n for (const dir of knownContentDirs) {\n if (dir.startsWith(contentDir)) continue\n if (!await pathExists(dir)) {\n orphans.push(`(missing custom path) ${dir}`)\n continue\n }\n await readDir(dir)\n }\n\n return orphans\n}\n\nasync function analyzeUnusedKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<UnusedKeyEntry[]> {\n const sourceDirs = await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, { paths: sourceDirs })\n if (files.length === 0) return []\n\n const chunks = await Promise.all(\n files.map(async (relPath) => {\n const content = await readText(join(projectRoot, relPath))\n return content ?? ''\n }),\n )\n const allSource = chunks.join('\\n')\n\n const models = await listModels(projectRoot)\n const defaultLocale = config.locales.default\n const unused: UnusedKeyEntry[] = []\n\n for (const m of models) {\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const keys = await extractContentKeys(projectRoot, fullModel, defaultLocale)\n for (const key of keys) {\n if (!allSource.includes(key)) {\n unused.push({ model: m.id, kind: m.kind, key, locale: defaultLocale })\n }\n }\n }\n\n return unused\n}\n\nasync function extractContentKeys(\n projectRoot: string,\n model: ModelDefinition,\n locale: string,\n): Promise<string[]> {\n const cDir = resolveContentDir(projectRoot, model)\n if (!await pathExists(cDir)) return []\n\n switch (model.kind) {\n case 'dictionary': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, string>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'collection': {\n const filePath = resolveJsonFilePath(cDir, model, locale)\n const data = await readJson<Record<string, Record<string, unknown>>>(filePath)\n return data ? Object.keys(data) : []\n }\n case 'document': {\n const strategy = resolveLocaleStrategy(model)\n const slugs: string[] = []\n if (!model.i18n) {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n } else if (strategy === 'file') {\n const dirs = await readDir(cDir)\n for (const d of dirs) if (!d.startsWith('.')) slugs.push(d)\n } else if (strategy === 'suffix') {\n const files = await readDir(cDir)\n const suffix = `.${locale}.md`\n for (const f of files) if (f.endsWith(suffix)) slugs.push(f.slice(0, -suffix.length))\n } else if (strategy === 'directory') {\n const localeDir = join(cDir, locale)\n if (await pathExists(localeDir)) {\n const files = await readDir(localeDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n } else {\n const files = await readDir(cDir)\n for (const f of files) if (f.endsWith('.md')) slugs.push(f.replace('.md', ''))\n }\n return slugs\n }\n case 'singleton':\n return []\n default:\n return []\n }\n}\n\nasync function analyzeDuplicateValues(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<DuplicateValueEntry[]> {\n const models = await listModels(projectRoot)\n const result: DuplicateValueEntry[] = []\n\n for (const m of models) {\n if (m.kind !== 'dictionary') continue\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n for (const locale of config.locales.supported) {\n const filePath = resolveJsonFilePath(cDir, fullModel, locale)\n const data = await readJson<Record<string, string>>(filePath)\n if (!data) continue\n\n const valueToKeys = new Map<string, string[]>()\n for (const [key, value] of Object.entries(data)) {\n const arr = valueToKeys.get(value)\n if (arr) arr.push(key)\n else valueToKeys.set(value, [key])\n }\n\n for (const [value, keys] of valueToKeys) {\n if (keys.length > 1) {\n result.push({ model: m.id, locale, value, keys })\n }\n }\n }\n }\n\n return result\n}\n\nasync function analyzeMissingLocaleKeys(\n projectRoot: string,\n config: ContentrainConfig,\n): Promise<MissingLocaleEntry[]> {\n if (config.locales.supported.length < 2) return []\n\n const models = await listModels(projectRoot)\n const result: MissingLocaleEntry[] = []\n const defaultLocale = config.locales.default\n const otherLocales = config.locales.supported.filter(l => l !== defaultLocale)\n\n for (const m of models) {\n if (m.kind !== 'dictionary' && m.kind !== 'collection') continue\n if (!m.i18n) continue\n\n const fullModel = await readModel(projectRoot, m.id)\n if (!fullModel) continue\n\n const cDir = resolveContentDir(projectRoot, fullModel)\n const defaultPath = resolveJsonFilePath(cDir, fullModel, defaultLocale)\n const defaultData = await readJson<Record<string, unknown>>(defaultPath)\n if (!defaultData) continue\n const defaultKeys = new Set(Object.keys(defaultData))\n\n for (const locale of otherLocales) {\n const localePath = resolveJsonFilePath(cDir, fullModel, locale)\n const localeData = await readJson<Record<string, unknown>>(localePath)\n const localeKeys = localeData ? new Set(Object.keys(localeData)) : new Set<string>()\n\n for (const key of defaultKeys) {\n if (!localeKeys.has(key)) {\n result.push({ model: m.id, key, missingIn: locale })\n }\n }\n }\n }\n\n return result\n}\n"],"mappings":"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"}
import { t as LOCAL_CAPABILITIES } from "./contracts-DfL0BfrD.mjs";
import { a as applyChangesToWorktree } from "./ops-B422KP_S.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { C as LocalReader } from "./model-manager-SiesTJrS.mjs";
import { a as deleteRemoteBranch, r as classifyMergedBranches } from "./branch-lifecycle-BAfgSQBv.mjs";
import { i as mergeBranch$1, n as createTransaction } from "./transaction-BHOsiEn1.mjs";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { simpleGit } from "simple-git";
//#region src/providers/local/branch-ops.ts
/**
* Branch/merge/diff helpers backed by the local simple-git worktree flow.
*
* Pure functions composed into `LocalProvider` — mirroring the shape of
* `providers/github/branch-ops.ts` so the two providers share the same
* surface at the `RepoProvider` boundary.
*/
async function getDefaultBranch(projectRoot) {
const config = await readConfig(projectRoot);
if (config?.repository?.default_branch) return config.repository.default_branch;
const envBranch = process.env["CONTENTRAIN_BRANCH"];
if (envBranch) return envBranch;
return (await simpleGit(projectRoot).raw(["branch", "--show-current"])).trim() || "main";
}
async function listBranches(projectRoot, prefix) {
const summary = await simpleGit(projectRoot).branchLocal();
const names = prefix ? summary.all.filter((n) => n.startsWith(prefix)) : summary.all;
const branches = [];
for (const name of names) {
const info = summary.branches[name];
branches.push({
name,
sha: info?.commit ?? ""
});
}
return branches;
}
async function createBranch(projectRoot, name, fromRef) {
await simpleGit(projectRoot).raw([
"branch",
name,
fromRef
]);
}
async function deleteBranch(projectRoot, name) {
await simpleGit(projectRoot).deleteLocalBranch(name, true);
await deleteRemoteBranch(projectRoot, name);
}
async function getBranchDiff(projectRoot, branch, base) {
const raw = await simpleGit(projectRoot).raw([
"diff",
"--name-status",
`${base}...${branch}`
]);
const diffs = [];
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
const [code, ...pathParts] = line.split(" ");
const path = pathParts[pathParts.length - 1];
if (!code || !path) continue;
const status = code.startsWith("A") ? "added" : code.startsWith("D") ? "removed" : "modified";
diffs.push({
path,
status,
before: null,
after: null
});
}
return diffs;
}
async function mergeBranch(projectRoot, branch, into) {
if (into !== CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`LocalProvider.mergeBranch only supports merging into "${CONTENTRAIN_BRANCH}" (got "${into}"). The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`), {
code: "UNSUPPORTED_MERGE_TARGET",
agent_hint: `Pass "${CONTENTRAIN_BRANCH}" as the merge target, or use a non-local provider that supports arbitrary targets.`,
developer_action: `Merge "${branch}" into "${CONTENTRAIN_BRANCH}" instead.`
});
const result = await mergeBranch$1(projectRoot, branch);
return {
merged: true,
sha: result.commit,
pullRequestUrl: null,
sync: result.sync,
...result.remote ? { remote: result.remote } : {}
};
}
async function isMerged(projectRoot, branch, into) {
try {
return (await classifyMergedBranches(projectRoot, [branch], into)).has(branch);
} catch {
return false;
}
}
//#endregion
//#region src/providers/local/provider.ts
const DEFAULT_AUTHOR_NAME = "Contentrain";
const DEFAULT_AUTHOR_EMAIL = "ai@contentrain.io";
/**
* LocalProvider — the local-filesystem, worktree-backed content provider.
*
* Implements the full `RepoProvider` surface:
* - Reader methods delegate to `LocalReader`.
* - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`
* (a superset of `Commit` carrying workflow action + selective sync).
* - Branch ops mirror `GitHubProvider` — thin wrappers over the local
* simple-git helpers in `./branch-ops.ts`.
*
* `mergeBranch` only supports merging into the singleton
* `CONTENTRAIN_BRANCH`; the local flow advances the base branch via
* `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets
* would bypass that invariant.
*/
var LocalProvider = class {
capabilities = LOCAL_CAPABILITIES;
reader;
constructor(projectRoot) {
this.projectRoot = projectRoot;
this.reader = new LocalReader(projectRoot);
}
readFile(path, ref) {
return this.reader.readFile(path, ref);
}
listDirectory(path, ref) {
return this.reader.listDirectory(path, ref);
}
fileExists(path, ref) {
return this.reader.fileExists(path, ref);
}
async applyPlan(input) {
const tx = await createTransaction(this.projectRoot, input.branch, { workflowOverride: input.workflowOverride });
try {
await tx.write(async (wt) => {
await applyChangesToWorktree(wt, input.changes);
});
await tx.commit(input.message, input.context);
const gitResult = await tx.complete();
return {
sha: gitResult.commit,
message: input.message,
author: {
name: process.env["CONTENTRAIN_AUTHOR_NAME"] ?? DEFAULT_AUTHOR_NAME,
email: process.env["CONTENTRAIN_AUTHOR_EMAIL"] ?? DEFAULT_AUTHOR_EMAIL
},
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
workflowAction: gitResult.action,
sync: gitResult.sync,
warning: gitResult.warning
};
} finally {
await tx.cleanup();
}
}
listBranches(prefix) {
return listBranches(this.projectRoot, prefix);
}
async createBranch(name, fromRef) {
const resolved = fromRef ?? CONTENTRAIN_BRANCH;
await createBranch(this.projectRoot, name, resolved);
}
deleteBranch(name) {
return deleteBranch(this.projectRoot, name);
}
getBranchDiff(branch, base) {
const resolved = base ?? CONTENTRAIN_BRANCH;
return getBranchDiff(this.projectRoot, branch, resolved);
}
mergeBranch(branch, into) {
return mergeBranch(this.projectRoot, branch, into);
}
isMerged(branch, into) {
const resolved = into ?? CONTENTRAIN_BRANCH;
return isMerged(this.projectRoot, branch, resolved);
}
getDefaultBranch() {
return getDefaultBranch(this.projectRoot);
}
};
//#endregion
export { isMerged as n, LocalProvider as t };
//# sourceMappingURL=local-DqmDeNxI.mjs.map
{"version":3,"file":"local-DqmDeNxI.mjs","names":["mergeBranchOp","listBranchesOp","createBranchOp","deleteBranchOp","getBranchDiffOp","mergeBranchOp","isMergedOp","getDefaultBranchOp"],"sources":["../src/providers/local/branch-ops.ts","../src/providers/local/provider.ts"],"sourcesContent":["import { simpleGit } from 'simple-git'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type { Branch, FileDiff, MergeResult } from '../../core/contracts/index.js'\nimport { readConfig } from '../../core/config.js'\nimport { classifyMergedBranches, deleteRemoteBranch } from '../../git/branch-lifecycle.js'\nimport { mergeBranch as mergeBranchOp } from '../../git/transaction.js'\n\n/**\n * Branch/merge/diff helpers backed by the local simple-git worktree flow.\n *\n * Pure functions composed into `LocalProvider` — mirroring the shape of\n * `providers/github/branch-ops.ts` so the two providers share the same\n * surface at the `RepoProvider` boundary.\n */\n\nexport async function getDefaultBranch(projectRoot: string): Promise<string> {\n const config = await readConfig(projectRoot)\n if (config?.repository?.default_branch) return config.repository.default_branch\n const envBranch = process.env['CONTENTRAIN_BRANCH']\n if (envBranch) return envBranch\n const git = simpleGit(projectRoot)\n const current = (await git.raw(['branch', '--show-current'])).trim()\n return current || 'main'\n}\n\nexport async function listBranches(\n projectRoot: string,\n prefix?: string,\n): Promise<Branch[]> {\n const git = simpleGit(projectRoot)\n const summary = await git.branchLocal()\n const names = prefix\n ? summary.all.filter(n => n.startsWith(prefix))\n : summary.all\n const branches: Branch[] = []\n for (const name of names) {\n const info = summary.branches[name]\n branches.push({ name, sha: info?.commit ?? '' })\n }\n return branches\n}\n\nexport async function createBranch(\n projectRoot: string,\n name: string,\n fromRef: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.raw(['branch', name, fromRef])\n}\n\nexport async function deleteBranch(\n projectRoot: string,\n name: string,\n): Promise<void> {\n const git = simpleGit(projectRoot)\n await git.deleteLocalBranch(name, true)\n // Parity with the remote-API providers, whose deleteBranch removes the\n // remote ref: best-effort, config-gated inside the helper, never throws.\n await deleteRemoteBranch(projectRoot, name)\n}\n\nexport async function getBranchDiff(\n projectRoot: string,\n branch: string,\n base: string,\n): Promise<FileDiff[]> {\n const git = simpleGit(projectRoot)\n const raw = await git.raw(['diff', '--name-status', `${base}...${branch}`])\n const diffs: FileDiff[] = []\n for (const line of raw.split('\\n')) {\n if (!line.trim()) continue\n const [code, ...pathParts] = line.split('\\t')\n const path = pathParts[pathParts.length - 1]\n if (!code || !path) continue\n const status: FileDiff['status'] = code.startsWith('A')\n ? 'added'\n : code.startsWith('D')\n ? 'removed'\n : 'modified'\n diffs.push({ path, status, before: null, after: null })\n }\n return diffs\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<MergeResult> {\n if (into !== CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `LocalProvider.mergeBranch only supports merging into \"${CONTENTRAIN_BRANCH}\" (got \"${into}\"). `\n + `The local flow merges feature branches into the content-tracking branch and fast-forwards the base branch via update-ref.`,\n ), {\n code: 'UNSUPPORTED_MERGE_TARGET',\n agent_hint: `Pass \"${CONTENTRAIN_BRANCH}\" as the merge target, or use a non-local provider that supports arbitrary targets.`,\n developer_action: `Merge \"${branch}\" into \"${CONTENTRAIN_BRANCH}\" instead.`,\n })\n }\n const result = await mergeBranchOp(projectRoot, branch)\n return {\n merged: true,\n sha: result.commit,\n pullRequestUrl: null,\n sync: result.sync,\n ...(result.remote ? { remote: result.remote } : {}),\n }\n}\n\nexport async function isMerged(\n projectRoot: string,\n branch: string,\n into: string,\n): Promise<boolean> {\n try {\n const merged = await classifyMergedBranches(projectRoot, [branch], into)\n return merged.has(branch)\n } catch {\n return false\n }\n}\n","import { CONTENTRAIN_BRANCH } from '@contentrain/types'\nimport type {\n Branch,\n FileDiff,\n MergeResult,\n ProviderCapabilities,\n RepoProvider,\n} from '../../core/contracts/index.js'\nimport { LOCAL_CAPABILITIES } from '../../core/contracts/index.js'\nimport { applyChangesToWorktree } from '../../core/ops/index.js'\nimport { createTransaction } from '../../git/transaction.js'\nimport {\n createBranch as createBranchOp,\n deleteBranch as deleteBranchOp,\n getBranchDiff as getBranchDiffOp,\n getDefaultBranch as getDefaultBranchOp,\n isMerged as isMergedOp,\n listBranches as listBranchesOp,\n mergeBranch as mergeBranchOp,\n} from './branch-ops.js'\nimport { LocalReader } from './reader.js'\nimport type { LocalApplyPlanInput, LocalApplyResult } from './types.js'\n\nconst DEFAULT_AUTHOR_NAME = 'Contentrain'\nconst DEFAULT_AUTHOR_EMAIL = 'ai@contentrain.io'\n\n/**\n * LocalProvider — the local-filesystem, worktree-backed content provider.\n *\n * Implements the full `RepoProvider` surface:\n * - Reader methods delegate to `LocalReader`.\n * - `applyPlan` wraps `createTransaction` and returns `LocalApplyResult`\n * (a superset of `Commit` carrying workflow action + selective sync).\n * - Branch ops mirror `GitHubProvider` — thin wrappers over the local\n * simple-git helpers in `./branch-ops.ts`.\n *\n * `mergeBranch` only supports merging into the singleton\n * `CONTENTRAIN_BRANCH`; the local flow advances the base branch via\n * `update-ref` in `transaction.mergeBranch`, so arbitrary merge targets\n * would bypass that invariant.\n */\nexport class LocalProvider implements RepoProvider {\n readonly capabilities: ProviderCapabilities = LOCAL_CAPABILITIES\n private readonly reader: LocalReader\n\n constructor(public readonly projectRoot: string) {\n this.reader = new LocalReader(projectRoot)\n }\n\n readFile(path: string, ref?: string): Promise<string> {\n return this.reader.readFile(path, ref)\n }\n\n listDirectory(path: string, ref?: string): Promise<string[]> {\n return this.reader.listDirectory(path, ref)\n }\n\n fileExists(path: string, ref?: string): Promise<boolean> {\n return this.reader.fileExists(path, ref)\n }\n\n async applyPlan(input: LocalApplyPlanInput): Promise<LocalApplyResult> {\n const tx = await createTransaction(this.projectRoot, input.branch, {\n workflowOverride: input.workflowOverride,\n })\n try {\n await tx.write(async (wt) => {\n await applyChangesToWorktree(wt, input.changes)\n })\n await tx.commit(input.message, input.context)\n const gitResult = await tx.complete()\n return {\n sha: gitResult.commit,\n message: input.message,\n author: {\n name: process.env['CONTENTRAIN_AUTHOR_NAME'] ?? DEFAULT_AUTHOR_NAME,\n email: process.env['CONTENTRAIN_AUTHOR_EMAIL'] ?? DEFAULT_AUTHOR_EMAIL,\n },\n timestamp: new Date().toISOString(),\n workflowAction: gitResult.action,\n sync: gitResult.sync,\n warning: gitResult.warning,\n }\n } finally {\n await tx.cleanup()\n }\n }\n\n listBranches(prefix?: string): Promise<Branch[]> {\n return listBranchesOp(this.projectRoot, prefix)\n }\n\n async createBranch(name: string, fromRef?: string): Promise<void> {\n const resolved = fromRef ?? CONTENTRAIN_BRANCH\n await createBranchOp(this.projectRoot, name, resolved)\n }\n\n deleteBranch(name: string): Promise<void> {\n return deleteBranchOp(this.projectRoot, name)\n }\n\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]> {\n const resolved = base ?? CONTENTRAIN_BRANCH\n return getBranchDiffOp(this.projectRoot, branch, resolved)\n }\n\n mergeBranch(branch: string, into: string): Promise<MergeResult> {\n return mergeBranchOp(this.projectRoot, branch, into)\n }\n\n isMerged(branch: string, into?: string): Promise<boolean> {\n const resolved = into ?? CONTENTRAIN_BRANCH\n return isMergedOp(this.projectRoot, branch, resolved)\n }\n\n getDefaultBranch(): Promise<string> {\n return getDefaultBranchOp(this.projectRoot)\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAeA,eAAsB,iBAAiB,aAAsC;CAC3E,MAAM,SAAS,MAAM,WAAW,YAAY;AAC5C,KAAI,QAAQ,YAAY,eAAgB,QAAO,OAAO,WAAW;CACjE,MAAM,YAAY,QAAQ,IAAI;AAC9B,KAAI,UAAW,QAAO;AAGtB,SADiB,MADL,UAAU,YAAY,CACP,IAAI,CAAC,UAAU,iBAAiB,CAAC,EAAE,MAAM,IAClD;;AAGpB,eAAsB,aACpB,aACA,QACmB;CAEnB,MAAM,UAAU,MADJ,UAAU,YAAY,CACR,aAAa;CACvC,MAAM,QAAQ,SACV,QAAQ,IAAI,QAAO,MAAK,EAAE,WAAW,OAAO,CAAC,GAC7C,QAAQ;CACZ,MAAM,WAAqB,EAAE;AAC7B,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,OAAO,QAAQ,SAAS;AAC9B,WAAS,KAAK;GAAE;GAAM,KAAK,MAAM,UAAU;GAAI,CAAC;;AAElD,QAAO;;AAGT,eAAsB,aACpB,aACA,MACA,SACe;AAEf,OADY,UAAU,YAAY,CACxB,IAAI;EAAC;EAAU;EAAM;EAAQ,CAAC;;AAG1C,eAAsB,aACpB,aACA,MACe;AAEf,OADY,UAAU,YAAY,CACxB,kBAAkB,MAAM,KAAK;AAGvC,OAAM,mBAAmB,aAAa,KAAK;;AAG7C,eAAsB,cACpB,aACA,QACA,MACqB;CAErB,MAAM,MAAM,MADA,UAAU,YAAY,CACZ,IAAI;EAAC;EAAQ;EAAiB,GAAG,KAAK,KAAK;EAAS,CAAC;CAC3E,MAAM,QAAoB,EAAE;AAC5B,MAAK,MAAM,QAAQ,IAAI,MAAM,KAAK,EAAE;AAClC,MAAI,CAAC,KAAK,MAAM,CAAE;EAClB,MAAM,CAAC,MAAM,GAAG,aAAa,KAAK,MAAM,IAAK;EAC7C,MAAM,OAAO,UAAU,UAAU,SAAS;AAC1C,MAAI,CAAC,QAAQ,CAAC,KAAM;EACpB,MAAM,SAA6B,KAAK,WAAW,IAAI,GACnD,UACA,KAAK,WAAW,IAAI,GAClB,YACA;AACN,QAAM,KAAK;GAAE;GAAM;GAAQ,QAAQ;GAAM,OAAO;GAAM,CAAC;;AAEzD,QAAO;;AAGT,eAAsB,YACpB,aACA,QACA,MACsB;AACtB,KAAI,SAAS,mBACX,OAAM,OAAO,uBAAO,IAAI,MACtB,yDAAyD,mBAAmB,UAAU,KAAK,+HAE5F,EAAE;EACD,MAAM;EACN,YAAY,SAAS,mBAAmB;EACxC,kBAAkB,UAAU,OAAO,UAAU,mBAAmB;EACjE,CAAC;CAEJ,MAAM,SAAS,MAAMA,cAAc,aAAa,OAAO;AACvD,QAAO;EACL,QAAQ;EACR,KAAK,OAAO;EACZ,gBAAgB;EAChB,MAAM,OAAO;EACb,GAAI,OAAO,SAAS,EAAE,QAAQ,OAAO,QAAQ,GAAG,EAAE;EACnD;;AAGH,eAAsB,SACpB,aACA,QACA,MACkB;AAClB,KAAI;AAEF,UADe,MAAM,uBAAuB,aAAa,CAAC,OAAO,EAAE,KAAK,EAC1D,IAAI,OAAO;SACnB;AACN,SAAO;;;;;AChGX,MAAM,sBAAsB;AAC5B,MAAM,uBAAuB;;;;;;;;;;;;;;;;AAiB7B,IAAa,gBAAb,MAAmD;CACjD,eAA8C;CAC9C;CAEA,YAAY,aAAqC;AAArB,OAAA,cAAA;AAC1B,OAAK,SAAS,IAAI,YAAY,YAAY;;CAG5C,SAAS,MAAc,KAA+B;AACpD,SAAO,KAAK,OAAO,SAAS,MAAM,IAAI;;CAGxC,cAAc,MAAc,KAAiC;AAC3D,SAAO,KAAK,OAAO,cAAc,MAAM,IAAI;;CAG7C,WAAW,MAAc,KAAgC;AACvD,SAAO,KAAK,OAAO,WAAW,MAAM,IAAI;;CAG1C,MAAM,UAAU,OAAuD;EACrE,MAAM,KAAK,MAAM,kBAAkB,KAAK,aAAa,MAAM,QAAQ,EACjE,kBAAkB,MAAM,kBACzB,CAAC;AACF,MAAI;AACF,SAAM,GAAG,MAAM,OAAO,OAAO;AAC3B,UAAM,uBAAuB,IAAI,MAAM,QAAQ;KAC/C;AACF,SAAM,GAAG,OAAO,MAAM,SAAS,MAAM,QAAQ;GAC7C,MAAM,YAAY,MAAM,GAAG,UAAU;AACrC,UAAO;IACL,KAAK,UAAU;IACf,SAAS,MAAM;IACf,QAAQ;KACN,MAAM,QAAQ,IAAI,8BAA8B;KAChD,OAAO,QAAQ,IAAI,+BAA+B;KACnD;IACD,4BAAW,IAAI,MAAM,EAAC,aAAa;IACnC,gBAAgB,UAAU;IAC1B,MAAM,UAAU;IAChB,SAAS,UAAU;IACpB;YACO;AACR,SAAM,GAAG,SAAS;;;CAItB,aAAa,QAAoC;AAC/C,SAAOC,aAAe,KAAK,aAAa,OAAO;;CAGjD,MAAM,aAAa,MAAc,SAAiC;EAChE,MAAM,WAAW,WAAW;AAC5B,QAAMC,aAAe,KAAK,aAAa,MAAM,SAAS;;CAGxD,aAAa,MAA6B;AACxC,SAAOC,aAAe,KAAK,aAAa,KAAK;;CAG/C,cAAc,QAAgB,MAAoC;EAChE,MAAM,WAAW,QAAQ;AACzB,SAAOC,cAAgB,KAAK,aAAa,QAAQ,SAAS;;CAG5D,YAAY,QAAgB,MAAoC;AAC9D,SAAOC,YAAc,KAAK,aAAa,QAAQ,KAAK;;CAGtD,SAAS,QAAgB,MAAiC;EACxD,MAAM,WAAW,QAAQ;AACzB,SAAOC,SAAW,KAAK,aAAa,QAAQ,SAAS;;CAGvD,mBAAoC;AAClC,SAAOC,iBAAmB,KAAK,YAAY"}
import { n as contentFilePath, r as documentFilePath, t as contentDirPath } from "./paths-CmVw5Cw2.mjs";
import { a as readJson, c as writeText, i as readDir, n as ensureDir, o as readText, s as writeJson, t as contentrainDir } from "./fs-DLbVB-Ek.mjs";
import { i as writeMeta, n as mergeEntryMeta, r as readMeta, t as deleteMeta } from "./meta-manager-CJUiTgP2.mjs";
import { join, resolve } from "node:path";
import { generateEntryId, parseMarkdownFrontmatter, parseMarkdownFrontmatter as parseFrontmatter, serializeMarkdownFrontmatter, serializeMarkdownFrontmatter as serializeFrontmatter, validateEntryId as validateEntryId$1, validateLocale as validateLocale$1, validateSlug as validateSlug$1 } from "@contentrain/types";
import { access, readFile, readdir, rm } from "node:fs/promises";
import { z } from "zod";
//#region src/providers/local/reader.ts
/**
* LocalReader — `RepoReader` backed by the local filesystem.
*
* Paths may be absolute or relative to `projectRoot` (`node:path/resolve`
* handles both). The `ref` parameter is accepted for interface compatibility
* but ignored because LocalReader always reads from the working tree.
*
* Phase 1: provided as plumbing; core ops still use direct `fs` calls.
* Phase 2 routes ops through this reader (and through GitHubProvider's reader
* in Phase 5) so the same op surface works on every backing store.
*/
var LocalReader = class {
constructor(projectRoot) {
this.projectRoot = projectRoot;
}
async readFile(path, _ref) {
return readFile(resolve(this.projectRoot, path), "utf-8");
}
async listDirectory(path, _ref) {
try {
return await readdir(resolve(this.projectRoot, path));
} catch {
return [];
}
}
async fileExists(path, _ref) {
try {
await access(resolve(this.projectRoot, path));
return true;
} catch {
return false;
}
}
};
//#endregion
//#region src/core/content-manager.ts
function resolveContentDir(projectRoot, model) {
if (model.content_path) return join(projectRoot, model.content_path);
return join(contentrainDir(projectRoot), "content", model.domain, model.id);
}
function resolveLocaleStrategy(model) {
return model.locale_strategy ?? "file";
}
/** Build the file path for a JSON content file (singleton/collection/dictionary) */
function resolveJsonFilePath(dir, model, locale) {
if (!model.i18n) return join(dir, "data.json");
switch (resolveLocaleStrategy(model)) {
case "suffix": return join(dir, `${model.id}.${locale}.json`);
case "directory": return join(dir, locale, `${model.id}.json`);
case "none": return join(dir, `${model.id}.json`);
default: return join(dir, `${locale}.json`);
}
}
/** Build the file path for a markdown document */
function resolveMdFilePath(dir, model, locale, slug) {
if (!model.i18n) return join(dir, `${slug}.md`);
switch (resolveLocaleStrategy(model)) {
case "suffix": return join(dir, `${slug}.${locale}.md`);
case "directory": return join(dir, locale, `${slug}.md`);
case "none": return join(dir, `${slug}.md`);
default: return join(dir, slug, `${locale}.md`);
}
}
async function writeContent(projectRoot, model, entries, config, vocabulary) {
const results = [];
const defaultLocale = config.locales.default;
for (const entry of entries) {
const locale = entry.locale ?? defaultLocale;
const localeErr = validateLocale$1(locale, config);
if (localeErr) throw new Error(localeErr);
if (entry.id) {
const idErr = validateEntryId$1(entry.id);
if (idErr) throw new Error(idErr);
}
if (entry.slug) {
const slugErr = validateSlug$1(entry.slug);
if (slugErr) throw new Error(slugErr);
}
switch (model.kind) {
case "singleton": {
await writeJson(resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale), entry.data);
const prevMeta = await readMeta(projectRoot, model, {
locale,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
defaultLocale
}, mergeEntryMeta(prevMeta ?? void 0, entry.data));
results.push({
action: "updated",
locale
});
break;
}
case "collection": {
const isNew = !entry.id;
const id = entry.id ?? generateEntryId();
const filePath = resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale);
const existing = await readJson(filePath) ?? {};
const action = isNew || !(id in existing) ? "created" : "updated";
existing[id] = entry.data;
const sorted = {};
for (const key of Object.keys(existing).toSorted()) sorted[key] = existing[key];
await writeJson(filePath, sorted);
const prevMetaMap = await readMeta(projectRoot, model, {
locale,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
entryId: id,
defaultLocale
}, mergeEntryMeta(prevMetaMap?.[id], entry.data));
results.push({
action,
id,
locale
});
break;
}
case "document": {
const slug = entry.slug ?? entry.data["slug"];
if (!slug) throw new Error("Document entries require a slug");
const slugErr = validateSlug$1(slug);
if (slugErr) throw new Error(slugErr);
const bodyContent = entry.data["body"] ?? "";
const fmData = { ...entry.data };
delete fmData["body"];
if (!fmData["slug"]) fmData["slug"] = slug;
const docPath = resolveMdFilePath(resolveContentDir(projectRoot, model), model, locale, slug);
const action = await readText(docPath) ? "updated" : "created";
await writeText(docPath, serializeMarkdownFrontmatter(fmData, bodyContent));
const prevMeta = await readMeta(projectRoot, model, {
locale,
slug,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
slug,
defaultLocale
}, mergeEntryMeta(prevMeta ?? void 0, entry.data));
results.push({
action,
slug,
locale
});
break;
}
case "dictionary": {
const filePath = resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale);
const existing = await readJson(filePath) ?? {};
const collisions = [];
for (const key of Object.keys(entry.data)) if (key in existing && existing[key] !== entry.data[key]) collisions.push(key);
if (collisions.length > 0) throw new Error(`Dictionary "${model.id}" (${locale}): ${collisions.length} key collision(s) — [${collisions.join(", ")}] already exist with different values. Read existing keys with contentrain_content_list first, or include all keys in a single save call.`);
const advisories = [];
const reverseMap = /* @__PURE__ */ new Map();
for (const [k, v] of Object.entries(existing)) reverseMap.set(v, k);
for (const [newKey, newValue] of Object.entries(entry.data)) {
if (newKey in existing) continue;
const existingKey = reverseMap.get(newValue);
if (existingKey && existingKey !== newKey) advisories.push(`Value "${newValue}" already exists as key "${existingKey}". Consider reusing instead of creating "${newKey}".`);
}
if (vocabulary && Object.keys(vocabulary.terms).length > 0) for (const [newKey, newValue] of Object.entries(entry.data)) {
if (newKey in existing) continue;
for (const [, translations] of Object.entries(vocabulary.terms)) if (Object.values(translations).includes(newValue)) {
advisories.push(`Value "${newValue}" matches a vocabulary term. Use the canonical form for consistency.`);
break;
}
}
await writeJson(filePath, {
...existing,
...entry.data
});
const prevMeta = await readMeta(projectRoot, model, {
locale,
defaultLocale
});
await writeMeta(projectRoot, model, {
locale,
defaultLocale
}, mergeEntryMeta(prevMeta ?? void 0, entry.data));
results.push({
action: "updated",
locale,
...advisories.length > 0 ? { advisories } : {}
});
break;
}
}
}
return results;
}
async function deleteContent(projectRoot, model, opts) {
const removed = [];
const cDir = resolveContentDir(projectRoot, model);
switch (model.kind) {
case "collection": {
if (!opts.id) throw new Error("Collection delete requires an entry ID");
const locales = opts.locale ? [opts.locale] : (await readDir(cDir)).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", "").replace(`${model.id}.`, ""));
for (const loc of locales) {
const filePath = resolveJsonFilePath(cDir, model, loc);
const data = await readJson(filePath);
if (data && opts.id in data) {
delete data[opts.id];
await writeJson(filePath, data);
removed.push(`content/${model.domain}/${model.id}/${loc}.json#${opts.id}`);
}
}
for (const loc of locales) await deleteMeta(projectRoot, model, {
locale: loc,
entryId: opts.id,
defaultLocale: opts.defaultLocale
});
break;
}
case "document": {
if (!opts.slug) throw new Error("Document delete requires a slug");
const slugDelErr = validateSlug$1(opts.slug);
if (slugDelErr) throw new Error(slugDelErr);
const strategy = resolveLocaleStrategy(model);
if (!model.i18n) await rm(join(cDir, `${opts.slug}.md`), { force: true });
else if (strategy === "file") await rm(join(cDir, opts.slug), {
recursive: true,
force: true
});
else if (opts.locale) await rm(resolveMdFilePath(cDir, model, opts.locale, opts.slug), { force: true });
else {
const files = await readDir(strategy === "directory" ? cDir : cDir);
for (const f of files) if (strategy === "suffix" && f.startsWith(`${opts.slug}.`) && f.endsWith(".md")) await rm(join(cDir, f), { force: true });
else if (strategy === "directory") await rm(join(cDir, f, `${opts.slug}.md`), { force: true });
else if (strategy === "none") {
if (f === `${opts.slug}.md`) await rm(join(cDir, f), { force: true });
}
}
removed.push(`${model.content_path ?? `content/${model.domain}/${model.id}`}/${opts.slug}`);
await deleteMeta(projectRoot, model, {
slug: opts.slug,
locale: opts.locale,
defaultLocale: opts.defaultLocale
});
break;
}
case "singleton": {
if (model.i18n && !opts.locale) throw new Error("Singleton delete requires a locale when i18n is enabled");
const locale = opts.locale ?? "data";
await rm(resolveJsonFilePath(cDir, model, locale), { force: true });
removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
await deleteMeta(projectRoot, model, {
locale: model.i18n ? locale : void 0,
defaultLocale: opts.defaultLocale
});
break;
}
case "dictionary": {
if (model.i18n && !opts.locale) throw new Error("Dictionary delete requires a locale when i18n is enabled");
const locale = opts.locale ?? "data";
const filePath = resolveJsonFilePath(cDir, model, locale);
if (opts.keys?.length) {
const existing = await readJson(filePath) ?? {};
const notFound = [];
for (const key of opts.keys) if (key in existing) delete existing[key];
else notFound.push(key);
if (notFound.length > 0) throw new Error(`Dictionary "${model.id}" (${locale}): keys not found — [${notFound.join(", ")}]`);
await writeJson(filePath, existing);
removed.push(...opts.keys.map((k) => `${model.id}/${locale}:${k}`));
} else {
await rm(filePath, { force: true });
removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`);
await deleteMeta(projectRoot, model, {
locale: model.i18n ? locale : void 0,
defaultLocale: opts.defaultLocale
});
}
break;
}
}
return removed;
}
async function listContent(input, model, opts, config) {
if (typeof input !== "string") return listContentViaReader(input, model, opts, config);
return listContentLocal(input, model, opts, config);
}
async function listContentLocal(projectRoot, model, opts, config) {
const cDir = resolveContentDir(projectRoot, model);
const locale = opts.locale ?? config.locales.default;
switch (model.kind) {
case "singleton": return {
kind: "singleton",
data: await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {},
locale
};
case "collection": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {};
let entries = Object.entries(data).map(([id, fields]) => {
const entry = { id };
Object.assign(entry, fields);
return entry;
});
if (opts.filter) entries = entries.filter((entry) => {
for (const [key, value] of Object.entries(opts.filter)) if (entry[key] !== value) return false;
return true;
});
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
entries = entries.slice(offset, offset + limit);
if (opts.resolve && model.fields) entries = await resolveRelations(projectRoot, model, entries, locale);
return {
kind: "collection",
data: entries,
total,
locale,
offset,
limit
};
}
case "document": {
const entries = [];
const strategy = resolveLocaleStrategy(model);
if (!model.i18n) {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else if (strategy === "file") {
const slugDirs = await readDir(cDir);
for (const slug of slugDirs) {
const raw = await readText(join(cDir, slug, `${locale}.md`));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) {
if (!f.endsWith(suffix)) continue;
const slug = f.slice(0, -suffix.length);
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
const files = await readDir(localeDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(localeDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
} else {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
}
}
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
return {
kind: "document",
data: entries.slice(offset, offset + limit),
total,
locale,
offset,
limit
};
}
case "dictionary": {
const data = await readJson(resolveJsonFilePath(cDir, model, locale)) ?? {};
return {
kind: "dictionary",
data,
total_keys: Object.keys(data).length,
locale
};
}
}
}
async function tryReadJsonViaReader$1(reader, path) {
try {
return JSON.parse(await reader.readFile(path));
} catch {
return null;
}
}
async function tryReadTextViaReader(reader, path) {
try {
return await reader.readFile(path);
} catch {
return null;
}
}
async function listContentViaReader(reader, model, opts, config) {
if (opts.resolve) throw new Error("contentrain_content_list with resolve:true requires local filesystem access. Use a LocalProvider (stdio or HTTP+LocalProvider) or omit resolve:true.");
const cDir = contentDirPath(model);
const locale = opts.locale ?? config.locales.default;
switch (model.kind) {
case "singleton": return {
kind: "singleton",
data: await tryReadJsonViaReader$1(reader, contentFilePath(model, locale)) ?? {},
locale
};
case "collection": {
const data = await tryReadJsonViaReader$1(reader, contentFilePath(model, locale)) ?? {};
let entries = Object.entries(data).map(([id, fields]) => {
const entry = { id };
Object.assign(entry, fields);
return entry;
});
if (opts.filter) entries = entries.filter((entry) => {
for (const [key, value] of Object.entries(opts.filter)) if (entry[key] !== value) return false;
return true;
});
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
entries = entries.slice(offset, offset + limit);
return {
kind: "collection",
data: entries,
total,
locale,
offset,
limit
};
}
case "document": {
const entries = [];
const strategy = resolveLocaleStrategy(model);
const collectEntry = async (relPath, slug) => {
const raw = await tryReadTextViaReader(reader, relPath);
if (!raw) return;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
entries.push({
slug,
frontmatter,
body
});
};
if (!model.i18n) {
const files = await reader.listDirectory(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
await collectEntry(documentFilePath(model, locale, f.replace(/\.md$/u, "")), f.replace(/\.md$/u, ""));
}
} else if (strategy === "file") {
const slugDirs = await reader.listDirectory(cDir);
for (const slug of slugDirs) await collectEntry(documentFilePath(model, locale, slug), slug);
} else if (strategy === "suffix") {
const files = await reader.listDirectory(cDir);
const suffix = `.${locale}.md`;
for (const f of files) {
if (!f.endsWith(suffix)) continue;
const slug = f.slice(0, -suffix.length);
await collectEntry(documentFilePath(model, locale, slug), slug);
}
} else if (strategy === "directory") {
const files = await reader.listDirectory(`${cDir}/${locale}`);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(/\.md$/u, "");
await collectEntry(documentFilePath(model, locale, slug), slug);
}
} else {
const files = await reader.listDirectory(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(/\.md$/u, "");
await collectEntry(documentFilePath(model, locale, slug), slug);
}
}
const total = entries.length;
const offset = opts.offset ?? 0;
const limit = opts.limit ?? entries.length;
return {
kind: "document",
data: entries.slice(offset, offset + limit),
total,
locale,
offset,
limit
};
}
case "dictionary": {
const data = await tryReadJsonViaReader$1(reader, contentFilePath(model, locale)) ?? {};
return {
kind: "dictionary",
data,
total_keys: Object.keys(data).length,
locale
};
}
}
}
async function readContent(projectRoot, model, opts) {
const cDir = resolveContentDir(projectRoot, model);
switch (model.kind) {
case "singleton": return readJson(resolveJsonFilePath(cDir, model, opts.locale));
case "collection": {
if (!opts.entryId) return null;
const data = await readJson(resolveJsonFilePath(cDir, model, opts.locale));
return data?.[opts.entryId] ? {
id: opts.entryId,
...data[opts.entryId]
} : null;
}
case "document": {
if (!opts.slug) return null;
const raw = await readText(resolveMdFilePath(cDir, model, opts.locale, opts.slug));
if (!raw) return null;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
return {
slug: opts.slug,
...frontmatter,
body
};
}
case "dictionary": return readJson(resolveJsonFilePath(cDir, model, opts.locale));
}
}
async function resolveRelations(projectRoot, model, entries, locale) {
if (!model.fields) return entries;
const relationFields = [];
for (const [name, field] of Object.entries(model.fields)) if (field.type === "relation" || field.type === "relations") {
const targets = Array.isArray(field.model) ? field.model : field.model ? [field.model] : [];
relationFields.push({
name,
targetModels: targets,
multi: field.type === "relations"
});
}
if (relationFields.length === 0) return entries;
const targetCache = {};
const visited = new Set([model.id]);
for (const rf of relationFields) for (const targetModelId of rf.targetModels) {
if (targetCache[targetModelId] || visited.has(targetModelId)) continue;
visited.add(targetModelId);
const targetModel = await readModel(projectRoot, targetModelId);
if (!targetModel) continue;
if (targetModel.kind === "collection") targetCache[targetModelId] = await readJson(resolveJsonFilePath(resolveContentDir(projectRoot, targetModel), targetModel, locale)) ?? {};
else if (targetModel.kind === "document") {
const docCache = {};
const cDir = resolveContentDir(projectRoot, targetModel);
const strategy = resolveLocaleStrategy(targetModel);
if (!targetModel.i18n) {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else if (strategy === "file") {
const slugDirs = await readDir(cDir);
for (const slug of slugDirs) {
const raw = await readText(join(cDir, slug, `${locale}.md`));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else if (strategy === "suffix") {
const files = await readDir(cDir);
const suffix = `.${locale}.md`;
for (const f of files) {
if (!f.endsWith(suffix)) continue;
const slug = f.slice(0, -suffix.length);
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else if (strategy === "directory") {
const localeDir = join(cDir, locale);
const files = await readDir(localeDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(localeDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
} else {
const files = await readDir(cDir);
for (const f of files) {
if (!f.endsWith(".md")) continue;
const slug = f.replace(".md", "");
const raw = await readText(join(cDir, f));
if (!raw) continue;
const { frontmatter, body } = parseMarkdownFrontmatter(raw);
docCache[slug] = {
slug,
...frontmatter,
body
};
}
}
targetCache[targetModelId] = docCache;
}
}
return entries.map((entry) => {
const resolved = { ...entry };
for (const rf of relationFields) {
const value = resolved[rf.name];
if (!value) continue;
if (rf.multi && Array.isArray(value)) resolved[rf.name] = value.map((id) => {
for (const targetModelId of rf.targetModels) {
const cached = targetCache[targetModelId]?.[id];
if (cached) return {
id,
...cached
};
}
return id;
});
else if (typeof value === "string") for (const targetModelId of rf.targetModels) {
const cached = targetCache[targetModelId]?.[value];
if (cached) {
resolved[rf.name] = {
id: value,
...cached
};
break;
}
}
}
return resolved;
});
}
//#endregion
//#region src/core/model-manager.ts
const MODELS_DIR_PATH = ".contentrain/models";
async function tryReadJsonViaReader(reader, path) {
try {
return JSON.parse(await reader.readFile(path));
} catch {
return null;
}
}
async function listModels(input) {
let files;
let load;
if (typeof input === "string") {
const modelsDir = join(contentrainDir(input), "models");
files = await readDir(modelsDir);
load = (file) => readJson(join(modelsDir, file));
} else {
files = await input.listDirectory(MODELS_DIR_PATH);
load = (file) => tryReadJsonViaReader(input, `${MODELS_DIR_PATH}/${file}`);
}
const jsonFiles = files.filter((f) => f.endsWith(".json"));
return (await Promise.all(jsonFiles.map(load))).filter((m) => m !== null && !!m.id).map((model) => ({
id: model.id,
kind: model.kind,
domain: model.domain,
i18n: model.i18n,
fields: model.fields ? Object.keys(model.fields).length : 0
})).toSorted((a, b) => a.id.localeCompare(b.id, "en"));
}
async function readModel(input, modelId) {
if (typeof input === "string") return readJson(join(contentrainDir(input), "models", `${modelId}.json`));
return tryReadJsonViaReader(input, `${MODELS_DIR_PATH}/${modelId}.json`);
}
async function countDocumentFileStrategy(reader, contentDir, entries) {
const locales = {};
let total = 0;
const results = await Promise.all(entries.map(async (entry) => {
const localeFiles = await reader.listDirectory(`${contentDir}/${entry}`);
return localeFiles.map((lf) => lf.replace(/\.(json|md|mdx)$/, "")).filter((locale, i) => locale !== localeFiles[i]);
}));
for (const entryLocales of results) for (const locale of entryLocales) {
locales[locale] = (locales[locale] ?? 0) + 1;
total++;
}
return {
total,
locales
};
}
async function countDocumentSuffixStrategy(_reader, _contentDir, files) {
const locales = {};
const slugsByLocale = {};
for (const f of files) {
if (!f.endsWith(".md")) continue;
const match = f.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)\.md$/);
if (!match) continue;
const locale = match[2];
if (!slugsByLocale[locale]) slugsByLocale[locale] = /* @__PURE__ */ new Set();
slugsByLocale[locale].add(match[1]);
}
let total = 0;
for (const [locale, slugs] of Object.entries(slugsByLocale)) {
locales[locale] = slugs.size;
total += slugs.size;
}
return {
total,
locales
};
}
async function countDocumentDirectoryStrategy(reader, contentDir, localeDirs) {
const locales = {};
let total = 0;
const results = await Promise.all(localeDirs.map(async (localeDir) => {
return {
locale: localeDir,
count: (await reader.listDirectory(`${contentDir}/${localeDir}`)).filter((f) => f.endsWith(".md")).length
};
}));
for (const { locale, count } of results) {
locales[locale] = count;
total += count;
}
return {
total,
locales
};
}
async function countDocumentNoneStrategy(reader, modelId, files, i18n) {
const mdFiles = files.filter((f) => f.endsWith(".md"));
if (!i18n) return {
total: mdFiles.length,
locales: { _: mdFiles.length }
};
const metaDir = `.contentrain/meta/${modelId}`;
const locales = {};
let total = 0;
const slugs = mdFiles.map((f) => f.replace(".md", ""));
const results = await Promise.all(slugs.map(async (slug) => {
return (await reader.listDirectory(`${metaDir}/${slug}`)).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
}));
for (const slugLocales of results) for (const locale of slugLocales) {
locales[locale] = (locales[locale] ?? 0) + 1;
total++;
}
return {
total,
locales
};
}
async function countCollectionEntries(reader, contentDir, jsonFiles) {
const locales = {};
let total = 0;
const results = await Promise.all(jsonFiles.map(async (file) => {
const locale = file.replace(/\.json$/, "");
const data = await tryReadJsonViaReader(reader, `${contentDir}/${file}`);
return {
locale,
count: data ? Object.keys(data).length : 0
};
}));
for (const { locale, count } of results) {
locales[locale] = count;
total += count;
}
return {
total,
locales
};
}
async function countEntries(input, model) {
const reader = typeof input === "string" ? new LocalReader(input) : input;
const cDir = contentDirPath(model);
const strategy = resolveLocaleStrategy(model);
const files = await reader.listDirectory(cDir);
if (model.kind === "document") {
if (!model.i18n) return countDocumentNoneStrategy(reader, model.id, files, false);
switch (strategy) {
case "file": return countDocumentFileStrategy(reader, cDir, files);
case "suffix": return countDocumentSuffixStrategy(reader, cDir, files);
case "directory": return countDocumentDirectoryStrategy(reader, cDir, files);
case "none": return countDocumentNoneStrategy(reader, model.id, files, true);
}
}
if (model.kind === "collection") {
if (!model.i18n) return countCollectionEntries(reader, cDir, files.filter((f) => f.endsWith(".json")));
switch (strategy) {
case "suffix": {
const jsonFiles = files.filter((f) => f.endsWith(".json"));
const locales = {};
for (const f of jsonFiles) {
const match = f.match(/^.+\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
if (match) {
const data = await tryReadJsonViaReader(reader, `${cDir}/${f}`);
locales[match[1]] = data ? Object.keys(data).length : 0;
}
}
return {
total: Object.values(locales).reduce((a, b) => a + b, 0),
locales
};
}
case "directory": {
const locales = {};
let total = 0;
for (const localeDir of files) {
const jsonFile = (await reader.listDirectory(`${cDir}/${localeDir}`)).find((f) => f.endsWith(".json"));
if (jsonFile) {
const data = await tryReadJsonViaReader(reader, `${cDir}/${localeDir}/${jsonFile}`);
const count = data ? Object.keys(data).length : 0;
locales[localeDir] = count;
total += count;
}
}
return {
total,
locales
};
}
case "none": {
const noneFile = files.find((f) => f === `${model.id}.json`);
if (!noneFile) return {
total: 0,
locales: {}
};
const data = await tryReadJsonViaReader(reader, `${cDir}/${noneFile}`);
const count = data ? Object.keys(data).length : 0;
return {
total: count,
locales: { _: count }
};
}
default: return countCollectionEntries(reader, cDir, files.filter((f) => f.endsWith(".json")));
}
}
if (!model.i18n) return {
total: files.some((f) => f === "data.json") ? 1 : 0,
locales: {}
};
switch (strategy) {
case "suffix": {
const locales = {};
for (const f of files) {
const match = f.match(/^.+\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/);
if (match) locales[match[1]] = 1;
}
return {
total: Object.keys(locales).length,
locales
};
}
case "directory": {
const locales = {};
for (const localeDir of files) if ((await reader.listDirectory(`${cDir}/${localeDir}`)).some((f) => f.endsWith(".json"))) locales[localeDir] = 1;
return {
total: Object.keys(locales).length,
locales
};
}
case "none": return {
total: files.some((f) => f === `${model.id}.json`) ? 1 : 0,
locales: {}
};
default: {
const jsonFiles = files.filter((f) => f.endsWith(".json"));
const locales = {};
for (const file of jsonFiles) locales[file.replace(/\.json$/, "")] = 1;
return {
total: jsonFiles.length,
locales
};
}
}
}
const MODEL_FIELD_ORDER = [
"id",
"name",
"kind",
"domain",
"i18n",
"description",
"content_path",
"locale_strategy",
"fields"
];
async function writeModel(projectRoot, model) {
await writeJson(join(contentrainDir(projectRoot), "models", `${model.id}.json`), model, MODEL_FIELD_ORDER);
await ensureDir(resolveContentDir(projectRoot, model));
await ensureDir(join(contentrainDir(projectRoot), "meta", model.id));
}
async function deleteModel(projectRoot, modelId) {
const model = await readModel(projectRoot, modelId);
if (!model) return [];
const crDir = contentrainDir(projectRoot);
const removed = [];
const modelPath = join(crDir, "models", `${modelId}.json`);
const contentPath = resolveContentDir(projectRoot, model);
const metaPath = join(crDir, "meta", modelId);
await rm(modelPath, { force: true });
removed.push(`models/${modelId}.json`);
try {
await rm(contentPath, {
recursive: true,
force: true
});
removed.push(model.content_path ?? `content/${model.domain}/${modelId}/`);
} catch {}
try {
await rm(metaPath, {
recursive: true,
force: true
});
removed.push(`meta/${modelId}/`);
} catch {}
return removed;
}
async function checkReferences(input, modelId) {
const others = (typeof input === "string" ? await listModels(input) : await listModels(input)).filter((s) => s.id !== modelId);
const models = await Promise.all(others.map((s) => typeof input === "string" ? readModel(input, s.id) : readModel(input, s.id)));
const refs = [];
for (const model of models) {
if (!model?.fields) continue;
for (const [fieldName, fieldDef] of Object.entries(model.fields)) {
if (fieldDef.type !== "relation" && fieldDef.type !== "relations") continue;
if ((Array.isArray(fieldDef.model) ? fieldDef.model : [fieldDef.model]).includes(modelId)) refs.push({
model: model.id,
field: fieldName,
type: fieldDef.type
});
}
}
return refs;
}
const FIELD_TYPE_ENUM = [
"string",
"text",
"email",
"url",
"slug",
"color",
"phone",
"code",
"icon",
"markdown",
"richtext",
"number",
"integer",
"decimal",
"percent",
"rating",
"boolean",
"date",
"datetime",
"image",
"video",
"file",
"relation",
"relations",
"select",
"array",
"object"
];
/**
* Shared Zod schema for field definitions.
* Used by both model_save and normalize extract for full parity.
*/
const fieldDefZodSchema = z.record(z.string(), z.object({
type: z.enum(FIELD_TYPE_ENUM).describe("Field type from the 27-type catalog"),
required: z.boolean().optional(),
unique: z.boolean().optional(),
default: z.unknown().optional(),
min: z.number().optional(),
max: z.number().optional(),
pattern: z.string().optional(),
options: z.array(z.string()).optional(),
model: z.union([z.string(), z.array(z.string())]).optional(),
items: z.union([z.string(), z.lazy(() => z.record(z.string(), z.unknown()))]).optional(),
fields: z.lazy(() => z.record(z.string(), z.unknown())).optional(),
accept: z.string().optional(),
maxSize: z.number().optional(),
description: z.string().optional()
}).refine((f) => {
if ((f.type === "relation" || f.type === "relations") && !f.model) return false;
if (f.type === "select" && (!f.options || f.options.length === 0)) return false;
return true;
}, { message: "relation/relations requires \"model\", select requires non-empty \"options\"" }));
const VALID_FIELD_TYPES = new Set(FIELD_TYPE_ENUM);
/**
* Validate a model definition before writing.
* Returns array of error messages (empty = valid).
* Used by both model_save tool and normalize extract.
*/
function validateModelDefinition(input) {
const errors = [];
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.id)) errors.push(`Invalid model ID "${input.id}": must be kebab-case`);
if (input.kind === "dictionary" && input.fields && Object.keys(input.fields).length > 0) errors.push("Dictionary models cannot have fields. Dictionaries store flat key-value pairs.");
if (input.fields) for (const [fieldName, fieldDef] of Object.entries(input.fields)) {
const def = fieldDef;
if (!/^[a-z][a-z0-9_]*$/.test(fieldName)) errors.push(`Field "${fieldName}": invalid name — must be snake_case starting with letter`);
if (!def.type || !VALID_FIELD_TYPES.has(def.type)) errors.push(`Field "${fieldName}": invalid type "${def.type}"`);
if ((def.type === "relation" || def.type === "relations") && !def.model) errors.push(`Field "${fieldName}": ${def.type} type requires "model" property`);
if (def.type === "select" && (!def.options || !Array.isArray(def.options) || def.options.length === 0)) errors.push(`Field "${fieldName}": select type requires non-empty "options" array`);
}
return errors;
}
//#endregion
export { LocalReader as C, writeContent as S, resolveMdFilePath as _, fieldDefZodSchema as a, validateLocale$1 as b, validateModelDefinition as c, listContent as d, parseFrontmatter as f, resolveLocaleStrategy as g, resolveJsonFilePath as h, deleteModel as i, writeModel as l, resolveContentDir as m, checkReferences as n, listModels as o, readContent as p, countEntries as r, readModel as s, FIELD_TYPE_ENUM as t, deleteContent as u, serializeFrontmatter as v, validateSlug$1 as x, validateEntryId$1 as y };
//# sourceMappingURL=model-manager-SiesTJrS.mjs.map

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

import { t as readConfig } from "./config-oxxgznz7.mjs";
import { r as writeContext } from "./context-CrS-IvVm.mjs";
import { a as deleteRemoteBranch, l as authorConfig } from "./branch-lifecycle-BAfgSQBv.mjs";
import { t as branchTimestamp } from "./id-DV_T9Ic8.mjs";
import { join } from "node:path";
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
import { rm } from "node:fs/promises";
import { simpleGit } from "simple-git";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
//#region src/providers/local/migration.ts
/**
* Migration: the first MCP release used `contentrain/*` feature branches.
* Once we introduced the singleton `contentrain` branch (tracking the
* committed content state), those old feature branches became a ref-
* namespace conflict — git cannot hold both `contentrain` (a leaf ref)
* and `contentrain/foo` (implying a directory) simultaneously.
*
* `migrateLegacyBranches` removes the old-prefix branches so the
* singleton `contentrain` ref can be created. It is idempotent and
* safe to call before every `ensureContentBranch` run.
*
* Strategy:
* 1. Delete merged `contentrain/*` branches first (`-d`). Their commits
* are already on the base branch via the old auto-merge flow.
* 2. Force-delete whatever remains (`-D`). Any unmerged leftover is
* from an abandoned or partially-committed legacy branch — content
* on `main` always wins, and the singleton `contentrain` branch is
* about to be created from `main`/`baseBranch` anyway.
*
* Returns the number of branches that were deleted. Callers may log it;
* the git transaction layer does not need the count for correctness.
*/
async function migrateLegacyBranches(git, baseBranch) {
if ((await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/")).length === 0) return 0;
let deleted = 0;
let mergedLegacy = [];
try {
mergedLegacy = (await git.raw([
"branch",
"--merged",
baseBranch
])).split("\n").map((b) => b.trim().replace(/^\*\s*/, "")).filter((b) => b.startsWith("contentrain/"));
} catch {}
for (const b of mergedLegacy) try {
await git.raw([
"branch",
"-d",
b
]);
deleted++;
} catch {}
const remaining = (await git.branchLocal()).all.filter((b) => b.startsWith("contentrain/"));
for (const b of remaining) try {
await git.raw([
"branch",
"-D",
b
]);
deleted++;
} catch {}
return deleted;
}
//#endregion
//#region src/git/transaction.ts
async function ensureContentBranch(projectRoot) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
if ((await git.branchLocal()).all.includes(CONTENTRAIN_BRANCH)) return;
const baseBranch = config?.repository?.default_branch || (await git.raw(["branch", "--show-current"])).trim() || "main";
await migrateLegacyBranches(git, baseBranch);
await git.branch([CONTENTRAIN_BRANCH, baseBranch]);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
try {
if ((await git.getRemotes()).some((r) => r.name === remoteName)) await git.push([
"-u",
remoteName,
CONTENTRAIN_BRANCH
]);
} catch {}
}
async function selectiveSync(projectRoot, _worktreePath, contentrainTip, _previousBaseRef, dirtyFilesBeforeUpdate) {
const git = simpleGit(projectRoot);
const synced = [];
const skipped = [];
const compareRef = _previousBaseRef ?? contentrainTip;
let changedFiles = [];
try {
changedFiles = (await git.raw([
"diff-tree",
"--name-only",
"-r",
"--no-commit-id",
compareRef,
contentrainTip
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
try {
changedFiles = (await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
".contentrain/"
])).split("\n").filter((f) => f.trim().length > 0);
} catch {
return {
synced,
skipped
};
}
}
if (changedFiles.length === 0) return {
synced,
skipped
};
const dirtyFiles = dirtyFilesBeforeUpdate ?? /* @__PURE__ */ new Set();
const filesInTip = /* @__PURE__ */ new Set();
try {
const lsOutput = await git.raw([
"ls-tree",
"-r",
"--name-only",
contentrainTip,
"--",
...changedFiles
]);
for (const f of lsOutput.split("\n")) {
const trimmed = f.trim();
if (trimmed) filesInTip.add(trimmed);
}
} catch {
for (const file of changedFiles) try {
await git.raw([
"cat-file",
"-e",
`${contentrainTip}:${file}`
]);
filesInTip.add(file);
} catch {}
}
const toCheckout = [];
const toRemove = [];
for (const file of changedFiles) if (dirtyFiles.has(file)) skipped.push(file);
else if (filesInTip.has(file)) toCheckout.push(file);
else toRemove.push(file);
if (toCheckout.length > 0) try {
await git.checkout([
"HEAD",
"--",
...toCheckout
]);
synced.push(...toCheckout);
} catch {
for (const file of toCheckout) try {
await git.checkout([
"HEAD",
"--",
file
]);
synced.push(file);
} catch {
skipped.push(file);
}
}
await Promise.all(toRemove.map(async (file) => {
try {
await rm(join(projectRoot, file), { force: true });
synced.push(file);
} catch {
skipped.push(file);
}
}));
return {
synced,
skipped,
warning: skipped.length > 0 ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(", ")}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(" ")}` : void 0
};
}
async function createTransaction(projectRoot, branchName, options) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const workflow = options?.workflowOverride ?? config?.workflow ?? "auto-merge";
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
let baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? "";
let currentBranch = "";
let hasRemote = false;
const [branchResult, remotes] = await Promise.all([git.raw(["branch", "--show-current"]).catch(() => ""), git.getRemotes().catch(() => [])]);
currentBranch = branchResult.trim();
if (!baseBranch) baseBranch = currentBranch || "main";
hasRemote = remotes.some((r) => r.name === remoteName);
if (currentBranch === CONTENTRAIN_BRANCH) throw Object.assign(/* @__PURE__ */ new Error(`The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. Contentrain manages this branch automatically. Switch to your working branch and retry.`), {
code: "CONTENT_BRANCH_CHECKED_OUT",
agent_hint: "Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.",
developer_action: `git checkout ${baseBranch}`
});
await ensureContentBranch(projectRoot);
if (hasRemote) await Promise.all([git.fetch(remoteName, baseBranch).catch(() => {}), git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {})]);
const worktreePath = join(tmpdir(), `cr-${randomUUID()}`);
const branch = branchName;
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath, { config: authorConfig() });
try {
await wtGit.merge([baseBranch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${baseBranch}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
}
if (hasRemote) try {
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
}
await wtGit.checkout(["-b", branch]);
let commitHash = "";
let pendingReview = false;
let savedContextUpdate;
return {
worktree: worktreePath,
branch,
async write(callback) {
await callback(worktreePath);
},
async commit(message, contextUpdate) {
savedContextUpdate = contextUpdate;
await wtGit.add(".");
commitHash = (await wtGit.commit(message, {
"--allow-empty": null,
"--no-verify": null
})).commit || "";
return commitHash;
},
async complete() {
if (workflow === "review") {
if (hasRemote) await git.push(remoteName, branch);
pendingReview = true;
return {
action: "pending-review",
commit: commitHash
};
}
await wtGit.checkout(CONTENTRAIN_BRANCH);
try {
await wtGit.merge([branch, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branch}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`
});
}
if (savedContextUpdate) await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate);
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
return {
action: "auto-merged",
commit: commitHash,
sync,
...sync.warning ? { warning: sync.warning } : {}
};
},
async cleanup() {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
if (!pendingReview) await safeDeleteBranch(git, branch);
}
};
}
async function mergeBranch(projectRoot, branchName) {
const git = simpleGit(projectRoot);
const config = await readConfig(projectRoot);
const remoteName = process.env["CONTENTRAIN_REMOTE"] ?? "origin";
const baseBranch = process.env["CONTENTRAIN_BRANCH"] ?? config?.repository?.default_branch ?? ((await git.raw(["branch", "--show-current"])).trim() || "main");
await ensureContentBranch(projectRoot);
let hasRemote = false;
try {
hasRemote = (await git.getRemotes()).some((r) => r.name === remoteName);
} catch {
hasRemote = false;
}
const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`);
await git.raw([
"worktree",
"add",
worktreePath,
CONTENTRAIN_BRANCH
]);
const wtGit = simpleGit(worktreePath, { config: authorConfig() });
try {
try {
await wtGit.merge([branchName, "--no-edit"]);
} catch {
try {
await wtGit.merge(["--abort"]);
} catch {}
throw Object.assign(/* @__PURE__ */ new Error(`Merge conflict when merging branch "${branchName}" into "${CONTENTRAIN_BRANCH}". The branch still exists with your changes intact. Resolve the conflict manually, or delete the branch and retry.`), {
code: "CONTENT_BRANCH_MERGE_CONFLICT",
agent_hint: "The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.",
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`
});
}
await regenerateContextOnContentrain(wtGit, worktreePath, {
tool: "contentrain_merge",
model: "*"
});
const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([
wtGit.raw(["rev-parse", "HEAD"]).then((s) => s.trim()),
git.raw(["rev-parse", baseBranch]).then((s) => s.trim()),
git.status()
]);
const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map((f) => f.path));
if (!await isAncestor(git, previousBaseRef, contentrainTip)) throw Object.assign(/* @__PURE__ */ new Error(`Cannot fast-forward "${baseBranch}" to contentrain tip. The base branch has diverged. Merge "${baseBranch}" into "${CONTENTRAIN_BRANCH}" first.`), {
code: "BASE_UPDATE_FAILED",
agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,
developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`
});
await git.raw([
"update-ref",
`refs/heads/${baseBranch}`,
contentrainTip
]);
try {
await git.raw(["read-tree", "HEAD"]);
} catch {
try {
await git.raw(["reset", "HEAD"]);
} catch {}
}
const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate);
if (hasRemote) {
try {
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {
try {
await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH);
await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, "--no-edit"]);
await git.push(remoteName, CONTENTRAIN_BRANCH);
} catch {}
}
try {
await git.push(remoteName, baseBranch);
} catch {}
}
await safeDeleteBranch(git, branchName);
let remote;
if (hasRemote) remote = await deleteRemoteBranch(projectRoot, branchName, { config });
return {
action: "merged",
commit: contentrainTip,
sync,
...remote ? { remote } : {}
};
} finally {
try {
await git.raw([
"worktree",
"remove",
worktreePath,
"--force"
]);
} catch {}
}
}
function buildBranchName(scope, target, locale) {
const ts = branchTimestamp();
const parts = [
"cr",
scope,
target
];
if (locale) parts.push(locale);
parts.push(ts);
return parts.join("/");
}
/**
* True when `ancestor` is an ancestor of (or equal to) `descendant`.
* Implemented with `rev-list --count` because `merge-base --is-ancestor`
* signals via exit code with empty stderr — simple-git reports that as
* success, so it cannot express a negative verdict.
*/
async function isAncestor(git, ancestor, descendant) {
try {
return Number((await git.raw([
"rev-list",
"--count",
ancestor,
`^${descendant}`
])).trim()) === 0;
} catch {
return false;
}
}
/**
* Force-delete a local branch, swallowing all errors. Never deletes the
* singleton `contentrain` branch. Used to prune feature branches after they
* are merged (auto-merge / contentrain_merge) or when a transaction fails
* before completing — so failed/merged `cr/*` refs do not accumulate.
*/
async function safeDeleteBranch(git, branch) {
if (!branch || branch === CONTENTRAIN_BRANCH) return;
try {
await git.raw([
"branch",
"-D",
branch
]);
} catch {}
}
/**
* Regenerate `.contentrain/context.json` deterministically inside a worktree
* that is currently on the `contentrain` branch, then commit it (hooks
* bypassed). Called AFTER a feature branch is merged so context.json is only
* ever written on `contentrain`, single-threaded — eliminating the per-branch
* merge conflicts that came from committing it on every feature branch.
*/
async function regenerateContextOnContentrain(wtGit, worktreePath, contextUpdate) {
await writeContext(worktreePath, contextUpdate);
await wtGit.add(".contentrain/context.json");
try {
await wtGit.commit("[contentrain] context: update", { "--no-verify": null });
} catch {}
}
//#endregion
export { mergeBranch as i, createTransaction as n, ensureContentBranch as r, buildBranchName as t };
//# sourceMappingURL=transaction-BHOsiEn1.mjs.map
{"version":3,"file":"transaction-BHOsiEn1.mjs","names":["removeFile"],"sources":["../src/providers/local/migration.ts","../src/git/transaction.ts"],"sourcesContent":["import type { SimpleGit } from 'simple-git'\n\n/**\n * Migration: the first MCP release used `contentrain/*` feature branches.\n * Once we introduced the singleton `contentrain` branch (tracking the\n * committed content state), those old feature branches became a ref-\n * namespace conflict — git cannot hold both `contentrain` (a leaf ref)\n * and `contentrain/foo` (implying a directory) simultaneously.\n *\n * `migrateLegacyBranches` removes the old-prefix branches so the\n * singleton `contentrain` ref can be created. It is idempotent and\n * safe to call before every `ensureContentBranch` run.\n *\n * Strategy:\n * 1. Delete merged `contentrain/*` branches first (`-d`). Their commits\n * are already on the base branch via the old auto-merge flow.\n * 2. Force-delete whatever remains (`-D`). Any unmerged leftover is\n * from an abandoned or partially-committed legacy branch — content\n * on `main` always wins, and the singleton `contentrain` branch is\n * about to be created from `main`/`baseBranch` anyway.\n *\n * Returns the number of branches that were deleted. Callers may log it;\n * the git transaction layer does not need the count for correctness.\n */\nexport async function migrateLegacyBranches(\n git: SimpleGit,\n baseBranch: string,\n): Promise<number> {\n const branches = await git.branchLocal()\n const oldPrefixBranches = branches.all.filter(b => b.startsWith('contentrain/'))\n if (oldPrefixBranches.length === 0) return 0\n\n let deleted = 0\n\n // 1) Delete merged legacy branches first — the safe path.\n let mergedLegacy: string[] = []\n try {\n const mergedOutput = await git.raw(['branch', '--merged', baseBranch])\n mergedLegacy = mergedOutput.split('\\n')\n .map(b => b.trim().replace(/^\\*\\s*/, ''))\n .filter(b => b.startsWith('contentrain/'))\n } catch {\n // `branch --merged` fails before baseBranch exists — fall through.\n }\n\n for (const b of mergedLegacy) {\n try {\n await git.raw(['branch', '-d', b])\n deleted++\n } catch {\n // Branch may be protected or already gone — safe to skip.\n }\n }\n\n // 2) Force-delete any unmerged legacy branches still present.\n const remaining = (await git.branchLocal()).all.filter(b => b.startsWith('contentrain/'))\n for (const b of remaining) {\n try {\n await git.raw(['branch', '-D', b])\n deleted++\n } catch {\n // Skip — best-effort cleanup.\n }\n }\n\n return deleted\n}\n","import { simpleGit, type SimpleGit } from 'simple-git'\nimport { join } from 'node:path'\nimport { rm as removeFile } from 'node:fs/promises'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { readConfig } from '../core/config.js'\nimport { writeContext } from '../core/context.js'\nimport { deleteRemoteBranch, type RemoteDeleteResult } from './branch-lifecycle.js'\nimport { authorConfig } from './identity.js'\nimport { branchTimestamp } from '../util/id.js'\nimport { migrateLegacyBranches } from '../providers/local/migration.js'\nimport type { SyncResult, WorkflowMode } from '@contentrain/types'\nimport { CONTENTRAIN_BRANCH } from '@contentrain/types'\n\nexport interface ContextUpdate {\n tool: string\n model: string\n locale?: string\n entries?: string[]\n}\n\nexport interface GitTransaction {\n worktree: string\n branch: string\n write(callback: (worktreePath: string) => Promise<void>): Promise<void>\n commit(message: string, contextUpdate?: ContextUpdate): Promise<string>\n complete(): Promise<{ action: 'auto-merged' | 'pending-review'; commit: string; sync?: SyncResult; warning?: string }>\n cleanup(): Promise<void>\n}\n\nexport async function ensureContentBranch(projectRoot: string): Promise<void> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n\n // Check if contentrain branch exists locally\n const branches = await git.branchLocal()\n if (branches.all.includes(CONTENTRAIN_BRANCH)) return\n\n // Detect base branch\n const baseBranch = config?.repository?.default_branch\n || (await git.raw(['branch', '--show-current'])).trim()\n || 'main'\n\n // Clean up legacy `contentrain/*` feature branches so the singleton\n // `contentrain` ref can be created. Idempotent — safe to call even\n // when no legacy branches exist.\n await migrateLegacyBranches(git, baseBranch)\n\n // Create contentrain branch from base\n await git.branch([CONTENTRAIN_BRANCH, baseBranch])\n\n // Push to remote if exists\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n try {\n const remotes = await git.getRemotes()\n if (remotes.some(r => r.name === remoteName)) {\n await git.push(['-u', remoteName, CONTENTRAIN_BRANCH])\n }\n } catch {\n // Remote push is best-effort\n }\n}\n\nasync function selectiveSync(\n projectRoot: string,\n _worktreePath: string,\n contentrainTip: string,\n _previousBaseRef?: string,\n dirtyFilesBeforeUpdate?: Set<string>,\n): Promise<SyncResult> {\n const git = simpleGit(projectRoot)\n const synced: string[] = []\n const skipped: string[] = []\n\n // Use git plumbing to find ALL files that differ between old and new commits.\n // diff-tree is fast and ignores working tree / index state entirely.\n // Not limited to .contentrain/ — some ops also modify .gitignore, etc.\n const compareRef = _previousBaseRef ?? contentrainTip\n let changedFiles: string[] = []\n try {\n const diffOutput = await git.raw([\n 'diff-tree', '--name-only', '-r', '--no-commit-id',\n compareRef, contentrainTip,\n ])\n changedFiles = diffOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n // Fallback: list .contentrain/ files from the contentrainTip commit\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '.contentrain/'])\n changedFiles = lsOutput.split('\\n').filter(f => f.trim().length > 0)\n } catch {\n return { synced, skipped }\n }\n }\n\n if (changedFiles.length === 0) return { synced, skipped }\n\n // Use pre-captured dirty files (before update-ref) to avoid false positives.\n // After update-ref, files appear as \"modified\" in status even though the developer\n // didn't touch them. We use the pre-update state to know what was truly dirty.\n const dirtyFiles = dirtyFilesBeforeUpdate ?? new Set<string>()\n\n // Which changed files still exist in contentrainTip (HEAD after update-ref)?\n // ONE `ls-tree` over the changed paths lists exactly the survivors, instead\n // of a `cat-file -e` spawn per file. Falls back to per-file probing if\n // ls-tree fails so behavior is preserved on any edge.\n const filesInTip = new Set<string>()\n try {\n const lsOutput = await git.raw(['ls-tree', '-r', '--name-only', contentrainTip, '--', ...changedFiles])\n for (const f of lsOutput.split('\\n')) {\n const trimmed = f.trim()\n if (trimmed) filesInTip.add(trimmed)\n }\n } catch {\n for (const file of changedFiles) {\n try {\n await git.raw(['cat-file', '-e', `${contentrainTip}:${file}`])\n filesInTip.add(file)\n } catch {\n // File does not exist in tip (was deleted)\n }\n }\n }\n\n // Partition: dirty developer files are skipped; survivors get checked out\n // from HEAD; the rest were deleted in the new HEAD and are removed on disk.\n const toCheckout: string[] = []\n const toRemove: string[] = []\n for (const file of changedFiles) {\n if (dirtyFiles.has(file)) skipped.push(file)\n else if (filesInTip.has(file)) toCheckout.push(file)\n else toRemove.push(file)\n }\n\n // ONE `git checkout HEAD -- f1 f2 …` restores every clean survivor at once.\n // On failure, fall back to per-file so a single unresolvable path still\n // yields precise skip accounting (dirty files were already excluded).\n if (toCheckout.length > 0) {\n try {\n await git.checkout(['HEAD', '--', ...toCheckout])\n synced.push(...toCheckout)\n } catch {\n for (const file of toCheckout) {\n try {\n await git.checkout(['HEAD', '--', file])\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }\n }\n }\n\n // Deletions are working-tree fs removals — no git spawn, safe to parallelize.\n await Promise.all(toRemove.map(async (file) => {\n try {\n await removeFile(join(projectRoot, file), { force: true })\n synced.push(file)\n } catch {\n skipped.push(file)\n }\n }))\n\n const warning = skipped.length > 0\n ? `${skipped.length} file(s) skipped due to local changes: ${skipped.join(', ')}. Commit your changes, then run: git checkout HEAD -- ${skipped.join(' ')}`\n : undefined\n\n return { synced, skipped, warning }\n}\n\nexport async function createTransaction(\n projectRoot: string,\n branchName: string,\n options?: { workflowOverride?: WorkflowMode },\n): Promise<GitTransaction> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const workflow = options?.workflowOverride ?? config?.workflow ?? 'auto-merge'\n\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch + current branch + remote in ONE batch\n // (reduces subprocess spawns from 4 to 2)\n let baseBranch = process.env['CONTENTRAIN_BRANCH'] ?? config?.repository?.default_branch ?? ''\n let currentBranch = ''\n let hasRemote = false\n\n const [branchResult, remotes] = await Promise.all([\n git.raw(['branch', '--show-current']).catch(() => ''),\n git.getRemotes().catch(() => []),\n ])\n currentBranch = branchResult.trim()\n if (!baseBranch) baseBranch = currentBranch || 'main'\n hasRemote = (remotes as { name: string }[]).some(r => r.name === remoteName)\n\n // Check if developer is on contentrain branch\n if (currentBranch === CONTENTRAIN_BRANCH) {\n throw Object.assign(new Error(\n `The '${CONTENTRAIN_BRANCH}' branch is checked out in your working directory. `\n + `Contentrain manages this branch automatically. `\n + `Switch to your working branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_CHECKED_OUT',\n agent_hint: 'Ask the developer to switch to their working branch (e.g., main or a feature branch), then retry the operation.',\n developer_action: `git checkout ${baseBranch}`,\n })\n }\n\n // Ensure contentrain branch exists (with migration for old contentrain/* branches)\n await ensureContentBranch(projectRoot)\n\n // Fetch latest from remote (parallel fetch for both branches)\n if (hasRemote) {\n await Promise.all([\n git.fetch(remoteName, baseBranch).catch(() => {}),\n git.fetch(remoteName, CONTENTRAIN_BRANCH).catch(() => {}),\n ])\n }\n\n const worktreePath = join(tmpdir(), `cr-${randomUUID()}`)\n const branch = branchName\n\n // Create worktree on contentrain branch\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity comes from `-c user.*` config (see authorConfig) — passed\n // as args, never via `.env()`, so simple-git's block-unsafe guard is never\n // triggered by an inherited EDITOR/GIT_ASKPASS/etc.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n // Sync contentrain with base branch (bring main changes into contentrain)\n try {\n await wtGit.merge([baseBranch, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${baseBranch}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n }\n\n // Sync with remote contentrain if exists\n if (hasRemote) {\n try {\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* ignore */ }\n }\n }\n\n // Create feature branch from contentrain\n await wtGit.checkout(['-b', branch])\n\n let commitHash = ''\n let pendingReview = false\n let savedContextUpdate: ContextUpdate | undefined\n\n return {\n worktree: worktreePath,\n branch,\n\n async write(callback) {\n await callback(worktreePath)\n },\n\n async commit(message, contextUpdate?) {\n // context.json is intentionally NOT committed on the feature branch — it\n // is regenerated on the contentrain branch after the merge (see\n // complete()). Committing it per-branch caused cross-branch merge\n // conflicts on a single mutable file. `--no-verify` keeps the repo's\n // commit-msg / pre-commit hooks (commitlint, lefthook, husky) from\n // rejecting these machine-generated infra commits.\n savedContextUpdate = contextUpdate\n await wtGit.add('.')\n const result = await wtGit.commit(message, { '--allow-empty': null, '--no-verify': null })\n commitHash = result.commit || ''\n return commitHash\n },\n\n async complete() {\n if (workflow === 'review') {\n if (hasRemote) {\n await git.push(remoteName, branch)\n }\n // Pending-review branches must survive for a later contentrain_merge.\n pendingReview = true\n return { action: 'pending-review', commit: commitHash }\n }\n\n // auto-merge: merge feature branch into contentrain, then advance base\n\n // Switch to contentrain branch in worktree\n await wtGit.checkout(CONTENTRAIN_BRANCH)\n\n // Merge feature branch into contentrain\n try {\n await wtGit.merge([branch, '--no-edit'])\n } catch {\n try {\n await wtGit.merge(['--abort'])\n } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branch}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branch}`,\n })\n }\n\n // Regenerate context.json on the contentrain branch (post-merge,\n // single-threaded) and fold it into the tip before advancing the base.\n if (savedContextUpdate) {\n await regenerateContextOnContentrain(wtGit, worktreePath, savedContextUpdate)\n }\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip\n // (guaranteed by the merge above, but verify for safety).\n // `rev-list --count` instead of `merge-base --is-ancestor`: the latter\n // signals via exit code with empty stderr, which simple-git reports as\n // success — the guard would silently pass on divergence.\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD.\n // update-ref moves the branch pointer but leaves the index stale.\n // read-tree updates the index to match HEAD without touching the working tree.\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n // fallback: try reset for older git versions\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort with retry)\n if (hasRemote) {\n // Push contentrain branch\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Retry: fetch, merge, push\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n // Push base branch\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n return {\n action: 'auto-merged' as const,\n commit: commitHash,\n sync,\n ...(sync.warning ? { warning: sync.warning } : {}),\n }\n },\n\n async cleanup() {\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n // Prune the feature branch unless it is a pending-review branch that must\n // survive for a later contentrain_merge. Auto-merged branches (already in\n // contentrain) and failed/empty branches are both safe to delete, so\n // failed saves and merged saves no longer leak dangling cr/* refs.\n if (!pendingReview) {\n await safeDeleteBranch(git, branch)\n }\n },\n }\n}\n\nexport async function mergeBranch(\n projectRoot: string,\n branchName: string,\n): Promise<{ action: 'merged'; commit: string; sync: SyncResult; remote?: RemoteDeleteResult }> {\n const git = simpleGit(projectRoot)\n const config = await readConfig(projectRoot)\n const remoteName = process.env['CONTENTRAIN_REMOTE'] ?? 'origin'\n\n // Detect base branch\n const baseBranch = process.env['CONTENTRAIN_BRANCH']\n ?? config?.repository?.default_branch\n ?? ((await git.raw(['branch', '--show-current'])).trim() || 'main')\n\n // Ensure contentrain branch exists\n await ensureContentBranch(projectRoot)\n\n // Check remote\n let hasRemote = false\n try {\n const remotes = await git.getRemotes()\n hasRemote = remotes.some(r => r.name === remoteName)\n } catch {\n hasRemote = false\n }\n\n // Create temp worktree on contentrain branch\n const worktreePath = join(tmpdir(), `cr-merge-${randomUUID()}`)\n await git.raw(['worktree', 'add', worktreePath, CONTENTRAIN_BRANCH])\n\n // Commit identity from `-c user.*` config (see authorConfig) — guard-safe,\n // no `.env()` spread.\n const wtGit = simpleGit(worktreePath, { config: authorConfig() })\n\n try {\n // Merge the feature branch into contentrain\n try {\n await wtGit.merge([branchName, '--no-edit'])\n } catch {\n try { await wtGit.merge(['--abort']) } catch { /* not in merge state */ }\n throw Object.assign(new Error(\n `Merge conflict when merging branch \"${branchName}\" into \"${CONTENTRAIN_BRANCH}\". `\n + `The branch still exists with your changes intact. `\n + `Resolve the conflict manually, or delete the branch and retry.`,\n ), {\n code: 'CONTENT_BRANCH_MERGE_CONFLICT',\n agent_hint: 'The feature branch could not be merged into the contentrain branch. Ask the developer to resolve the conflict.',\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${branchName}`,\n })\n }\n\n // Regenerate context.json on contentrain post-merge (deterministic,\n // single-threaded) so review-mode branches — which carry no context.json —\n // still produce up-to-date stats once landed.\n await regenerateContextOnContentrain(wtGit, worktreePath, { tool: 'contentrain_merge', model: '*' })\n\n // Get contentrain tip + old base ref + dirty files in parallel\n const [contentrainTip, previousBaseRef, statusBeforeUpdate] = await Promise.all([\n wtGit.raw(['rev-parse', 'HEAD']).then(s => s.trim()),\n git.raw(['rev-parse', baseBranch]).then(s => s.trim()),\n git.status(),\n ])\n const dirtyFilesBeforeUpdate = new Set(statusBeforeUpdate.files.map(f => f.path))\n\n // Verify fast-forward: baseBranch must be an ancestor of contentrainTip.\n // (See complete() — merge-base --is-ancestor is unusable via simple-git.)\n if (!(await isAncestor(git, previousBaseRef, contentrainTip))) {\n throw Object.assign(new Error(\n `Cannot fast-forward \"${baseBranch}\" to contentrain tip. `\n + `The base branch has diverged. Merge \"${baseBranch}\" into \"${CONTENTRAIN_BRANCH}\" first.`,\n ), {\n code: 'BASE_UPDATE_FAILED',\n agent_hint: `The base branch has commits not in contentrain. Merge ${baseBranch} into ${CONTENTRAIN_BRANCH} first.`,\n developer_action: `git checkout ${CONTENTRAIN_BRANCH} && git merge ${baseBranch} && git checkout ${baseBranch}`,\n })\n }\n\n // Advance base branch to contentrain tip via update-ref\n await git.raw(['update-ref', `refs/heads/${baseBranch}`, contentrainTip])\n\n // Refresh index to match new HEAD\n try {\n await git.raw(['read-tree', 'HEAD'])\n } catch {\n try { await git.raw(['reset', 'HEAD']) } catch { /* ignore */ }\n }\n\n // Selective sync: copy .contentrain/ files to developer's working tree\n const sync = await selectiveSync(projectRoot, worktreePath, contentrainTip, previousBaseRef, dirtyFilesBeforeUpdate)\n\n // Push to remote (best-effort)\n if (hasRemote) {\n try {\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n try {\n await wtGit.fetch(remoteName, CONTENTRAIN_BRANCH)\n await wtGit.merge([`${remoteName}/${CONTENTRAIN_BRANCH}`, '--no-edit'])\n await git.push(remoteName, CONTENTRAIN_BRANCH)\n } catch {\n // Push failed after retry — continue, local state is fine\n }\n }\n\n try {\n await git.push(remoteName, baseBranch)\n } catch {\n // push may fail, local merge succeeded\n }\n }\n\n // Prune the now-merged feature branch so merged cr/* refs don't accumulate.\n await safeDeleteBranch(git, branchName)\n\n // Delete the remote copy too (review-mode branches were pushed on save).\n // Best-effort and config-gated inside the helper: a failure surfaces as\n // `remote.warning`, never as a failed merge.\n let remote: RemoteDeleteResult | undefined\n if (hasRemote) {\n remote = await deleteRemoteBranch(projectRoot, branchName, { config })\n }\n\n return {\n action: 'merged' as const,\n commit: contentrainTip,\n sync,\n ...(remote ? { remote } : {}),\n }\n } finally {\n // Cleanup worktree\n try {\n await git.raw(['worktree', 'remove', worktreePath, '--force'])\n } catch {\n // worktree may already be cleaned up\n }\n }\n}\n\nexport function buildBranchName(scope: string, target: string, locale?: string): string {\n const ts = branchTimestamp()\n const parts = ['cr', scope, target]\n if (locale) parts.push(locale)\n parts.push(ts)\n return parts.join('/')\n}\n\n/**\n * True when `ancestor` is an ancestor of (or equal to) `descendant`.\n * Implemented with `rev-list --count` because `merge-base --is-ancestor`\n * signals via exit code with empty stderr — simple-git reports that as\n * success, so it cannot express a negative verdict.\n */\nasync function isAncestor(git: SimpleGit, ancestor: string, descendant: string): Promise<boolean> {\n try {\n const count = Number((await git.raw(['rev-list', '--count', ancestor, `^${descendant}`])).trim())\n return count === 0\n } catch {\n return false\n }\n}\n\n/**\n * Force-delete a local branch, swallowing all errors. Never deletes the\n * singleton `contentrain` branch. Used to prune feature branches after they\n * are merged (auto-merge / contentrain_merge) or when a transaction fails\n * before completing — so failed/merged `cr/*` refs do not accumulate.\n */\nasync function safeDeleteBranch(git: SimpleGit, branch: string): Promise<void> {\n if (!branch || branch === CONTENTRAIN_BRANCH) return\n try {\n await git.raw(['branch', '-D', branch])\n } catch {\n // Branch may not exist, be checked out, or already be deleted — ignore.\n }\n}\n\n/**\n * Regenerate `.contentrain/context.json` deterministically inside a worktree\n * that is currently on the `contentrain` branch, then commit it (hooks\n * bypassed). Called AFTER a feature branch is merged so context.json is only\n * ever written on `contentrain`, single-threaded — eliminating the per-branch\n * merge conflicts that came from committing it on every feature branch.\n */\nasync function regenerateContextOnContentrain(\n wtGit: SimpleGit,\n worktreePath: string,\n contextUpdate: ContextUpdate,\n): Promise<void> {\n await writeContext(worktreePath, contextUpdate)\n await wtGit.add('.contentrain/context.json')\n try {\n await wtGit.commit('[contentrain] context: update', { '--no-verify': null })\n } catch {\n // Nothing staged (context.json unchanged) — fine.\n }\n}\n"],"mappings":"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"}
import { i as metaFilePath, n as contentFilePath, r as documentFilePath, t as contentDirPath } from "./paths-CmVw5Cw2.mjs";
import { c as writeText, s as writeJson } from "./fs-DLbVB-Ek.mjs";
import { i as writeMeta } from "./meta-manager-CJUiTgP2.mjs";
import { t as readConfig } from "./config-oxxgznz7.mjs";
import { C as LocalReader, f as parseFrontmatter, g as resolveLocaleStrategy, o as listModels, s as readModel } from "./model-manager-SiesTJrS.mjs";
import { join } from "node:path";
import { detectSecrets, validateFieldValue } from "@contentrain/types";
import { rm } from "node:fs/promises";
//#region src/core/validator/entry.ts
/**
* Validate a single content entry against its model's field schema.
*
* Merges the rule sets from MCP's legacy `validator.ts` (secret detection,
* schema validation, unique constraints) with Studio's `content-validation.ts`
* (email/url heuristics, polymorphic relation structure, nested object and
* array-of-object recursion). The union is the authoritative per-entry
* validator — both MCP's project validator and Studio's save path should
* converge on this function over time.
*
* Asynchronous relation-integrity checks (does the referenced entry exist?)
* live in `relation-integrity.ts` because they require I/O.
*/
function validateContent(data, fields, modelId, locale, entryId, ctx) {
const errors = [];
for (const [fieldId, def] of Object.entries(fields)) {
const value = data[fieldId];
errors.push(...validateField(value, def, modelId, locale, entryId, fieldId, ctx));
}
return {
valid: errors.filter((e) => e.severity === "error").length === 0,
errors
};
}
function validateField(value, def, modelId, locale, entryId, fieldId, ctx) {
const errors = [];
const errCtx = {
model: modelId,
locale,
entry: entryId,
field: fieldId
};
if (value !== null && value !== void 0 && value !== "") {
const secretErrors = detectSecrets(value);
for (const e of secretErrors) errors.push({
...e,
...errCtx
});
}
if (!(def.type === "relation" || def.type === "relations")) {
const fieldErrors = validateFieldValue(value, def);
if (fieldErrors.length > 0) {
for (const e of fieldErrors) errors.push({
...e,
...errCtx
});
if (fieldErrors.some((e) => e.severity === "error")) return errors;
}
} else if (def.required && (value === null || value === void 0 || value === "")) {
errors.push({
severity: "error",
...errCtx,
message: `${fieldId} is required`
});
return errors;
}
if (value === null || value === void 0) return errors;
if (def.unique && ctx?.allEntries) {
const valueKey = String(value);
for (const [otherId, otherEntry] of Object.entries(ctx.allEntries)) {
if (otherId === ctx.currentEntryId) continue;
const otherValue = otherEntry[fieldId];
if (otherValue !== null && otherValue !== void 0 && String(otherValue) === valueKey) {
errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be unique — "${String(value)}" already exists in entry ${otherId}`
});
break;
}
}
}
if (def.type === "email" && typeof value === "string" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) errors.push({
severity: "warning",
...errCtx,
message: `${fieldId} may not be a valid email`
});
if (def.type === "url" && typeof value === "string" && !/^https?:\/\/.+/.test(value) && !value.startsWith("/")) errors.push({
severity: "warning",
...errCtx,
message: `${fieldId} may not be a valid URL`
});
if (def.type === "relation" && def.model) {
const targets = Array.isArray(def.model) ? def.model : [def.model];
if (targets.length > 1) if (typeof value !== "object" || value === null || !("model" in value) || !("ref" in value)) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be { model, ref } for polymorphic relation`
});
else {
const polyVal = value;
if (!targets.includes(polyVal.model)) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} target model "${polyVal.model}" must be one of: ${targets.join(", ")}`
});
}
else if (typeof value !== "string") errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be a string (entry ID or slug)`
});
}
if (def.type === "relations") if (!Array.isArray(value)) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must be an array`
});
else {
if (def.min !== void 0 && value.length < def.min) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must have at least ${def.min} items`
});
if (def.max !== void 0 && value.length > def.max) errors.push({
severity: "error",
...errCtx,
message: `${fieldId} must have at most ${def.max} items`
});
const targets = Array.isArray(def.model) ? def.model : def.model ? [def.model] : [];
const polymorphic = targets.length > 1;
for (let i = 0; i < value.length; i++) {
const item = value[i];
const itemCtx = {
...errCtx,
field: `${fieldId}[${i}]`
};
if (polymorphic) if (typeof item !== "object" || item === null || !("model" in item) || !("ref" in item)) errors.push({
severity: "error",
...itemCtx,
message: `${fieldId}[${i}] must be { model, ref } for polymorphic relations`
});
else {
const polyItem = item;
if (!targets.includes(polyItem.model)) errors.push({
severity: "error",
...itemCtx,
message: `${fieldId}[${i}] target model "${polyItem.model}" must be one of: ${targets.join(", ")}`
});
}
else if (typeof item !== "string") errors.push({
severity: "error",
...itemCtx,
message: `${fieldId}[${i}] must be a string (entry ID or slug)`
});
}
}
if (def.type === "array" && Array.isArray(value) && def.items && typeof def.items === "string") for (let i = 0; i < value.length; i++) errors.push(...validateArrayItemType(value[i], def.items, errCtx, `${fieldId}[${i}]`));
if (def.type === "object" && def.fields && typeof value === "object" && value !== null && !Array.isArray(value)) {
const nested = validateContent(value, def.fields, modelId, locale, entryId);
errors.push(...nested.errors);
}
if (def.type === "array" && Array.isArray(value) && def.items && typeof def.items === "object" && def.items.type === "object" && def.items.fields) {
for (let i = 0; i < value.length; i++) if (typeof value[i] === "object" && value[i] !== null) {
const nested = validateContent(value[i], def.items.fields, modelId, locale, entryId);
for (const e of nested.errors) errors.push({
...e,
field: `${fieldId}[${i}].${e.field}`
});
}
}
return errors;
}
function validateArrayItemType(value, itemType, errCtx, fieldPath) {
const ctx = {
...errCtx,
field: fieldPath
};
switch (itemType) {
case "string":
case "email":
case "url":
case "slug":
case "image":
case "video":
case "file":
if (typeof value !== "string") return [{
severity: "error",
...ctx,
message: `${fieldPath} must be a string`
}];
break;
case "number":
case "integer":
case "decimal":
if (typeof value !== "number") return [{
severity: "error",
...ctx,
message: `${fieldPath} must be a number`
}];
if (itemType === "integer" && !Number.isInteger(value)) return [{
severity: "error",
...ctx,
message: `${fieldPath} must be an integer`
}];
break;
case "boolean":
if (typeof value !== "boolean") return [{
severity: "error",
...ctx,
message: `${fieldPath} must be a boolean`
}];
break;
}
return [];
}
//#endregion
//#region src/core/validator/relation-integrity.ts
/**
* Verify that relation and relations fields reference targets that actually
* exist. Two severities are supported — Studio's per-save flow emits
* `warning` (the referenced entry may still be drafted), while MCP's
* project-wide validator emits `error` and additionally flags missing
* target models via `resolveTarget`.
*
* The loader abstraction keeps this function I/O-agnostic: MCP wires it
* to filesystem reads through LocalReader, Studio wires it to a
* `GitProvider.readFile`-backed loader, mocks pass in-memory maps.
*/
async function checkRelationIntegrity(data, fields, modelId, locale, entryId, loadContent, opts = {}) {
const errors = [];
const severity = opts.severity ?? "warning";
const resolve = opts.resolveTarget ?? (async (id, loc) => {
return {
exists: true,
content: await loadContent(id, loc)
};
});
for (const [fieldId, def] of Object.entries(fields)) {
const value = data[fieldId];
if (value === null || value === void 0) continue;
if (def.type === "relation" && def.model) {
const targets = Array.isArray(def.model) ? def.model : [def.model];
if (targets.length > 1 && typeof value === "object" && value !== null) {
const polyVal = value;
if (polyVal.model && polyVal.ref) {
const resolved = await resolve(polyVal.model, locale);
if (!resolved.exists) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: target model "${polyVal.model}" not found`
});
else if (resolved.content && !(polyVal.ref in resolved.content)) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: "${polyVal.ref}" not found in ${polyVal.model}`
});
}
} else if (typeof value === "string" && targets[0]) {
const resolved = await resolve(targets[0], locale);
if (!resolved.exists) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: target model "${targets[0]}" not found`
});
else if (resolved.content && !(value in resolved.content)) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: "${value}" not found in ${targets[0]}`
});
}
}
if (def.type === "relations" && def.model && Array.isArray(value)) {
const target = Array.isArray(def.model) ? def.model[0] : def.model;
if (target) {
const resolved = await resolve(target, locale);
if (!resolved.exists) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: target model "${target}" not found`
});
else if (resolved.content) {
for (const ref of value) if (typeof ref === "string" && !(ref in resolved.content)) errors.push({
severity,
model: modelId,
locale,
entry: entryId,
field: fieldId,
message: `Broken relation: "${ref}" not found in ${target}`
});
}
}
}
}
return errors;
}
//#endregion
//#region src/core/validator/schedule.ts
/**
* Validate `publish_at` and `expire_at` meta fields.
*
* Rules (matches legacy `validator.ts:validateScheduleFields`):
* - `publish_at` must parse as a valid Date
* - `expire_at` must parse as a valid Date
* - When both are present, `expire_at` must be strictly after `publish_at`
*/
function validateScheduleFields(meta, ctx, issues) {
if (meta.publish_at !== void 0) {
const d = new Date(meta.publish_at);
if (Number.isNaN(d.getTime())) issues.push({
severity: "error",
...ctx,
message: `Invalid publish_at date: "${meta.publish_at}". Must be a valid ISO 8601 date string.`
});
}
if (meta.expire_at !== void 0) {
const d = new Date(meta.expire_at);
if (Number.isNaN(d.getTime())) issues.push({
severity: "error",
...ctx,
message: `Invalid expire_at date: "${meta.expire_at}". Must be a valid ISO 8601 date string.`
});
}
if (meta.publish_at !== void 0 && meta.expire_at !== void 0) {
const pubDate = new Date(meta.publish_at);
const expDate = new Date(meta.expire_at);
if (!Number.isNaN(pubDate.getTime()) && !Number.isNaN(expDate.getTime()) && expDate <= pubDate) issues.push({
severity: "error",
...ctx,
message: `expire_at ("${meta.expire_at}") must be after publish_at ("${meta.publish_at}").`
});
}
}
//#endregion
//#region src/core/validator/project.ts
async function readJsonViaReader(reader, path) {
try {
return JSON.parse(await reader.readFile(path));
} catch {
return null;
}
}
async function readTextViaReader(reader, path) {
try {
return await reader.readFile(path);
} catch {
return null;
}
}
/**
* Build a `resolveTarget` adapter for `checkRelationIntegrity` that walks
* the project's content store via the shared {@link RepoReader}. Mirrors
* the target-resolution shape the legacy `checkRelation` used — collection
* targets return their entry object-map, documents return a "slug exists"
* marker map, singletons and dictionaries return null content so the
* checker skips key enforcement for them.
*/
function buildProjectTargetResolver(reader, config) {
return async (targetModelId, targetLocale) => {
const targetModel = await readModel(reader, targetModelId);
if (!targetModel) return { exists: false };
if (targetModel.kind === "document") {
const slugs = await discoverDocumentSlugs(reader, contentDirPath(targetModel), targetModel);
return {
exists: true,
content: Object.fromEntries(slugs.map((s) => [s, true]))
};
}
if (targetModel.kind === "singleton" || targetModel.kind === "dictionary") return {
exists: true,
content: null
};
const merged = {};
const primary = await readJsonViaReader(reader, contentFilePath(targetModel, targetLocale));
if (primary) Object.assign(merged, primary);
if (targetModel.i18n && targetLocale !== config.locales.default) {
const fallback = await readJsonViaReader(reader, contentFilePath(targetModel, config.locales.default));
if (fallback) Object.assign(merged, fallback);
}
return {
exists: true,
content: merged
};
};
}
/**
* Scan an entry's data fields for detected secrets in UNDECLARED keys —
* the legacy validator also flagged stray/rogue fields that were not in
* `model.fields`. `validateContent` only knows about declared fields, so
* this complementary pass preserves that coverage.
*/
function scanUndeclaredFieldsForSecrets(data, declared, ctx, issues) {
for (const [fieldName, value] of Object.entries(data)) {
if (declared && fieldName in declared) continue;
if (detectSecrets(value).length > 0) issues.push({
severity: "error",
...ctx,
field: fieldName,
message: `Potential secret detected in field "${fieldName}"`
});
}
}
async function validateCollectionModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
const locales = model.i18n ? config.locales.supported : [config.locales.default];
const localeEntryIds = {};
const allEntryIds = /* @__PURE__ */ new Set();
const resolveTarget = buildProjectTargetResolver(reader, config);
for (const locale of locales) {
const filePath = contentFilePath(model, locale);
const data = await readJsonViaReader(reader, filePath);
if (!data) {
if (model.i18n) {
issues.push({
severity: "error",
model: model.id,
locale,
message: `Locale file missing: ${locale}.json`
});
if (fix && projectRoot) {
await writeJson(join(projectRoot, filePath), {});
fixed++;
}
}
continue;
}
const ids = new Set(Object.keys(data));
localeEntryIds[locale] = ids;
for (const id of ids) allEntryIds.add(id);
const keys = Object.keys(data);
const sorted = [...keys].toSorted();
if (keys.join(",") !== sorted.join(",")) {
issues.push({
severity: "warning",
model: model.id,
locale,
message: "Content file keys not in canonical order"
});
if (fix && projectRoot) {
const resorted = {};
for (const key of sorted) resorted[key] = data[key];
await writeJson(join(projectRoot, filePath), resorted);
fixed++;
}
}
for (const [entryId, fields] of Object.entries(data)) {
entriesChecked++;
scanUndeclaredFieldsForSecrets(fields, model.fields, {
model: model.id,
locale,
entry: entryId
}, issues);
if (!model.fields) continue;
const entryResult = validateContent(fields, model.fields, model.id, locale, entryId, {
allEntries: data,
currentEntryId: entryId
});
issues.push(...entryResult.errors);
const relationErrors = await checkRelationIntegrity(fields, model.fields, model.id, locale, entryId, async () => null, {
severity: "error",
resolveTarget
});
issues.push(...relationErrors);
}
}
if (model.i18n && Object.keys(localeEntryIds).length > 1) {
const localeKeys = Object.keys(localeEntryIds);
for (let i = 1; i < localeKeys.length; i++) {
const locA = localeKeys[0];
const locB = localeKeys[i];
const idsA = localeEntryIds[locA];
const idsB = localeEntryIds[locB];
for (const id of idsA) if (!idsB.has(id)) issues.push({
severity: "error",
model: model.id,
locale: locB,
entry: id,
message: `Entry parity: entry "${id}" exists in ${locA} but missing in ${locB}`
});
for (const id of idsB) if (!idsA.has(id)) issues.push({
severity: "error",
model: model.id,
locale: locA,
entry: id,
message: `Entry parity: entry "${id}" exists in ${locB} but missing in ${locA}`
});
}
}
for (const locale of locales) {
const metaRelPath = metaFilePath(model, locale, config.locales.default);
const metaData = await readJsonViaReader(reader, metaRelPath);
const contentData = await readJsonViaReader(reader, contentFilePath(model, locale)) ?? {};
if (metaData) for (const metaEntryId of Object.keys(metaData)) {
validateScheduleFields(metaData[metaEntryId], {
model: model.id,
locale,
entry: metaEntryId
}, issues);
if (!(metaEntryId in contentData)) {
issues.push({
severity: "warning",
model: model.id,
locale,
entry: metaEntryId,
message: `Orphan meta: meta entry "${metaEntryId}" exists but content entry missing`
});
if (fix && projectRoot) {
delete metaData[metaEntryId];
const metaAbs = join(projectRoot, metaRelPath);
if (Object.keys(metaData).length > 0) await writeJson(metaAbs, metaData);
else await rm(metaAbs, { force: true });
fixed++;
}
}
}
for (const entryId of Object.keys(contentData)) if (!metaData || !(entryId in metaData)) {
issues.push({
severity: "warning",
model: model.id,
locale,
entry: entryId,
message: `Orphan content: entry "${entryId}" has no metadata`
});
if (fix && projectRoot) {
await writeMeta(projectRoot, model, {
locale,
entryId,
defaultLocale: config.locales.default
}, {
status: "draft",
source: "import",
updated_by: "contentrain-mcp"
});
fixed++;
}
}
if (metaData) {
const entries = Object.entries(metaData);
const draftIds = entries.filter(([, m]) => m.status === "draft").map(([id]) => id);
const publishedCount = entries.filter(([, m]) => m.status === "published").length;
if (publishedCount > 0 && draftIds.length > 0) issues.push({
severity: "notice",
model: model.id,
locale,
message: `Publish-state drift: ${draftIds.length} draft entr${draftIds.length === 1 ? "y" : "ies"} alongside ${publishedCount} published in the same collection — [${draftIds.join(", ")}]. If these were published before, restore them with contentrain_bulk update_status.`
});
}
}
await checkStrayNonI18nMeta(reader, model, config, issues);
return {
entries: entriesChecked,
fixed
};
}
/**
* Flag meta files a non-i18n model should not have.
*
* Such a model keeps all content in one `data.json` and therefore exactly one
* meta record, at the default locale. Earlier writes derived the meta path from
* the caller's locale, so saving under a non-default locale left a second meta
* file — and readers disagreed about which was authoritative. Reported rather
* than auto-removed: the stray may hold the only `published` status in the
* project, so deleting it silently could unpublish content.
*/
async function checkStrayNonI18nMeta(reader, model, config, issues) {
if (model.i18n) return;
const expected = `${config.locales.default}.json`;
let files;
try {
files = await reader.listDirectory(`.contentrain/meta/${model.id}`);
} catch {
return;
}
const strays = files.filter((f) => f.endsWith(".json") && f !== expected);
if (strays.length === 0) return;
issues.push({
severity: "warning",
model: model.id,
message: `Meta layout mismatch: "${model.id}" has i18n disabled, so its content lives in a single data.json and its meta belongs at ${expected} alone — but [${strays.join(", ")}] also exist. Readers may disagree about which file is authoritative. Merge any status you want to keep into ${expected}, then remove the extras.`
});
}
async function validateSingletonModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
for (const locale of model.i18n ? config.locales.supported : [config.locales.default]) {
const filePath = contentFilePath(model, locale);
const data = await readJsonViaReader(reader, filePath);
if (!data) {
if (model.i18n) {
issues.push({
severity: "error",
model: model.id,
locale,
message: `Locale file missing: ${locale}.json`
});
if (fix && projectRoot) {
await writeJson(join(projectRoot, filePath), {});
fixed++;
}
}
continue;
}
entriesChecked++;
scanUndeclaredFieldsForSecrets(data, model.fields, {
model: model.id,
locale
}, issues);
if (model.fields) {
const entryResult = validateContent(data, model.fields, model.id, locale);
issues.push(...entryResult.errors);
const resolveTarget = buildProjectTargetResolver(reader, config);
const relationErrors = await checkRelationIntegrity(data, model.fields, model.id, locale, void 0, async () => null, {
severity: "error",
resolveTarget
});
issues.push(...relationErrors);
}
const keys = Object.keys(data);
const sorted = [...keys].toSorted();
if (keys.join(",") !== sorted.join(",")) {
issues.push({
severity: "warning",
model: model.id,
locale,
message: "Content file keys not in canonical order"
});
if (fix && projectRoot) {
const resorted = {};
for (const key of sorted) resorted[key] = data[key];
await writeJson(join(projectRoot, filePath), resorted);
fixed++;
}
}
const singletonMetaData = await readJsonViaReader(reader, metaFilePath(model, locale, config.locales.default));
if (singletonMetaData) validateScheduleFields(singletonMetaData, {
model: model.id,
locale
}, issues);
}
return {
entries: entriesChecked,
fixed
};
}
async function validateDictionaryModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
const localeKeys = {};
for (const locale of model.i18n ? config.locales.supported : [config.locales.default]) {
const filePath = contentFilePath(model, locale);
const data = await readJsonViaReader(reader, filePath);
if (!data) {
if (model.i18n) {
issues.push({
severity: "error",
model: model.id,
locale,
message: `Locale file missing: ${locale}.json`
});
if (fix && projectRoot) {
await writeJson(join(projectRoot, filePath), {});
fixed++;
}
}
continue;
}
entriesChecked++;
localeKeys[locale] = new Set(Object.keys(data));
for (const [key, value] of Object.entries(data)) if (detectSecrets(value).length > 0) issues.push({
severity: "error",
model: model.id,
locale,
field: key,
message: `Potential secret detected in key "${key}"`
});
const valueToKeys = /* @__PURE__ */ new Map();
for (const [key, value] of Object.entries(data)) {
const arr = valueToKeys.get(value);
if (arr) arr.push(key);
else valueToKeys.set(value, [key]);
}
for (const [value, dupeKeys] of valueToKeys) if (dupeKeys.length > 1) {
const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
issues.push({
severity: "warning",
model: model.id,
locale,
message: `Duplicate value "${truncated}" mapped to ${dupeKeys.length} keys: [${dupeKeys.join(", ")}]`
});
}
const keys = Object.keys(data);
const sorted = [...keys].toSorted();
if (keys.join(",") !== sorted.join(",")) {
issues.push({
severity: "warning",
model: model.id,
locale,
message: "Content file keys not in canonical order"
});
if (fix && projectRoot) {
const resorted = {};
for (const key of sorted) resorted[key] = data[key];
await writeJson(join(projectRoot, filePath), resorted);
fixed++;
}
}
}
if (model.i18n && Object.keys(localeKeys).length > 1) {
const localeNames = Object.keys(localeKeys);
for (let i = 1; i < localeNames.length; i++) {
const locA = localeNames[0];
const locB = localeNames[i];
const keysA = localeKeys[locA];
const keysB = localeKeys[locB];
for (const k of keysA) if (!keysB.has(k)) issues.push({
severity: "warning",
model: model.id,
locale: locB,
field: k,
message: `Key parity: key "${k}" exists in ${locA} but missing in ${locB}`
});
for (const k of keysB) if (!keysA.has(k)) issues.push({
severity: "warning",
model: model.id,
locale: locA,
field: k,
message: `Key parity: key "${k}" exists in ${locB} but missing in ${locA}`
});
}
}
return {
entries: entriesChecked,
fixed
};
}
async function discoverDocumentSlugs(reader, cDir, model) {
const strategy = resolveLocaleStrategy(model);
const entries = await reader.listDirectory(cDir);
if (!model.i18n) return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
if (strategy === "file") return entries.filter((e) => !e.startsWith("."));
if (strategy === "suffix") {
const slugs = /* @__PURE__ */ new Set();
for (const f of entries) {
if (!f.endsWith(".md")) continue;
const parts = f.replace(/\.md$/, "").split(".");
if (parts.length >= 2) {
parts.pop();
slugs.add(parts.join("."));
}
}
return [...slugs];
}
if (strategy === "directory") {
const slugs = /* @__PURE__ */ new Set();
const localeLists = await Promise.all(entries.filter((localeDir) => !localeDir.startsWith(".")).map((localeDir) => reader.listDirectory(`${cDir}/${localeDir}`)));
for (const files of localeLists) for (const f of files) if (f.endsWith(".md")) slugs.add(f.replace(/\.md$/, ""));
return [...slugs];
}
return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
}
async function validateDocumentModel(reader, projectRoot, model, config, issues, fix) {
let entriesChecked = 0;
let fixed = 0;
const cDir = contentDirPath(model);
if (!await reader.fileExists(cDir)) return {
entries: 0,
fixed: 0
};
const slugs = await discoverDocumentSlugs(reader, cDir, model);
const locales = model.i18n ? config.locales.supported : [config.locales.default];
for (const slug of slugs) {
if (slug.startsWith(".")) continue;
for (const locale of locales) {
const filePath = documentFilePath(model, locale, slug);
const raw = await readTextViaReader(reader, filePath);
if (!raw) {
if (model.i18n) {
issues.push({
severity: "warning",
model: model.id,
locale,
slug,
message: `Missing translation: document "${slug}" missing ${locale} locale file`
});
if (fix && projectRoot) {
const template = `---\nslug: ${slug}\n---\n`;
await writeText(join(projectRoot, filePath), template);
fixed++;
}
}
continue;
}
entriesChecked++;
const { frontmatter, body } = parseFrontmatter(raw);
scanUndeclaredFieldsForSecrets(frontmatter, {
...model.fields,
body: true
}, {
model: model.id,
locale,
slug
}, issues);
if (detectSecrets(body).length > 0) issues.push({
severity: "error",
model: model.id,
locale,
slug,
field: "body",
message: "Potential secret detected in document body"
});
if (model.fields) {
const fieldsWithoutBody = Object.fromEntries(Object.entries(model.fields).filter(([name]) => name !== "body"));
const entryResult = validateContent(frontmatter, fieldsWithoutBody, model.id, locale);
for (const err of entryResult.errors) issues.push({
...err,
slug
});
const resolveTarget = buildProjectTargetResolver(reader, config);
const relationErrors = await checkRelationIntegrity(frontmatter, fieldsWithoutBody, model.id, locale, void 0, async () => null, {
severity: "error",
resolveTarget
});
for (const err of relationErrors) issues.push({
...err,
slug
});
}
}
}
return {
entries: entriesChecked,
fixed
};
}
async function checkOrphanContent(reader, validModelIds, issues, _fix) {
const fixed = 0;
const contentBase = ".contentrain/content";
const domains = await reader.listDirectory(contentBase);
const modelLists = await Promise.all(domains.filter((d) => !d.startsWith(".")).map(async (d) => ({
domain: d,
dirs: await reader.listDirectory(`${contentBase}/${d}`)
})));
for (const { dirs } of modelLists) for (const modelDir of dirs) {
if (modelDir.startsWith(".")) continue;
if (!validModelIds.has(modelDir)) issues.push({
severity: "warning",
model: modelDir,
message: `Orphan content: content directory exists for deleted model "${modelDir}"`
});
}
return fixed;
}
async function validateProject(input, options) {
const reader = typeof input === "string" ? new LocalReader(input) : input;
const projectRoot = typeof input === "string" ? input : void 0;
const fix = Boolean(options?.fix) && projectRoot !== void 0;
const issues = [];
let totalEntries = 0;
let totalFixed = 0;
let modelsChecked = 0;
const config = await readConfig(reader);
if (!config) return {
valid: false,
summary: {
errors: 1,
warnings: 0,
notices: 0,
models_checked: 0,
entries_checked: 0
},
issues: [{
severity: "error",
message: "Project not initialized: config.json missing"
}],
fixed: 0
};
const modelSummaries = await listModels(reader);
const validModelIds = new Set(modelSummaries.map((m) => m.id));
const modelsToCheck = options?.model ? modelSummaries.filter((m) => m.id === options.model) : modelSummaries;
for (const summary of modelsToCheck) {
const model = await readModel(reader, summary.id);
if (!model) continue;
modelsChecked++;
let result;
switch (model.kind) {
case "collection":
result = await validateCollectionModel(reader, projectRoot, model, config, issues, fix);
break;
case "singleton":
result = await validateSingletonModel(reader, projectRoot, model, config, issues, fix);
break;
case "dictionary":
result = await validateDictionaryModel(reader, projectRoot, model, config, issues, fix);
break;
case "document":
result = await validateDocumentModel(reader, projectRoot, model, config, issues, fix);
break;
default: result = {
entries: 0,
fixed: 0
};
}
totalEntries += result.entries;
totalFixed += result.fixed;
}
if (!options?.model) totalFixed += await checkOrphanContent(reader, validModelIds, issues, fix);
if (!options?.model) {
const dictModels = modelsToCheck.filter((m) => m.kind === "dictionary");
if (dictModels.length > 1) {
const globalValueMap = {};
for (const summary of dictModels) {
const model = await readModel(reader, summary.id);
if (!model) continue;
for (const locale of model.i18n ? config.locales.supported : [config.locales.default]) {
if (!globalValueMap[locale]) globalValueMap[locale] = /* @__PURE__ */ new Map();
const data = await readJsonViaReader(reader, contentFilePath(model, locale));
if (!data) continue;
for (const [key, value] of Object.entries(data)) {
const refs = globalValueMap[locale].get(value);
if (refs) refs.push({
model: model.id,
key
});
else globalValueMap[locale].set(value, [{
model: model.id,
key
}]);
}
}
}
for (const [locale, valueMap] of Object.entries(globalValueMap)) for (const [value, refs] of valueMap) if (new Set(refs.map((r) => r.model)).size > 1) {
const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
issues.push({
severity: "notice",
locale,
message: `Cross-model duplicate value "${truncated}" in ${refs.map((r) => `${r.model}/${r.key}`).join(", ")}`
});
}
}
}
const errors = issues.filter((i) => i.severity === "error").length;
const warnings = issues.filter((i) => i.severity === "warning").length;
const notices = issues.filter((i) => i.severity === "notice").length;
return {
valid: errors === 0,
summary: {
errors,
warnings,
notices,
models_checked: modelsChecked,
entries_checked: totalEntries
},
issues,
fixed: totalFixed
};
}
//#endregion
export { validateContent as i, validateScheduleFields as n, checkRelationIntegrity as r, validateProject as t };
//# sourceMappingURL=validator-Ck0puppQ.mjs.map

Sorry, the diff of this file is too big to display