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.
- package/.agents/commands/drupal-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +240 -0
- package/README.md +7 -1
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +52 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +40 -5
- package/scripts/install-commands.js +148 -11
- package/src/index.js +9 -12
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +233 -0
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +188 -27
- package/src/lib/tool-prompts.js +137 -6
- package/src/lib/verify.js +161 -58
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/moderation.js +1 -1
- package/src/tools/nodes.js +16 -6
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- 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 {
|
|
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
|
|
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
|
|
582
|
-
if (
|
|
583
|
-
if (!
|
|
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:
|
|
618
|
+
coverage: stat.present ? Number((stat.populated / nodes.length).toFixed(2)) : (nodes.length ? null : 0),
|
|
598
619
|
}];
|
|
599
620
|
})),
|
|
600
|
-
|
|
601
|
-
|
|
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,
|
|
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
|
|
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 } =
|
|
181
|
-
if (
|
|
182
|
-
|
|
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
|
-
|
|
188
|
-
field,
|
|
189
|
-
scanned: entities.length,
|
|
190
|
-
sampled: entities.length,
|
|
191
|
-
sampleSize,
|
|
192
|
-
approximate,
|
|
200
|
+
...base,
|
|
193
201
|
totalMissing: findings.length,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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: {
|
package/src/tools/reports.js
CHANGED
|
@@ -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
|
-
*
|
|
177
|
-
*
|
|
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<
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
200
|
-
let populated = 0, empty = 0,
|
|
213
|
+
const rows = fieldsToCheck.map((field) => {
|
|
214
|
+
let populated = 0, empty = 0, absent = 0;
|
|
201
215
|
for (const e of entities) {
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
})
|
|
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
|
|
224
|
-
fields:
|
|
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: {
|
package/src/tools/scheduler.js
CHANGED
|
@@ -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
|
}
|
package/src/tools/structure.js
CHANGED
|
@@ -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 =
|
|
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 (!
|
|
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
|
},
|