euparliamentmonitor 0.8.30 → 0.8.31

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/README.md CHANGED
@@ -124,7 +124,7 @@ import {
124
124
 
125
125
  **MCP Server Integration**: The project uses the
126
126
  [European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server)
127
- v1.2.6 for accessing real EU Parliament data via the Model Context Protocol.
127
+ v1.2.7 for accessing real EU Parliament data via the Model Context Protocol.
128
128
 
129
129
  - **MCP Server Status**: ✅ Fully operational — 60+ EP data tools available
130
130
  (feeds, direct lookups, analytical tools, intelligence correlation)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.8.30",
3
+ "version": "0.8.31",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -159,7 +159,7 @@
159
159
  "jscpd": "4.0.9",
160
160
  "lint-staged": "16.4.0",
161
161
  "papaparse": "5.5.3",
162
- "prettier": "3.8.2",
162
+ "prettier": "3.8.3",
163
163
  "ts-api-utils": "2.5.0",
164
164
  "tsx": "4.21.0",
165
165
  "typedoc": "0.28.19",
@@ -170,7 +170,7 @@
170
170
  "node": ">=25"
171
171
  },
172
172
  "dependencies": {
173
- "european-parliament-mcp-server": "1.2.6"
173
+ "european-parliament-mcp-server": "1.2.7"
174
174
  },
175
175
  "optionalDependencies": {
176
176
  "worldbank-mcp": "1.0.1"
@@ -349,9 +349,13 @@ export function buildAdoptedTextsSection(adoptedTexts, language) {
349
349
  if (adoptedTexts.length === 0)
350
350
  return '';
351
351
  const heading = ADOPTED_TEXTS_HEADINGS[language] ?? ADOPTED_TEXTS_HEADINGS['en'] ?? 'Recently Adopted Texts';
352
- const countFn = ADOPTED_TEXTS_COUNT_STRINGS[language] ?? ADOPTED_TEXTS_COUNT_STRINGS['en'];
352
+ const countFn = ADOPTED_TEXTS_COUNT_STRINGS[language] ??
353
+ ADOPTED_TEXTS_COUNT_STRINGS['en'] ??
354
+ ((n) => `${n} adopted texts`);
353
355
  const countText = countFn(adoptedTexts.length);
354
- const unknownDate = ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS[language] ?? ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS['en'];
356
+ const unknownDate = ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS[language] ??
357
+ ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS['en'] ??
358
+ 'Unknown date';
355
359
  // Group by date, sort most recent first
356
360
  const byDate = new Map();
357
361
  for (const item of adoptedTexts) {
@@ -118,8 +118,9 @@ let languagesInput = languagesArg
118
118
  ? (languagesArg.split(ARG_SEPARATOR)[1] ?? '').trim().toLowerCase()
119
119
  : 'en';
120
120
  // Expand presets
121
- if (LANGUAGE_PRESETS[languagesInput]) {
122
- languagesInput = LANGUAGE_PRESETS[languagesInput].join(',');
121
+ const presetLanguages = LANGUAGE_PRESETS[languagesInput];
122
+ if (presetLanguages) {
123
+ languagesInput = presetLanguages.join(',');
123
124
  }
124
125
  const languages = languagesInput
125
126
  .split(',')
@@ -402,7 +403,7 @@ function wireAIMetadata() {
402
403
  */
403
404
  export function computeDedupSuffix(articleTypes, analysisDir) {
404
405
  const baseSlugNoRun = deriveArticleTypeSlug(articleTypes.filter((t) => VALID_ARTICLE_CATEGORIES.includes(t)));
405
- const rawSuffix = analysisDir !== undefined && analysisDir.startsWith(baseSlugNoRun)
406
+ const rawSuffix = analysisDir?.startsWith(baseSlugNoRun)
406
407
  ? analysisDir.slice(baseSlugNoRun.length)
407
408
  : '';
408
409
  // Suffix validation patterns for dedup suffix extraction.
@@ -427,16 +427,16 @@ export async function fetchWeekAheadData(client, dateRange) {
427
427
  const wasHalfOpen = mcpCircuitBreaker.getState() === 'HALF_OPEN';
428
428
  console.log(`${MCP_FETCH_PREFIX} Fetching week-ahead data from MCP (parallel)...`);
429
429
  const [plenarySessions, committeeInfo, documents, pipeline, questions, epEvents] = await Promise.allSettled([
430
- client.getPlenarySessions({ startDate: dateRange.start, endDate: dateRange.end, limit: 50 }),
431
- client.getCommitteeInfo({ limit: 20 }),
432
- client.searchDocuments({ query: 'parliament', limit: 20 }),
430
+ client.getPlenarySessions({ dateFrom: dateRange.start, dateTo: dateRange.end, limit: 50 }),
431
+ client.getCommitteeInfo({ showCurrent: true }),
432
+ client.searchDocuments({ keyword: 'parliament', limit: 20 }),
433
433
  client.monitorLegislativePipeline({
434
434
  dateFrom: dateRange.start,
435
435
  dateTo: dateRange.end,
436
436
  status: 'ACTIVE',
437
437
  limit: 20,
438
438
  }),
439
- client.getParliamentaryQuestions({ startDate: dateRange.start, limit: 20 }),
439
+ client.getParliamentaryQuestions({ dateFrom: dateRange.start, limit: 20 }),
440
440
  client.getEvents({ dateFrom: dateRange.start, dateTo: dateRange.end, limit: 20 }),
441
441
  ]);
442
442
  const allFailed = [
@@ -459,7 +459,7 @@ export async function fetchWeekAheadData(client, dateRange) {
459
459
  const additionalEvents = parseEPEvents(epEvents, dateRange.start);
460
460
  const events = [...plenaryEvents, ...additionalEvents];
461
461
  return {
462
- events: events.length > 0 ? events : [{ ...PLACEHOLDER_EVENTS[0], date: dateRange.start }],
462
+ events: events.length > 0 ? events : PLACEHOLDER_EVENTS.map((e) => ({ ...e, date: dateRange.start })),
463
463
  committees: parseCommitteeMeetings(committeeInfo, dateRange.start),
464
464
  documents: parseLegislativeDocuments(documents),
465
465
  pipeline: parseLegislativePipeline(pipeline),
@@ -728,7 +728,7 @@ export async function fetchCommitteeData(client, abbreviation) {
728
728
  return defaultResult;
729
729
  try {
730
730
  console.log(`${MCP_FETCH_PREFIX} Fetching committee info for ${abbreviation}...`);
731
- const committeeResult = await callMCP(() => client.getCommitteeInfo({ committeeId: abbreviation }), null, `getCommitteeInfo(${abbreviation})`);
731
+ const committeeResult = await callMCP(() => client.getCommitteeInfo({ abbreviation }), null, `getCommitteeInfo(${abbreviation})`);
732
732
  if (committeeResult)
733
733
  applyCommitteeInfo(committeeResult, defaultResult, abbreviation);
734
734
  }
@@ -738,7 +738,7 @@ export async function fetchCommitteeData(client, abbreviation) {
738
738
  }
739
739
  try {
740
740
  console.log(`${MCP_FETCH_PREFIX} Fetching documents for ${abbreviation}...`);
741
- const docsResult = await callMCP(() => client.searchDocuments({ query: abbreviation, limit: 5 }), null, `searchDocuments(${abbreviation})`);
741
+ const docsResult = await callMCP(() => client.searchDocuments({ keyword: abbreviation, limit: 5 }), null, `searchDocuments(${abbreviation})`);
742
742
  if (docsResult)
743
743
  applyDocuments(docsResult, defaultResult);
744
744
  }
@@ -1062,8 +1062,7 @@ const TIMEFRAME_FALLBACK_CHAIN = new Map([
1062
1062
  ['one-day', 'one-week'],
1063
1063
  ['one-week', 'one-month'],
1064
1064
  ['one-month', undefined],
1065
- ['three-months', undefined],
1066
- ['one-year', undefined],
1065
+ ['custom', undefined],
1067
1066
  ]);
1068
1067
  /**
1069
1068
  * Get the next wider timeframe for fallback, or `undefined` if no fallback exists.
@@ -49,7 +49,7 @@ export function collectDocsHtmlFiles(dir, rootDir = PROJECT_ROOT) {
49
49
  */
50
50
  export function generateSitemap(articles, docsFiles = []) {
51
51
  const urls = [];
52
- const today = new Date().toISOString().split('T')[0];
52
+ const today = new Date().toISOString().slice(0, 10);
53
53
  // Add home pages for each language
54
54
  for (const lang of ALL_LANGUAGES) {
55
55
  const filename = lang === 'en' ? 'index.html' : `index-${lang}.html`;
@@ -301,8 +301,8 @@ export function generateSitemapHTML(lang, articleInfos, hasDocsDir = false) {
301
301
  const skipLinkText = getLocalizedString(SKIP_LINK_TEXTS, lang);
302
302
  const dir = getTextDirection(lang);
303
303
  const year = new Date().getFullYear();
304
- const sections = SITEMAP_SECTIONS[lang] ?? SITEMAP_SECTIONS['en'];
305
- const docsLabels = DOCS_LABELS[lang] ?? DOCS_LABELS['en'];
304
+ const sections = (SITEMAP_SECTIONS[lang] ?? SITEMAP_SECTIONS['en']);
305
+ const docsLabels = (DOCS_LABELS[lang] ?? DOCS_LABELS['en']);
306
306
  const heroTitle = getLocalizedString(PAGE_TITLES, lang).split(' - ')[0] ?? '';
307
307
  const headerSubtitle = escapeHTML(getLocalizedString(HEADER_SUBTITLE_LABELS, lang));
308
308
  const themeToggleLabel = escapeHTML(getLocalizedString(THEME_TOGGLE_LABELS, lang));
@@ -539,7 +539,7 @@ function main() {
539
539
  // Generate sitemap HTML for each language
540
540
  let htmlGenerated = 0;
541
541
  for (const lang of ALL_LANGUAGES) {
542
- const langArticles = articlesByLang.get(lang) || [];
542
+ const langArticles = articlesByLang.get(lang) ?? [];
543
543
  // Sort newest first
544
544
  langArticles.sort((a, b) => b.date.localeCompare(a.date));
545
545
  const html = generateSitemapHTML(lang, langArticles, hasDocsDir);
@@ -249,7 +249,7 @@ function buildAdoptedTextsSection(feedData, lang) {
249
249
  const sections = displayOrder
250
250
  .filter((cat) => grouped[cat]?.length)
251
251
  .map((cat) => {
252
- const items = grouped[cat];
252
+ const items = grouped[cat] ?? [];
253
253
  const listItems = items
254
254
  .map((item) => `<li class="adopted-text-item"><strong>${escapeHTML(item.title)}</strong> <span class="document-date">(${escapeHTML(item.date)})</span></li>`)
255
255
  .join('\n ');
@@ -70,32 +70,28 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
70
70
  /**
71
71
  * Get plenary sessions
72
72
  *
73
- * @param options - Filter options. `dateFrom` is mapped to `startDate` and `dateTo` to `endDate`
74
- * per the tool schema when the canonical fields are absent.
73
+ * @param options - Filter options including dateFrom, dateTo, eventId, year, location
75
74
  * @returns Plenary sessions data
76
75
  */
77
76
  getPlenarySessions(options?: GetPlenarySessionsOptions): Promise<MCPToolResult>;
78
77
  /**
79
78
  * Search legislative documents
80
79
  *
81
- * @param options - Search options (normalizes `query` to `keyword` if `keyword` is absent,
82
- * since the MCP tool schema requires the `keyword` parameter)
80
+ * @param options - Search options using v1.2.7 parameters: keyword, documentType, docId, etc.
83
81
  * @returns Search results
84
82
  */
85
83
  searchDocuments(options?: SearchDocumentsOptions): Promise<MCPToolResult>;
86
84
  /**
87
85
  * Get parliamentary questions
88
86
  *
89
- * @param options - Filter options. `dateFrom` is mapped to `startDate` per the tool schema.
90
- * `dateTo` is intentionally ignored because the `get_parliamentary_questions` tool schema
91
- * only supports `startDate` as a date filter; passing `dateTo` would have no effect.
87
+ * @param options - Filter options including docId, type, author, topic, status, dateFrom, dateTo
92
88
  * @returns Parliamentary questions data
93
89
  */
94
90
  getParliamentaryQuestions(options?: GetParliamentaryQuestionsOptions): Promise<MCPToolResult>;
95
91
  /**
96
92
  * Get committee information
97
93
  *
98
- * @param options - Filter options
94
+ * @param options - Filter options: id, abbreviation, showCurrent
99
95
  * @returns Committee info data
100
96
  */
101
97
  getCommitteeInfo(options?: GetCommitteeInfoOptions): Promise<MCPToolResult>;
@@ -123,21 +119,21 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
123
119
  /**
124
120
  * Analyze coalition dynamics and cohesion
125
121
  *
126
- * @param options - Options including optional political groups and date range
122
+ * @param options - Options including optional groupIds and date range
127
123
  * @returns Coalition cohesion and stress analysis
128
124
  */
129
125
  analyzeCoalitionDynamics(options?: AnalyzeCoalitionDynamicsOptions): Promise<MCPToolResult>;
130
126
  /**
131
127
  * Detect voting anomalies and party defections
132
128
  *
133
- * @param options - Options including optional MEP id, political group, and date
129
+ * @param options - Options including optional MEP id, groupId, and date range
134
130
  * @returns Anomaly detection results
135
131
  */
136
132
  detectVotingAnomalies(options?: DetectVotingAnomaliesOptions): Promise<MCPToolResult>;
137
133
  /**
138
134
  * Compare political groups across dimensions
139
135
  *
140
- * @param options - Options including required groups and optional metrics and date
136
+ * @param options - Options including required groupIds and optional dimensions and date range
141
137
  * @returns Cross-group comparative analysis
142
138
  */
143
139
  comparePoliticalGroups(options: ComparePoliticalGroupsOptions): Promise<MCPToolResult>;
@@ -151,7 +147,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
151
147
  /**
152
148
  * Retrieve voting records with optional filters
153
149
  *
154
- * @param options - Filter options (mepId, sessionId, limit)
150
+ * @param options - Filter options (sessionId, mepId, topic, dateFrom, dateTo, limit, offset)
155
151
  * @returns Voting records data
156
152
  */
157
153
  getVotingRecords(options?: VotingRecordsOptions): Promise<MCPToolResult>;
@@ -382,10 +378,10 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
382
378
  /**
383
379
  * Cross-tool OSINT intelligence correlation engine
384
380
  *
385
- * @param options - Options including optional mepId and correlation scenarios
381
+ * @param options - Options including required mepIds, optional groups, sensitivityLevel, includeNetworkAnalysis
386
382
  * @returns Correlated intelligence alerts and insights
387
383
  */
388
- correlateIntelligence(options?: CorrelateIntelligenceOptions): Promise<MCPToolResult>;
384
+ correlateIntelligence(options: CorrelateIntelligenceOptions): Promise<MCPToolResult>;
389
385
  /**
390
386
  * Retrieve precomputed European Parliament activity statistics (EP6–EP10, 2004–2025).
391
387
  * Includes yearly stats, category rankings, political landscape history, and
@@ -29,7 +29,7 @@ const SERVER_HEALTH_FALLBACK = '{"server": null, "feeds": []}';
29
29
  /**
30
30
  * Classify an error message into a diagnostic error category.
31
31
  *
32
- * Maps EP MCP Server v1.2.6 structured error codes and generic HTTP/network
32
+ * Maps EP MCP Server v1.2.7 structured error codes and generic HTTP/network
33
33
  * errors into one of six broad categories used for logging and retry decisions:
34
34
  *
35
35
  * Returned categories (priority order):
@@ -45,7 +45,7 @@ const SERVER_HEALTH_FALLBACK = '{"server": null, "feeds": []}';
45
45
  */
46
46
  function classifyToolError(message) {
47
47
  const lowerMsg = message.toLowerCase();
48
- // EP MCP Server v1.2.6 structured error codes (matched case-insensitively)
48
+ // EP MCP Server v1.2.7 structured error codes (matched case-insensitively)
49
49
  if (lowerMsg.includes('internal_error')) {
50
50
  return 'INTERNAL_ERROR';
51
51
  }
@@ -205,78 +205,38 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
205
205
  /**
206
206
  * Get plenary sessions
207
207
  *
208
- * @param options - Filter options. `dateFrom` is mapped to `startDate` and `dateTo` to `endDate`
209
- * per the tool schema when the canonical fields are absent.
208
+ * @param options - Filter options including dateFrom, dateTo, eventId, year, location
210
209
  * @returns Plenary sessions data
211
210
  */
212
211
  async getPlenarySessions(options = {}) {
213
- return this.safeCallTool('get_plenary_sessions', () => {
214
- const { dateFrom, dateTo, ...rest } = options;
215
- const normalizedOptions = { ...rest };
216
- if (normalizedOptions['startDate'] === undefined && dateFrom !== undefined) {
217
- normalizedOptions['startDate'] = dateFrom;
218
- }
219
- if (normalizedOptions['endDate'] === undefined && dateTo !== undefined) {
220
- normalizedOptions['endDate'] = dateTo;
221
- }
222
- return normalizedOptions;
223
- }, '{"sessions": []}');
212
+ return this.safeCallTool('get_plenary_sessions', options, '{"sessions": []}');
224
213
  }
225
214
  /**
226
215
  * Search legislative documents
227
216
  *
228
- * @param options - Search options (normalizes `query` to `keyword` if `keyword` is absent,
229
- * since the MCP tool schema requires the `keyword` parameter)
217
+ * @param options - Search options using v1.2.7 parameters: keyword, documentType, docId, etc.
230
218
  * @returns Search results
231
219
  */
232
220
  async searchDocuments(options = {}) {
233
- return this.safeCallTool('search_documents', () => {
234
- const { query, ...rest } = options;
235
- const normalizedOptions = { ...rest };
236
- // MCP tool schema expects 'keyword', not 'query'
237
- if (normalizedOptions['keyword'] === undefined && query !== undefined) {
238
- const trimmed = String(query).trim();
239
- if (trimmed.length > 0) {
240
- normalizedOptions['keyword'] = trimmed;
241
- }
242
- }
243
- return normalizedOptions;
244
- }, DOCUMENTS_FALLBACK);
221
+ return this.safeCallTool('search_documents', options, DOCUMENTS_FALLBACK);
245
222
  }
246
223
  /**
247
224
  * Get parliamentary questions
248
225
  *
249
- * @param options - Filter options. `dateFrom` is mapped to `startDate` per the tool schema.
250
- * `dateTo` is intentionally ignored because the `get_parliamentary_questions` tool schema
251
- * only supports `startDate` as a date filter; passing `dateTo` would have no effect.
226
+ * @param options - Filter options including docId, type, author, topic, status, dateFrom, dateTo
252
227
  * @returns Parliamentary questions data
253
228
  */
254
229
  async getParliamentaryQuestions(options = {}) {
255
- return this.safeCallTool('get_parliamentary_questions', () => {
256
- const { dateFrom, dateTo: _dateTo, ...rest } = options;
257
- const toolOptions = { ...rest };
258
- if (toolOptions['startDate'] === undefined && dateFrom !== undefined) {
259
- toolOptions['startDate'] = dateFrom;
260
- }
261
- return toolOptions;
262
- }, '{"questions": []}');
230
+ return this.safeCallTool('get_parliamentary_questions', options, '{"questions": []}');
263
231
  }
264
232
  /**
265
233
  * Get committee information
266
234
  *
267
- * @param options - Filter options
235
+ * @param options - Filter options: id, abbreviation, showCurrent
268
236
  * @returns Committee info data
269
237
  */
270
238
  async getCommitteeInfo(options = {}) {
271
- return this.safeCallTool('get_committee_info', () => {
272
- const { committeeId, ...rest } = options;
273
- const toolOptions = { ...rest };
274
- // MCP tool schema expects 'abbreviation', not 'committeeId'
275
- if (toolOptions['abbreviation'] === undefined && committeeId !== undefined) {
276
- toolOptions['abbreviation'] = committeeId;
277
- }
278
- return toolOptions;
279
- }, '{"committees": []}');
239
+ return this.safeCallTool('get_committee_info', options, '{"committees": []}');
280
240
  }
281
241
  /**
282
242
  * Monitor legislative pipeline
@@ -319,7 +279,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
319
279
  /**
320
280
  * Analyze coalition dynamics and cohesion
321
281
  *
322
- * @param options - Options including optional political groups and date range
282
+ * @param options - Options including optional groupIds and date range
323
283
  * @returns Coalition cohesion and stress analysis
324
284
  */
325
285
  async analyzeCoalitionDynamics(options = {}) {
@@ -328,7 +288,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
328
288
  /**
329
289
  * Detect voting anomalies and party defections
330
290
  *
331
- * @param options - Options including optional MEP id, political group, and date
291
+ * @param options - Options including optional MEP id, groupId, and date range
332
292
  * @returns Anomaly detection results
333
293
  */
334
294
  async detectVotingAnomalies(options = {}) {
@@ -337,19 +297,18 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
337
297
  /**
338
298
  * Compare political groups across dimensions
339
299
  *
340
- * @param options - Options including required groups and optional metrics and date
300
+ * @param options - Options including required groupIds and optional dimensions and date range
341
301
  * @returns Cross-group comparative analysis
342
302
  */
343
303
  async comparePoliticalGroups(options) {
344
- const rawGroups = options && Array.isArray(options.groups) ? options.groups : [];
345
- const groups = rawGroups
304
+ const groupIds = (Array.isArray(options.groupIds) ? options.groupIds : [])
346
305
  .map((g) => (typeof g === 'string' ? g.trim() : ''))
347
306
  .filter((g) => g.length > 0);
348
- if (groups.length === 0) {
349
- console.warn('compare_political_groups called without valid groups (non-empty string array required)');
307
+ if (groupIds.length === 0) {
308
+ console.warn('compare_political_groups called without valid groupIds (non-empty string array required)');
350
309
  return { content: [{ type: 'text', text: '{"comparison": {}}' }] };
351
310
  }
352
- return this.safeCallTool('compare_political_groups', { ...options, groups }, '{"comparison": {}}');
311
+ return this.safeCallTool('compare_political_groups', { ...options, groupIds }, '{"comparison": {}}');
353
312
  }
354
313
  /**
355
314
  * Get detailed information about a specific MEP
@@ -367,7 +326,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
367
326
  /**
368
327
  * Retrieve voting records with optional filters
369
328
  *
370
- * @param options - Filter options (mepId, sessionId, limit)
329
+ * @param options - Filter options (sessionId, mepId, topic, dateFrom, dateTo, limit, offset)
371
330
  * @returns Voting records data
372
331
  */
373
332
  async getVotingRecords(options = {}) {
@@ -708,10 +667,14 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
708
667
  /**
709
668
  * Cross-tool OSINT intelligence correlation engine
710
669
  *
711
- * @param options - Options including optional mepId and correlation scenarios
670
+ * @param options - Options including required mepIds, optional groups, sensitivityLevel, includeNetworkAnalysis
712
671
  * @returns Correlated intelligence alerts and insights
713
672
  */
714
- async correlateIntelligence(options = {}) {
673
+ async correlateIntelligence(options) {
674
+ if (!Array.isArray(options.mepIds) || options.mepIds.length === 0) {
675
+ console.warn('correlate_intelligence called without valid mepIds (non-empty string array required)');
676
+ return { content: [{ type: 'text', text: INTELLIGENCE_FALLBACK }] };
677
+ }
715
678
  return this.safeCallTool('correlate_intelligence', options, INTELLIGENCE_FALLBACK);
716
679
  }
717
680
  /**
@@ -419,7 +419,7 @@ export class MCPConnection {
419
419
  return trimmedKey;
420
420
  }
421
421
  }
422
- const rawScheme = typeof process !== 'undefined' && process.env && process.env['EP_MCP_GATEWAY_AUTH_SCHEME'];
422
+ const rawScheme = typeof process !== 'undefined' && process.env?.['EP_MCP_GATEWAY_AUTH_SCHEME'];
423
423
  const scheme = typeof rawScheme === 'string' ? rawScheme.trim() : '';
424
424
  if (scheme && tokenRegex.test(scheme)) {
425
425
  return `${scheme} ${trimmedKey}`;
@@ -430,6 +430,9 @@ export class MCPConnection {
430
430
  * Attempt a single connection via MCP Gateway (HTTP transport)
431
431
  */
432
432
  async _attemptGatewayConnection() {
433
+ if (!this.gatewayUrl) {
434
+ throw new Error('Gateway URL not configured. Set the EP_MCP_GATEWAY_URL environment variable or provide the gatewayUrl constructor option.');
435
+ }
433
436
  try {
434
437
  const headers = {
435
438
  'Content-Type': 'application/json',
@@ -562,17 +565,24 @@ export class MCPConnection {
562
565
  handleMessage(line) {
563
566
  try {
564
567
  const message = JSON.parse(line);
565
- if (message.id && this.pendingRequests.has(message.id)) {
568
+ if (message.id !== null && message.id !== undefined && this.pendingRequests.has(message.id)) {
566
569
  const pending = this.pendingRequests.get(message.id);
567
- this.pendingRequests.delete(message.id);
568
- if (message.error) {
569
- pending.reject(new Error(message.error.message ?? 'MCP server error'));
570
+ if (pending) {
571
+ this.pendingRequests.delete(message.id);
572
+ if (message.error) {
573
+ pending.reject(new Error(message.error.message ?? 'MCP server error'));
574
+ }
575
+ else {
576
+ pending.resolve(message.result);
577
+ }
570
578
  }
571
579
  else {
572
- pending.resolve(message.result);
580
+ // has() returned true but get() returned undefined — unexpected
581
+ this.pendingRequests.delete(message.id);
582
+ console.error(`MCP pending request ${String(message.id)} vanished before handling`);
573
583
  }
574
584
  }
575
- else if (!message.id && message.method) {
585
+ else if ((message.id === null || message.id === undefined) && message.method) {
576
586
  console.log(`MCP Notification: ${message.method}`);
577
587
  }
578
588
  }
@@ -619,6 +629,9 @@ export class MCPConnection {
619
629
  * @returns Server response
620
630
  */
621
631
  async _sendGatewayRequest(method, params = {}) {
632
+ if (!this.gatewayUrl) {
633
+ throw new Error('Gateway URL not configured. Set EP_MCP_GATEWAY_URL or provide gatewayUrl in MCP client options.');
634
+ }
622
635
  const id = ++this.requestId;
623
636
  const request = {
624
637
  jsonrpc: '2.0',
@@ -63,43 +63,58 @@ export interface GetMEPsOptions {
63
63
  }
64
64
  /** Options for getPlenarySessions */
65
65
  export interface GetPlenarySessionsOptions {
66
- /** Tool schema field name */
67
- startDate?: string | undefined;
68
- /** Tool schema field name */
69
- endDate?: string | undefined;
70
- /** Alternative field name used by generators */
66
+ /** Meeting event ID for single meeting lookup */
67
+ eventId?: string | undefined;
68
+ /** Filter by calendar year (recommended for annual counts) */
69
+ year?: number | undefined;
70
+ /** Start date in YYYY-MM-DD format */
71
71
  dateFrom?: string | undefined;
72
- /** Alternative field name used by generators */
72
+ /** End date in YYYY-MM-DD format */
73
73
  dateTo?: string | undefined;
74
+ /** Session location (e.g., "Strasbourg", "Brussels") */
74
75
  location?: string | undefined;
75
76
  limit?: number | undefined;
77
+ offset?: number | undefined;
76
78
  }
77
79
  /** Options for searchDocuments */
78
80
  export interface SearchDocumentsOptions {
79
- query?: string | undefined;
81
+ /** Document ID for single document lookup (bypasses keyword search) */
82
+ docId?: string | undefined;
83
+ /** Search keyword or phrase */
80
84
  keyword?: string | undefined;
81
- type?: string | undefined;
85
+ /** Document type: REPORT, RESOLUTION, DECISION, DIRECTIVE, REGULATION, OPINION, AMENDMENT */
86
+ documentType?: string | undefined;
82
87
  committee?: string | undefined;
83
88
  dateFrom?: string | undefined;
84
89
  dateTo?: string | undefined;
85
90
  limit?: number | undefined;
91
+ offset?: number | undefined;
86
92
  }
87
93
  /** Options for getParliamentaryQuestions */
88
94
  export interface GetParliamentaryQuestionsOptions {
95
+ /** Document ID for single question lookup */
96
+ docId?: string | undefined;
97
+ /** Question type: WRITTEN or ORAL */
89
98
  type?: string | undefined;
90
- startDate?: string | undefined;
99
+ /** MEP identifier or name of question author */
100
+ author?: string | undefined;
101
+ /** Question topic or keyword to search */
102
+ topic?: string | undefined;
103
+ /** Question status: PENDING or ANSWERED */
104
+ status?: string | undefined;
91
105
  dateFrom?: string | undefined;
92
106
  dateTo?: string | undefined;
93
107
  limit?: number | undefined;
108
+ offset?: number | undefined;
94
109
  }
95
110
  /** Options for getCommitteeInfo */
96
111
  export interface GetCommitteeInfoOptions {
97
- committeeId?: string | undefined;
98
- /** Alternative field name used by callers — maps to `abbreviation` in the MCP tool schema */
112
+ /** Committee identifier */
113
+ id?: string | undefined;
114
+ /** Committee abbreviation (e.g., "ENVI", "AGRI") */
99
115
  abbreviation?: string | undefined;
100
- dateFrom?: string | undefined;
101
- dateTo?: string | undefined;
102
- limit?: number | undefined;
116
+ /** If true, returns all current active corporate bodies */
117
+ showCurrent?: boolean | undefined;
103
118
  }
104
119
  /** Options for monitorLegislativePipeline */
105
120
  export interface MonitorLegislativePipelineOptions {
@@ -117,21 +132,31 @@ export interface AssessMEPInfluenceOptions {
117
132
  }
118
133
  /** Options for analyzeCoalitionDynamics */
119
134
  export interface AnalyzeCoalitionDynamicsOptions {
120
- politicalGroups?: string[] | undefined;
135
+ /** Political group identifiers to analyze (omit for all groups) */
136
+ groupIds?: string[] | undefined;
121
137
  dateFrom?: string | undefined;
122
138
  dateTo?: string | undefined;
139
+ /** Minimum cohesion threshold for alliance detection (0-1) */
140
+ minimumCohesion?: number | undefined;
123
141
  }
124
142
  /** Options for detectVotingAnomalies */
125
143
  export interface DetectVotingAnomaliesOptions {
126
144
  mepId?: string | undefined;
127
- politicalGroup?: string | undefined;
145
+ /** Political group to analyze */
146
+ groupId?: string | undefined;
128
147
  dateFrom?: string | undefined;
148
+ dateTo?: string | undefined;
149
+ /** Anomaly sensitivity (0-1, lower = more anomalies detected) */
150
+ sensitivityThreshold?: number | undefined;
129
151
  }
130
152
  /** Options for comparePoliticalGroups */
131
153
  export interface ComparePoliticalGroupsOptions {
132
- groups: string[];
133
- metrics?: string[] | undefined;
154
+ /** Political group identifiers to compare (minimum 2, maximum 10) */
155
+ groupIds: string[];
156
+ /** Comparison dimensions: voting_discipline, activity_level, legislative_output, attendance, cohesion */
157
+ dimensions?: string[] | undefined;
134
158
  dateFrom?: string | undefined;
159
+ dateTo?: string | undefined;
135
160
  }
136
161
  /** Options for analyzeLegislativeEffectiveness */
137
162
  export interface AnalyzeLegislativeEffectivenessOptions {
@@ -144,7 +169,11 @@ export interface AnalyzeLegislativeEffectivenessOptions {
144
169
  export interface VotingRecordsOptions {
145
170
  mepId?: string | undefined;
146
171
  sessionId?: string | undefined;
172
+ topic?: string | undefined;
173
+ dateFrom?: string | undefined;
174
+ dateTo?: string | undefined;
147
175
  limit?: number | undefined;
176
+ offset?: number | undefined;
148
177
  }
149
178
  /** Options for analyzing voting patterns */
150
179
  export interface VotingPatternsOptions {
@@ -192,6 +221,8 @@ export interface GetCurrentMEPsOptions {
192
221
  /** Options for getSpeeches */
193
222
  export interface GetSpeechesOptions {
194
223
  speechId?: string | undefined;
224
+ /** Filter by calendar year (recommended for annual counts) */
225
+ year?: number | undefined;
195
226
  dateFrom?: string | undefined;
196
227
  dateTo?: string | undefined;
197
228
  limit?: number | undefined;
@@ -214,6 +245,8 @@ export interface GetAdoptedTextsOptions {
214
245
  /** Options for getEvents */
215
246
  export interface GetEventsOptions {
216
247
  eventId?: string | undefined;
248
+ /** Filter by calendar year (recommended for annual counts) */
249
+ year?: number | undefined;
217
250
  dateFrom?: string | undefined;
218
251
  dateTo?: string | undefined;
219
252
  limit?: number | undefined;
@@ -338,8 +371,14 @@ export interface ComparativeIntelligenceOptions {
338
371
  }
339
372
  /** Options for correlateIntelligence */
340
373
  export interface CorrelateIntelligenceOptions {
341
- mepId?: number | undefined;
342
- correlationScenarios?: ('influence_anomaly' | 'coalition_stress' | 'network_activity')[] | undefined;
374
+ /** MEP identifiers to cross-correlate (1-5 MEPs, required) */
375
+ mepIds: string[];
376
+ /** Political groups for coalition fracture analysis (max 8, omit for all) */
377
+ groups?: string[] | undefined;
378
+ /** Alert sensitivity: HIGH, MEDIUM, or LOW */
379
+ sensitivityLevel?: 'HIGH' | 'MEDIUM' | 'LOW' | undefined;
380
+ /** Run network centrality analysis (increases response time) */
381
+ includeNetworkAnalysis?: boolean | undefined;
343
382
  }
344
383
  /** Allowed category values for getAllGeneratedStats */
345
384
  export type GeneratedStatsCategory = 'all' | 'plenary_sessions' | 'legislative_acts' | 'roll_call_votes' | 'committee_meetings' | 'parliamentary_questions' | 'resolutions' | 'speeches' | 'adopted_texts' | 'political_groups' | 'procedures' | 'events' | 'documents' | 'mep_turnover' | 'declarations';
@@ -356,7 +395,7 @@ export interface GetAllGeneratedStatsOptions {
356
395
  * Allowed timeframe values for EP API v2 feed endpoints.
357
396
  * Controls how far back the feed looks for recently updated items.
358
397
  */
359
- export type FeedTimeframe = 'today' | 'one-day' | 'one-week' | 'one-month' | 'three-months' | 'one-year';
398
+ export type FeedTimeframe = 'today' | 'one-day' | 'one-week' | 'one-month' | 'custom';
360
399
  /** Common options shared by all EP API v2 feed endpoints */
361
400
  export interface FeedBaseOptions {
362
401
  /** How far back to look for recently-updated items (default: `'one-day'`) */
@@ -106,6 +106,89 @@ const ENGLISH_PLACEHOLDER_PHRASES = [
106
106
  'political group dynamics',
107
107
  'committee coordinators',
108
108
  ];
109
+ // ─── Article Quality Gate Constants ───────────────────────────────────────────
110
+ /**
111
+ * Section headings that MUST NOT appear as article keywords.
112
+ * These leak into meta tags when AI agents copy their section headers
113
+ * into the keywords field instead of using policy terms.
114
+ *
115
+ * @see SHARED_PROMPT_PATTERNS.md § Keywords Quality Rules
116
+ */
117
+ const BANNED_KEYWORD_PATTERNS = [
118
+ 'Deep Political Analysis',
119
+ 'What Happened',
120
+ 'Key Actors',
121
+ 'Timeline',
122
+ 'Why It Matters',
123
+ 'Why This Matters',
124
+ 'Legislative Pipeline Overview',
125
+ 'Impact Assessment',
126
+ 'Actions → Consequences',
127
+ 'Miscalculations & Missed Opportunities',
128
+ 'Winners & Losers',
129
+ 'Root Causes',
130
+ 'Stakeholder Perspectives',
131
+ 'Multi-Stakeholder Perspectives',
132
+ 'Stakeholder Outcome Matrix',
133
+ 'Intelligence Policy Map',
134
+ 'Strategic Outlook',
135
+ 'SWOT Analysis',
136
+ 'Dashboard',
137
+ 'Pipeline Health',
138
+ 'Analysis Pipeline Insights',
139
+ 'Plenary Sessions',
140
+ 'Executive Summary',
141
+ 'Table of Contents',
142
+ 'Political Context',
143
+ ];
144
+ /**
145
+ * Minimum number of non-whitespace characters for a `<section>` to be
146
+ * considered non-empty. Below this threshold the section is treated as empty.
147
+ */
148
+ const MIN_SECTION_CONTENT_LENGTH = 10;
149
+ /**
150
+ * How many characters to look back from a tag position when checking
151
+ * whether the tag is inside a pipeline-health/pipeline-metrics container.
152
+ */
153
+ const PIPELINE_CONTEXT_LOOKBEHIND_CHARS = 2000;
154
+ /**
155
+ * Pre-computed normalized banned-keyword map for exact-match comparison.
156
+ * Built once at module init from BANNED_KEYWORD_PATTERNS + normalizeKeywordToken.
157
+ *
158
+ * Keys are normalized tokens; values are original patterns.
159
+ */
160
+ let _bannedNormalizedCache;
161
+ /**
162
+ * Return (and lazily compute once) the normalized banned-keyword map.
163
+ * Lazy initialization avoids a forward-reference to `normalizeKeywordToken`
164
+ * which is defined later in this module.
165
+ *
166
+ * @returns Map from normalized token to original banned pattern
167
+ */
168
+ function getBannedNormalized() {
169
+ if (!_bannedNormalizedCache) {
170
+ _bannedNormalizedCache = new Map();
171
+ for (const pattern of BANNED_KEYWORD_PATTERNS) {
172
+ _bannedNormalizedCache.set(normalizeKeywordToken(pattern), pattern);
173
+ }
174
+ }
175
+ return _bannedNormalizedCache;
176
+ }
177
+ /**
178
+ * HTML entity → decoded character pairs used by the single-pass decoder.
179
+ * Longest entities are listed first so that `&amp;` doesn't greedily match
180
+ * inside `&amp;lt;` before the full entity `&amp;lt;` is checked.
181
+ */
182
+ const ENTITY_PAIRS = [
183
+ ['&mdash;', '—'],
184
+ ['&ndash;', '–'],
185
+ ['&rarr;', '→'],
186
+ ['&quot;', '"'],
187
+ ['&amp;', '&'],
188
+ ['&#39;', "'"],
189
+ ['&lt;', '<'],
190
+ ['&gt;', '>'],
191
+ ];
109
192
  // ─── Helpers ──────────────────────────────────────────────────────────────────
110
193
  // stripScriptBlocks is imported from html-sanitize.ts
111
194
  /**
@@ -264,7 +347,357 @@ function checkMetaTagSync(html) {
264
347
  return false;
265
348
  return true;
266
349
  }
350
+ /**
351
+ * Decode common HTML entities that appear in meta keyword values.
352
+ * Only covers the entities actually used by the article template engine.
353
+ *
354
+ * Uses a single-pass scan to avoid double-unescaping (e.g. `&amp;lt;`
355
+ * becomes `&lt;`, NOT `<`). Each `&` in the input is checked once;
356
+ * decoded replacements are never re-scanned.
357
+ *
358
+ * @param s - String potentially containing HTML entities
359
+ * @returns The string with common entities decoded
360
+ */
361
+ function decodeKeywordEntities(s) {
362
+ const parts = [];
363
+ let i = 0;
364
+ while (i < s.length) {
365
+ const ch = s[i] ?? '';
366
+ if (ch === '&') {
367
+ const rest = s.slice(i).toLowerCase();
368
+ let matched = false;
369
+ for (const [entity, replacement] of ENTITY_PAIRS) {
370
+ if (rest.startsWith(entity)) {
371
+ parts.push(replacement);
372
+ i += entity.length;
373
+ matched = true;
374
+ break;
375
+ }
376
+ }
377
+ if (!matched) {
378
+ parts.push(ch);
379
+ i++;
380
+ }
381
+ }
382
+ else {
383
+ parts.push(ch);
384
+ i++;
385
+ }
386
+ }
387
+ return parts.join('');
388
+ }
389
+ /**
390
+ * Normalize a keyword token for comparison: decode HTML entities,
391
+ * collapse arrow/dash variants, and normalize whitespace.
392
+ *
393
+ * @param s - Raw keyword token to normalize
394
+ * @returns Lowercased, entity-decoded, dash-normalized token
395
+ */
396
+ function normalizeKeywordToken(s) {
397
+ let decoded = decodeKeywordEntities(s);
398
+ // Normalize arrow/dash variants → single canonical form
399
+ decoded = decoded.replace(/→/gu, '->');
400
+ decoded = decoded.replace(/—/gu, '-');
401
+ decoded = decoded.replace(/–/gu, '-');
402
+ // Collapse whitespace and lowercase
403
+ return decoded.replace(/\s+/gu, ' ').trim().toLowerCase();
404
+ }
405
+ /**
406
+ * Detect section-heading keywords that leaked into the article's meta keywords.
407
+ * Returns the list of banned keywords found.
408
+ *
409
+ * Decodes HTML entities (e.g. `&amp;` → `&`) and normalizes dash/arrow
410
+ * variants so that exact comma-separated tokens can be matched after
411
+ * normalization, for example "Winners &amp; Losers" matching
412
+ * "Winners & Losers". Combined phrases are not split on dash or arrow
413
+ * separators and therefore only match if the full normalized token is banned.
414
+ *
415
+ * @param html - HTML string to inspect
416
+ * @returns Array of section-heading keywords found in the meta tag
417
+ */
418
+ function detectBannedKeywords(html) {
419
+ const keywordsMeta = extractMetaContent(html, 'name', 'keywords');
420
+ if (!keywordsMeta)
421
+ return [];
422
+ // Parse comma-separated keywords and normalize each token
423
+ const tokens = keywordsMeta
424
+ .split(',')
425
+ .map((k) => normalizeKeywordToken(k))
426
+ .filter((k) => k.length > 0);
427
+ const bannedNormalized = getBannedNormalized();
428
+ const found = [];
429
+ for (const token of tokens) {
430
+ const original = bannedNormalized.get(token);
431
+ if (original) {
432
+ found.push(original);
433
+ }
434
+ }
435
+ return found;
436
+ }
437
+ /**
438
+ * Test whether a character is a boundary before/after the word "class"
439
+ * in an HTML attribute context.
440
+ *
441
+ * @param ch - The character to test (or undefined if at string edge)
442
+ * @param side - Whether to check as a 'before' or 'after' boundary
443
+ * @returns true if the character is a valid boundary
444
+ */
445
+ function isAttrBoundary(ch, side) {
446
+ if (!ch || ch === '')
447
+ return true;
448
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r')
449
+ return true;
450
+ if (side === 'before')
451
+ return ch === '"' || ch === "'";
452
+ return ch === '=';
453
+ }
454
+ /**
455
+ * Extract the quoted value of the `class` attribute starting at a given cursor
456
+ * position (immediately after the word "class"). Returns `null` if the syntax
457
+ * is not `class = "..."` or `class = '...'`.
458
+ *
459
+ * @param tag - The full start-tag string
460
+ * @param cursor - Index right after the word "class" in `tag`
461
+ * @returns `{ value, end }` or `null`
462
+ */
463
+ function extractClassValue(tag, cursor) {
464
+ let pos = cursor;
465
+ // Skip whitespace before '=' (space, tab, newline, carriage return)
466
+ while (pos < tag.length &&
467
+ (tag[pos] === ' ' || tag[pos] === '\t' || tag[pos] === '\n' || tag[pos] === '\r'))
468
+ pos++;
469
+ if (pos >= tag.length || tag[pos] !== '=')
470
+ return null;
471
+ pos++; // skip '='
472
+ // Skip whitespace before opening quote
473
+ while (pos < tag.length &&
474
+ (tag[pos] === ' ' || tag[pos] === '\t' || tag[pos] === '\n' || tag[pos] === '\r'))
475
+ pos++;
476
+ if (pos >= tag.length)
477
+ return null;
478
+ const quote = tag[pos];
479
+ if (quote !== '"' && quote !== "'")
480
+ return null;
481
+ const valueStart = pos + 1;
482
+ const valueEnd = tag.indexOf(quote, valueStart);
483
+ if (valueEnd === -1)
484
+ return null;
485
+ return { value: tag.slice(valueStart, valueEnd), end: valueEnd + 1 };
486
+ }
487
+ /**
488
+ * Check whether an HTML start tag has a specific class token (whitespace-tokenized).
489
+ * Handles both single-quoted and double-quoted class attributes.
490
+ *
491
+ * @param startTag - An opening HTML tag string (e.g. `<span class="metric-value foo">`)
492
+ * @param token - The class token to look for (e.g. `metric-value`)
493
+ * @returns true if the class attribute contains the exact token
494
+ */
495
+ function hasClassToken(startTag, token) {
496
+ const lowerTag = startTag.toLowerCase();
497
+ let searchFrom = 0;
498
+ while (searchFrom < lowerTag.length) {
499
+ const classPos = lowerTag.indexOf('class', searchFrom);
500
+ if (classPos === -1)
501
+ return false;
502
+ const before = classPos > 0 ? lowerTag[classPos - 1] : undefined;
503
+ const after = classPos + 5 < lowerTag.length ? lowerTag[classPos + 5] : undefined;
504
+ if (!isAttrBoundary(before, 'before') || !isAttrBoundary(after, 'after')) {
505
+ searchFrom = classPos + 5;
506
+ continue;
507
+ }
508
+ const extracted = extractClassValue(startTag, classPos + 5);
509
+ if (!extracted) {
510
+ searchFrom = classPos + 5;
511
+ continue;
512
+ }
513
+ const tokens = extracted.value.split(/\s+/u).filter((t) => t.length > 0);
514
+ if (tokens.includes(token))
515
+ return true;
516
+ searchFrom = extracted.end;
517
+ }
518
+ return false;
519
+ }
520
+ /**
521
+ * Detect metric values showing "0%" in pipeline-health / pipeline-metrics
522
+ * containers, which indicate no-data conditions that should not be rendered
523
+ * as real dashboard metrics.
524
+ *
525
+ * Only flags `0%` inside elements whose surrounding context includes a
526
+ * `pipeline-metrics` or `pipeline-health` class, avoiding false positives
527
+ * on legitimate trend-panel change indicators (e.g. week-over-week 0%).
528
+ *
529
+ * @param html - HTML string to inspect
530
+ * @returns Number of 0% pipeline metric values found
531
+ */
532
+ function detectZeroPercentMetrics(html) {
533
+ // Use indexOf-based search to avoid regex backtracking (ReDoS-safe)
534
+ let count = 0;
535
+ let searchFrom = 0;
536
+ const zeroValue = '0%';
537
+ const lowerHtml = html.toLowerCase();
538
+ while (searchFrom < html.length) {
539
+ const tagStart = html.indexOf('<', searchFrom);
540
+ if (tagStart === -1)
541
+ break;
542
+ const tagClose = html.indexOf('>', tagStart);
543
+ if (tagClose === -1)
544
+ break;
545
+ const startTag = html.slice(tagStart, tagClose + 1);
546
+ // Only check elements that have the 'metric-value' class token
547
+ if (hasClassToken(startTag, 'metric-value')) {
548
+ const contentStart = tagClose + 1;
549
+ const nextTag = html.indexOf('<', contentStart);
550
+ if (nextTag === -1)
551
+ break;
552
+ const textContent = html.slice(contentStart, nextTag).trim();
553
+ if (textContent === zeroValue && isInPipelineContext(lowerHtml, tagStart)) {
554
+ count++;
555
+ }
556
+ searchFrom = nextTag;
557
+ continue;
558
+ }
559
+ searchFrom = tagClose + 1;
560
+ }
561
+ return count;
562
+ }
563
+ /**
564
+ * Check whether a position in the HTML is inside a pipeline-health/metrics context.
565
+ * Looks backward up to 2000 chars for pipeline marker class names.
566
+ *
567
+ * If a `trend-panel` marker appears *after* the nearest pipeline marker,
568
+ * the element is inside a trend panel (not pipeline), so return false.
569
+ * This avoids flagging legitimate WoW/MoM 0% deltas rendered by
570
+ * `buildTrendPanel` that happen to fall within the look-behind window.
571
+ *
572
+ * @param lowerHtml - Lowercase HTML string
573
+ * @param position - Current scan position
574
+ * @returns true if inside a pipeline context
575
+ */
576
+ function isInPipelineContext(lowerHtml, position) {
577
+ const precedingHtml = lowerHtml.slice(Math.max(0, position - PIPELINE_CONTEXT_LOOKBEHIND_CHARS), position);
578
+ const pipelineMetricsPos = precedingHtml.lastIndexOf('pipeline-metrics');
579
+ const pipelineHealthPos = precedingHtml.lastIndexOf('pipeline-health');
580
+ const lastPipelinePos = Math.max(pipelineMetricsPos, pipelineHealthPos);
581
+ if (lastPipelinePos === -1)
582
+ return false;
583
+ // If a trend-panel marker appears after the pipeline marker, the element
584
+ // is inside a trend panel, not the pipeline panel.
585
+ const trendPanelPos = precedingHtml.lastIndexOf('trend-panel');
586
+ if (trendPanelPos !== -1 && trendPanelPos > lastPipelinePos)
587
+ return false;
588
+ return true;
589
+ }
590
+ /**
591
+ * Strip HTML tags from a string using a simple character scanner.
592
+ * ReDoS-safe alternative to regex-based tag removal.
593
+ *
594
+ * @param input - HTML string to strip tags from
595
+ * @returns Plain text content with tags removed
596
+ */
597
+ function stripHtmlTags(input) {
598
+ const parts = [];
599
+ let inTag = false;
600
+ for (let i = 0; i < input.length; i++) {
601
+ const ch = input[i] ?? '';
602
+ if (ch === '<') {
603
+ inTag = true;
604
+ }
605
+ else if (ch === '>') {
606
+ inTag = false;
607
+ }
608
+ else if (!inTag) {
609
+ parts.push(ch);
610
+ }
611
+ }
612
+ return parts.join('');
613
+ }
614
+ /**
615
+ * Evaluate whether a section's inner HTML has enough meaningful content.
616
+ *
617
+ * @param innerHtml - The HTML content between `<section>` and `</section>` tags
618
+ * @returns true if the section is empty or near-empty
619
+ */
620
+ function isSectionEmpty(innerHtml) {
621
+ const plainText = stripHtmlTags(innerHtml).replace(/\s+/gu, ' ').trim();
622
+ return plainText.length < MIN_SECTION_CONTENT_LENGTH;
623
+ }
624
+ /**
625
+ * Find the next `<section` open or `</section>` close tag from a given cursor.
626
+ * Returns `{ type, pos }` or `null` if no more section tags found.
627
+ *
628
+ * @param lowerHtml - Lowercase HTML string
629
+ * @param cursor - Start position
630
+ * @returns Tag event or null
631
+ */
632
+ function findNextSectionTag(lowerHtml, cursor) {
633
+ const nextOpen = lowerHtml.indexOf('<section', cursor);
634
+ const nextClose = lowerHtml.indexOf('</section>', cursor);
635
+ if (nextOpen === -1 && nextClose === -1)
636
+ return null;
637
+ const openFirst = nextOpen !== -1 && (nextClose === -1 || nextOpen < nextClose);
638
+ return openFirst ? { type: 'open', pos: nextOpen } : { type: 'close', pos: nextClose };
639
+ }
640
+ /**
641
+ * Count empty `<section>` elements — those with little or no visible content.
642
+ * An empty section contains only whitespace or very short boilerplate text.
643
+ * Uses a stack-based scanner to correctly handle nested `<section>` elements.
644
+ *
645
+ * @param html - HTML string to inspect
646
+ * @returns Number of empty sections found
647
+ */
648
+ function countEmptySections(html) {
649
+ const lowerHtml = html.toLowerCase();
650
+ let count = 0;
651
+ const stack = [];
652
+ let cursor = 0;
653
+ let event = findNextSectionTag(lowerHtml, cursor);
654
+ while (event) {
655
+ if (event.type === 'open') {
656
+ const tagEnd = html.indexOf('>', event.pos);
657
+ if (tagEnd === -1)
658
+ break;
659
+ stack.push(tagEnd + 1);
660
+ cursor = tagEnd + 1;
661
+ }
662
+ else {
663
+ if (stack.length > 0) {
664
+ const contentStart = stack[stack.length - 1] ?? 0;
665
+ stack.pop();
666
+ if (isSectionEmpty(html.slice(contentStart, event.pos))) {
667
+ count++;
668
+ }
669
+ }
670
+ cursor = event.pos + '</section>'.length;
671
+ }
672
+ event = findNextSectionTag(lowerHtml, cursor);
673
+ }
674
+ return count;
675
+ }
267
676
  // ─── Public API ───────────────────────────────────────────────────────────────
677
+ /**
678
+ * Collect warnings from machine-enforceable article quality gates.
679
+ * Extracted to keep `validateArticleContent` within cognitive-complexity limits.
680
+ *
681
+ * @param html - Complete HTML string
682
+ * @param warnings - Mutable warnings array to append to
683
+ */
684
+ function collectQualityGateWarnings(html, warnings) {
685
+ // Keyword quality: detect section-heading keywords leaked into meta tags
686
+ const bannedKeywords = detectBannedKeywords(html);
687
+ if (bannedKeywords.length > 0) {
688
+ warnings.push(`Keywords contain ${bannedKeywords.length} section heading(s) that should not be used as keywords: ${bannedKeywords.join(', ')}`);
689
+ }
690
+ // Dashboard metric quality: detect 0% metrics rendered as real data
691
+ const zeroMetricCount = detectZeroPercentMetrics(html);
692
+ if (zeroMetricCount > 0) {
693
+ warnings.push(`Dashboard renders ${zeroMetricCount} metric(s) showing "0%" — this likely indicates no-data, not a real score. Omit the dashboard when data is unavailable.`);
694
+ }
695
+ // Empty section detection: flag sections with no meaningful content
696
+ const emptySectionCount = countEmptySections(html);
697
+ if (emptySectionCount > 0) {
698
+ warnings.push(`Article contains ${emptySectionCount} empty or near-empty <section> element(s) that should be removed`);
699
+ }
700
+ }
268
701
  /**
269
702
  * Validate the quality of a generated article.
270
703
  *
@@ -287,7 +720,7 @@ export function validateArticleContent(html, language, articleType) {
287
720
  const errors = [];
288
721
  // Word count check
289
722
  const wordCount = countWordsInHtml(html);
290
- const minWords = MIN_WORD_COUNTS[articleType] !== undefined ? MIN_WORD_COUNTS[articleType] : DEFAULT_MIN_WORDS;
723
+ const minWords = MIN_WORD_COUNTS[articleType] ?? DEFAULT_MIN_WORDS;
291
724
  if (wordCount < minWords) {
292
725
  warnings.push(`Content too short: ${wordCount} words (minimum ${minWords} for "${articleType}")`);
293
726
  }
@@ -336,6 +769,8 @@ export function validateArticleContent(html, language, articleType) {
336
769
  }
337
770
  // Extended validation: cross-reference density, stakeholder balance, temporal coverage
338
771
  collectExtendedValidationWarnings(html, warnings);
772
+ // Machine-enforceable article quality gates
773
+ collectQualityGateWarnings(html, warnings);
339
774
  return {
340
775
  valid: errors.length === 0,
341
776
  warnings,
@@ -59,13 +59,19 @@ export function groupArticlesByLanguage(articles, languages) {
59
59
  }
60
60
  for (const article of articles) {
61
61
  const parsed = parseArticleFilename(article);
62
- if (parsed && grouped[parsed.lang] !== undefined) {
63
- grouped[parsed.lang].push(parsed);
62
+ if (parsed) {
63
+ const bucket = grouped[parsed.lang];
64
+ if (bucket) {
65
+ bucket.push(parsed);
66
+ }
64
67
  }
65
68
  }
66
69
  // Sort by date (newest first)
67
70
  for (const lang in grouped) {
68
- grouped[lang].sort((a, b) => b.date.localeCompare(a.date));
71
+ const bucket = grouped[lang];
72
+ if (bucket) {
73
+ bucket.sort((a, b) => b.date.localeCompare(a.date));
74
+ }
69
75
  }
70
76
  return grouped;
71
77
  }
@@ -89,8 +95,7 @@ export function formatSlug(slug) {
89
95
  */
90
96
  export function getModifiedDate(filepath) {
91
97
  const stats = fs.statSync(filepath);
92
- // split('T') on an ISO string always produces at least one element
93
- return stats.mtime.toISOString().split('T')[0];
98
+ return stats.mtime.toISOString().slice(0, 10);
94
99
  }
95
100
  /**
96
101
  * Format date for article slug
@@ -99,8 +104,7 @@ export function getModifiedDate(filepath) {
99
104
  * @returns Formatted date string (YYYY-MM-DD)
100
105
  */
101
106
  export function formatDateForSlug(date = new Date()) {
102
- // split('T') on an ISO string always produces at least one element
103
- return date.toISOString().split('T')[0];
107
+ return date.toISOString().slice(0, 10);
104
108
  }
105
109
  /**
106
110
  * Calculate read time estimate from content
@@ -529,6 +529,9 @@ function synthesiseOverallRisk(risks, assessmentId, date) {
529
529
  }
530
530
  // Safe: risks.length > 0 is guaranteed by the guard above
531
531
  const firstRisk = risks[0];
532
+ if (!firstRisk) {
533
+ throw new Error(`Invariant violation: risks[0] was undefined for non-empty risks array in assessment ${assessmentId} on ${date}`);
534
+ }
532
535
  const maxRisk = risks.reduce((max, r) => (r.riskScore > max.riskScore ? r : max), firstRisk);
533
536
  // Count confidence levels to pick the dominant one
534
537
  const confidenceCounts = { high: 0, medium: 0, low: 0 };
@@ -171,7 +171,7 @@ function splitCSVLine(line) {
171
171
  let current = '';
172
172
  let inQuotes = false;
173
173
  for (let i = 0; i < line.length; i++) {
174
- const ch = line[i];
174
+ const ch = line[i] ?? '';
175
175
  if (ch === '"') {
176
176
  if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
177
177
  // RFC 4180 escaped quote: "" → literal "
@@ -209,11 +209,11 @@ export function parseWorldBankCSV(csvText) {
209
209
  if (lines.length < 2) {
210
210
  return [];
211
211
  }
212
- const headers = splitCSVLine(lines[0]).map((h) => h.toLowerCase());
212
+ const headers = splitCSVLine(lines[0] ?? '').map((h) => h.toLowerCase());
213
213
  const colMap = Object.fromEntries(Object.entries(HEADER_ALIASES).map(([key, aliases]) => [key, findColumnIndex(headers, aliases)]));
214
214
  const results = [];
215
215
  for (let i = 1; i < lines.length; i++) {
216
- const cols = splitCSVLine(lines[i]);
216
+ const cols = splitCSVLine(lines[i] ?? '');
217
217
  const rawValue = readCol(cols, colMap['value'] ?? -1);
218
218
  const parsedValue = rawValue !== '' ? Number(rawValue) : null;
219
219
  const yearStr = readCol(cols, colMap['date'] ?? -1);