@contentrain/mcp 1.2.1 → 1.4.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.
Files changed (183) hide show
  1. package/README.md +93 -25
  2. package/dist/annotations-Clhbw2lh.mjs +126 -0
  3. package/dist/annotations-Clhbw2lh.mjs.map +1 -0
  4. package/dist/{apply-manager-BkPMfpsN.mjs → apply-manager-D5ym5Jba.mjs} +7 -7
  5. package/dist/{apply-manager-BkPMfpsN.mjs.map → apply-manager-D5ym5Jba.mjs.map} +1 -1
  6. package/dist/{astro-parser-BJ-54JGe.mjs → astro-parser-B-nLl0zE.mjs} +3 -3
  7. package/dist/{astro-parser-BJ-54JGe.mjs.map → astro-parser-B-nLl0zE.mjs.map} +1 -1
  8. package/dist/{branch-lifecycle-wGJg25LT.mjs → branch-lifecycle-CFVpTpNT.mjs} +33 -3
  9. package/dist/{branch-lifecycle-wGJg25LT.mjs.map → branch-lifecycle-CFVpTpNT.mjs.map} +1 -1
  10. package/dist/{compiler-rIC4ETHv.mjs → compiler-k4yttlzM.mjs} +2 -2
  11. package/dist/{compiler-rIC4ETHv.mjs.map → compiler-k4yttlzM.mjs.map} +1 -1
  12. package/dist/{compiler-sfc.cjs-BbD04rn_.mjs → compiler-sfc.cjs-D0d_OicF.mjs} +2 -2
  13. package/dist/{compiler-sfc.cjs-BbD04rn_.mjs.map → compiler-sfc.cjs-D0d_OicF.mjs.map} +1 -1
  14. package/dist/config-BIl5Ybj0.mjs +40 -0
  15. package/dist/config-BIl5Ybj0.mjs.map +1 -0
  16. package/dist/content-manager-DqDLSNQ-.d.mts +63 -0
  17. package/dist/content-manager-DqDLSNQ-.d.mts.map +1 -0
  18. package/dist/context-J0OU9Nfp.mjs +102 -0
  19. package/dist/context-J0OU9Nfp.mjs.map +1 -0
  20. package/dist/contracts-DfL0BfrD.mjs +2 -0
  21. package/dist/core/apply-manager.mjs +10 -10
  22. package/dist/core/config.d.mts +13 -0
  23. package/dist/core/config.d.mts.map +1 -1
  24. package/dist/core/config.mjs +2 -3
  25. package/dist/core/content-manager.d.mts +2 -45
  26. package/dist/core/content-manager.mjs +3 -4
  27. package/dist/core/context.d.mts +32 -2
  28. package/dist/core/context.d.mts.map +1 -1
  29. package/dist/core/context.mjs +7 -7
  30. package/dist/core/contracts/index.d.mts +2 -0
  31. package/dist/core/contracts/index.mjs +2 -0
  32. package/dist/core/doctor.d.mts +76 -0
  33. package/dist/core/doctor.d.mts.map +1 -0
  34. package/dist/core/doctor.mjs +8 -0
  35. package/dist/core/graph-builder.mjs +3 -4
  36. package/dist/core/meta-manager.mjs +2 -3
  37. package/dist/core/model-manager.d.mts +34 -1
  38. package/dist/core/model-manager.d.mts.map +1 -1
  39. package/dist/core/model-manager.mjs +3 -4
  40. package/dist/core/ops/index.d.mts +137 -0
  41. package/dist/core/ops/index.d.mts.map +1 -0
  42. package/dist/core/ops/index.mjs +4 -0
  43. package/dist/core/overlay-reader.d.mts +42 -0
  44. package/dist/core/overlay-reader.d.mts.map +1 -0
  45. package/dist/core/overlay-reader.mjs +2 -0
  46. package/dist/core/scan-config.mjs +2 -3
  47. package/dist/core/scanner.mjs +4 -5
  48. package/dist/core/validator/index.d.mts +129 -0
  49. package/dist/core/validator/index.d.mts.map +1 -0
  50. package/dist/core/validator/index.mjs +6 -0
  51. package/dist/{detect-DinOQAmK.mjs → detect-DL9mFmaS.mjs} +2 -2
  52. package/dist/{detect-DinOQAmK.mjs.map → detect-DL9mFmaS.mjs.map} +1 -1
  53. package/dist/doctor-CgBoT6h1.mjs +338 -0
  54. package/dist/doctor-CgBoT6h1.mjs.map +1 -0
  55. package/dist/{fs-BnIQ4dKj.mjs → fs-DLbVB-Ek.mjs} +2 -2
  56. package/dist/fs-DLbVB-Ek.mjs.map +1 -0
  57. package/dist/git/branch-lifecycle.d.mts +30 -1
  58. package/dist/git/branch-lifecycle.d.mts.map +1 -1
  59. package/dist/git/branch-lifecycle.mjs +4 -5
  60. package/dist/git/transaction.d.mts +2 -2
  61. package/dist/git/transaction.d.mts.map +1 -1
  62. package/dist/git/transaction.mjs +8 -8
  63. package/dist/{graph-builder-tM7zj-SX.mjs → graph-builder-DXv2JEp-.mjs} +3 -3
  64. package/dist/{graph-builder-tM7zj-SX.mjs.map → graph-builder-DXv2JEp-.mjs.map} +1 -1
  65. package/dist/{id-DV_T9Ic8.mjs → id-CIO9s0x_.mjs} +1 -1
  66. package/dist/{id-DV_T9Ic8.mjs.map → id-CIO9s0x_.mjs.map} +1 -1
  67. package/dist/index-D0wQJSgs.d.mts +2 -0
  68. package/dist/index.mjs +22 -17
  69. package/dist/index.mjs.map +1 -1
  70. package/dist/local-DvP4Y4T-.mjs +182 -0
  71. package/dist/local-DvP4Y4T-.mjs.map +1 -0
  72. package/dist/{meta-manager--0IwAPCl.mjs → meta-manager-C900rEyG.mjs} +2 -2
  73. package/dist/{meta-manager--0IwAPCl.mjs.map → meta-manager-C900rEyG.mjs.map} +1 -1
  74. package/dist/{model-manager-DW_fOGRM.mjs → model-manager-BhLsUgaB.mjs} +241 -49
  75. package/dist/model-manager-BhLsUgaB.mjs.map +1 -0
  76. package/dist/{node-Cbq5eR5i.mjs → node-DN9hZeil.mjs} +1 -1
  77. package/dist/{node-Cbq5eR5i.mjs.map → node-DN9hZeil.mjs.map} +1 -1
  78. package/dist/ops-DjEWi5Wa.mjs +486 -0
  79. package/dist/ops-DjEWi5Wa.mjs.map +1 -0
  80. package/dist/overlay-reader-DOdyDRIv.mjs +74 -0
  81. package/dist/overlay-reader-DOdyDRIv.mjs.map +1 -0
  82. package/dist/paths-C3xNyc7z.mjs +52 -0
  83. package/dist/paths-C3xNyc7z.mjs.map +1 -0
  84. package/dist/paths-CYl5vCBD.mjs +54 -0
  85. package/dist/paths-CYl5vCBD.mjs.map +1 -0
  86. package/dist/providers/github/index.d.mts +201 -0
  87. package/dist/providers/github/index.d.mts.map +1 -0
  88. package/dist/providers/github/index.mjs +494 -0
  89. package/dist/providers/github/index.mjs.map +1 -0
  90. package/dist/providers/gitlab/index.d.mts +158 -0
  91. package/dist/providers/gitlab/index.d.mts.map +1 -0
  92. package/dist/providers/gitlab/index.mjs +361 -0
  93. package/dist/providers/gitlab/index.mjs.map +1 -0
  94. package/dist/providers/local/index.d.mts +106 -0
  95. package/dist/providers/local/index.d.mts.map +1 -0
  96. package/dist/providers/local/index.mjs +12 -0
  97. package/dist/{scan-config-QgnJ1-52.mjs → scan-config-jC6XmiqF.mjs} +2 -2
  98. package/dist/{scan-config-QgnJ1-52.mjs.map → scan-config-jC6XmiqF.mjs.map} +1 -1
  99. package/dist/{scanner-DUoXnmtI.mjs → scanner-DKMwc03k.mjs} +7 -7
  100. package/dist/{scanner-DUoXnmtI.mjs.map → scanner-DKMwc03k.mjs.map} +1 -1
  101. package/dist/serialization-B1CEzR4H.mjs +2 -0
  102. package/dist/server/http/index.d.mts +107 -0
  103. package/dist/server/http/index.d.mts.map +1 -0
  104. package/dist/server/http/index.mjs +248 -0
  105. package/dist/server/http/index.mjs.map +1 -0
  106. package/dist/{server-Dtq8vqbK.mjs → server-BkhhtEE2.mjs} +486 -355
  107. package/dist/server-BkhhtEE2.mjs.map +1 -0
  108. package/dist/server-WqGx6ait.d.mts +50 -0
  109. package/dist/server-WqGx6ait.d.mts.map +1 -0
  110. package/dist/server.d.mts +2 -7
  111. package/dist/server.mjs +22 -17
  112. package/dist/{source-map-generator-DpO-ejrt.mjs → source-map-generator-BWDva49I.mjs} +1 -1
  113. package/dist/{source-map-generator-DpO-ejrt.mjs.map → source-map-generator-BWDva49I.mjs.map} +1 -1
  114. package/dist/{svelte-parser-1bBcMx3T.mjs → svelte-parser-Dr9mmZuk.mjs} +3 -3
  115. package/dist/{svelte-parser-1bBcMx3T.mjs.map → svelte-parser-Dr9mmZuk.mjs.map} +1 -1
  116. package/dist/templates/index.mjs +1 -1
  117. package/dist/{templates-bRPzYq4M.mjs → templates-VrLVqfe5.mjs} +1 -1
  118. package/dist/{templates-bRPzYq4M.mjs.map → templates-VrLVqfe5.mjs.map} +1 -1
  119. package/dist/testing/conformance.d.mts +59 -0
  120. package/dist/testing/conformance.d.mts.map +1 -0
  121. package/dist/testing/conformance.mjs +59 -0
  122. package/dist/testing/conformance.mjs.map +1 -0
  123. package/dist/tools/annotations.d.mts +24 -0
  124. package/dist/tools/annotations.d.mts.map +1 -0
  125. package/dist/tools/annotations.mjs +2 -0
  126. package/dist/{transaction-C_Pvux78.mjs → transaction-DgkNgWSI.mjs} +61 -32
  127. package/dist/transaction-DgkNgWSI.mjs.map +1 -0
  128. package/dist/tsx-parser-BOMZE_yQ.mjs +2 -0
  129. package/dist/{tsx-parser-BWiMGAIR.mjs → tsx-parser-CQc6nr-o.mjs} +1 -1
  130. package/dist/{tsx-parser-BWiMGAIR.mjs.map → tsx-parser-CQc6nr-o.mjs.map} +1 -1
  131. package/dist/util/detect.mjs +2 -3
  132. package/dist/util/fs.mjs +1 -2
  133. package/dist/util/id.mjs +1 -1
  134. package/dist/validator-B0nNRFeU.mjs +906 -0
  135. package/dist/validator-B0nNRFeU.mjs.map +1 -0
  136. package/dist/{vue-parser-DTPTN3Yb.mjs → vue-parser-COgjArqv.mjs} +3 -3
  137. package/dist/{vue-parser-DTPTN3Yb.mjs.map → vue-parser-COgjArqv.mjs.map} +1 -1
  138. package/package.json +82 -23
  139. package/testing/conformance/01-new-collection-entry/expected/.contentrain/config.json +15 -0
  140. package/testing/conformance/01-new-collection-entry/expected/.contentrain/content/blog/blog/en.json +6 -0
  141. package/testing/conformance/01-new-collection-entry/expected/.contentrain/context.json +22 -0
  142. package/testing/conformance/01-new-collection-entry/expected/.contentrain/meta/blog/en.json +7 -0
  143. package/testing/conformance/01-new-collection-entry/expected/.contentrain/models/blog.json +16 -0
  144. package/testing/conformance/01-new-collection-entry/scenario.json +19 -0
  145. package/testing/conformance/01-new-collection-entry/setup/.contentrain/config.json +15 -0
  146. package/testing/conformance/01-new-collection-entry/setup/.contentrain/models/blog.json +16 -0
  147. package/testing/conformance/02-new-singleton/expected/.contentrain/config.json +15 -0
  148. package/testing/conformance/02-new-singleton/expected/.contentrain/content/marketing/hero/en.json +4 -0
  149. package/testing/conformance/02-new-singleton/expected/.contentrain/context.json +22 -0
  150. package/testing/conformance/02-new-singleton/expected/.contentrain/meta/hero/en.json +5 -0
  151. package/testing/conformance/02-new-singleton/expected/.contentrain/models/hero.json +16 -0
  152. package/testing/conformance/02-new-singleton/scenario.json +18 -0
  153. package/testing/conformance/02-new-singleton/setup/.contentrain/config.json +15 -0
  154. package/testing/conformance/02-new-singleton/setup/.contentrain/models/hero.json +16 -0
  155. package/testing/conformance/03-new-dictionary/expected/.contentrain/config.json +15 -0
  156. package/testing/conformance/03-new-dictionary/expected/.contentrain/content/marketing/ui-strings/en.json +5 -0
  157. package/testing/conformance/03-new-dictionary/expected/.contentrain/context.json +22 -0
  158. package/testing/conformance/03-new-dictionary/expected/.contentrain/meta/ui-strings/en.json +5 -0
  159. package/testing/conformance/03-new-dictionary/expected/.contentrain/models/ui-strings.json +7 -0
  160. package/testing/conformance/03-new-dictionary/scenario.json +19 -0
  161. package/testing/conformance/03-new-dictionary/setup/.contentrain/config.json +15 -0
  162. package/testing/conformance/03-new-dictionary/setup/.contentrain/models/ui-strings.json +7 -0
  163. package/testing/conformance/README.md +77 -0
  164. package/testing/conformance/scenarios.json +36 -0
  165. package/dist/config-B5kc2oeV.mjs +0 -27
  166. package/dist/config-B5kc2oeV.mjs.map +0 -1
  167. package/dist/context-DIDt_TxX.mjs +0 -46
  168. package/dist/context-DIDt_TxX.mjs.map +0 -1
  169. package/dist/core/content-manager.d.mts.map +0 -1
  170. package/dist/core/validator.d.mts +0 -23
  171. package/dist/core/validator.d.mts.map +0 -1
  172. package/dist/core/validator.mjs +0 -7
  173. package/dist/fs-BnIQ4dKj.mjs.map +0 -1
  174. package/dist/model-manager-DW_fOGRM.mjs.map +0 -1
  175. package/dist/serializer-CnHTtmc1.mjs +0 -2
  176. package/dist/server-Dtq8vqbK.mjs.map +0 -1
  177. package/dist/server.d.mts.map +0 -1
  178. package/dist/transaction-C_Pvux78.mjs.map +0 -1
  179. package/dist/tsx-parser-BFLv292Y.mjs +0 -2
  180. package/dist/util/serializer.d.mts +0 -2
  181. package/dist/util/serializer.mjs +0 -2
  182. package/dist/validator-SQYUV2JP.mjs +0 -581
  183. package/dist/validator-SQYUV2JP.mjs.map +0 -1
@@ -0,0 +1,906 @@
1
+ import { i as metaFilePath, n as contentFilePath, r as documentFilePath, t as contentDirPath } from "./paths-CYl5vCBD.mjs";
2
+ import { c as writeText, s as writeJson } from "./fs-DLbVB-Ek.mjs";
3
+ import { t as readConfig } from "./config-BIl5Ybj0.mjs";
4
+ import { C as LocalReader, f as parseFrontmatter, g as resolveLocaleStrategy, o as listModels, s as readModel } from "./model-manager-BhLsUgaB.mjs";
5
+ import { r as writeMeta } from "./meta-manager-C900rEyG.mjs";
6
+ import { join } from "node:path";
7
+ import { detectSecrets, validateFieldValue } from "@contentrain/types";
8
+ import { rm } from "node:fs/promises";
9
+ //#region src/core/validator/entry.ts
10
+ /**
11
+ * Validate a single content entry against its model's field schema.
12
+ *
13
+ * Merges the rule sets from MCP's legacy `validator.ts` (secret detection,
14
+ * schema validation, unique constraints) with Studio's `content-validation.ts`
15
+ * (email/url heuristics, polymorphic relation structure, nested object and
16
+ * array-of-object recursion). The union is the authoritative per-entry
17
+ * validator — both MCP's project validator and Studio's save path should
18
+ * converge on this function over time.
19
+ *
20
+ * Asynchronous relation-integrity checks (does the referenced entry exist?)
21
+ * live in `relation-integrity.ts` because they require I/O.
22
+ */
23
+ function validateContent(data, fields, modelId, locale, entryId, ctx) {
24
+ const errors = [];
25
+ for (const [fieldId, def] of Object.entries(fields)) {
26
+ const value = data[fieldId];
27
+ errors.push(...validateField(value, def, modelId, locale, entryId, fieldId, ctx));
28
+ }
29
+ return {
30
+ valid: errors.filter((e) => e.severity === "error").length === 0,
31
+ errors
32
+ };
33
+ }
34
+ function validateField(value, def, modelId, locale, entryId, fieldId, ctx) {
35
+ const errors = [];
36
+ const errCtx = {
37
+ model: modelId,
38
+ locale,
39
+ entry: entryId,
40
+ field: fieldId
41
+ };
42
+ if (value !== null && value !== void 0 && value !== "") {
43
+ const secretErrors = detectSecrets(value);
44
+ for (const e of secretErrors) errors.push({
45
+ ...e,
46
+ ...errCtx
47
+ });
48
+ }
49
+ if (!(def.type === "relation" || def.type === "relations")) {
50
+ const fieldErrors = validateFieldValue(value, def);
51
+ if (fieldErrors.length > 0) {
52
+ for (const e of fieldErrors) errors.push({
53
+ ...e,
54
+ ...errCtx
55
+ });
56
+ if (fieldErrors.some((e) => e.severity === "error")) return errors;
57
+ }
58
+ } else if (def.required && (value === null || value === void 0 || value === "")) {
59
+ errors.push({
60
+ severity: "error",
61
+ ...errCtx,
62
+ message: `${fieldId} is required`
63
+ });
64
+ return errors;
65
+ }
66
+ if (value === null || value === void 0) return errors;
67
+ if (def.unique && ctx?.allEntries) {
68
+ const valueKey = String(value);
69
+ for (const [otherId, otherEntry] of Object.entries(ctx.allEntries)) {
70
+ if (otherId === ctx.currentEntryId) continue;
71
+ const otherValue = otherEntry[fieldId];
72
+ if (otherValue !== null && otherValue !== void 0 && String(otherValue) === valueKey) {
73
+ errors.push({
74
+ severity: "error",
75
+ ...errCtx,
76
+ message: `${fieldId} must be unique — "${String(value)}" already exists in entry ${otherId}`
77
+ });
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ if (def.type === "email" && typeof value === "string" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) errors.push({
83
+ severity: "warning",
84
+ ...errCtx,
85
+ message: `${fieldId} may not be a valid email`
86
+ });
87
+ if (def.type === "url" && typeof value === "string" && !/^https?:\/\/.+/.test(value) && !value.startsWith("/")) errors.push({
88
+ severity: "warning",
89
+ ...errCtx,
90
+ message: `${fieldId} may not be a valid URL`
91
+ });
92
+ if (def.type === "relation" && def.model) {
93
+ const targets = Array.isArray(def.model) ? def.model : [def.model];
94
+ if (targets.length > 1) if (typeof value !== "object" || value === null || !("model" in value) || !("ref" in value)) errors.push({
95
+ severity: "error",
96
+ ...errCtx,
97
+ message: `${fieldId} must be { model, ref } for polymorphic relation`
98
+ });
99
+ else {
100
+ const polyVal = value;
101
+ if (!targets.includes(polyVal.model)) errors.push({
102
+ severity: "error",
103
+ ...errCtx,
104
+ message: `${fieldId} target model "${polyVal.model}" must be one of: ${targets.join(", ")}`
105
+ });
106
+ }
107
+ else if (typeof value !== "string") errors.push({
108
+ severity: "error",
109
+ ...errCtx,
110
+ message: `${fieldId} must be a string (entry ID or slug)`
111
+ });
112
+ }
113
+ if (def.type === "relations") if (!Array.isArray(value)) errors.push({
114
+ severity: "error",
115
+ ...errCtx,
116
+ message: `${fieldId} must be an array`
117
+ });
118
+ else {
119
+ if (def.min !== void 0 && value.length < def.min) errors.push({
120
+ severity: "error",
121
+ ...errCtx,
122
+ message: `${fieldId} must have at least ${def.min} items`
123
+ });
124
+ if (def.max !== void 0 && value.length > def.max) errors.push({
125
+ severity: "error",
126
+ ...errCtx,
127
+ message: `${fieldId} must have at most ${def.max} items`
128
+ });
129
+ for (let i = 0; i < value.length; i++) if (typeof value[i] !== "string") errors.push({
130
+ severity: "error",
131
+ ...errCtx,
132
+ message: `${fieldId}[${i}] must be a string (entry ID or slug)`
133
+ });
134
+ }
135
+ 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}]`));
136
+ if (def.type === "object" && def.fields && typeof value === "object" && value !== null && !Array.isArray(value)) {
137
+ const nested = validateContent(value, def.fields, modelId, locale, entryId);
138
+ errors.push(...nested.errors);
139
+ }
140
+ if (def.type === "array" && Array.isArray(value) && def.items && typeof def.items === "object" && def.items.type === "object" && def.items.fields) {
141
+ for (let i = 0; i < value.length; i++) if (typeof value[i] === "object" && value[i] !== null) {
142
+ const nested = validateContent(value[i], def.items.fields, modelId, locale, entryId);
143
+ for (const e of nested.errors) errors.push({
144
+ ...e,
145
+ field: `${fieldId}[${i}].${e.field}`
146
+ });
147
+ }
148
+ }
149
+ return errors;
150
+ }
151
+ function validateArrayItemType(value, itemType, errCtx, fieldPath) {
152
+ const ctx = {
153
+ ...errCtx,
154
+ field: fieldPath
155
+ };
156
+ switch (itemType) {
157
+ case "string":
158
+ case "email":
159
+ case "url":
160
+ case "slug":
161
+ case "image":
162
+ case "video":
163
+ case "file":
164
+ if (typeof value !== "string") return [{
165
+ severity: "error",
166
+ ...ctx,
167
+ message: `${fieldPath} must be a string`
168
+ }];
169
+ break;
170
+ case "number":
171
+ case "integer":
172
+ case "decimal":
173
+ if (typeof value !== "number") return [{
174
+ severity: "error",
175
+ ...ctx,
176
+ message: `${fieldPath} must be a number`
177
+ }];
178
+ if (itemType === "integer" && !Number.isInteger(value)) return [{
179
+ severity: "error",
180
+ ...ctx,
181
+ message: `${fieldPath} must be an integer`
182
+ }];
183
+ break;
184
+ case "boolean":
185
+ if (typeof value !== "boolean") return [{
186
+ severity: "error",
187
+ ...ctx,
188
+ message: `${fieldPath} must be a boolean`
189
+ }];
190
+ break;
191
+ }
192
+ return [];
193
+ }
194
+ //#endregion
195
+ //#region src/core/validator/relation-integrity.ts
196
+ /**
197
+ * Verify that relation and relations fields reference targets that actually
198
+ * exist. Two severities are supported — Studio's per-save flow emits
199
+ * `warning` (the referenced entry may still be drafted), while MCP's
200
+ * project-wide validator emits `error` and additionally flags missing
201
+ * target models via `resolveTarget`.
202
+ *
203
+ * The loader abstraction keeps this function I/O-agnostic: MCP wires it
204
+ * to filesystem reads through LocalReader, Studio wires it to a
205
+ * `GitProvider.readFile`-backed loader, mocks pass in-memory maps.
206
+ */
207
+ async function checkRelationIntegrity(data, fields, modelId, locale, entryId, loadContent, opts = {}) {
208
+ const errors = [];
209
+ const severity = opts.severity ?? "warning";
210
+ const resolve = opts.resolveTarget ?? (async (id, loc) => {
211
+ return {
212
+ exists: true,
213
+ content: await loadContent(id, loc)
214
+ };
215
+ });
216
+ for (const [fieldId, def] of Object.entries(fields)) {
217
+ const value = data[fieldId];
218
+ if (value === null || value === void 0) continue;
219
+ if (def.type === "relation" && def.model) {
220
+ const targets = Array.isArray(def.model) ? def.model : [def.model];
221
+ if (targets.length > 1 && typeof value === "object" && value !== null) {
222
+ const polyVal = value;
223
+ if (polyVal.model && polyVal.ref) {
224
+ const resolved = await resolve(polyVal.model, locale);
225
+ if (!resolved.exists) errors.push({
226
+ severity,
227
+ model: modelId,
228
+ locale,
229
+ entry: entryId,
230
+ field: fieldId,
231
+ message: `Broken relation: target model "${polyVal.model}" not found`
232
+ });
233
+ else if (resolved.content && !(polyVal.ref in resolved.content)) errors.push({
234
+ severity,
235
+ model: modelId,
236
+ locale,
237
+ entry: entryId,
238
+ field: fieldId,
239
+ message: `Broken relation: "${polyVal.ref}" not found in ${polyVal.model}`
240
+ });
241
+ }
242
+ } else if (typeof value === "string" && targets[0]) {
243
+ const resolved = await resolve(targets[0], locale);
244
+ if (!resolved.exists) errors.push({
245
+ severity,
246
+ model: modelId,
247
+ locale,
248
+ entry: entryId,
249
+ field: fieldId,
250
+ message: `Broken relation: target model "${targets[0]}" not found`
251
+ });
252
+ else if (resolved.content && !(value in resolved.content)) errors.push({
253
+ severity,
254
+ model: modelId,
255
+ locale,
256
+ entry: entryId,
257
+ field: fieldId,
258
+ message: `Broken relation: "${value}" not found in ${targets[0]}`
259
+ });
260
+ }
261
+ }
262
+ if (def.type === "relations" && def.model && Array.isArray(value)) {
263
+ const target = Array.isArray(def.model) ? def.model[0] : def.model;
264
+ if (target) {
265
+ const resolved = await resolve(target, locale);
266
+ if (!resolved.exists) errors.push({
267
+ severity,
268
+ model: modelId,
269
+ locale,
270
+ entry: entryId,
271
+ field: fieldId,
272
+ message: `Broken relation: target model "${target}" not found`
273
+ });
274
+ else if (resolved.content) {
275
+ for (const ref of value) if (typeof ref === "string" && !(ref in resolved.content)) errors.push({
276
+ severity,
277
+ model: modelId,
278
+ locale,
279
+ entry: entryId,
280
+ field: fieldId,
281
+ message: `Broken relation: "${ref}" not found in ${target}`
282
+ });
283
+ }
284
+ }
285
+ }
286
+ }
287
+ return errors;
288
+ }
289
+ //#endregion
290
+ //#region src/core/validator/schedule.ts
291
+ /**
292
+ * Validate `publish_at` and `expire_at` meta fields.
293
+ *
294
+ * Rules (matches legacy `validator.ts:validateScheduleFields`):
295
+ * - `publish_at` must parse as a valid Date
296
+ * - `expire_at` must parse as a valid Date
297
+ * - When both are present, `expire_at` must be strictly after `publish_at`
298
+ */
299
+ function validateScheduleFields(meta, ctx, issues) {
300
+ if (meta.publish_at !== void 0) {
301
+ const d = new Date(meta.publish_at);
302
+ if (Number.isNaN(d.getTime())) issues.push({
303
+ severity: "error",
304
+ ...ctx,
305
+ message: `Invalid publish_at date: "${meta.publish_at}". Must be a valid ISO 8601 date string.`
306
+ });
307
+ }
308
+ if (meta.expire_at !== void 0) {
309
+ const d = new Date(meta.expire_at);
310
+ if (Number.isNaN(d.getTime())) issues.push({
311
+ severity: "error",
312
+ ...ctx,
313
+ message: `Invalid expire_at date: "${meta.expire_at}". Must be a valid ISO 8601 date string.`
314
+ });
315
+ }
316
+ if (meta.publish_at !== void 0 && meta.expire_at !== void 0) {
317
+ const pubDate = new Date(meta.publish_at);
318
+ const expDate = new Date(meta.expire_at);
319
+ if (!Number.isNaN(pubDate.getTime()) && !Number.isNaN(expDate.getTime()) && expDate <= pubDate) issues.push({
320
+ severity: "error",
321
+ ...ctx,
322
+ message: `expire_at ("${meta.expire_at}") must be after publish_at ("${meta.publish_at}").`
323
+ });
324
+ }
325
+ }
326
+ //#endregion
327
+ //#region src/core/validator/project.ts
328
+ async function readJsonViaReader(reader, path) {
329
+ try {
330
+ return JSON.parse(await reader.readFile(path));
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+ async function readTextViaReader(reader, path) {
336
+ try {
337
+ return await reader.readFile(path);
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+ /**
343
+ * Build a `resolveTarget` adapter for `checkRelationIntegrity` that walks
344
+ * the project's content store via the shared {@link RepoReader}. Mirrors
345
+ * the target-resolution shape the legacy `checkRelation` used — collection
346
+ * targets return their entry object-map, documents return a "slug exists"
347
+ * marker map, singletons and dictionaries return null content so the
348
+ * checker skips key enforcement for them.
349
+ */
350
+ function buildProjectTargetResolver(reader) {
351
+ return async (targetModelId, targetLocale) => {
352
+ const targetModel = await readModel(reader, targetModelId);
353
+ if (!targetModel) return { exists: false };
354
+ if (targetModel.kind === "document") {
355
+ const slugs = await discoverDocumentSlugs(reader, contentDirPath(targetModel), targetModel);
356
+ return {
357
+ exists: true,
358
+ content: Object.fromEntries(slugs.map((s) => [s, true]))
359
+ };
360
+ }
361
+ if (targetModel.kind === "singleton" || targetModel.kind === "dictionary") return {
362
+ exists: true,
363
+ content: null
364
+ };
365
+ return {
366
+ exists: true,
367
+ content: await readJsonViaReader(reader, contentFilePath(targetModel, targetLocale)) ?? {}
368
+ };
369
+ };
370
+ }
371
+ /**
372
+ * Scan an entry's data fields for detected secrets in UNDECLARED keys —
373
+ * the legacy validator also flagged stray/rogue fields that were not in
374
+ * `model.fields`. `validateContent` only knows about declared fields, so
375
+ * this complementary pass preserves that coverage.
376
+ */
377
+ function scanUndeclaredFieldsForSecrets(data, declared, ctx, issues) {
378
+ for (const [fieldName, value] of Object.entries(data)) {
379
+ if (declared && fieldName in declared) continue;
380
+ if (detectSecrets(value).length > 0) issues.push({
381
+ severity: "error",
382
+ ...ctx,
383
+ field: fieldName,
384
+ message: `Potential secret detected in field "${fieldName}"`
385
+ });
386
+ }
387
+ }
388
+ async function validateCollectionModel(reader, projectRoot, model, config, issues, fix) {
389
+ let entriesChecked = 0;
390
+ let fixed = 0;
391
+ const locales = config.locales.supported;
392
+ const localeEntryIds = {};
393
+ const allEntryIds = /* @__PURE__ */ new Set();
394
+ const resolveTarget = buildProjectTargetResolver(reader);
395
+ for (const locale of locales) {
396
+ const filePath = contentFilePath(model, locale);
397
+ const data = await readJsonViaReader(reader, filePath);
398
+ if (!data) {
399
+ if (model.i18n) {
400
+ issues.push({
401
+ severity: "error",
402
+ model: model.id,
403
+ locale,
404
+ message: `Locale file missing: ${locale}.json`
405
+ });
406
+ if (fix && projectRoot) {
407
+ await writeJson(join(projectRoot, filePath), {});
408
+ fixed++;
409
+ }
410
+ }
411
+ continue;
412
+ }
413
+ const ids = new Set(Object.keys(data));
414
+ localeEntryIds[locale] = ids;
415
+ for (const id of ids) allEntryIds.add(id);
416
+ const keys = Object.keys(data);
417
+ const sorted = [...keys].toSorted();
418
+ if (keys.join(",") !== sorted.join(",")) {
419
+ issues.push({
420
+ severity: "warning",
421
+ model: model.id,
422
+ locale,
423
+ message: "Content file keys not in canonical order"
424
+ });
425
+ if (fix && projectRoot) {
426
+ const resorted = {};
427
+ for (const key of sorted) resorted[key] = data[key];
428
+ await writeJson(join(projectRoot, filePath), resorted);
429
+ fixed++;
430
+ }
431
+ }
432
+ for (const [entryId, fields] of Object.entries(data)) {
433
+ entriesChecked++;
434
+ scanUndeclaredFieldsForSecrets(fields, model.fields, {
435
+ model: model.id,
436
+ locale,
437
+ entry: entryId
438
+ }, issues);
439
+ if (!model.fields) continue;
440
+ const entryResult = validateContent(fields, model.fields, model.id, locale, entryId, {
441
+ allEntries: data,
442
+ currentEntryId: entryId
443
+ });
444
+ issues.push(...entryResult.errors);
445
+ const relationErrors = await checkRelationIntegrity(fields, model.fields, model.id, locale, entryId, async () => null, {
446
+ severity: "error",
447
+ resolveTarget
448
+ });
449
+ issues.push(...relationErrors);
450
+ }
451
+ }
452
+ if (model.i18n && Object.keys(localeEntryIds).length > 1) {
453
+ const localeKeys = Object.keys(localeEntryIds);
454
+ for (let i = 1; i < localeKeys.length; i++) {
455
+ const locA = localeKeys[0];
456
+ const locB = localeKeys[i];
457
+ const idsA = localeEntryIds[locA];
458
+ const idsB = localeEntryIds[locB];
459
+ for (const id of idsA) if (!idsB.has(id)) issues.push({
460
+ severity: "error",
461
+ model: model.id,
462
+ locale: locB,
463
+ entry: id,
464
+ message: `Entry parity: entry "${id}" exists in ${locA} but missing in ${locB}`
465
+ });
466
+ for (const id of idsB) if (!idsA.has(id)) issues.push({
467
+ severity: "error",
468
+ model: model.id,
469
+ locale: locA,
470
+ entry: id,
471
+ message: `Entry parity: entry "${id}" exists in ${locB} but missing in ${locA}`
472
+ });
473
+ }
474
+ }
475
+ for (const locale of locales) {
476
+ const metaRelPath = metaFilePath(model, locale);
477
+ const metaData = await readJsonViaReader(reader, metaRelPath);
478
+ const contentData = await readJsonViaReader(reader, contentFilePath(model, locale)) ?? {};
479
+ if (metaData) for (const metaEntryId of Object.keys(metaData)) {
480
+ validateScheduleFields(metaData[metaEntryId], {
481
+ model: model.id,
482
+ locale,
483
+ entry: metaEntryId
484
+ }, issues);
485
+ if (!(metaEntryId in contentData)) {
486
+ issues.push({
487
+ severity: "warning",
488
+ model: model.id,
489
+ locale,
490
+ entry: metaEntryId,
491
+ message: `Orphan meta: meta entry "${metaEntryId}" exists but content entry missing`
492
+ });
493
+ if (fix && projectRoot) {
494
+ delete metaData[metaEntryId];
495
+ const metaAbs = join(projectRoot, metaRelPath);
496
+ if (Object.keys(metaData).length > 0) await writeJson(metaAbs, metaData);
497
+ else await rm(metaAbs, { force: true });
498
+ fixed++;
499
+ }
500
+ }
501
+ }
502
+ for (const entryId of Object.keys(contentData)) if (!metaData || !(entryId in metaData)) {
503
+ issues.push({
504
+ severity: "warning",
505
+ model: model.id,
506
+ locale,
507
+ entry: entryId,
508
+ message: `Orphan content: entry "${entryId}" has no metadata`
509
+ });
510
+ if (fix && projectRoot) {
511
+ await writeMeta(projectRoot, model, {
512
+ locale,
513
+ entryId
514
+ }, {
515
+ status: "draft",
516
+ source: "import",
517
+ updated_by: "contentrain-mcp"
518
+ });
519
+ fixed++;
520
+ }
521
+ }
522
+ }
523
+ return {
524
+ entries: entriesChecked,
525
+ fixed
526
+ };
527
+ }
528
+ async function validateSingletonModel(reader, projectRoot, model, config, issues, fix) {
529
+ let entriesChecked = 0;
530
+ let fixed = 0;
531
+ for (const locale of config.locales.supported) {
532
+ const filePath = contentFilePath(model, locale);
533
+ const data = await readJsonViaReader(reader, filePath);
534
+ if (!data) {
535
+ if (model.i18n) {
536
+ issues.push({
537
+ severity: "error",
538
+ model: model.id,
539
+ locale,
540
+ message: `Locale file missing: ${locale}.json`
541
+ });
542
+ if (fix && projectRoot) {
543
+ await writeJson(join(projectRoot, filePath), {});
544
+ fixed++;
545
+ }
546
+ }
547
+ continue;
548
+ }
549
+ entriesChecked++;
550
+ scanUndeclaredFieldsForSecrets(data, model.fields, {
551
+ model: model.id,
552
+ locale
553
+ }, issues);
554
+ if (model.fields) {
555
+ const entryResult = validateContent(data, model.fields, model.id, locale);
556
+ issues.push(...entryResult.errors);
557
+ const resolveTarget = buildProjectTargetResolver(reader);
558
+ const relationErrors = await checkRelationIntegrity(data, model.fields, model.id, locale, void 0, async () => null, {
559
+ severity: "error",
560
+ resolveTarget
561
+ });
562
+ issues.push(...relationErrors);
563
+ }
564
+ const keys = Object.keys(data);
565
+ const sorted = [...keys].toSorted();
566
+ if (keys.join(",") !== sorted.join(",")) {
567
+ issues.push({
568
+ severity: "warning",
569
+ model: model.id,
570
+ locale,
571
+ message: "Content file keys not in canonical order"
572
+ });
573
+ if (fix && projectRoot) {
574
+ const resorted = {};
575
+ for (const key of sorted) resorted[key] = data[key];
576
+ await writeJson(join(projectRoot, filePath), resorted);
577
+ fixed++;
578
+ }
579
+ }
580
+ const singletonMetaData = await readJsonViaReader(reader, metaFilePath(model, locale));
581
+ if (singletonMetaData) validateScheduleFields(singletonMetaData, {
582
+ model: model.id,
583
+ locale
584
+ }, issues);
585
+ }
586
+ return {
587
+ entries: entriesChecked,
588
+ fixed
589
+ };
590
+ }
591
+ async function validateDictionaryModel(reader, projectRoot, model, config, issues, fix) {
592
+ let entriesChecked = 0;
593
+ let fixed = 0;
594
+ const localeKeys = {};
595
+ for (const locale of config.locales.supported) {
596
+ const filePath = contentFilePath(model, locale);
597
+ const data = await readJsonViaReader(reader, filePath);
598
+ if (!data) {
599
+ if (model.i18n) {
600
+ issues.push({
601
+ severity: "error",
602
+ model: model.id,
603
+ locale,
604
+ message: `Locale file missing: ${locale}.json`
605
+ });
606
+ if (fix && projectRoot) {
607
+ await writeJson(join(projectRoot, filePath), {});
608
+ fixed++;
609
+ }
610
+ }
611
+ continue;
612
+ }
613
+ entriesChecked++;
614
+ localeKeys[locale] = new Set(Object.keys(data));
615
+ for (const [key, value] of Object.entries(data)) if (detectSecrets(value).length > 0) issues.push({
616
+ severity: "error",
617
+ model: model.id,
618
+ locale,
619
+ field: key,
620
+ message: `Potential secret detected in key "${key}"`
621
+ });
622
+ const valueToKeys = /* @__PURE__ */ new Map();
623
+ for (const [key, value] of Object.entries(data)) {
624
+ const arr = valueToKeys.get(value);
625
+ if (arr) arr.push(key);
626
+ else valueToKeys.set(value, [key]);
627
+ }
628
+ for (const [value, dupeKeys] of valueToKeys) if (dupeKeys.length > 1) {
629
+ const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
630
+ issues.push({
631
+ severity: "warning",
632
+ model: model.id,
633
+ locale,
634
+ message: `Duplicate value "${truncated}" mapped to ${dupeKeys.length} keys: [${dupeKeys.join(", ")}]`
635
+ });
636
+ }
637
+ const keys = Object.keys(data);
638
+ const sorted = [...keys].toSorted();
639
+ if (keys.join(",") !== sorted.join(",")) {
640
+ issues.push({
641
+ severity: "warning",
642
+ model: model.id,
643
+ locale,
644
+ message: "Content file keys not in canonical order"
645
+ });
646
+ if (fix && projectRoot) {
647
+ const resorted = {};
648
+ for (const key of sorted) resorted[key] = data[key];
649
+ await writeJson(join(projectRoot, filePath), resorted);
650
+ fixed++;
651
+ }
652
+ }
653
+ }
654
+ if (model.i18n && Object.keys(localeKeys).length > 1) {
655
+ const localeNames = Object.keys(localeKeys);
656
+ for (let i = 1; i < localeNames.length; i++) {
657
+ const locA = localeNames[0];
658
+ const locB = localeNames[i];
659
+ const keysA = localeKeys[locA];
660
+ const keysB = localeKeys[locB];
661
+ for (const k of keysA) if (!keysB.has(k)) issues.push({
662
+ severity: "warning",
663
+ model: model.id,
664
+ locale: locB,
665
+ field: k,
666
+ message: `Key parity: key "${k}" exists in ${locA} but missing in ${locB}`
667
+ });
668
+ for (const k of keysB) if (!keysA.has(k)) issues.push({
669
+ severity: "warning",
670
+ model: model.id,
671
+ locale: locA,
672
+ field: k,
673
+ message: `Key parity: key "${k}" exists in ${locB} but missing in ${locA}`
674
+ });
675
+ }
676
+ }
677
+ return {
678
+ entries: entriesChecked,
679
+ fixed
680
+ };
681
+ }
682
+ async function discoverDocumentSlugs(reader, cDir, model) {
683
+ const strategy = resolveLocaleStrategy(model);
684
+ const entries = await reader.listDirectory(cDir);
685
+ if (!model.i18n) return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
686
+ if (strategy === "file") return entries.filter((e) => !e.startsWith("."));
687
+ if (strategy === "suffix") {
688
+ const slugs = /* @__PURE__ */ new Set();
689
+ for (const f of entries) {
690
+ if (!f.endsWith(".md")) continue;
691
+ const parts = f.replace(/\.md$/, "").split(".");
692
+ if (parts.length >= 2) {
693
+ parts.pop();
694
+ slugs.add(parts.join("."));
695
+ }
696
+ }
697
+ return [...slugs];
698
+ }
699
+ if (strategy === "directory") {
700
+ const slugs = /* @__PURE__ */ new Set();
701
+ const localeLists = await Promise.all(entries.filter((localeDir) => !localeDir.startsWith(".")).map((localeDir) => reader.listDirectory(`${cDir}/${localeDir}`)));
702
+ for (const files of localeLists) for (const f of files) if (f.endsWith(".md")) slugs.add(f.replace(/\.md$/, ""));
703
+ return [...slugs];
704
+ }
705
+ return entries.filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
706
+ }
707
+ async function validateDocumentModel(reader, projectRoot, model, config, issues, fix) {
708
+ let entriesChecked = 0;
709
+ let fixed = 0;
710
+ const cDir = contentDirPath(model);
711
+ if (!await reader.fileExists(cDir)) return {
712
+ entries: 0,
713
+ fixed: 0
714
+ };
715
+ const slugs = await discoverDocumentSlugs(reader, cDir, model);
716
+ const locales = config.locales.supported;
717
+ for (const slug of slugs) {
718
+ if (slug.startsWith(".")) continue;
719
+ for (const locale of locales) {
720
+ const filePath = documentFilePath(model, locale, slug);
721
+ const raw = await readTextViaReader(reader, filePath);
722
+ if (!raw) {
723
+ if (model.i18n) {
724
+ issues.push({
725
+ severity: "warning",
726
+ model: model.id,
727
+ locale,
728
+ slug,
729
+ message: `Missing translation: document "${slug}" missing ${locale} locale file`
730
+ });
731
+ if (fix && projectRoot) {
732
+ const template = `---\nslug: ${slug}\n---\n`;
733
+ await writeText(join(projectRoot, filePath), template);
734
+ fixed++;
735
+ }
736
+ }
737
+ continue;
738
+ }
739
+ entriesChecked++;
740
+ const { frontmatter, body } = parseFrontmatter(raw);
741
+ scanUndeclaredFieldsForSecrets(frontmatter, {
742
+ ...model.fields,
743
+ body: true
744
+ }, {
745
+ model: model.id,
746
+ locale,
747
+ slug
748
+ }, issues);
749
+ if (detectSecrets(body).length > 0) issues.push({
750
+ severity: "error",
751
+ model: model.id,
752
+ locale,
753
+ slug,
754
+ field: "body",
755
+ message: "Potential secret detected in document body"
756
+ });
757
+ if (model.fields) {
758
+ const fieldsWithoutBody = Object.fromEntries(Object.entries(model.fields).filter(([name]) => name !== "body"));
759
+ const entryResult = validateContent(frontmatter, fieldsWithoutBody, model.id, locale);
760
+ for (const err of entryResult.errors) issues.push({
761
+ ...err,
762
+ slug
763
+ });
764
+ const resolveTarget = buildProjectTargetResolver(reader);
765
+ const relationErrors = await checkRelationIntegrity(frontmatter, fieldsWithoutBody, model.id, locale, void 0, async () => null, {
766
+ severity: "error",
767
+ resolveTarget
768
+ });
769
+ for (const err of relationErrors) issues.push({
770
+ ...err,
771
+ slug
772
+ });
773
+ }
774
+ }
775
+ }
776
+ return {
777
+ entries: entriesChecked,
778
+ fixed
779
+ };
780
+ }
781
+ async function checkOrphanContent(reader, validModelIds, issues, _fix) {
782
+ const fixed = 0;
783
+ const contentBase = ".contentrain/content";
784
+ const domains = await reader.listDirectory(contentBase);
785
+ const modelLists = await Promise.all(domains.filter((d) => !d.startsWith(".")).map(async (d) => ({
786
+ domain: d,
787
+ dirs: await reader.listDirectory(`${contentBase}/${d}`)
788
+ })));
789
+ for (const { dirs } of modelLists) for (const modelDir of dirs) {
790
+ if (modelDir.startsWith(".")) continue;
791
+ if (!validModelIds.has(modelDir)) issues.push({
792
+ severity: "warning",
793
+ model: modelDir,
794
+ message: `Orphan content: content directory exists for deleted model "${modelDir}"`
795
+ });
796
+ }
797
+ return fixed;
798
+ }
799
+ async function validateProject(input, options) {
800
+ const reader = typeof input === "string" ? new LocalReader(input) : input;
801
+ const projectRoot = typeof input === "string" ? input : void 0;
802
+ const fix = Boolean(options?.fix) && projectRoot !== void 0;
803
+ const issues = [];
804
+ let totalEntries = 0;
805
+ let totalFixed = 0;
806
+ let modelsChecked = 0;
807
+ const config = await readConfig(reader);
808
+ if (!config) return {
809
+ valid: false,
810
+ summary: {
811
+ errors: 1,
812
+ warnings: 0,
813
+ notices: 0,
814
+ models_checked: 0,
815
+ entries_checked: 0
816
+ },
817
+ issues: [{
818
+ severity: "error",
819
+ message: "Project not initialized: config.json missing"
820
+ }],
821
+ fixed: 0
822
+ };
823
+ const modelSummaries = await listModels(reader);
824
+ const validModelIds = new Set(modelSummaries.map((m) => m.id));
825
+ const modelsToCheck = options?.model ? modelSummaries.filter((m) => m.id === options.model) : modelSummaries;
826
+ for (const summary of modelsToCheck) {
827
+ const model = await readModel(reader, summary.id);
828
+ if (!model) continue;
829
+ modelsChecked++;
830
+ let result;
831
+ switch (model.kind) {
832
+ case "collection":
833
+ result = await validateCollectionModel(reader, projectRoot, model, config, issues, fix);
834
+ break;
835
+ case "singleton":
836
+ result = await validateSingletonModel(reader, projectRoot, model, config, issues, fix);
837
+ break;
838
+ case "dictionary":
839
+ result = await validateDictionaryModel(reader, projectRoot, model, config, issues, fix);
840
+ break;
841
+ case "document":
842
+ result = await validateDocumentModel(reader, projectRoot, model, config, issues, fix);
843
+ break;
844
+ default: result = {
845
+ entries: 0,
846
+ fixed: 0
847
+ };
848
+ }
849
+ totalEntries += result.entries;
850
+ totalFixed += result.fixed;
851
+ }
852
+ if (!options?.model) totalFixed += await checkOrphanContent(reader, validModelIds, issues, fix);
853
+ if (!options?.model) {
854
+ const dictModels = modelsToCheck.filter((m) => m.kind === "dictionary");
855
+ if (dictModels.length > 1) {
856
+ const globalValueMap = {};
857
+ for (const summary of dictModels) {
858
+ const model = await readModel(reader, summary.id);
859
+ if (!model) continue;
860
+ for (const locale of config.locales.supported) {
861
+ if (!globalValueMap[locale]) globalValueMap[locale] = /* @__PURE__ */ new Map();
862
+ const data = await readJsonViaReader(reader, contentFilePath(model, locale));
863
+ if (!data) continue;
864
+ for (const [key, value] of Object.entries(data)) {
865
+ const refs = globalValueMap[locale].get(value);
866
+ if (refs) refs.push({
867
+ model: model.id,
868
+ key
869
+ });
870
+ else globalValueMap[locale].set(value, [{
871
+ model: model.id,
872
+ key
873
+ }]);
874
+ }
875
+ }
876
+ }
877
+ for (const [locale, valueMap] of Object.entries(globalValueMap)) for (const [value, refs] of valueMap) if (new Set(refs.map((r) => r.model)).size > 1) {
878
+ const truncated = value.length > 40 ? `${value.slice(0, 40)}...` : value;
879
+ issues.push({
880
+ severity: "notice",
881
+ locale,
882
+ message: `Cross-model duplicate value "${truncated}" in ${refs.map((r) => `${r.model}/${r.key}`).join(", ")}`
883
+ });
884
+ }
885
+ }
886
+ }
887
+ const errors = issues.filter((i) => i.severity === "error").length;
888
+ const warnings = issues.filter((i) => i.severity === "warning").length;
889
+ const notices = issues.filter((i) => i.severity === "notice").length;
890
+ return {
891
+ valid: errors === 0,
892
+ summary: {
893
+ errors,
894
+ warnings,
895
+ notices,
896
+ models_checked: modelsChecked,
897
+ entries_checked: totalEntries
898
+ },
899
+ issues,
900
+ fixed: totalFixed
901
+ };
902
+ }
903
+ //#endregion
904
+ export { validateContent as i, validateScheduleFields as n, checkRelationIntegrity as r, validateProject as t };
905
+
906
+ //# sourceMappingURL=validator-B0nNRFeU.mjs.map