@gravitykit/block-mcp 2.0.0-beta

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 (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Per-site `serverInfo.instructions` assembly (BLOCK-19).
3
+ *
4
+ * The MCP server ships a hard-coded baseline string in `BASELINE`. At
5
+ * startup, the server fetches an admin-editable addendum from the
6
+ * connected WordPress site via `GET /gk-block-api/v1/instructions` and
7
+ * appends it to the baseline. The combined string is passed to the
8
+ * `McpServer` constructor's `instructions` field, where the MCP SDK
9
+ * surfaces it to clients during the initialize handshake.
10
+ *
11
+ * Two opt-out paths:
12
+ *
13
+ * - The fetch wraps every step in try/catch and falls back to baseline-
14
+ * only on any failure (DNS, timeout, 404, 5xx, malformed JSON). The
15
+ * server never blocks startup on the fetch.
16
+ * - The `BLOCK_MCP_INSTRUCTIONS_OFF=1` env var skips the fetch entirely
17
+ * — useful for offline testing and isolation.
18
+ *
19
+ * Threat model: the remote addendum comes from the WP options table and
20
+ * is sanitized server-side, but a compromised WP install could still
21
+ * push malicious instructions. This module performs defense-in-depth
22
+ * sanitization (length cap, control-char strip, suspicious-pattern
23
+ * filter) before passing the value to the SDK. The primary control
24
+ * remains "don't connect Block MCP to a WP site you don't trust."
25
+ */
26
+
27
+ import axios, { AxiosError } from 'axios';
28
+
29
+ /**
30
+ * The baseline instructions string.
31
+ *
32
+ * This is the canonical, version-controlled guidance every MCP client
33
+ * receives, regardless of which WordPress site the server connects to.
34
+ * Site-specific rules go in the WP option served by `/instructions`.
35
+ *
36
+ * Kept verbatim in sync with the inline literal that previously lived
37
+ * at `src/index.ts:102-107` so existing clients see no change in
38
+ * behaviour when no addendum is set.
39
+ */
40
+ export const BASELINE = `Block-level WordPress CRUD. URL → post_id is resolved server-side — pass URLs directly to get_page_blocks / resolve_url; never shell out to curl or wp-json.
41
+
42
+ After a write, the response already includes the canonical post-save snapshot (\`saved.inner_html\` + \`saved.attributes\` on update_block; \`saved\` per result on update_blocks with \`verbose:true\`). Use that for verification — do not fetch the public page to confirm edits. If you need a single-block re-read later, call get_block(ref) — same shape, no extra plumbing.
43
+
44
+ Tier policy is per-site config, surfaced inline (block.preference) and via list_block_types. Read block-mcp://agent-guide for the editing workflow.`;
45
+
46
+ /**
47
+ * Server-side cap on the addendum length. Mirrors `Instructions::MAX_LENGTH`
48
+ * in the PHP plugin. Truncation is silent — `McpServer` doesn't care, and
49
+ * surfacing an error would block startup over a value that's already on
50
+ * the wire.
51
+ */
52
+ export const MAX_ADDENDUM_LENGTH = 2000;
53
+
54
+ /**
55
+ * Timeout for the addendum fetch. Short, because we don't want startup
56
+ * to feel laggy when the site is offline — falling back to baseline-only
57
+ * is preferable to hanging.
58
+ */
59
+ const FETCH_TIMEOUT_MS = 3000;
60
+
61
+ /**
62
+ * Hard cap on the response body axios accepts from `/instructions`.
63
+ *
64
+ * The expected payload is `{ addendum, length, max_length, updated_at }`
65
+ * where `addendum` is at most `MAX_ADDENDUM_LENGTH` characters. With
66
+ * 4-byte UTF-8 codepoints and the JSON envelope, the realistic ceiling
67
+ * is well under 10 KB. Capping at 16 KB gives generous headroom while
68
+ * making a compromised or malicious WP site unable to push an unbounded
69
+ * stream of bytes at us (slowloris-style DoS, memory pressure).
70
+ *
71
+ * The HTTP-layer cap is the primary defense; `sanitizeAddendum` is the
72
+ * secondary defense. Both stay in place.
73
+ */
74
+ const FETCH_MAX_BYTES = 16 * 1024;
75
+
76
+ /**
77
+ * Env var that disables the fetch entirely. Useful for offline tests,
78
+ * isolation, or when running against an internal site whose admin you
79
+ * don't trust to manage the addendum.
80
+ */
81
+ const OFF_ENV_VAR = 'BLOCK_MCP_INSTRUCTIONS_OFF';
82
+
83
+ /**
84
+ * Response envelope from `GET /gk-block-api/v1/instructions`.
85
+ *
86
+ * `length` and `max_length` are advisory — the TypeScript side re-checks
87
+ * the actual addendum string and enforces its own truncation.
88
+ */
89
+ export interface InstructionsResponse {
90
+ addendum: string;
91
+ length?: number;
92
+ max_length?: number;
93
+ updated_at?: number;
94
+ }
95
+
96
+ /**
97
+ * Sanitize a remote addendum before handing it to the MCP SDK.
98
+ *
99
+ * Defense in depth — the PHP side already does most of this, but we
100
+ * cannot assume the WordPress install has the plugin version that
101
+ * sanitizes on the read path. Steps:
102
+ *
103
+ * 1. Cast to string; non-strings (objects, arrays, null) return empty.
104
+ * 2. Strip ASCII C0 control characters except `\t` (tab), `\n` (LF),
105
+ * `\r` (CR) — same set the PHP side keeps.
106
+ * 3. Strip the DEL character (`\x7F`).
107
+ * 4. Strip the unicode Bidi override and zero-width characters that
108
+ * have been used in prompt-injection PoCs to hide text from human
109
+ * review while still being visible to the LLM.
110
+ * 5. Normalize CRLF/CR to LF.
111
+ * 6. Trim outer whitespace.
112
+ * 7. Truncate to `MAX_ADDENDUM_LENGTH` characters.
113
+ */
114
+ export function sanitizeAddendum(input: unknown): string {
115
+ if (typeof input !== 'string') {
116
+ return '';
117
+ }
118
+ let s = input;
119
+
120
+ // ASCII C0/C1 control chars except tab/LF/CR + DEL.
121
+ s = s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
122
+
123
+ // Unicode Bidi overrides + zero-width chars. These render invisibly in
124
+ // some clients but still reach the LLM tokenizer, making them a
125
+ // prompt-injection vector. Stripped via explicit escape sequences so
126
+ // the source is readable and the set is unambiguous.
127
+ //
128
+ // U+200B ZERO WIDTH SPACE
129
+ // U+200C ZERO WIDTH NON-JOINER
130
+ // U+200D ZERO WIDTH JOINER
131
+ // U+2060 WORD JOINER
132
+ // U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM
133
+ // U+202A..U+202E LRE/RLE/PDF/LRO/RLO Bidi overrides
134
+ // U+2066..U+2069 LRI/RLI/FSI/PDI Bidi isolates
135
+ s = s.replace(/[\u200B-\u200D\u2060\uFEFF\u202A-\u202E\u2066-\u2069]/g, '');
136
+
137
+ // CRLF / CR → LF.
138
+ s = s.replace(/\r\n?/g, '\n');
139
+
140
+ s = s.trim();
141
+
142
+ // Count and slice by Unicode code points, NOT UTF-16 code units, so an
143
+ // astral character (emoji, rare CJK, math symbol) is never split mid
144
+ // surrogate pair — which would leave the tail as an unpaired surrogate
145
+ // that downstream JSON serializers either reject or mangle. Matches
146
+ // the server's `mb_strlen($s, 'UTF-8')` semantics.
147
+ const codePoints = Array.from(s);
148
+ if (codePoints.length > MAX_ADDENDUM_LENGTH) {
149
+ s = codePoints.slice(0, MAX_ADDENDUM_LENGTH).join('');
150
+ }
151
+
152
+ return s;
153
+ }
154
+
155
+ /**
156
+ * Combine the baseline with an optional addendum into the final string
157
+ * passed to `McpServer`. When the addendum is empty, returns the
158
+ * baseline unchanged — clients that don't customize see no marker
159
+ * polluting the handshake.
160
+ *
161
+ * Two newlines between baseline and addendum so markdown renderers in
162
+ * MCP clients see them as separate paragraphs / sections.
163
+ */
164
+ export function combineInstructions(baseline: string, addendum: string): string {
165
+ const clean = addendum.trim();
166
+ if (clean.length === 0) {
167
+ return baseline;
168
+ }
169
+ return `${baseline}\n\n${clean}`;
170
+ }
171
+
172
+ /**
173
+ * Fetch the addendum from the WordPress site's `/instructions` endpoint.
174
+ *
175
+ * Public, unauthenticated request (the endpoint is public-by-design).
176
+ * On any failure — network error, non-200 response, malformed JSON,
177
+ * missing addendum field — returns an empty string. Logs the cause to
178
+ * stderr so admins can debug, but never throws.
179
+ *
180
+ * Honors `BLOCK_MCP_INSTRUCTIONS_OFF=1` by returning empty immediately
181
+ * (no HTTP call).
182
+ *
183
+ * @param wordpressUrl Base URL of the WordPress site (no trailing slash
184
+ * enforced — the function normalizes both forms).
185
+ */
186
+ export async function fetchAddendum(wordpressUrl: string): Promise<string> {
187
+ if (process.env[OFF_ENV_VAR] === '1') {
188
+ return '';
189
+ }
190
+
191
+ // Normalize trailing slash and resolve relative to wp-json.
192
+ const base = wordpressUrl.replace(/\/+$/, '');
193
+ const url = `${base}/wp-json/gk-block-api/v1/instructions`;
194
+
195
+ try {
196
+ const response = await axios.get<InstructionsResponse>(url, {
197
+ timeout: FETCH_TIMEOUT_MS,
198
+ // Disable axios's automatic redirect following entirely. Without
199
+ // this, a compromised or misconfigured WP site could redirect us
200
+ // to a different origin (an exfil endpoint, an SSRF target on the
201
+ // internal network, etc.). Admins should configure
202
+ // WORDPRESS_URL with the canonical scheme + host so the first
203
+ // hop returns 200 directly. If the site needs an HTTP → HTTPS
204
+ // redirect, fix the env var instead of relying on axios to
205
+ // follow it for us.
206
+ //
207
+ // The corollary is a 3xx response now surfaces as an axios
208
+ // error and we fall back to baseline-only. Logged to stderr.
209
+ maxRedirects: 0,
210
+ // Hard cap the response body size. Primary defense against an
211
+ // unbounded payload — `sanitizeAddendum` still truncates after,
212
+ // but we never want raw bytes past this cap to hit our process.
213
+ maxContentLength: FETCH_MAX_BYTES,
214
+ // Lower-case Accept so caches see a stable Vary key.
215
+ headers: {
216
+ Accept: 'application/json',
217
+ },
218
+ // We treat 2xx as success; 3xx (any redirect) and 4xx/5xx fall
219
+ // back to empty via the catch block.
220
+ validateStatus: (status) => status >= 200 && status < 300,
221
+ });
222
+
223
+ if (!response.data || typeof response.data !== 'object') {
224
+ console.error(
225
+ `[block-mcp] /instructions returned non-object payload; using baseline only.`
226
+ );
227
+ return '';
228
+ }
229
+
230
+ return sanitizeAddendum(response.data.addendum);
231
+ } catch (err) {
232
+ // Don't crash startup — empty addendum, log to stderr, continue.
233
+ const message = formatFetchError(err);
234
+ console.error(`[block-mcp] Failed to fetch /instructions (${message}); using baseline only.`);
235
+ return '';
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Convenience helper: fetch + combine in one call. The default entry
241
+ * point used by `src/index.ts`.
242
+ */
243
+ export async function getInstructions(wordpressUrl: string): Promise<string> {
244
+ const addendum = await fetchAddendum(wordpressUrl);
245
+ return combineInstructions(BASELINE, addendum);
246
+ }
247
+
248
+ /**
249
+ * Map axios / network errors into a short stderr message. Kept private
250
+ * because callers shouldn't depend on the exact wording.
251
+ */
252
+ function formatFetchError(err: unknown): string {
253
+ if (axios.isAxiosError(err)) {
254
+ const axiosErr = err as AxiosError;
255
+ if (axiosErr.code === 'ECONNABORTED' || axiosErr.message.includes('timeout')) {
256
+ return `timeout after ${FETCH_TIMEOUT_MS}ms`;
257
+ }
258
+ if (axiosErr.response) {
259
+ return `HTTP ${axiosErr.response.status}`;
260
+ }
261
+ if (axiosErr.code) {
262
+ return axiosErr.code;
263
+ }
264
+ return axiosErr.message;
265
+ }
266
+ if (err instanceof Error) {
267
+ return err.message;
268
+ }
269
+ return String(err);
270
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Client-side Preference Enrichment
3
+ *
4
+ * Functions that add natural-language annotations and AI-friendly context
5
+ * to raw API responses. The WordPress plugin (gk-block-api) is the single
6
+ * source of truth for which namespaces are legacy, avoid, etc. — driven by
7
+ * admin-editable preferences (`wp_options.gk_block_api_preferences`) and
8
+ * extensible via the WordPress filter system.
9
+ *
10
+ * This module deliberately holds NO hardcoded namespace lists or replacement
11
+ * maps. It reads `block.preference.tier` and `block.preference.suggested_replacement`
12
+ * fields the server attaches to non-preferred blocks. Sites that want
13
+ * different policies just edit their Preferences config — no code change.
14
+ */
15
+
16
+ import type {
17
+ BlockType,
18
+ Pattern,
19
+ Block,
20
+ PreferenceWarning,
21
+ } from './types.js';
22
+
23
+ // ============================================
24
+ // Helpers
25
+ // ============================================
26
+
27
+ /**
28
+ * Extract the namespace from a fully-qualified block name.
29
+ *
30
+ * @param blockName - e.g. "filter/testimonial-wall"
31
+ * @returns Namespace string, e.g. "filter"
32
+ */
33
+ function getNamespace(blockName: string): string {
34
+ return blockName.split('/')[0] ?? blockName;
35
+ }
36
+
37
+ /**
38
+ * Extract the short name (after the slash) from a fully-qualified block name.
39
+ *
40
+ * @param blockName - e.g. "filter/testimonial-wall"
41
+ * @returns Short name string, e.g. "testimonial-wall", or the full name if no slash.
42
+ */
43
+ function getShortName(blockName: string): string {
44
+ const parts = blockName.split('/');
45
+ return parts[1] ?? parts[0];
46
+ }
47
+
48
+ // ============================================
49
+ // Enrichment Functions
50
+ // ============================================
51
+
52
+ /**
53
+ * Annotate a list of page blocks with preference warnings.
54
+ *
55
+ * Reads the `block.preference.tier` field the server attaches to non-preferred
56
+ * blocks. No client-side namespace list — whatever the server flags as legacy
57
+ * gets a warning here.
58
+ *
59
+ * @param blocks - Parsed blocks from a page
60
+ * @returns Object with blocks, warnings array, and a summary string
61
+ */
62
+ export function enrichBlockList(blocks: Block[]): {
63
+ blocks: Block[];
64
+ warnings: PreferenceWarning[];
65
+ summary: string;
66
+ } {
67
+ const warnings: PreferenceWarning[] = [];
68
+
69
+ // Walk the full tree so legacy/avoid blocks nested inside core/group,
70
+ // core/columns, etc. surface in the warning summary too. The previous
71
+ // single-level loop missed them, letting deprecated namespaces hide
72
+ // a level deep.
73
+ walkBlocksForPreferences(blocks, warnings);
74
+
75
+ const summary = warnings.length > 0
76
+ ? `Found ${warnings.length} non-preferred block(s) on this page:\n${warnings.map((w) => ` - ${w.message}`).join('\n')}`
77
+ : 'All blocks on this page use preferred or acceptable namespaces.';
78
+
79
+ return { blocks, warnings, summary };
80
+ }
81
+
82
+ function walkBlocksForPreferences(blocks: Block[], warnings: PreferenceWarning[]): void {
83
+ for (const block of blocks) {
84
+ const tier = block.preference?.tier;
85
+ if (tier === 'legacy' || tier === 'avoid') {
86
+ const replacement = block.preference?.suggested_replacement;
87
+ const verb = tier === 'legacy' ? 'LEGACY — do not use' : 'AVOID';
88
+ warnings.push({
89
+ block: block.name,
90
+ message: replacement
91
+ ? `Block ${block.index}: ${block.name} (${verb} — use ${replacement} instead)`
92
+ : `Block ${block.index}: ${block.name} (${verb})`,
93
+ suggested_replacement: replacement,
94
+ });
95
+ }
96
+ if (block.innerBlocks?.length) {
97
+ walkBlocksForPreferences(block.innerBlocks, warnings);
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Sort patterns by preference score and add a recommendation summary.
104
+ *
105
+ * Returns patterns ordered best-first with a natural-language summary
106
+ * grouping them into recommended, acceptable, and avoid tiers.
107
+ *
108
+ * @param patterns - Patterns from the API
109
+ * @returns Sorted patterns with a summary string
110
+ */
111
+ export function enrichPatternList(patterns: Pattern[]): {
112
+ patterns: Pattern[];
113
+ summary: string;
114
+ } {
115
+ // Sort descending by score for stable display ordering.
116
+ const sorted = [...patterns].sort(
117
+ (a, b) => b.preference.score - a.preference.score
118
+ );
119
+
120
+ // Classify by tier only — the server is the source of truth and already
121
+ // applied policy. Mixing in score-based fallbacks (the old logic) caused
122
+ // mis-bucketing: an `avoid`-tier pattern with score 5 leaked into the
123
+ // recommended bucket; a `recommended`-tier pattern with negative score
124
+ // was double-counted. Trust the tier.
125
+ const recommended = sorted.filter((p) => p.preference.tier === 'recommended');
126
+ const avoid = sorted.filter((p) => p.preference.tier === 'avoid' || p.preference.tier === 'legacy');
127
+
128
+ const lines: string[] = [];
129
+
130
+ if (recommended.length > 0) {
131
+ lines.push('RECOMMENDED patterns:');
132
+ for (const p of recommended) {
133
+ const blockInfo = p.contains_blocks.length > 0
134
+ ? `, uses ${p.contains_blocks.slice(0, 3).map(getNamespace).filter((v, i, a) => a.indexOf(v) === i).join('/')} blocks`
135
+ : '';
136
+ lines.push(
137
+ ` Recommended: "${p.name}" (score: ${p.preference.score}, ${p.type}${blockInfo})`
138
+ );
139
+ }
140
+ }
141
+
142
+ if (avoid.length > 0) {
143
+ lines.push('AVOID patterns (contain non-preferred blocks):');
144
+ for (const p of avoid) {
145
+ const legacyInfo = p.legacy_blocks && p.legacy_blocks.length > 0
146
+ ? `, contains ${p.legacy_blocks.slice(0, 3).join(', ')}`
147
+ : '';
148
+ lines.push(
149
+ ` Avoid: "${p.name}" (score: ${p.preference.score}${legacyInfo})`
150
+ );
151
+ }
152
+ }
153
+
154
+ const summary = lines.length > 0
155
+ ? lines.join('\n')
156
+ : 'No patterns found matching the criteria.';
157
+
158
+ return { patterns: sorted, summary };
159
+ }
160
+
161
+ /**
162
+ * Group block types by preference tier with natural-language guidance.
163
+ *
164
+ * Tier classification comes from the server (which reads from the
165
+ * Preferences config). No hardcoded namespace tables here.
166
+ *
167
+ * @param types - Block types from the API
168
+ * @returns Grouped types with a guidance summary string
169
+ */
170
+ export function enrichBlockTypes(types: BlockType[]): {
171
+ block_types: BlockType[];
172
+ guidance: string;
173
+ } {
174
+ const preferred: BlockType[] = [];
175
+ const acceptable: BlockType[] = [];
176
+ const avoid: BlockType[] = [];
177
+ const legacy: BlockType[] = [];
178
+
179
+ for (const t of types) {
180
+ switch (t.preference.tier) {
181
+ case 'preferred':
182
+ preferred.push(t);
183
+ break;
184
+ case 'acceptable':
185
+ acceptable.push(t);
186
+ break;
187
+ case 'avoid':
188
+ avoid.push(t);
189
+ break;
190
+ case 'legacy':
191
+ legacy.push(t);
192
+ break;
193
+ default:
194
+ acceptable.push(t);
195
+ }
196
+ }
197
+
198
+ const lines: string[] = [];
199
+
200
+ if (preferred.length > 0) {
201
+ const grouped = groupByNamespace(preferred);
202
+ for (const [ns, blocks] of Object.entries(grouped)) {
203
+ const names = blocks.map((t) => getShortName(t.name)).join(', ');
204
+ lines.push(`PREFERRED (${ns}/): ${names}`);
205
+ }
206
+ }
207
+ if (acceptable.length > 0) {
208
+ const grouped = groupByNamespace(acceptable);
209
+ for (const [ns, blocks] of Object.entries(grouped)) {
210
+ const names = blocks.map((t) => getShortName(t.name)).join(', ');
211
+ lines.push(`ACCEPTABLE (${ns}/): ${names}`);
212
+ }
213
+ }
214
+ if (avoid.length > 0) {
215
+ const grouped = groupByNamespace(avoid);
216
+ for (const [ns, blocks] of Object.entries(grouped)) {
217
+ const mappings = blocks.map((t) => {
218
+ const replacement = t.preference.replacement;
219
+ const shortName = getShortName(t.name);
220
+ return replacement ? `${shortName} -> use ${replacement}` : shortName;
221
+ });
222
+ lines.push(`AVOID (${ns}/): ${mappings.join(', ')}`);
223
+ }
224
+ }
225
+ if (legacy.length > 0) {
226
+ const grouped = groupByNamespace(legacy);
227
+ for (const [ns, blocks] of Object.entries(grouped)) {
228
+ const mappings = blocks.map((t) => {
229
+ const replacement = t.preference.replacement;
230
+ const shortName = getShortName(t.name);
231
+ return replacement ? `${shortName} -> use ${replacement}` : shortName;
232
+ });
233
+ lines.push(`LEGACY — DO NOT USE (${ns}/): ${mappings.join(', ')}`);
234
+ }
235
+ }
236
+
237
+ const guidance = lines.join('\n');
238
+
239
+ return { block_types: types, guidance };
240
+ }
241
+
242
+ /**
243
+ * Format a preference warning into a single human-readable line.
244
+ *
245
+ * @param warning - The preference warning to format
246
+ * @returns Formatted warning string
247
+ */
248
+ export function formatPreferenceWarning(warning: PreferenceWarning): string {
249
+ if (warning.suggested_replacement) {
250
+ return `WARNING: ${warning.block} is non-preferred. Use ${warning.suggested_replacement} instead.`;
251
+ }
252
+ return `WARNING: ${warning.message}`;
253
+ }
254
+
255
+ // ============================================
256
+ // Helpers
257
+ // ============================================
258
+
259
+ /**
260
+ * Group block types by their namespace.
261
+ *
262
+ * @param types - Array of block types
263
+ * @returns Object keyed by namespace
264
+ */
265
+ function groupByNamespace(types: BlockType[]): Record<string, BlockType[]> {
266
+ const groups: Record<string, BlockType[]> = {};
267
+ for (const t of types) {
268
+ const ns = getNamespace(t.name);
269
+ if (!groups[ns]) groups[ns] = [];
270
+ groups[ns].push(t);
271
+ }
272
+ return groups;
273
+ }