@contentrain/mcp 0.3.0 → 1.0.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/LICENSE +1 -1
- package/README.md +147 -238
- package/dist/apply-manager-CaTuu7Lj.mjs +751 -0
- package/dist/apply-manager-CaTuu7Lj.mjs.map +1 -0
- package/dist/astro-parser-RofKmKQi.mjs +166 -0
- package/dist/astro-parser-RofKmKQi.mjs.map +1 -0
- package/dist/branch-lifecycle-DB6sgeJi.mjs +97 -0
- package/dist/branch-lifecycle-DB6sgeJi.mjs.map +1 -0
- package/dist/chunk-BEJ448es.mjs +35 -0
- package/dist/compiler-rIC4ETHv.mjs +44509 -0
- package/dist/compiler-rIC4ETHv.mjs.map +1 -0
- package/dist/compiler-sfc.cjs-BbD04rn_.mjs +50279 -0
- package/dist/compiler-sfc.cjs-BbD04rn_.mjs.map +1 -0
- package/dist/config-CPlhDwsv.mjs +27 -0
- package/dist/config-CPlhDwsv.mjs.map +1 -0
- package/dist/context-yh3cuw5N.mjs +46 -0
- package/dist/context-yh3cuw5N.mjs.map +1 -0
- package/dist/core/apply-manager.d.mts +144 -0
- package/dist/core/apply-manager.d.mts.map +1 -0
- package/dist/core/apply-manager.mjs +6 -0
- package/dist/core/config.d.mts +8 -0
- package/dist/core/config.d.mts.map +1 -0
- package/dist/core/config.mjs +2 -0
- package/dist/core/content-manager.d.mts +53 -0
- package/dist/core/content-manager.d.mts.map +1 -0
- package/dist/core/content-manager.mjs +2 -0
- package/dist/core/context.d.mts +13 -0
- package/dist/core/context.d.mts.map +1 -0
- package/dist/core/context.mjs +3 -0
- package/dist/core/graph-builder.d.mts +12 -0
- package/dist/core/graph-builder.d.mts.map +1 -0
- package/dist/core/graph-builder.mjs +2 -0
- package/dist/core/meta-manager.d.mts +14 -0
- package/dist/core/meta-manager.d.mts.map +1 -0
- package/dist/core/meta-manager.mjs +2 -0
- package/dist/core/model-manager.d.mts +44 -0
- package/dist/core/model-manager.d.mts.map +1 -0
- package/dist/core/model-manager.mjs +2 -0
- package/dist/core/scan-config.d.mts +24 -0
- package/dist/core/scan-config.d.mts.map +1 -0
- package/dist/core/scan-config.mjs +2 -0
- package/dist/core/scanner.d.mts +25 -0
- package/dist/core/scanner.d.mts.map +1 -0
- package/dist/core/scanner.mjs +3 -0
- package/dist/core/validator.d.mts +23 -0
- package/dist/core/validator.d.mts.map +1 -0
- package/dist/core/validator.mjs +3 -0
- package/dist/detect-C8XmJ4S6.mjs +362 -0
- package/dist/detect-C8XmJ4S6.mjs.map +1 -0
- package/dist/fs-BbuGnVcG.mjs +52 -0
- package/dist/fs-BbuGnVcG.mjs.map +1 -0
- package/dist/git/branch-lifecycle.d.mts +28 -0
- package/dist/git/branch-lifecycle.d.mts.map +1 -0
- package/dist/git/branch-lifecycle.mjs +2 -0
- package/dist/git/transaction.d.mts +25 -0
- package/dist/git/transaction.d.mts.map +1 -0
- package/dist/git/transaction.mjs +4 -0
- package/dist/graph-builder-fPrDDwnu.mjs +162 -0
- package/dist/graph-builder-fPrDDwnu.mjs.map +1 -0
- package/dist/id-BdD4HvMY.mjs +12 -0
- package/dist/id-BdD4HvMY.mjs.map +1 -0
- package/dist/index.d.mts +1 -359
- package/dist/index.mjs +15 -2161
- package/dist/index.mjs.map +1 -1
- package/dist/meta-manager-CECrfsKm.mjs +53 -0
- package/dist/meta-manager-CECrfsKm.mjs.map +1 -0
- package/dist/model-manager-BcWWu5l2.mjs +963 -0
- package/dist/model-manager-BcWWu5l2.mjs.map +1 -0
- package/dist/node-Cbq5eR5i.mjs +300 -0
- package/dist/node-Cbq5eR5i.mjs.map +1 -0
- package/dist/scan-config-AJGxCdNd.mjs +159 -0
- package/dist/scan-config-AJGxCdNd.mjs.map +1 -0
- package/dist/scanner-BdTiv8sw.mjs +619 -0
- package/dist/scanner-BdTiv8sw.mjs.map +1 -0
- package/dist/serializer-Dueih-i2.mjs +33 -0
- package/dist/serializer-Dueih-i2.mjs.map +1 -0
- package/dist/server-CTcDpxwW.mjs +1861 -0
- package/dist/server-CTcDpxwW.mjs.map +1 -0
- package/dist/server.d.mts +7 -0
- package/dist/server.d.mts.map +1 -0
- package/dist/server.mjs +11 -0
- package/dist/source-map-generator-DpO-ejrt.mjs +893 -0
- package/dist/source-map-generator-DpO-ejrt.mjs.map +1 -0
- package/dist/svelte-parser-COTNG92w.mjs +225 -0
- package/dist/svelte-parser-COTNG92w.mjs.map +1 -0
- package/dist/templates/index.d.mts +8 -0
- package/dist/templates/index.d.mts.map +1 -0
- package/dist/templates/index.mjs +2 -0
- package/dist/templates-3HWi-PTu.mjs +824 -0
- package/dist/templates-3HWi-PTu.mjs.map +1 -0
- package/dist/transaction-BsECnmL5.mjs +128 -0
- package/dist/transaction-BsECnmL5.mjs.map +1 -0
- package/dist/tsx-parser-Bf8Firrq.mjs +2 -0
- package/dist/tsx-parser-CYVyBWjn.mjs +234 -0
- package/dist/tsx-parser-CYVyBWjn.mjs.map +1 -0
- package/dist/util/detect.d.mts +20 -0
- package/dist/util/detect.d.mts.map +1 -0
- package/dist/util/detect.mjs +2 -0
- package/dist/util/fs.d.mts +12 -0
- package/dist/util/fs.d.mts.map +1 -0
- package/dist/util/fs.mjs +2 -0
- package/dist/util/id.d.mts +6 -0
- package/dist/util/id.d.mts.map +1 -0
- package/dist/util/id.mjs +2 -0
- package/dist/util/serializer.d.mts +14 -0
- package/dist/util/serializer.d.mts.map +1 -0
- package/dist/util/serializer.mjs +2 -0
- package/dist/validator-ku84AyFr.mjs +755 -0
- package/dist/validator-ku84AyFr.mjs.map +1 -0
- package/dist/vue-parser-CjS4YAoQ.mjs +225 -0
- package/dist/vue-parser-CjS4YAoQ.mjs.map +1 -0
- package/package.json +107 -43
- package/dist/fs-D83-Ii_y.mjs +0 -75
- package/dist/fs-D83-Ii_y.mjs.map +0 -1
- package/dist/index.d.mts.map +0 -1
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { c as writeText, o as readText, r as pathExists } from "./fs-BbuGnVcG.mjs";
|
|
2
|
+
import { t as readConfig } from "./config-CPlhDwsv.mjs";
|
|
3
|
+
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-BcWWu5l2.mjs";
|
|
4
|
+
import { n as writeContext } from "./context-yh3cuw5N.mjs";
|
|
5
|
+
import { t as checkBranchHealth } from "./branch-lifecycle-DB6sgeJi.mjs";
|
|
6
|
+
import { n as createTransaction, t as buildBranchName } from "./transaction-BsECnmL5.mjs";
|
|
7
|
+
import { extname, join } from "node:path";
|
|
8
|
+
//#region src/core/apply-manager.ts
|
|
9
|
+
const MAX_PATCHES = 100;
|
|
10
|
+
/** File extensions allowed for patching — scannable source files only */
|
|
11
|
+
const PATCHABLE_EXTENSIONS = new Set([
|
|
12
|
+
".vue",
|
|
13
|
+
".tsx",
|
|
14
|
+
".jsx",
|
|
15
|
+
".ts",
|
|
16
|
+
".js",
|
|
17
|
+
".mjs",
|
|
18
|
+
".astro",
|
|
19
|
+
".svelte"
|
|
20
|
+
]);
|
|
21
|
+
/** Directories that must never be patched */
|
|
22
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set([
|
|
23
|
+
".contentrain",
|
|
24
|
+
"node_modules",
|
|
25
|
+
".git",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
".next",
|
|
29
|
+
".nuxt"
|
|
30
|
+
]);
|
|
31
|
+
function detectFileFramework(filePath) {
|
|
32
|
+
switch (extname(filePath).toLowerCase()) {
|
|
33
|
+
case ".vue": return "vue";
|
|
34
|
+
case ".svelte": return "svelte";
|
|
35
|
+
case ".tsx":
|
|
36
|
+
case ".jsx": return "jsx";
|
|
37
|
+
case ".astro": return "astro";
|
|
38
|
+
case ".ts":
|
|
39
|
+
case ".js":
|
|
40
|
+
case ".mjs": return "script";
|
|
41
|
+
default: return "script";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate that a replacement expression uses the correct template syntax
|
|
46
|
+
* for the target file's framework. Returns a warning string or null.
|
|
47
|
+
*/
|
|
48
|
+
function validateFrameworkExpression(filePath, newExpression, context) {
|
|
49
|
+
if (context !== "tag_text") return null;
|
|
50
|
+
switch (detectFileFramework(filePath)) {
|
|
51
|
+
case "vue":
|
|
52
|
+
if (!newExpression.includes("{{")) return `Vue file "${filePath}": tag text expression "${newExpression}" does not contain "{{" — expected Vue template syntax like {{ $t('key') }}`;
|
|
53
|
+
break;
|
|
54
|
+
case "jsx":
|
|
55
|
+
if (!newExpression.includes("{")) return `JSX file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected JSX syntax like {t('key')}`;
|
|
56
|
+
break;
|
|
57
|
+
case "svelte":
|
|
58
|
+
if (!newExpression.includes("{")) return `Svelte file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Svelte syntax like {$t('key')}`;
|
|
59
|
+
break;
|
|
60
|
+
case "astro":
|
|
61
|
+
if (!newExpression.includes("{")) return `Astro file "${filePath}": tag text expression "${newExpression}" does not contain "{" — expected Astro syntax like {t('key')}`;
|
|
62
|
+
break;
|
|
63
|
+
case "script": return `Script file "${filePath}": tag text replacement not applicable for .ts/.js files`;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Validate that a patch file path is safe and within allowed scope.
|
|
69
|
+
* Returns an error message or null if valid.
|
|
70
|
+
*/
|
|
71
|
+
function validatePatchPath(filePath) {
|
|
72
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
73
|
+
if (normalizedPath.includes("..")) return `Path traversal detected: "${filePath}"`;
|
|
74
|
+
if (normalizedPath.startsWith("/")) return `Absolute path not allowed: "${filePath}"`;
|
|
75
|
+
const segments = normalizedPath.split("/");
|
|
76
|
+
for (const seg of segments) if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return `Patching files inside "${seg}/" is not allowed: "${filePath}"`;
|
|
77
|
+
const ext = extname(filePath).toLowerCase();
|
|
78
|
+
if (!PATCHABLE_EXTENSIONS.has(ext)) return `File extension "${ext}" is not patchable. Allowed: ${[...PATCHABLE_EXTENSIONS].join(", ")}. Path: "${filePath}"`;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Perform a basic syntax check on a patched file.
|
|
83
|
+
* Returns an error message or null if syntax appears valid.
|
|
84
|
+
*/
|
|
85
|
+
function checkSyntax(filePath, content) {
|
|
86
|
+
switch (extname(filePath).toLowerCase()) {
|
|
87
|
+
case ".ts":
|
|
88
|
+
case ".tsx":
|
|
89
|
+
case ".js":
|
|
90
|
+
case ".jsx":
|
|
91
|
+
case ".mjs": return checkJsSyntax(content);
|
|
92
|
+
case ".vue": return checkVueSyntax(content);
|
|
93
|
+
case ".svelte":
|
|
94
|
+
case ".astro": return checkTagBalance(content);
|
|
95
|
+
default: return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Basic JS/TS syntax check: bracket/paren/brace balance + string literal closure.
|
|
100
|
+
* This is intentionally conservative — it catches obvious breakage without
|
|
101
|
+
* requiring a full parser.
|
|
102
|
+
*/
|
|
103
|
+
function checkJsSyntax(content) {
|
|
104
|
+
const stack = [];
|
|
105
|
+
const pairs = {
|
|
106
|
+
")": "(",
|
|
107
|
+
"]": "[",
|
|
108
|
+
"}": "{"
|
|
109
|
+
};
|
|
110
|
+
let inString = null;
|
|
111
|
+
let escaped = false;
|
|
112
|
+
let inLineComment = false;
|
|
113
|
+
let inBlockComment = false;
|
|
114
|
+
for (let i = 0; i < content.length; i++) {
|
|
115
|
+
const ch = content[i];
|
|
116
|
+
const next = content[i + 1];
|
|
117
|
+
if (escaped) {
|
|
118
|
+
escaped = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === "\\" && inString !== null) {
|
|
122
|
+
escaped = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!inString && !inBlockComment && ch === "/" && next === "/") {
|
|
126
|
+
inLineComment = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (inLineComment) {
|
|
130
|
+
if (ch === "\n") inLineComment = false;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!inString && !inBlockComment && ch === "/" && next === "*") {
|
|
134
|
+
inBlockComment = true;
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (inBlockComment) {
|
|
139
|
+
if (ch === "*" && next === "/") {
|
|
140
|
+
inBlockComment = false;
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (inString !== null) {
|
|
146
|
+
if (ch === inString) inString = null;
|
|
147
|
+
else if (inString !== "`" && ch === "\n") return `Unterminated string literal near offset ${i}`;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
151
|
+
inString = ch;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === "(" || ch === "[" || ch === "{") stack.push(ch);
|
|
155
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
156
|
+
const expected = pairs[ch];
|
|
157
|
+
if (stack.length === 0) return `Unmatched closing "${ch}" near offset ${i}`;
|
|
158
|
+
const top = stack.pop();
|
|
159
|
+
if (top !== expected) return `Mismatched bracket: expected closing for "${top}" but found "${ch}" near offset ${i}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (inString !== null) return `Unterminated string literal (opened with ${inString})`;
|
|
163
|
+
if (stack.length > 0) return `Unclosed bracket "${stack[stack.length - 1]}" — ${stack.length} unclosed bracket(s)`;
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Vue SFC syntax check: ensure <template>, <script>, <style> tags are balanced.
|
|
168
|
+
*/
|
|
169
|
+
function checkVueSyntax(content) {
|
|
170
|
+
const tagBalance = checkTagBalance(content);
|
|
171
|
+
if (tagBalance) return tagBalance;
|
|
172
|
+
for (const tag of ["template", "script"]) {
|
|
173
|
+
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
|
|
174
|
+
const closeRe = new RegExp(`</${tag}>`, "g");
|
|
175
|
+
const opens = content.match(openRe)?.length ?? 0;
|
|
176
|
+
const closes = content.match(closeRe)?.length ?? 0;
|
|
177
|
+
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Basic tag balance check for HTML-like files.
|
|
183
|
+
* Checks that self-closing tags are handled and major structural tags are balanced.
|
|
184
|
+
*/
|
|
185
|
+
function checkTagBalance(content) {
|
|
186
|
+
for (const tag of [
|
|
187
|
+
"div",
|
|
188
|
+
"section",
|
|
189
|
+
"main",
|
|
190
|
+
"header",
|
|
191
|
+
"footer",
|
|
192
|
+
"nav",
|
|
193
|
+
"article",
|
|
194
|
+
"aside",
|
|
195
|
+
"ul",
|
|
196
|
+
"ol",
|
|
197
|
+
"table"
|
|
198
|
+
]) {
|
|
199
|
+
const openRe = new RegExp(`<${tag}[\\s>]`, "g");
|
|
200
|
+
const closeRe = new RegExp(`</${tag}>`, "g");
|
|
201
|
+
const opens = content.match(openRe)?.length ?? 0;
|
|
202
|
+
const closes = content.match(closeRe)?.length ?? 0;
|
|
203
|
+
if (opens !== closes) return `Unbalanced <${tag}> tag: ${opens} opening vs ${closes} closing`;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
async function applyExtract(projectRoot, input) {
|
|
208
|
+
const config = await readConfig(projectRoot);
|
|
209
|
+
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
|
|
210
|
+
const { extractions, dry_run } = input;
|
|
211
|
+
const existingModels = await listModels(projectRoot);
|
|
212
|
+
const existingIds = new Set(existingModels.map((m) => m.id));
|
|
213
|
+
const modelsToCreate = [];
|
|
214
|
+
const modelsToUpdate = [];
|
|
215
|
+
const contentFiles = [];
|
|
216
|
+
let totalEntries = 0;
|
|
217
|
+
const validationErrors = [];
|
|
218
|
+
for (const ext of extractions) {
|
|
219
|
+
const modelErrors = validateModelDefinition({
|
|
220
|
+
id: ext.model,
|
|
221
|
+
kind: ext.kind,
|
|
222
|
+
fields: ext.fields
|
|
223
|
+
});
|
|
224
|
+
if (modelErrors.length > 0) validationErrors.push(...modelErrors.map((e) => `[${ext.model}] ${e}`));
|
|
225
|
+
for (const entry of ext.entries) if (ext.kind === "dictionary") {
|
|
226
|
+
if (entry.data["id"] !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Dictionary entries should not have id or slug`);
|
|
227
|
+
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}`);
|
|
228
|
+
} else if (ext.kind === "document") {
|
|
229
|
+
if (!entry.slug && !entry.data["slug"]) validationErrors.push(`[${ext.model}] Document entries must have a slug`);
|
|
230
|
+
} else if (ext.kind === "collection") {
|
|
231
|
+
if (entry.slug !== void 0 || entry.data["slug"] !== void 0) validationErrors.push(`[${ext.model}] Collection entries should not have slug`);
|
|
232
|
+
} else if (ext.kind === "singleton") {
|
|
233
|
+
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`);
|
|
234
|
+
}
|
|
235
|
+
if (existingIds.has(ext.model)) modelsToUpdate.push(ext.model);
|
|
236
|
+
else modelsToCreate.push(ext.model);
|
|
237
|
+
totalEntries += ext.entries.length;
|
|
238
|
+
let previewModel;
|
|
239
|
+
if (existingIds.has(ext.model)) {
|
|
240
|
+
const real = await readModel(projectRoot, ext.model);
|
|
241
|
+
if (real) previewModel = real;
|
|
242
|
+
else previewModel = {
|
|
243
|
+
id: ext.model,
|
|
244
|
+
kind: ext.kind,
|
|
245
|
+
domain: ext.domain,
|
|
246
|
+
i18n: ext.i18n ?? true
|
|
247
|
+
};
|
|
248
|
+
} else previewModel = {
|
|
249
|
+
id: ext.model,
|
|
250
|
+
kind: ext.kind,
|
|
251
|
+
domain: ext.domain,
|
|
252
|
+
i18n: ext.i18n ?? true
|
|
253
|
+
};
|
|
254
|
+
const cDir = resolveContentDir(projectRoot, previewModel);
|
|
255
|
+
for (const entry of ext.entries) {
|
|
256
|
+
const locale = entry.locale ?? config.locales.default;
|
|
257
|
+
if (ext.kind === "document" && entry.slug) contentFiles.push(resolveMdFilePath(cDir, previewModel, locale, entry.slug));
|
|
258
|
+
else contentFiles.push(resolveJsonFilePath(cDir, previewModel, locale));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const preview = {
|
|
262
|
+
models_to_create: modelsToCreate,
|
|
263
|
+
models_to_update: modelsToUpdate,
|
|
264
|
+
total_entries: totalEntries,
|
|
265
|
+
content_files: [...new Set(contentFiles)]
|
|
266
|
+
};
|
|
267
|
+
if (dry_run !== false) return {
|
|
268
|
+
dry_run: true,
|
|
269
|
+
preview,
|
|
270
|
+
...validationErrors.length > 0 ? { validation_errors: validationErrors } : {},
|
|
271
|
+
next_steps: [
|
|
272
|
+
...validationErrors.length > 0 ? [`WARNING: ${validationErrors.length} validation error(s) found — fix before executing`] : [],
|
|
273
|
+
"Review the preview above",
|
|
274
|
+
"Call contentrain_apply with mode:extract and dry_run:false to execute"
|
|
275
|
+
]
|
|
276
|
+
};
|
|
277
|
+
if (validationErrors.length > 0) return {
|
|
278
|
+
dry_run: false,
|
|
279
|
+
error: "Model validation failed — cannot execute extract with invalid model definitions",
|
|
280
|
+
validation_errors: validationErrors,
|
|
281
|
+
next_steps: ["Fix the validation errors and retry"]
|
|
282
|
+
};
|
|
283
|
+
const health = await checkBranchHealth(projectRoot);
|
|
284
|
+
if (health.blocked) return {
|
|
285
|
+
error: health.message,
|
|
286
|
+
action: "blocked",
|
|
287
|
+
hint: "Merge or delete old contentrain/* branches before executing normalize."
|
|
288
|
+
};
|
|
289
|
+
const branchName = buildBranchName("normalize", "extract");
|
|
290
|
+
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
|
|
291
|
+
const sourceMap = [];
|
|
292
|
+
const modelsCreated = [];
|
|
293
|
+
const modelsUpdated = [];
|
|
294
|
+
let entriesWritten = 0;
|
|
295
|
+
try {
|
|
296
|
+
await tx.write(async (wt) => {
|
|
297
|
+
for (const ext of extractions) {
|
|
298
|
+
const existing = await readModel(wt, ext.model);
|
|
299
|
+
if (existing) {
|
|
300
|
+
if (ext.fields) {
|
|
301
|
+
const merged = {
|
|
302
|
+
...existing.fields,
|
|
303
|
+
...ext.fields
|
|
304
|
+
};
|
|
305
|
+
if (Object.keys(ext.fields).filter((k) => !(k in (existing.fields ?? {}))).length > 0) {
|
|
306
|
+
existing.fields = merged;
|
|
307
|
+
await writeModel(wt, existing);
|
|
308
|
+
modelsUpdated.push(ext.model);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
await writeModel(wt, {
|
|
313
|
+
id: ext.model,
|
|
314
|
+
name: ext.model.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "),
|
|
315
|
+
kind: ext.kind,
|
|
316
|
+
domain: ext.domain,
|
|
317
|
+
i18n: ext.i18n ?? true,
|
|
318
|
+
fields: ext.fields
|
|
319
|
+
});
|
|
320
|
+
modelsCreated.push(ext.model);
|
|
321
|
+
}
|
|
322
|
+
const model = await readModel(wt, ext.model);
|
|
323
|
+
const entries = ext.entries.map((e) => ({
|
|
324
|
+
locale: e.locale,
|
|
325
|
+
slug: e.slug,
|
|
326
|
+
data: e.data
|
|
327
|
+
}));
|
|
328
|
+
await writeContent(wt, model, entries, await readConfig(wt) ?? config);
|
|
329
|
+
entriesWritten += entries.length;
|
|
330
|
+
for (const entry of ext.entries) if (ext.kind === "dictionary" && entry.sources) for (const s of entry.sources) sourceMap.push({
|
|
331
|
+
model: ext.model,
|
|
332
|
+
locale: entry.locale ?? config.locales.default,
|
|
333
|
+
value: s.value,
|
|
334
|
+
file: s.file,
|
|
335
|
+
line: s.line
|
|
336
|
+
});
|
|
337
|
+
else if (entry.source) sourceMap.push({
|
|
338
|
+
model: ext.model,
|
|
339
|
+
locale: entry.locale ?? config.locales.default,
|
|
340
|
+
value: entry.source.value,
|
|
341
|
+
file: entry.source.file,
|
|
342
|
+
line: entry.source.line
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (sourceMap.length > 0) {
|
|
346
|
+
const sourcesByModel = {};
|
|
347
|
+
for (const s of sourceMap) {
|
|
348
|
+
if (!sourcesByModel[s.model]) sourcesByModel[s.model] = {
|
|
349
|
+
source_files: [],
|
|
350
|
+
entry_count: 0
|
|
351
|
+
};
|
|
352
|
+
const modelEntry = sourcesByModel[s.model];
|
|
353
|
+
if (!modelEntry.source_files.includes(s.file)) modelEntry.source_files.push(s.file);
|
|
354
|
+
modelEntry.entry_count++;
|
|
355
|
+
}
|
|
356
|
+
const sourcesJson = JSON.stringify({
|
|
357
|
+
version: 1,
|
|
358
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
359
|
+
models: sourcesByModel
|
|
360
|
+
}, null, 2) + "\n";
|
|
361
|
+
await writeText(join(wt, ".contentrain", "normalize-sources.json"), sourcesJson);
|
|
362
|
+
}
|
|
363
|
+
await writeContext(wt, {
|
|
364
|
+
tool: "contentrain_apply",
|
|
365
|
+
model: extractions.map((e) => e.model).join(","),
|
|
366
|
+
locale: config.locales.default,
|
|
367
|
+
entries: extractions.flatMap((e) => e.entries.map((en) => en.slug ?? "entry"))
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
const commitMsg = `[contentrain] normalize: extract ${entriesWritten} entries to ${extractions.length} models`;
|
|
371
|
+
await tx.commit(commitMsg);
|
|
372
|
+
const gitResult = {
|
|
373
|
+
branch: branchName,
|
|
374
|
+
action: "pending-review",
|
|
375
|
+
commit: ""
|
|
376
|
+
};
|
|
377
|
+
try {
|
|
378
|
+
const completed = await tx.complete();
|
|
379
|
+
gitResult.action = completed.action;
|
|
380
|
+
gitResult.commit = completed.commit;
|
|
381
|
+
} catch {
|
|
382
|
+
gitResult.action = "pending-review";
|
|
383
|
+
} finally {
|
|
384
|
+
await tx.cleanup();
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
dry_run: false,
|
|
388
|
+
results: {
|
|
389
|
+
models_created: modelsCreated,
|
|
390
|
+
models_updated: modelsUpdated,
|
|
391
|
+
entries_written: entriesWritten,
|
|
392
|
+
source_map: sourceMap
|
|
393
|
+
},
|
|
394
|
+
git: gitResult,
|
|
395
|
+
context_updated: true,
|
|
396
|
+
next_steps: [
|
|
397
|
+
"Run contentrain_validate to check the extracted content",
|
|
398
|
+
"Run contentrain_submit to push the branch for review",
|
|
399
|
+
"After review, proceed with mode:reuse to patch source files"
|
|
400
|
+
]
|
|
401
|
+
};
|
|
402
|
+
} catch (error) {
|
|
403
|
+
await tx.cleanup();
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function applyReuse(projectRoot, input) {
|
|
408
|
+
const config = await readConfig(projectRoot);
|
|
409
|
+
if (!config) throw new Error("Project not initialized. Run contentrain_init first.");
|
|
410
|
+
const { scope, patches, dry_run } = input;
|
|
411
|
+
if (!scope.model && !scope.domain) throw new Error("Scope required: provide model or domain. Whole-project patching is not allowed.");
|
|
412
|
+
if (patches.length > MAX_PATCHES) throw new Error(`Too many patches (${patches.length}). Maximum ${MAX_PATCHES} per operation. Split into multiple calls.`);
|
|
413
|
+
if (scope.model) {
|
|
414
|
+
if (!await readModel(projectRoot, scope.model)) throw new Error(`Model "${scope.model}" not found. Run extract phase first.`);
|
|
415
|
+
}
|
|
416
|
+
const scopeWarnings = [];
|
|
417
|
+
for (const patch of patches) {
|
|
418
|
+
const pathError = validatePatchPath(patch.file);
|
|
419
|
+
if (pathError) throw new Error(`Invalid patch path: ${pathError}`);
|
|
420
|
+
}
|
|
421
|
+
if (scope.model || scope.domain) {
|
|
422
|
+
const models = await listModels(projectRoot);
|
|
423
|
+
const scopeModels = scope.model ? models.filter((m) => m.id === scope.model) : scope.domain ? models.filter((m) => m.domain === scope.domain) : models;
|
|
424
|
+
if (scopeModels.length === 0) throw new Error(`No models found for scope ${scope.model ? `model="${scope.model}"` : `domain="${scope.domain}"`}`);
|
|
425
|
+
const { autoDetectSourceDirs } = await import("./core/scan-config.mjs");
|
|
426
|
+
const sourceDirs = await autoDetectSourceDirs(projectRoot);
|
|
427
|
+
const allowedPrefixes = sourceDirs.map((d) => d === "." ? "" : d + "/");
|
|
428
|
+
for (const patch of patches) {
|
|
429
|
+
const normalizedPath = patch.file.replace(/\\/g, "/");
|
|
430
|
+
if (normalizedPath.startsWith(".contentrain/") || normalizedPath.includes("/.contentrain/")) throw new Error(`Cannot patch content/config files directly: "${patch.file}". Reuse patches source files only.`);
|
|
431
|
+
if (sourceDirs.length > 0 && sourceDirs[0] !== ".") {
|
|
432
|
+
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.`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (scope.model || scope.domain) {
|
|
436
|
+
const sourcesRaw = await readText(join(projectRoot, ".contentrain", "normalize-sources.json"));
|
|
437
|
+
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.");
|
|
438
|
+
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.");
|
|
439
|
+
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.");
|
|
440
|
+
else {
|
|
441
|
+
const sourcesData = JSON.parse(sourcesRaw);
|
|
442
|
+
let allowedSourceFiles = [];
|
|
443
|
+
let scopeLabel = "";
|
|
444
|
+
if (scope.model) {
|
|
445
|
+
const modelSources = sourcesData.models?.[scope.model]?.source_files;
|
|
446
|
+
if (modelSources) allowedSourceFiles = modelSources;
|
|
447
|
+
scopeLabel = `model "${scope.model}"`;
|
|
448
|
+
} else if (scope.domain) {
|
|
449
|
+
const domainModelIds = scopeModels.map((m) => m.id);
|
|
450
|
+
for (const modelId of domainModelIds) {
|
|
451
|
+
const modelSources = sourcesData.models?.[modelId]?.source_files;
|
|
452
|
+
if (modelSources) allowedSourceFiles.push(...modelSources);
|
|
453
|
+
}
|
|
454
|
+
allowedSourceFiles = [...new Set(allowedSourceFiles)];
|
|
455
|
+
scopeLabel = `domain "${scope.domain}"`;
|
|
456
|
+
}
|
|
457
|
+
if (allowedSourceFiles.length > 0) {
|
|
458
|
+
const outOfScopePatches = [];
|
|
459
|
+
for (const patch of patches) {
|
|
460
|
+
const normalizedPath = patch.file.replace(/\\/g, "/");
|
|
461
|
+
if (!allowedSourceFiles.includes(normalizedPath)) outOfScopePatches.push(patch.file);
|
|
462
|
+
}
|
|
463
|
+
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(", ")}.`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const patchesByFile = /* @__PURE__ */ new Map();
|
|
469
|
+
for (const patch of patches) {
|
|
470
|
+
if (!patchesByFile.has(patch.file)) patchesByFile.set(patch.file, []);
|
|
471
|
+
patchesByFile.get(patch.file).push(patch);
|
|
472
|
+
}
|
|
473
|
+
const filesToModify = [...patchesByFile.keys()];
|
|
474
|
+
const importsToAdd = patches.filter((p) => p.import_statement).length;
|
|
475
|
+
if (dry_run !== false) return {
|
|
476
|
+
dry_run: true,
|
|
477
|
+
preview: {
|
|
478
|
+
files_to_modify: filesToModify,
|
|
479
|
+
patches_count: patches.length,
|
|
480
|
+
imports_to_add: importsToAdd
|
|
481
|
+
},
|
|
482
|
+
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
|
|
483
|
+
next_steps: [
|
|
484
|
+
...scopeWarnings.length > 0 ? [`WARNING: ${scopeWarnings.length} patch file(s) not in extract source map — verify intent`] : [],
|
|
485
|
+
"Review the files and patches above",
|
|
486
|
+
"Call contentrain_apply with mode:reuse and dry_run:false to execute"
|
|
487
|
+
]
|
|
488
|
+
};
|
|
489
|
+
const reuseHealth = await checkBranchHealth(projectRoot);
|
|
490
|
+
if (reuseHealth.blocked) return {
|
|
491
|
+
dry_run: false,
|
|
492
|
+
error: `Branch blocked: ${reuseHealth.message}`,
|
|
493
|
+
next_steps: ["Merge or delete old contentrain/* branches before executing reuse."]
|
|
494
|
+
};
|
|
495
|
+
const scopeTarget = scope.model ?? scope.domain;
|
|
496
|
+
const branchName = buildBranchName("normalize/reuse", scopeTarget);
|
|
497
|
+
const tx = await createTransaction(projectRoot, branchName, { workflowOverride: "review" });
|
|
498
|
+
const filesModified = [];
|
|
499
|
+
let patchesApplied = 0;
|
|
500
|
+
let importsAdded = 0;
|
|
501
|
+
const patchesSkipped = [];
|
|
502
|
+
const frameworkWarnings = [];
|
|
503
|
+
const syntaxErrors = [];
|
|
504
|
+
try {
|
|
505
|
+
await tx.write(async (wt) => {
|
|
506
|
+
for (const [relFile, filePatches] of patchesByFile) {
|
|
507
|
+
const absPath = join(wt, relFile);
|
|
508
|
+
if (!await pathExists(absPath)) {
|
|
509
|
+
for (const p of filePatches) patchesSkipped.push({
|
|
510
|
+
file: relFile,
|
|
511
|
+
line: p.line,
|
|
512
|
+
reason: "file not found"
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const content = await readText(absPath);
|
|
517
|
+
if (content === null) {
|
|
518
|
+
for (const p of filePatches) patchesSkipped.push({
|
|
519
|
+
file: relFile,
|
|
520
|
+
line: p.line,
|
|
521
|
+
reason: "file unreadable"
|
|
522
|
+
});
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const sorted = [...filePatches].toSorted((a, b) => b.line - a.line);
|
|
526
|
+
const lines = content.split("\n");
|
|
527
|
+
let fileModified = false;
|
|
528
|
+
for (const patch of sorted) {
|
|
529
|
+
const isTagTextContext = (lines[patch.line - 1] ?? "").includes(`>${patch.old_value}<`);
|
|
530
|
+
const replacementContext = isTagTextContext ? "tag_text" : "other";
|
|
531
|
+
if (isTagTextContext) {
|
|
532
|
+
const fwWarning = validateFrameworkExpression(relFile, patch.new_expression, replacementContext);
|
|
533
|
+
if (fwWarning) frameworkWarnings.push({
|
|
534
|
+
file: relFile,
|
|
535
|
+
warning: fwWarning
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
if (applyPatchToLines(lines, patch)) {
|
|
539
|
+
patchesApplied++;
|
|
540
|
+
fileModified = true;
|
|
541
|
+
} else patchesSkipped.push({
|
|
542
|
+
file: relFile,
|
|
543
|
+
line: patch.line,
|
|
544
|
+
reason: "old_value not found at or near specified line"
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
const importStatements = new Set(filePatches.filter((p) => p.import_statement).map((p) => p.import_statement));
|
|
548
|
+
if (importStatements.size > 0) {
|
|
549
|
+
const added = addImportsToLines(lines, importStatements);
|
|
550
|
+
importsAdded += added;
|
|
551
|
+
if (added > 0) fileModified = true;
|
|
552
|
+
}
|
|
553
|
+
if (fileModified) {
|
|
554
|
+
const newContent = lines.join("\n");
|
|
555
|
+
await writeText(absPath, newContent);
|
|
556
|
+
filesModified.push(relFile);
|
|
557
|
+
const syntaxError = checkSyntax(relFile, newContent);
|
|
558
|
+
if (syntaxError) syntaxErrors.push({
|
|
559
|
+
file: relFile,
|
|
560
|
+
error: syntaxError
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
await writeContext(wt, {
|
|
565
|
+
tool: "contentrain_apply",
|
|
566
|
+
model: scopeTarget,
|
|
567
|
+
locale: config.locales.default
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
if (filesModified.length === 0) {
|
|
571
|
+
await tx.cleanup();
|
|
572
|
+
return {
|
|
573
|
+
dry_run: false,
|
|
574
|
+
results: {
|
|
575
|
+
files_modified: [],
|
|
576
|
+
patches_applied: 0,
|
|
577
|
+
patches_skipped: patchesSkipped,
|
|
578
|
+
imports_added: 0,
|
|
579
|
+
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0
|
|
580
|
+
},
|
|
581
|
+
next_steps: ["No files were modified. Check patch definitions and try again."]
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
const commitMsg = `[contentrain] normalize: reuse ${scopeTarget} — patch ${filesModified.length} files (${patchesApplied} replacements)`;
|
|
585
|
+
await tx.commit(commitMsg);
|
|
586
|
+
const gitResult = {
|
|
587
|
+
branch: branchName,
|
|
588
|
+
action: "pending-review",
|
|
589
|
+
commit: ""
|
|
590
|
+
};
|
|
591
|
+
try {
|
|
592
|
+
const completed = await tx.complete();
|
|
593
|
+
gitResult.action = completed.action;
|
|
594
|
+
gitResult.commit = completed.commit;
|
|
595
|
+
} catch {
|
|
596
|
+
gitResult.action = "pending-review";
|
|
597
|
+
} finally {
|
|
598
|
+
await tx.cleanup();
|
|
599
|
+
}
|
|
600
|
+
return {
|
|
601
|
+
dry_run: false,
|
|
602
|
+
results: {
|
|
603
|
+
files_modified: filesModified,
|
|
604
|
+
patches_applied: patchesApplied,
|
|
605
|
+
patches_skipped: patchesSkipped,
|
|
606
|
+
imports_added: importsAdded,
|
|
607
|
+
framework_warnings: frameworkWarnings.length > 0 ? frameworkWarnings : void 0,
|
|
608
|
+
syntax_errors: syntaxErrors.length > 0 ? syntaxErrors : void 0
|
|
609
|
+
},
|
|
610
|
+
...scopeWarnings.length > 0 ? { scope_warnings: scopeWarnings } : {},
|
|
611
|
+
git: gitResult,
|
|
612
|
+
next_steps: [
|
|
613
|
+
"Run contentrain_validate to verify the patched files",
|
|
614
|
+
patchesSkipped.length > 0 ? `${patchesSkipped.length} patches were skipped — review and retry if needed` : "",
|
|
615
|
+
syntaxErrors.length > 0 ? `WARNING: ${syntaxErrors.length} file(s) may have syntax errors after patching — review manually` : "",
|
|
616
|
+
scopeWarnings.length > 0 ? `NOTE: ${scopeWarnings.length} patch file(s) not in extract source map` : "",
|
|
617
|
+
"Run contentrain_submit to push the branch for review"
|
|
618
|
+
].filter(Boolean)
|
|
619
|
+
};
|
|
620
|
+
} catch (error) {
|
|
621
|
+
await tx.cleanup();
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Apply a single patch to a lines array. Mutates lines in place.
|
|
627
|
+
* Uses line hint for proximity matching — searches ±10 lines from hint.
|
|
628
|
+
*/
|
|
629
|
+
function applyPatchToLines(lines, patch) {
|
|
630
|
+
const { line, old_value, new_expression } = patch;
|
|
631
|
+
const lineIdx = line - 1;
|
|
632
|
+
const searchStart = Math.max(0, lineIdx - 10);
|
|
633
|
+
const searchEnd = Math.min(lines.length, lineIdx + 11);
|
|
634
|
+
if (lineIdx >= 0 && lineIdx < lines.length) {
|
|
635
|
+
const replaced = replaceInLine(lines[lineIdx], old_value, new_expression);
|
|
636
|
+
if (replaced !== null) {
|
|
637
|
+
lines[lineIdx] = replaced;
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for (let i = searchStart; i < searchEnd; i++) {
|
|
642
|
+
if (i === lineIdx) continue;
|
|
643
|
+
const replaced = replaceInLine(lines[i], old_value, new_expression);
|
|
644
|
+
if (replaced !== null) {
|
|
645
|
+
lines[i] = replaced;
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Replace old_value with new_expression in a single line.
|
|
653
|
+
* Matches the string literal (quoted or unquoted tag text).
|
|
654
|
+
* Returns the modified line, or null if not found.
|
|
655
|
+
*
|
|
656
|
+
* Guardrail #4: Safer patch matching — word boundary awareness and
|
|
657
|
+
* ambiguity rejection for plain text fallback.
|
|
658
|
+
*/
|
|
659
|
+
function replaceInLine(line, oldValue, newExpression) {
|
|
660
|
+
for (const quote of [
|
|
661
|
+
"\"",
|
|
662
|
+
"'",
|
|
663
|
+
"`"
|
|
664
|
+
]) {
|
|
665
|
+
const quoted = `${quote}${oldValue}${quote}`;
|
|
666
|
+
if (line.includes(quoted)) return line.replace(quoted, newExpression);
|
|
667
|
+
}
|
|
668
|
+
if (line.includes(`>${oldValue}<`)) return line.replace(`>${oldValue}<`, `>${newExpression}<`);
|
|
669
|
+
if (line.includes(oldValue)) {
|
|
670
|
+
if (countOccurrences(line, oldValue) > 1) return null;
|
|
671
|
+
const idx = line.indexOf(oldValue);
|
|
672
|
+
const charBefore = idx > 0 ? line[idx - 1] : "";
|
|
673
|
+
const charAfter = idx + oldValue.length < line.length ? line[idx + oldValue.length] : "";
|
|
674
|
+
const oldStartsWithWord = oldValue.length > 0 && isWordChar(oldValue[0]);
|
|
675
|
+
const oldEndsWithWord = oldValue.length > 0 && isWordChar(oldValue[oldValue.length - 1]);
|
|
676
|
+
if (oldStartsWithWord && isWordChar(charBefore)) return null;
|
|
677
|
+
if (oldEndsWithWord && isWordChar(charAfter)) return null;
|
|
678
|
+
return line.replace(oldValue, newExpression);
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
/** Count non-overlapping occurrences of a substring */
|
|
683
|
+
function countOccurrences(str, sub) {
|
|
684
|
+
let count = 0;
|
|
685
|
+
let pos = 0;
|
|
686
|
+
while (pos <= str.length - sub.length) {
|
|
687
|
+
const idx = str.indexOf(sub, pos);
|
|
688
|
+
if (idx === -1) break;
|
|
689
|
+
count++;
|
|
690
|
+
pos = idx + sub.length;
|
|
691
|
+
}
|
|
692
|
+
return count;
|
|
693
|
+
}
|
|
694
|
+
/** Check if a character is a word character (letter, digit, underscore) */
|
|
695
|
+
function isWordChar(ch) {
|
|
696
|
+
return /\w/.test(ch);
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Add import statements to the top of a file (after existing imports).
|
|
700
|
+
* Deduplicates — won't add if the import already exists.
|
|
701
|
+
* Returns number of imports actually added.
|
|
702
|
+
*/
|
|
703
|
+
function addImportsToLines(lines, imports) {
|
|
704
|
+
let added = 0;
|
|
705
|
+
const existingContent = lines.join("\n");
|
|
706
|
+
let lastImportIdx = -1;
|
|
707
|
+
let inMultiLineImport = false;
|
|
708
|
+
for (let i = 0; i < lines.length; i++) {
|
|
709
|
+
const trimmed = lines[i].trim();
|
|
710
|
+
if (inMultiLineImport) {
|
|
711
|
+
lastImportIdx = i;
|
|
712
|
+
if (trimmed.includes("}")) inMultiLineImport = false;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (trimmed.startsWith("import ") || trimmed.startsWith("import{")) {
|
|
716
|
+
lastImportIdx = i;
|
|
717
|
+
if (trimmed.includes("{") && !trimmed.includes("}")) inMultiLineImport = true;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (lastImportIdx >= 0 && trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) break;
|
|
721
|
+
}
|
|
722
|
+
let insertAt;
|
|
723
|
+
if (lastImportIdx >= 0) insertAt = lastImportIdx + 1;
|
|
724
|
+
else {
|
|
725
|
+
insertAt = 0;
|
|
726
|
+
for (let i = 0; i < lines.length; i++) {
|
|
727
|
+
const trimmed = lines[i].trim();
|
|
728
|
+
if (i === 0 && trimmed.startsWith("#!")) {
|
|
729
|
+
insertAt = i + 1;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
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\";") {
|
|
733
|
+
insertAt = i + 1;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (insertAt > 0 && trimmed.length > 0) break;
|
|
737
|
+
if (insertAt === 0 && trimmed.length > 0) break;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const toInsert = [];
|
|
741
|
+
for (const imp of imports) if (!existingContent.includes(imp)) {
|
|
742
|
+
toInsert.push(imp);
|
|
743
|
+
added++;
|
|
744
|
+
}
|
|
745
|
+
if (toInsert.length > 0) lines.splice(insertAt, 0, ...toInsert);
|
|
746
|
+
return added;
|
|
747
|
+
}
|
|
748
|
+
//#endregion
|
|
749
|
+
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 };
|
|
750
|
+
|
|
751
|
+
//# sourceMappingURL=apply-manager-CaTuu7Lj.mjs.map
|