drupal-mcp-connector 2.19.1 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.agents/commands/drupal-config-set.md +2 -2
  2. package/.agents/commands/drupal-create-node.md +2 -2
  3. package/.agents/commands/drupal-create-translation.md +1 -1
  4. package/.agents/commands/drupal-delete-node.md +1 -1
  5. package/.agents/commands/drupal-describe-fields.md +2 -2
  6. package/.agents/commands/drupal-drush-config-import.md +2 -2
  7. package/.agents/commands/drupal-drush-module-disable.md +2 -2
  8. package/.agents/commands/drupal-drush-module-list.md +1 -1
  9. package/.agents/commands/drupal-drush-user-list.md +1 -1
  10. package/.agents/commands/drupal-drush-watchdog.md +1 -1
  11. package/.agents/commands/drupal-entity-create.md +2 -2
  12. package/.agents/commands/drupal-entity-delete.md +1 -1
  13. package/.agents/commands/drupal-entity-update.md +4 -4
  14. package/.agents/commands/drupal-report-field-completeness.md +2 -2
  15. package/.agents/commands/drupal-report-missing-field.md +2 -2
  16. package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
  17. package/.agents/commands/drupal-report-status-report.md +1 -1
  18. package/.agents/commands/drupal-update-node.md +4 -4
  19. package/CHANGELOG.md +240 -0
  20. package/README.md +7 -1
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +52 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +40 -5
  25. package/scripts/install-commands.js +148 -11
  26. package/src/index.js +9 -12
  27. package/src/lib/backends/graphql-schema.js +9 -1
  28. package/src/lib/backends/graphql.js +4 -2
  29. package/src/lib/backends/index.js +9 -3
  30. package/src/lib/backends/jsonapi.js +2 -0
  31. package/src/lib/drupal-fetch.js +97 -26
  32. package/src/lib/dry-run-checks.js +78 -0
  33. package/src/lib/error-body.js +448 -0
  34. package/src/lib/error-status.js +38 -0
  35. package/src/lib/mcp-server.js +7 -1
  36. package/src/lib/metatag-audit.js +2 -1
  37. package/src/lib/module-tools.js +23 -2
  38. package/src/lib/patch-preflight.js +23 -5
  39. package/src/lib/reports-support.js +75 -0
  40. package/src/lib/security.js +233 -0
  41. package/src/lib/sentinel-draft.js +3 -2
  42. package/src/lib/server-tools.js +188 -27
  43. package/src/lib/tool-prompts.js +137 -6
  44. package/src/lib/verify.js +161 -58
  45. package/src/tools/config.js +70 -5
  46. package/src/tools/drush.js +191 -17
  47. package/src/tools/entities.js +18 -6
  48. package/src/tools/fields.js +40 -4
  49. package/src/tools/graphql.js +10 -5
  50. package/src/tools/moderation.js +1 -1
  51. package/src/tools/nodes.js +16 -6
  52. package/src/tools/reports-config.js +3 -3
  53. package/src/tools/reports-content.js +34 -26
  54. package/src/tools/reports-extra.js +46 -38
  55. package/src/tools/reports.js +50 -25
  56. package/src/tools/scheduler.js +1 -1
  57. package/src/tools/structure.js +12 -2
  58. package/src/tools/translations.js +5 -1
@@ -15,7 +15,9 @@
15
15
  import { getSiteConfig } from "../lib/config.js";
16
16
  import { resolveBackend } from "../lib/backends/index.js";
17
17
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
18
- import { collectEntities, fieldValue, daysSince } from "../lib/reports-support.js";
18
+ import {
19
+ collectEntities, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince,
20
+ } from "../lib/reports-support.js";
19
21
  import { bodyHtml, extractAnchors, classifyLink, normalizePath } from "../lib/audit-support.js";
20
22
  import {
21
23
  assertDraftLangcode, isMissingTranslationEndpoint, readTranslationInventory, supportsSentinelDraft,
@@ -554,7 +556,13 @@ const DEFAULT_META_FIELDS = ["field_meta_tags", "field_metatag", "metatag", "fie
554
556
  * Report structured-meta (SEO) coverage for a content type: how many sampled
555
557
  * nodes populate each meta field (metatag module, meta description, etc.).
556
558
  * Complements drupal_report_seo_audit's heuristic checks with explicit
557
- * per-field coverage.
559
+ * per-field coverage. Reads scalar and relationship fields.
560
+ *
561
+ * An absent key is not an empty value (#341). A field whose key is absent from
562
+ * every sampled node has `present: false` and `coverage: null` (unknown, not
563
+ * 0); when the caller named it, it is also listed in `notVisible`. When none of
564
+ * the checked fields is visible on any node the report flags no node, because
565
+ * JSON:API leaves a field the account may not view out of the response.
558
566
  *
559
567
  * @param {object} args - { site?, type?, fields?, sampleSize? }.
560
568
  * @returns {Promise<object>} Per-field coverage and nodes missing all meta.
@@ -565,7 +573,9 @@ async function seoMetaCoverage({ site: siteName, type, fields, sampleSize = 100
565
573
  assertReadAllowed(sec, "node", type);
566
574
  const backend = await resolveBackend(site);
567
575
  const contentType = type || "article";
568
- const metaFields = fields && fields.length ? fields : DEFAULT_META_FIELDS;
576
+ const named = requestedFieldNames(fields);
577
+ const requested = named.length > 0;
578
+ const metaFields = requested ? named : DEFAULT_META_FIELDS;
569
579
  const nodes = await collectEntities(
570
580
  backend,
571
581
  { entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
@@ -578,13 +588,24 @@ async function seoMetaCoverage({ site: siteName, type, fields, sampleSize = 100
578
588
  let any = false;
579
589
  for (const f of metaFields) {
580
590
  const stat = coverage.get(f);
581
- const v = fieldValue(n, [f]);
582
- if (v !== undefined) stat.present = true;
583
- if (!isEmpty(v)) { stat.populated++; any = true; }
591
+ const { present, value } = fieldPresence(n, f);
592
+ if (present) stat.present = true;
593
+ if (present && !isEmptyFieldValue(value)) { stat.populated++; any = true; }
584
594
  }
585
595
  if (!any) missingAll.push({ id: n.id, title: n.title, path: n.url });
586
596
  }
587
597
 
598
+ const anyVisible = metaFields.some((f) => coverage.get(f).present);
599
+ const unknown = nodes.length > 0 && !anyVisible;
600
+ const notVisible = metaFields.filter((f) => !coverage.get(f).present);
601
+ const notes = [];
602
+ if (unknown) {
603
+ notes.push(
604
+ "None of the checked meta fields is present on any sampled node (the keys are missing, not empty). " +
605
+ "They may be denied to this account or not exist on this content type, so no node was counted as missing meta."
606
+ );
607
+ }
608
+
588
609
  return {
589
610
  contentType,
590
611
  scanned: nodes.length,
@@ -594,30 +615,17 @@ async function seoMetaCoverage({ site: siteName, type, fields, sampleSize = 100
594
615
  return [f, {
595
616
  present: stat.present,
596
617
  populated: stat.populated,
597
- coverage: nodes.length ? Number((stat.populated / nodes.length).toFixed(2)) : 0,
618
+ coverage: stat.present ? Number((stat.populated / nodes.length).toFixed(2)) : (nodes.length ? null : 0),
598
619
  }];
599
620
  })),
600
- nodesMissingAllMeta: missingAll.length,
601
- findings: missingAll,
621
+ ...(requested && nodes.length ? { notVisible } : {}),
622
+ ...(requested && nodes.length && notVisible.length ? { notVisibleNote: FIELDS_NOT_VISIBLE_NOTE } : {}),
623
+ nodesMissingAllMeta: unknown ? null : missingAll.length,
624
+ ...(notes.length ? { note: notes.join(" ") } : {}),
625
+ findings: unknown ? [] : missingAll,
602
626
  };
603
627
  }
604
628
 
605
- /**
606
- * Whether a field value counts as empty (scalar, {value} object, array, ref).
607
- * @param {*} v Field value.
608
- * @returns {boolean}
609
- */
610
- function isEmpty(v) {
611
- if (v === undefined || v === null || v === "") return true;
612
- if (Array.isArray(v)) return v.length === 0;
613
- if (typeof v === "object") {
614
- if ("value" in v) return v.value === undefined || v.value === null || v.value === "";
615
- if ("id" in v) return !v.id;
616
- return Object.keys(v).length === 0;
617
- }
618
- return false;
619
- }
620
-
621
629
  // ---------------------------------------------------------------------------
622
630
  // Definitions & handlers
623
631
  // ---------------------------------------------------------------------------
@@ -714,7 +722,7 @@ export const definitions = [
714
722
  },
715
723
  {
716
724
  name: "drupal_report_seo_meta_coverage",
717
- description: "Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage.",
725
+ description: "Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage. Reads scalar and entity-reference fields. A field absent from every sampled node has `present: false` and `coverage: null` (unknown, not 0), and is listed in `notVisible` when you named it. When none of the checked fields is visible, no node is flagged and `nodesMissingAllMeta` is null: JSON:API leaves out a field this account may not view.",
718
726
  inputSchema: {
719
727
  type: "object",
720
728
  properties: {
@@ -13,7 +13,8 @@
13
13
  import { getSiteConfig } from "../lib/config.js";
14
14
  import { resolveBackend } from "../lib/backends/index.js";
15
15
  import { resolveSecurityConfig, assertReadAllowed, assertEntityTypeAllowed } from "../lib/security.js";
16
- import { collectEntities, fieldValue } from "../lib/reports-support.js";
16
+ import { collectEntities, fieldPresence } from "../lib/reports-support.js";
17
+ import { httpStatusOf } from "../lib/error-status.js";
17
18
 
18
19
  /** Author base fields that only ever point at `user`. */
19
20
  const AUTHOR_BASE_FIELDS = new Set(["uid", "revision_uid"]);
@@ -41,17 +42,15 @@ function isEntityTypeDenied(sec, entityType) {
41
42
  /**
42
43
  * Classify a getEntity failure. Only a 404 (or an unaddressable ref) is an
43
44
  * orphan. Connector policy, Drupal 401/403, and other failures are
44
- * unverifiable — and must not share one reason.
45
+ * unverifiable — and must not share one reason. The response status decides
46
+ * (see `httpStatusOf`); a "404" in the path or the detail does not (#355).
45
47
  * @param {unknown} err
46
48
  * @returns {"missing"|"forbidden"|"failed"}
47
49
  */
48
- function classifyTargetError(err) {
49
- const msg = String(err?.message || err || "");
50
- const statusMatch = msg.match(/\bDrupal (\d{3})\b/i);
51
- const status = statusMatch ? Number(statusMatch[1]) : NaN;
50
+ export function classifyTargetError(err) {
51
+ const status = httpStatusOf(err);
52
52
  if (status === 404) return "missing";
53
53
  if (status === 401 || status === 403) return "forbidden";
54
- if (/\b404\b/.test(msg) && !/\b40[13]\b/.test(msg)) return "missing";
55
54
  return "failed";
56
55
  }
57
56
 
@@ -77,24 +76,6 @@ function isEmptyValue(value) {
77
76
  return false;
78
77
  }
79
78
 
80
- /**
81
- * Read a field value from a canonical entity, looking in `fields` first and then
82
- * `relationships` (so a single `field` argument works for both scalar fields and
83
- * entity-reference fields).
84
- * @param {object} entity Canonical entity.
85
- * @param {string} field Field machine name.
86
- * @returns {{value: *, present: boolean}} The resolved value and whether the key existed at all.
87
- */
88
- function readField(entity, field) {
89
- const fromField = fieldValue(entity, [field]);
90
- if (fromField !== undefined) return { value: fromField, present: true };
91
- const rels = new Map(Object.entries(entity.relationships ?? {}));
92
- if (rels.has(field)) {
93
- return { value: rels.get(field), present: true };
94
- }
95
- return { value: undefined, present: false };
96
- }
97
-
98
79
  /**
99
80
  * Flatten a relationship value into an array of { id, entityType, bundle } refs,
100
81
  * dropping null/empty entries.
@@ -158,6 +139,14 @@ async function unpublished({ site: siteName, type, limit = 50 }) {
158
139
  * Sampling-bounded: flags `approximate` when the scan hits the sample cap while
159
140
  * more results remain.
160
141
  *
142
+ * An absent key is not an empty value (#337). JSON:API leaves a field out of
143
+ * the resource when the account may not view it, and keeps the key with an
144
+ * empty value when the field is merely empty. When the key is absent from
145
+ * EVERY sampled entity the report cannot know whether any value is missing,
146
+ * so it returns `notVisible: true` with no findings instead of flagging every
147
+ * entity. When only some entities omit the key, each finding carries a
148
+ * `reason` of `empty` or `absent`. No extra request is made per entity.
149
+ *
161
150
  * @param {object} args - { site?, type?, field, sampleSize? }. `field` is required.
162
151
  * @returns {Promise<object>} Per-entity findings plus scan metadata.
163
152
  * @throws {Error} If no field is supplied.
@@ -176,24 +165,43 @@ async function missingField({ site: siteName, type, field, sampleSize = 100 }) {
176
165
  sampleSize
177
166
  );
178
167
  const findings = [];
168
+ let totalAbsent = 0;
179
169
  for (const e of entities) {
180
- const { value } = readField(e, field);
181
- if (isEmptyValue(value)) {
182
- findings.push({ id: e.id, title: e.title, field, status: e.status ? "published" : "unpublished", path: e.url });
170
+ const { present, value } = fieldPresence(e, field);
171
+ if (!present) totalAbsent++;
172
+ if (!present || isEmptyValue(value)) {
173
+ findings.push({
174
+ id: e.id, title: e.title, field, reason: present ? "empty" : "absent",
175
+ status: e.status ? "published" : "unpublished", path: e.url,
176
+ });
183
177
  }
184
178
  }
185
179
  const approximate = entities.length >= sampleSize;
180
+ const notes = [];
181
+ if (approximate) notes.push("Result is sampling-bounded; more entities may exist beyond the sample cap.");
182
+ const base = { contentType, field, scanned: entities.length, sampled: entities.length, sampleSize, approximate };
183
+
184
+ if (entities.length > 0 && totalAbsent === entities.length) {
185
+ notes.unshift(
186
+ `"${field}" is absent from every sampled entity (the key is missing, not empty). ` +
187
+ "It may be denied to this account or not exist on this bundle, or the name may be wrong. " +
188
+ "Drupal leaves a field the account may not view out of the response with no marker, so this report cannot tell whether any value is missing. " +
189
+ "No entity was counted as missing the field."
190
+ );
191
+ return { ...base, notVisible: true, totalMissing: null, totalEmpty: 0, totalAbsent, note: notes.join(" "), findings: [] };
192
+ }
193
+ if (totalAbsent > 0) {
194
+ notes.push(
195
+ `${totalAbsent} sampled ${totalAbsent === 1 ? "entity omits" : "entities omit"} "${field}" entirely (reason "absent"). ` +
196
+ "Drupal leaves a field the account may not view out of the response, so those may be access-denied rather than empty."
197
+ );
198
+ }
186
199
  return {
187
- contentType,
188
- field,
189
- scanned: entities.length,
190
- sampled: entities.length,
191
- sampleSize,
192
- approximate,
200
+ ...base,
193
201
  totalMissing: findings.length,
194
- note: approximate
195
- ? "Result is sampling-bounded; more entities may exist beyond the sample cap."
196
- : undefined,
202
+ totalEmpty: findings.length - totalAbsent,
203
+ totalAbsent,
204
+ note: notes.length ? notes.join(" ") : undefined,
197
205
  findings,
198
206
  };
199
207
  }
@@ -330,7 +338,7 @@ export const definitions = [
330
338
  },
331
339
  {
332
340
  name: "drupal_report_missing_field",
333
- description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped.",
341
+ description: "Find entities of a content type where a given field is empty (e.g. a missing meta description, image, or summary). Works for scalar fields and entity-reference fields. Sampling-bounded — flags 'approximate' when the scan is capped. An absent field is not an empty one: JSON:API leaves out a field this account may not view. When the field is absent from every sampled entity the result is `notVisible: true` with no findings (the field may be denied to this account, not exist on the bundle, or be misspelled) instead of every entity counted as missing. Otherwise each finding has `reason` 'empty' (key present, no value) or 'absent' (key missing, possibly access-denied).",
334
342
  inputSchema: {
335
343
  type: "object", required: ["field"],
336
344
  properties: {
@@ -11,7 +11,7 @@
11
11
  import { getSiteConfig } from "../lib/config.js";
12
12
  import { resolveBackend } from "../lib/backends/index.js";
13
13
  import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
14
- import { collectEntities, gatedReport, fieldValue, daysSince } from "../lib/reports-support.js";
14
+ import { collectEntities, gatedReport, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince } from "../lib/reports-support.js";
15
15
  import { fetchRenderedMetaDescriptions } from "../lib/metatag-audit.js";
16
16
 
17
17
  // ---------------------------------------------------------------------------
@@ -171,14 +171,28 @@ async function recentlyPublished({ site: siteName, type, limit = 20 }) {
171
171
  };
172
172
  }
173
173
 
174
+ /** Field names checked when the caller names none. Guesses: a bundle rarely has all of them. */
175
+ const DEFAULT_COMPLETENESS_FIELDS = ["body", "summary", "metaDescription", "field_meta_description", "image", "field_image", "tags", "field_tags"];
176
+
174
177
  /**
175
178
  * Field completeness: what % of sampled nodes have optional fields populated.
176
- * A field absent on every sampled node is treated as "not on this content
177
- * type" and dropped from the results rather than counted as empty.
179
+ * Reads scalar fields and relationship (entity-reference) fields; a
180
+ * relationship with no target is empty.
181
+ *
182
+ * An absent key is not an empty value (#341). JSON:API keeps the key of an
183
+ * empty field and leaves out a field the account may not view. A node that
184
+ * omits the key is counted as `absent` and stays out of the percentage. A
185
+ * field the caller named that is absent from EVERY sampled node is listed in
186
+ * `notVisible` instead of being scored; one of the tool's own default guesses
187
+ * in the same state is dropped. Nothing is claimed when no node was sampled.
188
+ *
189
+ * Sampling-bounded: `approximate` is true when the scan hits `sampleSize`.
178
190
  *
179
191
  * @param {object} args - { site?, type, fields?, sampleSize? }. `type` is
180
192
  * required; `fields` defaults to common SEO/editorial field names.
181
- * @returns {Promise<object>} Per-field populated/empty counts and percentages.
193
+ * @returns {Promise<{contentType: string, sampleSize: number, approximate: boolean,
194
+ * fields: Array<{field: string, populated: number, empty: number, absent: number, completenessPercent: ?number}>,
195
+ * notVisible?: string[], notVisibleNote?: string, note?: string}>}
182
196
  * @throws {Error} If no content type is supplied.
183
197
  * @throws {SecurityError} If reading the content type is not permitted.
184
198
  */
@@ -188,40 +202,51 @@ async function fieldCompleteness({ site: siteName, type, fields, sampleSize = 10
188
202
  assertReadAllowed(sec, "node", type);
189
203
  if (!type) throw new Error("fieldCompleteness requires a content type.");
190
204
  const backend = await resolveBackend(site);
191
- const fieldsToCheck = (fields && fields.length)
192
- ? fields
193
- : ["body", "summary", "metaDescription", "field_meta_description", "image", "field_image", "tags", "field_tags"];
205
+ const named = requestedFieldNames(fields);
206
+ const requested = named.length > 0;
207
+ const fieldsToCheck = requested ? named : DEFAULT_COMPLETENESS_FIELDS;
194
208
  const entities = await collectEntities(
195
209
  backend,
196
210
  { entityType: "node", bundle: type, filters: [{ field: "status", op: "eq", value: true }] },
197
211
  sampleSize
198
212
  );
199
- const results = fieldsToCheck.map((field) => {
200
- let populated = 0, empty = 0, missing = 0;
213
+ const rows = fieldsToCheck.map((field) => {
214
+ let populated = 0, empty = 0, absent = 0;
201
215
  for (const e of entities) {
202
- const v = fieldValue(e, [field]);
203
- if (v === undefined) {
204
- missing++;
205
- } else if (v === null || v === "" || (typeof v === "object" && !v?.value && !(Array.isArray(v) && v.length))) {
206
- empty++;
207
- } else {
208
- populated++;
209
- }
216
+ const { present, value } = fieldPresence(e, field);
217
+ if (!present) absent++;
218
+ else if (isEmptyFieldValue(value)) empty++;
219
+ else populated++;
210
220
  }
211
221
  const checked = populated + empty;
212
222
  return {
213
- field,
214
- populated,
215
- empty,
216
- notOnContentType: (missing === entities.length && entities.length > 0) ? true : undefined,
223
+ field, populated, empty, absent,
217
224
  completenessPercent: checked > 0 ? Math.round((populated / checked) * 100) : null,
218
225
  };
219
- }).filter((r) => !r.notOnContentType);
226
+ });
227
+ const sampled = entities.length > 0;
228
+ const invisible = (row) => sampled && row.absent === entities.length;
229
+ const notVisible = rows.filter(invisible).map((row) => row.field);
230
+ const scored = rows.filter((row) => !invisible(row));
231
+
232
+ const approximate = entities.length >= sampleSize;
233
+ const notes = [];
234
+ if (!sampled) notes.push("No published nodes of this content type were sampled, so no field could be scored.");
235
+ if (approximate) notes.push("Result is sampling-bounded; more nodes may exist beyond the sample cap.");
236
+ if (scored.some((row) => row.absent > 0)) {
237
+ notes.push(
238
+ "Some sampled nodes omit a field entirely (`absent`). Drupal leaves a field the account may not view out of the response, " +
239
+ "so those may be access-denied rather than empty. They are not counted in completenessPercent."
240
+ );
241
+ }
220
242
  return {
221
243
  contentType: type,
222
244
  sampleSize: entities.length,
223
- approximate: false,
224
- fields: results.sort((a, b) => (a.completenessPercent ?? 0) - (b.completenessPercent ?? 0)),
245
+ approximate,
246
+ fields: scored.sort((a, b) => (a.completenessPercent ?? 0) - (b.completenessPercent ?? 0)),
247
+ ...(requested && sampled ? { notVisible } : {}),
248
+ ...(requested && notVisible.length ? { notVisibleNote: FIELDS_NOT_VISIBLE_NOTE } : {}),
249
+ note: notes.length ? notes.join(" ") : undefined,
225
250
  };
226
251
  }
227
252
 
@@ -583,7 +608,7 @@ export const definitions = [
583
608
  },
584
609
  {
585
610
  name: "drupal_report_field_completeness",
586
- description: "Score how completely optional fields are filled in for a content type. Finds nodes missing summaries, images, meta descriptions, tags, etc.",
611
+ description: "Score how completely optional fields are filled in for a content type. Finds nodes missing summaries, images, meta descriptions, tags, etc. Reads scalar and entity-reference fields; a reference with no target is empty. Sampling-bounded — flags 'approximate' when the scan hits sampleSize. An absent field is not an empty one: JSON:API leaves out a field this account may not view. A node that omits the key is counted as `absent` and stays out of completenessPercent. A field you name that is absent from every sampled node is listed in `notVisible` and not scored (it may be denied to this account, not exist on the content type, or be misspelled). Default field guesses that are absent are dropped.",
587
612
  inputSchema: {
588
613
  type: "object", required: ["type"],
589
614
  properties: {
@@ -29,7 +29,7 @@ import { resolveSecurityConfig, assertWriteAllowed, redactCanonicalEntity } from
29
29
  * @param {Error} err - The error thrown by the backend write.
30
30
  * @returns {boolean}
31
31
  */
32
- function looksLikeUnknownField(err) {
32
+ export function looksLikeUnknownField(err) {
33
33
  const msg = String(err?.message || err || "");
34
34
  return /unknown|not exist|no field|invalid field|unrecognized|publish_on|unpublish_on/i.test(msg);
35
35
  }
@@ -20,6 +20,7 @@ import {
20
20
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, redactCanonicalEntity,
21
21
  } from "../lib/security.js";
22
22
  import { assertDraftLangcode } from "../lib/sentinel-draft.js";
23
+ import { httpStatusOf } from "../lib/error-status.js";
23
24
 
24
25
  const MENU_LINK_TYPE = "menu_link_content";
25
26
  const BLOCK_TYPE = "block_content";
@@ -30,9 +31,18 @@ const BLOCK_TYPE = "block_content";
30
31
  // a transient path-validator/access-cache race (it warms during the first
31
32
  // attempt), so a single retry clears it. Prefer an `entity:node/<id>` target
32
33
  // over `internal:/<alias>` to avoid the alias-resolution step entirely.
33
- const INACCESSIBLE_PATH_RE = /\b422\b[\s\S]*inaccessible/i;
34
+ const INACCESSIBLE_PATH_RE = /inaccessible/i;
34
35
  const MENU_LINK_RETRY_DELAY_MS = 250;
35
36
 
37
+ /**
38
+ * Whether an error is the transient "422 path inaccessible" menu-link race.
39
+ * @param {unknown} err The error thrown by the backend write.
40
+ * @returns {boolean}
41
+ */
42
+ export function isInaccessiblePathError(err) {
43
+ return httpStatusOf(err) === 422 && INACCESSIBLE_PATH_RE.test(String(err?.message));
44
+ }
45
+
36
46
  /**
37
47
  * Resolve after `ms` milliseconds.
38
48
  * @param {number} ms Delay in milliseconds.
@@ -52,7 +62,7 @@ async function writeMenuLinkWithRetry(fn) {
52
62
  try {
53
63
  return await fn();
54
64
  } catch (err) {
55
- if (!INACCESSIBLE_PATH_RE.test(String(err?.message))) throw err;
65
+ if (!isInaccessiblePathError(err)) throw err;
56
66
  await sleep(MENU_LINK_RETRY_DELAY_MS);
57
67
  return fn();
58
68
  }
@@ -40,6 +40,7 @@ import {
40
40
  supportsSentinelDraft,
41
41
  } from "../lib/sentinel-draft.js";
42
42
  import { mapTranslationRow } from "../lib/translation-rows.js";
43
+ import { dryRunChecks, PREFLIGHT_SENTINEL_DRAFT } from "../lib/dry-run-checks.js";
43
44
 
44
45
  const LIST_NOTE =
45
46
  "Live languages are those on the default revision. Working languages are the " +
@@ -175,6 +176,9 @@ async function createTranslation({
175
176
  return {
176
177
  dryRun: true, operation: "create_translation", entityType, bundle: type, id,
177
178
  langcode: targetLang, attributes: drafted, ...(relationships ? { relationships } : {}),
179
+ // createTranslationDraft throws unless the site confirmed a non-saving
180
+ // preflight of this exact payload, so the claim holds when we get here.
181
+ ...dryRunChecks({ operation: "create_translation", preflight: PREFLIGHT_SENTINEL_DRAFT }),
178
182
  };
179
183
  }
180
184
 
@@ -244,7 +248,7 @@ export const definitions = [
244
248
  attributes: { type: "object", description: "Translated field values keyed by Drupal machine name" },
245
249
  relationships: { type: "object", description: "JSON:API relationships. Use for image alt (same file UUID, meta.alt)." },
246
250
  revisionId: { type: "string", description: "Paragraph revision id the host already pins. Required for Home-shaped non-default pins." },
247
- dryRun: { type: "boolean", description: "Validate without saving" },
251
+ dryRun: { type: "boolean", description: "Validate without saving. Sentinel's non-saving translation preflight receives the real fields, applies them through field access and validates the entity. The result's `checks` block says what was checked." },
248
252
  },
249
253
  },
250
254
  },