euparliamentmonitor 0.9.19 → 0.9.21

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 (158) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -3
  3. package/scripts/aggregator/editorial-brief-resolver.d.ts +38 -0
  4. package/scripts/aggregator/editorial-brief-resolver.js +32 -0
  5. package/scripts/aggregator/generator/render-one.js +35 -0
  6. package/scripts/aggregator/html/localize-body.d.ts +32 -0
  7. package/scripts/aggregator/html/localize-body.js +69 -0
  8. package/scripts/aggregator/html/shell.d.ts +10 -0
  9. package/scripts/aggregator/html/shell.js +11 -1
  10. package/scripts/aggregator/markdown-renderer.d.ts +23 -24
  11. package/scripts/aggregator/markdown-renderer.js +39 -25
  12. package/scripts/aggregator/metadata/artifact-highlight.d.ts +15 -22
  13. package/scripts/aggregator/metadata/artifact-highlight.js +14 -230
  14. package/scripts/aggregator/metadata/artifact-walker.d.ts +34 -0
  15. package/scripts/aggregator/metadata/artifact-walker.js +177 -0
  16. package/scripts/aggregator/metadata/editorial-highlight.d.ts +15 -0
  17. package/scripts/aggregator/metadata/editorial-highlight.js +53 -0
  18. package/scripts/aggregator/metadata/priority-finding-highlight.js +7 -2
  19. package/scripts/aggregator/metadata/resolve-helpers.js +9 -3
  20. package/scripts/aggregator/metadata/text-utils.js +7 -0
  21. package/scripts/aggregator/metadata/translated-sibling.d.ts +23 -0
  22. package/scripts/aggregator/metadata/translated-sibling.js +39 -0
  23. package/scripts/aggregator/reader-guide/builder.js +3 -1
  24. package/scripts/aggregator/reader-guide/labels.d.ts +7 -0
  25. package/scripts/aggregator/reader-guide/labels.js +22 -0
  26. package/scripts/aggregator/reader-intelligence-guide.d.ts +1 -1
  27. package/scripts/aggregator/reader-intelligence-guide.js +1 -1
  28. package/scripts/aggregator/seo-entity-extractor.d.ts +45 -0
  29. package/scripts/aggregator/seo-entity-extractor.js +211 -0
  30. package/scripts/constants/articles/breaking-strings-central.d.ts +8 -0
  31. package/scripts/constants/articles/breaking-strings-central.js +105 -0
  32. package/scripts/constants/articles/breaking-strings-east.d.ts +8 -0
  33. package/scripts/constants/articles/breaking-strings-east.js +203 -0
  34. package/scripts/constants/articles/breaking-strings-nordic.d.ts +8 -0
  35. package/scripts/constants/articles/breaking-strings-nordic.js +252 -0
  36. package/scripts/constants/articles/breaking-strings-west.d.ts +8 -0
  37. package/scripts/constants/articles/breaking-strings-west.js +154 -0
  38. package/scripts/constants/articles/breaking.d.ts +0 -1
  39. package/scripts/constants/articles/breaking.js +9 -6
  40. package/scripts/constants/articles/dashboard/ar.d.ts +8 -0
  41. package/scripts/constants/articles/dashboard/ar.js +71 -0
  42. package/scripts/constants/articles/dashboard/da.d.ts +8 -0
  43. package/scripts/constants/articles/dashboard/da.js +71 -0
  44. package/scripts/constants/articles/dashboard/de.d.ts +8 -0
  45. package/scripts/constants/articles/dashboard/de.js +71 -0
  46. package/scripts/constants/articles/dashboard/en.d.ts +8 -0
  47. package/scripts/constants/articles/dashboard/en.js +71 -0
  48. package/scripts/constants/articles/dashboard/es.d.ts +8 -0
  49. package/scripts/constants/articles/dashboard/es.js +71 -0
  50. package/scripts/constants/articles/dashboard/fi.d.ts +8 -0
  51. package/scripts/constants/articles/dashboard/fi.js +71 -0
  52. package/scripts/constants/articles/dashboard/fr.d.ts +8 -0
  53. package/scripts/constants/articles/dashboard/fr.js +71 -0
  54. package/scripts/constants/articles/dashboard/he.d.ts +8 -0
  55. package/scripts/constants/articles/dashboard/he.js +71 -0
  56. package/scripts/constants/articles/dashboard/index.d.ts +7 -0
  57. package/scripts/constants/articles/dashboard/index.js +33 -0
  58. package/scripts/constants/articles/dashboard/ja.d.ts +8 -0
  59. package/scripts/constants/articles/dashboard/ja.js +71 -0
  60. package/scripts/constants/articles/dashboard/ko.d.ts +8 -0
  61. package/scripts/constants/articles/dashboard/ko.js +71 -0
  62. package/scripts/constants/articles/dashboard/nl.d.ts +8 -0
  63. package/scripts/constants/articles/dashboard/nl.js +71 -0
  64. package/scripts/constants/articles/dashboard/no.d.ts +8 -0
  65. package/scripts/constants/articles/dashboard/no.js +71 -0
  66. package/scripts/constants/articles/dashboard/sv.d.ts +8 -0
  67. package/scripts/constants/articles/dashboard/sv.js +71 -0
  68. package/scripts/constants/articles/dashboard/zh.d.ts +8 -0
  69. package/scripts/constants/articles/dashboard/zh.js +71 -0
  70. package/scripts/constants/articles/dashboard.d.ts +7 -2
  71. package/scripts/constants/articles/dashboard.js +4 -8
  72. package/scripts/constants/articles/deep-analysis/ar.d.ts +8 -0
  73. package/scripts/constants/articles/deep-analysis/ar.js +75 -0
  74. package/scripts/constants/articles/deep-analysis/da.d.ts +8 -0
  75. package/scripts/constants/articles/deep-analysis/da.js +75 -0
  76. package/scripts/constants/articles/deep-analysis/de.d.ts +8 -0
  77. package/scripts/constants/articles/deep-analysis/de.js +75 -0
  78. package/scripts/constants/articles/deep-analysis/en.d.ts +8 -0
  79. package/scripts/constants/articles/deep-analysis/en.js +75 -0
  80. package/scripts/constants/articles/deep-analysis/es.d.ts +8 -0
  81. package/scripts/constants/articles/deep-analysis/es.js +75 -0
  82. package/scripts/constants/articles/deep-analysis/fi.d.ts +8 -0
  83. package/scripts/constants/articles/deep-analysis/fi.js +75 -0
  84. package/scripts/constants/articles/deep-analysis/fr.d.ts +8 -0
  85. package/scripts/constants/articles/deep-analysis/fr.js +75 -0
  86. package/scripts/constants/articles/deep-analysis/he.d.ts +8 -0
  87. package/scripts/constants/articles/deep-analysis/he.js +75 -0
  88. package/scripts/constants/articles/deep-analysis/index.d.ts +7 -0
  89. package/scripts/constants/articles/deep-analysis/index.js +33 -0
  90. package/scripts/constants/articles/deep-analysis/ja.d.ts +8 -0
  91. package/scripts/constants/articles/deep-analysis/ja.js +75 -0
  92. package/scripts/constants/articles/deep-analysis/ko.d.ts +8 -0
  93. package/scripts/constants/articles/deep-analysis/ko.js +75 -0
  94. package/scripts/constants/articles/deep-analysis/nl.d.ts +8 -0
  95. package/scripts/constants/articles/deep-analysis/nl.js +75 -0
  96. package/scripts/constants/articles/deep-analysis/no.d.ts +8 -0
  97. package/scripts/constants/articles/deep-analysis/no.js +75 -0
  98. package/scripts/constants/articles/deep-analysis/sv.d.ts +8 -0
  99. package/scripts/constants/articles/deep-analysis/sv.js +75 -0
  100. package/scripts/constants/articles/deep-analysis/zh.d.ts +8 -0
  101. package/scripts/constants/articles/deep-analysis/zh.js +75 -0
  102. package/scripts/constants/articles/deep-analysis.d.ts +4 -3
  103. package/scripts/constants/articles/deep-analysis.js +3 -7
  104. package/scripts/constants/articles/localized-keywords-central.d.ts +8 -0
  105. package/scripts/constants/articles/localized-keywords-central.js +118 -0
  106. package/scripts/constants/articles/localized-keywords-nordic.d.ts +8 -0
  107. package/scripts/constants/articles/localized-keywords-nordic.js +303 -0
  108. package/scripts/constants/articles/localized-keywords.js +4 -2
  109. package/scripts/constants/articles/swot-builder-central.d.ts +8 -0
  110. package/scripts/constants/articles/swot-builder-central.js +90 -0
  111. package/scripts/constants/articles/swot-builder-nordic.d.ts +8 -0
  112. package/scripts/constants/articles/swot-builder-nordic.js +216 -0
  113. package/scripts/constants/articles/swot.js +4 -2
  114. package/scripts/constants/articles/week-ahead-eu.d.ts +12 -0
  115. package/scripts/constants/articles/week-ahead-eu.js +278 -0
  116. package/scripts/constants/articles/week-ahead-global.d.ts +12 -0
  117. package/scripts/constants/articles/week-ahead-global.js +278 -0
  118. package/scripts/constants/articles/week-ahead.d.ts +4 -7
  119. package/scripts/constants/articles/week-ahead.js +11 -535
  120. package/scripts/constants/world-bank/category-map-analysis.d.ts +9 -0
  121. package/scripts/constants/world-bank/category-map-analysis.js +204 -0
  122. package/scripts/constants/world-bank/category-map-legislative.d.ts +9 -0
  123. package/scripts/constants/world-bank/category-map-legislative.js +130 -0
  124. package/scripts/constants/world-bank/category-map-periodic.d.ts +9 -0
  125. package/scripts/constants/world-bank/category-map-periodic.js +176 -0
  126. package/scripts/constants/world-bank/category-map.d.ts +3 -26
  127. package/scripts/constants/world-bank/category-map.js +8 -501
  128. package/scripts/discover-untranslated-briefs.js +123 -4
  129. package/scripts/generators/news-indexes/per-language.js +21 -7
  130. package/scripts/generators/political-intelligence/html.js +39 -8
  131. package/scripts/generators/sitemap/html.js +25 -7
  132. package/scripts/mcp/ep/client.d.ts +0 -1
  133. package/scripts/mcp/ep/client.js +0 -65
  134. package/scripts/mcp/ep/error-classifier.d.ts +2 -2
  135. package/scripts/mcp/ep/error-classifier.js +2 -2
  136. package/scripts/mcp/ep/tools-list.d.ts +13 -0
  137. package/scripts/mcp/ep/tools-list.js +79 -0
  138. package/scripts/mcp/ep-mcp-client.d.ts +1 -0
  139. package/scripts/mcp/ep-mcp-client.js +1 -0
  140. package/scripts/mcp/imf/client.d.ts +3 -64
  141. package/scripts/mcp/imf/client.js +18 -207
  142. package/scripts/mcp/imf/http-transport.d.ts +92 -0
  143. package/scripts/mcp/imf/http-transport.js +232 -0
  144. package/scripts/mcp/transport/connection.d.ts +25 -53
  145. package/scripts/mcp/transport/connection.js +90 -250
  146. package/scripts/mcp/transport/process.d.ts +62 -0
  147. package/scripts/mcp/transport/process.js +147 -0
  148. package/scripts/mcp/transport/reconnect.d.ts +73 -0
  149. package/scripts/mcp/transport/reconnect.js +96 -0
  150. package/scripts/validate-brief-translations.js +122 -6
  151. package/scripts/constants/articles/breaking-strings-eu.d.ts +0 -7
  152. package/scripts/constants/articles/breaking-strings-global.d.ts +0 -7
  153. package/scripts/constants/articles/dashboard-builder-eu.d.ts +0 -7
  154. package/scripts/constants/articles/dashboard-builder-global.d.ts +0 -7
  155. package/scripts/constants/articles/deep-analysis-strings-eu.d.ts +0 -7
  156. package/scripts/constants/articles/deep-analysis-strings-global.d.ts +0 -7
  157. package/scripts/constants/articles/localized-keywords-eu.d.ts +0 -7
  158. package/scripts/constants/articles/swot-builder-eu.d.ts +0 -7
@@ -0,0 +1,147 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * @module MCP/transport/process
5
+ * @description Stdio process spawn / teardown helpers and JSON-RPC message routing
6
+ * for the MCPConnection stdio transport.
7
+ *
8
+ * Extracted from `connection.ts` to keep individual file sizes under 400 LOC.
9
+ * Operates on an explicit {@link SpawnContext} adapter rather than `this`.
10
+ */
11
+ import { spawn } from 'child_process';
12
+ import { resolve, dirname } from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ // ─── Binary path constants ────────────────────────────────────────────────────
15
+ /** npm binary name for the European Parliament MCP server */
16
+ export const BINARY_NAME = 'european-parliament-mcp-server';
17
+ /** Platform-specific binary filename (Windows uses .cmd shim) */
18
+ export const BINARY_FILE = process.platform === 'win32' ? `${BINARY_NAME}.cmd` : BINARY_NAME;
19
+ /** Default binary resolved from node_modules/.bin relative to this file's compiled location */
20
+ export const DEFAULT_SERVER_BINARY = resolve(dirname(fileURLToPath(import.meta.url)), `../../../node_modules/.bin/${BINARY_FILE}`);
21
+ /** Default request timeout in milliseconds — EU Parliament API responses commonly take 30-120+ seconds for large datasets */
22
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 180_000;
23
+ /**
24
+ * Effective request timeout, configurable via `EP_REQUEST_TIMEOUT_MS` env var.
25
+ * This keeps the client-side timeout aligned with the MCP server timeout set
26
+ * in workflow configs and copilot-mcp.json.
27
+ */
28
+ export const REQUEST_TIMEOUT_MS = (() => {
29
+ const envVal = process.env['EP_REQUEST_TIMEOUT_MS'];
30
+ if (envVal) {
31
+ const parsed = Number(envVal);
32
+ if (Number.isFinite(parsed) && parsed > 0)
33
+ return parsed;
34
+ }
35
+ return DEFAULT_REQUEST_TIMEOUT_MS;
36
+ })();
37
+ /** Connection startup delay in milliseconds */
38
+ export const CONNECTION_STARTUP_DELAY_MS = 500;
39
+ // ─── Exported helpers ─────────────────────────────────────────────────────────
40
+ /**
41
+ * Attempt a single connection via stdio (spawns server binary).
42
+ *
43
+ * @param ctx - Spawn context adapter from MCPConnection
44
+ */
45
+ export async function attemptStdioConnection(ctx) {
46
+ try {
47
+ const isJavaScriptFile = ctx.serverPath.toLowerCase().endsWith('.js');
48
+ const command = isJavaScriptFile ? process.execPath : ctx.serverPath;
49
+ const args = isJavaScriptFile ? [ctx.serverPath] : [];
50
+ const childEnv = { ...process.env };
51
+ const envVal = childEnv['EP_REQUEST_TIMEOUT_MS'];
52
+ let effectiveTimeoutMs = ctx.requestTimeoutMs;
53
+ if (envVal !== undefined && envVal !== '') {
54
+ const parsed = Number(envVal);
55
+ if (Number.isFinite(parsed) && parsed > 0) {
56
+ effectiveTimeoutMs = parsed;
57
+ }
58
+ else {
59
+ console.warn(`Invalid EP_REQUEST_TIMEOUT_MS value (non-finite or ≤0); falling back to ${ctx.requestTimeoutMs}ms`);
60
+ }
61
+ }
62
+ childEnv['EP_REQUEST_TIMEOUT_MS'] = String(effectiveTimeoutMs);
63
+ if (!isJavaScriptFile) {
64
+ args.push('--timeout', String(effectiveTimeoutMs));
65
+ }
66
+ const child = spawn(command, args, {
67
+ stdio: ['pipe', 'pipe', 'pipe'],
68
+ env: childEnv,
69
+ });
70
+ ctx.setProcess(child);
71
+ let buffer = '';
72
+ let startupError = null;
73
+ child.stdout?.on('data', (data) => {
74
+ buffer += data.toString();
75
+ const lines = buffer.split('\n');
76
+ buffer = lines.pop() ?? '';
77
+ for (const line of lines) {
78
+ if (line.trim()) {
79
+ ctx.onMessage(line);
80
+ }
81
+ }
82
+ });
83
+ child.stderr?.on('data', (data) => {
84
+ const message = data.toString().trim();
85
+ if (message) {
86
+ console.error(`MCP Server: ${message}`);
87
+ }
88
+ });
89
+ child.on('close', (code) => {
90
+ console.log(`MCP Server exited with code ${code}`);
91
+ ctx.setConnected(false);
92
+ ctx.rejectAllPending('MCP server connection closed');
93
+ });
94
+ child.on('error', (err) => {
95
+ startupError = err;
96
+ ctx.setConnected(false);
97
+ });
98
+ await new Promise((resolve) => setTimeout(resolve, CONNECTION_STARTUP_DELAY_MS));
99
+ if (startupError) {
100
+ throw startupError;
101
+ }
102
+ ctx.setConnected(true);
103
+ console.log(`✅ Connected to ${ctx.serverLabel}`);
104
+ }
105
+ catch (error) {
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ console.error('❌ Failed to spawn MCP server:', message);
108
+ throw error;
109
+ }
110
+ }
111
+ /**
112
+ * Handle an incoming newline-delimited JSON-RPC message from the server.
113
+ * Routes responses to matching in-flight pending requests; logs notifications.
114
+ *
115
+ * @param line - Single JSON-RPC message line (no trailing newline)
116
+ * @param pending - In-flight request map maintained by {@link MCPConnection}
117
+ */
118
+ export function handleIncomingMessage(line, pending) {
119
+ try {
120
+ const message = JSON.parse(line);
121
+ if (message.id !== null && message.id !== undefined && pending.has(message.id)) {
122
+ const req = pending.get(message.id);
123
+ if (req) {
124
+ pending.delete(message.id);
125
+ if (message.error) {
126
+ req.reject(new Error(message.error.message ?? 'MCP server error'));
127
+ }
128
+ else {
129
+ req.resolve(message.result);
130
+ }
131
+ }
132
+ else {
133
+ pending.delete(message.id);
134
+ console.error(`MCP pending request ${String(message.id)} vanished before handling`);
135
+ }
136
+ }
137
+ else if ((message.id === null || message.id === undefined) && message.method) {
138
+ console.log(`MCP Notification: ${message.method}`);
139
+ }
140
+ }
141
+ catch (error) {
142
+ const errorMessage = error instanceof Error ? error.message : String(error);
143
+ console.error('Error parsing MCP message:', errorMessage);
144
+ console.error('Problematic line:', line);
145
+ }
146
+ }
147
+ //# sourceMappingURL=process.js.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module MCP/transport/reconnect
3
+ * @description Exponential back-off reconnect loop and tool-call retry logic
4
+ * for MCPConnection.
5
+ *
6
+ * Extracted from `connection.ts` to keep individual file sizes under 400 LOC.
7
+ * Operates on an explicit {@link ReconnectOps} adapter rather than `this`.
8
+ */
9
+ import type { MCPToolResult } from '../../types/index.js';
10
+ /**
11
+ * Adapter passed by {@link MCPConnection} to reconnect/retry helpers.
12
+ * Exposes the handful of connection fields these helpers need through
13
+ * getter/setter callbacks so the fields remain on the connection class
14
+ * (preserving external observability via `getConnectionHealth()`).
15
+ */
16
+ export interface ReconnectOps {
17
+ /** Maximum consecutive connection attempts before giving up */
18
+ readonly maxConnectionAttempts: number;
19
+ /** Base delay (ms) between retry attempts */
20
+ readonly connectionRetryDelay: number;
21
+ /** Human-readable label for log messages */
22
+ readonly serverLabel: string;
23
+ /** Call the underlying MCP tool (used by the retry loop) */
24
+ readonly callTool: (name: string, args: object) => Promise<MCPToolResult>;
25
+ /** Whether the transport is currently connected */
26
+ readonly isConnected: () => boolean;
27
+ /** Trigger a full reconnect cycle */
28
+ readonly connect: () => Promise<void>;
29
+ /** Mark the transport as (dis)connected */
30
+ readonly setConnected: (v: boolean) => void;
31
+ /** Current reconnect-attempt counter */
32
+ readonly getReconnectCount: () => number;
33
+ /** Update the reconnect-attempt counter */
34
+ readonly setReconnectCount: (n: number) => void;
35
+ /** Current in-flight reconnect promise (or null) */
36
+ readonly getReconnectingPromise: () => Promise<void> | null;
37
+ /** Persist or clear the in-flight reconnect promise */
38
+ readonly setReconnectingPromise: (p: Promise<void> | null) => void;
39
+ /** Current cumulative timeout counter */
40
+ readonly getTimeoutCount: () => number;
41
+ /** Update the cumulative timeout counter */
42
+ readonly setTimeoutCount: (n: number) => void;
43
+ }
44
+ /**
45
+ * Reconnect with exponential back-off. Concurrent callers await the same
46
+ * in-flight reconnect promise instead of spawning parallel attempts.
47
+ *
48
+ * @param ops - Reconnect operations adapter from MCPConnection
49
+ * @returns Promise that resolves when reconnection succeeds or all attempts are exhausted
50
+ */
51
+ export declare function performReconnect(ops: ReconnectOps): Promise<void>;
52
+ /**
53
+ * Log a retry warning and, if disconnected, attempt to reconnect before waiting.
54
+ *
55
+ * @param lastError - The error from the failed attempt
56
+ * @param attempt - Zero-based current attempt index
57
+ * @param retries - Total retry count
58
+ * @param ops - Reconnect operations adapter
59
+ * @returns Promise that resolves after logging, optional reconnect, and inter-retry delay
60
+ */
61
+ export declare function handleRetryAttempt(lastError: Error, attempt: number, retries: number, ops: ReconnectOps): Promise<void>;
62
+ /**
63
+ * Call an MCP tool with automatic retry on timeout or connection loss.
64
+ * Non-retriable errors are re-thrown immediately without consuming retry budget.
65
+ *
66
+ * @param name - Tool name
67
+ * @param args - Tool arguments (plain object, non-null, not an array)
68
+ * @param retries - Maximum number of retries (validated ≥ 0 by caller)
69
+ * @param ops - Reconnect operations adapter
70
+ * @returns Tool execution result
71
+ */
72
+ export declare function runWithRetry(name: string, args: object, retries: number, ops: ReconnectOps): Promise<MCPToolResult>;
73
+ //# sourceMappingURL=reconnect.d.ts.map
@@ -0,0 +1,96 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { isRetriableError, RECONNECT_MAX_DELAY_MS } from './retry-policy.js';
4
+ // ─── Exported helpers ─────────────────────────────────────────────────────────
5
+ /**
6
+ * Reconnect with exponential back-off. Concurrent callers await the same
7
+ * in-flight reconnect promise instead of spawning parallel attempts.
8
+ *
9
+ * @param ops - Reconnect operations adapter from MCPConnection
10
+ * @returns Promise that resolves when reconnection succeeds or all attempts are exhausted
11
+ */
12
+ export async function performReconnect(ops) {
13
+ const inflight = ops.getReconnectingPromise();
14
+ if (inflight !== null) {
15
+ return inflight;
16
+ }
17
+ ops.setReconnectCount(ops.getReconnectCount() + 1);
18
+ console.log(`🔄 Reconnecting to ${ops.serverLabel} (attempt ${ops.getReconnectCount()})...`);
19
+ const p = doReconnect(ops);
20
+ ops.setReconnectingPromise(p);
21
+ try {
22
+ await p;
23
+ }
24
+ finally {
25
+ ops.setReconnectingPromise(null);
26
+ }
27
+ }
28
+ /**
29
+ * Internal reconnect helper. Waits for an exponential back-off delay then
30
+ * delegates to `connect()`, which handles its own retry loop.
31
+ *
32
+ * @param ops - Reconnect operations adapter
33
+ */
34
+ async function doReconnect(ops) {
35
+ const normalizedMax = Math.max(1, ops.maxConnectionAttempts);
36
+ const attemptIndex = Math.min(Math.max(0, ops.getReconnectCount() - 1), normalizedMax - 1);
37
+ const delay = Math.min(ops.connectionRetryDelay * Math.pow(2, attemptIndex), RECONNECT_MAX_DELAY_MS);
38
+ await new Promise((r) => setTimeout(r, delay));
39
+ try {
40
+ ops.setConnected(false);
41
+ await ops.connect();
42
+ }
43
+ catch (error) {
44
+ console.error(`❌ Reconnection to ${ops.serverLabel} failed: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ }
47
+ /**
48
+ * Log a retry warning and, if disconnected, attempt to reconnect before waiting.
49
+ *
50
+ * @param lastError - The error from the failed attempt
51
+ * @param attempt - Zero-based current attempt index
52
+ * @param retries - Total retry count
53
+ * @param ops - Reconnect operations adapter
54
+ * @returns Promise that resolves after logging, optional reconnect, and inter-retry delay
55
+ */
56
+ export async function handleRetryAttempt(lastError, attempt, retries, ops) {
57
+ if (lastError.message.toLowerCase().includes('timeout')) {
58
+ ops.setTimeoutCount(ops.getTimeoutCount() + 1);
59
+ console.warn(`⏱️ Request timeout (total: ${ops.getTimeoutCount()}), retrying ${attempt + 1}/${retries}...`);
60
+ }
61
+ else {
62
+ console.warn(`⚠️ Request failed, retrying ${attempt + 1}/${retries}: ${lastError.message}`);
63
+ }
64
+ if (!ops.isConnected()) {
65
+ await performReconnect(ops);
66
+ }
67
+ await new Promise((r) => setTimeout(r, ops.connectionRetryDelay * (attempt + 1)));
68
+ }
69
+ /**
70
+ * Call an MCP tool with automatic retry on timeout or connection loss.
71
+ * Non-retriable errors are re-thrown immediately without consuming retry budget.
72
+ *
73
+ * @param name - Tool name
74
+ * @param args - Tool arguments (plain object, non-null, not an array)
75
+ * @param retries - Maximum number of retries (validated ≥ 0 by caller)
76
+ * @param ops - Reconnect operations adapter
77
+ * @returns Tool execution result
78
+ */
79
+ export async function runWithRetry(name, args, retries, ops) {
80
+ let lastError = new Error(`Failed to call tool '${name}' after ${retries} retries`);
81
+ for (let attempt = 0; attempt <= retries; attempt++) {
82
+ try {
83
+ return await ops.callTool(name, args);
84
+ }
85
+ catch (error) {
86
+ lastError = error instanceof Error ? error : new Error(String(error));
87
+ if (!isRetriableError(lastError))
88
+ throw lastError;
89
+ if (attempt === retries)
90
+ break;
91
+ await handleRetryAttempt(lastError, attempt, retries, ops);
92
+ }
93
+ }
94
+ throw lastError;
95
+ }
96
+ //# sourceMappingURL=reconnect.js.map
@@ -34,9 +34,25 @@
34
34
  * is a machine-readable fixed token; dropping a diagram silently breaks
35
35
  * downstream HTML rendering.
36
36
  *
37
+ * **Skeleton-aware mode**: when a translation file declares itself as a
38
+ * Phase A skeleton via the `<!-- translation-skeleton: lang=<code> ... -->`
39
+ * marker (written by the `news-translate` workflow's 2-phase
40
+ * largeSource strategy), gates 3–7 are SKIPPED and a single
41
+ * `skeleton-incomplete` advisory is emitted instead. The advisory is
42
+ * classified as `severity: warning` and does NOT cause a non-zero exit
43
+ * unless `--strict-skeletons` is passed. This is intentional: emergency
44
+ * partial flushes (Step 4b of the translate workflow) write skeleton
45
+ * stubs for languages that did not reach Phase B before the wall-clock
46
+ * budget expired; those stubs would otherwise trigger 5+ cascading
47
+ * violations per file (length-floor, fixed-token-preservation,
48
+ * heading-parity, mermaid-parity) that drown out real defects in fully
49
+ * translated siblings. Real translations in the same brief continue to
50
+ * receive strict validation.
51
+ *
37
52
  * Each translation that fails any gate produces a structured report entry.
38
53
  * The process exits with code 1 if any failures are present (unless
39
- * `--no-fail` is passed for advisory mode).
54
+ * `--no-fail` is passed for advisory mode, or unless every remaining
55
+ * violation has `severity: warning`).
40
56
  *
41
57
  * This script is invoked by:
42
58
  * - `npm run validate:translations` (CI + local)
@@ -48,6 +64,8 @@
48
64
  * [--paths <glob>...] # validate specific translation files only
49
65
  * [--report <path>] # write JSON report; default stdout
50
66
  * [--no-fail] # exit 0 even when violations found
67
+ * [--strict-skeletons] # treat skeleton-incomplete advisories as
68
+ * # blocking violations (default: warning)
51
69
  * [--quiet] # suppress per-file logging
52
70
  */
53
71
 
@@ -199,6 +217,45 @@ export function countMermaidBlocks(text) {
199
217
  return countGlobal(text, MERMAID_OPENER);
200
218
  }
201
219
 
220
+ /**
221
+ * Marker that the `news-translate` workflow Phase A writes at the very top
222
+ * of every skeleton file (before the H1 line). When the validator sees this
223
+ * marker it knows the file is a deliberately incomplete Phase A skeleton
224
+ * from an emergency partial flush and emits a single `skeleton-incomplete`
225
+ * advisory instead of cascading length/token/heading/mermaid violations.
226
+ *
227
+ * The marker is intentionally a forgiving regex: any HTML comment starting
228
+ * with `<!-- translation-skeleton` within the first 10 lines counts. This
229
+ * accommodates both `<!-- translation-skeleton: lang=sv phase=A -->` and
230
+ * the legacy `<!-- translation-skeleton -->` formats.
231
+ */
232
+ export const SKELETON_MARKER_RE = /<!--\s*translation-skeleton\b/;
233
+
234
+ /**
235
+ * Heuristic skeleton detector. A file is a skeleton if EITHER:
236
+ * - it contains the explicit `SKELETON_MARKER_RE` marker in its first
237
+ * 10 lines (preferred, set by Phase A), OR
238
+ * - it matches the fallback heuristic for older Phase A output that
239
+ * pre-dates the marker convention: ≥3 H2 headings AND the number of
240
+ * `<!-- pending -->` / `<PENDING>` / `PENDING` placeholders is at
241
+ * least equal to the H2 count (i.e. every H2 section appears to be
242
+ * an unfilled stub).
243
+ *
244
+ * @param {string} text
245
+ * @returns {boolean}
246
+ */
247
+ export function isSkeletonStub(text) {
248
+ if (typeof text !== 'string' || text.length === 0) return false;
249
+ const head = text.split('\n', 10).join('\n');
250
+ if (SKELETON_MARKER_RE.test(head)) return true;
251
+ // Fallback heuristic: many H2s, almost every body line is a pending marker.
252
+ const h2Count = countHeadings(text, 2);
253
+ if (h2Count < 3) return false;
254
+ const pendingRe = /(<!--\s*pending\s*-->|<PENDING>|\bPENDING\b)/i;
255
+ const pendingHits = (text.match(new RegExp(pendingRe.source, 'gi')) || []).length;
256
+ return pendingHits >= h2Count;
257
+ }
258
+
202
259
  /**
203
260
  * Extract H2 section titles from markdown text. Mirrors the shape returned
204
261
  * by `scripts/discover-untranslated-briefs.js#extractH2Titles` so the
@@ -324,6 +381,10 @@ export function aggregateByKey(items, key) {
324
381
  * @property {string} lang
325
382
  * @property {string} gate
326
383
  * @property {string} message
384
+ * @property {'error'|'warning'} [severity] - When present, controls
385
+ * blocking semantics: `'warning'` entries (e.g. `skeleton-incomplete`)
386
+ * do not cause a non-zero exit unless `--strict-skeletons` is passed.
387
+ * Omitted entries default to blocking (`'error'` equivalent).
327
388
  */
328
389
 
329
390
  /** Parse CLI argv. Exported for unit tests. */
@@ -334,6 +395,7 @@ export function parseArgs(argv) {
334
395
  report: null,
335
396
  fail: true,
336
397
  quiet: false,
398
+ strictSkeletons: false,
337
399
  };
338
400
  for (let i = 0; i < argv.length; i += 1) {
339
401
  const arg = argv[i];
@@ -355,6 +417,9 @@ export function parseArgs(argv) {
355
417
  case '--no-fail':
356
418
  opts.fail = false;
357
419
  break;
420
+ case '--strict-skeletons':
421
+ opts.strictSkeletons = true;
422
+ break;
358
423
  case '--quiet':
359
424
  opts.quiet = true;
360
425
  break;
@@ -362,7 +427,8 @@ export function parseArgs(argv) {
362
427
  case '-h':
363
428
  process.stdout.write(
364
429
  'Usage: validate-brief-translations.js [--repo-root <path>] ' +
365
- '[--paths <file>...] [--report <path>] [--no-fail] [--quiet]\n'
430
+ '[--paths <file>...] [--report <path>] [--no-fail] ' +
431
+ '[--strict-skeletons] [--quiet]\n'
366
432
  );
367
433
  process.exit(0);
368
434
  break;
@@ -444,6 +510,34 @@ export function validateTranslation(translationPath, repoRoot) {
444
510
  return violations;
445
511
  }
446
512
 
513
+ // Skeleton short-circuit: when a translation file declares itself as a
514
+ // Phase A skeleton (via the `<!-- translation-skeleton -->` marker the
515
+ // news-translate workflow writes during emergency partial flushes), skip
516
+ // gates 3–7 and emit a single non-blocking `skeleton-incomplete`
517
+ // advisory. The marker is a deliberate contract between the workflow
518
+ // and this validator: an emergency partial flush is a SUCCESSFUL
519
+ // outcome (some real translations were saved), and the unfilled
520
+ // skeleton stubs for languages that did not reach Phase B should not
521
+ // generate 5+ cascading violations that drown out real defects in
522
+ // the fully translated siblings. The next scheduled run will pick up
523
+ // the skeleton languages via the discovery queue's missing-language
524
+ // detection. See `.github/workflows/news-translate.md` §"🐘
525
+ // LARGE-SOURCE 2-PHASE STRATEGY" and §"4b. Wall-clock safety net".
526
+ const targetTextEarly = fs.readFileSync(translationPath, 'utf8');
527
+ if (isSkeletonStub(targetTextEarly)) {
528
+ violations.push({
529
+ translationPath: rel,
530
+ sourcePath: sourceRel,
531
+ lang,
532
+ gate: 'skeleton-incomplete',
533
+ severity: 'warning',
534
+ message:
535
+ `Phase A skeleton stub — translation for "${lang}" did not reach Phase B before the wall-clock budget expired. ` +
536
+ `Re-queue this language on the next scheduled run; the discovery script will detect it as a missing sibling.`,
537
+ });
538
+ return violations;
539
+ }
540
+
447
541
  const sourceBytes = fs.statSync(sourcePath).size;
448
542
  const targetBytes = fs.statSync(translationPath).size;
449
543
  if (sourceBytes > 0 && targetBytes < sourceBytes * LENGTH_FLOOR_RATIO) {
@@ -458,7 +552,7 @@ export function validateTranslation(translationPath, repoRoot) {
458
552
  });
459
553
  }
460
554
 
461
- const targetText = fs.readFileSync(translationPath, 'utf8');
555
+ const targetText = targetTextEarly;
462
556
  let englishHits = 0;
463
557
  for (const re of EN_PATTERNS) {
464
558
  if (re.test(targetText)) englishHits += 1;
@@ -503,7 +597,9 @@ export function validateTranslation(translationPath, repoRoot) {
503
597
  `Self-check before flush: \`node scripts/validate-brief-translations.js --paths ${relQuoted}\` ` +
504
598
  `(or \`--paths ${siblingGlobQuoted}\` to validate every sibling). ` +
505
599
  `Dutch example: \`IMF\` stays \`IMF\` (never \`IMV\`); \`WEO\` stays \`WEO\` ` +
506
- `(never \`Wereldwijde Economische Vooruitzichten\`).`,
600
+ `(never \`Wereldwijde Economische Vooruitzichten\`). ` +
601
+ `Norwegian example: \`IMF\` forblir \`IMF\` (never \`IPF\` / \`IMV\` / ` +
602
+ `\`Det internasjonale valutafondet\` / \`Pengefondet\`); \`WEO\` forblir \`WEO\`.`,
507
603
  });
508
604
  }
509
605
  }
@@ -600,8 +696,9 @@ export function runValidation(translationPaths, repoRoot, { quiet = false } = {}
600
696
  allViolations.push(...v);
601
697
  if (!quiet) {
602
698
  for (const entry of v) {
699
+ const icon = entry.severity === 'warning' ? '⚠️' : '❌';
603
700
  process.stderr.write(
604
- `❌ ${entry.translationPath} [${entry.gate}] ${entry.message}\n`
701
+ `${icon} ${entry.translationPath} [${entry.gate}] ${entry.message}\n`
605
702
  );
606
703
  }
607
704
  }
@@ -612,6 +709,23 @@ export function runValidation(translationPaths, repoRoot, { quiet = false } = {}
612
709
  return allViolations;
613
710
  }
614
711
 
712
+ /**
713
+ * Count the entries in a violations list that should be treated as
714
+ * blocking (cause a non-zero exit). When `strictSkeletons` is false
715
+ * (the default), entries with `severity: 'warning'` — i.e. the
716
+ * skeleton-incomplete advisory emitted for Phase A stubs from
717
+ * emergency partial flushes — are not counted as blocking.
718
+ */
719
+ export function countBlockingViolations(violations, { strictSkeletons = false } = {}) {
720
+ if (strictSkeletons) {
721
+ // Promote skeleton-incomplete advisories to blocking, but leave any
722
+ // other warning-level advisory types non-blocking so that future
723
+ // additions of new warning gates don't inadvertently become strict.
724
+ return violations.filter((v) => v.severity !== 'warning' || v.gate === 'skeleton-incomplete').length;
725
+ }
726
+ return violations.filter((v) => v.severity !== 'warning').length;
727
+ }
728
+
615
729
  /** Main entry point. */
616
730
  export function main(argv) {
617
731
  const opts = parseArgs(argv);
@@ -620,12 +734,14 @@ export function main(argv) {
620
734
  : findAllTranslations(opts.repoRoot);
621
735
 
622
736
  const violations = runValidation(paths, opts.repoRoot, { quiet: opts.quiet });
737
+ const blocking = countBlockingViolations(violations, { strictSkeletons: opts.strictSkeletons });
623
738
 
624
739
  const report = {
625
740
  generatedAt: new Date().toISOString(),
626
741
  totals: {
627
742
  filesChecked: paths.length,
628
743
  violations: violations.length,
744
+ blocking,
629
745
  byGate: aggregateByKey(violations, 'gate'),
630
746
  byLang: aggregateByKey(violations, 'lang'),
631
747
  },
@@ -639,7 +755,7 @@ export function main(argv) {
639
755
  process.stdout.write(json);
640
756
  }
641
757
 
642
- if (violations.length > 0 && opts.fail) {
758
+ if (blocking > 0 && opts.fail) {
643
759
  process.exit(1);
644
760
  }
645
761
  return report;
@@ -1,7 +0,0 @@
1
- /**
2
- * @module breaking-strings-eu
3
- * @description Per-language entries (en, sv, da, no, fi, de, fr) for BREAKING_STRINGS.
4
- */
5
- import type { LanguageMap, BreakingStrings } from '../../types/index.js';
6
- export declare const BREAKING_STRINGS_EU: Pick<LanguageMap<BreakingStrings>, 'en' | 'sv' | 'da' | 'no' | 'fi' | 'de' | 'fr'>;
7
- //# sourceMappingURL=breaking-strings-eu.d.ts.map
@@ -1,7 +0,0 @@
1
- /**
2
- * @module breaking-strings-global
3
- * @description Per-language entries (es, nl, ar, he, ja, ko, zh) for BREAKING_STRINGS.
4
- */
5
- import type { LanguageMap, BreakingStrings } from '../../types/index.js';
6
- export declare const BREAKING_STRINGS_GLOBAL: Pick<LanguageMap<BreakingStrings>, 'es' | 'nl' | 'ar' | 'he' | 'ja' | 'ko' | 'zh'>;
7
- //# sourceMappingURL=breaking-strings-global.d.ts.map
@@ -1,7 +0,0 @@
1
- /**
2
- * @module dashboard-builder-eu
3
- * @description Per-language entries (en, sv, da, no, fi, de, fr) for DASHBOARD_BUILDER_STRINGS.
4
- */
5
- import type { LanguageMap, DashboardBuilderStrings } from '../../types/index.js';
6
- export declare const DASHBOARD_BUILDER_STRINGS_EU: Pick<LanguageMap<DashboardBuilderStrings>, 'en' | 'sv' | 'da' | 'no' | 'fi' | 'de' | 'fr'>;
7
- //# sourceMappingURL=dashboard-builder-eu.d.ts.map
@@ -1,7 +0,0 @@
1
- /**
2
- * @module dashboard-builder-global
3
- * @description Per-language entries (es, nl, ar, he, ja, ko, zh) for DASHBOARD_BUILDER_STRINGS.
4
- */
5
- import type { LanguageMap, DashboardBuilderStrings } from '../../types/index.js';
6
- export declare const DASHBOARD_BUILDER_STRINGS_GLOBAL: Pick<LanguageMap<DashboardBuilderStrings>, 'es' | 'nl' | 'ar' | 'he' | 'ja' | 'ko' | 'zh'>;
7
- //# sourceMappingURL=dashboard-builder-global.d.ts.map
@@ -1,7 +0,0 @@
1
- /**
2
- * @module deep-analysis-strings-eu
3
- * @description Per-language entries (en, sv, da, no, fi, de, fr) for DEEP_ANALYSIS_STRINGS.
4
- */
5
- import type { LanguageMap, DeepAnalysisStrings } from '../../types/index.js';
6
- export declare const DEEP_ANALYSIS_STRINGS_EU: Pick<LanguageMap<DeepAnalysisStrings>, 'en' | 'sv' | 'da' | 'no' | 'fi' | 'de' | 'fr'>;
7
- //# sourceMappingURL=deep-analysis-strings-eu.d.ts.map
@@ -1,7 +0,0 @@
1
- /**
2
- * @module deep-analysis-strings-global
3
- * @description Per-language entries (es, nl, ar, he, ja, ko, zh) for DEEP_ANALYSIS_STRINGS.
4
- */
5
- import type { LanguageMap, DeepAnalysisStrings } from '../../types/index.js';
6
- export declare const DEEP_ANALYSIS_STRINGS_GLOBAL: Pick<LanguageMap<DeepAnalysisStrings>, 'es' | 'nl' | 'ar' | 'he' | 'ja' | 'ko' | 'zh'>;
7
- //# sourceMappingURL=deep-analysis-strings-global.d.ts.map
@@ -1,7 +0,0 @@
1
- /**
2
- * @module localized-keywords-eu
3
- * @description Per-language entries (en, sv, da, no, fi, de, fr) for LOCALIZED_KEYWORDS.
4
- */
5
- import type { LanguageMap } from '../../types/index.js';
6
- export declare const LOCALIZED_KEYWORDS_EU: Pick<LanguageMap<Record<string, readonly string[]>>, 'en' | 'sv' | 'da' | 'no' | 'fi' | 'de' | 'fr'>;
7
- //# sourceMappingURL=localized-keywords-eu.d.ts.map
@@ -1,7 +0,0 @@
1
- /**
2
- * @module swot-builder-eu
3
- * @description Per-language entries (en, sv, da, no, fi, de, fr) for SWOT_BUILDER_STRINGS.
4
- */
5
- import type { LanguageMap, SwotBuilderStrings } from '../../types/index.js';
6
- export declare const SWOT_BUILDER_STRINGS_EU: Pick<LanguageMap<SwotBuilderStrings>, 'en' | 'sv' | 'da' | 'no' | 'fi' | 'de' | 'fr'>;
7
- //# sourceMappingURL=swot-builder-eu.d.ts.map