@contentrain/mcp 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -20
- package/dist/{apply-manager-Cf5OWOJ7.mjs → apply-manager-BkPMfpsN.mjs} +8 -3
- package/dist/apply-manager-BkPMfpsN.mjs.map +1 -0
- package/dist/{astro-parser-RofKmKQi.mjs → astro-parser-BJ-54JGe.mjs} +3 -3
- package/dist/{astro-parser-RofKmKQi.mjs.map → astro-parser-BJ-54JGe.mjs.map} +1 -1
- package/dist/core/apply-manager.d.mts.map +1 -1
- package/dist/core/apply-manager.mjs +2 -2
- package/dist/core/scanner.d.mts +3 -9
- package/dist/core/scanner.d.mts.map +1 -1
- package/dist/core/scanner.mjs +3 -3
- package/dist/git/transaction.d.mts +6 -1
- package/dist/git/transaction.d.mts.map +1 -1
- package/dist/git/transaction.mjs +2 -2
- package/dist/index.mjs +5 -5
- package/dist/{scanner-DPrbU-oK.mjs → scanner-CQOxARUU.mjs} +275 -308
- package/dist/scanner-CQOxARUU.mjs.map +1 -0
- package/dist/{server-BXguCuJb.mjs → server-DfWRpzdP.mjs} +172 -20
- package/dist/server-DfWRpzdP.mjs.map +1 -0
- package/dist/server.mjs +5 -5
- package/dist/{svelte-parser-COTNG92w.mjs → svelte-parser-1bBcMx3T.mjs} +16 -6
- package/dist/{svelte-parser-COTNG92w.mjs.map → svelte-parser-1bBcMx3T.mjs.map} +1 -1
- package/dist/{transaction-B0V0_SE2.mjs → transaction-C_Pvux78.mjs} +102 -2
- package/dist/{transaction-B0V0_SE2.mjs.map → transaction-C_Pvux78.mjs.map} +1 -1
- package/dist/tsx-parser-BFLv292Y.mjs +2 -0
- package/dist/{tsx-parser-CYVyBWjn.mjs → tsx-parser-BWiMGAIR.mjs} +3 -2
- package/dist/tsx-parser-BWiMGAIR.mjs.map +1 -0
- package/dist/{vue-parser-CjS4YAoQ.mjs → vue-parser-DTPTN3Yb.mjs} +16 -5
- package/dist/{vue-parser-CjS4YAoQ.mjs.map → vue-parser-DTPTN3Yb.mjs.map} +1 -1
- package/package.json +2 -2
- package/dist/apply-manager-Cf5OWOJ7.mjs.map +0 -1
- package/dist/scanner-DPrbU-oK.mjs.map +0 -1
- package/dist/server-BXguCuJb.mjs.map +0 -1
- package/dist/tsx-parser-Bf8Firrq.mjs +0 -2
- package/dist/tsx-parser-CYVyBWjn.mjs.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scanner-CQOxARUU.mjs","names":[],"sources":["../src/core/ast-scanner/pre-filter.ts","../src/core/ast-scanner/index.ts","../src/core/scanner.ts"],"sourcesContent":["import type { ExtractedString } from './types.js'\n\n// ─── Pre-filter Result ───\n\nexport interface PreFilterResult {\n /** Strings that passed the pre-filter (candidates for agent) */\n candidates: ExtractedString[]\n /** Total number of strings removed by shouldSkip */\n skipped: number\n /** Total number of strings removed by low content score */\n lowConfidence: number\n /** Breakdown: skip reason → count */\n skipReasons: Record<string, number>\n}\n\n// ─── Value-based regexes ───\n\nconst PURE_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\nconst HEX_COLOR_RE = /^#[0-9a-f]{3,8}$/i\nconst FILE_EXT_RE = /\\.(png|jpg|jpeg|gif|svg|webp|ico|css|scss|less|js|ts|tsx|jsx|json|md|html|xml|yaml|yml|woff|woff2|ttf|eot|mp4|webm|mp3|wav|pdf)$/i\nconst SVG_PATH_DATA_RE = /^[Mm][\\d\\s.,LHVCSQTAZlhvcsqtazmMzZ-]+$/\nconst SVG_VIEWBOX_RE = /^\\d+(\\.\\d+)?\\s+\\d+(\\.\\d+)?\\s+\\d+(\\.\\d+)?\\s+\\d+(\\.\\d+)?$/\nconst I18N_KEY_RE = /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$/\nconst TECHNICAL_IDENTIFIER_RE = /^[a-z][a-z0-9_-]*$/\nconst ERROR_CODE_RE = /^[A-Z][A-Z0-9_]+$/\nconst PLACEHOLDER_RE = /^\\{\\d+\\}$|^\\.{2,}$/\nconst CAMEL_CASE_RE = /^[a-z]+[A-Z]/\n\n// ─── URL / path detection (consolidated from legacy isNonContent) ───\n\nfunction isURLLike(str: string): boolean {\n if (/^(https?|ftp|file|mailto|data):/.test(str)) return true\n if (/^(\\.\\.?\\/|\\/|[A-Za-z]:\\\\)/.test(str)) return true\n if (/^['\"]?[@a-z][\\w-]*/.test(str.toLowerCase()) && !str.includes(' ') && (str.includes('/') || str.includes('.'))) {\n return true\n }\n return false\n}\n\n// ─── CSS / Tailwind detection ───\n\nconst TAILWIND_SEGMENT_RE = /^(?:bg-|text-|border-|flex|grid|p-|px-|py-|pt-|pb-|pl-|pr-|m-|mx-|my-|mt-|mb-|ml-|mr-|rounded|shadow|w-|h-|min-|max-|gap-|space-|items-|justify-|self-|overflow-|z-|opacity-|transition|duration-|ease-|animate-|font-|leading-|tracking-|decoration-|underline|line-through|uppercase|lowercase|capitalize|truncate|whitespace-|break-|sr-only|not-sr-only|hover:|focus:|active:|disabled:|dark:|sm:|md:|lg:|xl:|2xl:|group-|peer-|ring-|outline-|divide-|table-|col-|row-|aspect-|object-|inset-|top-|right-|bottom-|left-|translate-|rotate-|scale-|skew-|origin-|cursor-|select-|resize-|fill-|stroke-|block|inline|absolute|relative|fixed|sticky|static|float-|clear-|isolate|visible|invisible|grow|shrink|basis-|order-|place-)/\n\nfunction isCssClassList(value: string): boolean {\n const segments = value.trim().split(/\\s+/)\n if (segments.length < 2) return false\n let matched = 0\n for (const seg of segments) {\n if (TAILWIND_SEGMENT_RE.test(seg)) matched++\n }\n return matched / segments.length >= 0.5\n}\n\nfunction isSingleCssUtility(value: string): boolean {\n const trimmed = value.trim()\n if (trimmed.includes(' ')) return false\n return TAILWIND_SEGMENT_RE.test(trimmed)\n}\n\n// ─── SVG technical attributes ───\n\nconst SVG_TECHNICAL_ATTRIBUTES = new Set([\n 'd', 'viewBox', 'points', 'transform', 'pathLength',\n 'xmlns', 'preserveAspectRatio',\n 'stroke-linecap', 'stroke-linejoin', 'stroke-width',\n 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit',\n 'fill-rule', 'clip-rule',\n])\n\nconst SVG_GRAPHIC_ELEMENTS = new Set([\n 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon',\n 'ellipse', 'g', 'defs', 'use', 'symbol', 'clipPath', 'mask',\n 'pattern', 'linearGradient', 'radialGradient', 'stop',\n 'marker', 'animate', 'animateTransform', 'image',\n])\n\n// ─── Known function names ───\n\nconst I18N_FUNCTIONS = new Set([\n 't', '$t', 'i18n', 'translate', 'formatMessage', 'msg',\n])\n\nconst EMIT_FUNCTIONS = new Set([\n 'emit', '$emit',\n])\n\n// ─── Translatable attribute whitelist (i18next-cli compatible + extended) ───\n\nconst TRANSLATABLE_ATTRIBUTES = new Set([\n // Standard HTML content attributes\n 'title', 'alt', 'placeholder', 'label', 'summary', 'caption',\n 'abbr', 'accesskey', 'content', 'description',\n // ARIA content\n 'aria-label', 'aria-description', 'aria-placeholder',\n 'aria-roledescription', 'aria-valuetext',\n // React Native accessibility (equivalent to aria-label)\n 'accessibilityLabel', 'accessibilityHint', 'accessibilityValue',\n // Common component content props\n 'heading', 'subheading', 'message', 'hint', 'tooltip',\n 'helper-text', 'error-message', 'success-message',\n 'confirm-text', 'cancel-text', 'empty-text', 'loading-text',\n 'no-data-text', 'no-results-text',\n])\n\n// ─── Translatable object property whitelist (i18next-cli compatible) ───\n\nconst TRANSLATABLE_PROPERTIES = new Set([\n 'label', 'title', 'description', 'text', 'message', 'placeholder',\n 'caption', 'summary', 'heading', 'subheading', 'subtitle', 'tooltip',\n 'hint', 'helpText', 'errorMessage', 'successMessage', 'name',\n])\n\n// ─── shouldSkip: Binary non-content detection ───\n\n/**\n * Determines if a string is definitely NOT user-visible content.\n * Returns skip reason if it should be filtered, null if it should proceed to scoring.\n *\n * Conservative for template_text/jsx_text (tag-between text is almost always content).\n * Aggressive for everything else (technical tokens, config values, framework artifacts).\n */\nexport function shouldSkip(str: ExtractedString): string | null {\n // ── Context-based rules (AST-determined, 100% accurate) ──\n\n if (str.context === 'import_path') return 'import_path'\n if (str.context === 'type_annotation') return 'type_annotation'\n if (str.context === 'css_class') return 'css_class'\n if (str.context === 'css_utility_call') return 'css_utility_call'\n if (str.context === 'console_call') return 'console_call'\n if (str.context === 'test_assertion') return 'test_assertion'\n if (str.context === 'switch_case') return 'switch_case'\n\n const v = str.value\n\n // ── Value-based rules (structural patterns) ──\n\n if (v.length <= 1) return 'single_char'\n if (/^\\s+$/.test(v)) return 'whitespace'\n if (PURE_NUMBER_RE.test(v)) return 'pure_number'\n if (HEX_COLOR_RE.test(v)) return 'hex_color'\n if (FILE_EXT_RE.test(v)) return 'file_extension'\n if (v.startsWith('--')) return 'cli_flag'\n\n // ── i18n key paths (checked before URL — both contain dots, but i18n keys are more specific) ──\n\n if (I18N_KEY_RE.test(v)) return 'i18n_key'\n\n // ── URL/path patterns ──\n\n if (isURLLike(v)) return 'url_path'\n\n // ── CSS patterns ──\n\n if (isCssClassList(v)) return 'css_class_list'\n if (isSingleCssUtility(v)) return 'css_utility_token'\n\n // ── SVG patterns ──\n\n if (v.length > 3 && SVG_PATH_DATA_RE.test(v)) return 'svg_path_data'\n if (SVG_VIEWBOX_RE.test(v)) return 'svg_viewbox'\n if (str.parentProperty !== undefined && SVG_TECHNICAL_ATTRIBUTES.has(str.parentProperty)) return 'svg_technical_attr'\n if (str.context === 'template_attribute' && SVG_GRAPHIC_ELEMENTS.has(str.parent)) return 'svg_element_attr'\n\n // ── Framework event patterns ──\n\n if (v.startsWith('update:')) return 'vue_emit_event'\n\n // ── Placeholder / interpolation ──\n\n if (PLACEHOLDER_RE.test(v)) return 'placeholder'\n\n // ── Known function argument detection ──\n\n if (str.context === 'function_argument' && I18N_FUNCTIONS.has(str.parent)) {\n // i18n function args: filter lowercase identifiers (namespace/key), keep sentences\n if (/^[a-z][a-z0-9_.-]*$/.test(v)) return 'i18n_function_arg'\n }\n\n if (str.context === 'function_argument' && EMIT_FUNCTIONS.has(str.parent)) {\n return 'emit_event_arg'\n }\n\n // ── CRITICAL: Technical identifier detection (i18next-cli proven pattern) ──\n // Single lowercase ASCII word/kebab-case/snake_case < 30 chars → technical token\n // EXEMPT: template_text and jsx_text (tag-between text IS content, even lowercase)\n\n if (str.context !== 'template_text' && str.context !== 'jsx_text') {\n if (TECHNICAL_IDENTIFIER_RE.test(v) && v.length < 30) {\n return 'technical_identifier'\n }\n }\n\n // ── Error codes (SCREAMING_SNAKE_CASE with underscores) ──\n\n if (ERROR_CODE_RE.test(v) && v.includes('_') && v.length > 3) {\n return 'error_code'\n }\n\n return null\n}\n\n// ─── calculateContentScore: 0-1 confidence scoring ───\n\n/**\n * Calculates a content confidence score (0-1) for a string that passed shouldSkip.\n * Uses AST context metadata (our advantage over offset-based tools) combined with\n * value-based signals proven by i18next-cli.\n *\n * Base score: 0.5. Boosted/penalized by context and value characteristics.\n */\nexport function calculateContentScore(str: ExtractedString): number {\n let score = 0.5\n\n // ── Context signals (AST metadata advantage) ──\n\n // Template/JSX text = almost certainly user-visible content\n if (str.context === 'template_text' || str.context === 'jsx_text') {\n score += 0.3\n }\n\n // Content-bearing attribute (title, alt, placeholder, aria-label, etc.)\n if (str.context === 'template_attribute' || str.context === 'jsx_attribute') {\n if (str.parentProperty && TRANSLATABLE_ATTRIBUTES.has(str.parentProperty)) {\n score += 0.2\n } else {\n score -= 0.2 // Unknown/technical attribute\n }\n }\n\n // Content-bearing object property (message, label, description, etc.)\n if (str.context === 'object_property') {\n if (str.parentProperty && TRANSLATABLE_PROPERTIES.has(str.parentProperty)) {\n score += 0.25\n }\n }\n\n // ── Value signals (i18next-cli proven heuristics) ──\n\n // Multi-word strings are more likely content\n const wordCount = str.value.split(/\\s+/).length\n if (wordCount >= 3) score += 0.2\n else if (wordCount === 2) score += 0.1\n\n // Terminal punctuation suggests a sentence\n if (/[.!?:;]$/.test(str.value)) score += 0.1\n\n // Non-ASCII characters (Turkish, Chinese, Arabic, etc.) → almost certainly content\n if (/[\\u0080-\\uFFFF]/.test(str.value)) score += 0.15\n\n // Capitalized first letter with lowercase body (Dashboard, Kaydet, Settings)\n if (/^[A-Z]/.test(str.value) && /[a-z]/.test(str.value)) score += 0.1\n\n // camelCase → probably a technical identifier\n if (CAMEL_CASE_RE.test(str.value)) score -= 0.3\n\n // Contains slash without spaces → path-like\n if (str.value.includes('/') && !str.value.includes(' ')) score -= 0.2\n\n return Math.max(0, Math.min(1, score))\n}\n\n// ─── Public API ───\n\n/**\n * Two-phase pre-filter:\n * 1. shouldSkip(): Binary removal of definite non-content\n * 2. calculateContentScore(): 0-1 confidence scoring for ambiguous strings\n *\n * Returns candidates that passed both phases, with content scores attached.\n */\nexport function applyPreFilter(\n strings: ExtractedString[],\n minScore: number = 0.4,\n): PreFilterResult {\n const candidates: ExtractedString[] = []\n const skipReasons: Record<string, number> = {}\n let skipped = 0\n let lowConfidence = 0\n\n for (const str of strings) {\n // Phase 1: Binary skip\n const skipReason = shouldSkip(str)\n if (skipReason) {\n skipped++\n skipReasons[skipReason] = (skipReasons[skipReason] ?? 0) + 1\n continue\n }\n\n // Phase 2: Content scoring\n const contentScore = calculateContentScore(str)\n if (contentScore < minScore) {\n lowConfidence++\n skipReasons['low_confidence'] = (skipReasons['low_confidence'] ?? 0) + 1\n continue\n }\n\n // Attach score to the extraction for downstream use\n ;(str as ExtractedString & { contentScore: number }).contentScore = contentScore\n candidates.push(str)\n }\n\n return { candidates, skipped, lowConfidence, skipReasons }\n}\n","import type { ExtractedString } from './types.js'\nimport { applyPreFilter } from './pre-filter.js'\nimport { parseTsx } from './tsx-parser.js'\n\n// ─── Extension sets ───\n\nconst TSX_EXTENSIONS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs'])\nconst VUE_EXTENSIONS = new Set(['.vue'])\nconst SVELTE_EXTENSIONS = new Set(['.svelte'])\nconst ASTRO_EXTENSIONS = new Set(['.astro'])\n\n// ─── Lazy-loaded parsers ───\n\n/**\n * Lazily import vue-parser. Returns undefined if not available\n * (@vue/compiler-sfc is an optional dependency).\n */\nasync function loadVueParser(): Promise<((content: string, fileName: string) => Promise<ExtractedString[]> | ExtractedString[]) | undefined> {\n try {\n const mod = await import('./vue-parser.js')\n return mod.parseVue\n } catch {\n return undefined\n }\n}\n\n/**\n * Lazily import svelte-parser. Returns undefined if not available\n * (svelte is an optional dependency).\n */\nasync function loadSvelteParser(): Promise<((content: string, fileName: string) => Promise<ExtractedString[]>) | undefined> {\n try {\n const mod = await import('./svelte-parser.js')\n return mod.parseSvelte\n } catch {\n return undefined\n }\n}\n\n/**\n * Lazily import astro-parser. Returns undefined if not available\n * (@astrojs/compiler is an optional dependency).\n */\nasync function loadAstroParser(): Promise<((content: string, fileName: string) => Promise<ExtractedString[]>) | undefined> {\n try {\n const mod = await import('./astro-parser.js')\n return mod.parseAstro\n } catch {\n return undefined\n }\n}\n\n// ─── Regex fallback for unknown extensions ───\n\nconst SURROUNDING_MAX = 120\n\n/**\n * Minimal regex-based extractor for file types without AST parsers.\n * Extracts quoted strings and tag text — conservative, low accuracy.\n * Used as fallback when dedicated parsers are unavailable.\n */\nfunction extractWithRegex(content: string, _filePath: string): ExtractedString[] {\n const lines = content.split('\\n')\n const results: ExtractedString[] = []\n\n // Simple quoted string extraction\n const stringRe = /(['\"`])(?:(?!\\1|\\\\).|\\\\.)*?\\1/g\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i]!\n const trimmed = line.trim()\n\n // Skip comments, imports\n if (trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) continue\n if (/^\\s*(import|export)\\s/.test(line)) continue\n\n let match: RegExpExecArray | null\n stringRe.lastIndex = 0\n\n while ((match = stringRe.exec(line)) !== null) {\n // Remove surrounding quotes\n const raw = match[0]\n const value = raw.slice(1, -1)\n if (value.length === 0) continue\n\n const surrounding = buildSurrounding(lines, i)\n\n results.push({\n value,\n line: i + 1,\n column: match.index + 1,\n context: 'other',\n scope: 'script',\n parent: '',\n surrounding,\n })\n }\n\n // Tag text extraction: >text<\n const tagTextRe = />([^<>{]+)</g\n let tagMatch: RegExpExecArray | null\n tagTextRe.lastIndex = 0\n\n while ((tagMatch = tagTextRe.exec(line)) !== null) {\n const text = tagMatch[1]!.trim()\n if (text.length === 0) continue\n if (/^[\\s\\W]*$/.test(text) && !/[a-zA-Z]/.test(text)) continue\n\n results.push({\n value: text,\n line: i + 1,\n column: tagMatch.index + 1,\n context: 'template_text',\n scope: 'template',\n parent: '',\n surrounding: buildSurrounding(lines, i),\n })\n }\n }\n\n return results\n}\n\nfunction buildSurrounding(lines: string[], lineIdx: number): string {\n const start = Math.max(0, lineIdx - 1)\n const end = Math.min(lines.length - 1, lineIdx + 1)\n\n const parts: string[] = []\n for (let i = start; i <= end; i++) {\n const line = lines[i]\n if (line !== undefined) {\n parts.push(line)\n }\n }\n\n const joined = parts.join('\\n')\n if (joined.length > SURROUNDING_MAX) {\n return joined.slice(0, SURROUNDING_MAX)\n }\n return joined\n}\n\n// ─── Public API ───\n\n/**\n * Extract strings from a source file with structural context.\n *\n * Routes to the correct parser based on file extension:\n * - .tsx/.jsx/.ts/.js/.mjs -> tsx-parser (AST-based)\n * - .vue -> vue-parser (lazy-loaded, AST-based)\n * - .svelte -> svelte-parser (lazy-loaded, AST-based)\n * - .astro -> astro-parser (lazy-loaded, AST-based)\n * - unknown -> empty array\n *\n * Falls back to regex extraction when the dedicated parser's\n * optional dependency is not installed.\n *\n * Applies structural pre-filter to remove 100% non-content strings.\n * Returns only candidates that should be sent to the agent.\n */\nexport async function extractStrings(\n filePath: string,\n content: string,\n ext: string,\n): Promise<ExtractedString[]> {\n const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`\n let rawStrings: ExtractedString[]\n\n if (TSX_EXTENSIONS.has(normalizedExt)) {\n rawStrings = parseTsx(content, filePath)\n } else if (VUE_EXTENSIONS.has(normalizedExt)) {\n const parseVue = await loadVueParser()\n if (parseVue) {\n rawStrings = await parseVue(content, filePath)\n } else {\n rawStrings = extractWithRegex(content, filePath)\n }\n } else if (SVELTE_EXTENSIONS.has(normalizedExt)) {\n const parseSvelte = await loadSvelteParser()\n if (parseSvelte) {\n try {\n rawStrings = await parseSvelte(content, filePath)\n } catch {\n // svelte/compiler not installed — fall back to regex\n rawStrings = extractWithRegex(content, filePath)\n }\n } else {\n rawStrings = extractWithRegex(content, filePath)\n }\n } else if (ASTRO_EXTENSIONS.has(normalizedExt)) {\n const parseAstro = await loadAstroParser()\n if (parseAstro) {\n try {\n rawStrings = await parseAstro(content, filePath)\n } catch {\n // @astrojs/compiler not installed — fall back to regex\n rawStrings = extractWithRegex(content, filePath)\n }\n } else {\n rawStrings = extractWithRegex(content, filePath)\n }\n } else {\n return []\n }\n\n // Apply structural pre-filter\n const { candidates } = applyPreFilter(rawStrings)\n return candidates\n}\n\n// Re-export types for convenience\nexport type { ExtractedString, StructuralContext, PreFilterRule } from './types.js'\nexport { applyPreFilter, shouldSkip, calculateContentScore } from './pre-filter.js'\nexport type { PreFilterResult } from './pre-filter.js'\n","import type {\n StringContext,\n ScanCandidate,\n DuplicateGroup,\n ScanCandidatesResult,\n ScanSummaryResult,\n} from '@contentrain/types'\nimport { join, extname } from 'node:path'\nimport { readText } from '../util/fs.js'\nimport {\n autoDetectSourceDirs,\n discoverFiles,\n} from './scan-config.js'\nimport { extractStrings } from './ast-scanner/index.js'\nimport { calculateContentScore } from './ast-scanner/pre-filter.js'\nimport type { StructuralContext } from './ast-scanner/types.js'\n\n// ─── Options ───\n\nexport interface ScanOptions {\n paths?: string[]\n include?: string[]\n exclude?: string[]\n limit?: number\n offset?: number\n min_length?: number\n max_length?: number\n /** Minimum content confidence score (0-1). Default: 0.4 */\n min_score?: number\n}\n\n// ─── Constants ───\n\nconst DEFAULT_LIMIT = 50\nconst DEFAULT_OFFSET = 0\nconst DEFAULT_MIN_LENGTH = 2\nconst DEFAULT_MAX_LENGTH = 500\nconst DEFAULT_MIN_SCORE = 0.4\nconst SUMMARY_SAMPLE_SIZE = 10\n\n// ─── Context mapping: StructuralContext → StringContext ───\n\nconst CONTEXT_MAP: Record<StructuralContext, StringContext> = {\n 'template_text': 'template_text',\n 'template_attribute': 'template_attribute',\n 'jsx_text': 'jsx_text',\n 'jsx_attribute': 'jsx_attribute',\n 'variable_assignment': 'variable_assignment',\n 'object_property': 'object_value',\n 'function_argument': 'function_argument',\n 'array_element': 'other',\n 'enum_value': 'other',\n 'template_literal': 'other',\n 'switch_case': 'other',\n 'other': 'other',\n // Pre-filtered contexts should not reach here, but map them just in case\n 'import_path': 'other',\n 'type_annotation': 'other',\n 'css_class': 'other',\n 'css_utility_call': 'other',\n 'console_call': 'other',\n 'test_assertion': 'other',\n}\n\n// ─── Main: scanCandidates ───\n\nexport async function scanCandidates(\n projectRoot: string,\n options?: ScanOptions,\n): Promise<ScanCandidatesResult> {\n const limit = options?.limit ?? DEFAULT_LIMIT\n const offset = options?.offset ?? DEFAULT_OFFSET\n const minLength = options?.min_length ?? DEFAULT_MIN_LENGTH\n const maxLength = options?.max_length ?? DEFAULT_MAX_LENGTH\n const minScore = options?.min_score ?? DEFAULT_MIN_SCORE\n\n const scanDirs = options?.paths ?? await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, {\n paths: scanDirs,\n include: options?.include,\n exclude: options?.exclude,\n })\n\n // ─── Phase 1: Extract strings from all files (pre-filter applied inside extractStrings) ───\n\n const filePromises = files.map(async (relPath) => {\n const filePath = join(projectRoot, relPath)\n const content = await readText(filePath)\n if (!content) return { relPath, extractions: [] }\n\n const ext = extname(filePath)\n const extractions = await extractStrings(filePath, content, ext)\n return { relPath, extractions }\n })\n\n const fileResults = await Promise.all(filePromises)\n\n // ─── Phase 2: Score + length filter + deduplicate ───\n\n let rawStringsFound = 0\n let skippedCount = 0\n let lowConfidenceCount = 0\n const skipReasons: Record<string, number> = {}\n\n // Deduplication map: value → first candidate + all occurrences\n const uniqueMap = new Map<string, {\n candidate: ScanCandidate\n maxScore: number\n }>()\n\n // Also track duplicates for backward compatibility\n const dupeMap = new Map<string, Array<{ file: string; line: number }>>()\n\n for (const { relPath, extractions } of fileResults) {\n rawStringsFound += extractions.length\n\n for (const extraction of extractions) {\n // Length filter\n if (extraction.value.length < minLength || extraction.value.length > maxLength) {\n skippedCount++\n skipReasons['length_filter'] = (skipReasons['length_filter'] ?? 0) + 1\n continue\n }\n\n // shouldSkip was already applied inside extractStrings (via applyPreFilter).\n // But applyPreFilter uses default minScore. Here we apply the user-configured minScore\n // on the contentScore that was attached during pre-filtering.\n\n // Get the contentScore attached by applyPreFilter\n const contentScore: number = (extraction as unknown as { contentScore?: number }).contentScore ?? calculateContentScore(extraction)\n\n if (contentScore < minScore) {\n lowConfidenceCount++\n skipReasons['low_confidence'] = (skipReasons['low_confidence'] ?? 0) + 1\n continue\n }\n\n const mappedContext = CONTEXT_MAP[extraction.context]\n const loc = { file: relPath, line: extraction.line }\n\n // Track all occurrences for duplicates section\n if (!dupeMap.has(extraction.value)) {\n dupeMap.set(extraction.value, [])\n }\n dupeMap.get(extraction.value)!.push(loc)\n\n // Deduplication: keep first occurrence, accumulate locations\n if (!uniqueMap.has(extraction.value)) {\n uniqueMap.set(extraction.value, {\n candidate: {\n file: relPath,\n line: extraction.line,\n column: extraction.column,\n value: extraction.value,\n context: mappedContext,\n surrounding: extraction.surrounding,\n contentScore,\n occurrences: [loc],\n },\n maxScore: contentScore,\n })\n } else {\n const entry = uniqueMap.get(extraction.value)!\n entry.candidate.occurrences.push(loc)\n // Keep the highest score across occurrences\n if (contentScore > entry.maxScore) {\n entry.maxScore = contentScore\n entry.candidate.contentScore = contentScore\n entry.candidate.file = relPath\n entry.candidate.line = extraction.line\n entry.candidate.column = extraction.column\n entry.candidate.context = mappedContext\n entry.candidate.surrounding = extraction.surrounding\n }\n }\n }\n }\n\n // Build sorted unique candidates (highest score first)\n const allUniqueCandidates = [...uniqueMap.values()]\n .map(e => e.candidate)\n .toSorted((a, b) => b.contentScore - a.contentScore)\n\n // Build duplicate groups (only count >= 2), sorted by count descending\n const duplicates: DuplicateGroup[] = [...dupeMap.entries()]\n .filter(([, occurrences]) => occurrences.length >= 2)\n .map(([value, occurrences]) => ({ value, count: occurrences.length, occurrences }))\n .toSorted((a, b) => b.count - a.count)\n\n // Pagination on unique candidates\n const uniqueCount = allUniqueCandidates.length\n const paginated = allUniqueCandidates.slice(offset, offset + limit)\n const hasMore = uniqueCount > offset + limit\n\n return {\n candidates: paginated,\n duplicates,\n stats: {\n files_scanned: files.length,\n raw_strings_found: rawStringsFound,\n skipped: skippedCount,\n low_confidence: lowConfidenceCount,\n unique_candidates: uniqueCount,\n candidates_returned: paginated.length,\n has_more: hasMore,\n skip_reasons: skipReasons,\n },\n }\n}\n\n// ─── Main: scanSummary ───\n\nexport async function scanSummary(\n projectRoot: string,\n options?: ScanOptions,\n): Promise<ScanSummaryResult> {\n const minLength = options?.min_length ?? DEFAULT_MIN_LENGTH\n const maxLength = options?.max_length ?? DEFAULT_MAX_LENGTH\n const minScore = options?.min_score ?? DEFAULT_MIN_SCORE\n\n const scanDirs = options?.paths ?? await autoDetectSourceDirs(projectRoot)\n const files = await discoverFiles(projectRoot, {\n paths: scanDirs,\n include: options?.include,\n exclude: options?.exclude,\n })\n\n // Group files by directory\n const dirFiles = new Map<string, string[]>()\n const fileTypes: Record<string, number> = {}\n\n for (const relPath of files) {\n const parts = relPath.split('/')\n const dir = parts.slice(0, -1).join('/') || '.'\n const ext = extname(relPath)\n\n if (!dirFiles.has(dir)) dirFiles.set(dir, [])\n dirFiles.get(dir)!.push(relPath)\n\n fileTypes[ext] = (fileTypes[ext] ?? 0) + 1\n }\n\n // Sample files per directory and count candidates\n const byDirectory: Record<string, { files: number; candidates: number }> = {}\n const freqMap = new Map<string, number>()\n let totalCandidatesEstimate = 0\n\n for (const [dir, dirFileList] of dirFiles) {\n const totalInDir = dirFileList.length\n const sampleFiles = dirFileList.slice(0, SUMMARY_SAMPLE_SIZE)\n let sampleCandidates = 0\n\n const samplePromises = sampleFiles.map(async (relPath) => {\n const filePath = join(projectRoot, relPath)\n const content = await readText(filePath)\n if (!content) return []\n\n const ext = extname(filePath)\n return extractStrings(filePath, content, ext)\n })\n\n const sampleResults = await Promise.all(samplePromises)\n\n for (const extractions of sampleResults) {\n for (const extraction of extractions) {\n // Apply same filters as scanCandidates\n if (extraction.value.length < minLength || extraction.value.length > maxLength) continue\n\n const contentScore: number = (extraction as unknown as { contentScore?: number }).contentScore ?? calculateContentScore(extraction)\n if (contentScore < minScore) continue\n\n sampleCandidates++\n\n // Track frequencies for top_repeated\n const prev = freqMap.get(extraction.value) ?? 0\n freqMap.set(extraction.value, prev + 1)\n }\n }\n\n // Estimate for full directory\n const avgPerFile = sampleFiles.length > 0 ? sampleCandidates / sampleFiles.length : 0\n const estimatedCandidates = Math.round(avgPerFile * totalInDir)\n\n byDirectory[dir] = {\n files: totalInDir,\n candidates: estimatedCandidates,\n }\n\n totalCandidatesEstimate += estimatedCandidates\n }\n\n // Top repeated strings\n const topRepeated = [...freqMap.entries()]\n .filter(([, count]) => count >= 2)\n .toSorted((a, b) => b[1] - a[1])\n .slice(0, 20)\n .map(([value, count]) => ({ value, count }))\n\n return {\n total_files: files.length,\n total_candidates_estimate: totalCandidatesEstimate,\n by_directory: byDirectory,\n top_repeated: topRepeated,\n sampling_note: `Based on first ${SUMMARY_SAMPLE_SIZE} files per directory. Counts are from sampled subset, not project-wide.`,\n file_types: fileTypes,\n }\n}\n"],"mappings":"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"}
|
|
@@ -6,20 +6,124 @@ import { t as readContext } from "./context-DIDt_TxX.mjs";
|
|
|
6
6
|
import { t as detectStack } from "./detect-DinOQAmK.mjs";
|
|
7
7
|
import { n as cleanupMergedBranches, t as checkBranchHealth } from "./branch-lifecycle-wGJg25LT.mjs";
|
|
8
8
|
import { n as listTemplates, t as getTemplate } from "./templates-bRPzYq4M.mjs";
|
|
9
|
-
import { n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-
|
|
9
|
+
import { i as mergeBranch, n as createTransaction, r as ensureContentBranch, t as buildBranchName } from "./transaction-C_Pvux78.mjs";
|
|
10
10
|
import { t as validateProject } from "./validator-SQYUV2JP.mjs";
|
|
11
11
|
import { t as buildGraph } from "./graph-builder-tM7zj-SX.mjs";
|
|
12
|
-
import { n as
|
|
13
|
-
import { n as applyExtract, r as applyReuse } from "./apply-manager-
|
|
12
|
+
import { n as scanSummary, t as scanCandidates } from "./scanner-CQOxARUU.mjs";
|
|
13
|
+
import { n as applyExtract, r as applyReuse } from "./apply-manager-BkPMfpsN.mjs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
16
|
import { z } from "zod";
|
|
17
17
|
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
18
18
|
import { CONTENTRAIN_BRANCH } from "@contentrain/types";
|
|
19
19
|
import { simpleGit } from "simple-git";
|
|
20
|
+
//#region src/tools/annotations.ts
|
|
21
|
+
/**
|
|
22
|
+
* Centralized tool annotations registry.
|
|
23
|
+
* MCP clients use these hints to distinguish read-only vs. write vs. destructive tools.
|
|
24
|
+
*/
|
|
25
|
+
const TOOL_ANNOTATIONS = {
|
|
26
|
+
contentrain_status: {
|
|
27
|
+
title: "Project Status",
|
|
28
|
+
readOnlyHint: true,
|
|
29
|
+
destructiveHint: false,
|
|
30
|
+
idempotentHint: true
|
|
31
|
+
},
|
|
32
|
+
contentrain_describe: {
|
|
33
|
+
title: "Describe Model",
|
|
34
|
+
readOnlyHint: true,
|
|
35
|
+
destructiveHint: false,
|
|
36
|
+
idempotentHint: true
|
|
37
|
+
},
|
|
38
|
+
contentrain_describe_format: {
|
|
39
|
+
title: "Describe Format",
|
|
40
|
+
readOnlyHint: true,
|
|
41
|
+
destructiveHint: false,
|
|
42
|
+
idempotentHint: true
|
|
43
|
+
},
|
|
44
|
+
contentrain_init: {
|
|
45
|
+
title: "Initialize Project",
|
|
46
|
+
readOnlyHint: false,
|
|
47
|
+
destructiveHint: false,
|
|
48
|
+
idempotentHint: false
|
|
49
|
+
},
|
|
50
|
+
contentrain_scaffold: {
|
|
51
|
+
title: "Scaffold Template",
|
|
52
|
+
readOnlyHint: false,
|
|
53
|
+
destructiveHint: false,
|
|
54
|
+
idempotentHint: false
|
|
55
|
+
},
|
|
56
|
+
contentrain_model_save: {
|
|
57
|
+
title: "Save Model",
|
|
58
|
+
readOnlyHint: false,
|
|
59
|
+
destructiveHint: false,
|
|
60
|
+
idempotentHint: true
|
|
61
|
+
},
|
|
62
|
+
contentrain_model_delete: {
|
|
63
|
+
title: "Delete Model",
|
|
64
|
+
readOnlyHint: false,
|
|
65
|
+
destructiveHint: true,
|
|
66
|
+
idempotentHint: false
|
|
67
|
+
},
|
|
68
|
+
contentrain_content_save: {
|
|
69
|
+
title: "Save Content",
|
|
70
|
+
readOnlyHint: false,
|
|
71
|
+
destructiveHint: false,
|
|
72
|
+
idempotentHint: true
|
|
73
|
+
},
|
|
74
|
+
contentrain_content_delete: {
|
|
75
|
+
title: "Delete Content",
|
|
76
|
+
readOnlyHint: false,
|
|
77
|
+
destructiveHint: true,
|
|
78
|
+
idempotentHint: false
|
|
79
|
+
},
|
|
80
|
+
contentrain_content_list: {
|
|
81
|
+
title: "List Content",
|
|
82
|
+
readOnlyHint: true,
|
|
83
|
+
destructiveHint: false,
|
|
84
|
+
idempotentHint: true
|
|
85
|
+
},
|
|
86
|
+
contentrain_validate: {
|
|
87
|
+
title: "Validate Project",
|
|
88
|
+
readOnlyHint: false,
|
|
89
|
+
destructiveHint: false,
|
|
90
|
+
idempotentHint: true
|
|
91
|
+
},
|
|
92
|
+
contentrain_submit: {
|
|
93
|
+
title: "Submit Branches",
|
|
94
|
+
readOnlyHint: false,
|
|
95
|
+
destructiveHint: false,
|
|
96
|
+
idempotentHint: true
|
|
97
|
+
},
|
|
98
|
+
contentrain_merge: {
|
|
99
|
+
title: "Merge Branch",
|
|
100
|
+
readOnlyHint: false,
|
|
101
|
+
destructiveHint: false,
|
|
102
|
+
idempotentHint: false
|
|
103
|
+
},
|
|
104
|
+
contentrain_scan: {
|
|
105
|
+
title: "Scan Source Code",
|
|
106
|
+
readOnlyHint: true,
|
|
107
|
+
destructiveHint: false,
|
|
108
|
+
idempotentHint: true
|
|
109
|
+
},
|
|
110
|
+
contentrain_apply: {
|
|
111
|
+
title: "Apply Normalize",
|
|
112
|
+
readOnlyHint: false,
|
|
113
|
+
destructiveHint: false,
|
|
114
|
+
idempotentHint: false
|
|
115
|
+
},
|
|
116
|
+
contentrain_bulk: {
|
|
117
|
+
title: "Bulk Operations",
|
|
118
|
+
readOnlyHint: false,
|
|
119
|
+
destructiveHint: false,
|
|
120
|
+
idempotentHint: false
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
20
124
|
//#region src/tools/context.ts
|
|
21
125
|
function registerContextTools(server, projectRoot) {
|
|
22
|
-
server.tool("contentrain_status", "Get full project status (read-only). Returns config, models, context. Do NOT manually edit .contentrain/ based on this output.", {}, async () => {
|
|
126
|
+
server.tool("contentrain_status", "Get full project status (read-only). Returns config, models, context. Do NOT manually edit .contentrain/ based on this output.", {}, TOOL_ANNOTATIONS["contentrain_status"], async () => {
|
|
23
127
|
if (!await pathExists(join(contentrainDir(projectRoot), "config.json"))) {
|
|
24
128
|
const detectedStack = await detectStack(projectRoot);
|
|
25
129
|
return { content: [{
|
|
@@ -85,7 +189,7 @@ function registerContextTools(server, projectRoot) {
|
|
|
85
189
|
model: z.string().describe("Model ID (e.g. \"blog-post\", \"hero\")"),
|
|
86
190
|
include_sample: z.boolean().optional().default(false).describe("Include one sample entry"),
|
|
87
191
|
locale: z.string().optional().describe("Locale for sample content (default: config default locale)")
|
|
88
|
-
}, async ({ model: modelId, include_sample, locale }) => {
|
|
192
|
+
}, TOOL_ANNOTATIONS["contentrain_describe"], async ({ model: modelId, include_sample, locale }) => {
|
|
89
193
|
const modelDef = await readModel(projectRoot, modelId);
|
|
90
194
|
if (!modelDef) return {
|
|
91
195
|
content: [{
|
|
@@ -120,7 +224,7 @@ function registerContextTools(server, projectRoot) {
|
|
|
120
224
|
text: JSON.stringify(result, null, 2)
|
|
121
225
|
}] };
|
|
122
226
|
});
|
|
123
|
-
server.tool("contentrain_describe_format", "Describes the Contentrain content file format for any language/platform. Returns a comprehensive specification of the file structure, JSON formats, markdown conventions, meta files, and locale strategies.", {}, async () => {
|
|
227
|
+
server.tool("contentrain_describe_format", "Describes the Contentrain content file format for any language/platform. Returns a comprehensive specification of the file structure, JSON formats, markdown conventions, meta files, and locale strategies.", {}, TOOL_ANNOTATIONS["contentrain_describe_format"], async () => {
|
|
124
228
|
return { content: [{
|
|
125
229
|
type: "text",
|
|
126
230
|
text: JSON.stringify({
|
|
@@ -331,7 +435,7 @@ function registerSetupTools(server, projectRoot) {
|
|
|
331
435
|
stack: z.string().optional().describe("Framework stack (nuxt, next, astro, svelte, react-vite, other). Auto-detected if omitted."),
|
|
332
436
|
locales: z.array(z.string()).optional().describe("Supported locales. Default: [\"en\"]"),
|
|
333
437
|
domains: z.array(z.string()).optional().describe("Content domains. Default: auto-suggested")
|
|
334
|
-
}, async ({ stack, locales, domains }) => {
|
|
438
|
+
}, TOOL_ANNOTATIONS["contentrain_init"], async ({ stack, locales, domains }) => {
|
|
335
439
|
if (await pathExists(join(contentrainDir(projectRoot), "config.json"))) return {
|
|
336
440
|
content: [{
|
|
337
441
|
type: "text",
|
|
@@ -457,7 +561,7 @@ function registerSetupTools(server, projectRoot) {
|
|
|
457
561
|
template: z.string().describe("Template ID: blog, landing, docs, ecommerce, saas, i18n, mobile"),
|
|
458
562
|
locales: z.array(z.string()).optional().describe("Override locales"),
|
|
459
563
|
with_sample_content: z.boolean().optional().default(true).describe("Include sample content (default: true)")
|
|
460
|
-
}, async ({ template: templateId, locales, with_sample_content }) => {
|
|
564
|
+
}, TOOL_ANNOTATIONS["contentrain_scaffold"], async ({ template: templateId, locales, with_sample_content }) => {
|
|
461
565
|
const config = await readConfig(projectRoot);
|
|
462
566
|
if (!config) return {
|
|
463
567
|
content: [{
|
|
@@ -636,7 +740,7 @@ function registerModelTools(server, projectRoot) {
|
|
|
636
740
|
"directory",
|
|
637
741
|
"none"
|
|
638
742
|
]).optional().describe("How locale is encoded in file names. Default: \"file\"")
|
|
639
|
-
}, async (input) => {
|
|
743
|
+
}, TOOL_ANNOTATIONS["contentrain_model_save"], async (input) => {
|
|
640
744
|
const config = await readConfig(projectRoot);
|
|
641
745
|
if (!config) return {
|
|
642
746
|
content: [{
|
|
@@ -747,7 +851,7 @@ function registerModelTools(server, projectRoot) {
|
|
|
747
851
|
server.tool("contentrain_model_delete", "Delete a model and its content/meta. Changes are auto-committed to git — do NOT manually edit .contentrain/ files.", {
|
|
748
852
|
model: z.string().describe("Model ID to delete"),
|
|
749
853
|
confirm: z.literal(true).describe("Must be true to confirm deletion")
|
|
750
|
-
}, async ({ model: modelId }) => {
|
|
854
|
+
}, TOOL_ANNOTATIONS["contentrain_model_delete"], async ({ model: modelId }) => {
|
|
751
855
|
if (!await readConfig(projectRoot)) return {
|
|
752
856
|
content: [{
|
|
753
857
|
type: "text",
|
|
@@ -840,7 +944,7 @@ function registerContentTools(server, projectRoot) {
|
|
|
840
944
|
publish_at: z.string().optional().describe("ISO 8601 date for scheduled publishing (stored in meta)"),
|
|
841
945
|
expire_at: z.string().optional().describe("ISO 8601 date for scheduled expiry (stored in meta, must be after publish_at)")
|
|
842
946
|
})).describe("Content entries to save")
|
|
843
|
-
}, async (input) => {
|
|
947
|
+
}, TOOL_ANNOTATIONS["contentrain_content_save"], async (input) => {
|
|
844
948
|
const config = await readConfig(projectRoot);
|
|
845
949
|
if (!config) return {
|
|
846
950
|
content: [{
|
|
@@ -966,7 +1070,7 @@ function registerContentTools(server, projectRoot) {
|
|
|
966
1070
|
locale: z.string().optional().describe("Locale code"),
|
|
967
1071
|
keys: z.array(z.string()).optional().describe("Dictionary only: specific keys to remove. Omit to delete entire locale file."),
|
|
968
1072
|
confirm: z.literal(true).describe("Must be true to confirm deletion")
|
|
969
|
-
}, async (input) => {
|
|
1073
|
+
}, TOOL_ANNOTATIONS["contentrain_content_delete"], async (input) => {
|
|
970
1074
|
if (!await readConfig(projectRoot)) return {
|
|
971
1075
|
content: [{
|
|
972
1076
|
type: "text",
|
|
@@ -1048,7 +1152,7 @@ function registerContentTools(server, projectRoot) {
|
|
|
1048
1152
|
resolve: z.boolean().optional().describe("Resolve relation fields to actual data"),
|
|
1049
1153
|
limit: z.number().optional().describe("Max entries to return"),
|
|
1050
1154
|
offset: z.number().optional().describe("Skip N entries")
|
|
1051
|
-
}, async (input) => {
|
|
1155
|
+
}, TOOL_ANNOTATIONS["contentrain_content_list"], async (input) => {
|
|
1052
1156
|
const config = await readConfig(projectRoot);
|
|
1053
1157
|
if (!config) return {
|
|
1054
1158
|
content: [{
|
|
@@ -1094,7 +1198,7 @@ function registerWorkflowTools(server, projectRoot) {
|
|
|
1094
1198
|
server.tool("contentrain_validate", "Validate project content against model schemas. Detects required field violations, type mismatches, broken relations, secret leaks, i18n parity issues, and more. If fix:true, auto-fixes structural issues (canonical sort, orphan meta, missing locale files) — do NOT manually edit .contentrain/ files.", {
|
|
1095
1199
|
model: z.string().optional().describe("Model ID to validate (omit for all models)"),
|
|
1096
1200
|
fix: z.boolean().optional().describe("Auto-fix structural issues (canonical sort, orphan meta, missing locale files). Default: false")
|
|
1097
|
-
}, async (input) => {
|
|
1201
|
+
}, TOOL_ANNOTATIONS["contentrain_validate"], async (input) => {
|
|
1098
1202
|
if (!await readConfig(projectRoot)) return {
|
|
1099
1203
|
content: [{
|
|
1100
1204
|
type: "text",
|
|
@@ -1190,7 +1294,7 @@ function registerWorkflowTools(server, projectRoot) {
|
|
|
1190
1294
|
server.tool("contentrain_submit", "Push contentrain/* branches to remote. MCP is push-only — PR creation is handled by the platform. Do NOT manually push or create PRs.", {
|
|
1191
1295
|
branches: z.array(z.string()).optional().describe("Specific branch names to push (omit for all contentrain/* branches)"),
|
|
1192
1296
|
message: z.string().optional().describe("Optional message for the push operation")
|
|
1193
|
-
}, async (input) => {
|
|
1297
|
+
}, TOOL_ANNOTATIONS["contentrain_submit"], async (input) => {
|
|
1194
1298
|
const config = await readConfig(projectRoot);
|
|
1195
1299
|
if (!config) return {
|
|
1196
1300
|
content: [{
|
|
@@ -1271,6 +1375,7 @@ function registerWorkflowTools(server, projectRoot) {
|
|
|
1271
1375
|
const cleanup = await cleanupMergedBranches(projectRoot);
|
|
1272
1376
|
const nextSteps = [];
|
|
1273
1377
|
if (pushed.length > 0) nextSteps.push("Create PRs on your git platform for review");
|
|
1378
|
+
if (pushed.length > 0) nextSteps.push("For merge: use contentrain_merge or direct user to http://localhost:3333/branches");
|
|
1274
1379
|
if (errors.length > 0) nextSteps.push("Fix push errors and retry");
|
|
1275
1380
|
if (cleanup.remaining >= 50) nextSteps.push(`Warning: ${cleanup.remaining} active contentrain branches. Consider reviewing old branches.`);
|
|
1276
1381
|
return { content: [{
|
|
@@ -1298,6 +1403,53 @@ function registerWorkflowTools(server, projectRoot) {
|
|
|
1298
1403
|
};
|
|
1299
1404
|
}
|
|
1300
1405
|
});
|
|
1406
|
+
server.tool("contentrain_merge", "Merge a review-mode branch into contentrain. Local git operation — no external platform needed. Merges the feature branch into the contentrain branch, advances the base branch via update-ref, and selectively syncs .contentrain/ files to the working tree.", {
|
|
1407
|
+
branch: z.string().describe("Branch name to merge (e.g. cr/normalize/extract/...)"),
|
|
1408
|
+
confirm: z.literal(true).describe("Must be true to confirm the merge")
|
|
1409
|
+
}, TOOL_ANNOTATIONS["contentrain_merge"], async (input) => {
|
|
1410
|
+
if (!await readConfig(projectRoot)) return {
|
|
1411
|
+
content: [{
|
|
1412
|
+
type: "text",
|
|
1413
|
+
text: JSON.stringify({ error: "Project not initialized. Run contentrain_init first." })
|
|
1414
|
+
}],
|
|
1415
|
+
isError: true
|
|
1416
|
+
};
|
|
1417
|
+
try {
|
|
1418
|
+
if (!(await simpleGit(projectRoot).branchLocal()).all.includes(input.branch)) return {
|
|
1419
|
+
content: [{
|
|
1420
|
+
type: "text",
|
|
1421
|
+
text: JSON.stringify({
|
|
1422
|
+
error: `Branch "${input.branch}" not found locally.`,
|
|
1423
|
+
next_steps: ["Check branch name with git branch -l", "Use contentrain_submit to push first if needed"]
|
|
1424
|
+
})
|
|
1425
|
+
}],
|
|
1426
|
+
isError: true
|
|
1427
|
+
};
|
|
1428
|
+
const result = await mergeBranch(projectRoot, input.branch);
|
|
1429
|
+
return { content: [{
|
|
1430
|
+
type: "text",
|
|
1431
|
+
text: JSON.stringify({
|
|
1432
|
+
status: "merged",
|
|
1433
|
+
action: result.action,
|
|
1434
|
+
commit: result.commit,
|
|
1435
|
+
sync: result.sync,
|
|
1436
|
+
next_steps: [
|
|
1437
|
+
"Run `npx contentrain generate` to update SDK client",
|
|
1438
|
+
"Run contentrain_validate to verify content integrity",
|
|
1439
|
+
result.sync.skipped.length > 0 ? `${result.sync.skipped.length} file(s) skipped due to local changes — resolve manually` : ""
|
|
1440
|
+
].filter(Boolean)
|
|
1441
|
+
}, null, 2)
|
|
1442
|
+
}] };
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
return {
|
|
1445
|
+
content: [{
|
|
1446
|
+
type: "text",
|
|
1447
|
+
text: JSON.stringify({ error: `Merge failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
1448
|
+
}],
|
|
1449
|
+
isError: true
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1301
1453
|
}
|
|
1302
1454
|
//#endregion
|
|
1303
1455
|
//#region src/tools/normalize.ts
|
|
@@ -1315,7 +1467,7 @@ function registerNormalizeTools(server, projectRoot) {
|
|
|
1315
1467
|
offset: z.number().optional().describe("Candidates mode: pagination offset. Default: 0"),
|
|
1316
1468
|
min_length: z.number().optional().describe("Candidates mode: minimum string length. Default: 2"),
|
|
1317
1469
|
max_length: z.number().optional().describe("Candidates mode: maximum string length. Default: 500")
|
|
1318
|
-
}, async (input) => {
|
|
1470
|
+
}, TOOL_ANNOTATIONS["contentrain_scan"], async (input) => {
|
|
1319
1471
|
if (!await readConfig(projectRoot)) return {
|
|
1320
1472
|
content: [{
|
|
1321
1473
|
type: "text",
|
|
@@ -1441,7 +1593,7 @@ function registerNormalizeTools(server, projectRoot) {
|
|
|
1441
1593
|
new_expression: z.string().describe("Replacement expression (agent-determined)"),
|
|
1442
1594
|
import_statement: z.string().optional().describe("Import to add if needed")
|
|
1443
1595
|
})).optional().describe("Reuse mode: patches to apply (max 100)")
|
|
1444
|
-
}, async (input) => {
|
|
1596
|
+
}, TOOL_ANNOTATIONS["contentrain_apply"], async (input) => {
|
|
1445
1597
|
if (!await readConfig(projectRoot)) return {
|
|
1446
1598
|
content: [{
|
|
1447
1599
|
type: "text",
|
|
@@ -1562,7 +1714,7 @@ function registerBulkTools(server, projectRoot) {
|
|
|
1562
1714
|
"archived"
|
|
1563
1715
|
]).optional().describe("New status for update_status"),
|
|
1564
1716
|
confirm: z.boolean().optional().describe("Must be true for delete_entries")
|
|
1565
|
-
}, async (input) => {
|
|
1717
|
+
}, TOOL_ANNOTATIONS["contentrain_bulk"], async (input) => {
|
|
1566
1718
|
const config = await readConfig(projectRoot);
|
|
1567
1719
|
if (!config) return {
|
|
1568
1720
|
content: [{
|
|
@@ -1860,7 +2012,7 @@ function registerBulkTools(server, projectRoot) {
|
|
|
1860
2012
|
}
|
|
1861
2013
|
//#endregion
|
|
1862
2014
|
//#region package.json
|
|
1863
|
-
var version = "1.
|
|
2015
|
+
var version = "1.2.0";
|
|
1864
2016
|
//#endregion
|
|
1865
2017
|
//#region src/server.ts
|
|
1866
2018
|
function createServer(projectRoot) {
|
|
@@ -1880,4 +2032,4 @@ function createServer(projectRoot) {
|
|
|
1880
2032
|
//#endregion
|
|
1881
2033
|
export { createServer as t };
|
|
1882
2034
|
|
|
1883
|
-
//# sourceMappingURL=server-
|
|
2035
|
+
//# sourceMappingURL=server-DfWRpzdP.mjs.map
|