[{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/constants/analysis-constants.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/constants/committee-indicator-map.ts","messages":[],"suppressedMessages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1325,"column":10,"endLine":1325,"endColumn":42,"suppressions":[{"kind":"directive","justification":"key validated via Object.hasOwn"}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/constants/config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/constants/language-articles.ts","messages":[],"suppressedMessages":[{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":40,"column":7,"endLine":40,"endColumn":22,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":44,"column":7,"endLine":44,"endColumn":28,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 16 times.","line":99,"column":7,"endLine":99,"endColumn":23,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":158,"column":7,"endLine":158,"endColumn":23,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":162,"column":7,"endLine":162,"endColumn":27,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":276,"column":7,"endLine":276,"endColumn":23,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":280,"column":7,"endLine":280,"endColumn":29,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 4 times.","line":281,"column":7,"endLine":281,"endColumn":21,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":335,"column":7,"endLine":335,"endColumn":21,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":339,"column":7,"endLine":339,"endColumn":31,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":394,"column":7,"endLine":394,"endColumn":27,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 4 times.","line":398,"column":7,"endLine":398,"endColumn":20,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":445,"column":7,"endLine":445,"endColumn":27,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 4 times.","line":449,"column":7,"endLine":449,"endColumn":20,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":496,"column":7,"endLine":496,"endColumn":21,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":500,"column":7,"endLine":500,"endColumn":27,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":555,"column":7,"endLine":555,"endColumn":26,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 3 times.","line":557,"column":7,"endLine":557,"endColumn":24,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 8 times.","line":591,"column":7,"endLine":591,"endColumn":24,"suppressions":[{"kind":"directive","justification":"Localized keyword dictionaries have intentional repetition across categories"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 4 times.","line":1673,"column":26,"endLine":1673,"endColumn":44,"suppressions":[{"kind":"directive","justification":"Translated analysis strings share common terms across languages"}]},{"ruleId":"sonarjs/no-duplicate-string","severity":2,"message":"Define a constant instead of duplicating this literal 4 times.","line":1740,"column":27,"endLine":1740,"endColumn":46,"suppressions":[{"kind":"directive","justification":"Translated analysis strings share common terms across languages"}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/constants/language-core.ts","messages":[],"suppressedMessages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":84,"column":38,"endLine":84,"endColumn":47,"suppressions":[{"kind":"directive","justification":"key validated via Object.hasOwn"}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/constants/language-ui.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/constants/languages.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/analysis-builders.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/breaking-content.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/builders/breaking-builders.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/builders/committee-builders.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/builders/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/builders/propositions-builders.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":102,"column":10,"endLine":102,"endColumn":40}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/Builders/PropositionsBuilders\n * @description Deep analysis, SWOT, dashboard and mindmap\n * builders for propositions articles.\n */\n\nimport type {\n  DeepAnalysis,\n  ActionConsequence,\n  StakeholderOutcome,\n  LanguageCode,\n  SwotAnalysis,\n  DashboardConfig,\n  DashboardPanel,\n  SwotBuilderStrings,\n  DashboardBuilderStrings,\n  IntelligenceMindmap,\n  MindmapNode,\n  ActorNode,\n  PolicyConnection,\n  StakeholderPerspective,\n  LegislativePipeline,\n} from '../../types/index.js';\nimport type { PipelineData } from '../propositions-content.js';\nimport {\n  getLocalizedString,\n  SWOT_BUILDER_STRINGS,\n  DASHBOARD_BUILDER_STRINGS,\n} from '../../constants/languages.js';\nimport { buildDefaultStakeholderPerspectives } from '../../utils/intelligence-analysis.js';\nimport { AI_MARKER } from '../../constants/analysis-constants.js';\nimport {\n  buildOutcomeMatrix,\n  buildFallbackImpactAssessment,\n  buildStakeholderMetricsFromPipeline,\n  buildStakeholderPanel,\n  applyAnalysisOverrides,\n  type AnalysisOverrides,\n  CIVIL_SOCIETY,\n} from './shared-builders.js';\n\nconst CONFERENCE_OF_PRESIDENTS_EN = 'Conference of Presidents';\nconst CONFERENCE_OF_PRESIDENTS: Partial<Record<LanguageCode, string>> = {\n  en: CONFERENCE_OF_PRESIDENTS_EN,\n  sv: 'Talmanskonferensen',\n  da: 'Formandskonferencen',\n  no: 'Formannskonferansen',\n  fi: 'Puheenjohtajakokous',\n  de: 'Konferenz der Präsidenten',\n  fr: 'Conférence des présidents',\n  es: 'Conferencia de Presidentes',\n  nl: 'Conferentie van voorzitters',\n  ar: '\\u0645\\u0624\\u062a\\u0645\\u0631 \\u0627\\u0644\\u0631\\u0624\\u0633\\u0627\\u0621',\n  he: '\\u05d5\\u05e2\\u05d9\\u05d3\\u05ea \\u05d4\\u05e0\\u05e9\\u05d9\\u05d0\\u05d9\\u05dd',\n  ja: '\\u8b70\\u9577\\u4f1a\\u8b70',\n  ko: '\\uc758\\uc7a5\\ud68c\\uc758',\n  zh: '\\u4e3b\\u5e2d\\u56e2\\u4f1a\\u8bae',\n};\n\n/**\n * Build multi-stakeholder perspectives for a propositions pipeline analysis.\n *\n * @param healthScore - Pipeline health score (0-1)\n * @param topic - Primary topic string for context\n * @returns Array of stakeholder perspectives\n */\nfunction buildPropositionsStakeholderPerspectives(\n  healthScore: number,\n  topic: string\n): StakeholderPerspective[] {\n  return buildDefaultStakeholderPerspectives(topic, {\n    political_groups: 0.7,\n    civil_society: healthScore < 0.5 ? 0.3 : 0.5,\n    industry: healthScore < 0.5 ? 0.3 : 0.6,\n    national_govts: healthScore < 0.5 ? 0.3 : 0.6,\n    citizens: healthScore < 0.5 ? 0.2 : 0.5,\n    eu_institutions: 0.8,\n  });\n}\n\n/**\n * Build the \"why\" explanation for propositions based on pipeline health.\n * Returns AI_MARKER so the gh-aw AI agent produces real political analysis\n * instead of template-generated prose.\n *\n * @returns AI_MARKER placeholder for AI-driven analysis\n */\nfunction buildPropositionsWhy(): string {\n  return AI_MARKER;\n}\n\n/**\n * Get the localized Conference of Presidents name.\n *\n * @param lang - Target language code\n * @returns Localized name or English fallback\n */\nfunction getConferenceOfPresidents(lang: LanguageCode): string {\n  return CONFERENCE_OF_PRESIDENTS[lang] ?? CONFERENCE_OF_PRESIDENTS_EN;\n}\n\n/**\n * Build the action-consequence pairs for propositions analysis.\n * Returns AI_MARKER for consequences so the AI agent writes real analysis.\n *\n * @param pct - Pipeline health percentage as string\n * @param healthScore - Pipeline health score (0-1)\n * @param throughput - Throughput rate\n * @returns Action-consequence pairs with AI_MARKER for consequence text\n */\nfunction buildPropositionsConsequences(\n  pct: string,\n  healthScore: number,\n  throughput: number\n): ActionConsequence[] {\n  const healthSeverity: ActionConsequence['severity'] =\n    healthScore < 0.3 ? 'critical' : healthScore < 0.5 ? 'high' : 'medium';\n  return [\n    {\n      action: `Pipeline health at ${pct}%`,\n      consequence: AI_MARKER,\n      severity: healthSeverity,\n    },\n    {\n      action: `Throughput rate at ${throughput}`,\n      consequence: AI_MARKER,\n      severity: throughput < 5 ? 'high' : 'low',\n    },\n  ];\n}\n\n/**\n * Build the primary stakeholder outcome for propositions analysis.\n * Reasoning text is deferred to the AI agent via AI_MARKER.\n *\n * @param healthScore - Pipeline health score used for outcome classification\n * @param pct - Pipeline health percentage used as reasoning context\n * @returns Single stakeholder outcome with AI_MARKER reasoning\n */\nfunction buildPropositionsStakeholderOutcome(healthScore: number, pct: string): StakeholderOutcome {\n  if (healthScore > 0.7) {\n    return {\n      actor: 'Parliament presidency',\n      outcome: 'winner',\n      reason: `${AI_MARKER} Pipeline health at ${pct}%`,\n    };\n  }\n  return {\n    actor: 'Pending legislation sponsors',\n    outcome: 'loser',\n    reason: `${AI_MARKER} Pipeline health at ${pct}%`,\n  };\n}\n\n/**\n * Build a pipeline status breakdown panel for propositions dashboard.\n *\n * @param d - Localized strings\n * @param pipeline - Legislative pipeline data\n * @returns Panel object or null\n */\nfunction buildPropositionsPipelinePanel(\n  d: DashboardBuilderStrings,\n  pipeline: LegislativePipeline | null\n): DashboardPanel | null {\n  if (!pipeline) return null;\n  return {\n    title: d.pipelineStatus,\n    metrics: [\n      {\n        label: d.onTrack,\n        value: String(pipeline.onTrack),\n        trend: (pipeline.onTrack > 0 ? 'up' : 'stable') as 'up' | 'down' | 'stable',\n      },\n      {\n        label: d.delayed,\n        value: String(pipeline.delayed),\n        trend: (pipeline.delayed > 0 ? 'down' : 'stable') as 'up' | 'down' | 'stable',\n      },\n      {\n        label: d.blocked,\n        value: String(pipeline.blocked),\n        trend: (pipeline.blocked > 0 ? 'down' : 'stable') as 'up' | 'down' | 'stable',\n      },\n    ],\n    chart: {\n      type: 'bar' as const,\n      title: d.pipelineStatusChart,\n      data: {\n        labels: [d.onTrack, d.delayed, d.blocked],\n        datasets: [\n          {\n            label: d.procedures,\n            data: [pipeline.onTrack, pipeline.delayed, pipeline.blocked],\n            backgroundColor: ['#28a745', '#ffc107', '#dc3545'],\n          },\n        ],\n      },\n    },\n  };\n}\n\n/**\n * Resolve the pipeline strength label from a health score.\n *\n * @param d - Localized strings\n * @param healthScore - Health score 0-1\n * @returns Localized pipeline strength label\n */\nfunction resolvePipelineStrengthLabel(d: DashboardBuilderStrings, healthScore: number): string {\n  if (healthScore > 0.7) return d.pipelineStrong;\n  if (healthScore > 0.4) return d.pipelineModerate;\n  return d.pipelineWeak;\n}\n\n/**\n * Build legislative pipeline data from PipelineData.\n *\n * @param pipelineData - Pipeline metrics or null\n * @returns Legislative pipeline object\n */\nfunction buildPipelineFromPipelineData(\n  pipelineData: { healthScore: number; throughput: number } | null\n): LegislativePipeline | null {\n  if (!pipelineData) return null;\n  const healthScore = Math.round(pipelineData.healthScore * 100);\n  const total = pipelineData.throughput;\n  if (total === 0) return null;\n\n  const onTrack = Math.round(total * pipelineData.healthScore);\n  const remaining = total - onTrack;\n  const blocked = Math.round(remaining * 0.3);\n  const delayed = remaining - blocked;\n\n  return {\n    healthScore,\n    onTrack,\n    delayed,\n    blocked,\n    fastTracked: 0,\n    total,\n  };\n}\n\n/**\n * Build deep analysis for propositions articles.\n *\n * @param proposalsHtml - Proposals HTML (used to detect content presence)\n * @param pipelineData - Pipeline metrics\n * @param date - Publication date\n * @param lang - Target display language (default: 'en')\n * @param adoptedTextsHtml - Adopted texts HTML (also used to detect content presence)\n * @param overrides - Optional AI-authored overrides (see Analysis-to-Article\n *   Data Contract in `.github/prompts/SHARED_PROMPT_PATTERNS.md`).\n * @returns Deep analysis object, with overrides applied when present.\n */\nexport function buildPropositionsAnalysis(\n  proposalsHtml: string,\n  pipelineData: PipelineData | null,\n  date: string,\n  lang: LanguageCode = 'en',\n  adoptedTextsHtml: string = '',\n  overrides?: AnalysisOverrides\n): DeepAnalysis {\n  const hasProposals = proposalsHtml.length > 0 || adoptedTextsHtml.length > 0;\n  const healthScore = pipelineData?.healthScore ?? 0;\n  const throughput = pipelineData?.throughput ?? 0;\n  const pct = (healthScore * 100).toFixed(0);\n\n  const base: DeepAnalysis = {\n    what: hasProposals\n      ? `Legislative pipeline assessment as of ${date}: Active proposals under consideration.`\n      : `Legislative pipeline assessment as of ${date}: No new proposals detected in this period.`,\n    who: [\n      'European Commission (proposal originator)',\n      'Rapporteurs (responsible for steering through committee)',\n      'Shadow rapporteurs (political group negotiators)',\n      'Council of the EU (co-legislator)',\n    ],\n    when: [`Assessment date: ${date}`, 'Pipeline health reflects cumulative legislative progress'],\n    why: buildPropositionsWhy(),\n    stakeholderOutcomes: [buildPropositionsStakeholderOutcome(healthScore, pct)],\n    impactAssessment: buildFallbackImpactAssessment(),\n    actionConsequences: buildPropositionsConsequences(pct, healthScore, throughput),\n    mistakes:\n      healthScore < 0.5\n        ? [\n            {\n              actor: getConferenceOfPresidents(lang),\n              description: `Pipeline health dropped to ${pct}%`,\n              alternative: AI_MARKER,\n            },\n          ]\n        : [],\n    outlook: AI_MARKER,\n    stakeholderPerspectives: buildPropositionsStakeholderPerspectives(\n      healthScore,\n      `legislative pipeline as of ${date}`\n    ),\n    stakeholderOutcomeMatrix: buildOutcomeMatrix([\n      {\n        action: `Pipeline health at ${pct}% (throughput ${throughput})`,\n        scores: {\n          political_groups: 0.7,\n          civil_society: healthScore < 0.5 ? 0.3 : 0.5,\n          industry: healthScore < 0.5 ? 0.3 : 0.6,\n          national_govts: healthScore < 0.5 ? 0.3 : 0.6,\n          citizens: healthScore < 0.5 ? 0.2 : 0.5,\n          eu_institutions: 0.8,\n        },\n        confidence: pipelineData !== null ? 'high' : 'low',\n      },\n    ]),\n  };\n  return applyAnalysisOverrides(base, overrides);\n}\n\n/**\n * Build SWOT analysis for propositions articles.\n *\n * @param pipelineData - Pipeline metrics\n * @param lang - Target language code\n * @returns SWOT analysis data\n */\nexport function buildPropositionsSwot(\n  pipelineData: PipelineData | null,\n  lang: LanguageCode = 'en'\n): SwotAnalysis {\n  const s: SwotBuilderStrings = getLocalizedString(SWOT_BUILDER_STRINGS, lang);\n  const healthScore = pipelineData?.healthScore ?? 0;\n  const throughput = pipelineData?.throughput ?? 0;\n  const pct = (healthScore * 100).toFixed(0);\n\n  return {\n    strengths: [\n      ...(healthScore > 0.7\n        ? [\n            {\n              text: s.propositionsHealthStrong(pct),\n              severity: 'high' as const,\n            },\n          ]\n        : []),\n      ...(throughput >= 5\n        ? [\n            {\n              text: s.propositionsThroughputGood(throughput),\n              severity: 'medium' as const,\n            },\n          ]\n        : []),\n    ],\n    weaknesses: [\n      ...(healthScore < 0.5\n        ? [\n            {\n              text: s.propositionsHealthWeak(pct),\n              severity: 'high' as const,\n            },\n          ]\n        : []),\n      ...(throughput < 5\n        ? [\n            {\n              text: s.propositionsThroughputLow(throughput),\n              severity: 'medium' as const,\n            },\n          ]\n        : []),\n    ],\n    opportunities: [\n      {\n        text: s.propositionsPrioritisation,\n        severity: 'medium' as const,\n      },\n      {\n        text: s.propositionsTrilogueAcceleration,\n        severity: 'medium' as const,\n      },\n    ],\n    threats: [\n      ...(healthScore < 0.3\n        ? [\n            {\n              text: s.propositionsCriticalCongestion,\n              severity: 'high' as const,\n            },\n          ]\n        : []),\n      {\n        text: s.propositionsOverlapping,\n        severity: 'medium' as const,\n      },\n    ],\n  };\n}\n\n/**\n * Build dashboard for propositions articles.\n * Includes color-coded pipeline status chart and stakeholder scorecard.\n *\n * @param pipelineData - Pipeline metrics\n * @param lang - Target language code\n * @returns Dashboard configuration with pipeline intelligence panels\n */\nexport function buildPropositionsDashboard(\n  pipelineData: PipelineData | null,\n  lang: LanguageCode = 'en'\n): DashboardConfig {\n  const d: DashboardBuilderStrings = getLocalizedString(DASHBOARD_BUILDER_STRINGS, lang);\n  const healthScore = pipelineData?.healthScore ?? 0;\n  const throughput = pipelineData?.throughput ?? 0;\n  const pct = (healthScore * 100).toFixed(0);\n\n  const healthPanel = {\n    title: d.pipelineHealth,\n    metrics: [\n      {\n        label: d.healthScore,\n        value: `${pct}%`,\n        trend: (healthScore > 0.7 ? 'up' : healthScore < 0.5 ? 'down' : 'stable') as\n          | 'up'\n          | 'down'\n          | 'stable',\n      },\n      {\n        label: d.throughput,\n        value: String(throughput),\n        trend: throughput >= 5 ? ('up' as const) : ('down' as const),\n      },\n      {\n        label: d.status,\n        value: resolvePipelineStrengthLabel(d, healthScore),\n      },\n    ],\n  };\n\n  // Pipeline status breakdown panel\n  const pipeline = buildPipelineFromPipelineData(pipelineData);\n  const pipelinePanel = buildPropositionsPipelinePanel(d, pipeline);\n\n  // Stakeholder impact scorecard for pipeline actors\n  const stakeholderMetrics = buildStakeholderMetricsFromPipeline(pipeline);\n  const stakeholderPanel = buildStakeholderPanel(d, stakeholderMetrics);\n\n  const panels = [\n    healthPanel,\n    ...(pipelinePanel ? [pipelinePanel] : []),\n    ...(stakeholderPanel ? [stakeholderPanel] : []),\n  ];\n\n  return { panels };\n}\n\n/**\n * Build intelligence mindmap for propositions / legislative pipeline articles.\n *\n * Maps the legislative pipeline stages as policy domain nodes with procedure\n * health and throughput indicators.\n *\n * @param pipelineData - Legislative pipeline metrics (null when unavailable)\n * @param _lang - Reserved for future localisation (default: 'en')\n * @returns Intelligence mindmap data\n */\nexport function buildPropositionsMindmap(\n  pipelineData: { healthScore: number; throughput: number } | null,\n  _lang: LanguageCode = 'en'\n): IntelligenceMindmap {\n  void _lang;\n  const healthScore = pipelineData?.healthScore ?? 0;\n  const throughput = pipelineData?.throughput ?? 0;\n  const healthPct = (healthScore * 100).toFixed(0);\n\n  const pipelineStages: MindmapNode[] = [\n    {\n      id: 'proposal',\n      label: 'Commission Proposals',\n      category: 'policy_domain' as const,\n      influence: 0.9,\n      color: 'cyan',\n      children: [\n        {\n          id: 'proposal-review',\n          label: 'Initial Committee Review',\n          category: 'action' as const,\n          influence: 0.7,\n          color: 'cyan',\n          children: [],\n          metadata: { committee: 'Lead Committee' },\n        },\n      ],\n    },\n    {\n      id: 'committee',\n      label: 'Committee Stage',\n      category: 'policy_domain' as const,\n      influence: 0.85,\n      color: 'green',\n      children: [\n        {\n          id: 'rapporteur',\n          label: 'Rapporteur Report',\n          category: 'action' as const,\n          influence: 0.8,\n          color: 'green',\n          children: [],\n        },\n        {\n          id: 'amendments',\n          label: 'Amendments',\n          category: 'action' as const,\n          influence: 0.75,\n          color: 'yellow',\n          children: [],\n        },\n      ],\n    },\n    {\n      id: 'plenary',\n      label: 'Plenary Vote',\n      category: 'policy_domain' as const,\n      influence: healthScore,\n      color: healthScore > 0.7 ? 'green' : healthScore > 0.4 ? 'yellow' : 'red',\n      children: [\n        {\n          id: 'plenary-debate',\n          label: 'Debate',\n          category: 'action' as const,\n          influence: 0.7,\n          color: 'blue',\n          children: [],\n        },\n      ],\n    },\n    {\n      id: 'trilogue',\n      label: 'Inter-institutional Trilogue',\n      category: 'policy_domain' as const,\n      influence: 0.8,\n      color: 'orange',\n      children: [\n        {\n          id: 'council-position',\n          label: 'Council Position',\n          category: 'actor' as const,\n          influence: 0.85,\n          color: 'orange',\n          children: [],\n          metadata: { committee: 'Council of the EU' },\n        },\n      ],\n    },\n    {\n      id: 'adoption',\n      label: 'Final Adoption',\n      category: 'outcome' as const,\n      influence: healthScore > 0.5 ? 0.9 : 0.4,\n      color: healthScore > 0.5 ? 'green' : 'red',\n      children: [],\n    },\n  ];\n\n  const actorNetwork: ActorNode[] = [\n    {\n      id: 'commission',\n      name: 'European Commission',\n      type: 'external' as const,\n      influence: 0.9,\n      connections: ['proposal'],\n    },\n    {\n      id: 'parliament',\n      name: 'European Parliament',\n      type: 'committee' as const,\n      influence: 0.95,\n      connections: ['committee', 'plenary'],\n    },\n    {\n      id: 'council',\n      name: 'Council of the EU',\n      type: 'external' as const,\n      influence: 0.9,\n      connections: ['trilogue', 'adoption'],\n    },\n  ];\n\n  const connections: PolicyConnection[] = [\n    {\n      from: 'proposal',\n      to: 'committee',\n      strength: 'strong',\n      type: 'legislative',\n      evidence: 'Formal referral to committee',\n    },\n    {\n      from: 'committee',\n      to: 'plenary',\n      strength: 'strong',\n      type: 'procedural',\n      evidence: 'Committee report referred to plenary',\n    },\n    {\n      from: 'plenary',\n      to: 'trilogue',\n      strength: throughput > 5 ? 'strong' : 'moderate',\n      type: 'legislative',\n      evidence: 'Parliament position triggers inter-institutional negotiations',\n    },\n    {\n      from: 'trilogue',\n      to: 'adoption',\n      strength: healthScore > 0.6 ? 'strong' : 'weak',\n      type: 'legislative',\n      evidence: `Pipeline health: ${healthPct}%`,\n    },\n  ];\n\n  return {\n    centralTopic: 'Legislative Pipeline Intelligence',\n    layers: [{ depth: 1, nodes: pipelineStages }],\n    connections,\n    actorNetwork,\n    stakeholderGroups: ['Commission', 'Parliament', 'Council', 'Businesses', CIVIL_SOCIETY],\n    summary: `Pipeline health: ${healthPct}%. Throughput rate: ${throughput}. ${throughput > 5 ? 'Strong legislative momentum.' : 'Moderate legislative pace.'}`,\n  };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/builders/prospective-builders.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/builders/shared-builders.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":83,"column":19,"endLine":83,"endColumn":50},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":85,"column":9,"endLine":85,"endColumn":20},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":227,"column":55,"endLine":227,"endColumn":70}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/Builders/SharedBuilders\n * @description Shared helper functions used across multiple analysis builder\n * domains. Includes outcome matrix construction, AI marker impact assessments,\n * coalition metrics, pipeline builders, and trend analytics.\n */\n\nimport type {\n  DeepAnalysis,\n  StakeholderOutcomeMatrix,\n  AnalysisStakeholderType,\n  CoalitionMetrics,\n  LegislativePipeline,\n  TrendAnalytics,\n  DashboardPanel,\n  DashboardBuilderStrings,\n  WeekAheadData,\n  VotingPattern,\n  StakeholderMetric,\n  StakeholderPerspective,\n} from '../../types/index.js';\nimport { buildStakeholderOutcomeMatrix } from '../../utils/intelligence-analysis.js';\nimport { AI_MARKER } from '../../constants/analysis-constants.js';\n\n// ─── Analysis override types ─────────────────────────────────────────────────\n\n/**\n * Optional AI-authored overrides that callers may supply to the five\n * `build*Analysis()` functions.  When a field is present and non-empty, it\n * replaces the corresponding section of the default template-derived\n * {@link DeepAnalysis} object.\n *\n * These overrides are typically sourced from loaded analysis markdown via\n * {@link module:Utils/ParseAnalysisStakeholders} and close the\n * Analysis-to-Article Data Contract gap documented in\n * `.github/prompts/SHARED_PROMPT_PATTERNS.md`.\n */\nexport interface AnalysisOverrides {\n  /** Replace the default six-bucket stakeholder perspective array */\n  readonly stakeholderPerspectives?: readonly StakeholderPerspective[] | null | undefined;\n  /** Replace the default stakeholder outcome matrix */\n  readonly stakeholderOutcomeMatrix?: readonly StakeholderOutcomeMatrix[] | null | undefined;\n  /** Replace the `AI_MARKER` placeholders in every impact-assessment dimension */\n  readonly impactAssessment?: DeepAnalysis['impactAssessment'] | null | undefined;\n}\n\n/**\n * Merge a caller-supplied {@link AnalysisOverrides} set into the default\n * `DeepAnalysis` produced by a builder.\n *\n * Undefined, null, and empty array/record overrides are ignored, so callers\n * can pass a single unified overrides object across the five builders without\n * null-guarding every field.\n *\n * @param base - The builder's default `DeepAnalysis` object.\n * @param overrides - Optional AI-authored overrides (see {@link AnalysisOverrides}).\n * @returns The merged `DeepAnalysis`.\n */\nexport function applyAnalysisOverrides(\n  base: DeepAnalysis,\n  overrides?: AnalysisOverrides\n): DeepAnalysis {\n  if (!overrides) return base;\n  type Mutable = { -readonly [K in keyof DeepAnalysis]: DeepAnalysis[K] };\n  const next: Mutable = { ...base };\n  if (overrides.stakeholderPerspectives && overrides.stakeholderPerspectives.length > 0) {\n    next.stakeholderPerspectives = [...overrides.stakeholderPerspectives];\n  }\n  if (overrides.stakeholderOutcomeMatrix && overrides.stakeholderOutcomeMatrix.length > 0) {\n    next.stakeholderOutcomeMatrix = [...overrides.stakeholderOutcomeMatrix];\n  }\n  if (overrides.impactAssessment) {\n    // Only overlay dimensions whose override is non-empty and not the AI_MARKER\n    // sentinel, so partial AI-authored impact blocks can coexist with fallback\n    // markers for missing dimensions.\n    const merged: Record<keyof DeepAnalysis['impactAssessment'], string> = {\n      ...base.impactAssessment,\n    };\n    for (const key of ['political', 'economic', 'social', 'legal', 'geopolitical'] as const) {\n      const val = overrides.impactAssessment[key];\n      if (typeof val === 'string' && val.length > 0 && val !== AI_MARKER) {\n        merged[key] = val;\n      }\n    }\n    next.impactAssessment = merged;\n  }\n  return next;\n}\n\n// ─── Style constants ─────────────────────────────────────────────────────────\n\nexport const EP_BLUE_TRANSPARENT = 'rgba(0,51,153,0.1)';\n\nexport const EP_BLUE_BORDER = '#003399';\n\nexport const CIVIL_SOCIETY = 'Civil Society';\n\n/**\n * Build the stakeholder outcome matrix for a list of key actions.\n * Used by all 5 analysis builders to populate the outcome matrix.\n *\n * @param actions - Readonly array of (action, scores) pairs to include in the matrix\n * @returns Stakeholder outcome matrix rows\n */\nexport function buildOutcomeMatrix(\n  actions: readonly {\n    readonly action: string;\n    readonly scores: Readonly<Partial<Record<AnalysisStakeholderType, number>>>;\n    readonly confidence: 'high' | 'medium' | 'low';\n  }[]\n): StakeholderOutcomeMatrix[] {\n  return actions.map(({ action, scores, confidence }) =>\n    buildStakeholderOutcomeMatrix(action, scores, confidence)\n  );\n}\n\n/**\n * Build a placeholder impact assessment with every dimension marked `AI_MARKER`.\n *\n * This is the **last-resort fallback** used only when no AI-authored\n * `## Impact Assessment` block was parseable from the run's\n * `synthesis-summary.md` or `deep-analysis.md`.  Agentic workflows should\n * satisfy the Analysis-to-Article Data Contract (see\n * `.github/prompts/SHARED_PROMPT_PATTERNS.md#-analysis-to-article-data-contract`)\n * so this fallback is never rendered.  When it is rendered, the downstream\n * `article-rewriter` step replaces the `AI_MARKER` strings with real analysis\n * content before publication.\n *\n * @returns Impact assessment with `AI_MARKER` placeholders in every dimension.\n */\nexport function buildFallbackImpactAssessment(): DeepAnalysis['impactAssessment'] {\n  return {\n    political: AI_MARKER,\n    economic: AI_MARKER,\n    social: AI_MARKER,\n    legal: AI_MARKER,\n    geopolitical: AI_MARKER,\n  };\n}\n\n/**\n * @deprecated Use {@link buildFallbackImpactAssessment} — the name was changed\n * to reflect that this is a last-resort path rather than the AI-integration\n * path.  The alias is retained for backward compatibility with internal\n * callers and will be removed after all builders route through\n * `buildFallbackImpactAssessment`.\n */\nexport const buildAiMarkerImpactAssessment = buildFallbackImpactAssessment;\n\n/**\n * Build coalition metrics from voting patterns data.\n * Derives alignment scores and shift indicators for the coalition radar chart.\n *\n * @param patterns - Voting pattern data\n * @returns Coalition metrics object or null if no real patterns\n */\nexport function buildCoalitionMetricsFromPatterns(\n  patterns: readonly VotingPattern[]\n): CoalitionMetrics | null {\n  const realPatterns = patterns.filter((p) => !/placeholder/i.test(p.group));\n  if (realPatterns.length === 0) return null;\n\n  const avgCohesion = realPatterns.reduce((sum, p) => sum + p.cohesion, 0) / realPatterns.length;\n  const alignmentScore = Math.round(avgCohesion * 100);\n\n  // Detect shift from cohesion spread\n  const maxCohesion = Math.max(...realPatterns.map((p) => p.cohesion));\n  const minCohesion = Math.min(...realPatterns.map((p) => p.cohesion));\n  const spread = maxCohesion - minCohesion;\n  const shiftIndicator: CoalitionMetrics['shiftIndicator'] =\n    spread > 0.3 ? 'weakening' : avgCohesion > 0.7 ? 'strengthening' : 'stable';\n\n  return {\n    alignmentScore,\n    votingBlocs: realPatterns.slice(0, 6).map((p) => ({\n      group: p.group,\n      alignmentScore: Math.round(p.cohesion * 100),\n    })),\n    shiftIndicator,\n  };\n}\n\n/**\n * Build legislative pipeline data from WeekAheadData.\n *\n * @param weekData - Aggregated week/month data\n * @returns Legislative pipeline object\n */\nexport function buildPipelineFromWeekData(weekData: WeekAheadData): LegislativePipeline {\n  const bottlenecked = weekData.pipeline.filter((p) => p.bottleneck === true).length;\n  const total = weekData.pipeline.length;\n  const onTrack = total - bottlenecked;\n  const healthScore = total > 0 ? Math.round((onTrack / total) * 100) : 100;\n\n  return {\n    healthScore,\n    onTrack,\n    delayed: bottlenecked,\n    blocked: 0,\n    fastTracked: 0,\n    total,\n  };\n}\n\n/**\n * Build trend analytics from feed data counts using the provided periods as-is.\n *\n * @param counts - Array of activity counts per period in chronological order\n * @param period - Trend period label\n * @returns Trend analytics object or null if no data\n */\nexport function buildTrendFromCounts(\n  counts: readonly number[],\n  period: TrendAnalytics['period']\n): TrendAnalytics | null {\n  if (counts.length === 0 || counts.every((c) => c === 0)) return null;\n\n  const periodLabels = counts.map((_, i) => {\n    if (period === 'weekly') return `W${i + 1}`;\n    if (period === 'monthly') return `M${i + 1}`;\n    return `Q${i + 1}`;\n  });\n\n  const metrics = counts.map((value, i) => ({ period: periodLabels[i] ?? `${i + 1}`, value }));\n\n  const last = counts.at(-1) ?? 0;\n  const prev = counts.at(-2) ?? last;\n  const change = prev > 0 ? ((last - prev) / prev) * 100 : 0;\n  const direction: TrendAnalytics['direction'] =\n    change > 5 ? 'improving' : change < -5 ? 'declining' : 'stable';\n\n  return {\n    period,\n    metrics,\n    direction,\n    weekOverWeekChange: period === 'weekly' ? Math.round(change * 10) / 10 : undefined,\n    monthOverMonthChange: period === 'monthly' ? Math.round(change * 10) / 10 : undefined,\n  };\n}\n\n/**\n * Build stakeholder metrics from voting patterns.\n *\n * @param patterns - Voting patterns\n * @param anomalyCount - Number of anomalies\n * @returns Stakeholder metric array\n */\nexport function buildStakeholderMetricsFromVoting(\n  patterns: readonly VotingPattern[],\n  anomalyCount: number\n): StakeholderMetric[] {\n  const realPatterns = patterns.filter((p) => !/placeholder/i.test(p.group));\n  const metrics: StakeholderMetric[] = realPatterns.slice(0, 4).map((p) => ({\n    stakeholder: p.group,\n    impactScore: Math.round(p.cohesion * 100),\n    impactDirection: (p.cohesion > 0.7 ? 'positive' : p.cohesion < 0.4 ? 'negative' : 'neutral') as\n      | 'positive'\n      | 'negative'\n      | 'neutral',\n  }));\n  if (anomalyCount > 0) {\n    metrics.push({\n      stakeholder: 'Coalition stability',\n      impactScore: Math.max(0, 100 - anomalyCount * 15),\n      impactDirection: anomalyCount > 3 ? 'negative' : 'neutral',\n    });\n  }\n  return metrics;\n}\n\n/**\n * Build stakeholder metrics for legislative pipeline actors.\n *\n * @param pipeline - Legislative pipeline data\n * @returns Stakeholder metric array\n */\nexport function buildStakeholderMetricsFromPipeline(\n  pipeline: LegislativePipeline | null\n): StakeholderMetric[] {\n  if (!pipeline || pipeline.total === 0) return [];\n  return [\n    {\n      stakeholder: 'Legislators',\n      impactScore: pipeline.healthScore,\n      impactDirection:\n        pipeline.healthScore > 70 ? 'positive' : pipeline.healthScore < 40 ? 'negative' : 'neutral',\n    },\n    {\n      stakeholder: 'Pending proposals',\n      impactScore: pipeline.total > 0 ? Math.round((pipeline.blocked / pipeline.total) * 100) : 0,\n      impactDirection: pipeline.blocked > 0 ? 'negative' : 'neutral',\n      description:\n        pipeline.blocked > 0\n          ? `${pipeline.blocked} blocked procedure${pipeline.blocked > 1 ? 's' : ''}`\n          : undefined,\n    },\n  ];\n}\n\n/**\n * Build a stakeholder panel from stakeholder metric array.\n *\n * @param d - Localized strings\n * @param stakeholderMetrics - Stakeholder metric data\n * @returns Panel object or null\n */\nexport function buildStakeholderPanel(\n  d: DashboardBuilderStrings,\n  stakeholderMetrics: readonly StakeholderMetric[]\n): DashboardPanel | null {\n  if (stakeholderMetrics.length === 0) return null;\n  return {\n    title: d.stakeholderImpact,\n    metrics: stakeholderMetrics.map((s) => ({\n      label: s.stakeholder,\n      value: `${s.impactScore}/100`,\n      trend: (s.impactDirection === 'positive'\n        ? 'up'\n        : s.impactDirection === 'negative'\n          ? 'down'\n          : 'stable') as 'up' | 'down' | 'stable',\n    })),\n  };\n}\n\n/**\n * Resolve a direction label from trend direction.\n *\n * @param d - Localized strings\n * @param direction - Trend direction\n * @returns Localized direction label\n */\nexport function resolveTrendDirectionLabel(\n  d: DashboardBuilderStrings,\n  direction: TrendAnalytics['direction']\n): string {\n  if (direction === 'improving') return d.trendImproving;\n  if (direction === 'declining') return d.trendDeclining;\n  return d.trendStableLabel;\n}\n\n/**\n * Build a generic trend panel from a trend object.\n *\n * @param d - Localized strings\n * @param trend - Trend analytics\n * @param labels - Labels for x-axis\n * @param datasetLabel - Label for the dataset\n * @returns Panel object or null\n */\nexport function buildGenericTrendPanel(\n  d: DashboardBuilderStrings,\n  trend: TrendAnalytics | null,\n  labels: string[],\n  datasetLabel: string\n): DashboardPanel | null {\n  if (!trend) return null;\n  return {\n    title: d.trendAnalysis,\n    metrics: [\n      {\n        label: d.trendAnalysis,\n        value: resolveTrendDirectionLabel(d, trend.direction),\n      },\n    ],\n    chart: {\n      type: 'line' as const,\n      title: d.activityTrendChart,\n      data: {\n        labels,\n        datasets: [\n          {\n            label: datasetLabel,\n            data: trend.metrics.map((m) => m.value),\n            borderColor: EP_BLUE_BORDER,\n            backgroundColor: EP_BLUE_TRANSPARENT,\n          },\n        ],\n      },\n    },\n  };\n}\n\n/**\n * Build a category distribution panel showing counts per category as a bar chart.\n * Unlike `buildGenericTrendPanel`, this does not compute direction or week-over-week\n * change metrics, which are only meaningful for chronological time-series data.\n *\n * @param d - Localized strings\n * @param labels - Category labels for x-axis\n * @param counts - Counts per category (must align with labels)\n * @param datasetLabel - Label for the dataset\n * @param title - Panel title\n * @returns Panel object or null if all counts are zero\n */\nexport function buildCategoryDistributionPanel(\n  d: DashboardBuilderStrings,\n  labels: readonly string[],\n  counts: readonly number[],\n  datasetLabel: string,\n  title: string\n): DashboardPanel | null {\n  if (counts.length === 0 || counts.every((c) => c === 0)) return null;\n  return {\n    title,\n    metrics: [\n      {\n        label: d.trendAnalysis,\n        value: `${counts.reduce((a, b) => a + b, 0)} total`,\n      },\n    ],\n    chart: {\n      type: 'bar' as const,\n      title,\n      data: {\n        labels: [...labels],\n        datasets: [\n          {\n            label: datasetLabel,\n            data: [...counts],\n            borderColor: EP_BLUE_BORDER,\n            backgroundColor: EP_BLUE_TRANSPARENT,\n          },\n        ],\n      },\n    },\n  };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/builders/voting-builders.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/committee-helpers.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/dashboard-content.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/deep-analysis-content.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":778,"column":10,"endLine":778,"endColumn":26},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":798,"column":10,"endLine":798,"endColumn":21},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":817,"column":10,"endLine":817,"endColumn":23},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":833,"column":10,"endLine":833,"endColumn":22}],"suppressedMessages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":905,"column":25,"endLine":905,"endColumn":40,"suppressions":[{"kind":"directive","justification":"key from const array"}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/DeepAnalysisContent\n * @description Pure functions for building the deep political analysis HTML\n * section using the \"5W + Impact\" framework. This section is injected into\n * every article type to provide parliament-intelligence-grade analysis.\n *\n * The framework covers:\n * - **What**: Core subject\n * - **Who**: Key actors (political groups, rapporteurs, MEPs, institutions)\n * - **When**: Timeline and key dates\n * - **Why**: Root causes and strategic motivations\n * - **Winners/Losers**: Stakeholder impact assessment\n * - **Impact**: Multi-perspective consequences (political, economic, social, legal, geopolitical)\n * - **Actions → Consequences**: Causal chains from decisions to outcomes\n * - **Mistakes**: Miscalculations and missed opportunities\n * - **Outlook**: Strategic forward look\n */\n\nimport { escapeHTML, isSafeURL } from '../utils/file-utils.js';\nimport { getLocalizedString, DEEP_ANALYSIS_STRINGS } from '../constants/languages.js';\nimport { isAiMarker, AI_PENDING_CLASS } from '../constants/analysis-constants.js';\nimport type {\n  DeepAnalysis,\n  DeepAnalysisStrings,\n  StakeholderOutcome,\n  ActionConsequence,\n  PoliticalMistake,\n  StakeholderPerspective,\n  StakeholderOutcomeMatrix,\n  AnalysisStakeholderType,\n  EnhancedDeepAnalysis,\n  ConfidenceLevel,\n  ReasoningChain,\n  ScenarioPlanning,\n  AnalysisQualityMetadata,\n} from '../types/index.js';\nimport { ALL_STAKEHOLDER_TYPES } from '../types/index.js';\n\n// ─── AI pending notice helper ────────────────────────────────────────────────\n\n/**\n * Return an inline HTML notice for an AI-pending field.\n *\n * @param message - Short notice text (no HTML)\n * @returns Safe HTML string\n */\nfunction aiPendingNotice(message: string): string {\n  return `<em class=\"${AI_PENDING_CLASS}\">${escapeHTML(message)}</em>`;\n}\n\n// ─── Sub-section builders ────────────────────────────────────────────────────\n\n/**\n * Build the \"What\" sub-section\n *\n * @param what - Description of what happened\n * @param heading - Localized heading\n * @returns HTML string\n */\nfunction buildWhatSection(what: string, heading: string): string {\n  if (!what) return '';\n  return `\n            <div class=\"analysis-what\">\n              <h3>${escapeHTML(heading)}</h3>\n              <p>${escapeHTML(what)}</p>\n            </div>`;\n}\n\n/**\n * Build the \"Who\" sub-section with key actors list\n *\n * @param who - Array of actor names/descriptions\n * @param heading - Localized heading\n * @returns HTML string\n */\nfunction buildWhoSection(who: readonly string[], heading: string): string {\n  if (who.length === 0) return '';\n  const items = who.map((actor) => `<li>${escapeHTML(actor)}</li>`).join('\\n                ');\n  return `\n            <div class=\"analysis-who\">\n              <h3>${escapeHTML(heading)}</h3>\n              <ul class=\"actor-list\">\n                ${items}\n              </ul>\n            </div>`;\n}\n\n/**\n * Build the \"When\" sub-section with timeline\n *\n * @param when - Array of date/milestone descriptions\n * @param heading - Localized heading\n * @returns HTML string\n */\nfunction buildWhenSection(when: readonly string[], heading: string): string {\n  if (when.length === 0) return '';\n  const items = when\n    .map((milestone) => `<li class=\"timeline-item\">${escapeHTML(milestone)}</li>`)\n    .join('\\n                ');\n  return `\n            <div class=\"analysis-when\">\n              <h3>${escapeHTML(heading)}</h3>\n              <ol class=\"timeline-list\">\n                ${items}\n              </ol>\n            </div>`;\n}\n\n/**\n * Build the \"Why\" sub-section\n *\n * @param why - Root cause analysis text\n * @param heading - Localized heading\n * @param pendingNotice - Localized pending notice text for AI markers\n * @returns HTML string\n */\nfunction buildWhySection(why: string, heading: string, pendingNotice: string): string {\n  if (!why) return '';\n  if (isAiMarker(why)) {\n    return `\n            <div class=\"analysis-why ${AI_PENDING_CLASS}\">\n              <h3>${escapeHTML(heading)}</h3>\n              <p>${aiPendingNotice(pendingNotice)}</p>\n            </div>`;\n  }\n  return `\n            <div class=\"analysis-why\">\n              <h3>${escapeHTML(heading)}</h3>\n              <p>${escapeHTML(why)}</p>\n            </div>`;\n}\n\n/**\n * Map stakeholder outcome to CSS class\n *\n * @param outcome - Winner/loser/neutral\n * @returns CSS class name\n */\nfunction outcomeClass(outcome: StakeholderOutcome['outcome']): string {\n  return `stakeholder-${outcome}`;\n}\n\n/**\n * Get localized label for stakeholder outcome\n *\n * @param outcome - Winner/loser/neutral\n * @param strings - Localized strings\n * @param strings.winnerLabel - Label for winning stakeholders\n * @param strings.loserLabel - Label for losing stakeholders\n * @param strings.neutralLabel - Label for neutral stakeholders\n * @returns Localized label\n */\nfunction outcomeLabel(\n  outcome: StakeholderOutcome['outcome'],\n  strings: { winnerLabel: string; loserLabel: string; neutralLabel: string }\n): string {\n  switch (outcome) {\n    case 'winner':\n      return strings.winnerLabel;\n    case 'loser':\n      return strings.loserLabel;\n    default:\n      return strings.neutralLabel;\n  }\n}\n\n/**\n * Build the \"Winners & Losers\" sub-section\n *\n * @param outcomes - Stakeholder assessments\n * @param heading - Localized heading\n * @param strings - Localized label strings\n * @param strings.winnerLabel - Label for winning stakeholders\n * @param strings.loserLabel - Label for losing stakeholders\n * @param strings.neutralLabel - Label for neutral stakeholders\n * @param strings.pendingNotice - Localized pending notice text for AI markers\n * @returns HTML string\n */\nfunction buildStakeholderSection(\n  outcomes: readonly StakeholderOutcome[],\n  heading: string,\n  strings: { winnerLabel: string; loserLabel: string; neutralLabel: string; pendingNotice: string }\n): string {\n  if (outcomes.length === 0) return '';\n  const items = outcomes\n    .map((s) => {\n      const reasonText = isAiMarker(s.reason)\n        ? aiPendingNotice(strings.pendingNotice)\n        : escapeHTML(s.reason);\n      return (\n        `<li class=\"stakeholder-item ${outcomeClass(s.outcome)}\">` +\n        `<span class=\"stakeholder-badge\">${escapeHTML(outcomeLabel(s.outcome, strings))}</span> ` +\n        `<span><strong>${escapeHTML(s.actor)}</strong>: ${reasonText}</span>` +\n        `</li>`\n      );\n    })\n    .join('\\n                ');\n  return `\n            <div class=\"analysis-stakeholders\">\n              <h3>${escapeHTML(heading)}</h3>\n              <ul class=\"stakeholder-list\">\n                ${items}\n              </ul>\n            </div>`;\n}\n\n/**\n * Build the multi-perspective \"Impact Assessment\" sub-section\n *\n * @param impact - Impact strings per perspective\n * @param heading - Localized heading\n * @param labels - Localized perspective labels\n * @param labels.politicalLabel - Label for political perspective\n * @param labels.economicLabel - Label for economic perspective\n * @param labels.socialLabel - Label for social perspective\n * @param labels.legalLabel - Label for legal perspective\n * @param labels.geopoliticalLabel - Label for geopolitical perspective\n * @param labels.pendingNotice - Localized pending notice text for AI markers\n * @returns HTML string\n */\nfunction buildImpactSection(\n  impact: DeepAnalysis['impactAssessment'],\n  heading: string,\n  labels: {\n    politicalLabel: string;\n    economicLabel: string;\n    socialLabel: string;\n    legalLabel: string;\n    geopoliticalLabel: string;\n    pendingNotice: string;\n  }\n): string {\n  const perspectives = [\n    { label: labels.politicalLabel, text: impact.political, css: 'impact-political' },\n    { label: labels.economicLabel, text: impact.economic, css: 'impact-economic' },\n    { label: labels.socialLabel, text: impact.social, css: 'impact-social' },\n    { label: labels.legalLabel, text: impact.legal, css: 'impact-legal' },\n    { label: labels.geopoliticalLabel, text: impact.geopolitical, css: 'impact-geopolitical' },\n  ].filter((p) => p.text);\n  if (perspectives.length === 0) return '';\n  const items = perspectives\n    .map((p) => {\n      if (isAiMarker(p.text)) {\n        return (\n          `<div class=\"impact-card ${p.css} ${AI_PENDING_CLASS}\">` +\n          `<h4>${escapeHTML(p.label)}</h4>` +\n          `<p>${aiPendingNotice(labels.pendingNotice)}</p>` +\n          `</div>`\n        );\n      }\n      return (\n        `<div class=\"impact-card ${p.css}\">` +\n        `<h4>${escapeHTML(p.label)}</h4>` +\n        `<p>${escapeHTML(p.text)}</p>` +\n        `</div>`\n      );\n    })\n    .join('\\n              ');\n  return `\n            <div class=\"analysis-impact\">\n              <h3>${escapeHTML(heading)}</h3>\n              <div class=\"impact-grid\">\n              ${items}\n              </div>\n            </div>`;\n}\n\n/**\n * Get localized severity label\n *\n * @param severity - Severity level\n * @param strings - Localized strings\n * @param strings.severityLow - Label for low severity\n * @param strings.severityMedium - Label for medium severity\n * @param strings.severityHigh - Label for high severity\n * @param strings.severityCritical - Label for critical severity\n * @returns Localized label\n */\nfunction severityLabel(\n  severity: ActionConsequence['severity'],\n  strings: {\n    severityLow: string;\n    severityMedium: string;\n    severityHigh: string;\n    severityCritical: string;\n  }\n): string {\n  switch (severity) {\n    case 'low':\n      return strings.severityLow;\n    case 'medium':\n      return strings.severityMedium;\n    case 'high':\n      return strings.severityHigh;\n    case 'critical':\n      return strings.severityCritical;\n    default:\n      return String(severity);\n  }\n}\n\n/**\n * Build the \"Actions → Consequences\" sub-section\n *\n * @param items - Action-consequence pairs\n * @param heading - Localized heading\n * @param labels - Localized column labels\n * @param labels.actionLabel - Column header for action\n * @param labels.consequenceLabel - Column header for consequence\n * @param labels.severityColumnLabel - Column header for severity\n * @param strings - Localized severity strings\n * @param strings.severityLow - Label for low severity\n * @param strings.severityMedium - Label for medium severity\n * @param strings.severityHigh - Label for high severity\n * @param strings.severityCritical - Label for critical severity\n * @param strings.pendingNotice - Localized pending notice text for AI markers\n * @returns HTML string\n */\nfunction buildConsequencesSection(\n  items: readonly ActionConsequence[],\n  heading: string,\n  labels: Readonly<\n    Pick<DeepAnalysisStrings, 'actionLabel' | 'consequenceLabel' | 'severityColumnLabel'>\n  >,\n  strings: {\n    severityLow: string;\n    severityMedium: string;\n    severityHigh: string;\n    severityCritical: string;\n    pendingNotice: string;\n  }\n): string {\n  if (items.length === 0) return '';\n  const rows = items\n    .map((item) => {\n      const consequenceText = isAiMarker(item.consequence)\n        ? aiPendingNotice(strings.pendingNotice)\n        : escapeHTML(item.consequence);\n      return (\n        `<tr class=\"consequence-row severity-${escapeHTML(item.severity)}\">` +\n        `<td class=\"action-cell\">${escapeHTML(item.action)}</td>` +\n        `<td class=\"arrow-cell\">→</td>` +\n        `<td class=\"consequence-cell\">${consequenceText}</td>` +\n        `<td class=\"severity-cell\"><span class=\"severity-badge severity-${escapeHTML(item.severity)}\">${escapeHTML(severityLabel(item.severity, strings))}</span></td>` +\n        `</tr>`\n      );\n    })\n    .join('\\n                ');\n  return `\n            <div class=\"analysis-consequences\">\n              <h3>${escapeHTML(heading)}</h3>\n              <table class=\"consequences-table\" role=\"table\">\n                <thead>\n                  <tr>\n                    <th scope=\"col\">${escapeHTML(labels.actionLabel)}</th>\n                    <th scope=\"col\" aria-hidden=\"true\"></th>\n                    <th scope=\"col\">${escapeHTML(labels.consequenceLabel)}</th>\n                    <th scope=\"col\">${escapeHTML(labels.severityColumnLabel)}</th>\n                  </tr>\n                </thead>\n                <tbody>\n                ${rows}\n                </tbody>\n              </table>\n            </div>`;\n}\n\n/**\n * Build the \"Mistakes\" sub-section\n *\n * @param mistakes - Political mistake assessments\n * @param heading - Localized heading\n * @param alternativeLabel - Localized \"should have\" label\n * @param pendingNotice - Localized pending notice text for AI markers\n * @returns HTML string\n */\nfunction buildMistakesSection(\n  mistakes: readonly PoliticalMistake[],\n  heading: string,\n  alternativeLabel: string,\n  pendingNotice: string\n): string {\n  if (mistakes.length === 0) return '';\n  const items = mistakes\n    .map((m) => {\n      const altText = isAiMarker(m.alternative)\n        ? aiPendingNotice(pendingNotice)\n        : escapeHTML(m.alternative);\n      return (\n        `<div class=\"mistake-card\">` +\n        `<p class=\"mistake-actor\"><strong>${escapeHTML(m.actor)}</strong></p>` +\n        `<p class=\"mistake-description\">${escapeHTML(m.description)}</p>` +\n        `<p class=\"mistake-alternative\"><em>${escapeHTML(alternativeLabel)}:</em> <span>${altText}</span></p>` +\n        `</div>`\n      );\n    })\n    .join('\\n              ');\n  return `\n            <div class=\"analysis-mistakes\">\n              <h3>${escapeHTML(heading)}</h3>\n              ${items}\n            </div>`;\n}\n\n/**\n * Build the \"Strategic Outlook\" sub-section\n *\n * @param outlook - Forward-looking analysis text\n * @param heading - Localized heading\n * @param pendingNotice - Localized pending notice text for AI markers\n * @returns HTML string\n */\nfunction buildOutlookSection(outlook: string, heading: string, pendingNotice: string): string {\n  if (!outlook) return '';\n  if (isAiMarker(outlook)) {\n    return `\n            <div class=\"analysis-outlook ${AI_PENDING_CLASS}\">\n              <h3>${escapeHTML(heading)}</h3>\n              <p>${aiPendingNotice(pendingNotice)}</p>\n            </div>`;\n  }\n  return `\n            <div class=\"analysis-outlook\">\n              <h3>${escapeHTML(heading)}</h3>\n              <p>${escapeHTML(outlook)}</p>\n            </div>`;\n}\n\n// ─── Enhanced analysis section builders ──────────────────────────────────────\n\n/**\n * Type guard — checks whether an analysis object carries enhanced fields\n *\n * @param a - Base deep analysis to test\n * @returns `true` when the object is an `EnhancedDeepAnalysis`\n */\nfunction isEnhancedDeepAnalysis(a: DeepAnalysis): a is EnhancedDeepAnalysis {\n  if (typeof a !== 'object' || a === null) return false;\n  return (\n    'qualityMetadata' in a ||\n    'scenarioPlanning' in a ||\n    'reasoningChains' in a ||\n    'executiveSummary' in a\n  );\n}\n\n/**\n * Build a confidence badge with emoji indicator and text label\n *\n * @param confidence - Confidence level\n * @param strings - Localized strings\n * @returns HTML span element\n */\nfunction buildConfidenceBadge(confidence: ConfidenceLevel, strings: DeepAnalysisStrings): string {\n  let emoji: string;\n  let label: string;\n  switch (confidence) {\n    case 'high':\n      emoji = '🟢';\n      label = strings.confidenceHigh;\n      break;\n    case 'medium':\n      emoji = '🟡';\n      label = strings.confidenceMedium;\n      break;\n    default:\n      emoji = '🔴';\n      label = strings.confidenceLow;\n  }\n  return `<span class=\"confidence-badge confidence-${escapeHTML(confidence)}\" aria-label=\"${escapeHTML(label)}\">${emoji} ${escapeHTML(label)}</span>`;\n}\n\n/**\n * Build the executive summary section\n *\n * @param summary - Executive summary text\n * @param confidence - Optional overall confidence level\n * @param heading - Localized heading\n * @param strings - Localized strings\n * @returns HTML string\n */\nfunction buildExecutiveSummarySection(\n  summary: string,\n  confidence: ConfidenceLevel | undefined,\n  heading: string,\n  strings: DeepAnalysisStrings\n): string {\n  if (!summary) return '';\n  const badge = confidence ? buildConfidenceBadge(confidence, strings) : '';\n  return `\n            <div class=\"analysis-executive-summary\">\n              <h3>${escapeHTML(heading)}</h3>\n              <div class=\"summary-header\">\n                <p>${escapeHTML(summary)}</p>${badge}\n              </div>\n            </div>`;\n}\n\n/**\n * Build the reasoning chains section\n *\n * @param chains - Reasoning chain items\n * @param heading - Localized heading\n * @param strings - Localized strings\n * @returns HTML string\n */\nfunction buildReasoningChainSection(\n  chains: readonly ReasoningChain[],\n  heading: string,\n  strings: DeepAnalysisStrings\n): string {\n  if (chains.length === 0) return '';\n  const cards = chains\n    .map((chain) => {\n      const evidenceItems = chain.evidence\n        .map((ref) => {\n          const dateText = ref.date ? ` (${escapeHTML(ref.date)})` : '';\n          if (ref.url && isSafeURL(ref.url)) {\n            return `<li><a href=\"${escapeHTML(ref.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">${escapeHTML(ref.title)}${dateText}</a></li>`;\n          }\n          return `<li>${escapeHTML(ref.title)}${dateText}</li>`;\n        })\n        .join('\\n                  ');\n      const evidenceHtml =\n        chain.evidence.length > 0\n          ? `<div class=\"evidence-refs-block\">\n                  <h4>${escapeHTML(strings.evidenceRefsHeading)}</h4>\n                  <ul class=\"evidence-refs\">\n                  ${evidenceItems}\n                  </ul>\n                </div>`\n          : '';\n\n      const counterItems = chain.counterArguments\n        .map((ca) => `<li>${escapeHTML(ca)}</li>`)\n        .join('\\n                  ');\n      const counterHtml =\n        chain.counterArguments.length > 0\n          ? `<div class=\"counter-args-block\">\n                  <h4>${escapeHTML(strings.counterArgumentsHeading)}</h4>\n                  <ul class=\"counter-arguments\">\n                  ${counterItems}\n                  </ul>\n                </div>`\n          : '';\n\n      return `<div class=\"reasoning-chain-card\">\n                <p><strong>${escapeHTML(strings.premiseLabel)}</strong> <span>${escapeHTML(chain.premise)}</span></p>\n                ${evidenceHtml}\n                <p><strong>${escapeHTML(strings.inferenceLabel)}</strong> <span>${escapeHTML(chain.inference)}</span></p>\n                ${buildConfidenceBadge(chain.confidence, strings)}\n                ${counterHtml}\n                <p class=\"chain-conclusion\"><strong>${escapeHTML(strings.conclusionLabel)}</strong> <span>${escapeHTML(chain.conclusion)}</span></p>\n              </div>`;\n    })\n    .join('\\n              ');\n  return `\n            <div class=\"analysis-reasoning-chains\">\n              <h3>${escapeHTML(heading)}</h3>\n              ${cards}\n            </div>`;\n}\n\n/**\n * Build the scenario planning section\n *\n * @param scenarios - Scenario planning data\n * @param heading - Localized heading\n * @param strings - Localized strings\n * @returns HTML string\n */\nfunction buildScenarioPlanningSection(\n  scenarios: ScenarioPlanning,\n  heading: string,\n  strings: DeepAnalysisStrings\n): string {\n  function renderScenario(\n    scenario: ScenarioPlanning['bestCase'],\n    cssClass: string,\n    label: string\n  ): string {\n    const rawPct = Number.isFinite(scenario.probability) ? scenario.probability * 100 : 0;\n    const pct = Math.max(0, Math.min(100, Math.round(rawPct)));\n    const triggerItems = scenario.triggers\n      .map((t) => `<li>${escapeHTML(t)}</li>`)\n      .join('\\n                  ');\n    const impactItems = scenario.implications\n      .map(\n        (imp) =>\n          `<li class=\"scenario-impact scenario-severity-${escapeHTML(imp.severity)}\">` +\n          `<strong>${escapeHTML(imp.stakeholder)}</strong>: <span>${escapeHTML(imp.impact)}</span>` +\n          `</li>`\n      )\n      .join('\\n                  ');\n    return `<div class=\"scenario-card ${escapeHTML(cssClass)}\">\n                <h4>${escapeHTML(label)}</h4>\n                <p>${escapeHTML(scenario.description)}</p>\n                <div class=\"scenario-probability\">\n                  <span>${escapeHTML(strings.probabilityLabel)}: ${pct}%</span>\n                  <div class=\"probability-bar\" style=\"width:${pct}%\" role=\"progressbar\" aria-valuenow=\"${pct}\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"${escapeHTML(label)} ${pct}%\"></div>\n                </div>\n                ${\n                  scenario.triggers.length > 0\n                    ? `<details class=\"scenario-triggers\">\n                  <summary>${escapeHTML(strings.triggersLabel)}</summary>\n                  <ul>${triggerItems}</ul>\n                </details>`\n                    : ''\n                }\n                ${\n                  scenario.implications.length > 0\n                    ? `<details class=\"scenario-impacts\">\n                  <summary>${escapeHTML(strings.impliedImpactsLabel)}</summary>\n                  <ul class=\"scenario-impact-list\">${impactItems}</ul>\n                </details>`\n                    : ''\n                }\n                <p class=\"scenario-timeline\"><strong>${escapeHTML(strings.timelineLabel)}:</strong> <span>${escapeHTML(scenario.timeline)}</span></p>\n              </div>`;\n  }\n\n  const wildcardItems = scenarios.wildcards\n    .map((w) => `<li>${escapeHTML(w)}</li>`)\n    .join('\\n              ');\n  const wildcardHtml =\n    scenarios.wildcards.length > 0\n      ? `<div class=\"scenario-wildcards\">\n              <h4>${escapeHTML(strings.wildcardsLabel)}</h4>\n              <ul class=\"wildcard-list\">${wildcardItems}</ul>\n            </div>`\n      : '';\n\n  return `\n            <div class=\"analysis-scenario-planning\">\n              <h3>${escapeHTML(heading)}</h3>\n              <div class=\"scenario-grid\">\n                ${renderScenario(scenarios.bestCase, 'scenario-best', strings.bestCaseLabel)}\n                ${renderScenario(scenarios.mostLikely, 'scenario-likely', strings.mostLikelyLabel)}\n                ${renderScenario(scenarios.worstCase, 'scenario-worst', strings.worstCaseLabel)}\n              </div>\n              ${wildcardHtml}\n            </div>`;\n}\n\n/**\n * Map iteration type to localized label\n *\n * @param type - Iteration type\n * @param strings - Localized strings\n * @returns Localized label\n */\nfunction iterationTypeLabel(\n  type: 'initial' | 'stakeholder_challenge' | 'evidence_validation' | 'synthesis',\n  strings: DeepAnalysisStrings\n): string {\n  switch (type) {\n    case 'initial':\n      return strings.iterationInitial;\n    case 'stakeholder_challenge':\n      return strings.iterationStakeholderChallenge;\n    case 'evidence_validation':\n      return strings.iterationEvidenceValidation;\n    default:\n      return strings.iterationSynthesis;\n  }\n}\n\n/**\n * Map evidence strength to localized label\n *\n * @param strength - Evidence strength\n * @param strings - Localized strings\n * @returns Localized label\n */\nfunction evidenceStrengthLabel(\n  strength: 'strong' | 'moderate' | 'weak',\n  strings: DeepAnalysisStrings\n): string {\n  switch (strength) {\n    case 'strong':\n      return strings.evidenceStrong;\n    case 'moderate':\n      return strings.evidenceModerate;\n    default:\n      return strings.evidenceWeak;\n  }\n}\n\n/**\n * Build the analysis methodology section\n *\n * @param metadata - Quality metadata\n * @param heading - Localized heading\n * @param strings - Localized strings\n * @returns HTML string\n */\nfunction buildAnalysisMethodologySection(\n  metadata: AnalysisQualityMetadata,\n  heading: string,\n  strings: DeepAnalysisStrings\n): string {\n  const iterationItems = metadata.iterations\n    .map((iter) => {\n      const findingItems = iter.findings\n        .map((f) => `<li>${escapeHTML(f)}</li>`)\n        .join('\\n                  ');\n      const refinementItems = iter.refinements\n        .map((r) => `<li>${escapeHTML(r)}</li>`)\n        .join('\\n                  ');\n      return `<div class=\"iteration-item\">\n                <div class=\"iteration-header\">\n                  <span class=\"iteration-pass\">Pass ${escapeHTML(String(Number.isFinite(iter.pass) ? iter.pass : 0))}</span>\n                  <span class=\"iteration-type\">${escapeHTML(iterationTypeLabel(iter.type, strings))}</span>\n                  ${buildConfidenceBadge(iter.confidence, strings)}\n                </div>\n                ${\n                  iter.findings.length > 0\n                    ? `<ul class=\"iteration-findings\">${findingItems}</ul>`\n                    : ''\n                }\n                ${\n                  iter.refinements.length > 0\n                    ? `<ul class=\"iteration-refinements\">${refinementItems}</ul>`\n                    : ''\n                }\n              </div>`;\n    })\n    .join('\\n              ');\n  return `\n            <div class=\"analysis-methodology\">\n              <h3>${escapeHTML(heading)}</h3>\n              <dl class=\"methodology-stats\">\n                <dt>${escapeHTML(strings.overallConfidenceLabel)}</dt>\n                <dd>${buildConfidenceBadge(metadata.overallConfidence, strings)}</dd>\n                <dt>${escapeHTML(strings.evidenceStrengthLabel)}</dt>\n                <dd>${escapeHTML(evidenceStrengthLabel(metadata.evidenceStrength, strings))}</dd>\n                <dt>${escapeHTML(strings.iterationCountLabel)}</dt>\n                <dd>${escapeHTML(String(Number.isFinite(metadata.iterationCount) ? metadata.iterationCount : 0))}</dd>\n              </dl>\n              ${metadata.iterations.length > 0 ? `<div class=\"iteration-timeline\">${iterationItems}</div>` : ''}\n            </div>`;\n}\n\n// ─── Main builder ────────────────────────────────────────────────────────────\n\n/**\n * Map a StakeholderPerspective impact to a CSS class suffix.\n *\n * @param impact - Stakeholder impact direction\n * @returns CSS class suffix\n */\nfunction perspectiveImpactClass(impact: StakeholderPerspective['impact']): string {\n  return `perspective-${impact}`;\n}\n\n/**\n * Map a stakeholder type to its localized display label.\n *\n * @param stakeholder - Internal stakeholder type identifier\n * @param strings - Localized label strings\n * @returns Localized stakeholder label\n */\nfunction localizedStakeholderLabel(\n  stakeholder: AnalysisStakeholderType,\n  strings: DeepAnalysisStrings\n): string {\n  const map: Record<AnalysisStakeholderType, string> = {\n    political_groups: strings.politicalGroupsLabel,\n    civil_society: strings.civilSocietyLabel,\n    industry: strings.industryLabel,\n    national_govts: strings.nationalGovtsLabel,\n    citizens: strings.citizensLabel,\n    eu_institutions: strings.euInstitutionsLabel,\n  };\n  return map[stakeholder];\n}\n\n/**\n * Map a stakeholder impact direction to its localized display label.\n *\n * @param impact - Impact direction value\n * @param strings - Localized label strings\n * @returns Localized impact label\n */\nfunction localizedImpactLabel(\n  impact: StakeholderPerspective['impact'],\n  strings: DeepAnalysisStrings\n): string {\n  const map: Record<StakeholderPerspective['impact'], string> = {\n    positive: strings.positiveLabel,\n    negative: strings.negativeLabel,\n    neutral: strings.neutralLabel,\n    mixed: strings.mixedLabel,\n  };\n  return map[impact];\n}\n\n/**\n * Map a severity level to its localized display label.\n *\n * @param severity - Severity level value\n * @param strings - Localized label strings\n * @returns Localized severity label\n */\nfunction localizedSeverityLabel(\n  severity: StakeholderPerspective['severity'],\n  strings: DeepAnalysisStrings\n): string {\n  const map: Record<StakeholderPerspective['severity'], string> = {\n    high: strings.severityHigh,\n    medium: strings.severityMedium,\n    low: strings.severityLow,\n  };\n  return map[severity];\n}\n\n/**\n * Map an outcome value to its localized display label.\n *\n * @param outcome - Outcome value (winner/loser/neutral)\n * @param strings - Localized label strings\n * @returns Localized outcome label\n */\nfunction localizedOutcomeLabel(outcome: string, strings: DeepAnalysisStrings): string {\n  const map: Record<string, string> = {\n    winner: strings.winnerLabel,\n    loser: strings.loserLabel,\n    neutral: strings.neutralLabel,\n  };\n  return map[outcome] ?? outcome;\n}\n\n/**\n * Build the \"Multi-Stakeholder Perspectives\" sub-section.\n * Renders a card grid with one card per stakeholder group showing\n * impact direction, severity, reasoning, and evidence.\n *\n * @param perspectives - Array of stakeholder perspectives\n * @param heading - Localized section heading\n * @param strings - Localized label strings for stakeholder names, impact, and severity\n * @returns HTML string, or empty string if no perspectives provided\n */\nfunction buildStakeholderPerspectivesSection(\n  perspectives: readonly StakeholderPerspective[] | undefined,\n  heading: string,\n  strings: DeepAnalysisStrings\n): string {\n  if (!perspectives || perspectives.length === 0) return '';\n  const cards = perspectives\n    .map((p) => {\n      const evidenceItems = p.evidence.map((e) => `<li>${escapeHTML(e)}</li>`).join('');\n      const evidenceHtml = evidenceItems\n        ? `<ul class=\"perspective-evidence\">${evidenceItems}</ul>`\n        : '';\n      return (\n        `<div class=\"stakeholder-perspective-card ${escapeHTML(perspectiveImpactClass(p.impact))} severity-${escapeHTML(p.severity)}\">` +\n        `<div class=\"perspective-header\">` +\n        `<span class=\"perspective-stakeholder\">${escapeHTML(localizedStakeholderLabel(p.stakeholder, strings))}</span>` +\n        `<span class=\"perspective-impact-badge perspective-impact-${escapeHTML(p.impact)}\">${escapeHTML(localizedImpactLabel(p.impact, strings))}</span>` +\n        `<span class=\"perspective-severity-badge severity-${escapeHTML(p.severity)}\">${escapeHTML(localizedSeverityLabel(p.severity, strings))}</span>` +\n        `</div>` +\n        `<p class=\"perspective-reasoning\">${isAiMarker(p.reasoning) ? aiPendingNotice(strings.pendingNotice) : escapeHTML(p.reasoning)}</p>` +\n        evidenceHtml +\n        `</div>`\n      );\n    })\n    .join('\\n              ');\n  return `\n            <div class=\"analysis-stakeholder-perspectives\">\n              <h3>${escapeHTML(heading)}</h3>\n              <div class=\"stakeholder-perspectives-grid\">\n              ${cards}\n              </div>\n            </div>`;\n}\n\n/**\n * Build the \"Stakeholder Outcome Matrix\" sub-section.\n * Renders an accessible table mapping each action to winner/loser/neutral\n * outcomes per stakeholder group.\n *\n * @param matrix - Array of stakeholder outcome matrix rows\n * @param heading - Localized section heading\n * @param strings - Localized label strings for columns and stakeholder groups\n * @returns HTML string, or empty string if no matrix rows provided\n */\nfunction buildStakeholderOutcomeMatrixSection(\n  matrix: readonly StakeholderOutcomeMatrix[] | undefined,\n  heading: string,\n  strings: DeepAnalysisStrings\n): string {\n  if (!matrix || matrix.length === 0) return '';\n\n  const headerCells = ALL_STAKEHOLDER_TYPES.map(\n    (s) => `<th scope=\"col\">${escapeHTML(localizedStakeholderLabel(s, strings))}</th>`\n  ).join('');\n\n  const rows = matrix\n    .map((row) => {\n      const cells = ALL_STAKEHOLDER_TYPES.map((s) => {\n        // eslint-disable-next-line security/detect-object-injection -- key from const array\n        const outcome = row.outcomes[s];\n        return `<td class=\"matrix-cell outcome-${escapeHTML(outcome)}\">${escapeHTML(localizedOutcomeLabel(outcome, strings))}</td>`;\n      }).join('');\n      return (\n        `<tr>` +\n        `<th scope=\"row\" class=\"matrix-action\">${escapeHTML(row.action)}</th>` +\n        `<td class=\"matrix-confidence confidence-${escapeHTML(row.confidence)}\">${escapeHTML(localizedSeverityLabel(row.confidence, strings))}</td>` +\n        cells +\n        `</tr>`\n      );\n    })\n    .join('\\n                ');\n\n  return `\n            <div class=\"analysis-outcome-matrix\">\n              <h3>${escapeHTML(heading)}</h3>\n              <div class=\"outcome-matrix-scroll\">\n              <table class=\"outcome-matrix-table\" role=\"table\">\n                <thead>\n                  <tr>\n                    <th scope=\"col\">${escapeHTML(strings.actionLabel)}</th>\n                    <th scope=\"col\">${escapeHTML(strings.confidenceLabel)}</th>\n                    ${headerCells}\n                  </tr>\n                </thead>\n                <tbody>\n                ${rows}\n                </tbody>\n              </table>\n              </div>\n            </div>`;\n}\n\n/**\n * Build the complete deep political analysis section HTML.\n *\n * This section provides parliament-intelligence-grade analysis using the\n * \"5W + Impact\" framework. When the input is an `EnhancedDeepAnalysis` it\n * additionally renders an executive summary, reasoning chains, scenario\n * planning, and analysis methodology.\n *\n * Returns an empty string if the analysis object is null/undefined or\n * contains no meaningful content.\n *\n * @param analysis - Deep analysis data (null/undefined returns empty string).\n *   Accepts both `DeepAnalysis` and `EnhancedDeepAnalysis`.\n * @param lang - BCP 47 language code for localized headings\n *   differs from `lang`, each content element gets a `lang` attribute so\n *   screen readers and translation tools handle the language switch correctly.\n *   Defaults to `lang` (no extra attributes added).\n * @returns HTML section string or empty string\n */\nexport function buildDeepAnalysisSection(\n  analysis: DeepAnalysis | EnhancedDeepAnalysis | null | undefined,\n  lang: string\n): string {\n  if (!analysis) return '';\n\n  const strings = getLocalizedString(DEEP_ANALYSIS_STRINGS, lang);\n\n  // ─── Enhanced sections (before/after base sections) ────────────────────\n  let executiveSummaryHtml = '';\n  let reasoningChainsHtml = '';\n  let scenarioPlanningHtml = '';\n  let methodologyHtml = '';\n\n  if (isEnhancedDeepAnalysis(analysis)) {\n    if (analysis.executiveSummary) {\n      executiveSummaryHtml = buildExecutiveSummarySection(\n        analysis.executiveSummary,\n        analysis.qualityMetadata?.overallConfidence,\n        strings.executiveSummaryHeading,\n        strings\n      );\n    }\n    if (analysis.reasoningChains && analysis.reasoningChains.length > 0) {\n      reasoningChainsHtml = buildReasoningChainSection(\n        analysis.reasoningChains,\n        strings.reasoningChainsHeading,\n        strings\n      );\n    }\n    if (analysis.scenarioPlanning) {\n      scenarioPlanningHtml = buildScenarioPlanningSection(\n        analysis.scenarioPlanning,\n        strings.scenarioPlanningHeading,\n        strings\n      );\n    }\n    if (analysis.qualityMetadata) {\n      methodologyHtml = buildAnalysisMethodologySection(\n        analysis.qualityMetadata,\n        strings.analysisMethodologyHeading,\n        strings\n      );\n    }\n  }\n\n  // ─── Base \"5W + Impact\" sections ───────────────────────────────────────\n  const whatHtml = buildWhatSection(analysis.what, strings.whatHeading);\n  const whoHtml = buildWhoSection(analysis.who, strings.whoHeading);\n  const whenHtml = buildWhenSection(analysis.when, strings.whenHeading);\n  const whyHtml = buildWhySection(analysis.why, strings.whyHeading, strings.pendingNotice);\n  const stakeholderHtml = buildStakeholderSection(\n    analysis.stakeholderOutcomes,\n    strings.stakeholderHeading,\n    strings\n  );\n  const impactHtml = buildImpactSection(analysis.impactAssessment, strings.impactHeading, strings);\n  const consequencesHtml = buildConsequencesSection(\n    analysis.actionConsequences,\n    strings.consequencesHeading,\n    strings,\n    strings\n  );\n  const mistakesHtml = buildMistakesSection(\n    analysis.mistakes,\n    strings.mistakesHeading,\n    strings.alternativeLabel,\n    strings.pendingNotice\n  );\n  const outlookHtml = buildOutlookSection(\n    analysis.outlook,\n    strings.outlookHeading,\n    strings.pendingNotice\n  );\n  const perspectivesHtml = buildStakeholderPerspectivesSection(\n    analysis.stakeholderPerspectives,\n    strings.perspectivesHeading,\n    strings\n  );\n  const outcomeMatrixHtml = buildStakeholderOutcomeMatrixSection(\n    analysis.stakeholderOutcomeMatrix,\n    strings.outcomeMatrixHeading,\n    strings\n  );\n\n  const innerContent =\n    executiveSummaryHtml +\n    whatHtml +\n    whoHtml +\n    whenHtml +\n    whyHtml +\n    reasoningChainsHtml +\n    stakeholderHtml +\n    impactHtml +\n    consequencesHtml +\n    mistakesHtml +\n    outlookHtml +\n    scenarioPlanningHtml +\n    perspectivesHtml +\n    outcomeMatrixHtml +\n    methodologyHtml;\n\n  // If all sub-sections are empty, return nothing\n  if (!innerContent.trim()) return '';\n\n  return `\n          <section class=\"deep-analysis\" lang=\"${escapeHTML(lang)}\">\n            <h2>${escapeHTML(strings.sectionHeading)}</h2>\n            ${innerContent}\n          </section>`;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/mindmap-content.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":246,"column":19,"endLine":246,"endColumn":45},{"ruleId":"@typescript-eslint/prefer-nullish-coalescing","severity":1,"message":"Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.","line":463,"column":21,"messageId":"preferNullishOverOr","endLine":463,"endColumn":23,"suggestions":[{"messageId":"suggestNullish","data":{"equals":""},"fix":{"range":[16750,16804],"text":"(heading?.trim() ?? INTELLIGENCE_MINDMAP_HEADINGS[lang])"},"desc":"Fix to nullish coalescing operator (`??`)."}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":463,"column":24,"endLine":463,"endColumn":59},{"ruleId":"@typescript-eslint/prefer-nullish-coalescing","severity":1,"message":"Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.","line":463,"column":60,"messageId":"preferNullishOverOr","endLine":463,"endColumn":62,"suggestions":[{"messageId":"suggestNullish","data":{"equals":""},"fix":{"range":[16805,16807],"text":"??"},"desc":"Fix to nullish coalescing operator (`??`)."}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":464,"column":24,"endLine":464,"endColumn":44},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":465,"column":28,"endLine":465,"endColumn":65},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":466,"column":28,"endLine":466,"endColumn":59},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":467,"column":29,"endLine":467,"endColumn":55},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":468,"column":30,"endLine":468,"endColumn":57},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":469,"column":26,"endLine":469,"endColumn":48},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":470,"column":28,"endLine":470,"endColumn":52}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":11,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * Generates intelligence mindmap HTML sections using pure CSS —\n * no JavaScript or third-party libraries required.\n *\n * Provides `buildIntelligenceMindmapSection` — a multi-layer policy domain\n * intelligence map with actor-network nodes, influence-weighted nodes\n * (via CSS `--node-influence` custom property), policy connection indicators,\n * and stakeholder perspective overlays using `<details>/<summary>` elements\n * (CSS-only toggle, no JavaScript).\n *\n * Also re-exports the `MindmapBranchColor` type from the types barrel for\n * backward compatibility with consumers that imported it from this module.\n *\n * Produces WCAG 2.1 AA compliant HTML with appropriate ARIA roles, labels,\n * and heading levels.\n *\n * @module Generators/MindmapContent\n */\n\nimport { escapeHTML } from '../utils/file-utils.js';\nimport type { IntelligenceMindmap, MindmapBranchColor, MindmapNode } from '../types/index.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n// Re-export the canonical MindmapBranchColor from the types barrel so that\n// existing consumers importing it from this module continue to work.\nexport type { MindmapBranchColor } from '../types/index.js';\n\n// ---------------------------------------------------------------------------\n// Color palette (mapped to CSS custom properties on each branch element)\n// ---------------------------------------------------------------------------\n\nconst BRANCH_PALETTE: Readonly<\n  Record<MindmapBranchColor, { bg: string; border: string; text: string }>\n> = {\n  cyan: { bg: '#e3f2fd', border: '#1565c0', text: '#1565c0' },\n  magenta: { bg: '#fce4ec', border: '#c62828', text: '#c62828' },\n  yellow: { bg: '#fff8e1', border: '#f57f17', text: '#f57f17' },\n  green: { bg: '#e8f5e9', border: '#2e7d32', text: '#2e7d32' },\n  purple: { bg: '#f3e5f5', border: '#7b1fa2', text: '#7b1fa2' },\n  orange: { bg: '#fff3e0', border: '#e65100', text: '#e65100' },\n  blue: { bg: '#e8eaf6', border: '#283593', text: '#283593' },\n  red: { bg: '#ffebee', border: '#b71c1c', text: '#b71c1c' },\n};\n\n/** Color mapping for intelligence mindmap node categories. */\nconst CATEGORY_PALETTE: Readonly<Record<string, { bg: string; border: string; text: string }>> = {\n  policy_domain: { bg: '#e3f2fd', border: '#1565c0', text: '#1565c0' },\n  sub_topic: { bg: '#f3e5f5', border: '#7b1fa2', text: '#7b1fa2' },\n  actor: { bg: '#e8f5e9', border: '#2e7d32', text: '#2e7d32' },\n  action: { bg: '#fff3e0', border: '#e65100', text: '#e65100' },\n  outcome: { bg: '#e8eaf6', border: '#283593', text: '#283593' },\n};\n\n/** Icon/emoji mapping for actor types within intelligence mindmaps. */\nconst ACTOR_TYPE_LABELS: Readonly<Record<string, string>> = {\n  mep: '👤',\n  group: '🏛️',\n  committee: '📋',\n  external: '🌐',\n};\n\n// ---------------------------------------------------------------------------\n// Section heading labels (14 languages)\n// ---------------------------------------------------------------------------\n\nconst INTELLIGENCE_MINDMAP_HEADINGS: Readonly<Record<string, string>> = {\n  en: 'Intelligence Policy Map',\n  sv: 'Underrättelsebaserad policykarta',\n  da: 'Intelligensbaseret politikkort',\n  no: 'Etterretningsbasert politikkart',\n  fi: 'Tiedustelupohjainen politiikkakartta',\n  de: 'Intelligenz-Politikkarte',\n  fr: 'Carte de renseignement politique',\n  es: 'Mapa de inteligencia política',\n  nl: 'Intelligentie beleidskaart',\n  ar: 'خريطة الاستخبارات السياسية',\n  he: 'מפת מודיעין מדיני',\n  ja: '政策インテリジェンスマップ',\n  ko: '정책 인텔리전스 맵',\n  zh: '政策情报图谱',\n};\n\nconst STAKEHOLDER_PERSPECTIVES_LABELS: Readonly<Record<string, string>> = {\n  en: 'Stakeholder Perspectives',\n  sv: 'Intressentperspektiv',\n  da: 'Interessentperspektiver',\n  no: 'Interessentperspektiver',\n  fi: 'Sidosryhmänäkökulmat',\n  de: 'Interessengruppen-Perspektiven',\n  fr: 'Perspectives des parties prenantes',\n  es: 'Perspectivas de las partes interesadas',\n  nl: 'Perspectieven van belanghebbenden',\n  ar: 'وجهات نظر أصحاب المصلحة',\n  he: 'נקודות מבט של בעלי עניין',\n  ja: 'ステークホルダーの視点',\n  ko: '이해관계자 관점',\n  zh: '利益相关者视角',\n};\n\nconst POLICY_CONNECTIONS_LABELS: Readonly<Record<string, string>> = {\n  en: 'Policy Connections',\n  sv: 'Policyförbindelser',\n  da: 'Politikforbindelser',\n  no: 'Politikkforbindelser',\n  fi: 'Politiikkayhteydet',\n  de: 'Politikverbindungen',\n  fr: 'Connexions politiques',\n  es: 'Conexiones políticas',\n  nl: 'Beleidsverbindingen',\n  ar: 'الروابط السياسية',\n  he: 'קשרים מדיניים',\n  ja: '政策的つながり',\n  ko: '정책 연결',\n  zh: '政策关联',\n};\n\nconst ACTOR_NETWORK_LABELS: Readonly<Record<string, string>> = {\n  en: 'Actor Network',\n  sv: 'Aktörsnätverk',\n  da: 'Aktørnetværk',\n  no: 'Aktørnettverk',\n  fi: 'Toimijaverkosto',\n  de: 'Akteursnetzwerk',\n  fr: \"Réseau d'acteurs\",\n  es: 'Red de actores',\n  nl: 'Actornetwerk',\n  ar: 'شبكة الجهات الفاعلة',\n  he: 'רשת גורמים',\n  ja: 'アクターネットワーク',\n  ko: '행위자 네트워크',\n  zh: '行动者网络',\n};\n\nconst POLICY_DOMAINS_LABELS: Readonly<Record<string, string>> = {\n  en: 'Policy Domains',\n  sv: 'Policyområden',\n  da: 'Politikområder',\n  no: 'Politikkområder',\n  fi: 'Politiikan alat',\n  de: 'Politikbereiche',\n  fr: 'Domaines politiques',\n  es: 'Ámbitos políticos',\n  nl: 'Beleidsdomeinen',\n  ar: 'مجالات السياسة',\n  he: 'תחומי מדיניות',\n  ja: '政策分野',\n  ko: '정책 분야',\n  zh: '政策领域',\n};\n\nconst INFLUENCE_LABELS: Readonly<Record<string, string>> = {\n  en: 'Influence',\n  sv: 'Inflytande',\n  da: 'Indflydelse',\n  no: 'Innflytelse',\n  fi: 'Vaikutusvalta',\n  de: 'Einfluss',\n  fr: 'Influence',\n  es: 'Influencia',\n  nl: 'Invloed',\n  ar: 'التأثير',\n  he: 'השפעה',\n  ja: '影響力',\n  ko: '영향력',\n  zh: '影响力',\n};\n\nconst PERSPECTIVE_LABELS: Readonly<Record<string, string>> = {\n  en: 'perspective',\n  sv: 'perspektiv',\n  da: 'perspektiv',\n  no: 'perspektiv',\n  fi: 'näkökulma',\n  de: 'Perspektive',\n  fr: 'perspective',\n  es: 'perspectiva',\n  nl: 'perspectief',\n  ar: 'منظور',\n  he: 'נקודת מבט',\n  ja: '視点',\n  ko: '관점',\n  zh: '视角',\n};\n\nconst DETAILS_LABELS: Readonly<Record<string, string>> = {\n  en: 'Details',\n  sv: 'Detaljer',\n  da: 'Detaljer',\n  no: 'Detaljer',\n  fi: 'Yksityiskohdat',\n  de: 'Details',\n  fr: 'Détails',\n  es: 'Detalles',\n  nl: 'Details',\n  ar: 'التفاصيل',\n  he: 'פרטים',\n  ja: '詳細',\n  ko: '세부 정보',\n  zh: '详情',\n};\n\n// ---------------------------------------------------------------------------\n// Rendering helpers — intelligence mindmap\n// ---------------------------------------------------------------------------\n\n/**\n * Recursively count all nodes in the tree, including nested children.\n *\n * @param nodes - Root-level nodes to count\n * @returns Total node count including all descendants\n */\nfunction countNodesRecursive(nodes: readonly MindmapNode[]): number {\n  let count = nodes.length;\n  for (const node of nodes) {\n    if (node.children.length > 0) {\n      count += countNodesRecursive(node.children);\n    }\n  }\n  return count;\n}\n\n/**\n * Get color palette entry for a mindmap node.\n * Checks the node's `color` field against `BRANCH_PALETTE` first (allowing\n * builders to override category-based colors), then falls back to the\n * category palette, and finally to the default `policy_domain` palette.\n *\n * @param category - Node category string key\n * @param color - Optional semantic color key from the branch palette\n * @returns Color palette object with bg, border, and text properties\n */\nfunction getNodePalette(\n  category: string,\n  color?: string\n): { bg: string; border: string; text: string } {\n  if (color && Object.hasOwn(BRANCH_PALETTE, color)) {\n    return BRANCH_PALETTE[color as MindmapBranchColor];\n  }\n  if (Object.hasOwn(CATEGORY_PALETTE, category)) {\n    const entry = CATEGORY_PALETTE[category];\n    if (entry) return entry;\n  }\n  return { bg: '#e3f2fd', border: '#1565c0', text: '#1565c0' };\n}\n\n/**\n * Clamp influence value to valid 0–1 range.\n *\n * @param value - Influence value to clamp\n * @returns Clamped value between 0 and 1 inclusive\n */\nfunction clampInfluence(value: number): number {\n  return Math.max(0, Math.min(1, value));\n}\n\n/**\n * Render a single intelligence mindmap node as HTML.\n * Recursively renders children as nested sub-nodes.\n *\n * @param node - The mindmap node to render\n * @param depth - Current depth in the hierarchy (1 = domain layer)\n * @param detailsLabel - Localized label for the child details toggle\n * @param influenceLabel - Localized label for influence meter\n * @returns HTML string for this node and its children\n */\nfunction renderIntelligenceNode(\n  node: MindmapNode,\n  depth: number,\n  detailsLabel: string,\n  influenceLabel: string\n): string {\n  const palette = getNodePalette(node.category, node.color);\n  const influence = clampInfluence(node.influence);\n  const influencePct = (influence * 100).toFixed(0);\n  const metaCommittee = node.metadata?.committee\n    ? ` data-committee=\"${escapeHTML(node.metadata.committee)}\"`\n    : '';\n  const metaGroup = node.metadata?.politicalGroup\n    ? ` data-group=\"${escapeHTML(node.metadata.politicalGroup)}\"`\n    : '';\n  const metaDoc = node.metadata?.documentRef\n    ? ` data-doc=\"${escapeHTML(node.metadata.documentRef)}\"`\n    : '';\n\n  const childrenHtml =\n    node.children.length > 0\n      ? `\\n        <details class=\"mindmap-actor-overlay\" aria-label=\"${escapeHTML(detailsLabel)}: ${escapeHTML(node.label)}\">\\n          <summary class=\"mindmap-actor-toggle\">${escapeHTML(detailsLabel)}</summary>\\n          <ul class=\"mindmap-subnodes mindmap-layer-${depth + 1}\" role=\"list\">\\n${node.children\n          .map(\n            (child) =>\n              `            <li>${renderIntelligenceNode(child, depth + 1, detailsLabel, influenceLabel)}</li>`\n          )\n          .join('\\n')}\\n          </ul>\\n        </details>`\n      : '';\n\n  return `<div class=\"mindmap-intel-node mindmap-node-${escapeHTML(node.category)}\"\n        data-node-id=\"${escapeHTML(node.id)}\" data-influence=\"${influencePct}\"\n        style=\"--branch-bg:${palette.bg};--branch-border:${palette.border};--branch-text:${palette.text};--node-influence:${influence.toFixed(2)}\"${metaCommittee}${metaGroup}${metaDoc}\n        aria-label=\"${escapeHTML(node.label)} (${escapeHTML(influenceLabel)}: ${influencePct}%)\">\n        <div class=\"mindmap-intel-label\">${escapeHTML(node.label)}</div>\n        <div class=\"mindmap-influence-bar\" role=\"meter\" aria-valuenow=\"${influencePct}\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"${escapeHTML(influenceLabel)}: ${influencePct}%\">\n          <div class=\"mindmap-influence-fill\" style=\"width:${influencePct}%\"></div>\n        </div>${childrenHtml}\n      </div>`;\n}\n\n/**\n * Render the connections section as a `<details>` overlay panel.\n * Each connection is rendered with strength and type indicators.\n * Connection endpoints may reference either layer node IDs or actorNetwork\n * IDs; when a `nodeLabels` map is provided, IDs are resolved to\n * human-readable labels for display.\n *\n * @param connections - Policy connections to render\n * @param label - Localized heading label for the toggle\n * @param nodeLabels - Optional ID → label map for resolving endpoint names\n * @returns HTML string for the connections overlay, or empty string\n */\nfunction renderConnectionsOverlay(\n  connections: IntelligenceMindmap['connections'],\n  label: string,\n  nodeLabels?: ReadonlyMap<string, string>\n): string {\n  if (connections.length === 0) return '';\n\n  const resolveName = (id: string): string => nodeLabels?.get(id) ?? id;\n\n  const items = connections\n    .map(\n      (c) =>\n        `      <li class=\"mindmap-connection mindmap-connection-${escapeHTML(c.strength)} mindmap-connection-type-${escapeHTML(c.type)}\"\n         aria-label=\"${escapeHTML(resolveName(c.from))} → ${escapeHTML(resolveName(c.to))}: ${escapeHTML(c.type)} (${escapeHTML(c.strength)}) — ${escapeHTML(c.evidence)}\">\n        <span class=\"connection-from\">${escapeHTML(resolveName(c.from))}</span>\n        <span class=\"connection-arrow\" aria-hidden=\"true\"> → </span>\n        <span class=\"connection-to\">${escapeHTML(resolveName(c.to))}</span>\n        <span class=\"connection-meta\">[${escapeHTML(c.type)}, ${escapeHTML(c.strength)}]</span>\n        <span class=\"connection-evidence\">${escapeHTML(c.evidence)}</span>\n      </li>`\n    )\n    .join('\\n');\n\n  return `  <details class=\"mindmap-connections-overlay\">\n    <summary class=\"mindmap-connections-toggle\">${escapeHTML(label)}</summary>\n    <ul class=\"mindmap-connections-list\" role=\"list\">\n${items}\n    </ul>\n  </details>`;\n}\n\n/**\n * Render the actor network section as a `<details>` overlay panel.\n *\n * @param actorNetwork - Actor nodes to render in the network panel\n * @param label - Localized heading label for the toggle\n * @param influenceLabel - Localized label for influence meter\n * @returns HTML string for the actor network overlay, or empty string\n */\nfunction renderActorNetworkOverlay(\n  actorNetwork: IntelligenceMindmap['actorNetwork'],\n  label: string,\n  influenceLabel: string\n): string {\n  if (actorNetwork.length === 0) return '';\n\n  const items = actorNetwork\n    .map((actor) => {\n      const typeIcon = ACTOR_TYPE_LABELS[actor.type] ?? '•';\n      const influence = clampInfluence(actor.influence);\n      const influencePct = (influence * 100).toFixed(0);\n      return `      <li class=\"mindmap-actor mindmap-actor-${escapeHTML(actor.type)}\"\n         data-actor-id=\"${escapeHTML(actor.id)}\"\n         style=\"--node-influence:${influence.toFixed(2)}\"\n         aria-label=\"${escapeHTML(actor.name)} (${escapeHTML(actor.type)}, ${escapeHTML(influenceLabel)}: ${influencePct}%)\">\n        <span class=\"actor-icon\" aria-hidden=\"true\">${escapeHTML(typeIcon)}</span>\n        <span class=\"actor-name\">${escapeHTML(actor.name)}</span>\n        <span class=\"actor-type-badge\">${escapeHTML(actor.type)}</span>\n        <div class=\"mindmap-influence-bar\" role=\"meter\" aria-valuenow=\"${influencePct}\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"${escapeHTML(influenceLabel)}: ${influencePct}%\">\n          <div class=\"mindmap-influence-fill\" style=\"width:${influencePct}%\"></div>\n        </div>\n      </li>`;\n    })\n    .join('\\n');\n\n  return `  <details class=\"mindmap-actor-network-overlay\">\n    <summary class=\"mindmap-actor-network-toggle\">${escapeHTML(label)}</summary>\n    <ul class=\"mindmap-actor-network-list\" role=\"list\">\n${items}\n    </ul>\n  </details>`;\n}\n\n/**\n * Render stakeholder perspective overlays as `<details>` panels.\n *\n * @param groups - Stakeholder group labels to render as panels\n * @param label - Localized heading label for the outer toggle\n * @param perspectiveLabel - Localized suffix for stakeholder perspective aria-label\n * @returns HTML string for the stakeholder overlay, or empty string\n */\nfunction renderStakeholderOverlays(\n  groups: readonly string[] | undefined,\n  label: string,\n  perspectiveLabel: string\n): string {\n  if (!groups || groups.length === 0) return '';\n\n  const panels = groups\n    .map(\n      (g) =>\n        `      <details class=\"mindmap-stakeholder-panel\" aria-label=\"${escapeHTML(g)} ${escapeHTML(perspectiveLabel)}\">\n        <summary>${escapeHTML(g)}</summary>\n        <p class=\"mindmap-stakeholder-desc\">${escapeHTML(g)}</p>\n      </details>`\n    )\n    .join('\\n');\n\n  return `  <details class=\"mindmap-stakeholder-overlay\">\n    <summary class=\"mindmap-stakeholder-toggle\">${escapeHTML(label)}</summary>\n    <div class=\"mindmap-stakeholder-panels\">\n${panels}\n    </div>\n  </details>`;\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a multi-layer intelligence mindmap section as an HTML string.\n *\n * Renders a hierarchical policy domain intelligence map with:\n * - Influence-weighted nodes (CSS `--node-influence` custom property)\n * - Actor-network visualization via `<details>` child overlays\n * - Policy connection indicators in a collapsible `<details>` panel\n * - Stakeholder perspective overlays via `<details>/<summary>` (no JS)\n * - WCAG 2.1 AA accessible roles, labels, and meter elements\n *\n * Returns an empty string when `imap` is null/undefined or has no layers\n * with nodes.\n *\n * @param imap - Intelligence mindmap data.\n * @param lang - BCP 47 language code for section headings.\n * @param heading - Optional heading override.\n * @returns HTML string for the intelligence mindmap section, or empty string.\n */\nexport function buildIntelligenceMindmapSection(\n  imap: IntelligenceMindmap | null | undefined,\n  lang: string = 'en',\n  heading?: string\n): string {\n  if (!imap) return '';\n\n  const allNodes = imap.layers.flatMap((l) => l.nodes);\n  if (allNodes.length === 0) return '';\n\n  const titleText: string =\n    heading?.trim() || INTELLIGENCE_MINDMAP_HEADINGS[lang] || 'Intelligence Policy Map';\n  const detailsLabel = DETAILS_LABELS[lang] ?? 'Details';\n  const stakeholderLabel = STAKEHOLDER_PERSPECTIVES_LABELS[lang] ?? 'Stakeholder Perspectives';\n  const connectionsLabel = POLICY_CONNECTIONS_LABELS[lang] ?? 'Policy Connections';\n  const actorNetworkLabel = ACTOR_NETWORK_LABELS[lang] ?? 'Actor Network';\n  const policyDomainsLabel = POLICY_DOMAINS_LABELS[lang] ?? 'Policy Domains';\n  const influenceLabel = INFLUENCE_LABELS[lang] ?? 'Influence';\n  const perspectiveLabel = PERSPECTIVE_LABELS[lang] ?? 'perspective';\n\n  const summaryBlock = imap.summary?.trim()\n    ? `  <p class=\"mindmap-summary\">${escapeHTML(imap.summary.trim())}</p>\\n`\n    : '';\n\n  // Render domain layer (depth 1 nodes as primary branches); fall back to\n  // allNodes when the depth-1 layer is missing *or* empty.\n  // Deeper hierarchy (depth 2–4) is expressed via MindmapNode.children and\n  // rendered recursively by renderIntelligenceNode — separate layer entries\n  // at depth > 1 are used only for label resolution, not for direct rendering.\n  const depth1Nodes = imap.layers.find((l) => l.depth === 1)?.nodes ?? [];\n  const domainNodes = depth1Nodes.length > 0 ? depth1Nodes : allNodes;\n  const domainItems = domainNodes\n    .map(\n      (node) => `      <li>${renderIntelligenceNode(node, 1, detailsLabel, influenceLabel)}</li>`\n    )\n    .join('\\n');\n\n  // Build label lookup for connection endpoint resolution.\n  // This allows connections referencing actorNetwork IDs to display\n  // human-readable names instead of raw IDs.  We index *all* layer nodes\n  // (including depth > 1) so that connections referencing deeper nodes still\n  // resolve to human-readable names even though those nodes are rendered via\n  // children rather than as standalone layer entries.\n  const nodeLabels = new Map<string, string>();\n  const addLabels = (nodes: readonly MindmapNode[]): void => {\n    for (const n of nodes) {\n      nodeLabels.set(n.id, n.label);\n      if (n.children.length > 0) addLabels(n.children);\n    }\n  };\n  addLabels(allNodes);\n  for (const actor of imap.actorNetwork) {\n    nodeLabels.set(actor.id, actor.name);\n  }\n\n  const connectionsHtml = renderConnectionsOverlay(imap.connections, connectionsLabel, nodeLabels);\n  const actorNetworkHtml = renderActorNetworkOverlay(\n    imap.actorNetwork,\n    actorNetworkLabel,\n    influenceLabel\n  );\n  const stakeholderHtml = renderStakeholderOverlays(\n    imap.stakeholderGroups,\n    stakeholderLabel,\n    perspectiveLabel\n  );\n\n  // Count from the rendered tree (domainNodes + their children recursively)\n  // rather than allNodes, so data-total-nodes reflects only what the renderer\n  // actually outputs.\n  const totalNodes = countNodesRecursive(domainNodes);\n  const totalConnections = imap.connections.length;\n  const totalActors = imap.actorNetwork.length;\n\n  return `<section class=\"mindmap-section intelligence-mindmap\" role=\"region\" aria-label=\"${escapeHTML(titleText)}\">\n  <h2>${escapeHTML(titleText)}</h2>\n${summaryBlock}  <div class=\"mindmap-container intelligence-map\" data-branch-count=\"${domainNodes.length}\" data-total-nodes=\"${totalNodes}\" data-connections=\"${totalConnections}\" data-actors=\"${totalActors}\">\n    <div class=\"mindmap-center\" role=\"heading\" aria-level=\"3\">${escapeHTML(imap.centralTopic)}</div>\n    <ul class=\"mindmap-branches mindmap-layer-1\" role=\"list\" aria-label=\"${escapeHTML(policyDomainsLabel)}\">\n${domainItems}\n    </ul>\n${connectionsHtml}\n${actorNetworkHtml}\n${stakeholderHtml}  </div>\n</section>`;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/motions-content.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":416,"column":5,"endLine":416,"endColumn":37},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":418,"column":5,"endLine":418,"endColumn":42},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":423,"column":5,"endLine":423,"endColumn":49}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/MotionsContent\n * @description Pure functions for building motions article HTML and\n * generating placeholder/fallback data when MCP is unavailable.\n */\n\nimport { escapeHTML } from '../utils/file-utils.js';\nimport { getLocalizedString, EDITORIAL_STRINGS, MOTIONS_STRINGS } from '../constants/languages.js';\nimport type {\n  VotingRecord,\n  VotingPattern,\n  VotingAnomaly,\n  MotionsQuestion,\n  CoalitionIntelligence,\n  AdoptedTextFeedItem,\n} from '../types/index.js';\n\n/** Marker string used in all fallback/placeholder data to indicate MCP data is unavailable */\nexport const PLACEHOLDER_MARKER = 'DATA_UNAVAILABLE (placeholder)';\n\n/**\n * Get fallback data for motions article\n *\n * @param dateStr - Current date string\n * @param dateFromStr - Start date string\n * @returns Object with all fallback data arrays\n */\nexport function getMotionsFallbackData(\n  dateStr: string,\n  dateFromStr: string\n): {\n  votingRecords: VotingRecord[];\n  votingPatterns: VotingPattern[];\n  anomalies: VotingAnomaly[];\n  questions: MotionsQuestion[];\n} {\n  return {\n    votingRecords: [\n      {\n        title: 'Example motion (placeholder – data unavailable)',\n        date: dateStr,\n        result: PLACEHOLDER_MARKER,\n        votes: { for: 0, against: 0, abstain: 0 },\n      },\n      {\n        title: 'Example amendment (placeholder – data unavailable)',\n        date: dateFromStr,\n        result: PLACEHOLDER_MARKER,\n        votes: { for: 0, against: 0, abstain: 0 },\n      },\n    ],\n    votingPatterns: [\n      {\n        group: 'Example group A (placeholder)',\n        cohesion: 0.0,\n        participation: 0.0,\n      },\n      {\n        group: 'Example group B (placeholder)',\n        cohesion: 0.0,\n        participation: 0.0,\n      },\n    ],\n    anomalies: [\n      {\n        type: 'Placeholder example',\n        description:\n          'No real anomaly data available from MCP – this is illustrative placeholder content only.',\n        severity: 'LOW',\n      },\n    ],\n    questions: [\n      {\n        author: 'Placeholder MEP 1',\n        topic: 'Placeholder parliamentary question on energy security (MCP data unavailable)',\n        date: dateStr,\n        status: PLACEHOLDER_MARKER,\n      },\n      {\n        author: 'Placeholder MEP 2',\n        topic: 'Placeholder parliamentary question on migration policy (MCP data unavailable)',\n        date: dateFromStr,\n        status: PLACEHOLDER_MARKER,\n      },\n    ],\n  };\n}\n\n/**\n * Returns true when there is no real roll-call data: either the records\n * array is empty or every voting record carries the placeholder marker,\n * indicating no real roll-call data was retrieved from MCP.\n *\n * @param records - Voting records to test\n * @returns `true` if the array is empty or all records are placeholder-only data\n */\nfunction isPlaceholderVotingRecords(records: readonly VotingRecord[]): boolean {\n  return records.length === 0 || records.every((r) => r.result === PLACEHOLDER_MARKER);\n}\n\n/**\n * Returns true when there is no real voting pattern data: either the patterns\n * array is empty or every voting pattern is placeholder/fallback data\n * (groups whose name contains the word \"placeholder\" — case-insensitive).\n *\n * @param patterns - Voting patterns to test\n * @returns `true` if the array is empty or all patterns are placeholder-only data\n */\nfunction isPlaceholderVotingPatterns(patterns: readonly VotingPattern[]): boolean {\n  return patterns.length === 0 || patterns.every((p) => /placeholder/i.test(p.group));\n}\n\n/**\n * Returns true when there is no real anomaly data: either the array is empty\n * or every anomaly type contains the word \"placeholder\" (case-insensitive).\n *\n * @param anomalies - Anomalies to test\n * @returns `true` if the array is empty or all anomalies are placeholder-only data\n */\nfunction isPlaceholderAnomalies(anomalies: readonly VotingAnomaly[]): boolean {\n  return anomalies.length === 0 || anomalies.every((a) => /placeholder/i.test(a.type));\n}\n\n/**\n * Returns true when there is no real questions data: either the array is empty\n * or every question carries the placeholder marker in its status field.\n *\n * @param questions - Parliamentary questions to test\n * @returns `true` if the array is empty or all questions are placeholder-only data\n */\nfunction isPlaceholderQuestions(questions: readonly MotionsQuestion[]): boolean {\n  return questions.length === 0 || questions.every((q) => q.status === PLACEHOLDER_MARKER);\n}\n\n/**\n * Generate HTML content for motions article\n *\n * @param dateFromStr - Start date\n * @param dateStr - End date\n * @param votingRecords - Voting records data\n * @param votingPatterns - Voting patterns data\n * @param anomalies - Anomalies data\n * @param questions - Questions data\n * @param lang - Language code for editorial strings (default: 'en')\n * @returns HTML content string\n */\nexport function generateMotionsContent(\n  dateFromStr: string,\n  dateStr: string,\n  votingRecords: VotingRecord[],\n  votingPatterns: VotingPattern[],\n  anomalies: VotingAnomaly[],\n  questions: MotionsQuestion[],\n  lang = 'en'\n): string {\n  const editorial = getLocalizedString(EDITORIAL_STRINGS, lang);\n  const strings = getLocalizedString(MOTIONS_STRINGS, lang);\n  const ledeAnalysisRaw = strings.ledeAnalysis\n    .replace('{DATE_FROM}', dateFromStr)\n    .replace('{DATE_TO}', dateStr);\n  const showVotingResults = !isPlaceholderVotingRecords(votingRecords);\n  const showVotingPatterns = !isPlaceholderVotingPatterns(votingPatterns);\n  const showAnomalies = !isPlaceholderAnomalies(anomalies);\n  const showQuestions = !isPlaceholderQuestions(questions);\n  return `\n    <div class=\"article-content\">\n      <section class=\"lede\">\n        <p>${escapeHTML(strings.lede)} ${escapeHTML(editorial.sourceAttribution)}, ${escapeHTML(ledeAnalysisRaw)}</p>\n      </section>\n      ${\n        showVotingResults\n          ? `\n      <section class=\"voting-results\">\n        <h2>${escapeHTML(strings.votingRecordsHeading)}</h2>\n        ${votingRecords\n          .filter((r) => r.result !== PLACEHOLDER_MARKER)\n          .map(\n            (record) => `\n          <div class=\"vote-item\">\n            <h3>${escapeHTML(record.title)}</h3>\n            <p class=\"vote-date\">${escapeHTML(strings.dateLabel)}: ${escapeHTML(record.date)}</p>\n            <p class=\"vote-result\"><strong>${escapeHTML(strings.resultLabel)}:</strong> ${escapeHTML(record.result)}</p>\n            <div class=\"vote-breakdown\">\n              <span class=\"vote-for\">${escapeHTML(strings.forLabel)}: ${escapeHTML(String(record.votes.for))}</span>\n              <span class=\"vote-against\">${escapeHTML(strings.againstLabel)}: ${escapeHTML(String(record.votes.against))}</span>\n              <span class=\"vote-abstain\">${escapeHTML(strings.abstainLabel)}: ${escapeHTML(String(record.votes.abstain))}</span>\n            </div>\n          </div>\n        `\n          )\n          .join('')}\n      </section>`\n          : ''\n      }\n      ${\n        showVotingPatterns\n          ? `\n      <section class=\"voting-patterns\">\n        <h2>${escapeHTML(strings.partyCohesionHeading)}</h2>\n        <p>${escapeHTML(editorial.parliamentaryContext)}: Analysis of voting behavior reveals varying levels of party discipline across political groups:</p>\n        ${votingPatterns\n          .filter((p) => !/placeholder/i.test(p.group))\n          .map(\n            (pattern) => `\n          <div class=\"pattern-item\">\n            <h3>${escapeHTML(pattern.group)}</h3>\n            <p><strong>${escapeHTML(strings.cohesionLabel)}:</strong> ${escapeHTML(String((pattern.cohesion * 100).toFixed(1)))}%</p>\n            <p><strong>${escapeHTML(strings.participationLabel)}:</strong> ${escapeHTML(String((pattern.participation * 100).toFixed(1)))}%</p>\n          </div>\n        `\n          )\n          .join('')}\n      </section>`\n          : ''\n      }\n      ${\n        showAnomalies\n          ? `\n      <section class=\"anomalies\">\n        <h2>${escapeHTML(strings.anomaliesHeading)}</h2>\n        <p>${escapeHTML(editorial.analysisNote)}: Unusual voting patterns that deviate from typical party lines:</p>\n        ${anomalies\n          .filter((a) => !/placeholder/i.test(a.type))\n          .map((anomaly) => {\n            const rawSeverity = anomaly.severity ?? 'unknown';\n            const severityDisplay =\n              typeof rawSeverity === 'string' ? rawSeverity : String(rawSeverity);\n            const severityClass = severityDisplay.toLowerCase();\n            return `\n          <div class=\"anomaly-item severity-${escapeHTML(severityClass)}\">\n            <h3>${escapeHTML(anomaly.type)}</h3>\n            <p>${escapeHTML(anomaly.description)}</p>\n            <p class=\"severity\">${escapeHTML(strings.severityLabel)}: ${escapeHTML(severityDisplay)}</p>\n          </div>\n        `;\n          })\n          .join('')}\n      </section>`\n          : ''\n      }\n      ${\n        showQuestions\n          ? `\n      <section class=\"questions\">\n        <h2>${escapeHTML(strings.questionsHeading)}</h2>\n        ${questions\n          .filter((q) => q.status !== PLACEHOLDER_MARKER)\n          .map(\n            (question) => `\n          <div class=\"question-item\">\n            <p class=\"question-author\">${escapeHTML(question.author)}</p>\n            <p class=\"question-topic\"><strong>${escapeHTML(question.topic)}</strong></p>\n            <p class=\"question-meta\">${escapeHTML(strings.dateLabel)}: ${escapeHTML(question.date)} | ${escapeHTML(strings.statusLabel)}: ${escapeHTML(question.status)}</p>\n          </div>\n        `\n          )\n          .join('')}\n      </section>`\n          : ''\n      }\n\n      <!-- /article-content -->\n    </div>\n  `;\n}\n\n// ─── Political Alignment section ──────────────────────────────────────────────\n\n/**\n * Build HTML list items for voting record alignment rows\n *\n * @param records - Voting records to render\n * @returns HTML list items string\n */\nfunction buildVoteAlignmentHtml(records: VotingRecord[]): string {\n  if (records.length === 0) return '';\n  const realRecords = records.filter((r) => r.result !== PLACEHOLDER_MARKER);\n  if (realRecords.length === 0) return '';\n  const items = realRecords\n    .map((r) => {\n      const forVotes = escapeHTML(String(r.votes.for));\n      const againstVotes = escapeHTML(String(r.votes.against));\n      const abstainVotes = escapeHTML(String(r.votes.abstain));\n      return (\n        `<li class=\"alignment-vote\">` +\n        `<strong>${escapeHTML(r.title)}</strong> — ` +\n        `${escapeHTML(r.result)} ` +\n        `(${forVotes}&#43; / ${againstVotes}&#8722; / ${abstainVotes} abstain)` +\n        `</li>`\n      );\n    })\n    .join('\\n          ');\n  return `<ul class=\"alignment-votes\">\\n          ${items}\\n        </ul>`;\n}\n\n/**\n * Build HTML list items for coalition alignment rows\n *\n * @param coalitions - Coalition intelligence items to render\n * @returns HTML list items string\n */\nfunction buildCoalitionAlignmentHtml(coalitions: CoalitionIntelligence[]): string {\n  if (coalitions.length === 0) return '';\n  const items = coalitions\n    .map(\n      (c) =>\n        `<li class=\"alignment-coalition alignment-${escapeHTML(c.riskLevel)}\">` +\n        `${escapeHTML(c.groups.join(', '))} — ` +\n        `cohesion: ${escapeHTML(String(Math.round(c.cohesionScore * 100)))}% ` +\n        `(${escapeHTML(c.alignmentTrend)})</li>`\n    )\n    .join('\\n          ');\n  return `<ul class=\"alignment-coalitions\">\\n          ${items}\\n        </ul>`;\n}\n\n/**\n * Build political alignment analysis section for motions, showing how\n * voting records map to coalition cohesion and cross-party dynamics.\n * Returns an empty string when both input arrays are empty or yield no items.\n *\n * @param votingRecords - Voting records to analyse\n * @param coalitions - Coalition intelligence data from MCP\n * @param language - BCP 47 language code used as the section lang attribute\n * @returns HTML string for the political alignment section\n */\nexport function buildPoliticalAlignmentSection(\n  votingRecords: VotingRecord[],\n  coalitions: CoalitionIntelligence[],\n  language: string\n): string {\n  if (votingRecords.length === 0 && coalitions.length === 0) return '';\n  const recordsHtml = buildVoteAlignmentHtml(votingRecords);\n  const coalitionsHtml = buildCoalitionAlignmentHtml(coalitions);\n  if (!recordsHtml && !coalitionsHtml) return '';\n  const strings = getLocalizedString(MOTIONS_STRINGS, language);\n  return `\n        <section class=\"political-alignment\" lang=\"${escapeHTML(language)}\">\n          <h2>${escapeHTML(strings.politicalAlignmentHeading)}</h2>\n          ${recordsHtml}\n          ${coalitionsHtml}\n        </section>`;\n}\n\n/** Localized headings for the adopted texts feed section */\nconst ADOPTED_TEXTS_HEADINGS: Record<string, string> = {\n  en: 'Recently Adopted Texts',\n  sv: 'Nyligen Antagna Texter',\n  da: 'Nyligt Vedtagne Tekster',\n  no: 'Nylig Vedtatte Tekster',\n  fi: 'Äskettäin Hyväksytyt Tekstit',\n  de: 'Kürzlich Angenommene Texte',\n  fr: 'Textes Récemment Adoptés',\n  es: 'Textos Recientemente Adoptados',\n  nl: 'Recent Aangenomen Teksten',\n  ar: 'النصوص المعتمدة مؤخراً',\n  he: 'טקסטים שאומצו לאחרונה',\n  ja: '最近採択されたテキスト',\n  ko: '최근 채택된 텍스트',\n  zh: '最近通过的文本',\n};\n\n/** Localized fallback label for items with no adoption date */\nconst ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS: Record<string, string> = {\n  en: 'Unknown',\n  sv: 'Okänt',\n  da: 'Ukendt',\n  no: 'Ukjent',\n  fi: 'Tuntematon',\n  de: 'Unbekannt',\n  fr: 'Inconnu',\n  es: 'Desconocido',\n  nl: 'Onbekend',\n  ar: 'غير معروف',\n  he: 'לא ידוע',\n  ja: '不明',\n  ko: '알 수 없음',\n  zh: '未知',\n};\n\n/** Localized count descriptions for the adopted texts feed section */\nconst ADOPTED_TEXTS_COUNT_STRINGS: Record<string, (n: number) => string> = {\n  en: (n) => `${n} texts adopted in recent plenary sessions:`,\n  sv: (n) => `${n} texter antagna i nyliga plenarsammanträden:`,\n  da: (n) => `${n} tekster vedtaget i seneste plenarmøder:`,\n  no: (n) => `${n} tekster vedtatt i nylige plenumsmøter:`,\n  fi: (n) => `${n} tekstiä hyväksytty viimeisimmissä täysistunnoissa:`,\n  de: (n) => `${n} Texte in jüngsten Plenarsitzungen angenommen:`,\n  fr: (n) => `${n}\\u00a0textes adoptés lors des récentes sessions plénières\\u00a0:`,\n  es: (n) => `${n} textos adoptados en recientes sesiones plenarias:`,\n  nl: (n) => `${n} teksten aangenomen in recente plenaire vergaderingen:`,\n  ar: (n) => `تم اعتماد ${n} نصاً في جلسات البرلمان الأخيرة:`,\n  he: (n) => `${n} טקסטים אומצו בישיבות המליאה האחרונות:`,\n  ja: (n) => `最近の本会議セッションで ${n} 件のテキストが採択されました：`,\n  ko: (n) => `최근 전체 회의에서 ${n}개의 텍스트가 채택되었습니다:`,\n  zh: (n) => `最近全体会议共通过了 ${n} 份文本：`,\n};\n\n/**\n * Build an HTML section listing recently adopted texts from EP feed data.\n * Groups texts by adoption date and renders them as a structured list.\n *\n * @param adoptedTexts - Adopted text feed items\n * @param language - BCP 47 language code\n * @returns HTML section string, or empty string if no texts\n */\nexport function buildAdoptedTextsSection(\n  adoptedTexts: readonly AdoptedTextFeedItem[],\n  language: string\n): string {\n  if (adoptedTexts.length === 0) return '';\n\n  const heading =\n    ADOPTED_TEXTS_HEADINGS[language] ?? ADOPTED_TEXTS_HEADINGS['en'] ?? 'Recently Adopted Texts';\n  const countFn =\n    ADOPTED_TEXTS_COUNT_STRINGS[language] ??\n    ADOPTED_TEXTS_COUNT_STRINGS['en'] ??\n    ((n: number) => `${n} adopted texts`);\n  const countText = countFn(adoptedTexts.length);\n  const unknownDate =\n    ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS[language] ??\n    ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS['en'] ??\n    'Unknown date';\n\n  // Group by date, sort most recent first\n  const byDate = new Map<string, AdoptedTextFeedItem[]>();\n  for (const item of adoptedTexts) {\n    const date = item.date || unknownDate;\n    const group = byDate.get(date) ?? [];\n    group.push(item);\n    byDate.set(date, group);\n  }\n  const sortedDates = [...byDate.keys()].sort().reverse();\n\n  let itemsHtml = '';\n  for (const date of sortedDates) {\n    const items = byDate.get(date) ?? [];\n    for (const item of items) {\n      const ref = item.identifier ?? item.id;\n      const title = item.title || ref;\n      itemsHtml += `\n            <li class=\"adopted-text-item\">\n              <strong>${escapeHTML(title)}</strong>\n              <span class=\"feed-label\">${escapeHTML(ref)}</span>\n              <span class=\"feed-date\">${escapeHTML(date)}</span>\n            </li>`;\n    }\n  }\n\n  return `\n        <section class=\"adopted-texts-feed\" lang=\"${escapeHTML(language)}\">\n          <h2>${escapeHTML(heading)}</h2>\n          <p>${escapeHTML(countText)}</p>\n          <ul class=\"adopted-texts-list\">\n            ${itemsHtml}\n          </ul>\n        </section>`;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/news-enhanced.ts","messages":[{"ruleId":"@typescript-eslint/prefer-nullish-coalescing","severity":1,"message":"Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.","line":185,"column":46,"messageId":"preferNullishOverOr","endLine":185,"endColumn":48,"suggestions":[{"messageId":"suggestNullish","data":{"equals":""},"fix":{"range":[6936,7016],"text":"(runIdArg?.slice('--run-id='.length).trim() ??\n  process.env['GITHUB_RUN_NUMBER'])"},"desc":"Fix to nullish coalescing operator (`??`)."}]},{"ruleId":"@typescript-eslint/prefer-nullish-coalescing","severity":1,"message":"Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.","line":186,"column":36,"messageId":"preferNullishOverOr","endLine":186,"endColumn":38,"suggestions":[{"messageId":"suggestNullish","data":{"equals":""},"fix":{"range":[7017,7019],"text":"??"},"desc":"Fix to nullish coalescing operator (`??`)."}]}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"#!/usr/bin/env node\n\n// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/NewsEnhanced\n * @description CLI orchestrator for European Parliament news generation.\n *\n * Coordinates the four-stage pipeline (fetch → analysis discovery → generate → output)\n * via dedicated pipeline-stage modules and a strategy registry.  Each article\n * type is handled by its own {@link ArticleStrategy} implementation.\n *\n * When the `--analysis` flag is supplied (all 9 agentic workflows do this),\n * the analysis discovery stage runs **before** article generation, discovering\n * AI-generated analysis `.md` files under `analysis/daily/{date}/{article-type}/`.\n * The AI agentic workflows produce all analysis content directly — the TypeScript\n * pipeline only discovers and links to these artifacts.  Analysis artifacts are\n * committed to the repository for review and improvement.\n *\n * Pipeline stages:\n * - {@link module:Generators/Pipeline/FetchStage}\n * - {@link module:Generators/Pipeline/AnalysisStage}  (discovers AI-generated analysis artifacts)\n * - {@link module:Generators/Pipeline/GenerateStage}\n * - {@link module:Generators/Pipeline/OutputStage}\n *\n * Article strategies:\n * - {@link module:Generators/Strategies/WeekAheadStrategy}\n * - {@link module:Generators/Strategies/MonthAheadStrategy}\n * - {@link module:Generators/Strategies/BreakingNewsStrategy}\n * - {@link module:Generators/Strategies/CommitteeReportsStrategy}\n * - {@link module:Generators/Strategies/PropositionsStrategy}\n * - {@link module:Generators/Strategies/MotionsStrategy}\n * - {@link module:Generators/Strategies/WeeklyReviewStrategy}\n * - {@link module:Generators/Strategies/MonthlyReviewStrategy}\n *\n * Bounded-context content modules (re-exported for backward compatibility):\n * - {@link module:Generators/WeekAheadContent}\n * - {@link module:Generators/BreakingContent}\n * - {@link module:Generators/CommitteeHelpers}\n * - {@link module:Generators/MotionsContent}\n * - {@link module:Generators/PropositionsContent}\n */\n\nimport path, { resolve } from 'path';\nimport { pathToFileURL } from 'url';\nimport {\n  NEWS_DIR,\n  METADATA_DIR,\n  VALID_ARTICLE_CATEGORIES,\n  ARTICLE_TYPE_WEEK_AHEAD,\n  ARG_SEPARATOR,\n} from '../constants/config.js';\nimport { ALL_LANGUAGES, LANGUAGE_PRESETS, isSupportedLanguage } from '../constants/languages.js';\nimport { closeEPMCPClient } from '../mcp/ep-mcp-client.js';\nimport type { EuropeanParliamentMCPClient } from '../mcp/ep-mcp-client.js';\nimport { ensureDirectoryExists } from '../utils/file-utils.js';\nimport type {\n  LanguageCode,\n  LanguagePreset,\n  GenerationStats,\n  GenerationResult,\n} from '../types/index.js';\nimport type { ArticleCategory } from '../types/index.js';\n\n// ─── Pipeline-stage imports ───────────────────────────────────────────────────\n\nimport { initializeMCPClient, fetchEPFeedData } from './pipeline/fetch-stage.js';\nimport {\n  createStrategyRegistry,\n  generateArticleForStrategy,\n  setAIMetadata,\n} from './pipeline/generate-stage.js';\nimport { writeGenerationMetadata } from './pipeline/output-stage.js';\nimport type { OutputOptions } from './pipeline/output-stage.js';\nimport {\n  runAnalysisStage,\n  ALL_ANALYSIS_METHODS,\n  VALID_ANALYSIS_METHODS,\n  hasSubstantiveData,\n  deriveArticleTypeSlug,\n} from './pipeline/analysis-stage.js';\nimport type { AnalysisMethod, AnalysisContext } from './pipeline/analysis-stage.js';\nimport type { AnalysisFileEntry } from '../types/index.js';\nimport { discoverAnalysisFileEntries } from '../utils/file-utils.js';\n\n// ─── Content-module imports (bounded contexts) ───────────────────────────────\n\nimport {\n  parsePlenarySessions,\n  parseEPEvents,\n  parseCommitteeMeetings,\n  parseLegislativeDocuments,\n  parseLegislativePipeline,\n  parseParliamentaryQuestions,\n  buildWeekAheadContent,\n  buildKeywords,\n  PLACEHOLDER_EVENTS,\n  buildWhatToWatchSection,\n  buildStakeholderImpactMatrix,\n  computeWeekPoliticalTemperature,\n} from './week-ahead-content.js';\n\nimport {\n  buildBreakingNewsContent,\n  scoreBreakingNewsSignificance,\n  SIGNIFICANCE_THRESHOLD,\n} from './breaking-content.js';\n\nimport {\n  applyCommitteeInfo,\n  applyDocuments,\n  applyEffectiveness,\n  FEATURED_COMMITTEES,\n} from './committee-helpers.js';\n\nimport {\n  PLACEHOLDER_MARKER,\n  getMotionsFallbackData,\n  generateMotionsContent,\n  buildPoliticalAlignmentSection,\n} from './motions-content.js';\n\nimport { buildPropositionsContent } from './propositions-content.js';\nimport type { PipelineData } from './propositions-content.js';\n\n// ─── Re-exports for backward compatibility (tests import from this module) ───\n\nexport {\n  parsePlenarySessions,\n  parseEPEvents,\n  parseCommitteeMeetings,\n  parseLegislativeDocuments,\n  parseLegislativePipeline,\n  parseParliamentaryQuestions,\n  buildWeekAheadContent,\n  buildKeywords,\n  PLACEHOLDER_EVENTS,\n  buildWhatToWatchSection,\n  buildStakeholderImpactMatrix,\n  computeWeekPoliticalTemperature,\n};\nexport { buildBreakingNewsContent, scoreBreakingNewsSignificance, SIGNIFICANCE_THRESHOLD };\nexport { applyCommitteeInfo, applyDocuments, applyEffectiveness, FEATURED_COMMITTEES };\nexport {\n  PLACEHOLDER_MARKER,\n  getMotionsFallbackData,\n  generateMotionsContent,\n  buildPoliticalAlignmentSection,\n};\nexport { buildPropositionsContent };\nexport type { PipelineData };\n\n// ─── CLI argument parsing ─────────────────────────────────────────────────────\n\nconst useMCP = process.env['USE_EP_MCP'] !== 'false';\n\nconst args = process.argv.slice(2);\nconst typesArg = args.find((arg) => arg.startsWith('--types='));\nconst languagesArg = args.find((arg) => arg.startsWith('--languages='));\nconst feedDataArg = args.find((arg) => arg.startsWith('--feed-data='));\nconst dryRunArg = args.includes('--dry-run');\nconst skipExistingArg = args.includes('--skip-existing');\nconst runAnalysisArg = args.includes('--analysis');\nconst analysisOnlyArg = args.includes('--analysis-only');\nconst analysisVerboseArg = args.includes('--analysis-verbose');\nconst analysisDirArg = args.find((arg) => arg.startsWith('--analysis-dir='));\nconst analysisMethodsArg = args.find((arg) => arg.startsWith('--analysis-methods='));\nconst runIdArg = args.find((arg) => arg.startsWith('--run-id='));\nconst titleArg = args.find((arg) => arg.startsWith('--title='));\nconst descriptionArg = args.find((arg) => arg.startsWith('--description='));\n\n/**\n * Workflow run identifier (typically `GITHUB_RUN_NUMBER`) used to create\n * a unique analysis directory per workflow execution.  This prevents\n * multiple runs on the same date from overwriting each other's analysis\n * artifacts and ensures article transparency links point to the exact\n * analysis used for that specific article generation run.\n *\n * Falls back to `GITHUB_RUN_NUMBER` env var, then empty string (no suffix).\n * Sanitised to alphanumeric and hyphens only (supports both numeric run\n * numbers and custom identifiers passed via `--run-id`).\n */\nexport const runId: string = (\n  runIdArg?.slice('--run-id='.length).trim() ||\n  process.env['GITHUB_RUN_NUMBER'] ||\n  ''\n).replace(/[^a-z0-9-]/giu, '');\n\n/**\n * AI-generated article title passed by the agentic workflow.\n * When provided, this OVERRIDES any script-generated title.\n * The AI agent must analyse the content and produce this.\n */\nexport const aiTitle: string = titleArg ? titleArg.slice('--title='.length).trim() : '';\n\n/**\n * AI-generated article description/subtitle passed by the agentic workflow.\n * When provided, this OVERRIDES any script-generated description.\n * The AI agent must analyse the content and produce this.\n */\nexport const aiDescription: string = descriptionArg\n  ? descriptionArg.slice('--description='.length).trim()\n  : '';\n\n/** Path to a JSON file containing pre-fetched EP feed data (optional). */\nconst feedDataPath = feedDataArg?.startsWith('--feed-data=')\n  ? feedDataArg.slice('--feed-data='.length).trim()\n  : '';\n\nconst articleTypes = typesArg\n  ? (typesArg.split(ARG_SEPARATOR)[1] ?? '').split(',').map((t) => t.trim())\n  : [ARTICLE_TYPE_WEEK_AHEAD];\n\nlet languagesInput = languagesArg\n  ? (languagesArg.split(ARG_SEPARATOR)[1] ?? '').trim().toLowerCase()\n  : 'en';\n\n// Expand presets\nconst presetLanguages = LANGUAGE_PRESETS[languagesInput as LanguagePreset];\nif (presetLanguages) {\n  languagesInput = presetLanguages.join(',');\n}\n\nconst languages: LanguageCode[] = languagesInput\n  .split(',')\n  .map((l) => l.trim())\n  .filter((l): l is LanguageCode => isSupportedLanguage(l));\n\nif (languages.length === 0) {\n  console.error('❌ No valid language codes provided. Valid codes:', ALL_LANGUAGES.join(', '));\n  process.exit(1);\n}\n\n// Validate article types\nconst invalidTypes = articleTypes.filter(\n  (t) => !VALID_ARTICLE_CATEGORIES.includes(t.trim() as ArticleCategory)\n);\nif (invalidTypes.length > 0) {\n  console.warn(`⚠️ Unknown article types ignored: ${invalidTypes.join(', ')}`);\n}\n\nconsole.log('📰 Enhanced News Generation Script');\nconsole.log('Article types:', articleTypes.join(', '));\nconsole.log('Languages:', languages.join(', '));\nconsole.log('Dry run:', dryRunArg ? 'Yes (no files written)' : 'No');\nconsole.log('Skip existing:', skipExistingArg ? 'Yes' : 'No');\nif (runAnalysisArg || analysisOnlyArg) {\n  console.log(\n    'Analysis stage:',\n    analysisOnlyArg ? 'Analysis only (no article generation)' : 'Enabled'\n  );\n}\nif (feedDataPath) {\n  console.log('Feed data file:', feedDataPath);\n}\n\n// Ensure directories exist\nensureDirectoryExists(METADATA_DIR);\nensureDirectoryExists(NEWS_DIR);\n\n// Generation statistics\nconst stats: GenerationStats = {\n  generated: 0,\n  skipped: 0,\n  dryRun: 0,\n  errors: 0,\n  articles: [],\n  timestamp: new Date().toISOString(),\n};\n\n// ─── Main orchestration ───────────────────────────────────────────────────────\n\n/**\n * Type guard that narrows a string to {@link AnalysisMethod}.\n *\n * Uses {@link Array.some} so no type assertion is needed — the predicate\n * compares each element directly to the candidate string.\n *\n * @param name - The string to validate\n * @returns `true` when `name` is a recognised analysis method\n */\nfunction isValidAnalysisMethod(name: string): name is AnalysisMethod {\n  return VALID_ANALYSIS_METHODS.some((m) => m === name);\n}\n\n/**\n * Parse the `--analysis-methods=` CLI flag into a validated, deduplicated list.\n * Warns on unrecognised method names and falls back to all methods when no valid\n * names remain.\n *\n * @returns Validated list of analysis methods\n */\nfunction parseAnalysisMethods(): readonly AnalysisMethod[] {\n  const raw = analysisMethodsArg?.split(ARG_SEPARATOR)[1]?.trim();\n  if (!raw) return ALL_ANALYSIS_METHODS;\n\n  const requestedNames = raw\n    .split(',')\n    .map((m) => m.trim())\n    .filter((m) => m.length > 0);\n\n  if (requestedNames.length === 0) return ALL_ANALYSIS_METHODS;\n\n  const validMethods = new Set<AnalysisMethod>();\n  const unknownMethods: string[] = [];\n\n  for (const name of requestedNames) {\n    if (isValidAnalysisMethod(name)) {\n      validMethods.add(name);\n    } else {\n      unknownMethods.push(name);\n    }\n  }\n\n  if (unknownMethods.length > 0) {\n    console.warn(`⚠️ Unknown analysis methods ignored: ${unknownMethods.join(', ')}`);\n  }\n\n  const methods = Array.from(validMethods);\n  if (methods.length === 0) {\n    console.warn('⚠️ No valid analysis methods specified; defaulting to all analysis methods.');\n    return ALL_ANALYSIS_METHODS;\n  }\n\n  return methods;\n}\n\n/**\n * Run the analysis discovery stage (Fetch → Discover) before article generation.\n *\n * This function fetches EP feed data and then discovers the analysis `.md`\n * files that the AI agentic workflow wrote to `analysis/daily/{date}/{article-type}/`.\n * It writes a minimal `manifest.json` if one doesn't already exist, so that\n * downstream consumers (strategies, article template) can reference the analysis.\n *\n * The AI agent performs ALL analytical work directly — this function merely\n * discovers and catalogues what exists on disk.  The returned\n * {@link AnalysisContext} is informational; strategies read analysis output\n * from disk rather than consuming the context object in-memory.\n *\n * The feed timeframe is derived from the requested article types: if any\n * month-level types (month-ahead, month-in-review, committee-reports, motions)\n * are present, the stage fetches 'one-month' of data; otherwise 'one-week'.\n *\n * **Note:** The analysis stage fetches EP feed data independently of the\n * generation stage.  Strategies also call `fetchEPFeedData()` during their own\n * `fetchData()`.  Sharing a single fetch result between analysis and generation\n * is a planned optimisation (tracked separately) to reduce MCP traffic.\n *\n * @param date - ISO date string (YYYY-MM-DD)\n * @param client - Connected MCP client or null\n * @returns Analysis context or null\n */\nasync function maybeRunAnalysis(\n  date: string,\n  client: EuropeanParliamentMCPClient | null\n): Promise<AnalysisContext | null> {\n  if (!runAnalysisArg && !analysisOnlyArg) return null;\n\n  const rawAnalysisDirBase = analysisDirArg?.split(ARG_SEPARATOR)[1];\n  const trimmedAnalysisDirBase = rawAnalysisDirBase?.trim();\n  const analysisDirBase =\n    trimmedAnalysisDirBase && trimmedAnalysisDirBase.length > 0\n      ? trimmedAnalysisDirBase\n      : 'analysis/daily';\n  const enabledMethods = parseAnalysisMethods();\n\n  console.log('');\n  console.log('🔬 Running analysis discovery stage...');\n  console.log(`   Output dir: ${analysisDirBase}/${date}`);\n  console.log(`   Methods: ${enabledMethods.length} enabled`);\n  console.log('');\n\n  // Derive the feed timeframe from the requested article types so the analysis\n  // window matches the generation window.  Month-level types need 'one-month'.\n  const MONTH_LEVEL_TYPES = ['month-ahead', 'month-in-review', 'committee-reports', 'motions'];\n  const normalizedArticleTypes = articleTypes.map((t) => t.trim());\n  const needsMonthData = normalizedArticleTypes.some((t) => MONTH_LEVEL_TYPES.includes(t));\n  const feedTimeframe = needsMonthData ? 'one-month' : 'one-week';\n\n  // Fetch comprehensive EP feed data.  fetchEPFeedData handles a null client\n  // gracefully (returns undefined) and also loads from EP_FEED_DATA_FILE when\n  // set, so we call it unconditionally.\n  //\n  // Always initialise voting-derived keys (`patterns`, `votingRecords`) to\n  // empty arrays so coalition/voting/cross-session analyses never receive\n  // undefined.  These feeds are not yet exposed by fetchEPFeedData, so they\n  // stay empty until a future MCP voting-records endpoint is available.\n  const fetchedData: Record<string, unknown> = {\n    date,\n    patterns: [],\n    votingRecords: [],\n  };\n  const feedData = await fetchEPFeedData(client, feedTimeframe);\n  if (feedData) {\n    fetchedData['events'] = feedData.events ?? [];\n    fetchedData['documents'] = feedData.documents ?? [];\n    fetchedData['adoptedTexts'] = feedData.adoptedTexts ?? [];\n    fetchedData['procedures'] = feedData.procedures ?? [];\n    fetchedData['mepUpdates'] = feedData.mepUpdates ?? [];\n    fetchedData['plenaryDocuments'] = feedData.plenaryDocuments ?? [];\n    fetchedData['committeeDocuments'] = feedData.committeeDocuments ?? [];\n    fetchedData['plenarySessionDocuments'] = feedData.plenarySessionDocuments ?? [];\n    fetchedData['externalDocuments'] = feedData.externalDocuments ?? [];\n    fetchedData['questions'] = feedData.questions ?? [];\n    fetchedData['declarations'] = feedData.declarations ?? [];\n    fetchedData['corporateBodies'] = feedData.corporateBodies ?? [];\n  }\n  if (!fetchedData['events']) {\n    // No MCP or feed-data file available — populate empty arrays so builders don't fail\n    fetchedData['events'] = [];\n    fetchedData['sessions'] = [];\n    fetchedData['documents'] = [];\n  }\n\n  // Validate that substantive EP data was actually fetched.\n  // Agentic workflows must not proceed with empty data — analysis on empty\n  // data produces hollow output that should never feed article generation.\n  if (!hasSubstantiveData(fetchedData)) {\n    const msg =\n      '❌ Analysis aborted: no substantive EP data was fetched. ' +\n      'MCP data fetch must succeed before analysis can run. ' +\n      'Check MCP connection, feed data file, or EP API availability.';\n    throw new Error(msg);\n  }\n\n  const validArticleTypes = normalizedArticleTypes.filter((t): t is ArticleCategory =>\n    VALID_ARTICLE_CATEGORIES.includes(t as ArticleCategory)\n  ) as readonly ArticleCategory[];\n\n  // Derive a slug from the article types to scope analysis output per workflow,\n  // preventing merge conflicts when multiple workflows run on the same date.\n  // When a run ID is provided (e.g. GITHUB_RUN_NUMBER), append it to the slug\n  // so each workflow execution gets a unique analysis directory.  This prevents\n  // overwrites when the same workflow runs multiple times on the same day.\n  const baseSlugForAnalysis = deriveArticleTypeSlug(validArticleTypes);\n  const slug = runId ? `${baseSlugForAnalysis}-run${runId}` : baseSlugForAnalysis;\n\n  console.log(`   Article type slug: ${slug}`);\n  if (runId) console.log(`   Run ID: ${runId}`);\n\n  // Pass requireData=true so runAnalysisStage enforces data availability\n  // and aborts when no substantive data is available — discovery on empty data produces no artifacts.\n  const ctx = await runAnalysisStage(fetchedData, {\n    articleTypes: validArticleTypes,\n    date,\n    outputDir: analysisDirBase,\n    articleTypeSlug: slug,\n    enabledMethods,\n    skipCompleted: true,\n    verbose: analysisVerboseArg,\n    requireData: true,\n  });\n\n  const totalMethods = ctx.manifest.methods.length;\n  const completedCount = ctx.manifest.methods.filter(\n    (method) => method.status === 'completed'\n  ).length;\n  const skippedCount = ctx.manifest.methods.filter((method) => method.status === 'skipped').length;\n  const failedMethods = ctx.manifest.methods.filter((method) => method.status === 'failed');\n  const failedCount = failedMethods.length;\n\n  console.log('');\n  console.log(\n    `🔬 Analysis discovery complete: ${completedCount} files found, ${skippedCount} skipped, ${failedCount} issues (of ${totalMethods})`\n  );\n  console.log(`   Confidence: ${ctx.manifest.overallConfidence}`);\n  console.log(`   Manifest: ${ctx.outputDir}/manifest.json`);\n  console.log('');\n\n  // Verify analysis discovery found files — article generation must never\n  // proceed without analysis artifacts.  Zero results mean the AI agent\n  // needs to write analysis files before the generator can proceed.\n  if (failedCount > 0) {\n    const failedNames = failedMethods.map((m) => m.method).join(', ');\n    throw new Error(\n      `Analysis incomplete: ${failedCount} of ${totalMethods} discovered analysis files had issues (${failedNames}). ` +\n        'Article generation requires analysis artifacts to exist.'\n    );\n  }\n\n  if (ctx.completedMethods.length === 0) {\n    throw new Error(\n      `Analysis produced no discovered files (${failedCount} issues). ` +\n        'Article generation requires AI-generated analysis artifacts in the analysis output directory.'\n    );\n  }\n\n  return ctx;\n}\n\n/**\n * Run the analysis stage and enforce agentic workflow pipeline guards.\n *\n * Wraps `maybeRunAnalysis()` with error handling that aborts the process\n * when analysis was requested but fails (data fetch or method execution).\n *\n * @param date - ISO date string\n * @param client - MCP client or null\n * @returns Analysis context or null (when analysis not requested)\n */\nasync function runAnalysisWithGuard(\n  date: string,\n  client: EuropeanParliamentMCPClient | null\n): Promise<AnalysisContext | null> {\n  let analysisCtx: AnalysisContext | null;\n  try {\n    analysisCtx = await maybeRunAnalysis(date, client);\n  } catch (err: unknown) {\n    const message = err instanceof Error ? err.message : String(err);\n    console.error(`❌ Analysis stage failed: ${message}`);\n    console.error(\n      '🛑 Aborting: agentic workflow requires successful data fetch and analysis before article generation.'\n    );\n    throw err instanceof Error ? err : new Error(message);\n  }\n\n  // Gate: when analysis was requested, verify it produced output before\n  // proceeding to article generation.  Never produce articles without\n  // completed analysis — this enforces the agentic workflow principle.\n  if ((runAnalysisArg || analysisOnlyArg) && !analysisCtx) {\n    const msg =\n      '--analysis was requested but no analysis context was produced. ' +\n      'Article generation requires completed analysis.';\n    console.error(`🛑 Aborting: ${msg}`);\n    throw new Error(msg);\n  }\n\n  return analysisCtx;\n}\n\n/**\n * Wire AI-provided title/description from CLI `--title` and `--description` flags.\n * The AI agent passes these after analysing the content.\n * They override ALL script-generated metadata for the English version.\n */\nfunction wireAIMetadata(): void {\n  if (aiTitle || aiDescription) {\n    setAIMetadata(aiTitle, aiDescription);\n    if (aiTitle) console.log(`📝 AI-provided title: \"${aiTitle}\"`);\n    if (aiDescription) console.log(`📝 AI-provided description: \"${aiDescription}\"`);\n  }\n}\n\n/**\n * Compute the dedup suffix by comparing the resolved analysis directory\n * basename with the original slug.  Examples:\n * - `('breaking', 'breaking-2')`    → `'-2'`\n * - `('breaking', 'breaking')`       → `''`\n * - `('breaking-run6', 'breaking-run6-2')` → `'-run6-2'`\n *\n * @param articleTypes - Article type strings to derive the base slug\n * @param analysisDir - Resolved analysis directory basename (may include suffix)\n * @returns Validated dedup suffix string (empty when no suffix applies)\n */\nexport function computeDedupSuffix(articleTypes: readonly string[], analysisDir?: string): string {\n  const baseSlugNoRun = deriveArticleTypeSlug(\n    articleTypes.filter((t): t is ArticleCategory =>\n      VALID_ARTICLE_CATEGORIES.includes(t as ArticleCategory)\n    )\n  );\n  const rawSuffix = analysisDir?.startsWith(baseSlugNoRun)\n    ? analysisDir.slice(baseSlugNoRun.length)\n    : '';\n  // Suffix validation patterns for dedup suffix extraction.\n  // Run IDs are sanitized to alphanumeric + hyphen, so preserve the same\n  // character class here to avoid dropping custom run scopes such as\n  // `-runabc-1` or `-runrelease-candidate`.\n  // -run6, -runabc-1              → run-id only\n  // -2, -3, -a1b2c3d4             → dedup only (numeric or UUID-fragment)\n  // -run6-2, -runabc-1-a1b2c3d4   → combined run-id + dedup\n  const RUN_ID_SUFFIX = /^-run[a-z0-9-]{1,64}$/iu;\n  const DEDUP_SUFFIX = /^-[\\da-f]{1,8}$/iu;\n  const isValidSuffix =\n    rawSuffix === '' || RUN_ID_SUFFIX.test(rawSuffix) || DEDUP_SUFFIX.test(rawSuffix);\n  return isValidSuffix ? rawSuffix : '';\n}\n\n/**\n * Resolve analysis file entries for article transparency links.\n *\n * Extracts entries from the manifest when it uses the standard pipeline format\n * (methods[] with status and outputFile).  When the manifest lacks standard\n * entries (e.g. agentic workflow manifests), falls back to scanning the\n * analysis directory on disk for all `.md` files.  This ensures articles\n * link to ALL analysis artifacts regardless of manifest format.\n *\n * @param analysisCtx - Analysis context from the pipeline stage, or null\n * @returns Array of analysis file entries, or undefined when unavailable\n */\nfunction resolveAnalysisFileEntries(\n  analysisCtx: AnalysisContext | null\n): AnalysisFileEntry[] | undefined {\n  if (!analysisCtx) return undefined;\n\n  const manifestMethods = analysisCtx.manifest.methods;\n  const completedEntries = manifestMethods\n    .filter((m) => m.status === 'completed')\n    .map((m) => ({ method: m.method, outputFile: m.outputFile }));\n\n  if (completedEntries.length > 0) {\n    return completedEntries;\n  }\n\n  // Manifest may use agentic-workflow format without standard methods[].\n  // Fall back to scanning the analysis directory on disk for all .md files.\n  const discovered = discoverAnalysisFileEntries(analysisCtx.outputDir);\n  if (discovered.length > 0) {\n    console.log(\n      `📂 Discovered ${discovered.length} analysis files from disk (manifest lacked standard methods)`\n    );\n    return discovered;\n  }\n\n  return undefined;\n}\n\n/**\n * Main execution: initialise the MCP client, optionally run analysis stage,\n * iterate over requested article types, delegate to the appropriate strategy,\n * then persist metadata.\n */\nasync function main(): Promise<void> {\n  console.log('');\n  console.log('🚀 Starting news generation...');\n  console.log('');\n\n  // Wire AI-provided title/description from CLI flags.\n  wireAIMetadata();\n\n  // When --feed-data is provided, expose the path via env so strategies can\n  // load pre-fetched data without requiring a live MCP connection.\n  if (feedDataPath) {\n    process.env['EP_FEED_DATA_FILE'] = feedDataPath;\n    console.log(`📂 Pre-fetched feed data will be loaded from: ${feedDataPath}`);\n  }\n\n  const client = await initializeMCPClient(useMCP);\n\n  // Determine today's date for the analysis stage\n  // split('T')[0] on a valid ISO string always returns the date portion\n  const isoToday = new Date().toISOString();\n  const todayDate = isoToday.slice(0, 10);\n\n  try {\n    // Run analysis stage with pipeline enforcement guards\n    const analysisCtx = await runAnalysisWithGuard(todayDate, client);\n\n    // Extract the resolved analysis directory basename (e.g. 'breaking-2')\n    // so article transparency links point to the correct suffixed analysis\n    // directory when suffix deduplication is active.\n    const analysisDir = analysisCtx ? path.basename(analysisCtx.outputDir) : undefined;\n\n    // Expose analysis dir/slug via env vars so strategies can locate analysis\n    // artifacts without hard-coding paths.  Follows the EP_FEED_DATA_FILE pattern.\n    if (analysisCtx) {\n      // Base dir: parent of date-scoped dir (e.g. 'analysis/daily' from 'analysis/daily/2026-04-06/breaking')\n      const analysisOutputParent = path.dirname(analysisCtx.outputDir);\n      const analysisBaseDir = path.dirname(analysisOutputParent);\n      process.env['EP_ANALYSIS_DIR'] = analysisBaseDir;\n      // Slug: the resolved directory basename (may include dedup suffix like 'breaking-2')\n      process.env['EP_ANALYSIS_SLUG'] = analysisDir;\n    }\n\n    // Compute dedup suffix by comparing resolved analysis dir with the base slug\n    const dedupSuffix = computeDedupSuffix(articleTypes, analysisDir);\n\n    // Extract analysis file entries for the article template's transparency section.\n    const analysisFiles = resolveAnalysisFileEntries(analysisCtx);\n\n    // If --analysis-only, skip article generation\n    if (analysisOnlyArg) {\n      console.log('ℹ️  --analysis-only specified. Skipping article generation.');\n      return;\n    }\n\n    const outputOptions: OutputOptions = {\n      dryRun: dryRunArg,\n      skipExisting: skipExistingArg,\n      newsDir: path.resolve(NEWS_DIR),\n    };\n\n    const registry = createStrategyRegistry();\n\n    const results: GenerationResult[] = [];\n\n    for (const articleType of articleTypes) {\n      if (!VALID_ARTICLE_CATEGORIES.includes(articleType as ArticleCategory)) {\n        console.log(`⏭️ Skipping unknown article type: ${articleType}`);\n        continue;\n      }\n\n      const strategy = registry.get(articleType as ArticleCategory);\n      if (!strategy) {\n        console.log(`⏭️ Article type \"${articleType}\" not yet implemented`);\n        continue;\n      }\n\n      results.push(\n        await generateArticleForStrategy(\n          strategy,\n          client,\n          languages,\n          outputOptions,\n          stats,\n          dedupSuffix,\n          analysisDir,\n          analysisFiles\n        )\n      );\n    }\n\n    console.log('');\n    console.log('📊 Generation Summary:');\n    console.log(`  ✅ Generated: ${stats.generated} articles`);\n    console.log(`  ⏭️ Skipped: ${stats.skipped} articles`);\n    if (dryRunArg) console.log(`  🔍 Dry run: ${stats.dryRun} articles`);\n    console.log(`  ❌ Errors: ${stats.errors}`);\n    console.log('');\n\n    writeGenerationMetadata(stats, results, client !== null, METADATA_DIR, dryRunArg);\n\n    process.exitCode = stats.errors > 0 ? 1 : 0;\n  } finally {\n    if (client) {\n      console.log('🔌 Closing MCP client connection...');\n      await closeEPMCPClient();\n    }\n  }\n}\n\n// Only run main when executed directly (not when imported)\nif (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {\n  main().catch((err: unknown) => {\n    const message = err instanceof Error ? err.message : String(err);\n    console.error(`💥 Fatal: ${message}`);\n    process.exitCode = 1;\n  });\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/news-indexes.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":108,"column":22,"endLine":108,"endColumn":48},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":215,"column":27,"endLine":215,"endColumn":46},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":379,"column":26,"endLine":379,"endColumn":39}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"#!/usr/bin/env node\n\n// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/NewsIndexes\n * @description Generates index.html files for each language listing all news articles.\n * English is the primary homepage (index.html), other languages use index-{lang}.html.\n * Design follows riksdagsmonitor patterns: compact language switcher, Hack23 AB footer.\n */\n\nimport path, { resolve } from 'path';\nimport { pathToFileURL } from 'url';\nimport {\n  PROJECT_ROOT,\n  APP_VERSION,\n  NEWS_DIR,\n  createThemeToggleButton,\n} from '../constants/config.js';\nimport {\n  ALL_LANGUAGES,\n  LANGUAGE_NAMES,\n  LANGUAGE_FLAGS,\n  PAGE_TITLES,\n  PAGE_DESCRIPTIONS,\n  SECTION_HEADINGS,\n  NO_ARTICLES_MESSAGES,\n  SKIP_LINK_TEXTS,\n  AI_SECTION_CONTENT,\n  FILTER_LABELS,\n  ARTICLE_TYPE_LABELS,\n  HEADER_SUBTITLE_LABELS,\n  THEME_TOGGLE_LABELS,\n  getLocalizedString,\n  getTextDirection,\n} from '../constants/languages.js';\nimport { buildSiteFooter } from '../templates/section-builders.js';\nimport {\n  getNewsArticles,\n  groupArticlesByLanguage,\n  formatSlug,\n  parseArticleFilename,\n  extractArticleMeta,\n  escapeHTML,\n  atomicWrite,\n} from '../utils/file-utils.js';\nimport { writeMetadataDatabase } from '../utils/news-metadata.js';\nimport { detectCategory } from '../utils/article-category.js';\nimport type {\n  ParsedArticle,\n  ArticleCategoryLabels,\n  ArticleCategory,\n  LanguageCode,\n} from '../types/index.js';\n\n/**\n * Get the index filename for a given language code.\n * English uses index.html (the primary homepage), others use index-{lang}.html.\n *\n * @param lang - Language code\n * @returns Filename string\n */\nexport function getIndexFilename(lang: string): string {\n  return lang === 'en' ? 'index.html' : `index-${lang}.html`;\n}\n\n/**\n * Build the compact language switcher nav HTML.\n * Uses flag emoji + language code, riksdagsmonitor style.\n *\n * @param currentLang - Active language code\n * @returns HTML string\n */\nfunction buildLangSwitcher(currentLang: string): string {\n  return ALL_LANGUAGES.map((code) => {\n    const flag = getLocalizedString(LANGUAGE_FLAGS, code);\n    const name = getLocalizedString(LANGUAGE_NAMES, code);\n    const active = code === currentLang ? ' active' : '';\n    const href = getIndexFilename(code);\n    const current = code === currentLang ? ' aria-current=\"page\"' : '';\n    const safeHref = escapeHTML(href);\n    const safeCode = escapeHTML(code);\n    const safeName = escapeHTML(name);\n    return `<a href=\"${safeHref}\" class=\"lang-link${active}\" hreflang=\"${safeCode}\" lang=\"${safeCode}\" title=\"${safeName}\" aria-label=\"${safeName}\"${current}>${flag} ${code.toUpperCase()}</a>`;\n  }).join('\\n        ');\n}\n\n/**\n * Render a single news card element.\n *\n * @param article - Parsed article data\n * @param meta - Real title and description extracted from the article HTML\n * @param meta.title - Article title\n * @param meta.description - Article description/excerpt\n * @param categoryLabels - Optional localized article category labels\n * @returns HTML string for one card\n */\nfunction renderCard(\n  article: ParsedArticle,\n  meta: { title: string; description: string },\n  categoryLabels?: ArticleCategoryLabels\n): string {\n  const category = detectCategory(article.slug);\n  // Sanitize the category for safe use in CSS class names (allow only alphanumeric and hyphens)\n  const safeCategory = String(category).replace(/[^a-z0-9-]/gi, '');\n  const title = escapeHTML(meta.title || formatSlug(article.slug));\n  const badgeLabel = categoryLabels?.[category] ?? formatSlug(safeCategory);\n  const excerpt = meta.description\n    ? `\\n            <p class=\"news-card__excerpt\">${escapeHTML(meta.description)}</p>`\n    : '';\n\n  return `\n      <li class=\"news-card\">\n        <a href=\"news/${escapeHTML(article.filename)}\" class=\"news-card__link\" lang=\"${escapeHTML(article.lang)}\" hreflang=\"${escapeHTML(article.lang)}\">\n          <div class=\"news-card__accent news-card__accent--${safeCategory}\"></div>\n          <div class=\"news-card__body\">\n            <div class=\"news-card__meta\">\n              <span class=\"news-card__badge news-card__badge--${safeCategory}\">${escapeHTML(badgeLabel)}</span>\n              <time class=\"news-card__date\" datetime=\"${escapeHTML(article.date)}\">${escapeHTML(article.date)}</time>\n            </div>\n            <h3 class=\"news-card__title\">${title}</h3>${excerpt}\n          </div>\n        </a>\n      </li>`;\n}\n\n/**\n * Build hreflang alternate link tags for SEO multi-language support.\n *\n * @returns HTML string of link elements\n */\nfunction buildHreflangTags(): string {\n  const links = ALL_LANGUAGES.map((code) => {\n    const href = getIndexFilename(code);\n    return `<link rel=\"alternate\" hreflang=\"${code}\" href=\"${href}\">`;\n  });\n  links.push('<link rel=\"alternate\" hreflang=\"x-default\" href=\"index.html\">');\n  return links.join('\\n  ');\n}\n\n/**\n * Generate index HTML for a language.\n *\n * Produces a complete, standards-compliant HTML5 page with:\n * - Sticky header with EU branding\n * - Compact language switcher with flag + code\n * - Hero section with page title and description\n * - Responsive card grid for news articles\n * - Accessible empty state when no articles exist\n * - Hack23 AB multi-section footer (About, Quick Links, Built by Hack23, Languages)\n *\n * @param lang - Language code\n * @param articles - Articles for this language\n * @param metaMap - Map of article filename to real title and description\n * @returns Complete HTML document\n */\nexport function generateIndexHTML(\n  lang: string,\n  articles: ParsedArticle[],\n  metaMap: Map<string, { title: string; description: string }> = new Map()\n): string {\n  const title = getLocalizedString(PAGE_TITLES, lang);\n  const description = getLocalizedString(PAGE_DESCRIPTIONS, lang);\n  const heading = getLocalizedString(SECTION_HEADINGS, lang);\n  const noArticlesText = getLocalizedString(NO_ARTICLES_MESSAGES, lang);\n  const skipLinkText = getLocalizedString(SKIP_LINK_TEXTS, lang);\n  const dir = getTextDirection(lang);\n  const selfHref = getIndexFilename(lang);\n  const heroTitle = title.split(' - ')[0];\n  const filterLabels = getLocalizedString(FILTER_LABELS, lang) as { all: string; search: string };\n  const categoryLabels = getLocalizedString(ARTICLE_TYPE_LABELS, lang) as ArticleCategoryLabels;\n\n  // Collect distinct categories from the current article set\n  const usedCategories = new Set<ArticleCategory>();\n  for (const a of articles) {\n    usedCategories.add(detectCategory(a.slug));\n  }\n\n  const content =\n    articles.length === 0\n      ? `\n    <div class=\"empty-state\">\n      <div class=\"empty-state__icon\" aria-hidden=\"true\">📰</div>\n      <p class=\"empty-state__text\">${noArticlesText}</p>\n    </div>`\n      : `\n    <ul class=\"news-grid\" role=\"list\">\n      ${articles\n        .map((a) =>\n          renderCard(\n            a,\n            metaMap.get(a.filename) ?? { title: formatSlug(a.slug), description: '' },\n            categoryLabels\n          )\n        )\n        .join('\\n')}\n    </ul>`;\n\n  const ai = getLocalizedString(AI_SECTION_CONTENT, lang);\n\n  // Build filter buttons from used categories (with article count)\n  const categoryCounts = new Map<ArticleCategory, number>();\n  for (const a of articles) {\n    const cat = detectCategory(a.slug);\n    categoryCounts.set(cat, (categoryCounts.get(cat) ?? 0) + 1);\n  }\n\n  const filterButtons =\n    articles.length > 0\n      ? Array.from(usedCategories)\n          .sort()\n          .map((cat) => {\n            const safeCat = String(cat).replace(/[^a-z0-9-]/gi, '');\n            const label = categoryLabels[cat] ?? formatSlug(safeCat);\n            const count = categoryCounts.get(cat) ?? 0;\n            return `<button type=\"button\" class=\"filter-btn\" data-category=\"${safeCat}\">${escapeHTML(label)}<span class=\"filter-btn__count\">${count}</span></button>`;\n          })\n          .join('\\n          ')\n      : '';\n\n  const headerSubtitle = escapeHTML(getLocalizedString(HEADER_SUBTITLE_LABELS, lang));\n  const themeToggleLabel = escapeHTML(getLocalizedString(THEME_TOGGLE_LABELS, lang));\n  const canonicalUrl = `https://hack23.github.io/euparliamentmonitor/${selfHref}`;\n\n  return `<!DOCTYPE html>\n<html lang=\"${lang}\" dir=\"${dir}\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <meta http-equiv=\"X-Content-Type-Options\" content=\"nosniff\">\n  <meta name=\"referrer\" content=\"no-referrer\">\n  <meta name=\"generator\" content=\"EU Parliament Monitor v${escapeHTML(APP_VERSION)}\">\n  <title>${title}</title>\n  <meta name=\"description\" content=\"${description}\">\n  <link rel=\"canonical\" href=\"${canonicalUrl}\">\n  <meta property=\"og:type\" content=\"website\">\n  <meta property=\"og:title\" content=\"${heroTitle}\">\n  <meta property=\"og:description\" content=\"${description}\">\n  <meta property=\"og:url\" content=\"${canonicalUrl}\">\n  <meta property=\"og:site_name\" content=\"EU Parliament Monitor\">\n  <meta property=\"og:locale\" content=\"${lang}\">\n  <meta property=\"og:image\" content=\"https://hack23.github.io/euparliamentmonitor/images/og-image.jpg\">\n  <meta property=\"og:image:width\" content=\"1200\">\n  <meta property=\"og:image:height\" content=\"630\">\n  <meta property=\"og:image:alt\" content=\"EU Parliament Monitor — AI-Disrupted Parliamentary Intelligence\">\n  <meta name=\"twitter:card\" content=\"summary_large_image\">\n  <meta name=\"twitter:title\" content=\"${heroTitle}\">\n  <meta name=\"twitter:description\" content=\"${description}\">\n  <meta name=\"twitter:image\" content=\"https://hack23.github.io/euparliamentmonitor/images/og-image.jpg\">\n  <meta name=\"twitter:image:alt\" content=\"EU Parliament Monitor — AI-Disrupted Parliamentary Intelligence\">\n  ${buildHreflangTags()}\n  <!-- Favicons -->\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"favicon.ico\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"images/favicon-32x32.png\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"images/favicon-16x16.png\">\n  <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"images/apple-touch-icon.png\">\n  <link rel=\"manifest\" href=\"site.webmanifest\">\n  <meta name=\"theme-color\" content=\"#003399\">\n  <link rel=\"alternate\" type=\"application/rss+xml\" title=\"EU Parliament Monitor RSS\" href=\"rss.xml\">\n  <link rel=\"stylesheet\" href=\"styles.css\">\n</head>\n<body>\n  <a href=\"#main\" class=\"skip-link\">${skipLinkText}</a>\n\n  <header class=\"site-header\" role=\"banner\">\n    <div class=\"site-header__inner site-header__inner--stacked\">\n      <a href=\"${selfHref}\" class=\"site-header__brand\" aria-label=\"${heroTitle}\">\n        <picture class=\"site-header__logo-picture\">\n          <source srcset=\"images/header-logo.webp\" type=\"image/webp\">\n          <img class=\"site-header__logo site-header__logo--header\" src=\"images/header-logo.png\" alt=\"\" width=\"72\" height=\"48\" aria-hidden=\"true\">\n        </picture>\n        <span>\n          <span class=\"site-header__title\">${heroTitle}</span>\n        </span>\n      </a>\n      ${createThemeToggleButton(themeToggleLabel)}\n      <nav class=\"site-header__langs\" role=\"navigation\" aria-label=\"Language selection\">\n        ${buildLangSwitcher(lang)}\n      </nav>\n    </div>\n  </header>\n\n  <section class=\"hero\">\n    <div class=\"hero__inner\">\n      <div class=\"hero__content\">\n        <p class=\"hero__kicker\">${headerSubtitle}</p>\n        <h1 class=\"hero__title\">${heroTitle}</h1>\n        <p class=\"hero__description\">${description}</p>\n      </div>\n      <picture class=\"hero__banner\">\n        <source srcset=\"images/banner.webp\" type=\"image/webp\">\n        <img src=\"images/banner.jpg\" alt=\"EU Parliament Monitor — AI-Disrupted Parliamentary Intelligence\" class=\"hero__banner-img\" width=\"1200\" height=\"400\" loading=\"eager\">\n      </picture>\n    </div>\n  </section>\n\n  <main id=\"main\" class=\"site-main\">\n    <h2 class=\"section-heading\"><span class=\"section-heading__icon\" aria-hidden=\"true\">📋</span> ${heading}</h2>${\n      articles.length > 0\n        ? `\n    <div class=\"filter-toolbar\" role=\"toolbar\" aria-label=\"Filter articles\">\n      <div class=\"filter-buttons\">\n        <button type=\"button\" class=\"filter-btn active\" data-category=\"all\">${escapeHTML(filterLabels.all)}<span class=\"filter-btn__count\">${articles.length}</span></button>\n        ${filterButtons}\n      </div>\n      <div class=\"filter-search\">\n        <input type=\"search\" class=\"filter-search__input\" placeholder=\"${escapeHTML(filterLabels.search)}\" aria-label=\"${escapeHTML(filterLabels.search)}\">\n      </div>\n    </div>`\n        : ''\n    }\n    ${content}\n  </main>\n\n  <section class=\"ai-intelligence\" aria-labelledby=\"ai-heading\">\n    <h2 id=\"ai-heading\"><span aria-hidden=\"true\">🤖</span> ${escapeHTML(ai.heading)}</h2>\n    <blockquote class=\"ai-intelligence__quote\">${escapeHTML(ai.quote)}</blockquote>\n    <p>${escapeHTML(ai.description)}</p>\n    <ul class=\"ai-intelligence__features\">\n      <li><strong>${escapeHTML(ai.featureAgents)}</strong> &mdash; ${escapeHTML(ai.featureAgentsDesc)}</li>\n      <li><strong>${escapeHTML(ai.featureSchedule)}</strong> &mdash; ${escapeHTML(ai.featureScheduleDesc)}</li>\n      <li><strong>${escapeHTML(ai.featureHuman)}</strong> &mdash; ${escapeHTML(ai.featureHumanDesc)}</li>\n      <li><strong>${escapeHTML(ai.featureData)}</strong> &mdash; ${escapeHTML(ai.featureDataDesc)}</li>\n    </ul>\n  </section>\n\n  ${buildSiteFooter({ lang: lang as LanguageCode, pathPrefix: '', articleCount: articles.length })}\n\n  <script src=\"js/index-runtime.js\" defer></script>\n</body>\n</html>`;\n}\n\n/**\n * Main execution - generates index files for all languages.\n * English generates index.html (primary homepage), others generate index-{lang}.html.\n */\nfunction main(): void {\n  console.log('📰 Generating news indexes...');\n\n  const articles = getNewsArticles();\n  console.log(`📊 Found ${articles.length} articles`);\n\n  const grouped = groupArticlesByLanguage(articles, ALL_LANGUAGES);\n\n  // Build metadata map (real titles + descriptions from each article HTML)\n  const metaBuildTimerLabel = `⏱️ Built metadata map for ${articles.length} articles`;\n  console.time(metaBuildTimerLabel);\n  const metaMap = new Map<string, { title: string; description: string }>();\n  for (const filename of articles) {\n    const filepath = path.join(NEWS_DIR, filename);\n    metaMap.set(filename, extractArticleMeta(filepath));\n  }\n  console.timeEnd(metaBuildTimerLabel);\n\n  // Also update the metadata database, reusing the already-extracted meta to avoid re-reading files\n  const dbArticles = articles\n    .map((filename) => {\n      const parsed = parseArticleFilename(filename);\n      if (!parsed) return null;\n      const meta = metaMap.get(filename) ?? { title: '', description: '' };\n      return {\n        filename: parsed.filename,\n        date: parsed.date,\n        slug: parsed.slug,\n        lang: parsed.lang,\n        title: meta.title || formatSlug(parsed.slug),\n        description: meta.description,\n      };\n    })\n    .filter((e): e is NonNullable<typeof e> => e !== null);\n  dbArticles.sort((a, b) => b.date.localeCompare(a.date));\n  writeMetadataDatabase({ lastUpdated: new Date().toISOString(), articles: dbArticles });\n  console.log('📝 Updated articles metadata database');\n\n  let generated = 0;\n  for (const lang of ALL_LANGUAGES) {\n    const langArticles = grouped[lang] ?? [];\n    const html = generateIndexHTML(lang, langArticles, metaMap);\n    const filename = getIndexFilename(lang);\n    const filepath = path.join(PROJECT_ROOT, filename);\n\n    atomicWrite(filepath, html);\n    console.log(`  ✅ Generated ${filename} (${langArticles.length} articles)`);\n    generated++;\n  }\n\n  console.log(`✅ Generated ${generated} index files`);\n}\n\n// Only run main when executed directly (not when imported)\nif (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {\n  main();\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/pipeline/analysis-stage.ts","messages":[],"suppressedMessages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":179,"column":15,"endLine":179,"endColumn":24,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/pipeline/fetch-stage.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Function Call Object Injection Sink","line":492,"column":21,"endLine":492,"endColumn":29},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":492,"column":34,"endLine":492,"endColumn":42},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":772,"column":19,"endLine":772,"endColumn":36},{"ruleId":null,"message":"Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').","line":1943,"column":3,"severity":1,"fix":{"range":[69424,69485],"text":" "}},{"ruleId":"@typescript-eslint/no-non-null-assertion","severity":1,"message":"Forbidden non-null assertion.","line":1947,"column":5,"messageId":"noNonNull","endLine":1947,"endColumn":12,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[69636,69637],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]},{"ruleId":null,"message":"Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').","line":1961,"column":3,"severity":1,"fix":{"range":[70051,70112],"text":" "}},{"ruleId":"@typescript-eslint/no-non-null-assertion","severity":1,"message":"Forbidden non-null assertion.","line":1965,"column":5,"messageId":"noNonNull","endLine":1965,"endColumn":12,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[70271,70272],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]},{"ruleId":null,"message":"Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').","line":1979,"column":3,"severity":1,"fix":{"range":[70697,70758],"text":" "}},{"ruleId":"@typescript-eslint/no-non-null-assertion","severity":1,"message":"Forbidden non-null assertion.","line":1983,"column":5,"messageId":"noNonNull","endLine":1983,"endColumn":12,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[70919,70920],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]},{"ruleId":null,"message":"Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').","line":1997,"column":3,"severity":1,"fix":{"range":[71358,71419],"text":" "}},{"ruleId":"@typescript-eslint/no-non-null-assertion","severity":1,"message":"Forbidden non-null assertion.","line":2001,"column":5,"messageId":"noNonNull","endLine":2001,"endColumn":12,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[71586,71587],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]},{"ruleId":null,"message":"Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').","line":2056,"column":3,"severity":1,"fix":{"range":[73498,73559],"text":" "}},{"ruleId":"@typescript-eslint/no-non-null-assertion","severity":1,"message":"Forbidden non-null assertion.","line":2060,"column":5,"messageId":"noNonNull","endLine":2060,"endColumn":12,"suggestions":[{"messageId":"suggestOptionalChain","fix":{"range":[73724,73725],"text":"?"},"desc":"Consider using the optional chain operator `?.` instead. This operator includes runtime checks, so it is safer than the compile-only non-null assertion operator."}]},{"ruleId":null,"message":"Unused eslint-disable directive (no problems were reported from '@typescript-eslint/no-unused-vars').","line":2107,"column":3,"severity":1,"fix":{"range":[75444,75505],"text":" "}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":14,"fixableErrorCount":0,"fixableWarningCount":6,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/Pipeline/FetchStage\n * @description MCP data-fetching pipeline stage with circuit breaker protection.\n *\n * MCP-facing functions accept an explicit `client` argument instead of reading\n * module-level state, making them straightforward to unit-test with a mock\n * client.  The {@link loadFeedDataFromFile} and {@link loadEPFeedDataFromFile}\n * helpers introduce filesystem I/O to load pre-fetched feed JSON produced by\n * agentic workflows.\n *\n * The {@link CircuitBreaker} (imported from `mcp/mcp-retry`) prevents cascading\n * failures when the MCP server is degraded: after\n * {@link CircuitBreakerOptions.failureThreshold} consecutive errors the circuit\n * opens and subsequent calls short-circuit immediately.\n */\n\nimport fs from 'fs';\nimport type { EuropeanParliamentMCPClient } from '../../mcp/ep-mcp-client.js';\nimport { getEPMCPClient } from '../../mcp/ep-mcp-client.js';\nimport type {\n  WeekAheadData,\n  DateRange,\n  CommitteeData,\n  MCPToolResult,\n  VotingRecord,\n  VotingPattern,\n  VotingAnomaly,\n  MotionsQuestion,\n  LegislativeDocument,\n  AdoptedTextFeedItem,\n  EventFeedItem,\n  ProcedureFeedItem,\n  MEPFeedItem,\n  DocumentFeedItem,\n  QuestionFeedItem,\n  DeclarationFeedItem,\n  CorporateBodyFeedItem,\n  BreakingNewsFeedData,\n  EPFeedData,\n  FeedTimeframe,\n} from '../../types/index.js';\nimport {\n  parsePlenarySessions,\n  parseCommitteeMeetings,\n  parseLegislativeDocuments,\n  parseLegislativePipeline,\n  parseParliamentaryQuestions,\n  parseEPEvents,\n  PLACEHOLDER_EVENTS,\n} from '../week-ahead-content.js';\nimport {\n  applyCommitteeInfo,\n  applyDocuments,\n  applyEffectiveness,\n  PLACEHOLDER_CHAIR,\n  PLACEHOLDER_MEMBERS,\n} from '../committee-helpers.js';\nimport { getMotionsFallbackData } from '../motions-content.js';\nimport { escapeHTML } from '../../utils/file-utils.js';\nimport type { PipelineData } from '../propositions-content.js';\n\n// ─── Circuit Breaker (re-exported from mcp-retry bounded context) ────────────\n\nexport {\n  CircuitBreaker,\n  type CircuitState,\n  type CircuitBreakerOptions,\n} from '../../mcp/mcp-retry.js';\nimport { CircuitBreaker } from '../../mcp/mcp-retry.js';\n\n/** Module-level circuit breaker shared across all MCP fetch operations */\nexport const mcpCircuitBreaker = new CircuitBreaker();\n\n// ─── Shared string constants ─────────────────────────────────────────────────\n\n/** Log prefix for MCP fetch operations */\nconst MCP_FETCH_PREFIX = '  📡';\n\n/** Warning prefix for MCP failures */\nconst WARN_PREFIX = '  ⚠️';\n\n/** Info prefix for fallback messages */\nconst INFO_PREFIX = '  ℹ️';\n\n/**\n * Execute a single MCP API call through the module-level circuit breaker.\n * Short-circuits with `fallback` whenever the circuit breaker is not\n * accepting requests (for example when OPEN, or in HALF_OPEN with no\n * probe slots available).\n * Records success or failure after each call, opening the circuit when\n * {@link CircuitBreakerOptions.failureThreshold} consecutive failures occur.\n *\n * @param fn - Async factory that performs the MCP call\n * @param fallback - Value returned when the circuit is not accepting requests\n * @param context - Label used in warning messages\n * @returns Result of `fn` or `fallback`\n */\nasync function callMCP<T>(fn: () => Promise<T>, fallback: T, context: string): Promise<T> {\n  if (!mcpCircuitBreaker.canRequest()) {\n    console.warn(\n      `${WARN_PREFIX} Circuit breaker not accepting requests (${mcpCircuitBreaker.getState()}) — skipping ${context}`\n    );\n    return fallback;\n  }\n  try {\n    const result = await fn();\n    mcpCircuitBreaker.recordSuccess();\n    return result;\n  } catch (error) {\n    mcpCircuitBreaker.recordFailure();\n    throw error;\n  }\n}\n\n// ─── Internal helpers ─────────────────────────────────────────────────────────\n\n/**\n * Parse JSON text, returning `null` and logging a warning on parse failure.\n *\n * @param text - Raw JSON string\n * @param context - Label used in the warning message\n * @returns Parsed value or null\n */\nfunction parseJSON<T>(text: string, context: string): T | null {\n  try {\n    return JSON.parse(text) as T;\n  } catch {\n    console.warn(`${WARN_PREFIX} Failed to parse JSON for ${context}`);\n    return null;\n  }\n}\n\n/** Base shape for feed items that carry a date field */\ntype DatedFeedItem = { date: string };\n\n/**\n * Normalize a feed-item date into canonical UTC `YYYY-MM-DD` form.\n *\n * @param value - Raw date string from MCP or a prefetched feed file\n * @returns Canonical date string, or undefined when the value is missing/invalid\n */\nfunction normalizeFeedItemDate(value: string): string | undefined {\n  const trimmed = value.trim();\n  if (trimmed === '') return undefined;\n\n  const directDate = trimmed.slice(0, 10);\n  if (directDate.length === 10) {\n    const direct = new Date(`${directDate}T00:00:00Z`);\n    if (!Number.isNaN(direct.getTime())) {\n      return directDate;\n    }\n  }\n\n  const parsed = new Date(trimmed);\n  if (Number.isNaN(parsed.getTime())) return undefined;\n\n  const parts = parsed.toISOString().split('T');\n  return parts[0];\n}\n\n/**\n * Filter dated feed items to an inclusive UTC date window.\n *\n * Items without a parseable `date` are dropped when a window is supplied.\n *\n * @param items - Feed items to filter\n * @param dateRange - Inclusive UTC window, or undefined to keep all items\n * @param label - Human-readable label used in logs\n * @returns Filtered array\n */\nfunction filterFeedItemsByDateRange<T extends DatedFeedItem>(\n  items: readonly T[],\n  dateRange: DateRange | undefined,\n  label: string\n): T[] {\n  if (!dateRange) return [...items];\n\n  const filtered = items.filter((item) => {\n    const normalized = normalizeFeedItemDate(item.date);\n    if (normalized === undefined) return false;\n    return normalized >= dateRange.start && normalized <= dateRange.end;\n  });\n\n  if (filtered.length !== items.length) {\n    console.log(\n      `${INFO_PREFIX} Filtered ${label} to ${filtered.length}/${items.length} items within ` +\n        `${dateRange.start}..${dateRange.end}`\n    );\n  }\n\n  return filtered;\n}\n\n/**\n * Apply a date-range filter across all breaking-news feed arrays.\n *\n * @param feedData - Feed data to filter\n * @param dateRange - Inclusive UTC window, or undefined to keep all items\n * @returns Filtered feed payload\n */\nfunction filterBreakingNewsFeedDataByDateRange(\n  feedData: BreakingNewsFeedData,\n  dateRange: DateRange | undefined\n): BreakingNewsFeedData {\n  const filteredMEPUpdates = filterFeedItemsByDateRange(\n    feedData.mepUpdates,\n    dateRange,\n    'MEP updates'\n  );\n  return {\n    adoptedTexts: filterFeedItemsByDateRange(feedData.adoptedTexts, dateRange, 'adopted texts'),\n    events: filterFeedItemsByDateRange(feedData.events, dateRange, 'events'),\n    procedures: filterFeedItemsByDateRange(feedData.procedures, dateRange, 'procedures'),\n    mepUpdates: filteredMEPUpdates,\n    // When a date-range filter is applied the API-reported total covers the full\n    // feed window, not the filtered subset — clear it to avoid a misleading\n    // truncation note (\"showing 10 of 525\" on a single-day slice).\n    totalMEPUpdates: dateRange === undefined ? feedData.totalMEPUpdates : undefined,\n  };\n}\n\n/**\n * Apply a date-range filter across all comprehensive EP feed arrays.\n *\n * @param feedData - Feed data to filter\n * @param dateRange - Inclusive UTC window, or undefined to keep all items\n * @returns Filtered feed payload\n */\nfunction filterEPFeedDataByDateRange(\n  feedData: EPFeedData,\n  dateRange: DateRange | undefined\n): EPFeedData {\n  return {\n    adoptedTexts: filterFeedItemsByDateRange(feedData.adoptedTexts, dateRange, 'adopted texts'),\n    events: filterFeedItemsByDateRange(feedData.events, dateRange, 'events'),\n    procedures: filterFeedItemsByDateRange(feedData.procedures, dateRange, 'procedures'),\n    mepUpdates: filterFeedItemsByDateRange(feedData.mepUpdates, dateRange, 'MEP updates'),\n    documents: filterFeedItemsByDateRange(feedData.documents, dateRange, 'documents'),\n    plenaryDocuments: filterFeedItemsByDateRange(\n      feedData.plenaryDocuments,\n      dateRange,\n      'plenary documents'\n    ),\n    committeeDocuments: filterFeedItemsByDateRange(\n      feedData.committeeDocuments,\n      dateRange,\n      'committee documents'\n    ),\n    plenarySessionDocuments: filterFeedItemsByDateRange(\n      feedData.plenarySessionDocuments,\n      dateRange,\n      'plenary session documents'\n    ),\n    externalDocuments: filterFeedItemsByDateRange(\n      feedData.externalDocuments,\n      dateRange,\n      'external documents'\n    ),\n    questions: filterFeedItemsByDateRange(feedData.questions, dateRange, 'questions'),\n    declarations: filterFeedItemsByDateRange(feedData.declarations, dateRange, 'declarations'),\n    corporateBodies: filterFeedItemsByDateRange(\n      feedData.corporateBodies,\n      dateRange,\n      'corporate bodies'\n    ),\n  };\n}\n\n/**\n * Compute an inclusive UTC date window ending on `endDate`.\n *\n * @param endDate - Inclusive UTC end date in `YYYY-MM-DD` form\n * @param lookbackDays - Number of calendar days to subtract for the start date\n * @param context - Label used in error messages\n * @returns Inclusive date range\n */\nexport function computeRollingDateRange(\n  endDate: string,\n  lookbackDays: number,\n  context: string\n): DateRange {\n  const startDate = new Date(`${endDate}T00:00:00Z`);\n  startDate.setUTCDate(startDate.getUTCDate() - lookbackDays);\n  const startDateParts = startDate.toISOString().split('T');\n  if (!startDateParts[0]) {\n    throw new Error(`Invalid date format generated for ${context}`);\n  }\n  return { start: startDateParts[0], end: endDate };\n}\n\n// ─── MCP client initialisation ───────────────────────────────────────────────\n\n/**\n * Attempt to connect to the European Parliament MCP server.\n * Returns `null` (with a warning) if the connection fails or MCP is disabled.\n *\n * @param useMCP - Whether MCP should be used at all\n * @returns Connected client or null\n */\nexport async function initializeMCPClient(\n  useMCP: boolean\n): Promise<EuropeanParliamentMCPClient | null> {\n  if (!useMCP) {\n    console.log(`${INFO_PREFIX} MCP client disabled via USE_EP_MCP=false`);\n    return null;\n  }\n  try {\n    console.log('🔌 Attempting to connect to European Parliament MCP Server...');\n    const client = await getEPMCPClient();\n    console.log('✅ MCP client connected successfully');\n    return client;\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn('⚠️ Could not connect to MCP server:', message);\n    console.warn('⚠️ Falling back to placeholder content');\n    return null;\n  }\n}\n\n// ─── Pre-fetched feed data loading ───────────────────────────────────────────\n\n/**\n * Check whether a value is a non-null, non-array plain object.\n *\n * @param v - Value to check\n * @returns True when v is a plain object\n */\nfunction isPlainObject(v: unknown): v is Record<string, unknown> {\n  return typeof v === 'object' && v !== null && !Array.isArray(v);\n}\n\n/**\n * Sanitize an array of raw items into feed items with title-based required fields.\n * Filters out non-objects and coerces `id`, `title`, `date` to strings.\n *\n * Uses `as unknown as T` because the spread preserves optional properties from\n * the source JSON while the explicit field assignments guarantee the required\n * base fields — TypeScript cannot infer this mixed provenance automatically.\n *\n * @param items - Raw array of unknown values from JSON\n * @returns Sanitized array of typed feed items\n */\nfunction sanitizeTitleItems<T extends { id: string; title: string; date: string }>(\n  items: readonly unknown[]\n): T[] {\n  return items\n    .filter(isPlainObject)\n    .filter(\n      (item) =>\n        (item['id'] !== undefined && item['id'] !== null) ||\n        (item['title'] !== undefined && item['title'] !== null)\n    )\n    .map(\n      (item) =>\n        ({\n          ...item,\n          id: String(item['id'] ?? ''),\n          title: String(item['title'] ?? ''),\n          date: String(item['date'] ?? ''),\n        }) as unknown as T\n    );\n}\n\n/**\n * Sanitize an array of raw items into MEP feed items.\n * Filters out non-objects and coerces `id`, `name`, `date` to strings.\n *\n * @param items - Raw array of unknown values from JSON\n * @returns Sanitized array of MEP feed items\n */\nfunction sanitizeMEPItems(items: readonly unknown[]): MEPFeedItem[] {\n  return items\n    .filter(isPlainObject)\n    .filter(\n      (item) =>\n        (item['id'] !== undefined && item['id'] !== null) ||\n        (item['name'] !== undefined && item['name'] !== null)\n    )\n    .map(\n      (item) =>\n        ({\n          ...item,\n          id: String(item['id'] ?? ''),\n          name: String(item['name'] ?? ''),\n          date: String(item['date'] ?? ''),\n        }) as unknown as MEPFeedItem\n    );\n}\n\n/**\n * Load pre-fetched feed data from a JSON file on disk.\n *\n * Agentic workflows fetch EP data via framework MCP tools but the TypeScript\n * generator cannot access those tools directly.  The workflow saves the MCP\n * results to a JSON file and the generator reads them via this function,\n * avoiding the need to manually construct article HTML.\n *\n * The file must contain a JSON object. The optional keys\n * `adoptedTexts`, `events`, `procedures`, and `mepUpdates` are treated as\n * arrays and default to empty arrays when missing (an empty object `{}` is valid).\n *\n * @param filePath - Absolute or relative path to the JSON file\n * @param dateRange - Optional inclusive UTC window for filtering loaded items\n * @returns Parsed {@link BreakingNewsFeedData}, or `undefined` on any error\n */\nexport function loadFeedDataFromFile(\n  filePath: string,\n  dateRange?: DateRange\n): BreakingNewsFeedData | undefined {\n  try {\n    if (!fs.existsSync(filePath)) {\n      console.warn(`${WARN_PREFIX} Feed data file not found: ${filePath}`);\n      return undefined;\n    }\n    const raw = fs.readFileSync(filePath, 'utf-8');\n    const parsed: unknown = JSON.parse(raw);\n    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n      console.warn(`${WARN_PREFIX} Feed data file must contain a JSON object`);\n      return undefined;\n    }\n    const obj = parsed as Record<string, unknown>;\n    const adoptedTexts = sanitizeTitleItems<AdoptedTextFeedItem>(\n      Array.isArray(obj['adoptedTexts']) ? obj['adoptedTexts'] : []\n    );\n    const events = sanitizeTitleItems<EventFeedItem>(\n      Array.isArray(obj['events']) ? obj['events'] : []\n    );\n    const procedures = sanitizeTitleItems<ProcedureFeedItem>(\n      Array.isArray(obj['procedures']) ? obj['procedures'] : []\n    );\n    const mepUpdates = sanitizeMEPItems(Array.isArray(obj['mepUpdates']) ? obj['mepUpdates'] : []);\n    const totalMEPUpdates =\n      typeof obj['totalMEPUpdates'] === 'number' ? obj['totalMEPUpdates'] : undefined;\n    const filteredData = filterBreakingNewsFeedDataByDateRange(\n      {\n        adoptedTexts,\n        events,\n        procedures,\n        mepUpdates,\n        totalMEPUpdates,\n      },\n      dateRange\n    );\n    console.log(\n      `${INFO_PREFIX} Loaded feed data from file: ` +\n        `${filteredData.adoptedTexts.length} adopted texts, ${filteredData.events.length} events, ` +\n        `${filteredData.procedures.length} procedures, ${filteredData.mepUpdates.length} MEP updates`\n    );\n    return filteredData;\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} Failed to load feed data from file: ${message}`);\n    return undefined;\n  }\n}\n\n/**\n * Load pre-fetched comprehensive EP feed data from a JSON file on disk.\n *\n * Agentic workflows fetch EP data via framework MCP tools but the TypeScript\n * generator cannot access those tools directly.  The workflow saves the MCP\n * results to a JSON file and the generator reads them via this function,\n * avoiding the need to manually construct article HTML.\n *\n * The file must contain a JSON object with EP feed data keys.\n * Missing keys default to empty arrays.\n *\n * @param filePath - Absolute or relative path to the JSON file\n * @param dateRange - Optional inclusive UTC window for filtering loaded items\n * @returns Parsed {@link EPFeedData}, or `undefined` on any error\n */\nexport function loadEPFeedDataFromFile(\n  filePath: string,\n  dateRange?: DateRange\n): EPFeedData | undefined {\n  try {\n    if (!fs.existsSync(filePath)) {\n      console.warn(`${WARN_PREFIX} EP feed data file not found: ${filePath}`);\n      return undefined;\n    }\n    const raw = fs.readFileSync(filePath, 'utf-8');\n    const parsed: unknown = JSON.parse(raw);\n    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n      console.warn(`${WARN_PREFIX} EP feed data file must contain a JSON object`);\n      return undefined;\n    }\n    const obj = parsed as Record<string, unknown>;\n    const safeArray = (key: string): readonly unknown[] =>\n      Array.isArray(obj[key]) ? (obj[key] as unknown[]) : [];\n    const adoptedTexts = sanitizeTitleItems<AdoptedTextFeedItem>(safeArray('adoptedTexts'));\n    const events = sanitizeTitleItems<EventFeedItem>(safeArray('events'));\n    const procedures = sanitizeTitleItems<ProcedureFeedItem>(safeArray('procedures'));\n    const mepUpdates = sanitizeMEPItems(safeArray('mepUpdates'));\n    const documents = sanitizeTitleItems<DocumentFeedItem>(safeArray('documents'));\n    const plenaryDocuments = sanitizeTitleItems<DocumentFeedItem>(safeArray('plenaryDocuments'));\n    const committeeDocuments = sanitizeTitleItems<DocumentFeedItem>(\n      safeArray('committeeDocuments')\n    );\n    const plenarySessionDocuments = sanitizeTitleItems<DocumentFeedItem>(\n      safeArray('plenarySessionDocuments')\n    );\n    const externalDocuments = sanitizeTitleItems<DocumentFeedItem>(safeArray('externalDocuments'));\n    const questions = sanitizeTitleItems<QuestionFeedItem>(safeArray('questions'));\n    const declarations = sanitizeTitleItems<DeclarationFeedItem>(safeArray('declarations'));\n    const corporateBodies = sanitizeTitleItems<CorporateBodyFeedItem>(safeArray('corporateBodies'));\n    const filteredData = filterEPFeedDataByDateRange(\n      {\n        adoptedTexts,\n        events,\n        procedures,\n        mepUpdates,\n        documents,\n        plenaryDocuments,\n        committeeDocuments,\n        plenarySessionDocuments,\n        externalDocuments,\n        questions,\n        declarations,\n        corporateBodies,\n      },\n      dateRange\n    );\n    const totalItems =\n      filteredData.adoptedTexts.length +\n      filteredData.events.length +\n      filteredData.procedures.length +\n      filteredData.mepUpdates.length +\n      filteredData.documents.length +\n      filteredData.plenaryDocuments.length +\n      filteredData.committeeDocuments.length +\n      filteredData.plenarySessionDocuments.length +\n      filteredData.externalDocuments.length +\n      filteredData.questions.length +\n      filteredData.declarations.length +\n      filteredData.corporateBodies.length;\n    console.log(\n      `${INFO_PREFIX} Loaded EP feed data from file: ${totalItems} total items across 12 keys`\n    );\n    return filteredData;\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} Failed to load EP feed data from file: ${message}`);\n    return undefined;\n  }\n}\n\n// ─── Week-Ahead fetches ──────────────────────────────────────────────────────\n\n/**\n * Fetch aggregated week-ahead data from multiple MCP sources in parallel.\n * Returns placeholder data when the client is unavailable.\n *\n * @param client - MCP client or null\n * @param dateRange - Date range for the week-ahead period\n * @returns Aggregated week-ahead data\n */\nexport async function fetchWeekAheadData(\n  client: EuropeanParliamentMCPClient | null,\n  dateRange: DateRange\n): Promise<WeekAheadData> {\n  if (!client) {\n    console.log(`${INFO_PREFIX} MCP unavailable — using placeholder events`);\n    return {\n      events: PLACEHOLDER_EVENTS.map((e) => ({ ...e, date: dateRange.start })),\n      committees: [],\n      documents: [],\n      pipeline: [],\n      questions: [],\n    };\n  }\n\n  if (!mcpCircuitBreaker.canRequest()) {\n    console.warn(\n      `${WARN_PREFIX} Circuit breaker not accepting requests (${mcpCircuitBreaker.getState()}) — using placeholder events`\n    );\n    return {\n      events: PLACEHOLDER_EVENTS.map((e) => ({ ...e, date: dateRange.start })),\n      committees: [],\n      documents: [],\n      pipeline: [],\n      questions: [],\n    };\n  }\n\n  // Record whether we entered as a HALF_OPEN probe so any rejection triggers\n  // an immediate re-open (normal circuit-breaker probe semantics).\n  const wasHalfOpen = mcpCircuitBreaker.getState() === 'HALF_OPEN';\n\n  console.log(`${MCP_FETCH_PREFIX} Fetching week-ahead data from MCP (parallel)...`);\n\n  const [plenarySessions, committeeInfo, documents, pipeline, questions, epEvents] =\n    await Promise.allSettled([\n      client.getPlenarySessions({ dateFrom: dateRange.start, dateTo: dateRange.end, limit: 50 }),\n      client.getCommitteeInfo({ showCurrent: true }),\n      client.searchDocuments({ keyword: 'parliament', limit: 20 }),\n      client.monitorLegislativePipeline({\n        dateFrom: dateRange.start,\n        dateTo: dateRange.end,\n        status: 'ACTIVE',\n        limit: 20,\n      }),\n      client.getParliamentaryQuestions({ dateFrom: dateRange.start, limit: 20 }),\n      client.getEvents({ limit: 20 }),\n    ]);\n\n  const allFailed = [\n    plenarySessions,\n    committeeInfo,\n    documents,\n    pipeline,\n    questions,\n    epEvents,\n  ].every((r) => r.status === 'rejected');\n  const anyFailed = [plenarySessions, committeeInfo, documents, pipeline, questions, epEvents].some(\n    (r) => r.status === 'rejected'\n  );\n  // In HALF_OPEN any single rejection means the probe failed — re-open immediately.\n  if (allFailed || (wasHalfOpen && anyFailed)) {\n    mcpCircuitBreaker.recordFailure();\n  } else {\n    mcpCircuitBreaker.recordSuccess();\n  }\n\n  const plenaryEvents = parsePlenarySessions(plenarySessions, dateRange.start);\n  const additionalEvents = parseEPEvents(epEvents, dateRange.start);\n  const events = [...plenaryEvents, ...additionalEvents];\n\n  return {\n    events:\n      events.length > 0 ? events : PLACEHOLDER_EVENTS.map((e) => ({ ...e, date: dateRange.start })),\n    committees: parseCommitteeMeetings(committeeInfo, dateRange.start),\n    documents: parseLegislativeDocuments(documents),\n    pipeline: parseLegislativePipeline(pipeline),\n    questions: parseParliamentaryQuestions(questions),\n  };\n}\n\n// ─── Breaking-News fetches ───────────────────────────────────────────────────\n\n/**\n * Fetch voting anomaly text from MCP, returning empty string on failure.\n *\n * @param client - MCP client or null\n * @returns Raw anomaly data text\n */\nexport async function fetchVotingAnomalies(\n  client: EuropeanParliamentMCPClient | null\n): Promise<string> {\n  if (!client) return '';\n  try {\n    const result = await callMCP(\n      () => client.callTool('detect_voting_anomalies', { sensitivityThreshold: 0.3 }),\n      undefined,\n      'detect_voting_anomalies'\n    );\n    return result?.content?.[0]?.text ?? '';\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} detect_voting_anomalies failed:`, message);\n    return '';\n  }\n}\n\n/**\n * Fetch coalition dynamics analysis text from MCP.\n *\n * @param client - MCP client or null\n * @returns Raw coalition dynamics text\n */\nexport async function fetchCoalitionDynamics(\n  client: EuropeanParliamentMCPClient | null\n): Promise<string> {\n  if (!client) return '';\n  try {\n    const result = await callMCP(\n      () => client.callTool('analyze_coalition_dynamics', {}),\n      undefined,\n      'analyze_coalition_dynamics'\n    );\n    return result?.content?.[0]?.text ?? '';\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} analyze_coalition_dynamics failed:`, message);\n    return '';\n  }\n}\n\n/**\n * Fetch voting statistics report text from MCP.\n *\n * @param client - MCP client or null\n * @returns Raw voting report text\n */\nexport async function fetchVotingReport(\n  client: EuropeanParliamentMCPClient | null\n): Promise<string> {\n  if (!client) return '';\n  try {\n    const result = await callMCP(\n      () => client.callTool('generate_report', { reportType: 'VOTING_STATISTICS' }),\n      undefined,\n      'generate_report'\n    );\n    return result?.content?.[0]?.text ?? '';\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} generate_report failed:`, message);\n    return '';\n  }\n}\n\n/**\n * Fetch MEP influence assessment text from MCP.\n * Short-circuits immediately when `mepId` is empty.\n *\n * @param client - MCP client or null\n * @param mepId - MEP identifier; pass empty string to skip the call\n * @returns Raw influence data text\n */\nexport async function fetchMEPInfluence(\n  client: EuropeanParliamentMCPClient | null,\n  mepId: string\n): Promise<string> {\n  if (!mepId || !client) return '';\n  try {\n    const result = await callMCP(\n      () => client.callTool('assess_mep_influence', { mepId, includeDetails: true }),\n      undefined,\n      'assess_mep_influence'\n    );\n    return result?.content?.[0]?.text ?? '';\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} assess_mep_influence failed:`, message);\n    return '';\n  }\n}\n\n// ─── Committee-Reports fetches ───────────────────────────────────────────────\n\n/**\n * Load pre-fetched committee data for a given abbreviation from a JSON file.\n *\n * The file must be a JSON object keyed by committee abbreviation, where each\n * value conforms to {@link CommitteeData}.  This allows agentic workflows to\n * inject real EP committee data into the generator without a live MCP\n * connection (same pattern as {@link loadEPFeedDataFromFile}).\n *\n * @param filePath - Path to the JSON file\n * @param abbreviation - Committee code (e.g. `\"ENVI\"`)\n * @returns Parsed {@link CommitteeData} for the committee, or `undefined`\n */\nexport function loadCommitteeDataFromFile(\n  filePath: string,\n  abbreviation: string\n): CommitteeData | undefined {\n  try {\n    if (!fs.existsSync(filePath)) {\n      console.warn(`${WARN_PREFIX} Committee data file not found: ${filePath}`);\n      return undefined;\n    }\n    const raw = fs.readFileSync(filePath, 'utf-8');\n    const parsed: unknown = JSON.parse(raw);\n    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n      console.warn(`${WARN_PREFIX} Committee data file must contain a JSON object`);\n      return undefined;\n    }\n    const obj = parsed as Record<string, unknown>;\n    const entry = obj[abbreviation];\n    if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {\n      return undefined;\n    }\n    const e = entry as Record<string, unknown>;\n    const docs = Array.isArray(e['documents'])\n      ? (e['documents'] as unknown[])\n          .filter(\n            (d): d is Record<string, unknown> =>\n              typeof d === 'object' && d !== null && !Array.isArray(d)\n          )\n          .map((doc) => ({\n            title: typeof doc['title'] === 'string' ? doc['title'] : 'Document',\n            type: typeof doc['type'] === 'string' ? doc['type'] : 'Document',\n            date: typeof doc['date'] === 'string' ? doc['date'] : '',\n          }))\n      : [];\n    const result: CommitteeData = {\n      name: typeof e['name'] === 'string' ? e['name'] : `${abbreviation} Committee`,\n      abbreviation,\n      chair: typeof e['chair'] === 'string' ? e['chair'] : 'N/A',\n      members: typeof e['members'] === 'number' && Number.isFinite(e['members']) ? e['members'] : 0,\n      documents: docs,\n      effectiveness: typeof e['effectiveness'] === 'string' ? e['effectiveness'] : null,\n    };\n    console.log(\n      `${INFO_PREFIX} Loaded committee data from file: ${result.name} (${docs.length} documents)`\n    );\n    return result;\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} Failed to load committee data from file: ${message}`);\n    return undefined;\n  }\n}\n\n/**\n * Try to load committee data from the `EP_COMMITTEE_DATA_FILE` env var.\n * Returns the loaded {@link CommitteeData} when available, or `undefined` when\n * the env var is unset or the file does not contain an entry for the given\n * committee abbreviation.  Logs a warning when the file exists but the entry\n * is missing so callers can fall through to an MCP fetch.\n *\n * @param abbreviation - Committee code (e.g. `\"ENVI\"`)\n * @returns Pre-fetched committee data or `undefined`\n */\nfunction tryLoadCommitteeDataFromEnv(abbreviation: string): CommitteeData | undefined {\n  const filePath = process.env['EP_COMMITTEE_DATA_FILE'];\n  if (!filePath) return undefined;\n  const data = loadCommitteeDataFromFile(filePath, abbreviation);\n  if (!data && fs.existsSync(filePath)) {\n    console.warn(\n      `${WARN_PREFIX} Committee data for ${abbreviation} not found in file — falling through to MCP fetch`\n    );\n  }\n  return data;\n}\n\n// ─── EP v2 API direct fallback ──────────────────────────────────────────────\n\n/** Base URL for the EP Open Data Portal v2 API */\nconst EP_API_V2_BASE = 'https://data.europarl.europa.eu/api/v2';\n\n/** Timeout for direct EP API requests (ms) */\nconst EP_API_TIMEOUT_MS = 15_000;\n\n/**\n * Shape of a corporate body item from the EP Open Data Portal v2 API.\n * Represents the structure returned by `GET /corporate-bodies/{abbreviation}`.\n */\ninterface EPCorporateBodyItem {\n  id?: string;\n  label?: string;\n  prefLabel?: Record<string, string>;\n  altLabel?: Record<string, string>;\n  classification?: string;\n  inverse_isVersionOf?: string[];\n}\n\n/**\n * Fetch committee info directly from the EP v2 API as a fallback when MCP\n * returns placeholder data.  Uses `GET /corporate-bodies/{abbreviation}` which\n * is the canonical lookup for a committee by its code (e.g. `ENVI`).\n *\n * This function is intentionally conservative: it primarily populates `name`\n * and `abbreviation`, and may populate `members` from `inverse_isVersionOf`\n * when available. Placeholder status is broken by changing `members` from `0`\n * (placeholder criteria is chair='N/A' AND members=0 AND docs=[]).\n *\n * @param abbreviation - Committee abbreviation (e.g. `\"ENVI\"`)\n * @param data - Existing committee data to enrich\n */\nexport async function fetchCommitteeInfoFromEPAPI(\n  abbreviation: string,\n  data: CommitteeData\n): Promise<void> {\n  const url = `${EP_API_V2_BASE}/corporate-bodies/${encodeURIComponent(abbreviation)}?format=application%2Fld%2Bjson`;\n  try {\n    const controller = new AbortController();\n    const timer = setTimeout(() => controller.abort(), EP_API_TIMEOUT_MS);\n    let response: Response;\n    try {\n      response = await fetch(url, {\n        signal: controller.signal,\n        headers: { Accept: 'application/ld+json' },\n      });\n    } finally {\n      clearTimeout(timer);\n    }\n    if (!response.ok) {\n      console.warn(\n        `${WARN_PREFIX} EP API direct lookup for ${abbreviation} returned ${String(response.status)}`\n      );\n      return;\n    }\n    const body = (await response.json()) as { data?: EPCorporateBodyItem[] };\n    const items = body.data;\n    if (!Array.isArray(items) || items.length === 0) return;\n    const item = items[0];\n    if (!item) return;\n\n    // Extract name: prefer English prefLabel, then English altLabel, then label\n    const name = item.prefLabel?.['en'] ?? item.altLabel?.['en'] ?? item.label ?? undefined;\n    if (name && name.length > 0) {\n      data.name = name;\n    }\n\n    // Extract abbreviation: the label field holds the abbreviation code\n    const abbr = item.label;\n    if (abbr && abbr.length > 0 && !abbr.startsWith('org/')) {\n      data.abbreviation = abbr;\n    }\n\n    console.log(`  ✅ EP API fallback: ${data.name} (${data.abbreviation})`);\n  } catch (err) {\n    const message = err instanceof Error ? err.message : String(err);\n    console.warn(`${WARN_PREFIX} EP API direct fallback failed for ${abbreviation}:`, message);\n  }\n}\n\n/**\n * Check whether a single committee data entry is still in placeholder state.\n *\n * @param data - Committee data to inspect\n * @returns `true` when the entry matches all placeholder criteria\n */\nfunction isPlaceholderEntry(data: CommitteeData): boolean {\n  return (\n    data.chair === PLACEHOLDER_CHAIR &&\n    data.members === PLACEHOLDER_MEMBERS &&\n    data.documents.length === 0\n  );\n}\n\n/**\n * Fetch committee data from three MCP sources for the given abbreviation.\n * Each source failure is caught individually so partial data is still returned.\n *\n * When the environment variable `EP_COMMITTEE_DATA_FILE` is set, pre-fetched\n * committee data is loaded from that JSON file instead of calling the MCP\n * client.  This enables agentic workflows to inject real EP data.\n *\n * When MCP returns placeholder data (chair=N/A, members=0, docs=[]),\n * a direct call to the EP v2 API is attempted as a fallback to populate\n * at least the committee name and abbreviation.\n *\n * @param client - MCP client or null\n * @param abbreviation - Committee code (e.g. `\"ENVI\"`)\n * @returns Populated committee data\n */\nexport async function fetchCommitteeData(\n  client: EuropeanParliamentMCPClient | null,\n  abbreviation: string\n): Promise<CommitteeData> {\n  const defaultResult: CommitteeData = {\n    name: `${abbreviation} Committee`,\n    abbreviation,\n    chair: 'N/A',\n    members: 0,\n    documents: [],\n    effectiveness: null,\n  };\n\n  // Check for pre-fetched committee data file (set by EP_COMMITTEE_DATA_FILE env var).\n  // This mirrors the EP_FEED_DATA_FILE pattern for fetchEPFeedData.\n  const fromFile = tryLoadCommitteeDataFromEnv(abbreviation);\n  if (fromFile) return fromFile;\n\n  if (!client) return defaultResult;\n\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching committee info for ${abbreviation}...`);\n    const committeeResult = await callMCP(\n      () => client.getCommitteeInfo({ abbreviation }),\n      null,\n      `getCommitteeInfo(${abbreviation})`\n    );\n    if (committeeResult) applyCommitteeInfo(committeeResult, defaultResult, abbreviation);\n  } catch (err) {\n    const message = err instanceof Error ? err.message : String(err);\n    console.warn(`${WARN_PREFIX} getCommitteeInfo failed for ${abbreviation}:`, message);\n  }\n\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching documents for ${abbreviation}...`);\n    const docsResult = await callMCP(\n      () => client.searchDocuments({ keyword: abbreviation, limit: 5 }),\n      null,\n      `searchDocuments(${abbreviation})`\n    );\n    if (docsResult) applyDocuments(docsResult, defaultResult);\n  } catch (err) {\n    const message = err instanceof Error ? err.message : String(err);\n    console.warn(`${WARN_PREFIX} searchDocuments failed for ${abbreviation}:`, message);\n  }\n\n  try {\n    const effectivenessResult = await callMCP(\n      () =>\n        client.analyzeLegislativeEffectiveness({\n          subjectType: 'COMMITTEE',\n          subjectId: abbreviation,\n        }),\n      null,\n      `analyzeLegislativeEffectiveness(${abbreviation})`\n    );\n    if (effectivenessResult) applyEffectiveness(effectivenessResult, defaultResult);\n  } catch (err) {\n    const message = err instanceof Error ? err.message : String(err);\n    console.warn(\n      `${WARN_PREFIX} analyzeLegislativeEffectiveness failed for ${abbreviation}:`,\n      message\n    );\n  }\n\n  // Fallback: when MCP left the committee in placeholder state, try the EP v2\n  // API directly.  This provides resilience when the MCP server has issues\n  // parsing committee data (see European-Parliament-MCP-Server#233).\n  if (isPlaceholderEntry(defaultResult)) {\n    console.log(\n      `${INFO_PREFIX} Committee ${abbreviation} still placeholder after MCP — trying EP v2 API directly...`\n    );\n    await fetchCommitteeInfoFromEPAPI(abbreviation, defaultResult);\n  }\n\n  return defaultResult;\n}\n\n// ─── Motions fetches ─────────────────────────────────────────────────────────\n\n/**\n * Fetch recent voting records from MCP.\n *\n * @param client - MCP client or null\n * @param dateFromStr - Start date (YYYY-MM-DD)\n * @param dateStr - End date (YYYY-MM-DD)\n * @returns Array of voting records\n */\nexport async function fetchVotingRecords(\n  client: EuropeanParliamentMCPClient | null,\n  dateFromStr: string,\n  dateStr: string\n): Promise<VotingRecord[]> {\n  if (!client) return [];\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching voting records from MCP server...`);\n    const votingResult = (await callMCP(\n      () =>\n        client.callTool('get_voting_records', {\n          dateFrom: dateFromStr,\n          dateTo: dateStr,\n          limit: 20,\n        }),\n      undefined,\n      'get_voting_records'\n    )) as MCPToolResult | undefined;\n\n    if (votingResult?.content?.[0]) {\n      const data = parseJSON<{\n        records?: Array<{\n          title?: string | undefined;\n          date?: string | undefined;\n          result?: string | undefined;\n          votes?:\n            | {\n                for?: number | undefined;\n                against?: number | undefined;\n                abstain?: number | undefined;\n              }\n            | undefined;\n        }>;\n      }>(votingResult.content[0].text, 'voting records');\n\n      if (data?.records && data.records.length > 0) {\n        console.log(`  ✅ Fetched ${data.records.length} voting records from MCP`);\n        return data.records.map((r) => ({\n          title: r.title ?? 'Parliamentary Vote',\n          date: r.date ?? dateStr,\n          result: r.result ?? 'Adopted',\n          votes: {\n            for: r.votes?.for ?? 0,\n            against: r.votes?.against ?? 0,\n            abstain: r.votes?.abstain ?? 0,\n          },\n        }));\n      }\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} MCP voting records fetch failed:`, message);\n  }\n  return [];\n}\n\n/**\n * Fetch voting patterns from MCP.\n *\n * @param client - MCP client or null\n * @param dateFromStr - Start date\n * @param dateStr - End date\n * @returns Array of voting patterns\n */\nexport async function fetchVotingPatterns(\n  client: EuropeanParliamentMCPClient | null,\n  dateFromStr: string,\n  dateStr: string\n): Promise<VotingPattern[]> {\n  if (!client) return [];\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching voting patterns from MCP server...`);\n    const patternsResult = (await callMCP(\n      () =>\n        client.callTool('analyze_voting_patterns', {\n          dateFrom: dateFromStr,\n          dateTo: dateStr,\n        }),\n      undefined,\n      'analyze_voting_patterns'\n    )) as MCPToolResult | undefined;\n\n    if (patternsResult?.content?.[0]) {\n      const data = parseJSON<{\n        patterns?:\n          | Array<{\n              group?: string | undefined;\n              cohesion?: number | undefined;\n              participation?: number | undefined;\n            }>\n          | undefined;\n      }>(patternsResult.content[0].text, 'voting patterns');\n\n      if (data?.patterns && data.patterns.length > 0) {\n        console.log(`  ✅ Fetched ${data.patterns.length} voting patterns from MCP`);\n        return data.patterns.map((p) => ({\n          group: p.group ?? 'Unknown Group',\n          cohesion: p.cohesion ?? 0,\n          participation: p.participation ?? 0,\n        }));\n      }\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} MCP voting patterns fetch failed:`, message);\n  }\n  return [];\n}\n\n/**\n * Fetch voting anomalies for a date range from MCP.\n *\n * @param client - MCP client or null\n * @param dateFromStr - Start date\n * @param dateStr - End date\n * @returns Array of voting anomalies\n */\nexport async function fetchMotionsAnomalies(\n  client: EuropeanParliamentMCPClient | null,\n  dateFromStr: string,\n  dateStr: string\n): Promise<VotingAnomaly[]> {\n  if (!client) return [];\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching voting anomalies from MCP server...`);\n    const anomaliesResult = (await callMCP(\n      () =>\n        client.callTool('detect_voting_anomalies', {\n          dateFrom: dateFromStr,\n          dateTo: dateStr,\n        }),\n      undefined,\n      'detect_voting_anomalies'\n    )) as MCPToolResult | undefined;\n\n    if (anomaliesResult?.content?.[0]) {\n      const data = parseJSON<{\n        anomalies?:\n          | Array<{\n              type?: string | undefined;\n              description?: string | undefined;\n              severity?: string | undefined;\n            }>\n          | undefined;\n      }>(anomaliesResult.content[0].text, 'voting anomalies');\n\n      if (data?.anomalies && data.anomalies.length > 0) {\n        console.log(`  ✅ Fetched ${data.anomalies.length} voting anomalies from MCP`);\n        return data.anomalies.map((a) => ({\n          type: a.type ?? 'Unusual Pattern',\n          description: a.description ?? 'No description available',\n          severity: a.severity ?? 'MEDIUM',\n        }));\n      }\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} MCP voting anomalies fetch failed:`, message);\n  }\n  return [];\n}\n\n/**\n * Fetch parliamentary questions from MCP for the given date range.\n *\n * @param client - MCP client or null\n * @param dateFromStr - Start date\n * @param dateStr - End date\n * @returns Array of parliamentary questions\n */\nexport async function fetchParliamentaryQuestionsForMotions(\n  client: EuropeanParliamentMCPClient | null,\n  dateFromStr: string,\n  dateStr: string\n): Promise<MotionsQuestion[]> {\n  if (!client) return [];\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching parliamentary questions from MCP server...`);\n    const questionsResult = await callMCP(\n      () =>\n        client.getParliamentaryQuestions({\n          dateFrom: dateFromStr,\n          dateTo: dateStr,\n          limit: 10,\n        }),\n      undefined,\n      'get_parliamentary_questions'\n    );\n\n    if (questionsResult?.content?.[0]) {\n      const data = parseJSON<{\n        questions?: Array<{\n          author?: string | undefined;\n          topic?: string | undefined;\n          subject?: string | undefined;\n          date?: string | undefined;\n          status?: string | undefined;\n        }>;\n      }>(questionsResult.content[0].text, 'parliamentary questions');\n\n      if (data?.questions && data.questions.length > 0) {\n        console.log(`  ✅ Fetched ${data.questions.length} parliamentary questions from MCP`);\n        return data.questions.map((q) => ({\n          author: q.author ?? 'Unknown MEP',\n          topic: q.topic ?? q.subject ?? 'General inquiry',\n          date: q.date ?? dateStr,\n          status: q.status ?? 'PENDING',\n        }));\n      }\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} MCP parliamentary questions fetch failed:`, message);\n  }\n  return [];\n}\n\n/**\n * Fetch all motions data in parallel, applying fallback arrays for any\n * section where MCP returned nothing.\n *\n * @param client - MCP client or null\n * @param dateFromStr - Start date\n * @param dateStr - End date\n * @returns All motions data with fallbacks applied\n */\nexport async function fetchMotionsData(\n  client: EuropeanParliamentMCPClient | null,\n  dateFromStr: string,\n  dateStr: string\n): Promise<{\n  votingRecords: VotingRecord[];\n  votingPatterns: VotingPattern[];\n  anomalies: VotingAnomaly[];\n  questions: MotionsQuestion[];\n}> {\n  const [votingRecordsResult, votingPatternsResult, anomaliesResult, questionsResult] =\n    await Promise.allSettled([\n      fetchVotingRecords(client, dateFromStr, dateStr),\n      fetchVotingPatterns(client, dateFromStr, dateStr),\n      fetchMotionsAnomalies(client, dateFromStr, dateStr),\n      fetchParliamentaryQuestionsForMotions(client, dateFromStr, dateStr),\n    ]);\n\n  let votingRecords: VotingRecord[] =\n    votingRecordsResult.status === 'fulfilled' ? votingRecordsResult.value : [];\n  if (votingRecordsResult.status === 'rejected') {\n    console.warn(`${WARN_PREFIX} Failed to fetch voting records from MCP`);\n  }\n\n  let votingPatterns: VotingPattern[] =\n    votingPatternsResult.status === 'fulfilled' ? votingPatternsResult.value : [];\n  if (votingPatternsResult.status === 'rejected') {\n    console.warn(`${WARN_PREFIX} Failed to fetch voting patterns from MCP`);\n  }\n\n  let anomalies: VotingAnomaly[] =\n    anomaliesResult.status === 'fulfilled' ? anomaliesResult.value : [];\n  if (anomaliesResult.status === 'rejected') {\n    console.warn(`${WARN_PREFIX} Failed to fetch voting anomalies from MCP`);\n  }\n\n  let questions: MotionsQuestion[] =\n    questionsResult.status === 'fulfilled' ? questionsResult.value : [];\n  if (questionsResult.status === 'rejected') {\n    console.warn(`${WARN_PREFIX} Failed to fetch parliamentary questions from MCP`);\n  }\n\n  const fallback = getMotionsFallbackData(dateStr, dateFromStr);\n\n  if (votingRecords.length === 0) {\n    console.log(`${INFO_PREFIX} Using placeholder voting records`);\n    votingRecords = fallback.votingRecords;\n  }\n  if (votingPatterns.length === 0) {\n    console.log(`${INFO_PREFIX} Using placeholder voting patterns`);\n    votingPatterns = fallback.votingPatterns;\n  }\n  if (anomalies.length === 0) {\n    console.log(`${INFO_PREFIX} Using placeholder voting anomalies`);\n    anomalies = fallback.anomalies;\n  }\n  if (questions.length === 0) {\n    console.log(`${INFO_PREFIX} Using placeholder parliamentary questions`);\n    questions = fallback.questions;\n  }\n\n  return { votingRecords, votingPatterns, anomalies, questions };\n}\n\n// ─── Propositions fetches ─────────────────────────────────────────────────────\n\n/**\n * Fetch legislative proposals from MCP and build pre-sanitised HTML.\n *\n * @param client - MCP client or null\n * @returns Proposals HTML and the first procedure ID found (if any)\n */\nexport async function fetchProposalsFromMCP(\n  client: EuropeanParliamentMCPClient | null\n): Promise<{ html: string; firstProcedureId: string }> {\n  if (!client) return { html: '', firstProcedureId: '' };\n\n  const docsResult = await callMCP(\n    () => client.searchDocuments({ keyword: 'legislative proposal', limit: 10 }),\n    undefined,\n    'search_documents(proposals)'\n  );\n  if (!docsResult?.content?.[0]) return { html: '', firstProcedureId: '' };\n\n  const data = parseJSON<{ documents?: Array<Partial<LegislativeDocument>> }>(\n    docsResult.content[0].text,\n    'proposals'\n  );\n  if (!data?.documents?.length) return { html: '', firstProcedureId: '' };\n\n  console.log(`  ✅ Fetched ${data.documents.length} proposals from MCP`);\n\n  const firstProcedureId =\n    data.documents.find((d) => /\\d{4}\\/\\d+\\(.+\\)/.test(d.id ?? ''))?.id ?? '';\n\n  const html = data.documents\n    .map(\n      (doc) => `\n      <div class=\"proposal-card\">\n        <h3>${escapeHTML(doc.title ?? 'Legislative Proposal')}</h3>\n        <div class=\"proposal-meta\">\n          ${doc.id ? `<span class=\"proposal-id\">${escapeHTML(doc.id)}</span>` : ''}\n          ${doc.date ? `<span class=\"proposal-date\">${escapeHTML(doc.date)}</span>` : ''}\n          ${doc.status ? `<span class=\"proposal-status\">${escapeHTML(doc.status)}</span>` : ''}\n        </div>\n        ${doc.committee ? `<p class=\"proposal-committee\">${escapeHTML(doc.committee)}</p>` : ''}\n        ${doc.rapporteur ? `<p class=\"proposal-rapporteur\">${escapeHTML(doc.rapporteur)}</p>` : ''}\n      </div>`\n    )\n    .join('');\n\n  return { html, firstProcedureId };\n}\n\n/**\n * Fetch active legislative pipeline data from MCP.\n *\n * @param client - MCP client or null\n * @returns Structured pipeline data or null when unavailable\n */\nexport async function fetchPipelineFromMCP(\n  client: EuropeanParliamentMCPClient | null\n): Promise<PipelineData | null> {\n  if (!client) return null;\n\n  const pipelineResult = await callMCP(\n    () => client.monitorLegislativePipeline({ status: 'ACTIVE', limit: 5 }),\n    undefined,\n    'monitor_legislative_pipeline'\n  );\n  if (!pipelineResult?.content?.[0]) return null;\n\n  const pipeData = parseJSON<{\n    pipelineHealthScore?: number | undefined;\n    throughputRate?: number | undefined;\n    procedures?:\n      | Array<{\n          id?: string | undefined;\n          title?: string | undefined;\n          stage?: string | undefined;\n        }>\n      | undefined;\n  }>(pipelineResult.content[0].text, 'pipeline');\n\n  if (!pipeData) return null;\n\n  const healthScore = pipeData.pipelineHealthScore ?? 0;\n  const throughput = pipeData.throughputRate ?? 0;\n  const procRowsHtml =\n    pipeData.procedures\n      ?.map(\n        (proc) => `\n      <div class=\"procedure-item\">\n        ${proc.id ? `<span class=\"procedure-id\">${escapeHTML(proc.id)}</span>` : ''}\n        ${proc.title ? `<span class=\"procedure-title\">${escapeHTML(proc.title)}</span>` : ''}\n        ${proc.stage ? `<span class=\"procedure-stage\">${escapeHTML(proc.stage)}</span>` : ''}\n      </div>`\n      )\n      .join('') ?? '';\n\n  return { healthScore, throughput, procRowsHtml };\n}\n\n/**\n * Fetch a specific procedure's tracked-status HTML from MCP.\n * Returns empty string when `procedureId` is empty or MCP is unavailable.\n *\n * @param client - MCP client or null\n * @param procedureId - Procedure ID (e.g. `\"2024/0001(COD)\"`)\n * @returns HTML snippet for the procedure status section\n */\nexport async function fetchProcedureStatusFromMCP(\n  client: EuropeanParliamentMCPClient | null,\n  procedureId: string\n): Promise<string> {\n  if (!procedureId || !client) return '';\n  try {\n    const result = await callMCP(\n      () => client.trackLegislation(procedureId),\n      undefined,\n      `track_legislation(${procedureId})`\n    );\n    if (!result?.content?.[0]) return '';\n    const raw = result.content[0].text;\n    return `<pre class=\"data-summary\">${escapeHTML(raw.slice(0, 2000))}</pre>`;\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} track_legislation failed:`, message);\n    return '';\n  }\n}\n\n// ─── EP Feed-based fetches (Breaking News) ──────────────────────────────────\n\n/**\n * Ordered fallback chain for feed timeframes.\n * When a narrow timeframe returns empty/404 (common during recess), we widen\n * the window to retrieve at least *some* recent data for analysis.\n */\nconst TIMEFRAME_FALLBACK_CHAIN: ReadonlyMap<FeedTimeframe, FeedTimeframe | undefined> = new Map<\n  FeedTimeframe,\n  FeedTimeframe | undefined\n>([\n  ['today', 'one-day'],\n  ['one-day', 'one-week'],\n  ['one-week', 'one-month'],\n  ['one-month', undefined],\n  ['custom', undefined],\n]);\n\n/**\n * Get the next wider timeframe for fallback, or `undefined` if no fallback exists.\n *\n * @param current - Current timeframe\n * @returns Next wider timeframe, or undefined when at widest\n */\nfunction getWiderTimeframe(current: FeedTimeframe): FeedTimeframe | undefined {\n  return TIMEFRAME_FALLBACK_CHAIN.get(current);\n}\n\n/**\n * Error thrown when the EP MCP server returns a timeout envelope instead of data.\n * This is distinct from network-level timeouts — the MCP server responds successfully\n * but reports that the upstream EP API did not respond in time.\n */\nclass UpstreamTimeoutError extends Error {\n  constructor(toolName: string) {\n    super(`EP MCP upstream timeout for ${toolName} — data may be incomplete`);\n    this.name = 'UpstreamTimeoutError';\n  }\n}\n\n/**\n * Error thrown when the EP MCP server returns a response indicating the feed\n * is unavailable (uniform `{status:\"unavailable\"}` envelope or the legacy raw\n * upstream 404 envelope historically emitted pre-v1.2.10). Distinct from\n * {@link UpstreamTimeoutError} so logs/diagnostics do not misattribute a\n * 404/unavailable response to a timeout. Shares the same control-flow role —\n * callers treat it as \"stop the timeframe-widening retry loop and return the\n * empty sentinel\".\n */\nclass FeedUnavailableError extends Error {\n  constructor(toolName: string) {\n    super(`EP MCP feed unavailable for ${toolName} — treating as known-empty`);\n    this.name = 'FeedUnavailableError';\n  }\n}\n\n/**\n * Type guard: returns `true` for either error type that should stop the\n * timeframe-widening retry loop ({@link UpstreamTimeoutError} or\n * {@link FeedUnavailableError}).\n *\n * @param error - Caught error value\n * @returns `true` when the caller should stop retrying and return the empty sentinel\n */\nfunction isStopRetryError(error: unknown): boolean {\n  return error instanceof UpstreamTimeoutError || error instanceof FeedUnavailableError;\n}\n\n/**\n * Check whether a parsed MCP response envelope indicates an upstream timeout\n * and throw {@link UpstreamTimeoutError} if so.  The EP MCP server returns\n * `{ timedOut: true, status: \"timeout\" }` when the upstream EP API did not\n * respond within the configured timeout window.  Throwing ensures callers\n * stop timeframe-widening retry loops instead of treating the empty `data: []`\n * as \"no data\".\n *\n * @param value - Parsed response value (may be an object envelope or a bare array)\n * @throws {UpstreamTimeoutError} when the response indicates an upstream timeout\n */\nfunction checkUpstreamTimeout(value: unknown): void {\n  if (typeof value !== 'object' || value === null) return;\n  const envelope = value as Record<string, unknown>;\n  if (envelope['timedOut'] === true || envelope['status'] === 'timeout') {\n    const toolName = envelope['toolName'] ? String(envelope['toolName']) : 'unknown';\n    console.warn(\n      `${WARN_PREFIX} EP MCP upstream timeout for ${toolName} — data may be incomplete. ` +\n        'Consider using year-based endpoints as fallback.'\n    );\n    throw new UpstreamTimeoutError(toolName);\n  }\n  // Defensive detection of the legacy raw upstream 404 shape that pre-v1.2.10\n  // get_events_feed / get_procedures_feed emitted\n  // (Hack23/European-Parliament-MCP-Server#378, closed by PR #380 in\n  // v1.2.10). Shape:\n  //   {\"@id\":\"https://data.europarl.europa.eu/eli/dl/...\", \"error\":\"404 N...\"}\n  // Treated identically to the uniform `{status:\"unavailable\"}` envelope —\n  // i.e. stop timeframe-widening retry loops instead of silently returning [].\n  // Retained as belt-and-braces so older pinned server versions (or any future\n  // regression) do not bypass the NOT_FOUND bookkeeping done by the EP MCP\n  // client's safeCallTool.\n  const idField = envelope['@id'];\n  const errorField = envelope['error'];\n  if (\n    typeof idField === 'string' &&\n    idField.startsWith('https://data.europarl.europa.eu/') &&\n    typeof errorField === 'string' &&\n    errorField.includes('404')\n  ) {\n    console.warn(\n      `${WARN_PREFIX} EP MCP returned raw upstream 404 shape — treating feed as unavailable ` +\n        '(upstream #378). This should have been caught earlier as a NOT_FOUND failure.'\n    );\n    throw new FeedUnavailableError('raw_404_envelope');\n  }\n}\n\n/**\n * Handle errors from feed-fetching functions with timeframe-widening logic.\n * Encapsulates the common catch-block pattern: upstream timeouts return `undefined`\n * (caller should stop retrying), errors whose message contains '404' or 'timed out'\n * widen to the next timeframe, and other errors log and return `undefined`.\n *\n * @param error - Caught error\n * @param tf - Current feed timeframe\n * @param toolName - Feed tool name for log messages\n * @returns A wider {@link FeedTimeframe} to retry, or `undefined` to stop\n */\nfunction handleFeedFetchError(\n  error: unknown,\n  tf: FeedTimeframe,\n  toolName: string\n): FeedTimeframe | undefined {\n  if (isStopRetryError(error)) return undefined;\n  const message = error instanceof Error ? error.message : String(error);\n  const wider = getWiderTimeframe(tf);\n  if (wider && (message.includes('404') || message.includes('timed out'))) {\n    console.warn(`${WARN_PREFIX} ${toolName} failed (${tf}): ${message} — retrying with ${wider}`);\n    return wider;\n  }\n  console.warn(`${WARN_PREFIX} ${toolName} failed:`, message);\n  return undefined;\n}\n\n/**\n * Parse a feed result from MCP into a flat array of items.\n * EP API v2 feeds return items under the `data` key:\n * `{ data: [{ id, type, work_type, identifier, label }], \"@context\": [...] }`\n *\n * Also handles legacy shapes (`feed`, `entries`, `items`) and bare arrays.\n * Throws {@link UpstreamTimeoutError} when the MCP server reports an upstream\n * timeout (`{ timedOut: true, status: \"timeout\" }`), so callers can stop\n * timeframe-widening loops instead of treating the empty `data: []` as \"no data\".\n *\n * @param result - Raw MCP tool result\n * @returns Array of parsed feed entry objects (may be empty)\n * @throws {UpstreamTimeoutError} when the response indicates an upstream timeout\n */\nfunction parseFeedResult(result: MCPToolResult | undefined): Record<string, unknown>[] {\n  if (!result?.content?.[0]?.text) return [];\n  const parsed = parseJSON<unknown>(result.content[0].text, 'feed');\n  if (!parsed) return [];\n  checkUpstreamTimeout(parsed);\n  const envelope = parsed as Record<string, unknown>;\n  // EP API v2 feeds use `data` key; also check legacy shapes\n  const candidates = [\n    envelope['data'],\n    envelope['feed'],\n    envelope['entries'],\n    envelope['items'],\n    parsed,\n  ];\n  for (const candidate of candidates) {\n    if (Array.isArray(candidate)) return candidate as Record<string, unknown>[];\n  }\n  return [];\n}\n\n/**\n * Parse an EP API v2 feed response envelope in a single JSON parse, returning\n * both the array of feed items and the API-reported total count.\n * Avoids parsing the same JSON payload twice when both values are needed.\n * Throws {@link UpstreamTimeoutError} when the MCP server reports an upstream\n * timeout (`{ timedOut: true, status: \"timeout\" }`), so callers can stop\n * timeframe-widening loops instead of treating the empty `data: []` as \"no data\".\n *\n * @param result - Raw MCP tool result\n * @returns Object with `items` array and `total` count from the API\n * @throws {UpstreamTimeoutError} when the response indicates an upstream timeout\n */\nfunction parseFeedEnvelope(result: MCPToolResult | undefined): {\n  items: Record<string, unknown>[];\n  total: number;\n} {\n  if (!result?.content?.[0]?.text) return { items: [], total: 0 };\n  const parsed = parseJSON<unknown>(result.content[0].text, 'feed');\n  if (!parsed || typeof parsed !== 'object') return { items: [], total: 0 };\n  checkUpstreamTimeout(parsed);\n  const envelope = parsed as Record<string, unknown>;\n  const total = typeof envelope['total'] === 'number' ? envelope['total'] : 0;\n  const candidates = [\n    envelope['data'],\n    envelope['feed'],\n    envelope['entries'],\n    envelope['items'],\n    parsed,\n  ];\n  for (const candidate of candidates) {\n    if (Array.isArray(candidate)) return { items: candidate as Record<string, unknown>[], total };\n  }\n  return { items: [], total };\n}\n\n/**\n * Map a raw EP API v2 feed item to a normalized feed item.\n * EP feeds return `{ id, type, work_type, identifier, label }` — we normalize\n * these into the domain feed item shape, using `label` as `title` when no title exists.\n *\n * @param item - Raw feed item record\n * @returns Common feed item fields\n */\nfunction mapFeedItemBase(item: Record<string, unknown>): {\n  id: string;\n  title: string;\n  date: string;\n  type?: string | undefined;\n  url?: string | undefined;\n  identifier?: string | undefined;\n  label?: string | undefined;\n} {\n  return {\n    id: String(item['id'] ?? item['docId'] ?? ''),\n    title: String(\n      item['title'] ?? item['label'] ?? item['name'] ?? item['identifier'] ?? 'Untitled'\n    ),\n    date: String(item['date'] ?? item['published'] ?? item['updated'] ?? ''),\n    type: item['type']\n      ? String(item['type'])\n      : item['work_type']\n        ? String(item['work_type'])\n        : undefined,\n    url: item['url'] ? String(item['url']) : undefined,\n    identifier: item['identifier'] ? String(item['identifier']) : undefined,\n    label: item['label'] ? String(item['label']) : undefined,\n  };\n}\n\n/**\n * Fetch adopted texts feed from MCP.\n * Falls back to a wider timeframe when the initial timeframe returns no data.\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Array of adopted text feed items\n */\nexport async function fetchAdoptedTextsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<AdoptedTextFeedItem[]> {\n  if (!client) return [];\n  let currentTimeframe: FeedTimeframe | undefined = timeframe;\n  while (currentTimeframe) {\n    const tf: FeedTimeframe = currentTimeframe;\n    try {\n      console.log(`${MCP_FETCH_PREFIX} Fetching adopted texts feed (${currentTimeframe})...`);\n      const result = await callMCP(\n        () => client.getAdoptedTextsFeed({ timeframe: tf, limit: 20 }),\n        undefined,\n        'get_adopted_texts_feed'\n      );\n      const items = parseFeedResult(result).map((item) => mapFeedItemBase(item));\n      if (items.length > 0 || !getWiderTimeframe(currentTimeframe)) return items;\n      console.log(\n        `${INFO_PREFIX} adopted texts feed empty for ${currentTimeframe}, widening timeframe...`\n      );\n      currentTimeframe = getWiderTimeframe(currentTimeframe);\n    } catch (error) {\n      const wider = handleFeedFetchError(error, tf, 'get_adopted_texts_feed');\n      if (wider) {\n        currentTimeframe = wider;\n      } else {\n        return [];\n      }\n    }\n  }\n  return [];\n}\n\n/**\n * Fetch events feed from MCP.\n * Falls back to a wider timeframe when the initial timeframe returns no data\n * (common during parliamentary recess when the EP API returns 404 for narrow windows).\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Array of event feed items\n */\nexport async function fetchEventsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<EventFeedItem[]> {\n  if (!client) return [];\n  let currentTimeframe: FeedTimeframe | undefined = timeframe;\n  while (currentTimeframe) {\n    const tf: FeedTimeframe = currentTimeframe;\n    try {\n      console.log(`${MCP_FETCH_PREFIX} Fetching events feed (${currentTimeframe})...`);\n      const result = await callMCP(\n        () => client.getEventsFeed({ timeframe: tf, limit: 20 }),\n        undefined,\n        'get_events_feed'\n      );\n      const items = parseFeedResult(result).map((item) => ({\n        ...mapFeedItemBase(item),\n        location: item['location'] ? String(item['location']) : undefined,\n      }));\n      if (items.length > 0 || !getWiderTimeframe(currentTimeframe)) return items;\n      console.log(\n        `${INFO_PREFIX} events feed empty for ${currentTimeframe}, widening timeframe...`\n      );\n      currentTimeframe = getWiderTimeframe(currentTimeframe);\n    } catch (error) {\n      const wider = handleFeedFetchError(error, tf, 'get_events_feed');\n      if (wider) {\n        currentTimeframe = wider;\n      } else {\n        return [];\n      }\n    }\n  }\n  return [];\n}\n\n/**\n * Fetch procedures feed from MCP.\n * Falls back to a wider timeframe when the initial timeframe returns no data.\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Array of procedure feed items\n */\nexport async function fetchProceduresFeed(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<ProcedureFeedItem[]> {\n  if (!client) return [];\n  let currentTimeframe: FeedTimeframe | undefined = timeframe;\n  while (currentTimeframe) {\n    const tf: FeedTimeframe = currentTimeframe;\n    try {\n      console.log(`${MCP_FETCH_PREFIX} Fetching procedures feed (${currentTimeframe})...`);\n      const result = await callMCP(\n        () => client.getProceduresFeed({ timeframe: tf, limit: 20 }),\n        undefined,\n        'get_procedures_feed'\n      );\n      const items = parseFeedResult(result).map((item) => ({\n        ...mapFeedItemBase(item),\n        stage: item['stage'] ? String(item['stage']) : undefined,\n      }));\n      if (items.length > 0 || !getWiderTimeframe(currentTimeframe)) return items;\n      console.log(\n        `${INFO_PREFIX} procedures feed empty for ${currentTimeframe}, widening timeframe...`\n      );\n      currentTimeframe = getWiderTimeframe(currentTimeframe);\n    } catch (error) {\n      const wider = handleFeedFetchError(error, tf, 'get_procedures_feed');\n      if (wider) {\n        currentTimeframe = wider;\n      } else {\n        return [];\n      }\n    }\n  }\n  return [];\n}\n\n/**\n * Fetch MEPs feed from MCP.\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Array of MEP feed items\n */\nexport async function fetchMEPsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<MEPFeedItem[]> {\n  return (await fetchMEPsFeedWithTotal(client, timeframe)).items;\n}\n\n/**\n * Fetch MEPs feed from MCP, returning both items and the API's reported total count.\n * The `total` from the API response reflects all matching records in the feed,\n * which may exceed the `limit` parameter (currently capped at 100 per request).\n *\n * The limit is set to 100 (the EP API maximum) so the fetched sample is large\n * enough to populate a meaningful truncation note (\"showing 10 of N\") while\n * keeping each request bounded.  When the feed contains more than 100 MEP\n * updates, the `total` field in the API response carries the true count.\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Object with `items` array and `total` count from the API\n */\nexport async function fetchMEPsFeedWithTotal(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<{ items: MEPFeedItem[]; total: number }> {\n  if (!client) return { items: [], total: 0 };\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching MEPs feed (${timeframe})...`);\n    const result = await callMCP(\n      () => client.getMEPsFeed({ timeframe, limit: 100 }),\n      undefined,\n      'get_meps_feed'\n    );\n    const { items: rawItems, total } = parseFeedEnvelope(result);\n    const items = rawItems.map((item) => ({\n      id: String(item['id'] ?? item['mepId'] ?? ''),\n      name: String(item['name'] ?? item['label'] ?? item['title'] ?? 'Unknown'),\n      date: String(item['date'] ?? item['published'] ?? item['updated'] ?? ''),\n      country: item['country'] ? String(item['country']) : undefined,\n      group: item['group'] ? String(item['group']) : undefined,\n      url: item['url'] ? String(item['url']) : undefined,\n      identifier: item['identifier'] ? String(item['identifier']) : undefined,\n      label: item['label'] ? String(item['label']) : undefined,\n    }));\n    return { items, total };\n  } catch (error) {\n    if (isStopRetryError(error)) return { items: [], total: 0 };\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} get_meps_feed failed:`, message);\n    return { items: [], total: 0 };\n  }\n}\n\n/**\n * Fetch a fixed-window EP API v2 feed that ignores the `timeframe` parameter.\n *\n * The EP MCP server splits feed tools into two groups — sliding-window feeds\n * accept `timeframe`/`startDate`, fixed-window feeds (documents,\n * plenary_documents, committee_documents, plenary_session_documents,\n * parliamentary_questions, corporate_bodies, controlled_vocabularies) serve a\n * server-defined window. As of v1.2.10 the server silently ignores\n * `timeframe`/`startDate` on fixed-window tools\n * (Hack23/European-Parliament-MCP-Server#379); pre-v1.2.10 it rejected them\n * with `INVALID_PARAMS` (#377). This helper issues a single RPC either way —\n * there is no point in timeframe-widening retry loops because the server does\n * not narrow/widen results based on timeframe.\n *\n * @param client - MCP client (null returns `[]`)\n * @param toolName - Tool name for log messages\n * @param callFn - Callback that issues the feed RPC\n * @returns Array of mapped feed items (may be empty)\n */\nasync function fetchFixedWindowFeed(\n  client: EuropeanParliamentMCPClient | null,\n  toolName: string,\n  callFn: () => Promise<MCPToolResult | undefined>\n): Promise<\n  {\n    id: string;\n    title: string;\n    date: string;\n    type?: string | undefined;\n    url?: string | undefined;\n    identifier?: string | undefined;\n    label?: string | undefined;\n  }[]\n> {\n  if (!client) return [];\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching ${toolName}...`);\n    const result = await callMCP(callFn, undefined, toolName);\n    return parseFeedResult(result).map((item) => mapFeedItemBase(item));\n  } catch (error) {\n    if (isStopRetryError(error)) return [];\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} ${toolName} failed:`, message);\n    return [];\n  }\n}\n\n/**\n * Fetch documents feed from MCP.\n * The underlying EP MCP `get_documents_feed` tool serves a server-defined\n * fixed window and ignores any `timeframe` parameter; the parameter is kept\n * on this function's signature for backwards compatibility with callers.\n *\n * @param client - MCP client or null\n * @param _timeframe - Retained for signature compatibility (ignored by the server)\n * @returns Array of document feed items\n */\nexport async function fetchDocumentsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  _timeframe: FeedTimeframe = 'one-week'\n): Promise<DocumentFeedItem[]> {\n  return fetchFixedWindowFeed(client, 'get_documents_feed', () =>\n    client!.getDocumentsFeed({ limit: 20 })\n  );\n}\n\n/**\n * Fetch plenary documents feed from MCP.\n * Fixed-window feed; `timeframe` is ignored by the server.\n *\n * @param client - MCP client or null\n * @param _timeframe - Retained for signature compatibility (ignored by the server)\n * @returns Array of document feed items\n */\nexport async function fetchPlenaryDocumentsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  _timeframe: FeedTimeframe = 'one-week'\n): Promise<DocumentFeedItem[]> {\n  return fetchFixedWindowFeed(client, 'get_plenary_documents_feed', () =>\n    client!.getPlenaryDocumentsFeed({ limit: 20 })\n  );\n}\n\n/**\n * Fetch committee documents feed from MCP.\n * Fixed-window feed; `timeframe` is ignored by the server.\n *\n * @param client - MCP client or null\n * @param _timeframe - Retained for signature compatibility (ignored by the server)\n * @returns Array of document feed items\n */\nexport async function fetchCommitteeDocumentsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  _timeframe: FeedTimeframe = 'one-week'\n): Promise<DocumentFeedItem[]> {\n  return fetchFixedWindowFeed(client, 'get_committee_documents_feed', () =>\n    client!.getCommitteeDocumentsFeed({ limit: 20 })\n  );\n}\n\n/**\n * Fetch plenary session documents feed from MCP.\n * Fixed-window feed; `timeframe` is ignored by the server.\n *\n * @param client - MCP client or null\n * @param _timeframe - Retained for signature compatibility (ignored by the server)\n * @returns Array of document feed items\n */\nexport async function fetchPlenarySessionDocumentsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  _timeframe: FeedTimeframe = 'one-week'\n): Promise<DocumentFeedItem[]> {\n  return fetchFixedWindowFeed(client, 'get_plenary_session_documents_feed', () =>\n    client!.getPlenarySessionDocumentsFeed({ limit: 20 })\n  );\n}\n\n/**\n * Fetch external documents feed from MCP.\n * Falls back to a wider timeframe when the initial timeframe returns no data.\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Array of document feed items\n */\nexport async function fetchExternalDocumentsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<DocumentFeedItem[]> {\n  if (!client) return [];\n  let currentTimeframe: FeedTimeframe | undefined = timeframe;\n  while (currentTimeframe) {\n    const tf: FeedTimeframe = currentTimeframe;\n    try {\n      console.log(`${MCP_FETCH_PREFIX} Fetching external documents feed (${currentTimeframe})...`);\n      const result = await callMCP(\n        () => client.getExternalDocumentsFeed({ timeframe: tf, limit: 20 }),\n        undefined,\n        'get_external_documents_feed'\n      );\n      const items = parseFeedResult(result).map((item) => mapFeedItemBase(item));\n      if (items.length > 0 || !getWiderTimeframe(currentTimeframe)) return items;\n      console.log(\n        `${INFO_PREFIX} external documents feed empty for ${currentTimeframe}, widening timeframe...`\n      );\n      currentTimeframe = getWiderTimeframe(currentTimeframe);\n    } catch (error) {\n      const wider = handleFeedFetchError(error, tf, 'get_external_documents_feed');\n      if (wider) {\n        currentTimeframe = wider;\n      } else {\n        return [];\n      }\n    }\n  }\n  return [];\n}\n\n/**\n * Fetch parliamentary questions feed from MCP.\n * Fixed-window feed; `timeframe` is ignored by the server.\n *\n * @param client - MCP client or null\n * @param _timeframe - Retained for signature compatibility (ignored by the server)\n * @returns Array of question feed items\n */\nexport async function fetchQuestionsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  _timeframe: FeedTimeframe = 'one-week'\n): Promise<QuestionFeedItem[]> {\n  return fetchFixedWindowFeed(client, 'get_parliamentary_questions_feed', () =>\n    client!.getParliamentaryQuestionsFeed({ limit: 20 })\n  );\n}\n\n/**\n * Fetch MEP declarations feed from MCP.\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Array of declaration feed items\n */\nexport async function fetchDeclarationsFeed(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<DeclarationFeedItem[]> {\n  if (!client) return [];\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching MEP declarations feed (${timeframe})...`);\n    const result = await callMCP(\n      () => client.getMEPDeclarationsFeed({ timeframe, limit: 20 }),\n      undefined,\n      'get_mep_declarations_feed'\n    );\n    return parseFeedResult(result).map((item) => mapFeedItemBase(item));\n  } catch (error) {\n    if (isStopRetryError(error)) return [];\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} get_mep_declarations_feed failed:`, message);\n    return [];\n  }\n}\n\n/**\n * Fetch corporate bodies feed from MCP.\n *\n * `_timeframe` is retained only for signature compatibility with sliding-window\n * fetchers (so the shared `fetchEPFeedData` orchestrator can dispatch uniformly);\n * the EP MCP server serves a server-defined fixed window for this feed and\n * ignores any timeframe input (as of v1.2.10; pre-v1.2.10 it rejected with\n * `INVALID_PARAMS` — see Hack23/European-Parliament-MCP-Server#377).\n *\n * @param client - MCP client or null\n * @param _timeframe - Ignored by the server; kept for signature compatibility\n * @returns Array of corporate body feed items\n */\nexport async function fetchCorporateBodiesFeed(\n  client: EuropeanParliamentMCPClient | null,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  _timeframe: FeedTimeframe = 'one-week'\n): Promise<CorporateBodyFeedItem[]> {\n  if (!client) return [];\n  try {\n    console.log(`${MCP_FETCH_PREFIX} Fetching corporate bodies feed (fixed window)...`);\n    const result = await callMCP(\n      () => client.getCorporateBodiesFeed({ limit: 20 }),\n      undefined,\n      'get_corporate_bodies_feed'\n    );\n    return parseFeedResult(result).map((item) => mapFeedItemBase(item));\n  } catch (error) {\n    if (isStopRetryError(error)) return [];\n    const message = error instanceof Error ? error.message : String(error);\n    console.warn(`${WARN_PREFIX} get_corporate_bodies_feed failed:`, message);\n    return [];\n  }\n}\n\n/**\n * Fetch all EP feed data for breaking news articles.\n * Calls adopted texts, events, procedures, and MEPs feeds in parallel.\n * Returns `undefined` when client is null (MCP unavailable).\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @returns Aggregated feed data for breaking news, or undefined when client is null\n */\nexport async function fetchBreakingNewsFeedData(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week'\n): Promise<BreakingNewsFeedData | undefined> {\n  if (!client) return undefined;\n  if (!mcpCircuitBreaker.canRequest()) {\n    console.warn(\n      `${WARN_PREFIX} Circuit breaker OPEN — treating as MCP unavailable for breaking news feeds`\n    );\n    return undefined;\n  }\n  const [adoptedTexts, events, procedures, mepFeedResult] = await Promise.all([\n    fetchAdoptedTextsFeed(client, timeframe),\n    fetchEventsFeed(client, timeframe),\n    fetchProceduresFeed(client, timeframe),\n    fetchMEPsFeedWithTotal(client, timeframe),\n  ]);\n  const { items: mepUpdates, total: totalMEPUpdates } = mepFeedResult;\n  return {\n    adoptedTexts,\n    events,\n    procedures,\n    mepUpdates,\n    totalMEPUpdates: totalMEPUpdates > 0 ? totalMEPUpdates : undefined,\n  };\n}\n\n/**\n * Fetch comprehensive EP feed data from all 12 feed endpoints in parallel.\n * This is the primary data source for all article strategies.\n *\n * @param client - MCP client or null\n * @param timeframe - How far back to look (default: 'one-week')\n * @param dateRange - Optional inclusive UTC window for filtering feed items\n * @returns Full EPFeedData or undefined when client is null\n */\nexport async function fetchEPFeedData(\n  client: EuropeanParliamentMCPClient | null,\n  timeframe: FeedTimeframe = 'one-week',\n  dateRange?: DateRange\n): Promise<EPFeedData | undefined> {\n  // Check for pre-fetched feed data file (set by --feed-data CLI arg).\n  // This allows agentic workflows to pass MCP data fetched via framework tools\n  // into the generator without requiring a direct MCP connection.\n  const feedDataFile = process.env['EP_FEED_DATA_FILE'];\n  if (feedDataFile) {\n    const fileData = loadEPFeedDataFromFile(feedDataFile, dateRange);\n    if (fileData) return fileData;\n    console.log(\n      `${WARN_PREFIX} Pre-fetched EP feed data failed to load — falling through to MCP fetch`\n    );\n  }\n  if (!client) return undefined;\n  if (!mcpCircuitBreaker.canRequest()) {\n    console.warn(`${WARN_PREFIX} Circuit breaker OPEN — treating as MCP unavailable for EP feeds`);\n    return undefined;\n  }\n  console.log(`${MCP_FETCH_PREFIX} Fetching comprehensive EP feed data (${timeframe})...`);\n  const [\n    adoptedTexts,\n    events,\n    procedures,\n    mepUpdates,\n    documents,\n    plenaryDocuments,\n    committeeDocuments,\n    plenarySessionDocuments,\n    externalDocuments,\n    questions,\n    declarations,\n    corporateBodies,\n  ] = await Promise.all([\n    fetchAdoptedTextsFeed(client, timeframe),\n    fetchEventsFeed(client, timeframe),\n    fetchProceduresFeed(client, timeframe),\n    fetchMEPsFeed(client, timeframe),\n    fetchDocumentsFeed(client, timeframe),\n    fetchPlenaryDocumentsFeed(client, timeframe),\n    fetchCommitteeDocumentsFeed(client, timeframe),\n    fetchPlenarySessionDocumentsFeed(client, timeframe),\n    fetchExternalDocumentsFeed(client, timeframe),\n    fetchQuestionsFeed(client, timeframe),\n    fetchDeclarationsFeed(client, timeframe),\n    fetchCorporateBodiesFeed(client, timeframe),\n  ]);\n\n  const filteredData = filterEPFeedDataByDateRange(\n    {\n      adoptedTexts,\n      events,\n      procedures,\n      mepUpdates,\n      documents,\n      plenaryDocuments,\n      committeeDocuments,\n      plenarySessionDocuments,\n      externalDocuments,\n      questions,\n      declarations,\n      corporateBodies,\n    },\n    dateRange\n  );\n  const totalItems =\n    filteredData.adoptedTexts.length +\n    filteredData.events.length +\n    filteredData.procedures.length +\n    filteredData.mepUpdates.length +\n    filteredData.documents.length +\n    filteredData.plenaryDocuments.length +\n    filteredData.committeeDocuments.length +\n    filteredData.plenarySessionDocuments.length +\n    filteredData.externalDocuments.length +\n    filteredData.questions.length +\n    filteredData.declarations.length +\n    filteredData.corporateBodies.length;\n  console.log(`  ✅ Fetched ${totalItems} total feed items across 12 endpoints`);\n\n  return filteredData;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/pipeline/generate-stage.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/pipeline/output-stage.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/pipeline/transform-stage.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/propositions-content.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/sitemap.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":362,"column":24,"endLine":362,"endColumn":44},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":368,"column":21,"endLine":368,"endColumn":43},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":373,"column":23,"endLine":373,"endColumn":40}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"#!/usr/bin/env node\n\n// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/Sitemap\n * @description Generates sitemap.xml and multi-language sitemap HTML pages\n * for all news articles, index pages, and documentation files.\n */\n\nimport fs from 'fs';\nimport path, { resolve } from 'path';\nimport { pathToFileURL } from 'url';\nimport {\n  NEWS_DIR,\n  BASE_URL,\n  PROJECT_ROOT,\n  createThemeToggleButton,\n  THEME_TOGGLE_SCRIPT,\n} from '../constants/config.js';\nimport {\n  ALL_LANGUAGES,\n  LANGUAGE_NAMES,\n  LANGUAGE_FLAGS,\n  PAGE_TITLES,\n  PAGE_DESCRIPTIONS,\n  SKIP_LINK_TEXTS,\n  HEADER_SUBTITLE_LABELS,\n  THEME_TOGGLE_LABELS,\n  getLocalizedString,\n  getTextDirection,\n} from '../constants/languages.js';\nimport {\n  getNewsArticles,\n  getModifiedDate,\n  parseArticleFilename,\n  formatSlug,\n  extractArticleMeta,\n  escapeHTML,\n} from '../utils/file-utils.js';\nimport type { SitemapUrl } from '../types/index.js';\n\n/** Absolute docs directory under project root */\nconst DOCS_DIR: string = path.join(PROJECT_ROOT, 'docs');\n\n/**\n * Recursively collect all HTML files under a directory, returning paths\n * relative to the project root.\n *\n * @param dir - Directory to scan\n * @param rootDir - Project root for computing relative paths\n * @returns Array of relative paths (e.g. \"docs/api/index.html\")\n */\nexport function collectDocsHtmlFiles(dir: string, rootDir: string = PROJECT_ROOT): string[] {\n  const results: string[] = [];\n  if (!fs.existsSync(dir)) {\n    return results;\n  }\n  const entries = fs.readdirSync(dir, { withFileTypes: true });\n  for (const entry of entries) {\n    const fullPath = path.join(dir, entry.name);\n    if (entry.isDirectory()) {\n      results.push(...collectDocsHtmlFiles(fullPath, rootDir));\n    } else if (entry.isFile() && entry.name.endsWith('.html')) {\n      results.push(path.relative(rootDir, fullPath).replace(/\\\\/g, '/'));\n    }\n  }\n  return results.sort();\n}\n\n/**\n * Generate sitemap XML including index pages, news articles, sitemap HTML pages,\n * and documentation files from the docs/ folder.\n *\n * @param articles - List of article filenames\n * @param docsFiles - Relative paths to docs HTML files (e.g. \"docs/api/index.html\")\n * @returns Complete sitemap XML string\n */\nexport function generateSitemap(articles: string[], docsFiles: string[] = []): string {\n  const urls: SitemapUrl[] = [];\n  const today = new Date().toISOString().slice(0, 10);\n\n  // Add home pages for each language\n  for (const lang of ALL_LANGUAGES) {\n    const filename = lang === 'en' ? 'index.html' : `index-${lang}.html`;\n    urls.push({\n      loc: `${BASE_URL}/${filename}`,\n      lastmod: today,\n      changefreq: 'daily',\n      priority: '1.0',\n    });\n  }\n\n  // Add sitemap HTML pages for each language\n  for (const lang of ALL_LANGUAGES) {\n    const filename = lang === 'en' ? 'sitemap.html' : `sitemap_${lang}.html`;\n    urls.push({\n      loc: `${BASE_URL}/${filename}`,\n      lastmod: today,\n      changefreq: 'daily',\n      priority: '0.5',\n    });\n  }\n\n  // Add RSS feed\n  urls.push({\n    loc: `${BASE_URL}/rss.xml`,\n    lastmod: today,\n    changefreq: 'daily',\n    priority: '0.5',\n  });\n\n  // Add news articles\n  for (const article of articles) {\n    const filepath = path.join(NEWS_DIR, article);\n    const lastmod = getModifiedDate(filepath);\n\n    urls.push({\n      loc: `${BASE_URL}/news/${article}`,\n      lastmod,\n      changefreq: 'monthly',\n      priority: '0.8',\n    });\n  }\n\n  // Add docs HTML files\n  for (const relPath of docsFiles) {\n    const fullPath = path.join(PROJECT_ROOT, relPath);\n    let lastmod = today;\n    try {\n      lastmod = getModifiedDate(fullPath);\n    } catch {\n      // Use today if file stat fails\n    }\n    urls.push({\n      loc: `${BASE_URL}/${relPath.replace(/\\\\/g, '/')}`,\n      lastmod,\n      changefreq: 'weekly',\n      priority: '0.3',\n    });\n  }\n\n  return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n${urls\n  .map(\n    (url) => `  <url>\n    <loc>${url.loc}</loc>\n    <lastmod>${url.lastmod}</lastmod>\n    <changefreq>${url.changefreq}</changefreq>\n    <priority>${url.priority}</priority>\n  </url>`\n  )\n  .join('\\n')}\n</urlset>`;\n}\n\n/** Default sitemap title used as English fallback */\nconst DEFAULT_SITEMAP_TITLE = 'Sitemap';\n\n/** Sitemap page titles per language */\nconst SITEMAP_TITLES: Record<string, string> = {\n  en: DEFAULT_SITEMAP_TITLE,\n  sv: 'Webbplatskarta',\n  da: DEFAULT_SITEMAP_TITLE,\n  no: 'Nettstedskart',\n  fi: 'Sivukartta',\n  de: 'Seitenübersicht',\n  fr: 'Plan du site',\n  es: 'Mapa del sitio',\n  nl: DEFAULT_SITEMAP_TITLE,\n  ar: 'خريطة الموقع',\n  he: 'מפת אתר',\n  ja: 'サイトマップ',\n  ko: '사이트맵',\n  zh: '网站地图',\n};\n\n/** Sitemap section headings per language */\nconst SITEMAP_SECTIONS: Record<string, { news: string; docs: string; pages: string }> = {\n  en: { news: 'News Articles', docs: 'Documentation', pages: 'Pages' },\n  sv: { news: 'Nyhetsartiklar', docs: 'Dokumentation', pages: 'Sidor' },\n  da: { news: 'Nyhedsartikler', docs: 'Dokumentation', pages: 'Sider' },\n  no: { news: 'Nyhetsartikler', docs: 'Dokumentasjon', pages: 'Sider' },\n  fi: { news: 'Uutisartikkelit', docs: 'Dokumentaatio', pages: 'Sivut' },\n  de: { news: 'Nachrichtenartikel', docs: 'Dokumentation', pages: 'Seiten' },\n  fr: { news: 'Articles de presse', docs: 'Documentation', pages: 'Pages' },\n  es: { news: 'Artículos de noticias', docs: 'Documentación', pages: 'Páginas' },\n  nl: { news: 'Nieuwsartikelen', docs: 'Documentatie', pages: \"Pagina's\" },\n  ar: { news: 'مقالات إخبارية', docs: 'التوثيق', pages: 'الصفحات' },\n  he: { news: 'מאמרי חדשות', docs: 'תיעוד', pages: 'דפים' },\n  ja: { news: 'ニュース記事', docs: 'ドキュメント', pages: 'ページ' },\n  ko: { news: '뉴스 기사', docs: '문서', pages: '페이지' },\n  zh: { news: '新闻文章', docs: '文档', pages: '页面' },\n};\n\n/** Documentation section labels per language */\nconst DOCS_LABELS: Record<\n  string,\n  { api: string; coverage: string; testResults: string; docsHome: string }\n> = {\n  en: {\n    api: 'API Documentation',\n    coverage: 'Code Coverage',\n    testResults: 'Test Results',\n    docsHome: 'Documentation Home',\n  },\n  sv: {\n    api: 'API-dokumentation',\n    coverage: 'Kodtäckning',\n    testResults: 'Testresultat',\n    docsHome: 'Dokumentationsstart',\n  },\n  da: {\n    api: 'API-dokumentation',\n    coverage: 'Kodedækning',\n    testResults: 'Testresultater',\n    docsHome: 'Dokumentationsstart',\n  },\n  no: {\n    api: 'API-dokumentasjon',\n    coverage: 'Kodedekning',\n    testResults: 'Testresultater',\n    docsHome: 'Dokumentasjonsstart',\n  },\n  fi: {\n    api: 'API-dokumentaatio',\n    coverage: 'Koodikattavuus',\n    testResults: 'Testitulokset',\n    docsHome: 'Dokumentaation etusivu',\n  },\n  de: {\n    api: 'API-Dokumentation',\n    coverage: 'Codeabdeckung',\n    testResults: 'Testergebnisse',\n    docsHome: 'Dokumentationsstart',\n  },\n  fr: {\n    api: 'Documentation API',\n    coverage: 'Couverture du code',\n    testResults: 'Résultats des tests',\n    docsHome: 'Accueil documentation',\n  },\n  es: {\n    api: 'Documentación API',\n    coverage: 'Cobertura de código',\n    testResults: 'Resultados de pruebas',\n    docsHome: 'Inicio de documentación',\n  },\n  nl: {\n    api: 'API-documentatie',\n    coverage: 'Codedekking',\n    testResults: 'Testresultaten',\n    docsHome: 'Documentatiestart',\n  },\n  ar: {\n    api: 'وثائق API',\n    coverage: 'تغطية الكود',\n    testResults: 'نتائج الاختبار',\n    docsHome: 'الصفحة الرئيسية للتوثيق',\n  },\n  he: {\n    api: 'תיעוד API',\n    coverage: 'כיסוי קוד',\n    testResults: 'תוצאות בדיקות',\n    docsHome: 'דף הבית של התיעוד',\n  },\n  ja: {\n    api: 'APIドキュメント',\n    coverage: 'コードカバレッジ',\n    testResults: 'テスト結果',\n    docsHome: 'ドキュメントホーム',\n  },\n  ko: {\n    api: 'API 문서',\n    coverage: '코드 커버리지',\n    testResults: '테스트 결과',\n    docsHome: '문서 홈',\n  },\n  zh: { api: 'API 文档', coverage: '代码覆盖率', testResults: '测试结果', docsHome: '文档首页' },\n};\n\n/**\n * Get the sitemap HTML filename for a given language code.\n *\n * @param lang - Language code\n * @returns Filename string\n */\nexport function getSitemapFilename(lang: string): string {\n  return lang === 'en' ? 'sitemap.html' : `sitemap_${lang}.html`;\n}\n\n/**\n * Get the index filename for a given language code.\n *\n * @param lang - Language code\n * @returns Filename string\n */\nfunction getIndexFilename(lang: string): string {\n  return lang === 'en' ? 'index.html' : `index-${lang}.html`;\n}\n\n/**\n * Build compact language switcher nav HTML for sitemap pages.\n *\n * @param currentLang - Active language code\n * @returns HTML string\n */\nfunction buildSitemapLangSwitcher(currentLang: string): string {\n  return ALL_LANGUAGES.map((code) => {\n    const flag = getLocalizedString(LANGUAGE_FLAGS, code);\n    const name = getLocalizedString(LANGUAGE_NAMES, code);\n    const active = code === currentLang ? ' active' : '';\n    const ariaCurrent = code === currentLang ? ' aria-current=\"page\"' : '';\n    const href = getSitemapFilename(code);\n    return `<a href=\"${href}\" class=\"lang-link${active}\" hreflang=\"${code}\" title=\"${escapeHTML(name)}\"${ariaCurrent}>${flag} ${code.toUpperCase()}</a>`;\n  }).join('\\n        ');\n}\n\n/**\n * Build the footer language grid for sitemap pages.\n *\n * @param currentLang - Active language code\n * @returns HTML string\n */\nfunction buildSitemapFooterLanguageGrid(currentLang: string): string {\n  return ALL_LANGUAGES.map((code) => {\n    const flag = getLocalizedString(LANGUAGE_FLAGS, code);\n    const name = getLocalizedString(LANGUAGE_NAMES, code);\n    const href = getSitemapFilename(code);\n    const active = code === currentLang ? ' class=\"active\"' : '';\n    return `<a href=\"${href}\"${active} hreflang=\"${code}\">${flag} ${escapeHTML(name)}</a>`;\n  }).join('\\n            ');\n}\n\n/**\n * Article info extracted for sitemap HTML display\n */\ninterface SitemapArticleInfo {\n  filename: string;\n  date: string;\n  title: string;\n  description: string;\n}\n\n/**\n * Generate a sitemap HTML page for a specific language.\n * Lists all articles for that language with titles and descriptions,\n * plus a high-level documentation section.\n *\n * @param lang - Language code\n * @param articleInfos - Article info (title/description) for this language\n * @param hasDocsDir - Whether the docs directory exists\n * @returns Complete HTML document string\n */\nexport function generateSitemapHTML(\n  lang: string,\n  articleInfos: SitemapArticleInfo[],\n  hasDocsDir: boolean = false\n): string {\n  const sitemapTitle = SITEMAP_TITLES[lang] ?? SITEMAP_TITLES['en'] ?? DEFAULT_SITEMAP_TITLE;\n  const pageTitle = `${getLocalizedString(PAGE_TITLES, lang).split(' - ')[0]} - ${sitemapTitle}`;\n  const description = getLocalizedString(PAGE_DESCRIPTIONS, lang);\n  const skipLinkText = getLocalizedString(SKIP_LINK_TEXTS, lang);\n  const dir = getTextDirection(lang);\n  const year = new Date().getFullYear();\n  const sections = (SITEMAP_SECTIONS[lang] ?? SITEMAP_SECTIONS['en']) as {\n    news: string;\n    docs: string;\n    pages: string;\n  };\n  const docsLabels = (DOCS_LABELS[lang] ?? DOCS_LABELS['en']) as {\n    api: string;\n    coverage: string;\n    testResults: string;\n    docsHome: string;\n  };\n  const heroTitle = getLocalizedString(PAGE_TITLES, lang).split(' - ')[0] ?? '';\n  const headerSubtitle = escapeHTML(getLocalizedString(HEADER_SUBTITLE_LABELS, lang));\n  const themeToggleLabel = escapeHTML(getLocalizedString(THEME_TOGGLE_LABELS, lang));\n\n  // Pages section\n  const pagesSection = ALL_LANGUAGES.map((code) => {\n    const name = getLocalizedString(LANGUAGE_NAMES, code);\n    const flag = getLocalizedString(LANGUAGE_FLAGS, code);\n    const href = getIndexFilename(code);\n    return `          <li><a href=\"${href}\">${flag} ${escapeHTML(name)}</a></li>`;\n  }).join('\\n');\n\n  // News articles section\n  const articlesSection =\n    articleInfos.length === 0\n      ? ''\n      : articleInfos\n          .map(\n            (a) =>\n              `          <li>\n            <a href=\"news/${escapeHTML(a.filename)}\">${escapeHTML(a.title)}</a>\n            <span class=\"sitemap-date\">${escapeHTML(a.date)}</span>${a.description ? `\\n            <p class=\"sitemap-desc\">${escapeHTML(a.description)}</p>` : ''}\n          </li>`\n          )\n          .join('\\n');\n\n  // Documentation section (high-level links)\n  const docsSection = hasDocsDir\n    ? `\n      <section class=\"sitemap-section\">\n        <h2><span aria-hidden=\"true\">📚</span> ${escapeHTML(sections.docs)}</h2>\n        <ul class=\"sitemap-list\">\n          <li><a href=\"docs/index.html\">${escapeHTML(docsLabels.docsHome)}</a></li>\n          <li><a href=\"docs/api/index.html\">${escapeHTML(docsLabels.api)}</a></li>\n          <li><a href=\"docs/coverage/index.html\">${escapeHTML(docsLabels.coverage)}</a></li>\n          <li><a href=\"docs/test-results/index.html\">${escapeHTML(docsLabels.testResults)}</a></li>\n        </ul>\n      </section>`\n    : '';\n\n  return `<!DOCTYPE html>\n<html lang=\"${lang}\" dir=\"${dir}\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <meta http-equiv=\"X-Content-Type-Options\" content=\"nosniff\">\n  <meta name=\"referrer\" content=\"no-referrer\">\n  <title>${escapeHTML(pageTitle)}</title>\n  <meta name=\"description\" content=\"${escapeHTML(description)}\">\n  <meta property=\"og:type\" content=\"website\">\n  <meta property=\"og:title\" content=\"${escapeHTML(sitemapTitle)}\">\n  <meta property=\"og:description\" content=\"${escapeHTML(description)}\">\n  <meta property=\"og:site_name\" content=\"EU Parliament Monitor\">\n  <meta property=\"og:locale\" content=\"${lang}\">\n  <meta property=\"og:image\" content=\"https://hack23.github.io/euparliamentmonitor/images/og-image.jpg\">\n  <meta property=\"og:image:width\" content=\"1200\">\n  <meta property=\"og:image:height\" content=\"630\">\n  <!-- Favicons -->\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"favicon.ico\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"images/favicon-32x32.png\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"images/favicon-16x16.png\">\n  <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"images/apple-touch-icon.png\">\n  <link rel=\"manifest\" href=\"site.webmanifest\">\n  <meta name=\"theme-color\" content=\"#003399\">\n  <link rel=\"stylesheet\" href=\"styles.css\">\n</head>\n<body>\n  <a href=\"#main\" class=\"skip-link\">${escapeHTML(skipLinkText)}</a>\n\n  <header class=\"site-header\" role=\"banner\">\n    <div class=\"site-header__inner\">\n      <a href=\"${getIndexFilename(lang)}\" class=\"site-header__brand\" aria-label=\"${escapeHTML(heroTitle)}\">\n        <picture class=\"site-header__logo-picture\">\n          <source srcset=\"images/favicon-96x96.webp\" type=\"image/webp\">\n          <img class=\"site-header__logo\" src=\"images/favicon-96x96.png\" alt=\"\" width=\"36\" height=\"36\" aria-hidden=\"true\">\n        </picture>\n        <span>\n          <span class=\"site-header__title\">${escapeHTML(heroTitle)}</span>\n          <span class=\"site-header__subtitle\">${headerSubtitle}</span>\n        </span>\n      </a>\n      ${createThemeToggleButton(themeToggleLabel)}\n    </div>\n  </header>\n\n  <nav class=\"language-switcher\" role=\"navigation\" aria-label=\"Language selection\">\n    ${buildSitemapLangSwitcher(lang)}\n  </nav>\n\n  <main id=\"main\" class=\"site-main\">\n    <h1>${escapeHTML(sitemapTitle)}</h1>\n\n    <section class=\"sitemap-section\">\n      <h2><span aria-hidden=\"true\">🏠</span> ${escapeHTML(sections.pages)}</h2>\n      <ul class=\"sitemap-list\">\n${pagesSection}\n      </ul>\n    </section>\n${docsSection}\n    <section class=\"sitemap-section\">\n      <h2><span aria-hidden=\"true\">📰</span> ${escapeHTML(sections.news)}</h2>\n      <ul class=\"sitemap-list\">\n${articlesSection}\n      </ul>\n    </section>\n  </main>\n\n  <footer class=\"site-footer\" role=\"contentinfo\">\n    <div class=\"footer-content\">\n      <div class=\"footer-section\">\n        <h3>About EU Parliament Monitor</h3>\n        <p>European Parliament Intelligence Platform — monitoring political activity with systematic transparency. Powered by European Parliament open data.</p>\n      </div>\n      <div class=\"footer-section\">\n        <h3>Quick Links</h3>\n        <ul>\n          <li><a href=\"${getIndexFilename(lang)}\">Home</a></li>\n          <li><a href=\"rss.xml\">RSS Feed</a></li>\n          <li><a href=\"https://github.com/Hack23/euparliamentmonitor\">GitHub Repository</a></li>\n          <li><a href=\"https://github.com/Hack23/euparliamentmonitor/blob/main/LICENSE\">Apache-2.0 License</a></li>\n          <li><a href=\"https://www.europarl.europa.eu/\">European Parliament</a></li>\n        </ul>\n      </div>\n      <div class=\"footer-section\">\n        <h3>Built by Hack23 AB</h3>\n        <ul>\n          <li><a href=\"https://hack23.com\">hack23.com</a></li>\n          <li><a href=\"https://www.linkedin.com/company/hack23\">LinkedIn</a></li>\n          <li><a href=\"https://github.com/Hack23/ISMS-PUBLIC\">Security &amp; Privacy Policy</a></li>\n          <li><a href=\"mailto:james@hack23.com\">Contact</a></li>\n        </ul>\n      </div>\n      <div class=\"footer-section\">\n        <h3>Languages</h3>\n        <div class=\"language-grid\">\n          ${buildSitemapFooterLanguageGrid(lang)}\n        </div>\n      </div>\n    </div>\n    <div class=\"footer-bottom\">\n      <p>&copy; 2008-${year} <a href=\"https://hack23.com\">Hack23 AB</a> (Org.nr 5595347807) | Gothenburg, Sweden</p>\n    </div>\n  </footer>${THEME_TOGGLE_SCRIPT}\n</body>\n</html>`;\n}\n\n/**\n * RSS feed item data.\n */\ninterface RssItem {\n  title: string;\n  link: string;\n  description: string;\n  pubDate: string;\n  lang: string;\n}\n\n/**\n * Escape special XML characters in text content.\n *\n * @param str - Raw string to escape for XML\n * @returns XML-safe string\n */\nfunction escapeXML(str: string): string {\n  return str\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&apos;');\n}\n\n/**\n * Generate RSS 2.0 XML feed with all news articles across all languages.\n * Articles are sorted newest-first. Each item includes the article language.\n *\n * @param articleInfos - Article metadata sorted newest first\n * @returns Complete RSS 2.0 XML string\n */\nexport function generateRssFeed(articleInfos: RssItem[]): string {\n  const buildDate = new Date().toUTCString();\n\n  const items = articleInfos\n    .map(\n      (item) => `    <item>\n      <title>${escapeXML(item.title)}</title>\n      <link>${escapeXML(item.link)}</link>\n      <description>${escapeXML(item.description)}</description>\n      <pubDate>${item.pubDate}</pubDate>\n      <guid isPermaLink=\"true\">${escapeXML(item.link)}</guid>\n      <dc:language>${escapeXML(item.lang)}</dc:language>\n    </item>`\n    )\n    .join('\\n');\n\n  return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- SPDX-FileCopyrightText: 2024-2026 Hack23 AB -->\n<!-- SPDX-License-Identifier: Apache-2.0 -->\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n  <channel>\n    <title>EU Parliament Monitor</title>\n    <link>${BASE_URL}</link>\n    <description>European Parliament Intelligence Platform — monitoring political activity with systematic transparency.</description>\n    <language>en</language>\n    <lastBuildDate>${buildDate}</lastBuildDate>\n    <atom:link href=\"${BASE_URL}/rss.xml\" rel=\"self\" type=\"application/rss+xml\"/>\n${items}\n  </channel>\n</rss>`;\n}\n\n/**\n * Main execution - generates sitemap.xml, multi-language sitemap HTML pages, and rss.xml.\n */\nfunction main(): void {\n  console.log('🗺️ Generating sitemap...');\n\n  const articles = getNewsArticles();\n  console.log(`📊 Found ${articles.length} articles`);\n\n  // Collect docs HTML files\n  const docsFiles = collectDocsHtmlFiles(DOCS_DIR);\n  console.log(`📚 Found ${docsFiles.length} docs files`);\n\n  const sitemap = generateSitemap(articles, docsFiles);\n  const filepath = path.join(PROJECT_ROOT, 'sitemap.xml');\n\n  fs.writeFileSync(filepath, sitemap, 'utf-8');\n  const totalUrls =\n    articles.length + ALL_LANGUAGES.length + ALL_LANGUAGES.length + docsFiles.length + 1;\n  console.log(`✅ Generated sitemap.xml with ${totalUrls} URLs`);\n\n  // Build article metadata map for sitemap HTML pages and RSS,\n  // pre-grouped by language for O(N) iteration\n  const articlesByLang = new Map<string, SitemapArticleInfo[]>();\n  const rssItems: RssItem[] = [];\n  for (const lang of ALL_LANGUAGES) {\n    articlesByLang.set(lang, []);\n  }\n  for (const filename of articles) {\n    const parsed = parseArticleFilename(filename);\n    if (parsed) {\n      const meta = extractArticleMeta(path.join(NEWS_DIR, filename));\n      const info: SitemapArticleInfo = {\n        filename: parsed.filename,\n        date: parsed.date,\n        title: meta.title || formatSlug(parsed.slug),\n        description: meta.description,\n      };\n      const bucket = articlesByLang.get(parsed.lang);\n      if (bucket) {\n        bucket.push(info);\n      }\n      rssItems.push({\n        title: info.title,\n        link: `${BASE_URL}/news/${info.filename}`,\n        description: info.description || info.title,\n        pubDate: new Date(parsed.date).toUTCString(),\n        lang: parsed.lang,\n      });\n    }\n  }\n\n  // Check if docs directory exists\n  const hasDocsDir = fs.existsSync(DOCS_DIR);\n\n  // Generate sitemap HTML for each language\n  let htmlGenerated = 0;\n  for (const lang of ALL_LANGUAGES) {\n    const langArticles = articlesByLang.get(lang) ?? [];\n    // Sort newest first\n    langArticles.sort((a, b) => b.date.localeCompare(a.date));\n\n    const html = generateSitemapHTML(lang, langArticles, hasDocsDir);\n    const sitemapFilename = getSitemapFilename(lang);\n    const sitemapPath = path.join(PROJECT_ROOT, sitemapFilename);\n    fs.writeFileSync(sitemapPath, html, 'utf-8');\n    console.log(`  ✅ Generated ${sitemapFilename} (${langArticles.length} articles)`);\n    htmlGenerated++;\n  }\n\n  console.log(`✅ Generated ${htmlGenerated} sitemap HTML files`);\n\n  // Sort RSS items newest first using numeric timestamps\n  rssItems.sort((a, b) => Date.parse(b.pubDate) - Date.parse(a.pubDate));\n\n  const rss = generateRssFeed(rssItems);\n  const rssPath = path.join(PROJECT_ROOT, 'rss.xml');\n  fs.writeFileSync(rssPath, rss, 'utf-8');\n  console.log(`✅ Generated rss.xml with ${rssItems.length} items`);\n}\n\n// Only run main when executed directly (not when imported)\nif (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {\n  main();\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/article-strategy.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":147,"column":9,"endLine":147,"endColumn":38},{"ruleId":"@typescript-eslint/prefer-nullish-coalescing","severity":1,"message":"Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.","line":147,"column":47,"messageId":"preferNullishOverOr","endLine":147,"endColumn":49,"suggestions":[{"messageId":"suggestNullish","data":{"equals":""},"fix":{"range":[5968,5970],"text":"??"},"desc":"Fix to nullish coalescing operator (`??`)."}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":150,"column":24,"endLine":150,"endColumn":54},{"ruleId":"@typescript-eslint/prefer-nullish-coalescing","severity":1,"message":"Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.","line":150,"column":63,"messageId":"preferNullishOverOr","endLine":150,"endColumn":65,"suggestions":[{"messageId":"suggestNullish","data":{"equals":""},"fix":{"range":[6128,6130],"text":"??"},"desc":"Fix to nullish coalescing operator (`??`)."}]},{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":190,"column":27,"endLine":190,"endColumn":76}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/Strategies/ArticleStrategy\n * @description Base interface and shared types for article generation strategies.\n * Each strategy encapsulates the fetch, build, and metadata logic for one\n * {@link ArticleCategory}, making it trivial to add new article types without\n * touching the orchestration layer.\n *\n * Includes utilities for loading analysis pipeline output so that strategies\n * can consume classification, threat assessment, risk scoring, and other\n * analysis artifacts produced by the analysis stage.\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport type { ArticleCategory } from '../../types/index.js';\nimport type { LanguageCode } from '../../types/index.js';\nimport type { ArticleSource } from '../../types/index.js';\nimport type { EuropeanParliamentMCPClient } from '../../mcp/ep-mcp-client.js';\nimport { escapeHTML } from '../../utils/file-utils.js';\nimport { ANALYSIS_INSIGHTS_HEADING, getLocalizedString } from '../../constants/languages.js';\n\n// ─── Analysis context types ──────────────────────────────────────────────────\n\n/** Content of a single loaded analysis file */\nexport interface AnalysisFileContent {\n  /** Analysis method that produced this file */\n  readonly method: string;\n  /** Subdirectory category (e.g. 'classification', 'risk-scoring') */\n  readonly subdir: string;\n  /** Raw markdown content (frontmatter included) */\n  readonly content: string;\n  /** Absolute file path on disk */\n  readonly filePath: string;\n}\n\n/**\n * Analysis context loaded from the analysis pipeline output directory.\n *\n * Strategies call {@link loadAnalysisContext} during {@link ArticleStrategy.fetchData}\n * and store the result in their data payload.  The context is then consumed by\n * {@link ArticleStrategy.buildContent} to enrich articles with analytical depth.\n *\n * When analysis files are not available (e.g. the analysis stage was skipped),\n * the context is `null` and strategies degrade gracefully to their existing\n * behaviour.\n */\nexport interface LoadedAnalysisContext {\n  /** ISO date of the analysis */\n  readonly date: string;\n  /** Resolved analysis directory path */\n  readonly analysisDir: string;\n  /** Parsed manifest.json (null when manifest not found) */\n  readonly manifest: Record<string, unknown> | null;\n  /** Overall confidence from the manifest */\n  readonly overallConfidence: string | null;\n  /** Loaded analysis files keyed by method name */\n  readonly files: ReadonlyMap<string, AnalysisFileContent>;\n}\n\n// ─── Analysis loading defaults ───────────────────────────────────────────────\n\n/** Default base directory for analysis output */\nconst DEFAULT_ANALYSIS_BASE_DIR = 'analysis/daily';\n\n/**\n * Environment variable name for overriding the analysis base directory.\n * Set by the orchestration layer when `--analysis-dir` is provided.\n */\nconst ENV_ANALYSIS_DIR = 'EP_ANALYSIS_DIR';\n\n/**\n * Environment variable name for overriding the analysis slug.\n * Set by the orchestration layer with the resolved slug from\n * `deriveArticleTypeSlug()`, so multi-type runs and custom analysis\n * directories are correctly resolved without hard-coding per-strategy slugs.\n */\nconst ENV_ANALYSIS_SLUG = 'EP_ANALYSIS_SLUG';\n\n/**\n * Analysis subdirectories to scan for markdown files.\n *\n * Includes the two richer-content conventions used by reference-quality runs:\n * - `intelligence/` — used by breaking / month-ahead workflows (e.g. Run 190)\n *   for stakeholder-map, scenario-forecast, pestle-analysis, mcp-reliability-audit\n * - `synthesis/` — alternate location used by monthly-review runs for\n *   synthesis-summary and stakeholder-impact\n *\n * Adding these here means `loadAnalysisContext` surfaces the AI-authored\n * markdown to the article-rewriter AI pass as raw context.  Per the AI-First\n * Analysis-to-Article Data Contract\n * (`.github/prompts/SHARED_PROMPT_PATTERNS.md#-analysis-to-article-data-contract-ai-first`),\n * **scripts never parse this markdown into structured data**; the AI reads\n * every file here as context and authors all stakeholder / outcome / impact\n * content directly in the rendered HTML.\n */\nconst ANALYSIS_SUBDIRS = [\n  'classification',\n  'threat-assessment',\n  'risk-scoring',\n  'existing',\n  'documents',\n  'intelligence',\n  'synthesis',\n  'risk',\n] as const;\n\n/**\n * Load analysis context from the analysis pipeline output directory.\n *\n * Scans `{baseDir}/{date}/{articleTypeSlug}/` for a `manifest.json` and\n * analysis markdown files in known subdirectories.  When the directory\n * does not exist or contains no analysis files, returns `null` for graceful\n * degradation — strategies then behave exactly as before.\n *\n * Handles suffixed directories (e.g. `breaking-2`, `breaking-3`) by\n * scanning for the latest match.\n *\n * Resolution order for base directory:\n * 1. Explicit `baseDir` parameter (when non-default)\n * 2. `EP_ANALYSIS_DIR` environment variable (set by orchestration)\n * 3. Default `'analysis/daily'`\n *\n * Resolution order for slug:\n * 1. `EP_ANALYSIS_SLUG` environment variable (set by orchestration)\n * 2. The `articleTypeSlug` parameter passed by each strategy\n *\n * @param date - ISO 8601 date (YYYY-MM-DD) of the analysis run\n * @param articleTypeSlug - Article type slug (e.g. 'breaking', 'week-ahead')\n * @param baseDir - Base analysis directory (defaults to 'analysis/daily')\n * @returns Loaded analysis context or null when unavailable\n */\nexport function loadAnalysisContext(\n  date: string,\n  articleTypeSlug: string,\n  baseDir: string = DEFAULT_ANALYSIS_BASE_DIR\n): LoadedAnalysisContext | null {\n  // Validate date format (YYYY-MM-DD) and reject path traversal\n  if (!/^\\d{4}-\\d{2}-\\d{2}$/u.test(date)) return null;\n\n  // Resolve base dir: prefer explicit non-default param, then env var, then default\n  const resolvedBaseDir =\n    baseDir !== DEFAULT_ANALYSIS_BASE_DIR\n      ? baseDir\n      : process.env[ENV_ANALYSIS_DIR]?.trim() || DEFAULT_ANALYSIS_BASE_DIR;\n\n  // Resolve slug: prefer env var override, then per-strategy slug\n  const resolvedSlug = process.env[ENV_ANALYSIS_SLUG]?.trim() || articleTypeSlug;\n\n  // Validate slug: alphanumeric, hyphens only — no path separators\n  if (!/^[\\da-z][\\da-z-]*$/u.test(resolvedSlug)) return null;\n\n  const dateDir = path.resolve(resolvedBaseDir, date);\n  if (!fs.existsSync(dateDir)) return null;\n\n  // Find the best matching analysis directory (exact or latest suffixed)\n  const analysisDir = findAnalysisDirectory(dateDir, resolvedSlug);\n  if (!analysisDir) return null;\n\n  // Load manifest.json\n  const manifest = loadManifest(analysisDir);\n\n  // Load analysis markdown files from known subdirectories\n  const files = loadAnalysisFiles(analysisDir);\n  if (files.size === 0 && !manifest) return null;\n\n  const overallConfidence =\n    manifest && typeof manifest['overallConfidence'] === 'string'\n      ? manifest['overallConfidence']\n      : null;\n\n  return { date, analysisDir, manifest, overallConfidence, files };\n}\n\n/**\n * Find the best matching analysis directory for an article type slug.\n * Checks exact match first, then scans for suffixed variants and picks\n * the latest (highest suffix number).\n *\n * @param dateDir - Date-scoped parent directory\n * @param slug - Article type slug\n * @returns Resolved directory path or null\n */\nfunction findAnalysisDirectory(dateDir: string, slug: string): string | null {\n  // Always scan for all matching directories (exact + suffixed) to find the latest\n  try {\n    const entries = fs.readdirSync(dateDir, { withFileTypes: true });\n    const suffixPattern = new RegExp(`^${escapeRegExp(slug)}(?:-(\\\\d+))?$`);\n    let bestPath: string | null = null;\n    let bestSuffix = -1;\n\n    for (const entry of entries) {\n      if (!entry.isDirectory()) continue;\n      const match = suffixPattern.exec(entry.name);\n      if (match) {\n        const suffix = match[1] ? parseInt(match[1], 10) : 0;\n        if (suffix > bestSuffix) {\n          bestSuffix = suffix;\n          bestPath = path.join(dateDir, entry.name);\n        }\n      }\n    }\n    return bestPath;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Escape special regex characters in a string.\n *\n * @param str - Input string to escape\n * @returns Escaped string safe for use in RegExp constructor\n */\nfunction escapeRegExp(str: string): string {\n  return str.replace(/[.*+?^${}()|[\\]\\\\]/gu, '\\\\$&');\n}\n\n/**\n * Load and parse `manifest.json` from an analysis directory.\n *\n * @param analysisDir - Analysis output directory\n * @returns Parsed manifest or null\n */\nfunction loadManifest(analysisDir: string): Record<string, unknown> | null {\n  const manifestPath = path.join(analysisDir, 'manifest.json');\n  try {\n    if (!fs.existsSync(manifestPath)) return null;\n    const raw = fs.readFileSync(manifestPath, 'utf-8');\n    return JSON.parse(raw) as Record<string, unknown>;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Extract the `method:` value from YAML frontmatter in a markdown string.\n *\n * Analysis files produced by the pipeline embed the canonical method ID in\n * their frontmatter (e.g. `method: coalition-analysis`).  When this differs\n * from the filename (e.g. `coalition-dynamics.md`), the frontmatter value is\n * the authoritative key for strategy lookups.\n *\n * @param content - Raw markdown content\n * @returns The frontmatter `method` value, or `null` if absent/unparseable\n */\nexport function extractFrontmatterMethod(content: string): string | null {\n  if (!content.startsWith('---')) return null;\n  const endIdx = content.indexOf('---', 3);\n  if (endIdx === -1) return null;\n  const frontmatter = content.slice(3, endIdx);\n  const match = /^method:\\s*(.+)$/mu.exec(frontmatter);\n  return match?.[1]?.trim() ?? null;\n}\n\n/**\n * Load a single analysis markdown file, register it in the map by both its\n * frontmatter-derived method key and filename-derived alias.\n *\n * @param files - Map to register the file content into\n * @param filePath - Absolute path to the .md file\n * @param entry - Filename (e.g. `coalition-dynamics.md`)\n * @param subdir - Parent subdirectory name (e.g. `existing`)\n */\nfunction loadSingleAnalysisFile(\n  files: Map<string, AnalysisFileContent>,\n  filePath: string,\n  entry: string,\n  subdir: string\n): void {\n  try {\n    const content = fs.readFileSync(filePath, 'utf-8');\n    const filenameKey = entry.replace(/\\.md$/u, '');\n    const frontmatterMethod = extractFrontmatterMethod(content);\n    // Primary key: frontmatter method (canonical ID), fallback to filename\n    const method = frontmatterMethod ?? filenameKey;\n    const fileContent: AnalysisFileContent = { method, subdir, content, filePath };\n    files.set(method, fileContent);\n    // Register filename alias when it differs from the frontmatter method\n    if (frontmatterMethod && frontmatterMethod !== filenameKey) {\n      files.set(filenameKey, fileContent);\n    }\n  } catch {\n    // Skip unreadable files\n  }\n}\n\n/**\n * Load analysis markdown files from known subdirectories.\n *\n * Keys the returned map by the `method:` value extracted from the file's\n * YAML frontmatter (canonical method ID).  When the filename differs from\n * the frontmatter method (e.g. `coalition-dynamics.md` with frontmatter\n * `method: coalition-analysis`), both the frontmatter key and the\n * filename-derived key are registered so that callers can look up files\n * by either identifier.\n *\n * @param analysisDir - Analysis output directory\n * @returns Map of method name → file content\n */\nfunction loadAnalysisFiles(analysisDir: string): Map<string, AnalysisFileContent> {\n  const files = new Map<string, AnalysisFileContent>();\n\n  for (const subdir of ANALYSIS_SUBDIRS) {\n    const subdirPath = path.join(analysisDir, subdir);\n    try {\n      if (!fs.existsSync(subdirPath) || !fs.statSync(subdirPath).isDirectory()) continue;\n      const entries = fs.readdirSync(subdirPath);\n      for (const entry of entries) {\n        if (!entry.endsWith('.md')) continue;\n        loadSingleAnalysisFile(files, path.join(subdirPath, entry), entry, subdir);\n      }\n    } catch {\n      // Skip unreadable directories\n    }\n  }\n\n  return files;\n}\n\n/**\n * Check whether a line is part of a fenced code block delimiter or table row.\n * Used to filter out non-prose content from analysis summaries.\n *\n * @param trimmed - Trimmed line of text to check\n * @returns `true` when the line is non-prose content (code, table, HTML)\n */\nfunction isNonProseContent(trimmed: string): boolean {\n  // Fenced code block delimiters\n  if (trimmed.startsWith('```')) return true;\n  // Markdown table rows — lines starting with | or containing multiple | separators\n  if (trimmed.startsWith('|') && trimmed.includes('|', 1)) return true;\n  // Table separator rows (e.g. |---|---|)\n  if (/^[\\s|:|-]+$/u.test(trimmed) && trimmed.includes('|')) return true;\n  // HTML-like content\n  if (trimmed.startsWith('<') && trimmed.endsWith('>')) return true;\n  return false;\n}\n\n/** Patterns that indicate scaffold/placeholder content — not real analysis */\nconst SCAFFOLD_PATTERNS = [\n  /\\[TO BE FILLED BY AI AGENT/i,\n  /\\[AI_ANALYSIS_REQUIRED\\]/i,\n  /\\[REQUIRED\\]/i,\n  /\\[\\?\\]/,\n  /Quality gate: minimum \\d+ words/i,\n  /Instructions for AI Agent/i,\n] as const;\n\n/**\n * Check whether an analysis file contains only scaffold/template content\n * (i.e. the AI agent did not fill in the analysis).\n *\n * @param content - Raw markdown file content\n * @returns `true` when the file is an unfilled scaffold\n */\nexport function isScaffoldContent(content: string): boolean {\n  return SCAFFOLD_PATTERNS.some((pattern) => pattern.test(content));\n}\n\n/**\n * Check whether a line should be included as prose content.\n *\n * @param trimmed - Trimmed line text\n * @returns `true` when the line is valid prose (not heading, separator, blockquote, or non-prose)\n */\nfunction isProseContent(trimmed: string): boolean {\n  if (trimmed === '') return false;\n  if (trimmed.startsWith('#')) return false;\n  if (trimmed.startsWith('---')) return false;\n  if (trimmed.startsWith('>')) return false;\n  if (isNonProseContent(trimmed)) return false;\n  return true;\n}\n\n/**\n * Strip markdown formatting (bold, italic) from a text string.\n *\n * @param text - Raw markdown text\n * @returns Plain text with bold/italic markers removed\n */\nfunction stripMarkdownFormatting(text: string): string {\n  return text.replace(/\\*\\*([^*]+)\\*\\*/g, '$1').replace(/\\*([^*]+)\\*/g, '$1');\n}\n\n/**\n * Prepare analysis content body by stripping frontmatter and code blocks.\n *\n * @param content - Raw markdown content\n * @returns Body text ready for paragraph extraction, or empty string for scaffold content\n */\nfunction prepareAnalysisBody(content: string): string {\n  if (isScaffoldContent(content)) return '';\n\n  let body = content;\n  if (body.startsWith('---')) {\n    const endIdx = body.indexOf('---', 3);\n    if (endIdx !== -1) body = body.slice(endIdx + 3);\n  }\n  return body.replace(/```[\\s\\S]*?```/g, '');\n}\n\n/**\n * Collect prose paragraphs from prepared analysis body text.\n *\n * @param body - Analysis body with frontmatter/code blocks removed\n * @returns Array of prose paragraphs\n */\nfunction collectParagraphs(body: string): readonly string[] {\n  const lines = body.split('\\n');\n  const paragraphs: string[] = [];\n  let current = '';\n\n  for (const line of lines) {\n    const trimmed = line.trim();\n    if (trimmed === '' && current) {\n      paragraphs.push(current.trim());\n      current = '';\n    } else if (isProseContent(trimmed)) {\n      current += (current ? ' ' : '') + stripMarkdownFormatting(trimmed);\n    }\n  }\n  if (current) paragraphs.push(current.trim());\n  return paragraphs;\n}\n\n/**\n * Extract the first meaningful paragraph from an analysis markdown file.\n * Strips YAML frontmatter, headings, fenced code blocks, tables,\n * scaffold markers, and markdown formatting. Returns plain prose content.\n *\n * @param content - Raw markdown content\n * @param maxLength - Maximum character length to return (default 500)\n * @returns Extracted summary text or empty string\n */\nexport function extractAnalysisSummary(content: string, maxLength: number = 500): string {\n  const body = prepareAnalysisBody(content);\n  if (!body) return '';\n\n  const paragraphs = collectParagraphs(body);\n  const meaningful = filterMeaningfulParagraphs(paragraphs, 20);\n\n  const summary = meaningful[0] ?? '';\n  return summary.length > maxLength ? summary.slice(0, maxLength - 3) + '...' : summary;\n}\n\n/**\n * Filter paragraphs to only include meaningful prose content.\n * Removes short fragments and data-only paragraphs (e.g. \"— | — | —\").\n *\n * @param paragraphs - Array of paragraph strings to filter\n * @param minLength - Minimum character length for a paragraph to be considered meaningful\n * @returns Filtered array of meaningful paragraphs\n */\nfunction filterMeaningfulParagraphs(\n  paragraphs: readonly string[],\n  minLength: number\n): readonly string[] {\n  return paragraphs.filter(\n    (p) => p.length > minLength && !/^[\\d\\s|—–-]+$/u.test(p) && !/^\\s*—\\s*$/u.test(p)\n  );\n}\n\n/**\n * Extract multiple meaningful paragraphs from an analysis markdown file.\n * Provides richer content than the single-paragraph extractAnalysisSummary.\n *\n * @param content - Raw markdown file content\n * @param maxParagraphs - Maximum number of paragraphs to return (default 3)\n * @param maxTotalLength - Maximum total character length (default 1500)\n * @returns Array of extracted prose paragraphs\n */\nexport function extractAnalysisParagraphs(\n  content: string,\n  maxParagraphs: number = 3,\n  maxTotalLength: number = 1500\n): readonly string[] {\n  const body = prepareAnalysisBody(content);\n  if (!body) return [];\n\n  const paragraphs = collectParagraphs(body);\n  const meaningful = filterMeaningfulParagraphs(paragraphs, 50);\n\n  const result: string[] = [];\n  let totalLength = 0;\n  for (const p of meaningful) {\n    if (result.length >= maxParagraphs) break;\n    const remaining = maxTotalLength - totalLength;\n    if (remaining <= 0) break;\n\n    if (p.length > remaining) {\n      // Truncate overlong paragraph when result is still empty so we\n      // never return [] for content that has substantive prose.\n      if (result.length === 0) {\n        result.push(p.slice(0, remaining).trimEnd());\n      }\n      break;\n    }\n    result.push(p);\n    totalLength += p.length;\n  }\n  return result;\n}\n\n/**\n * Check whether an analysis file contains substantive AI-produced content\n * (as opposed to pipeline scaffolding or empty templates).\n *\n * @param content - Raw markdown file content\n * @returns `true` when the file contains real analytical prose\n */\nexport function hasSubstantiveAIContent(content: string): boolean {\n  const body = prepareAnalysisBody(content);\n  if (!body) return false;\n\n  // Count words in prose lines (not tables, not headings, not blockquotes)\n  let wordCount = 0;\n  for (const line of body.split('\\n')) {\n    const trimmed = line.trim();\n    if (isProseContent(trimmed) && !trimmed.startsWith('>')) {\n      wordCount += trimmed.split(/\\s+/u).length;\n    }\n  }\n  // Minimum 5 words of prose — primarily relies on scaffold detection above\n  return wordCount >= 5;\n}\n\n/**\n * Build an HTML section summarising analysis pipeline insights.\n *\n * Creates a structured `<section class=\"analysis-pipeline-insights\">` element\n * containing key findings from loaded analysis files.  Each strategy passes\n * the methods it considers relevant; only those with loaded content are rendered.\n *\n * Filters out scaffold/template files and files with no substantive AI content.\n * Uses extended paragraph extraction for richer insight content.\n *\n * @param ctx - Loaded analysis context (null-safe: returns empty string)\n * @param relevantMethods - Method names this strategy wants to display\n * @param lang - Target language code (used for localized section heading)\n * @returns HTML string (empty when no context or no relevant files)\n */\nexport function buildAnalysisInsightsSection(\n  ctx: LoadedAnalysisContext | null | undefined,\n  relevantMethods: readonly string[],\n  lang: LanguageCode\n): string {\n  if (!ctx) return '';\n\n  const items: string[] = [];\n  for (const method of relevantMethods) {\n    const file = ctx.files.get(method);\n    if (!file) continue;\n    // `extractAnalysisParagraphs()` already filters scaffold, empty, and\n    // non-substantive analysis bodies, so use it as the single gate here.\n    const paragraphs = extractAnalysisParagraphs(file.content, 2, 800);\n    if (paragraphs.length === 0) continue;\n    const label = formatMethodLabel(method);\n    const paragraphHtml = paragraphs.map((p) => `<p>${escapeHTML(p)}</p>`).join('\\n');\n    items.push(\n      `<div class=\"analysis-insight-item\" data-method=\"${escapeHTML(method)}\">\\n` +\n        `<h4>${escapeHTML(label)}</h4>\\n` +\n        paragraphHtml +\n        '\\n' +\n        `</div>`\n    );\n  }\n\n  if (items.length === 0) return '';\n\n  const heading = getLocalizedString(ANALYSIS_INSIGHTS_HEADING, lang);\n  const confidence = ctx.overallConfidence\n    ? ` <span class=\"confidence-badge\">${escapeHTML(ctx.overallConfidence)}</span>`\n    : '';\n\n  return (\n    `<section class=\"analysis-pipeline-insights\" role=\"region\" aria-label=\"${escapeHTML(heading)}\">\\n` +\n    `<h2>${escapeHTML(heading)}${confidence}</h2>\\n` +\n    items.join('\\n') +\n    `\\n</section>\\n`\n  );\n}\n\n/**\n * Format an analysis method identifier into a human-readable label.\n *\n * @param method - Method identifier (e.g. 'significance-classification')\n * @returns Formatted label (e.g. 'Significance Classification')\n */\nfunction formatMethodLabel(method: string): string {\n  return method\n    .split('-')\n    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n    .join(' ');\n}\n\n// ─── Article data and strategy interfaces ─────────────────────────────────────\n\n/**\n * Minimum payload every strategy must carry: the article's publication date.\n * Strategy-specific data interfaces extend this base.\n */\nexport interface ArticleData {\n  /** ISO 8601 publication date (YYYY-MM-DD) */\n  readonly date: string;\n  /** Loaded analysis context from the analysis pipeline (when available) */\n  readonly analysisContext?: LoadedAnalysisContext | null | undefined;\n}\n\n/**\n * Resolved title, subtitle, keywords, and optional sources for one\n * language version of an article.\n */\nexport interface ArticleMetadata {\n  /** Localized article title */\n  readonly title: string;\n  /** Localized article subtitle */\n  readonly subtitle: string;\n  /** SEO keywords */\n  readonly keywords: readonly string[];\n  /** Article category */\n  readonly category: ArticleCategory;\n  /** Optional source references */\n  readonly sources?: readonly ArticleSource[] | undefined;\n}\n\n/**\n * Non-generic base interface for {@link ArticleStrategy} used by the strategy\n * registry.  Expresses the common operations with {@link ArticleData} as the\n * data payload type so that concrete strategies parameterised on a subtype can\n * be stored in a single {@link StrategyRegistry} map without unsafe casts.\n *\n * This interface deliberately relies on TypeScript's bivariant method-parameter\n * checking: a concrete strategy whose methods accept a narrower `TData`\n * (extending {@link ArticleData}) still satisfies this interface structurally.\n * That means the type is not fully sound — a caller that only sees\n * {@link ArticleStrategyBase} could, in principle, pass an {@link ArticleData}\n * value that does not match the concrete strategy's expected `TData` shape.\n *\n * To use this interface safely:\n * - Only pass the `data` object returned by a given strategy's own\n *   {@link ArticleStrategyBase.fetchData | fetchData} to that same strategy's\n *   {@link ArticleStrategyBase.buildContent | buildContent} and\n *   {@link ArticleStrategyBase.getMetadata | getMetadata} methods.\n * - Never mix data payloads between different strategies, even if they share\n *   the {@link ArticleData} base type.\n *\n * External callers that need strong typing for a specific strategy should\n * prefer the generic {@link ArticleStrategy} interface, which preserves the\n * concrete `TData` type and avoids this intentional unsoundness. The\n * {@link ArticleStrategyBase} interface is intended primarily for the internal\n * orchestration / pipeline layer that manages a heterogeneous strategy\n * registry.\n */\nexport interface ArticleStrategyBase {\n  /** The article category this strategy handles */\n  readonly type: ArticleCategory;\n  /** Names of MCP tools this strategy calls */\n  readonly requiredMCPTools: readonly string[];\n  /**\n   * Fetch all domain data needed to render this article type.\n   *\n   * @param client - Connected MCP client, or null when MCP is unavailable\n   * @param date - ISO 8601 publication date (YYYY-MM-DD)\n   * @returns Populated article data payload\n   */\n  fetchData(client: EuropeanParliamentMCPClient | null, date: string): Promise<ArticleData>;\n  /**\n   * Build the article HTML body for the given language.\n   *\n   * @param data - Data payload returned by {@link fetchData}\n   * @param lang - Target language code\n   * @returns Article body HTML string\n   */\n  buildContent(data: ArticleData, lang: LanguageCode): string;\n  /**\n   * Return title, subtitle, keywords, and sources for the given language.\n   *\n   * @param data - Data payload returned by {@link fetchData}\n   * @param lang - Target language code\n   * @returns Article metadata\n   */\n  getMetadata(data: ArticleData, lang: LanguageCode): ArticleMetadata;\n  /**\n   * Optional guard that lets a strategy opt out of generation when the\n   * fetched data contains only placeholder / fallback values (e.g. MCP\n   * unavailable).  When `true` is returned the orchestrator skips writing\n   * all language variants and logs a notice rather than publishing empty\n   * placeholder articles.\n   *\n   * Strategies that do not implement this method are treated as always\n   * wanting to generate (i.e. the default is `false`).\n   *\n   * @param data - Data payload returned by {@link fetchData}\n   * @returns `true` when all fetched data is placeholder and generation should be skipped\n   */\n  shouldSkip?(data: ArticleData): boolean;\n}\n\n/**\n * Strategy interface for article generation.\n *\n * Each concrete implementation handles one {@link ArticleCategory}:\n * - {@link module:Generators/Strategies/WeekAheadStrategy}\n * - {@link module:Generators/Strategies/BreakingNewsStrategy}\n * - {@link module:Generators/Strategies/CommitteeReportsStrategy}\n * - {@link module:Generators/Strategies/PropositionsStrategy}\n * - {@link module:Generators/Strategies/MotionsStrategy}\n *\n * @template TData - Concrete data payload type returned by {@link fetchData}\n */\nexport interface ArticleStrategy<\n  TData extends ArticleData = ArticleData,\n> extends ArticleStrategyBase {\n  /**\n   * Fetch all domain data needed to render this article type.\n   *\n   * @param client - Connected MCP client, or null when MCP is unavailable\n   * @param date - ISO 8601 publication date (YYYY-MM-DD)\n   * @returns Populated article data payload\n   */\n  fetchData(client: EuropeanParliamentMCPClient | null, date: string): Promise<TData>;\n  /**\n   * Build the article HTML body for the given language.\n   *\n   * @param data - Data payload returned by {@link fetchData}\n   * @param lang - Target language code\n   * @returns Article body HTML string\n   */\n  buildContent(data: TData, lang: LanguageCode): string;\n  /**\n   * Return title, subtitle, keywords, and sources for the given language.\n   *\n   * @param data - Data payload returned by {@link fetchData}\n   * @param lang - Target language code\n   * @returns Article metadata\n   */\n  getMetadata(data: TData, lang: LanguageCode): ArticleMetadata;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/breaking-news-strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/committee-reports-strategy.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":310,"column":20,"endLine":310,"endColumn":32},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":312,"column":5,"endLine":312,"endColumn":17},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":332,"column":22,"endLine":332,"endColumn":34},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":334,"column":21,"endLine":334,"endColumn":33},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":343,"column":32,"endLine":343,"endColumn":51}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/Strategies/CommitteeReportsStrategy\n * @description Article strategy for the Committee Reports article type.\n * Fetches data for each featured committee in parallel and renders an\n * effectiveness and activity overview article.\n */\n\nimport type { EuropeanParliamentMCPClient } from '../../mcp/ep-mcp-client.js';\nimport { ArticleCategory } from '../../types/index.js';\nimport type { LanguageCode, CommitteeData, EPFeedData } from '../../types/index.js';\nimport {\n  COMMITTEE_REPORTS_TITLES,\n  COMMITTEE_ANALYSIS_CONTENT_STRINGS,\n  getLocalizedString,\n} from '../../constants/languages.js';\nimport {\n  computeRollingDateRange,\n  fetchCommitteeData,\n  fetchEPFeedData,\n} from '../pipeline/fetch-stage.js';\nimport {\n  FEATURED_COMMITTEES,\n  isPlaceholderCommitteeData,\n  PLACEHOLDER_CHAIR,\n  PLACEHOLDER_MEMBERS,\n} from '../committee-helpers.js';\nimport { escapeHTML } from '../../utils/file-utils.js';\nimport { buildDeepAnalysisSection } from '../deep-analysis-content.js';\nimport {\n  buildCommitteeAnalysis,\n  buildCommitteeSwot,\n  buildCommitteeDashboard,\n  buildCommitteeMindmap,\n} from '../analysis-builders.js';\nimport { buildSwotSection } from '../swot-content.js';\nimport { buildDashboardSection } from '../dashboard-content.js';\nimport { buildIntelligenceMindmapSection } from '../mindmap-content.js';\nimport type { ArticleStrategy, ArticleData, ArticleMetadata } from './article-strategy.js';\nimport { loadAnalysisContext, buildAnalysisInsightsSection } from './article-strategy.js';\nimport type { ArticleSource } from '../../types/index.js';\nimport { truncateTitle, MIN_MEANINGFUL_TITLE_LENGTH } from '../../utils/metadata-utils.js';\n\n/** European Parliament home-page URL used as source reference */\nconst EP_SOURCE_URL = 'https://www.europarl.europa.eu';\n\n/** European Parliament display name for source titles and article lede */\nconst EP_DISPLAY_NAME = 'European Parliament';\n\n/** Base keywords shared by all Committee Reports articles */\nconst COMMITTEE_REPORTS_BASE_KEYWORDS = ['committee', 'EU Parliament', 'legislation'] as const;\n\n/** Source reference included in every committee reports article */\nconst COMMITTEE_REPORTS_SOURCES: readonly ArticleSource[] = [\n  { title: EP_DISPLAY_NAME, url: EP_SOURCE_URL },\n];\n\n/**\n * Extract content-aware keywords from committee data and feed data.\n *\n * Includes committee abbreviations, names, document types, and adopted-text\n * themes for richer SEO coverage.\n *\n * @param committeeDataList - Fetched committee data\n * @param feedData - EP feed data (may be undefined)\n * @returns Deduplicated keyword array\n */\nfunction buildCommitteeKeywords(\n  committeeDataList: readonly CommitteeData[],\n  feedData?: EPFeedData | undefined\n): string[] {\n  const keywords: string[] = [...COMMITTEE_REPORTS_BASE_KEYWORDS];\n\n  // Add committee abbreviations and names\n  for (const c of committeeDataList) {\n    if (c.abbreviation) keywords.push(c.abbreviation);\n    if (c.name) keywords.push(c.name);\n  }\n\n  // Add adopted text themes from feed\n  if (feedData?.adoptedTexts) {\n    for (const text of feedData.adoptedTexts.slice(0, 5)) {\n      if (text.title) {\n        const theme = categorizeAdoptedText(text.title);\n        if (theme !== 'OTHER') keywords.push(theme);\n      }\n    }\n  }\n\n  return [...new Set(keywords)];\n}\n\n/**\n * Build a content-aware description from committee data.\n * Summarises the number of active committees, document counts, and\n * effectiveness highlights when available.\n *\n * @param committeeDataList - Fetched committee data\n * @param feedData - EP feed data (may be undefined)\n * @returns SEO-friendly description (≤ 200 chars)\n */\nfunction buildCommitteeDescription(\n  committeeDataList: readonly CommitteeData[],\n  feedData?: EPFeedData | undefined\n): string {\n  // Priority 1: Use the title of the most significant adopted text\n  const topAdopted = feedData?.adoptedTexts?.find(\n    (t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH\n  );\n  if (topAdopted) {\n    const abbrs = committeeDataList\n      .filter((c) => c.chair !== PLACEHOLDER_CHAIR)\n      .map((c) => c.abbreviation)\n      .join(', ');\n    const desc = abbrs\n      ? `EP committees ${abbrs}: ${topAdopted.title}`\n      : `EP committee report: ${topAdopted.title}`;\n    return desc.length > 200 ? desc.slice(0, 197) + '...' : desc;\n  }\n\n  // Priority 2: Name the active committees\n  const abbrs = committeeDataList\n    .filter((c) => c.chair !== PLACEHOLDER_CHAIR)\n    .map((c) => c.abbreviation)\n    .join(', ');\n  if (abbrs) {\n    return `European Parliament committee activity report covering ${abbrs}`;\n  }\n\n  return 'Analysis of recent legislative output, effectiveness metrics, and key committee activities';\n}\n\n/**\n * Build a content-aware title suffix from the most significant\n * committee item.  Uses actual legislation titles, not data counts.\n *\n * @param committeeDataList - Fetched committee data\n * @param feedData - EP feed data (may be undefined)\n * @returns Short analytical suffix, or empty string\n */\nfunction buildCommitteeTitleSuffix(\n  committeeDataList: readonly CommitteeData[],\n  feedData?: EPFeedData | undefined\n): string {\n  // Priority 1: Name the most significant adopted text\n  const topAdopted = feedData?.adoptedTexts?.find(\n    (t) => t.title && t.title.length > MIN_MEANINGFUL_TITLE_LENGTH\n  );\n  if (topAdopted) {\n    return truncateTitle(topAdopted.title);\n  }\n\n  // Priority 2: List active committee abbreviations\n  const activeAbbrs = committeeDataList\n    .filter((c) => c.chair !== PLACEHOLDER_CHAIR)\n    .map((c) => c.abbreviation);\n  if (activeAbbrs.length > 0) {\n    return activeAbbrs.slice(0, 5).join(', ');\n  }\n\n  return '';\n}\n\n// ─── Data payload ─────────────────────────────────────────────────────────────\n\n/** Data fetched and pre-processed by {@link CommitteeReportsStrategy} */\nexport interface CommitteeReportsArticleData extends ArticleData {\n  /** Resolved data for each featured committee */\n  readonly committeeDataList: readonly CommitteeData[];\n  /** EP feed data for enrichment (when available) */\n  readonly feedData?: EPFeedData | undefined;\n}\n\n// ─── Adopted-texts categorization ─────────────────────────────────────────────\n\n/**\n * Narrow union type for the committee theme category keys used when\n * grouping and displaying adopted texts.\n */\nexport type CommitteeTheme = 'ENVI' | 'ECON' | 'AFET' | 'LIBE' | 'AGRI' | 'OTHER';\n\n// Keyword lists are pre-normalized to lowercase so that each call to\n// categorizeAdoptedText only needs to lowercase the title once.\n//\n// LIBE is checked before AFET so that human-rights and human-trafficking\n// titles are correctly classified under civil liberties even when the title\n// also mentions a country (e.g. \"Ukraine\") that would match AFET.\n// Both LIBE and AFET are checked before AGRI so that person-name false\n// positives (e.g. \"Bobi Wine\" matching the agricultural keyword \"wine\")\n// are avoided.\n// AGRI is then checked before ENVI so that titles containing 'agri-food' are\n// not incorrectly captured by ENVI's broader 'food' keyword.\n\n/** Lowercase keywords that map an adopted-text title to the AFET theme group */\nexport const AFET_KEYWORDS: readonly string[] = [\n  'foreign affairs',\n  'foreign policy',\n  'security policy',\n  'security cooperation',\n  'defence',\n  'defense',\n  'sanctions',\n  'magnitsky',\n  'ukraine',\n  'aggression',\n  'peace agreement',\n  'peace process',\n  'peace mission',\n  'peace operation',\n  'post-election',\n  'opposition leader',\n  'threats against',\n];\n\n/** Lowercase keywords that map an adopted-text title to the LIBE theme group */\nexport const LIBE_KEYWORDS: readonly string[] = [\n  'civil liberties',\n  'civil rights',\n  'justice and home affairs',\n  'justice cooperation',\n  'criminal justice',\n  'fundamental rights',\n  'human rights',\n  'human trafficking',\n  \"workers' rights\",\n  'safe countries',\n  'safe third',\n  'asylum',\n  'migration',\n];\n\n/** Lowercase keywords that map an adopted-text title to the AGRI theme group */\nexport const AGRI_KEYWORDS: readonly string[] = [\n  'agriculture',\n  'wine',\n  'agri-food',\n  'mercosur',\n  'rural',\n  'farming',\n  'fisheries',\n];\n\n/** Lowercase keywords that map an adopted-text title to the ENVI theme group */\nexport const ENVI_KEYWORDS: readonly string[] = [\n  'environment',\n  'climate',\n  'emission',\n  'health',\n  'food',\n  'medicinal',\n  'detergent',\n  'gmo',\n  'genetically',\n  'cancer',\n];\n\n/** Lowercase keywords that map an adopted-text title to the ECON theme group */\nexport const ECON_KEYWORDS: readonly string[] = [\n  'economic',\n  'financial',\n  'central bank',\n  'monetary',\n  'fiscal',\n  '28th regime',\n];\n\n/**\n * Categorize an adopted-text title into a committee theme group.\n *\n * LIBE is tested before AFET so that human-rights and human-trafficking\n * titles are classified under civil liberties even when they also mention\n * an AFET country keyword (e.g. \"Ukraine\"). Both are tested before AGRI\n * to avoid person-name false positives (e.g. \"Bobi Wine\").\n *\n * @param title - Adopted text title to categorize\n * @returns Committee theme key — one of `'ENVI'` | `'ECON'` | `'AFET'` | `'LIBE'` | `'AGRI'` | `'OTHER'`\n */\nexport function categorizeAdoptedText(title: string): CommitteeTheme {\n  const t = title.toLowerCase();\n  if (LIBE_KEYWORDS.some((k) => t.includes(k))) return 'LIBE';\n  if (AFET_KEYWORDS.some((k) => t.includes(k))) return 'AFET';\n  if (AGRI_KEYWORDS.some((k) => t.includes(k))) return 'AGRI';\n  if (ENVI_KEYWORDS.some((k) => t.includes(k))) return 'ENVI';\n  if (ECON_KEYWORDS.some((k) => t.includes(k))) return 'ECON';\n  return 'OTHER';\n}\n\n// ─── Feed data enrichment ─────────────────────────────────────────────────────\n\n/**\n * Build an HTML section from adopted texts feed data.\n * Groups texts by thematic area and provides committee-relevant context.\n *\n * @param feedData - EP feed data with adopted texts\n * @param lang - Language code for localized strings\n * @returns HTML string, or empty string when no feed data\n */\nfunction buildAdoptedTextsSection(feedData: EPFeedData | undefined, lang: LanguageCode): string {\n  if (!feedData?.adoptedTexts?.length) return '';\n  const texts = feedData.adoptedTexts;\n  const s = getLocalizedString(COMMITTEE_ANALYSIS_CONTENT_STRINGS, lang);\n\n  const grouped: Partial<\n    Record<CommitteeTheme, Array<{ id: string; title: string; date: string }>>\n  > = {};\n  for (const text of texts) {\n    const cat = categorizeAdoptedText(text.title);\n    const bucket = grouped[cat] ?? [];\n    bucket.push(text);\n    grouped[cat] = bucket;\n  }\n\n  const committeeNames: Record<CommitteeTheme, string> = {\n    ENVI: s.committeeNameENVI,\n    ECON: s.committeeNameECON,\n    AFET: s.committeeNameAFET,\n    LIBE: s.committeeNameLIBE,\n    AGRI: s.committeeNameAGRI,\n    OTHER: s.committeeNameOTHER,\n  };\n\n  const sectionLabel = s.adoptedTextsSectionHeading;\n  const summary =\n    texts.length === 1\n      ? s.adoptedTextsSummarySingular\n      : s.adoptedTextsSummary.replace('{count}', String(texts.length));\n  const displayOrder = ['ENVI', 'ECON', 'AFET', 'LIBE', 'AGRI', 'OTHER'] as const;\n\n  const sections = displayOrder\n    .filter((cat) => grouped[cat]?.length)\n    .map((cat) => {\n      const items = grouped[cat] ?? [];\n      const listItems = items\n        .map(\n          (item) =>\n            `<li class=\"adopted-text-item\"><strong>${escapeHTML(item.title)}</strong> <span class=\"document-date\">(${escapeHTML(item.date)})</span></li>`\n        )\n        .join('\\n                ');\n      return `\n            <div class=\"committee-theme-group\">\n              <h4>${escapeHTML(committeeNames[cat] ?? cat)}</h4>\n              <ul class=\"adopted-texts-list\">${listItems}</ul>\n            </div>`;\n    })\n    .join('');\n\n  if (!sections) return '';\n\n  return `\n          <section class=\"adopted-texts-overview\">\n            <h3>${escapeHTML(sectionLabel)}</h3>\n            <p>${escapeHTML(summary)}</p>\n            ${sections}\n          </section>`;\n}\n\n// ─── HTML builders ────────────────────────────────────────────────────────────\n\n/**\n * Build the HTML body for a committee reports article.\n *\n * @param committeeDataList - Pre-fetched committee data\n * @param lang - Language code for localized strings\n * @returns Article HTML body\n */\nfunction buildCommitteeReportsHTML(\n  committeeDataList: readonly CommitteeData[],\n  lang: LanguageCode\n): string {\n  const s = getLocalizedString(COMMITTEE_ANALYSIS_CONTENT_STRINGS, lang);\n\n  const committeeSections = committeeDataList\n    .map((committee) => {\n      // Render an unavailable notice for individual placeholder committee entries\n      if (\n        committee.chair === PLACEHOLDER_CHAIR &&\n        committee.members === PLACEHOLDER_MEMBERS &&\n        committee.documents.length === 0\n      ) {\n        return `\n      <section class=\"committee-card committee-card--unavailable\">\n        <h3 class=\"committee-name\">${escapeHTML(committee.name)} (${escapeHTML(committee.abbreviation)})</h3>\n        <p class=\"committee-metadata-unavailable\">${escapeHTML(s.committeeMetadataUnavailable)}</p>\n      </section>`;\n      }\n\n      const docItems =\n        committee.documents.length > 0\n          ? committee.documents\n              .map(\n                (doc) => `\n                <li class=\"document-item\">\n                  <span class=\"document-type\">${escapeHTML(doc.type)}</span>\n                  <span class=\"document-title\">${escapeHTML(doc.title)}</span>\n                  ${doc.date ? `<span class=\"document-date\">${escapeHTML(doc.date)}</span>` : ''}\n                </li>`\n              )\n              .join('')\n          : `<li>${escapeHTML(s.noRecentDocs)}</li>`;\n\n      const effectivenessHtml = committee.effectiveness\n        ? `<p class=\"effectiveness-score\">${escapeHTML(committee.effectiveness)}</p>`\n        : '';\n\n      return `\n      <section class=\"committee-card\">\n        <h3 class=\"committee-name\">${escapeHTML(committee.name)} (${escapeHTML(committee.abbreviation)})</h3>\n        <div class=\"committee-meta\">\n          <span class=\"committee-chair\">${escapeHTML(s.chairLabel)} ${escapeHTML(committee.chair)}</span>\n          <span class=\"committee-members\">${committee.members} ${escapeHTML(s.membersLabel)}</span>\n        </div>\n        <section class=\"recent-activity\">\n          <ul class=\"document-list\">${docItems}</ul>\n        </section>\n        <section class=\"effectiveness-metrics\">${effectivenessHtml}</section>\n      </section>`;\n    })\n    .join('');\n\n  return `\n    <div class=\"article-content\">\n      <section class=\"committee-overview\">\n        <p class=\"lede\">${escapeHTML(s.lede)}</p>\n      </section>\n      <section class=\"committee-reports\">${committeeSections}</section>\n    </div>`;\n}\n\n// ─── Strategy implementation ──────────────────────────────────────────────────\n\n/**\n * Article strategy for {@link ArticleCategory.COMMITTEE_REPORTS}.\n * Fetches info, documents and effectiveness data for the featured committees\n * then renders an activity overview.\n */\nexport class CommitteeReportsStrategy implements ArticleStrategy<CommitteeReportsArticleData> {\n  readonly type = ArticleCategory.COMMITTEE_REPORTS;\n\n  readonly requiredMCPTools = [\n    'get_committee_info',\n    'search_documents',\n    'analyze_legislative_effectiveness',\n    'get_committee_documents_feed',\n  ] as const;\n\n  /**\n   * Fetch committee data for all featured committees in parallel.\n   *\n   * @param client - MCP client or null\n   * @param date - ISO 8601 publication date\n   * @returns Populated committee reports data payload\n   */\n  async fetchData(\n    client: EuropeanParliamentMCPClient | null,\n    date: string\n  ): Promise<CommitteeReportsArticleData> {\n    const feedDateRange = computeRollingDateRange(date, 30, 'committee feed window');\n\n    // Fetch individual committee data and EP feeds in parallel\n    const [committeeDataRaw, feedData] = await Promise.all([\n      Promise.all(\n        FEATURED_COMMITTEES.map((abbr) =>\n          fetchCommitteeData(client, abbr).catch((error: unknown) => {\n            const message = error instanceof Error ? error.message : String(error);\n            console.error(`  ⚠️ Failed to fetch data for committee ${abbr}:`, message);\n            return null;\n          })\n        )\n      ),\n      fetchEPFeedData(client, 'one-month', feedDateRange),\n    ]);\n\n    const committeeDataList = committeeDataRaw.filter(\n      (committee): committee is CommitteeData => committee !== null\n    );\n\n    return {\n      date,\n      committeeDataList,\n      feedData,\n      analysisContext: loadAnalysisContext(date, 'committee-reports'),\n    };\n  }\n\n  /**\n   * Build the committee reports HTML body.\n   *\n   * @param data - Committee reports data payload\n   * @param lang - Language code for localized content\n   * @returns Article HTML body\n   */\n  buildContent(data: CommitteeReportsArticleData, lang: LanguageCode): string {\n    const base = buildCommitteeReportsHTML(data.committeeDataList, lang);\n    const feedSection = buildAdoptedTextsSection(data.feedData, lang);\n    const analysis = buildCommitteeAnalysis(data.committeeDataList, data.date, lang);\n    const deepSection = buildDeepAnalysisSection(analysis, lang);\n    const mindmapData = buildCommitteeMindmap(data.committeeDataList, lang);\n    const mindmapSection = buildIntelligenceMindmapSection(mindmapData, lang);\n    const swotData = buildCommitteeSwot(data.committeeDataList, lang);\n    const swotSection = buildSwotSection(swotData, lang);\n    const dashboardData = buildCommitteeDashboard(data.committeeDataList, lang);\n    const dashboardSection = buildDashboardSection(dashboardData, lang);\n    const analysisInsights = buildAnalysisInsightsSection(\n      data.analysisContext,\n      [\n        'deep-analysis',\n        'synthesis-summary',\n        'stakeholder-analysis',\n        'coalition-analysis',\n        'cross-session-intelligence',\n        'significance-classification',\n        'impact-matrix',\n        'actor-mapping',\n      ],\n      lang\n    );\n    const injection =\n      feedSection +\n      deepSection +\n      mindmapSection +\n      swotSection +\n      dashboardSection +\n      analysisInsights;\n    // Inject before the closing </div> of .article-content\n    if (injection) {\n      const closingTag = '</div>';\n      const lastIdx = base.lastIndexOf(closingTag);\n      if (lastIdx !== -1) {\n        return base.slice(0, lastIdx) + injection + '\\n' + base.slice(lastIdx);\n      }\n    }\n    return base;\n  }\n\n  /**\n   * Return language-specific metadata for the committee reports article.\n   *\n   * @param data - Committee reports data payload\n   * @param lang - Target language code\n   * @returns Localised metadata\n   */\n  getMetadata(data: CommitteeReportsArticleData, lang: LanguageCode): ArticleMetadata {\n    const committeeLabel = FEATURED_COMMITTEES.join(', ');\n    const titleFn = getLocalizedString(COMMITTEE_REPORTS_TITLES, lang);\n    const { title: baseTitle, subtitle: baseSubtitle } = titleFn(committeeLabel);\n    const suffix =\n      lang === 'en' ? buildCommitteeTitleSuffix(data.committeeDataList, data.feedData) : '';\n    const title = suffix ? `${baseTitle} — ${suffix}` : baseTitle;\n    const subtitle =\n      lang === 'en'\n        ? buildCommitteeDescription(data.committeeDataList, data.feedData) || baseSubtitle\n        : baseSubtitle;\n    return {\n      title,\n      subtitle,\n      keywords: buildCommitteeKeywords(data.committeeDataList, data.feedData),\n      category: ArticleCategory.COMMITTEE_REPORTS,\n      sources: COMMITTEE_REPORTS_SOURCES,\n    };\n  }\n\n  /**\n   * Skip generation when no real data is available.\n   *\n   * Skips when:\n   * - All committee fetches failed (empty committeeDataList), or\n   * - All fetched committee data is placeholder AND no feed data is available.\n   * When EP feed data contains adopted texts or other items, the article can still\n   * provide valuable content even if individual committee metadata is sparse.\n   *\n   * @param data - Committee reports data payload\n   * @returns `true` when there is no usable data at all\n   */\n  shouldSkip(data: CommitteeReportsArticleData): boolean {\n    const { committeeDataList, feedData } = data;\n\n    if (committeeDataList.length === 0) {\n      return true;\n    }\n\n    // If feed data has any items, generate even with placeholder committees\n    if (feedData) {\n      const feedItemCount =\n        (feedData.adoptedTexts?.length ?? 0) +\n        (feedData.committeeDocuments?.length ?? 0) +\n        (feedData.plenaryDocuments?.length ?? 0) +\n        (feedData.documents?.length ?? 0) +\n        (feedData.procedures?.length ?? 0);\n      if (feedItemCount > 0) {\n        return false;\n      }\n    }\n\n    return isPlaceholderCommitteeData(committeeDataList);\n  }\n}\n\n/** Singleton instance for use by the strategy registry */\nexport const committeeReportsStrategy = new CommitteeReportsStrategy();\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/month-ahead-strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/monthly-review-strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/motions-strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/propositions-strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/week-ahead-strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/strategies/weekly-review-strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/swot-content.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/synthesis-summary.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/generators/week-ahead-content.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":355,"column":10,"endLine":355,"endColumn":19}],"suppressedMessages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":71,"column":28,"endLine":71,"endColumn":37,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Generators/WeekAheadContent\n * @description Pure functions for parsing MCP data and building week-ahead article HTML.\n * No side effects — all functions accept data and return strings.\n */\n\nimport { escapeHTML } from '../utils/file-utils.js';\nimport {\n  getLocalizedString,\n  WEEK_AHEAD_STRINGS,\n  WEEK_AHEAD_STAKEHOLDER_STRINGS,\n} from '../constants/languages.js';\nimport type {\n  ParliamentEvent,\n  CommitteeMeeting,\n  LegislativeDocument,\n  LegislativeProcedure,\n  ParliamentaryQuestion,\n  WeekAheadData,\n  DateRange,\n  MCPToolResult,\n  LegislativeVelocity,\n  StakeholderImpactSection,\n  StakeholderImpactRow,\n  PoliticalTemperature,\n  PoliticalTemperatureBand,\n  WeekAheadStakeholderStrings,\n} from '../types/index.js';\n\n/** Keyword constant for article tagging */\nconst KEYWORD_EUROPEAN_PARLIAMENT = 'European Parliament';\n\n/** Placeholder events used when MCP is unavailable or returns no sessions */\nexport const PLACEHOLDER_EVENTS: ParliamentEvent[] = [\n  {\n    date: '',\n    title: 'Plenary Session',\n    type: 'Plenary',\n    description: 'Full parliamentary session',\n  },\n  {\n    date: '',\n    title: 'ENVI Committee Meeting',\n    type: 'Committee',\n    description: 'Environment committee discussion',\n  },\n];\n\n/**\n * Generic parser for settled MCP results.\n * Extracts an array from the JSON payload at the given key and maps each element.\n *\n * @param settled - Promise.allSettled result\n * @param key - JSON key containing the array of items\n * @param mapper - Function to map raw items to typed objects\n * @returns Array of typed objects, or empty array on failure\n */\nfunction parseSettledMCPResult<T>(\n  settled: PromiseSettledResult<MCPToolResult>,\n  key: string,\n  mapper: (raw: Record<string, unknown>) => T\n): T[] {\n  if (settled.status !== 'fulfilled') return [];\n  try {\n    const data = JSON.parse(settled.value.content?.[0]?.text ?? '{}') as Record<string, unknown>;\n    if (!Object.hasOwn(data, key)) return [];\n    // eslint-disable-next-line security/detect-object-injection\n    const items: unknown = data[key];\n    if (!Array.isArray(items)) return [];\n    return items.map(mapper);\n  } catch {\n    return [];\n  }\n}\n\n/**\n * Parse plenary sessions from a settled MCP result\n *\n * @param settled - Promise.allSettled result\n * @param fallbackDate - Fallback date when session has none\n * @returns Array of parliament events\n */\nexport function parsePlenarySessions(\n  settled: PromiseSettledResult<MCPToolResult>,\n  fallbackDate: string\n): ParliamentEvent[] {\n  return parseSettledMCPResult(settled, 'sessions', (s) => ({\n    date: (s.date as string | undefined) ?? fallbackDate,\n    title: (s.title as string | undefined) ?? 'Parliamentary Session',\n    type: (s.type as string | undefined) ?? 'Session',\n    description: (s.description as string | undefined) ?? '',\n  }));\n}\n\n/**\n * Parse EP events (hearings, conferences, seminars) from a settled MCP result\n *\n * @param settled - Promise.allSettled result\n * @param fallbackDate - Fallback date when event has none\n * @returns Array of parliament events\n */\nexport function parseEPEvents(\n  settled: PromiseSettledResult<MCPToolResult>,\n  fallbackDate: string\n): ParliamentEvent[] {\n  return parseSettledMCPResult(settled, 'events', (e) => ({\n    date: (e.date as string | undefined) ?? fallbackDate,\n    title: (e.title as string | undefined) ?? 'EP Event',\n    type: (e.type as string | undefined) ?? 'Event',\n    description: (e.description as string | undefined) ?? '',\n  }));\n}\n\n/**\n * Parse committee meetings from a settled MCP result\n *\n * @param settled - Promise.allSettled result\n * @param fallbackDate - Fallback date when meeting has no date\n * @returns Array of committee meetings\n */\nexport function parseCommitteeMeetings(\n  settled: PromiseSettledResult<MCPToolResult>,\n  fallbackDate?: string\n): CommitteeMeeting[] {\n  return parseSettledMCPResult(settled, 'committees', (c) => ({\n    id: c.id as string | undefined,\n    committee: (c.committee as string | undefined) ?? 'Unknown',\n    committeeName: c.committeeName as string | undefined,\n    date: (c.date as string | undefined) ?? fallbackDate ?? '',\n    time: c.time as string | undefined,\n    location: c.location as string | undefined,\n    agenda: (\n      c.agenda as\n        | Array<{\n            item?: number | undefined;\n            title?: string | undefined;\n            type?: string | undefined;\n          }>\n        | undefined\n    )?.map((a) => ({\n      item: a.item,\n      title: a.title ?? '',\n      type: a.type,\n    })),\n  }));\n}\n\n/**\n * Parse legislative documents from a settled MCP result\n *\n * @param settled - Promise.allSettled result\n * @returns Array of legislative documents\n */\nexport function parseLegislativeDocuments(\n  settled: PromiseSettledResult<MCPToolResult>\n): LegislativeDocument[] {\n  return parseSettledMCPResult(settled, 'documents', (d) => ({\n    id: d.id as string | undefined,\n    type: d.type as string | undefined,\n    title: (d.title as string | undefined) ?? 'Untitled Document',\n    date: d.date as string | undefined,\n    status: d.status as string | undefined,\n    committee: d.committee as string | undefined,\n    rapporteur: d.rapporteur as string | undefined,\n  }));\n}\n\n/**\n * Parse legislative pipeline from a settled MCP result\n *\n * @param settled - Promise.allSettled result\n * @returns Array of legislative procedures\n */\nexport function parseLegislativePipeline(\n  settled: PromiseSettledResult<MCPToolResult>\n): LegislativeProcedure[] {\n  return parseSettledMCPResult(settled, 'procedures', (p) => ({\n    id: p.id as string | undefined,\n    title: (p.title as string | undefined) ?? 'Unnamed Procedure',\n    stage: p.stage as string | undefined,\n    committee: p.committee as string | undefined,\n    status: p.status as string | undefined,\n    bottleneck: p.bottleneck as boolean | undefined,\n  }));\n}\n\n/**\n * Parse parliamentary questions from a settled MCP result\n *\n * @param settled - Promise.allSettled result\n * @returns Array of parliamentary questions\n */\nexport function parseParliamentaryQuestions(\n  settled: PromiseSettledResult<MCPToolResult>\n): ParliamentaryQuestion[] {\n  return parseSettledMCPResult(settled, 'questions', (q) => ({\n    id: q.id as string | undefined,\n    type: q.type as string | undefined,\n    author: q.author as string | undefined,\n    subject: (q.subject as string | undefined) ?? 'No subject',\n    date: q.date as string | undefined,\n    status: q.status as string | undefined,\n  }));\n}\n\n// ─── Render helpers ──────────────────────────────────────────────────────────\n\n/**\n * Render a single plenary event as HTML\n *\n * @param event - Parliament event data\n * @returns HTML string\n */\nfunction renderPlenaryEvent(event: ParliamentEvent): string {\n  return `\n              <div class=\"event-item\">\n                <div class=\"event-date\">${escapeHTML(event.date)}</div>\n                <div class=\"event-details\">\n                  <h3>${escapeHTML(event.title)}</h3>\n                  <p class=\"event-type\">${escapeHTML(event.type)}</p>\n                  ${event.description ? `<p>${escapeHTML(event.description)}</p>` : ''}\n                </div>\n              </div>`;\n}\n\n/**\n * Render a single committee meeting as HTML\n *\n * @param meeting - Committee meeting data\n * @returns HTML string\n */\nfunction renderCommitteeMeeting(meeting: CommitteeMeeting): string {\n  const agendaHtml =\n    meeting.agenda && meeting.agenda.length > 0\n      ? `<ul class=\"agenda-list\">${meeting.agenda.map((item) => `<li>${escapeHTML(item.title)}${item.type ? ` <span class=\"agenda-type\">(${escapeHTML(item.type)})</span>` : ''}</li>`).join('')}</ul>`\n      : '';\n  return `\n              <div class=\"committee-item\">\n                <div class=\"committee-date\">${escapeHTML(meeting.date)}${meeting.time ? ` ${escapeHTML(meeting.time)}` : ''}</div>\n                <div class=\"committee-details\">\n                  <h3>${escapeHTML(meeting.committeeName ?? meeting.committee)}</h3>\n                  ${meeting.location ? `<p class=\"committee-location\">${escapeHTML(meeting.location)}</p>` : ''}\n                  ${agendaHtml}\n                </div>\n              </div>`;\n}\n\n/**\n * Render a single legislative document as HTML\n *\n * @param doc - Legislative document\n * @returns HTML string\n */\nfunction renderLegislativeDocument(doc: LegislativeDocument): string {\n  return `\n              <li class=\"document-item\">\n                <span class=\"document-title\">${escapeHTML(doc.title)}</span>\n                ${doc.type ? ` <span class=\"document-type\">(${escapeHTML(doc.type)})</span>` : ''}\n                ${doc.committee ? ` — <span class=\"document-committee\">${escapeHTML(doc.committee)}</span>` : ''}\n                ${doc.status ? ` <span class=\"document-status\">[${escapeHTML(doc.status)}]</span>` : ''}\n              </li>`;\n}\n\n/**\n * Render a single pipeline procedure as HTML\n *\n * @param proc - Legislative procedure\n * @returns HTML string\n */\nfunction renderPipelineProcedure(proc: LegislativeProcedure): string {\n  return `\n              <li class=\"pipeline-item${proc.bottleneck ? ' bottleneck' : ''}\">\n                <span class=\"procedure-title\">${escapeHTML(proc.title)}</span>\n                ${proc.stage ? ` <span class=\"procedure-stage\">${escapeHTML(proc.stage)}</span>` : ''}\n                ${proc.committee ? ` — <span class=\"procedure-committee\">${escapeHTML(proc.committee)}</span>` : ''}\n                ${proc.bottleneck ? ' <span class=\"bottleneck-indicator\">⚠ Bottleneck</span>' : ''}\n              </li>`;\n}\n\n/**\n * Render a single parliamentary question as HTML\n *\n * @param q - Parliamentary question\n * @returns HTML string\n */\nfunction renderQuestion(q: ParliamentaryQuestion): string {\n  return `\n              <li class=\"qa-item\">\n                <span class=\"qa-subject\">${escapeHTML(q.subject)}</span>\n                ${q.type ? ` <span class=\"qa-type\">(${escapeHTML(q.type)})</span>` : ''}\n                ${q.author ? ` — <span class=\"qa-author\">${escapeHTML(q.author)}</span>` : ''}\n              </li>`;\n}\n\n// ─── Content builders ────────────────────────────────────────────────────────\n\n/**\n * Clamp a number to the 0–100 range.\n *\n * @param n - Number to clamp\n * @returns Clamped value between 0 and 100\n */\nfunction clamp0to100(n: number): number {\n  return Math.max(0, Math.min(100, n));\n}\n\n/**\n * Map from band key to CSS class suffix used in `temp-*` class names.\n */\nconst BAND_CSS_CLASS: Record<PoliticalTemperatureBand, string> = {\n  low: 'temp-low',\n  moderate: 'temp-moderate',\n  high: 'temp-high',\n  veryHigh: 'temp-very-high',\n};\n\n/**\n * Derive a language-agnostic temperature band from a 0–100 score.\n *\n * This is the single source of truth for score → band mapping.\n * `computeWeekPoliticalTemperature()` uses this to derive the\n * `temperature.band` value that the renderer later consumes, avoiding\n * duplicated threshold logic.\n *\n * @param score - Clamped score (0–100)\n * @returns Band key\n */\nfunction temperatureBand(score: number): PoliticalTemperatureBand {\n  if (score >= 75) return 'veryHigh';\n  if (score >= 50) return 'high';\n  if (score >= 25) return 'moderate';\n  return 'low';\n}\n\n/**\n * Resolve the localized temperature descriptor for a given band.\n *\n * @param band - Language-agnostic band key\n * @param strings - Localized stakeholder strings\n * @returns Localized descriptor (e.g. \"Modéré\", \"高い\")\n */\nfunction localizedTempLabel(\n  band: PoliticalTemperatureBand,\n  strings: WeekAheadStakeholderStrings\n): string {\n  const map: Record<PoliticalTemperatureBand, string> = {\n    low: strings.tempLow,\n    moderate: strings.tempModerate,\n    high: strings.tempHigh,\n    veryHigh: strings.tempVeryHigh,\n  };\n  return map[band];\n}\n\n/**\n * Compute a composite political temperature score (0–100) indicating how\n * contentious or urgent the upcoming parliamentary week is likely to be.\n *\n * The score is derived from the volume and diversity of scheduled events\n * and questions — a pure scoring function with no side effects.\n *\n * @param events - Plenary / parliamentary events for the week\n * @param questions - Parliamentary questions tabled for the week\n * @returns Political temperature score and band\n */\nexport function computeWeekPoliticalTemperature(\n  events: readonly ParliamentEvent[],\n  questions: readonly ParliamentaryQuestion[]\n): PoliticalTemperature {\n  // Base: volume-driven heuristic — more events & questions ⇒ higher temperature\n  const eventContribution = Math.min(events.length * 10, 50);\n  const questionContribution = Math.min(questions.length * 5, 30);\n\n  // Diversity bonus: distinct event types signal a broader agenda\n  const uniqueTypes = new Set(events.map((e) => e.type));\n  const diversityBonus = Math.min(uniqueTypes.size * 5, 20);\n\n  const raw = eventContribution + questionContribution + diversityBonus;\n  const score = clamp0to100(Math.round(raw));\n  return { score, band: temperatureBand(score) };\n}\n\n/**\n * Determine impact level based on a count and a threshold for \"high\".\n *\n * @param count - Item count\n * @param highThreshold - Minimum count for high impact\n * @returns Impact level\n */\nfunction impactFromCount(count: number, highThreshold: number): 'high' | 'medium' | 'low' {\n  if (count >= highThreshold) return 'high';\n  return count > 0 ? 'medium' : 'low';\n}\n\n/**\n * Build a stakeholder impact matrix from scheduled events and legislative\n * documents, assessing which groups are most affected by the agenda.\n *\n * Returns an empty section when no events or documents are available\n * (graceful fallback). This function only constructs raw data; HTML\n * escaping (for example via `escapeHTML()`) must be applied at render\n * time by the caller (e.g. in `renderStakeholderSection()`).\n *\n * @param events - Parliament events for the upcoming week\n * @param docs - Legislative documents expected in the period\n * @param lang - Language code for localized labels (defaults to 'en')\n * @returns Stakeholder impact section with rows\n */\nexport function buildStakeholderImpactMatrix(\n  events: readonly ParliamentEvent[],\n  docs: readonly LegislativeDocument[],\n  lang: string = 'en'\n): StakeholderImpactSection {\n  if (events.length === 0 && docs.length === 0) {\n    return { rows: [] };\n  }\n\n  const strings = getLocalizedString(WEEK_AHEAD_STAKEHOLDER_STRINGS, lang);\n  const rows: StakeholderImpactRow[] = [];\n  const eventCount = events.length;\n  const docCount = docs.length;\n  const totalCount = eventCount + docCount;\n\n  if (eventCount > 0) {\n    rows.push({\n      stakeholder: strings.stakeholderPoliticalGroups,\n      impact: impactFromCount(eventCount, 3),\n      reason: strings.reasonEventsScheduled.replace('{count}', String(eventCount)),\n    });\n  }\n\n  if (docCount > 0) {\n    rows.push({\n      stakeholder: strings.stakeholderCivilSociety,\n      impact: impactFromCount(docCount, 3),\n      reason: strings.reasonDocumentsUnderReview.replace('{count}', String(docCount)),\n    });\n  }\n\n  rows.push({\n    stakeholder: strings.stakeholderIndustry,\n    impact: impactFromCount(totalCount, 5),\n    reason: strings.reasonIndustryRegulatoryAgenda,\n  });\n\n  rows.push({\n    stakeholder: strings.stakeholderEuCitizens,\n    impact: impactFromCount(eventCount, 3),\n    reason: strings.reasonCitizensDecisionsShapePolicy,\n  });\n\n  if (docCount > 0) {\n    rows.push({\n      stakeholder: strings.stakeholderNationalGovernments,\n      impact: impactFromCount(docCount, 3),\n      reason: strings.reasonDocumentsRequireTransposition.replace('{count}', String(docCount)),\n    });\n  }\n\n  rows.push({\n    stakeholder: strings.stakeholderEuInstitutions,\n    impact: impactFromCount(totalCount, 4),\n    reason: strings.reasonInstitutionsCoordination,\n  });\n\n  return { rows };\n}\n\n/**\n * Render the stakeholder impact section as HTML.\n *\n * @param section - Stakeholder impact data\n * @param temperature - Political temperature score\n * @param lang - Language code for localized headings\n * @returns HTML string, or empty string when section is empty\n */\nfunction renderStakeholderSection(\n  section: StakeholderImpactSection,\n  temperature: PoliticalTemperature,\n  lang: string\n): string {\n  if (section.rows.length === 0) return '';\n\n  const strings = getLocalizedString(WEEK_AHEAD_STAKEHOLDER_STRINGS, lang);\n\n  const tempClass = BAND_CSS_CLASS[temperature.band];\n  const tempDescriptor = localizedTempLabel(temperature.band, strings);\n\n  const tableRows = section.rows\n    .map(\n      (row) =>\n        `<tr>` +\n        `<td>${escapeHTML(row.stakeholder)}</td>` +\n        `<td class=\"impact-${escapeHTML(row.impact)}\">${escapeHTML(row.impact)}</td>` +\n        `<td>${escapeHTML(row.reason)}</td>` +\n        `</tr>`\n    )\n    .join('');\n\n  return `\n          <section class=\"stakeholder-impact\" lang=\"${escapeHTML(lang)}\">\n            <h2>${escapeHTML(strings.heading)}</h2>\n            <div class=\"political-temperature ${tempClass}\">\n              <span class=\"temp-label\">${escapeHTML(strings.temperatureLabel)}:</span>\n              <span class=\"temp-score\">${temperature.score}/100</span>\n              <span class=\"temp-descriptor\">(${escapeHTML(tempDescriptor)})</span>\n            </div>\n            <table class=\"stakeholder-matrix\">\n              <thead>\n                <tr>\n                  <th scope=\"col\">${escapeHTML(strings.stakeholderHeader)}</th>\n                  <th scope=\"col\">${escapeHTML(strings.impactHeader)}</th>\n                  <th scope=\"col\">${escapeHTML(strings.reasonHeader)}</th>\n                </tr>\n              </thead>\n              <tbody>\n                ${tableRows}\n              </tbody>\n            </table>\n          </section>`;\n}\n\n/**\n * Build the supplementary lede sentence about committee and pipeline counts.\n *\n * @param committeeCount - Number of committee meetings\n * @param pipelineCount - Number of pipeline procedures\n * @returns Sentence fragment or empty string\n */\nfunction buildLedeDetail(committeeCount: number, pipelineCount: number): string {\n  if (committeeCount === 0 && pipelineCount === 0) return '';\n  const committeePart =\n    committeeCount > 0\n      ? `${committeeCount} committee meeting${committeeCount !== 1 ? 's are' : ' is'} scheduled`\n      : '';\n  const pipelinePart =\n    pipelineCount > 0\n      ? `${pipelineCount} legislative procedure${pipelineCount !== 1 ? 's are' : ' is'} advancing through the pipeline`\n      : '';\n  const conjunction = committeePart && pipelinePart ? ' and ' : '';\n  return ` Notably, ${committeePart}${conjunction}${pipelinePart}.`;\n}\n\n/**\n * Build article content HTML from week-ahead data\n *\n * @param weekData - Aggregated week-ahead data\n * @param dateRange - Date range for the article\n * @param lang - Language code for localized content strings (default: 'en')\n * @returns HTML content string\n */\nexport function buildWeekAheadContent(\n  weekData: WeekAheadData,\n  dateRange: DateRange,\n  lang = 'en'\n): string {\n  const strings = getLocalizedString(WEEK_AHEAD_STRINGS, lang);\n  const plenaryHtml =\n    weekData.events.length > 0\n      ? weekData.events.map(renderPlenaryEvent).join('')\n      : `<p>${escapeHTML(strings.noPlenary)}</p>`;\n\n  const committeeSection =\n    weekData.committees.length > 0\n      ? `<section class=\"committee-calendar\">\n            <h2>${escapeHTML(strings.committeeMeetings)}</h2>\n            ${weekData.committees.map(renderCommitteeMeeting).join('')}\n          </section>`\n      : '';\n\n  const documentsSection =\n    weekData.documents.length > 0\n      ? `<section class=\"legislative-documents\">\n            <h2>${escapeHTML(strings.legislativeDocuments)}</h2>\n            <ul class=\"document-list\">${weekData.documents.map(renderLegislativeDocument).join('')}</ul>\n          </section>`\n      : '';\n\n  const pipelineSection =\n    weekData.pipeline.length > 0\n      ? `<section class=\"legislative-pipeline\">\n            <h2>${escapeHTML(strings.legislativePipeline)}</h2>\n            <ul class=\"pipeline-list\">${weekData.pipeline.map(renderPipelineProcedure).join('')}</ul>\n          </section>`\n      : '';\n\n  const qaSection =\n    weekData.questions.length > 0\n      ? `<section class=\"qa-schedule\">\n            <h2>${escapeHTML(strings.parliamentaryQuestions)}</h2>\n            <ul class=\"qa-list\">${weekData.questions.map(renderQuestion).join('')}</ul>\n          </section>`\n      : '';\n\n  const ledeDetail = buildLedeDetail(weekData.committees.length, weekData.pipeline.length);\n\n  // Stakeholder impact analysis\n  const stakeholderData = buildStakeholderImpactMatrix(weekData.events, weekData.documents, lang);\n  const temperature = computeWeekPoliticalTemperature(weekData.events, weekData.questions);\n  const stakeholderSection = renderStakeholderSection(stakeholderData, temperature, lang);\n\n  return `\n        <div class=\"article-content\">\n          <section class=\"lede\">\n            <p>${escapeHTML(strings.lede)} from ${escapeHTML(dateRange.start)} to ${escapeHTML(dateRange.end)}.${escapeHTML(ledeDetail)}</p>\n          </section>\n          <section class=\"plenary-schedule\">\n            <h2>${escapeHTML(strings.plenarySessions)}</h2>\n            ${plenaryHtml}\n          </section>\n          ${committeeSection}\n          ${documentsSection}\n          ${pipelineSection}\n          ${qaSection}\n          ${stakeholderSection}\n          <!-- /article-content -->\n        </div>\n      `;\n}\n\n/**\n * Build article keywords from week-ahead data\n *\n * @param weekData - Aggregated week-ahead data\n * @returns Array of keyword strings\n */\nexport function buildKeywords(weekData: WeekAheadData): string[] {\n  const keywords = [KEYWORD_EUROPEAN_PARLIAMENT, 'week ahead', 'plenary', 'committees'];\n  for (const c of weekData.committees) {\n    if (c.committee && !keywords.includes(c.committee)) {\n      keywords.push(c.committee);\n    }\n  }\n  if (weekData.pipeline.length > 0) keywords.push('legislative pipeline');\n  if (weekData.questions.length > 0) keywords.push('parliamentary questions');\n  return keywords;\n}\n\n// ─── What-to-Watch section ─────────────────────────────────────────────────\n\n/** CSS class for bottleneck-risk watch items */\nconst CSS_WATCH_HIGH = 'watch-high';\n\n/** CSS class for bottlenecked procedure watch items */\nconst CSS_WATCH_PROCEDURE = 'watch-procedure';\n\n/** CSS class for standard velocity watch items */\nconst CSS_WATCH_ITEM = 'watch-item';\n\n/** Maximum number of non-bottleneck velocity items to include */\nconst WATCH_MAX_NORMAL_ITEMS = 3;\n\n/**\n * Build list items for high-risk velocities\n *\n * @param velocities - All legislative velocities\n * @returns HTML list items for high bottleneck risk items\n */\nfunction buildHighRiskItems(velocities: LegislativeVelocity[]): string {\n  return velocities\n    .filter((v) => v.bottleneckRisk === 'high')\n    .map(\n      (v) =>\n        `<li class=\"${CSS_WATCH_HIGH}\">${escapeHTML(v.title)} — ` +\n        `Stage: ${escapeHTML(v.stage)} (bottleneck risk detected)</li>`\n    )\n    .join('');\n}\n\n/**\n * Build list items for procedures flagged as bottlenecks\n *\n * @param procedures - All legislative procedures\n * @returns HTML list items for bottlenecked procedures\n */\nfunction buildBottleneckProcedureItems(procedures: LegislativeProcedure[]): string {\n  return procedures\n    .filter((p) => p.bottleneck === true)\n    .map(\n      (p) =>\n        `<li class=\"${CSS_WATCH_PROCEDURE}\">${escapeHTML(p.title)} — ` +\n        `${escapeHTML(p.stage ?? 'in progress')} stage</li>`\n    )\n    .join('');\n}\n\n/**\n * Build list items for normal-risk velocities\n *\n * @param velocities - All legislative velocities\n * @returns HTML list items for the top normal-risk velocity items\n */\nfunction buildNormalVelocityItems(velocities: LegislativeVelocity[]): string {\n  return velocities\n    .filter((v) => v.bottleneckRisk !== 'high')\n    .slice(0, WATCH_MAX_NORMAL_ITEMS)\n    .map(\n      (v) =>\n        `<li class=\"${CSS_WATCH_ITEM}\">${escapeHTML(v.title)} — ` +\n        `predicted completion: ${escapeHTML(v.predictedCompletion)}</li>`\n    )\n    .join('');\n}\n\n/**\n * Build predictive \"What to Watch\" analysis section showing legislative\n * procedures and velocity data ordered by bottleneck risk.\n * Returns an empty string when both input arrays are empty or yield no items.\n *\n * @param procedures - Legislative procedures to analyse\n * @param velocities - Legislative velocity data (for example from a separate velocity analysis feed)\n * @param language - BCP 47 language code used as the section lang attribute\n * @returns HTML string for the \"What to Watch\" section\n */\nexport function buildWhatToWatchSection(\n  procedures: LegislativeProcedure[],\n  velocities: LegislativeVelocity[],\n  language: string\n): string {\n  if (procedures.length === 0 && velocities.length === 0) return '';\n  const allItems =\n    buildHighRiskItems(velocities) +\n    buildBottleneckProcedureItems(procedures) +\n    buildNormalVelocityItems(velocities);\n  if (!allItems) return '';\n  const strings = getLocalizedString(WEEK_AHEAD_STRINGS, language);\n  return `\n        <section class=\"what-to-watch\" lang=\"${escapeHTML(language)}\">\n          <h2>${escapeHTML(strings.whatToWatch)}</h2>\n          <ul>\n            ${allItems}\n          </ul>\n        </section>`;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/mcp/ep-mcp-client.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/mcp/imf-mcp-client.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":219,"column":21,"endLine":219,"endColumn":33}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module MCP/IMFMCPClient\n * @description Native TypeScript IMF Data client — calls the IMF SDMX 3.0\n * REST API at {@link https://dataservices.imf.org/REST/SDMX_3.0/} directly\n * via `fetch()`, with no external MCP server process.\n *\n * Historical note: the first Wave-1 iteration delegated to the Python\n * `c-cf/imf-data-mcp` MCP server. That dependency blocked Wave 0 rollout\n * because the upstream project is a Python git-URL package (not npm) and\n * could not be pinned to an integrity hash per the ISMS Secure Development\n * Policy §7. This module replaces the Python transport with a direct,\n * typed HTTP client — the public API is preserved so callers\n * (`src/utils/imf-data.ts`, validator fingerprints, workflow probes) are\n * untouched.\n *\n * ## Public API (unchanged from the MCP-backed iteration)\n *\n * - {@link IMFMCPClient} — class with semantic wrappers for five \"tools\".\n * - {@link IMF_MCP_TOOLS} — stable virtual tool-name list used by the\n *   content-validator fingerprint and the workflow probe.\n * - {@link getIMFMCPClient} / {@link closeIMFMCPClient} — singleton lifecycle.\n *\n * The return envelope of every method is {@link MCPToolResult}\n * (`{ content: [{ type: \"text\", text: \"<json>\" }] }`) so downstream code\n * that already calls `parseSDMXJSON(response.content[0]?.text)` continues\n * to work unmodified.\n *\n * ## Transport\n *\n * - Uses the native Node 25 `fetch()` — no extra runtime dependency.\n * - Every call has an independent `AbortController` with a configurable\n *   timeout (`IMF_API_TIMEOUT_MS`, default 30 s).\n * - Errors (HTTP 4xx/5xx, network faults, JSON parse failures, abort) are\n *   caught and converted to the {@link IMF_FALLBACK} envelope. Callers\n *   upstream can therefore treat \"no IMF\" as \"empty data\" without\n *   defensive try/catch, matching the `WorldBankMCPClient` pattern.\n *\n * Environment variables:\n * - `IMF_API_BASE_URL` — override base URL (default\n *   `https://dataservices.imf.org/REST/SDMX_3.0`).\n * - `IMF_API_TIMEOUT_MS` — per-request timeout (default `30000`).\n *\n * Legacy env vars (`IMF_MCP_GATEWAY_URL`, `IMF_MCP_GATEWAY_API_KEY`,\n * `IMF_MCP_SERVER_PATH`) are no longer consulted — no gateway is needed\n * because the IMF SDMX 3.0 API is an unauthenticated public endpoint.\n */\n\nimport type { MCPToolResult, MCPClientOptions } from '../types/index.js';\n\n// ─── Defaults ────────────────────────────────────────────────────────────────\n\n/** Default base URL for the IMF SDMX 3.0 REST API. */\nconst DEFAULT_IMF_API_BASE_URL = 'https://dataservices.imf.org/REST/SDMX_3.0';\n\n/** Default per-request timeout (milliseconds). */\nconst DEFAULT_IMF_API_TIMEOUT_MS = 30_000;\n\n/** Fallback payload shape when an IMF call fails or the server is offline. */\nconst IMF_FALLBACK: MCPToolResult = {\n  content: [{ type: 'text', text: '' }],\n};\n\n/**\n * Canonical list of \"virtual tools\" exposed by this client. The client no\n * longer talks to an MCP server, but the tool-name list is preserved so\n * it continues to serve as:\n *\n * 1. The content-validator fingerprint source (`IMF_STRONG_FINGERPRINTS`).\n * 2. The workflow probe's heartbeat identifiers.\n * 3. A drift guard against method additions: if a new helper method lands\n *    here, `test/integration/mcp/imf-mcp.test.js` fails unless the list\n *    and the test are updated in lock-step.\n *\n * Kept in sync with `analysis/methodologies/imf-indicator-mapping.md`.\n */\nexport const IMF_MCP_TOOLS: readonly string[] = [\n  'imf-list-databases',\n  'imf-search-databases',\n  'imf-get-parameter-defs',\n  'imf-get-parameter-codes',\n  'imf-fetch-data',\n];\n\n// ─── Client options ──────────────────────────────────────────────────────────\n\n/**\n * Options accepted by {@link IMFMCPClient}. Shape intentionally matches\n * {@link MCPClientOptions} for historical compatibility — fields unused by\n * the native HTTP transport (`serverPath`, `gatewayUrl`, `gatewayApiKey`,\n * `maxConnectionAttempts`, `connectionRetryDelay`) are accepted and\n * silently ignored so existing call-sites do not break.\n */\nexport interface IMFClientOptions extends MCPClientOptions {\n  /** Override the IMF REST base URL (default: {@link DEFAULT_IMF_API_BASE_URL}). */\n  apiBaseUrl?: string;\n  /** Per-request timeout in milliseconds (default: {@link DEFAULT_IMF_API_TIMEOUT_MS}). */\n  timeoutMs?: number;\n  /** Optional `fetch` implementation injection for testing. */\n  fetchImpl?: typeof fetch;\n}\n\n// ─── SDMX 3.0 response narrow types (only the fields we consume) ─────────────\n\ninterface SDMXCategoryReference {\n  id?: string;\n  name?: string | Record<string, string>;\n  description?: string | Record<string, string>;\n}\n\ninterface SDMXDataflowListResponse {\n  data?: {\n    dataflows?: SDMXCategoryReference[];\n  };\n}\n\ninterface SDMXDimensionValue {\n  id: string;\n  name?: string | Record<string, string>;\n}\n\ninterface SDMXDimension {\n  id: string;\n  name?: string | Record<string, string>;\n  localRepresentation?: {\n    enumeration?: string;\n  };\n  values?: SDMXDimensionValue[];\n}\n\ninterface SDMXDataStructureResponse {\n  data?: {\n    dataStructures?: Array<{\n      id?: string;\n      dataStructureComponents?: {\n        dimensionList?: { dimensions?: SDMXDimension[] };\n      };\n    }>;\n    codelists?: Array<{\n      id?: string;\n      codes?: SDMXDimensionValue[];\n    }>;\n  };\n}\n\n// ─── Utilities ───────────────────────────────────────────────────────────────\n\n/**\n * Unwrap SDMX localised labels to a plain string.\n *\n * SDMX 3.0 sometimes returns `name`/`description` as a language-keyed\n * object (`{ en: \"World Economic Outlook\" }`); older payloads return a\n * raw string. Prefer English, fall back to the first available value.\n *\n * @param raw - Raw label (string, locale object, or undefined).\n * @returns Plain string (empty when no label is available).\n * @internal\n */\nfunction unwrapLocalisedLabel(raw: string | Record<string, string> | undefined): string {\n  if (!raw) return '';\n  if (typeof raw === 'string') return raw;\n  if (typeof raw['en'] === 'string') return raw['en'];\n  for (const v of Object.values(raw)) {\n    if (typeof v === 'string') return v;\n  }\n  return '';\n}\n\n/**\n * Wrap a JSON-serialisable value in the canonical MCP tool-result shape\n * so consumers that already expect `response.content[0]?.text` to hold a\n * JSON blob keep working without change.\n *\n * @param payload - Serialisable payload (object, array, or already-stringified JSON).\n * @returns MCP tool-result envelope with a single text content item.\n * @internal\n */\nfunction wrapAsMCPResult(payload: unknown): MCPToolResult {\n  const text = typeof payload === 'string' ? payload : JSON.stringify(payload ?? null);\n  return { content: [{ type: 'text', text }] };\n}\n\n/**\n * Simple value-encoder for SDMX URL dimension components. SDMX uses `+`\n * to join alternative codes inside a single dimension slot and `.` as\n * the dimension separator, so the value must be URI-encoded first to\n * avoid collisions with user-supplied codes that happen to contain\n * those characters.\n *\n * @param codes - Ordered code values for a single dimension (may be empty = wildcard).\n * @returns URL-safe dimension component (`\"\"` for wildcard, `\"A+B\"` for union).\n * @internal\n */\nfunction encodeSDMXDimension(codes: readonly string[]): string {\n  return codes.map((c) => encodeURIComponent(c)).join('+');\n}\n\n/**\n * Build an SDMX key from a filters map + declared dimension order.\n *\n * If a declared dimension is absent from `filters`, the slot is left as\n * the wildcard (empty string). Extra filter keys not present in the\n * declared order are ignored — the caller is expected to have discovered\n * the correct dimension names via {@link IMFMCPClient.getParameterDefs}.\n *\n * @param dimensions - Declared dimension order (e.g. `[\"country\",\"indicator\",\"frequency\"]`).\n * @param filters - Map of dimension → selected codes.\n * @returns SDMX key (e.g. `\"DEU.NGDP_RPCH.A\"`).\n * @internal\n */\nfunction buildSDMXKey(\n  dimensions: readonly string[],\n  filters: Readonly<Record<string, readonly string[]>>\n): string {\n  return dimensions\n    .map((dim) => {\n      const codes = filters[dim];\n      return Array.isArray(codes) ? encodeSDMXDimension(codes) : '';\n    })\n    .join('.');\n}\n\n/**\n * Infer the dimension order for a given dataflow when\n * {@link IMFMCPClient.getParameterDefs} has not been called yet. Used as a\n * fallback because the WEO datastructure in particular is so widely used\n * that encoding a well-known default eliminates one round-trip per fetch.\n *\n * Order mirrors the conventional `{country}.{indicator}.{frequency}`\n * layout documented on the IMF Data Services pages.\n *\n * @param databaseId - Dataflow identifier (case-insensitive).\n * @returns Default dimension order used when the caller omits it.\n * @internal\n */\nfunction defaultDimensionOrder(databaseId: string): readonly string[] {\n  switch (databaseId.toUpperCase()) {\n    case 'WEO':\n    case 'FM':\n      return ['country', 'indicator', 'frequency'];\n    case 'IFS':\n    case 'CPI':\n      return ['frequency', 'country', 'indicator'];\n    case 'BOP_AGG':\n    case 'ER':\n    case 'PCPS':\n      return ['frequency', 'country', 'indicator'];\n    default:\n      return ['country', 'indicator', 'frequency'];\n  }\n}\n\n// ─── Client ──────────────────────────────────────────────────────────────────\n\n/**\n * Native TypeScript client for the IMF SDMX 3.0 REST API.\n *\n * Despite the historical class name, no MCP server process is involved —\n * the class keeps the name `IMFMCPClient` purely to avoid breaking the\n * existing import surface (`src/index.ts`, test suites, documentation).\n * New code is welcome to import it as `IMFClient` (alias below).\n */\nexport class IMFMCPClient {\n  private readonly _apiBaseUrl: string;\n  private readonly _timeoutMs: number;\n  private readonly _fetchImpl: typeof fetch;\n  private _connected = false;\n\n  constructor(options: IMFClientOptions = {}) {\n    const envBase = process.env['IMF_API_BASE_URL'];\n    const envTimeout = process.env['IMF_API_TIMEOUT_MS'];\n    const parsedEnvTimeout =\n      envTimeout !== undefined && envTimeout !== '' ? Number.parseInt(envTimeout, 10) : Number.NaN;\n    const base =\n      options.apiBaseUrl ?? (envBase && envBase !== '' ? envBase : DEFAULT_IMF_API_BASE_URL);\n    // Strip trailing slashes without a regex so the CodeQL polynomial-ReDoS\n    // detector has nothing to flag. Single linear pass from the right.\n    let end = base.length;\n    while (end > 0 && base.charCodeAt(end - 1) === 47 /* '/' */) {\n      end -= 1;\n    }\n    this._apiBaseUrl = end === base.length ? base : base.slice(0, end);\n    this._timeoutMs =\n      options.timeoutMs !== undefined && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0\n        ? options.timeoutMs\n        : Number.isFinite(parsedEnvTimeout) && parsedEnvTimeout > 0\n          ? parsedEnvTimeout\n          : DEFAULT_IMF_API_TIMEOUT_MS;\n    this._fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);\n  }\n\n  /**\n   * Base URL currently in use (read-only — set at construction time).\n   *\n   * @returns The fully-qualified IMF SDMX base URL (no trailing slash).\n   */\n  getApiBaseUrl(): string {\n    return this._apiBaseUrl;\n  }\n\n  /**\n   * Per-request timeout in milliseconds.\n   *\n   * @returns The timeout currently applied to every `fetch()` call.\n   */\n  getTimeoutMs(): number {\n    return this._timeoutMs;\n  }\n\n  /**\n   * Mark the client as ready. HTTP is stateless so there is no real\n   * connection, but callers historically invoke `connect()` before use —\n   * this is preserved for API compatibility and also exercises the\n   * base URL to catch misconfiguration early.\n   *\n   * @returns A resolved promise; never throws for valid URLs.\n   */\n  async connect(): Promise<void> {\n    try {\n      // Validate the base URL shape without making a network request so\n      // construction-time errors surface immediately.\n      new URL(this._apiBaseUrl);\n      this._connected = true;\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      throw new Error(`Invalid IMF_API_BASE_URL \"${this._apiBaseUrl}\": ${message}`, {\n        cause: error,\n      });\n    }\n  }\n\n  /**\n   * Whether {@link connect} has been called successfully.\n   *\n   * @returns `true` after a successful {@link connect}; reset by {@link disconnect}.\n   */\n  isConnected(): boolean {\n    return this._connected;\n  }\n\n  /** Reset the connected flag. No real socket to close. */\n  disconnect(): void {\n    this._connected = false;\n  }\n\n  /**\n   * List every IMF database (dataflow) exposed by the SDMX 3.0 API.\n   *\n   * Virtual tool: `imf-list-databases`.\n   *\n   * @returns MCP-shaped result whose `content[0].text` carries a JSON\n   *   array of `{ id, name, description }` entries. Empty on error.\n   */\n  async listDatabases(): Promise<MCPToolResult> {\n    try {\n      const json = await this._getJSON<SDMXDataflowListResponse>('/dataflow/IMF');\n      const flows = json?.data?.dataflows ?? [];\n      const rows = flows.map((f) => ({\n        id: f.id ?? '',\n        name: unwrapLocalisedLabel(f.name),\n        description: unwrapLocalisedLabel(f.description),\n      }));\n      return wrapAsMCPResult(rows);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      console.warn('imf-list-databases not available:', message);\n      return IMF_FALLBACK;\n    }\n  }\n\n  /**\n   * Search IMF databases by free-text keyword (case-insensitive\n   * substring match against id / name / description).\n   *\n   * Virtual tool: `imf-search-databases`. Runs client-side over the\n   * full dataflow list so a single SDMX round-trip serves every\n   * keyword query in a workflow run.\n   *\n   * @param keyword - Free-text keyword (e.g. `\"inflation\"`, `\"trade\"`).\n   * @returns Filtered list in MCP-shaped result; empty on error or when keyword is blank.\n   */\n  async searchDatabases(keyword: string): Promise<MCPToolResult> {\n    if (!keyword) {\n      console.warn('imf-search-databases called without a keyword');\n      return IMF_FALLBACK;\n    }\n    try {\n      const json = await this._getJSON<SDMXDataflowListResponse>('/dataflow/IMF');\n      const flows = json?.data?.dataflows ?? [];\n      const needle = keyword.toLowerCase();\n      const rows = flows\n        .map((f) => ({\n          id: f.id ?? '',\n          name: unwrapLocalisedLabel(f.name),\n          description: unwrapLocalisedLabel(f.description),\n        }))\n        .filter((r) => {\n          const hay = `${r.id} ${r.name} ${r.description}`.toLowerCase();\n          return hay.includes(needle);\n        });\n      return wrapAsMCPResult(rows);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      console.warn('imf-search-databases not available:', message);\n      return IMF_FALLBACK;\n    }\n  }\n\n  /**\n   * Fetch the dimension (parameter) definitions for a specific IMF\n   * dataflow. Essential before building an SDMX key for\n   * {@link fetchData} because each database has its own dimension set.\n   *\n   * Virtual tool: `imf-get-parameter-defs`.\n   *\n   * @param databaseId - IMF dataflow identifier (e.g. `\"WEO\"`, `\"IFS\"`).\n   * @returns MCP-shaped result whose `content[0].text` carries the\n   *   ordered list of dimensions (`[{ id, name }]`). Empty on error.\n   */\n  async getParameterDefs(databaseId: string): Promise<MCPToolResult> {\n    if (!databaseId) {\n      console.warn('imf-get-parameter-defs called without databaseId');\n      return IMF_FALLBACK;\n    }\n    try {\n      const json = await this._getJSON<SDMXDataStructureResponse>(\n        `/datastructure/${encodeURIComponent(databaseId)}`\n      );\n      const ds = json?.data?.dataStructures?.[0];\n      const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];\n      const rows = dims.map((d) => ({ id: d.id, name: unwrapLocalisedLabel(d.name) }));\n      return wrapAsMCPResult(rows);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      console.warn('imf-get-parameter-defs not available:', message);\n      return IMF_FALLBACK;\n    }\n  }\n\n  /**\n   * List valid codes for a single dimension of an IMF dataflow, with\n   * an optional free-text filter to narrow the result.\n   *\n   * Virtual tool: `imf-get-parameter-codes`. The underlying SDMX\n   * `/codelist/` endpoint is used, looked up from the datastructure so\n   * the caller does not need to know the codelist identifier ahead of\n   * time.\n   *\n   * @param databaseId - IMF dataflow identifier.\n   * @param parameter - Dimension name (e.g. `\"country\"`, `\"indicator\"`).\n   * @param search - Optional free-text search (case-insensitive substring).\n   * @returns MCP-shaped result with `[{ id, name }]` rows; empty on error.\n   */\n  async getParameterCodes(\n    databaseId: string,\n    parameter: string,\n    search?: string\n  ): Promise<MCPToolResult> {\n    if (!databaseId || !parameter) {\n      console.warn('imf-get-parameter-codes requires databaseId and parameter');\n      return IMF_FALLBACK;\n    }\n    try {\n      // 1. Discover the codelist id for the requested dimension.\n      const structure = await this._getJSON<SDMXDataStructureResponse>(\n        `/datastructure/${encodeURIComponent(databaseId)}?references=codelist`\n      );\n      const ds = structure?.data?.dataStructures?.[0];\n      const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];\n      const dim = dims.find((d) => d.id.toLowerCase() === parameter.toLowerCase());\n      if (!dim) {\n        return wrapAsMCPResult([]);\n      }\n      // The SDMX codelist reference URN looks like\n      //   \"urn:sdmx:org.sdmx.infomodel.codelist.Codelist=IMF:CL_AREA(1.0)\"\n      // We only need the codelist id — use string-split parsing\n      // (no regex) so the static-analysis \"unsafe regex\" detector has\n      // nothing to object to and the extraction stays obviously linear.\n      let codelistId: string | undefined = dim.localRepresentation?.enumeration;\n      if (codelistId) {\n        const afterEquals = codelistId.includes('=')\n          ? (codelistId.split('=')[1] ?? '')\n          : codelistId;\n        const beforeParen = afterEquals.split('(')[0] ?? '';\n        const parts = beforeParen.split(':');\n        codelistId = (parts[parts.length - 1] ?? beforeParen).trim() || codelistId;\n      }\n      // Some payloads inline the values directly; prefer those when present.\n      let codes: SDMXDimensionValue[] = dim.values ?? [];\n      if (codes.length === 0 && codelistId) {\n        const cl = structure?.data?.codelists?.find((c) => c.id === codelistId);\n        codes = cl?.codes ?? [];\n      }\n      const needle = (search ?? '').toLowerCase();\n      const rows = codes\n        .map((c) => ({ id: c.id, name: unwrapLocalisedLabel(c.name) }))\n        .filter((r) => {\n          if (!needle) return true;\n          return `${r.id} ${r.name}`.toLowerCase().includes(needle);\n        });\n      return wrapAsMCPResult(rows);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      console.warn('imf-get-parameter-codes not available:', message);\n      return IMF_FALLBACK;\n    }\n  }\n\n  /**\n   * Fetch a time-series slice from an IMF dataflow as SDMX-JSON.\n   *\n   * Virtual tool: `imf-fetch-data`. The response is already in SDMX-JSON\n   * format, so {@link parseSDMXJSON} (`src/utils/imf-data.ts`) can\n   * consume `response.content[0]?.text` directly without reshaping.\n   *\n   * @param options - Fetch parameters.\n   * @param options.databaseId - IMF dataflow ID (`\"WEO\"`, `\"IFS\"`, ...).\n   * @param options.startYear - Inclusive start year (e.g. `2015`).\n   * @param options.endYear - Inclusive end year (e.g. `2030` for WEO forecasts).\n   * @param options.filters - Map of dimension → selected codes.\n   * @param options.dimensionOrder - Optional override of the dimension order\n   *   used to build the SDMX key. Defaults to\n   *   {@link defaultDimensionOrder} for the database.\n   * @returns MCP-shaped result whose `content[0].text` carries the raw\n   *   SDMX-JSON response. Empty on error or invalid inputs.\n   */\n  async fetchData(options: {\n    databaseId: string;\n    startYear: number;\n    endYear: number;\n    filters: Readonly<Record<string, readonly string[]>>;\n    dimensionOrder?: readonly string[];\n  }): Promise<MCPToolResult> {\n    const { databaseId, startYear, endYear, filters, dimensionOrder } = options;\n    if (!databaseId || !filters || Object.keys(filters).length === 0) {\n      console.warn('imf-fetch-data requires databaseId and a non-empty filters map');\n      return IMF_FALLBACK;\n    }\n    if (!Number.isFinite(startYear) || !Number.isFinite(endYear) || endYear < startYear) {\n      console.warn(`imf-fetch-data invalid year range: ${startYear}-${endYear}`);\n      return IMF_FALLBACK;\n    }\n    try {\n      const dims = dimensionOrder ?? defaultDimensionOrder(databaseId);\n      const key = buildSDMXKey(dims, filters);\n      const qs = new URLSearchParams({\n        startPeriod: String(startYear),\n        endPeriod: String(endYear),\n        format: 'jsondata',\n      });\n      const url = `/data/${encodeURIComponent(databaseId)}/${key}?${qs.toString()}`;\n      const text = await this._getText(url);\n      return wrapAsMCPResult(text);\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      console.warn('imf-fetch-data not available:', message);\n      return IMF_FALLBACK;\n    }\n  }\n\n  // ─── private transport helpers ─────────────────────────────────────────────\n\n  /**\n   * Build a full URL and GET it as text, enforcing the client-wide timeout.\n   *\n   * @param path - Path (already URL-encoded) to append to the base URL.\n   * @returns Response body (`text/*` or `application/*`) as a string.\n   * @throws When the HTTP status is not 2xx, the request times out, or\n   *   the network layer raises.\n   * @internal\n   */\n  private async _getText(path: string): Promise<string> {\n    const url = `${this._apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;\n    const controller = new AbortController();\n    const timer = setTimeout(() => controller.abort(), this._timeoutMs);\n    try {\n      const response = await this._fetchImpl(url, {\n        method: 'GET',\n        headers: { Accept: 'application/json' },\n        signal: controller.signal,\n      });\n      if (!response.ok) {\n        throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);\n      }\n      return await response.text();\n    } finally {\n      clearTimeout(timer);\n    }\n  }\n\n  /**\n   * GET a URL and parse the response body as JSON.\n   *\n   * @template T - Narrow response type declared by the caller.\n   * @param path - Path to append to the base URL.\n   * @returns Parsed JSON value.\n   * @throws When the response is not JSON, not 2xx, or the request fails.\n   * @internal\n   */\n  private async _getJSON<T>(path: string): Promise<T> {\n    const raw = await this._getText(path);\n    try {\n      return JSON.parse(raw) as T;\n    } catch (error) {\n      const message = error instanceof Error ? error.message : String(error);\n      throw new Error(`Failed to parse IMF response as JSON: ${message}`, { cause: error });\n    }\n  }\n}\n\n/**\n * Forward-looking alias for {@link IMFMCPClient}. New code should prefer\n * `IMFClient`; the `IMFMCPClient` name is retained for backward\n * compatibility with the MCP-backed iteration shipped in Wave 1.\n */\nexport const IMFClient = IMFMCPClient;\n\n// ─── Singleton lifecycle ─────────────────────────────────────────────────────\n\n/** Singleton instance, created lazily by {@link getIMFMCPClient}. */\nlet imfClientInstance: IMFMCPClient | null = null;\n\n/**\n * Get or create the singleton IMF client, validating the base URL on\n * first use. Subsequent calls return the cached instance.\n *\n * @param options - Client options (override env vars and defaults).\n * @returns Connected singleton client.\n * @throws When the base URL is malformed (e.g. missing protocol).\n */\nexport async function getIMFMCPClient(options: IMFClientOptions = {}): Promise<IMFMCPClient> {\n  if (!imfClientInstance) {\n    const client = new IMFMCPClient(options);\n    try {\n      await client.connect();\n      imfClientInstance = client;\n    } catch (error) {\n      imfClientInstance = null;\n      throw error;\n    }\n  }\n  return imfClientInstance;\n}\n\n/** Close and clear the singleton instance (idempotent). */\nexport async function closeIMFMCPClient(): Promise<void> {\n  if (imfClientInstance) {\n    imfClientInstance.disconnect();\n    imfClientInstance = null;\n  }\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/mcp/mcp-connection.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/mcp/mcp-health.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/mcp/mcp-retry.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/mcp/wb-mcp-client.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/templates/article-template.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":260,"column":25,"endLine":260,"endColumn":49},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":429,"column":41,"endLine":429,"endColumn":60},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":613,"column":20,"endLine":613,"endColumn":44},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":768,"column":25,"endLine":768,"endColumn":48}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Templates/ArticleTemplate\n * @description Generates HTML templates for news articles with proper structure and metadata\n */\n\nimport type {\n  ArticleOptions,\n  ArticleSource,\n  ArticleCategoryLabels,\n  LanguageCode,\n  RelatedArticleLink,\n  AnalysisFileEntry,\n  LanguageMap,\n} from '../types/index.js';\nimport {\n  ALL_LANGUAGES,\n  LANGUAGE_FLAGS,\n  LANGUAGE_NAMES,\n  ARTICLE_TYPE_LABELS,\n  READ_TIME_LABELS,\n  BACK_TO_NEWS_LABELS,\n  ARTICLE_NAV_LABELS,\n  RELATED_ARTICLES_NAV_LABELS,\n  BREADCRUMB_HOME_LABELS,\n  BREADCRUMB_NEWS_LABELS,\n  SKIP_LINK_TEXTS,\n  SOURCES_HEADING_LABELS,\n  HEADER_SUBTITLE_LABELS,\n  THEME_TOGGLE_LABELS,\n  ANALYSIS_TRANSPARENCY_LABELS,\n  ANALYSIS_SUMMARY_LABELS,\n  METHODOLOGY_LABELS,\n  TRANSPARENCY_DISCLOSURE_LABELS,\n  CLASSIFICATION_ANALYSIS_LABELS,\n  THREAT_ASSESSMENT_LABELS,\n  RISK_SCORING_LABELS,\n  DEEP_ANALYSIS_LABELS,\n  VIEW_SOURCE_LABELS,\n  OPEN_SOURCE_NOTE_LABELS,\n  AI_ANALYSIS_GUIDE_LABELS,\n  SWOT_FRAMEWORK_LABELS,\n  RISK_METHODOLOGY_LABELS,\n  THREAT_FRAMEWORK_LABELS,\n  CLASSIFICATION_GUIDE_LABELS,\n  STYLE_GUIDE_LABELS,\n  SIGNIFICANCE_CLASSIFICATION_LABELS,\n  ACTOR_MAPPING_LABELS,\n  FORCES_ANALYSIS_LABELS,\n  IMPACT_MATRIX_LABELS,\n  POLITICAL_THREAT_LANDSCAPE_LABELS,\n  ACTOR_THREAT_PROFILING_LABELS,\n  CONSEQUENCE_TREES_LABELS,\n  LEGISLATIVE_DISRUPTION_LABELS,\n  RISK_MATRIX_LABELS,\n  QUANTITATIVE_SWOT_LABELS,\n  POLITICAL_CAPITAL_RISK_LABELS,\n  LEGISLATIVE_VELOCITY_RISK_LABELS,\n  AGENT_RISK_WORKFLOW_LABELS,\n  STAKEHOLDER_IMPACT_LABELS,\n  COALITION_DYNAMICS_LABELS,\n  VOTING_PATTERNS_LABELS,\n  CROSS_SESSION_INTELLIGENCE_LABELS,\n  SYNTHESIS_SUMMARY_LABELS,\n  DOCUMENT_ANALYSIS_LABELS,\n  SIGNIFICANCE_SCORING_LABELS,\n  getLocalizedString,\n  getTextDirection,\n} from '../constants/languages.js';\nimport { escapeHTML, isSafeURL } from '../utils/file-utils.js';\nimport { stripHtmlTags } from '../utils/html-sanitize.js';\nimport { createThemeToggleButton, APP_VERSION } from '../constants/config.js';\nimport { buildSiteFooter } from './section-builders.js';\n\n/** Pattern for valid article dates (YYYY-MM-DD) */\nconst DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$/u;\n\n/** Pattern for valid article slugs (lowercase letters, digits, hyphens) */\nconst SLUG_PATTERN = /^[a-z0-9-]+$/u;\n\n/** Pattern for valid SRI integrity hashes (sha256/sha384/sha512 + base64) */\nconst SRI_HASH_PATTERN = /^sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2}$/u;\n\n/** Words per minute for read-time calculation */\nconst TEMPLATE_WORDS_PER_MINUTE = 250;\n\n/**\n * Serialize an object to JSON suitable for embedding inside `<script>` tags.\n *\n * `JSON.stringify` alone does not prevent `</script>` or `<!--` sequences that\n * can terminate a script element and enable XSS. This helper replaces `<` with\n * the Unicode escape `\\u003c` in the serialized output, rendering such\n * sequences inert while remaining valid JSON.\n *\n * @param value - The value to serialize (typically a structured data object).\n * @returns Pretty-printed JSON string with `<` characters safely escaped.\n */\nfunction safeJsonLdForHtml(value: unknown): string {\n  return JSON.stringify(value, null, 4).replace(/</gu, '\\\\u003c');\n}\n\n/**\n * Base URL for the deployed site, constructed via the URL API so that CodeQL\n * recognises it as a validated URL rather than a potential regex pattern.\n */\nconst SITE_BASE_URL: string = new URL('/euparliamentmonitor', 'https://hack23.github.io').href;\n\n/**\n * BCP47 / Open Graph locale mapping for og:locale meta tag.\n * Maps our 2-letter language codes to proper BCP47 locale strings.\n */\nconst OG_LOCALE_MAP: Readonly<Record<string, string>> = {\n  en: 'en_GB',\n  sv: 'sv_SE',\n  da: 'da_DK',\n  no: 'nb_NO',\n  fi: 'fi_FI',\n  de: 'de_DE',\n  fr: 'fr_FR',\n  es: 'es_ES',\n  nl: 'nl_NL',\n  ar: 'ar_SA',\n  he: 'he_IL',\n  ja: 'ja_JP',\n  ko: 'ko_KR',\n  zh: 'zh_CN',\n} as const;\n\n/**\n * Build the article language switcher nav HTML.\n * Links to the same article in all available languages using the filename pattern {date}-{slug}-{lang}.html.\n *\n * @param date - Article date (YYYY-MM-DD)\n * @param slug - Article slug\n * @param currentLang - Active language code\n * @param availableLanguages - Languages for which the article exists; defaults to all supported languages\n * @returns HTML string\n */\nfunction buildArticleLangSwitcher(\n  date: string,\n  slug: string,\n  currentLang: LanguageCode,\n  availableLanguages?: ReadonlyArray<LanguageCode>\n): string {\n  if (!DATE_PATTERN.test(date)) {\n    throw new Error(`Invalid article date format: \"${date}\"`);\n  }\n\n  if (!SLUG_PATTERN.test(slug)) {\n    throw new Error(`Invalid article slug format: \"${slug}\"`);\n  }\n\n  const safeDate = escapeHTML(date);\n  const safeSlug = escapeHTML(slug);\n\n  const langs = availableLanguages ?? ALL_LANGUAGES;\n  return langs\n    .map((code) => {\n      const flag = getLocalizedString(LANGUAGE_FLAGS, code);\n      const name = getLocalizedString(LANGUAGE_NAMES, code);\n      const active = code === currentLang ? ' active' : '';\n      const href = `${safeDate}-${safeSlug}-${code}.html`;\n      const safeTitle = escapeHTML(name);\n      return `<a href=\"${href}\" class=\"lang-link${active}\" hreflang=\"${code}\" lang=\"${code}\" title=\"${safeTitle}\">${flag} ${code.toUpperCase()}</a>`;\n    })\n    .join('\\n        ');\n}\n\n/**\n * Build the related articles navigation section at the bottom of an article.\n *\n * Renders a `<nav>` element with links to same-day articles of different types.\n * Returns an empty string when the array is empty.\n *\n * @param articles - Related article links to render\n * @param lang - Language code for the localized section heading\n * @returns HTML string for the related articles `<nav>`, or empty string when empty\n */\nfunction buildRelatedArticlesNav(\n  articles: ReadonlyArray<RelatedArticleLink>,\n  lang: LanguageCode\n): string {\n  if (articles.length === 0) return '';\n\n  const safeHeading = escapeHTML(getLocalizedString(RELATED_ARTICLES_NAV_LABELS, lang));\n\n  // Filter articles to only those with valid date, slug, and lang to prevent XSS via URL schemes\n  const validArticles = articles.filter(\n    (a) => DATE_PATTERN.test(a.date) && SLUG_PATTERN.test(a.slug) && ALL_LANGUAGES.includes(a.lang)\n  );\n\n  if (validArticles.length === 0) return '';\n\n  // Localized category labels for display (fall back to raw key)\n  const categoryLabels = getLocalizedString(ARTICLE_TYPE_LABELS, lang) as ArticleCategoryLabels;\n\n  const items = validArticles\n    .map((a) => {\n      const safeTitle = escapeHTML(a.title);\n      const safeCategory = escapeHTML(categoryLabels[a.category] ?? a.category);\n      // Safe: date/slug/lang are validated above, so href is always a relative filename\n      // escapeHTML applied for defense-in-depth within HTML attribute context\n      const href = escapeHTML(`./${a.date}-${a.slug}-${a.lang}.html`);\n      return (\n        `<li class=\"related-article-item\">` +\n        `<span class=\"related-article-type\">${safeCategory}</span> ` +\n        `<a href=\"${href}\" hreflang=\"${escapeHTML(a.lang)}\">${safeTitle}</a>` +\n        `</li>`\n      );\n    })\n    .join('\\n        ');\n\n  return `\n    <nav class=\"related-articles-nav\" aria-label=\"${safeHeading}\">\n      <h2>${safeHeading}</h2>\n      <ul class=\"related-articles-list\">\n        ${items}\n      </ul>\n    </nav>`;\n}\n\n/**\n * Generate complete HTML for a news article\n *\n * @param options - Article generation options\n * @returns Complete HTML document string\n */\nexport function generateArticleHTML(options: ArticleOptions): string {\n  const {\n    slug,\n    title,\n    subtitle,\n    date,\n    category,\n    readTime,\n    lang,\n    content,\n    keywords = [],\n    sources = [],\n    stylesHash,\n    availableLanguages,\n    analysisDir,\n    relatedArticles = [],\n    analysisFiles,\n  } = options;\n\n  const dir = getTextDirection(lang);\n\n  // Format date for display\n  const displayDate = new Date(date).toLocaleDateString(lang, {\n    year: 'numeric',\n    month: 'long',\n    day: 'numeric',\n  });\n\n  const languageName = getLocalizedString(LANGUAGE_NAMES, lang);\n  const categoryLabels = getLocalizedString(ARTICLE_TYPE_LABELS, lang) as ArticleCategoryLabels;\n  const categoryLabel = categoryLabels[category] ?? category;\n  const readTimeFormatter = getLocalizedString(READ_TIME_LABELS, lang);\n\n  // Auto-compute read-time from content word count if not explicitly set\n  const contentWordCount = stripHtmlTags(content).replace(/\\s+/gu, ' ').trim().split(' ').length;\n  const computedReadTime = Math.max(1, Math.ceil(contentWordCount / TEMPLATE_WORDS_PER_MINUTE));\n  const effectiveReadTime = readTime > 0 ? readTime : computedReadTime;\n  const readTimeLabel = readTimeFormatter(effectiveReadTime);\n  const backLabel = getLocalizedString(BACK_TO_NEWS_LABELS, lang);\n  const articleNavLabel = getLocalizedString(ARTICLE_NAV_LABELS, lang);\n  const skipLinkText = getLocalizedString(SKIP_LINK_TEXTS, lang);\n  const headerSubtitle = escapeHTML(getLocalizedString(HEADER_SUBTITLE_LABELS, lang));\n  const indexHref = lang === 'en' ? '../index.html' : `../index-${lang}.html`;\n\n  // Escape values for safe HTML embedding\n  const safeTitle = escapeHTML(title);\n  const safeSubtitle = escapeHTML(subtitle);\n  const safeKeywords = keywords.map((k) => escapeHTML(k)).join(', ');\n  const safeCategoryLabel = escapeHTML(categoryLabel);\n\n  // Build JSON-LD as object for safe serialization\n  const jsonLd = safeJsonLdForHtml({\n    '@context': 'https://schema.org',\n    '@type': 'NewsArticle',\n    headline: title,\n    description: subtitle,\n    datePublished: date,\n    dateModified: date,\n    inLanguage: lang,\n    articleSection: categoryLabel,\n    timeRequired: `PT${effectiveReadTime}M`,\n    author: {\n      '@type': 'Organization',\n      name: 'EU Parliament Monitor',\n    },\n    publisher: {\n      '@type': 'Organization',\n      name: 'EU Parliament Monitor',\n      url: SITE_BASE_URL,\n    },\n    keywords: keywords.join(', '),\n    about: {\n      '@type': 'GovernmentOrganization',\n      name: 'European Parliament',\n      url: 'https://www.europarl.europa.eu',\n    },\n    hasPart: [\n      ...(content.includes('deep-analysis')\n        ? [\n            {\n              '@type': 'WebPageElement',\n              cssSelector: '.deep-analysis',\n              name: 'Deep Political Analysis',\n            },\n          ]\n        : []),\n      ...(sources.length > 0\n        ? [\n            {\n              '@type': 'WebPageElement',\n              cssSelector: '.article-sources',\n              name: 'Sources',\n            },\n          ]\n        : []),\n    ],\n    isBasedOn:\n      sources.length > 0\n        ? sources\n            .filter((s) => typeof s.url === 'string' && /^https?:\\/\\//i.test(s.url))\n            .slice(0, 5)\n            .map((s) => ({\n              '@type': 'Dataset',\n              name: s.title,\n              url: s.url,\n            }))\n        : undefined,\n    mainEntityOfPage: {\n      '@type': 'WebPage',\n      '@id': `${SITE_BASE_URL}/news/${date}-${slug}-${lang}.html`,\n    },\n  });\n\n  // BreadcrumbList structured data for SEO (localized names)\n  const breadcrumbHome = getLocalizedString(BREADCRUMB_HOME_LABELS, lang);\n  const breadcrumbNews = getLocalizedString(BREADCRUMB_NEWS_LABELS, lang);\n  const breadcrumbLd = safeJsonLdForHtml({\n    '@context': 'https://schema.org',\n    '@type': 'BreadcrumbList',\n    itemListElement: [\n      {\n        '@type': 'ListItem',\n        position: 1,\n        name: breadcrumbHome,\n        item: `${SITE_BASE_URL}/`,\n      },\n      {\n        '@type': 'ListItem',\n        position: 2,\n        name: breadcrumbNews,\n        item: `${SITE_BASE_URL}/news/`,\n      },\n      {\n        '@type': 'ListItem',\n        position: 3,\n        name: title,\n        item: `${SITE_BASE_URL}/news/${date}-${slug}-${lang}.html`,\n      },\n    ],\n  });\n\n  // Validate and escape stylesHash — only allow valid SRI hash format\n  const safeSriAttrs =\n    stylesHash && SRI_HASH_PATTERN.test(stylesHash)\n      ? ` integrity=\"${escapeHTML(stylesHash)}\" crossorigin=\"anonymous\"`\n      : '';\n\n  // Compute SHA-256 hashes were previously required for inline <script>\n  // blocks (JSON-LD, reading progress, theme toggle). All executable inline\n  // scripts have been externalised to `js/article-runtime.js`, so the CSP\n  // reduces to `script-src 'self'`. JSON-LD blocks use\n  // `type=\"application/ld+json\"` which is non-executable and not governed\n  // by `script-src`.\n\n  // Localized theme toggle button\n  const themeToggleLabel = escapeHTML(getLocalizedString(THEME_TOGGLE_LABELS, lang));\n\n  // Related articles navigation HTML (optional)\n  const relatedArticlesHtml = buildRelatedArticlesNav(relatedArticles, lang);\n\n  return `<!DOCTYPE html>\n<html lang=\"${lang}\" dir=\"${dir}\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <meta http-equiv=\"X-Content-Type-Options\" content=\"nosniff\">\n  <meta name=\"referrer\" content=\"no-referrer\">\n  <meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self'; frame-src 'none'; base-uri 'self'; form-action 'none'\">\n  <title>${safeTitle} | EU Parliament Monitor</title>\n  <meta name=\"description\" content=\"${safeSubtitle}\">\n  <meta name=\"keywords\" content=\"${safeKeywords}\">\n  <meta name=\"author\" content=\"EU Parliament Monitor\">\n  <meta name=\"generator\" content=\"EU Parliament Monitor v${escapeHTML(APP_VERSION)}\">\n  <meta name=\"date\" content=\"${date}\">\n  <meta property=\"article:published_time\" content=\"${date}\">\n  <meta property=\"article:modified_time\" content=\"${date}\">\n  <meta property=\"article:author\" content=\"EU Parliament Monitor\">\n  <meta property=\"article:section\" content=\"${safeCategoryLabel}\">\n  <meta name=\"article-type\" content=\"${escapeHTML(category)}\">\n  ${keywords\n    .slice(0, 10)\n    .map((k) => `<meta property=\"article:tag\" content=\"${escapeHTML(k)}\">`)\n    .join('\\n  ')}\n  \n  <!-- Favicons -->\n  <link rel=\"icon\" type=\"image/x-icon\" href=\"../favicon.ico\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"../images/favicon-32x32.png\">\n  <link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"../images/favicon-16x16.png\">\n  <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"../images/apple-touch-icon.png\">\n  <link rel=\"manifest\" href=\"../site.webmanifest\">\n  <meta name=\"theme-color\" content=\"#003399\">\n  <link rel=\"alternate\" type=\"application/rss+xml\" title=\"EU Parliament Monitor RSS\" href=\"../rss.xml\">\n\n  <!-- Open Graph -->\n  <meta property=\"og:type\" content=\"article\">\n  <meta property=\"og:title\" content=\"${safeTitle}\">\n  <meta property=\"og:description\" content=\"${safeSubtitle}\">\n  <meta property=\"og:url\" content=\"${SITE_BASE_URL}/news/${date}-${slug}-${lang}.html\">\n  <meta property=\"og:site_name\" content=\"EU Parliament Monitor\">\n  <meta property=\"og:locale\" content=\"${OG_LOCALE_MAP[lang] ?? lang}\">\n  <meta property=\"og:image\" content=\"${SITE_BASE_URL}/images/og-image.jpg\">\n  <meta property=\"og:image:width\" content=\"1200\">\n  <meta property=\"og:image:height\" content=\"630\">\n  <meta property=\"og:image:alt\" content=\"EU Parliament Monitor — AI-Disrupted Parliamentary Intelligence\">\n  \n  <!-- Twitter Card -->\n  <meta name=\"twitter:card\" content=\"summary_large_image\">\n  <meta name=\"twitter:title\" content=\"${safeTitle}\">\n  <meta name=\"twitter:description\" content=\"${safeSubtitle}\">\n  <meta name=\"twitter:image\" content=\"${SITE_BASE_URL}/images/og-image.jpg\">\n  <meta name=\"twitter:image:alt\" content=\"EU Parliament Monitor — AI-Disrupted Parliamentary Intelligence\">\n  \n  <!-- Hreflang alternates for SEO multi-language support -->\n  ${(availableLanguages ?? ALL_LANGUAGES)\n    .map(\n      (code) =>\n        `<link rel=\"alternate\" hreflang=\"${code}\" href=\"${escapeHTML(`${date}-${slug}-${code}.html`)}\">`\n    )\n    .join('\\n  ')}\n  <link rel=\"alternate\" hreflang=\"x-default\" href=\"${escapeHTML(`${date}-${slug}-en.html`)}\">\n  <link rel=\"canonical\" href=\"${SITE_BASE_URL}/news/${date}-${slug}-${lang}.html\">\n  <link rel=\"stylesheet\" href=\"../styles.css\"${safeSriAttrs}>\n  \n  <!-- Schema.org structured data -->\n  <script type=\"application/ld+json\">\n  ${jsonLd}\n  </script>\n  <!-- BreadcrumbList structured data -->\n  <script type=\"application/ld+json\">\n  ${breadcrumbLd}\n  </script>\n</head>\n<body>\n  <div class=\"reading-progress\" aria-hidden=\"true\"></div>\n  <a href=\"#main\" class=\"skip-link\">${skipLinkText}</a>\n\n  <header class=\"site-header\" role=\"banner\">\n    <div class=\"site-header__inner\">\n      <a href=\"${indexHref}\" class=\"site-header__brand\" aria-label=\"EU Parliament Monitor\">\n        <picture class=\"site-header__logo-picture\">\n          <source srcset=\"../images/header-logo.webp\" type=\"image/webp\">\n          <img class=\"site-header__logo site-header__logo--header\" src=\"../images/header-logo.png\" alt=\"\" width=\"72\" height=\"48\" aria-hidden=\"true\">\n        </picture>\n        <span>\n          <span class=\"site-header__title\">EU Parliament Monitor</span>\n          <span class=\"site-header__subtitle\">${headerSubtitle}</span>\n        </span>\n      </a>\n      ${createThemeToggleButton(themeToggleLabel)}\n      <nav class=\"site-header__langs\" role=\"navigation\" aria-label=\"Language selection\">\n        ${buildArticleLangSwitcher(date, slug, lang, availableLanguages)}\n      </nav>\n    </div>\n  </header>\n\n  <nav class=\"article-top-nav\" aria-label=\"${escapeHTML(articleNavLabel)}\">\n    <a href=\"${indexHref}\" class=\"back-to-news\">${backLabel}</a>\n  </nav>\n\n  <main id=\"main\" class=\"site-main\">\n  <article class=\"news-article\" lang=\"${lang}\">\n    <header class=\"article-header\">\n      <div class=\"article-meta\">\n        <span class=\"article-type\">${safeCategoryLabel}</span>\n        <span class=\"article-date\">${displayDate}</span>\n        <span class=\"article-read-time\">${readTimeLabel}</span>\n        <span class=\"article-lang\">${languageName}</span>\n      </div>\n      <h1>${safeTitle}</h1>\n      <p class=\"article-subtitle\">${safeSubtitle}</p>\n    </header>\n    \n    ${content}\n    \n    ${renderSourcesSection(sources, lang)}\n    \n    ${renderAnalysisTransparencySection(date, slug, lang, analysisDir, analysisFiles)}\n    \n    ${relatedArticlesHtml}\n    \n    <nav class=\"article-nav\" aria-label=\"${escapeHTML(articleNavLabel)}\">\n      <a href=\"${indexHref}\" class=\"back-to-news\">${backLabel}</a>\n    </nav>\n  </article>\n  </main>\n\n  ${buildSiteFooter({ lang, pathPrefix: '../' })}\n\n  <script src=\"../js/article-runtime.js\" defer></script>${\n    content.includes('data-chart-config')\n      ? `\n  <script src=\"../js/vendor/chart.umd.min.js\" defer></script>\n  <script src=\"../js/vendor/chartjs-plugin-annotation.min.js\" defer></script>\n  <script src=\"../js/chart-init.js\" defer></script>`\n      : ''\n  }${\n    content.includes('mindmap-container') || content.includes('swot-matrix')\n      ? `\n  <script src=\"../js/vendor/d3.min.js\" defer></script>\n  <script src=\"../js/d3-init.js\" defer></script>`\n      : ''\n  }\n</body>\n</html>`;\n}\n\n/**\n * Render the sources section if sources are provided\n *\n * @param sources - Article source references\n * @param lang - Language code for localized heading\n * @returns HTML string for sources section or empty string\n */\nfunction renderSourcesSection(sources: ArticleSource[], lang: LanguageCode): string {\n  if (sources.length === 0) {\n    return '';\n  }\n\n  const sourcesHeading = escapeHTML(getLocalizedString(SOURCES_HEADING_LABELS, lang));\n  return `\n    <section class=\"article-sources\">\n      <h2>${sourcesHeading}</h2>\n      <ul>\n        ${sources\n          .map((source) => {\n            const safeSourceTitle = escapeHTML(source.title);\n            const href = isSafeURL(source.url) ? escapeHTML(source.url) : '#';\n            return `<li><a href=\"${href}\" target=\"_blank\" rel=\"noopener noreferrer\">${safeSourceTitle}</a></li>`;\n          })\n          .join('\\n          ')}\n      </ul>\n    </section>\n    `;\n}\n\n/**\n * Map of analysis method names to their localized label constants.\n * Used by renderAnalysisTransparencySection to resolve display names.\n */\nconst METHOD_LABEL_MAP: Readonly<Record<string, LanguageMap>> = {\n  'significance-classification': SIGNIFICANCE_CLASSIFICATION_LABELS,\n  'significance-scoring': SIGNIFICANCE_SCORING_LABELS,\n  'actor-mapping': ACTOR_MAPPING_LABELS,\n  'forces-analysis': FORCES_ANALYSIS_LABELS,\n  'impact-matrix': IMPACT_MATRIX_LABELS,\n  'political-threat-landscape': POLITICAL_THREAT_LANDSCAPE_LABELS,\n  'actor-threat-profiling': ACTOR_THREAT_PROFILING_LABELS,\n  'consequence-trees': CONSEQUENCE_TREES_LABELS,\n  'legislative-disruption': LEGISLATIVE_DISRUPTION_LABELS,\n  'risk-matrix': RISK_MATRIX_LABELS,\n  'quantitative-swot': QUANTITATIVE_SWOT_LABELS,\n  'political-capital-risk': POLITICAL_CAPITAL_RISK_LABELS,\n  'legislative-velocity-risk': LEGISLATIVE_VELOCITY_RISK_LABELS,\n  'agent-risk-workflow': AGENT_RISK_WORKFLOW_LABELS,\n  'deep-analysis': DEEP_ANALYSIS_LABELS,\n  'stakeholder-analysis': STAKEHOLDER_IMPACT_LABELS,\n  'coalition-analysis': COALITION_DYNAMICS_LABELS,\n  'voting-patterns': VOTING_PATTERNS_LABELS,\n  'cross-session-intelligence': CROSS_SESSION_INTELLIGENCE_LABELS,\n  'synthesis-summary': SYNTHESIS_SUMMARY_LABELS,\n  'document-analysis': DOCUMENT_ANALYSIS_LABELS,\n};\n\n/**\n * Map of analysis subdirectory names to their section heading label constants\n * and display emoji.\n */\nconst SUBDIR_SECTION_MAP: Readonly<Record<string, { labels: LanguageMap; emoji: string }>> = {\n  classification: { labels: CLASSIFICATION_ANALYSIS_LABELS, emoji: '🏷️' },\n  'threat-assessment': { labels: THREAT_ASSESSMENT_LABELS, emoji: '🛡️' },\n  'risk-scoring': { labels: RISK_SCORING_LABELS, emoji: '⚖️' },\n  existing: { labels: DEEP_ANALYSIS_LABELS, emoji: '🔍' },\n  documents: { labels: DOCUMENT_ANALYSIS_LABELS, emoji: '📄' },\n};\n\n/**\n * Resolve the localized display label for an analysis method.\n *\n * @param method - Canonical method name\n * @param lang - Language code\n * @returns Localized and HTML-escaped label, or titleized method name as fallback\n */\nfunction getMethodLabel(method: string, lang: LanguageCode): string {\n  const labelMap = METHOD_LABEL_MAP[method];\n  if (labelMap) {\n    return escapeHTML(getLocalizedString(labelMap, lang));\n  }\n  // Fallback: titleize the method name (e.g. 'political-stride' → 'Political Stride')\n  return escapeHTML(method.replace(/-/gu, ' ').replace(/\\b\\w/gu, (c) => c.toUpperCase()));\n}\n\n/**\n * Render the analysis transparency section with links to analysis artifacts and methodology.\n *\n * When `analysisFiles` is provided (from manifest.json), links are generated dynamically\n * for ALL analysis files produced during the run — including document-analysis per-document\n * files, synthesis summaries, and any additional methods. This ensures every article links\n * to the exact analysis that produced it.\n *\n * When `analysisFiles` is not available (legacy/fallback), a hardcoded set of standard\n * analysis file links is rendered.\n *\n * @param date - Article date (YYYY-MM-DD)\n * @param slug - Article type slug (e.g., 'committee-reports', 'breaking')\n * @param lang - Language code\n * @param analysisDir - Optional override for analysis directory name (e.g. 'breaking-2' after deduplication)\n * @param analysisFiles - Optional manifest-derived file entries for dynamic link generation\n * @returns HTML string for analysis transparency section\n */\nexport function renderAnalysisTransparencySection(\n  date: string,\n  slug: string,\n  lang: LanguageCode,\n  analysisDir?: string | undefined,\n  analysisFiles?: ReadonlyArray<AnalysisFileEntry> | undefined\n): string {\n  const safeDate = escapeHTML(date);\n  const safeAnalysisDirName = escapeHTML(analysisDir ?? slug);\n  const heading = escapeHTML(getLocalizedString(ANALYSIS_TRANSPARENCY_LABELS, lang));\n  const analysisSummaryLabel = escapeHTML(getLocalizedString(ANALYSIS_SUMMARY_LABELS, lang));\n  const methodologyLabel = escapeHTML(getLocalizedString(METHODOLOGY_LABELS, lang));\n  const disclosure = escapeHTML(getLocalizedString(TRANSPARENCY_DISCLOSURE_LABELS, lang));\n  const viewSourceLabel = escapeHTML(getLocalizedString(VIEW_SOURCE_LABELS, lang));\n  const openSourceNote = escapeHTML(getLocalizedString(OPEN_SOURCE_NOTE_LABELS, lang));\n  const aiGuideLabel = escapeHTML(getLocalizedString(AI_ANALYSIS_GUIDE_LABELS, lang));\n  const swotLabel = escapeHTML(getLocalizedString(SWOT_FRAMEWORK_LABELS, lang));\n  const riskMethodLabel = escapeHTML(getLocalizedString(RISK_METHODOLOGY_LABELS, lang));\n  const threatFrameworkLabel = escapeHTML(getLocalizedString(THREAT_FRAMEWORK_LABELS, lang));\n  const classGuideLabel = escapeHTML(getLocalizedString(CLASSIFICATION_GUIDE_LABELS, lang));\n  const styleGuideLabel = escapeHTML(getLocalizedString(STYLE_GUIDE_LABELS, lang));\n\n  const repoBase = 'https://github.com/Hack23/euparliamentmonitor/blob/main';\n  const treeDirBase = 'https://github.com/Hack23/euparliamentmonitor/tree/main';\n  const analysisDirUrl = `${treeDirBase}/analysis/daily/${safeDate}/${safeAnalysisDirName}`;\n  const analysisFileBase = `${repoBase}/analysis/daily/${safeDate}/${safeAnalysisDirName}`;\n  const methodologyDir = `${repoBase}/analysis/methodologies`;\n\n  // Build the analysis links section — dynamic from manifest or hardcoded fallback\n  const analysisLinksHtml =\n    analysisFiles && analysisFiles.length > 0\n      ? renderDynamicAnalysisLinks(analysisFiles, analysisFileBase, lang)\n      : renderFallbackAnalysisLinks(analysisFileBase, lang);\n\n  return `\n    <section class=\"analysis-transparency\" aria-label=\"${heading}\">\n      <h2 id=\"analysis-transparency-heading\">${heading}</h2>\n      <p>${disclosure}</p>\n      <nav class=\"analysis-links\" aria-labelledby=\"analysis-transparency-heading\">\n        <h3><span aria-hidden=\"true\">📊</span> ${analysisSummaryLabel}</h3>\n        <ul>\n          <li><a href=\"${analysisDirUrl}\" target=\"_blank\" rel=\"noopener noreferrer\"><span aria-hidden=\"true\">📁</span> ${analysisSummaryLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/manifest.json\" target=\"_blank\" rel=\"noopener noreferrer\">manifest.json</a></li>\n        </ul>\n${analysisLinksHtml}\n      </nav>\n      <nav class=\"methodology-links\" aria-label=\"${methodologyLabel}\">\n        <h3>${methodologyLabel}</h3>\n        <ul>\n          <li><a href=\"${methodologyDir}/ai-driven-analysis-guide.md\" target=\"_blank\" rel=\"noopener noreferrer\">${aiGuideLabel}</a></li>\n          <li><a href=\"${methodologyDir}/political-swot-framework.md\" target=\"_blank\" rel=\"noopener noreferrer\">${swotLabel}</a></li>\n          <li><a href=\"${methodologyDir}/political-risk-methodology.md\" target=\"_blank\" rel=\"noopener noreferrer\">${riskMethodLabel}</a></li>\n          <li><a href=\"${methodologyDir}/political-threat-framework.md\" target=\"_blank\" rel=\"noopener noreferrer\">${threatFrameworkLabel}</a></li>\n          <li><a href=\"${methodologyDir}/political-classification-guide.md\" target=\"_blank\" rel=\"noopener noreferrer\">${classGuideLabel}</a></li>\n          <li><a href=\"${methodologyDir}/political-style-guide.md\" target=\"_blank\" rel=\"noopener noreferrer\">${styleGuideLabel}</a></li>\n        </ul>\n      </nav>\n      <p class=\"transparency-note\"><a href=\"https://github.com/Hack23/euparliamentmonitor\" target=\"_blank\" rel=\"noopener noreferrer\"><span aria-hidden=\"true\">🔓</span> ${viewSourceLabel}</a> — ${openSourceNote}</p>\n    </section>\n    `;\n}\n\n/**\n * Validate that an analysis output file path is safe for use in URLs.\n *\n * Rejects absolute paths, path traversal (`..`), backslashes, and any\n * characters outside the expected alphanumeric + hyphen + slash + dot + underscore set.\n *\n * @param outputFile - Relative path from manifest (e.g. 'classification/significance-classification.md')\n * @returns true if the path is safe to interpolate into a URL\n */\nfunction isSafeAnalysisPath(outputFile: string): boolean {\n  if (!outputFile || outputFile.length === 0 || outputFile.length > 256) return false;\n  // Reject path traversal, absolute paths, backslashes, and consecutive slashes\n  if (/\\.\\.|[\\\\]|^[/]|\\/\\//u.test(outputFile)) return false;\n  // Only allow safe characters: alphanumeric, hyphens, underscores, dots, forward slashes\n  return /^[\\da-zA-Z][\\da-zA-Z._/-]*$/u.test(outputFile);\n}\n\n/**\n * Render dynamic analysis links from manifest entries, grouped by subdirectory.\n *\n * @param files - Analysis file entries from the manifest\n * @param analysisFileBase - Base URL for analysis file links\n * @param lang - Language code for localized labels\n * @returns HTML string for the grouped analysis links\n */\nfunction renderDynamicAnalysisLinks(\n  files: ReadonlyArray<AnalysisFileEntry>,\n  analysisFileBase: string,\n  lang: LanguageCode\n): string {\n  // Filter out entries with unsafe paths (path traversal, absolute, backslashes)\n  const safeFiles = files.filter((f) => isSafeAnalysisPath(f.outputFile));\n\n  // Group files by their subdirectory (first path segment)\n  const groups = new Map<string, AnalysisFileEntry[]>();\n  for (const file of safeFiles) {\n    const slashIdx = file.outputFile.indexOf('/');\n    const subdir = slashIdx > 0 ? file.outputFile.slice(0, slashIdx) : '';\n    const key = subdir || '_root';\n    const group = groups.get(key);\n    if (group) {\n      group.push(file);\n    } else {\n      groups.set(key, [file]);\n    }\n  }\n\n  // Known ordering for subdirectories\n  const orderedSubdirs = [\n    'classification',\n    'threat-assessment',\n    'risk-scoring',\n    'existing',\n    'documents',\n  ];\n  const sortedKeys = [\n    ...orderedSubdirs.filter((k) => groups.has(k)),\n    ...[...groups.keys()].filter((k) => k !== '_root' && !orderedSubdirs.includes(k)),\n    ...(groups.has('_root') ? ['_root'] : []),\n  ];\n\n  const sections: string[] = [];\n\n  for (const key of sortedKeys) {\n    const groupFiles = groups.get(key);\n    if (!groupFiles || groupFiles.length === 0) continue;\n\n    const sectionInfo = SUBDIR_SECTION_MAP[key];\n    const sectionHeading = sectionInfo\n      ? escapeHTML(getLocalizedString(sectionInfo.labels, lang))\n      : escapeHTML(key.replace(/-/gu, ' ').replace(/\\b\\w/gu, (c) => c.toUpperCase()));\n    const emoji = sectionInfo?.emoji ?? '📋';\n\n    const items = groupFiles.map((f) => {\n      const label = getMethodLabel(f.method, lang);\n      // URL-encode each path segment to prevent URL injection\n      const encodedFile = f.outputFile\n        .split('/')\n        .map((seg) => encodeURIComponent(seg))\n        .join('/');\n      return `          <li><a href=\"${analysisFileBase}/${encodedFile}\" target=\"_blank\" rel=\"noopener noreferrer\">${label}</a></li>`;\n    });\n\n    sections.push(`        <h3><span aria-hidden=\"true\">${emoji}</span> ${sectionHeading}</h3>\n        <ul>\n${items.join('\\n')}\n        </ul>`);\n  }\n\n  return sections.join('\\n');\n}\n\n/**\n * Render the legacy hardcoded analysis links for when no manifest data is available.\n *\n * @param analysisFileBase - Base URL for analysis file links\n * @param lang - Language code for localized labels\n * @returns HTML string for the hardcoded analysis links\n */\nfunction renderFallbackAnalysisLinks(analysisFileBase: string, lang: LanguageCode): string {\n  const classificationLabel = escapeHTML(getLocalizedString(CLASSIFICATION_ANALYSIS_LABELS, lang));\n  const threatLabel = escapeHTML(getLocalizedString(THREAT_ASSESSMENT_LABELS, lang));\n  const riskLabel = escapeHTML(getLocalizedString(RISK_SCORING_LABELS, lang));\n  const deepLabel = escapeHTML(getLocalizedString(DEEP_ANALYSIS_LABELS, lang));\n  const documentLabel = escapeHTML(getLocalizedString(DOCUMENT_ANALYSIS_LABELS, lang));\n\n  const significanceLabel = escapeHTML(\n    getLocalizedString(SIGNIFICANCE_CLASSIFICATION_LABELS, lang)\n  );\n  const significanceScoringLabel = escapeHTML(\n    getLocalizedString(SIGNIFICANCE_SCORING_LABELS, lang)\n  );\n  const actorMappingLabel = escapeHTML(getLocalizedString(ACTOR_MAPPING_LABELS, lang));\n  const forcesLabel = escapeHTML(getLocalizedString(FORCES_ANALYSIS_LABELS, lang));\n  const impactMatrixLabel = escapeHTML(getLocalizedString(IMPACT_MATRIX_LABELS, lang));\n  const threatLandscapeLabel = escapeHTML(\n    getLocalizedString(POLITICAL_THREAT_LANDSCAPE_LABELS, lang)\n  );\n  const actorThreatProfilingLabel = escapeHTML(\n    getLocalizedString(ACTOR_THREAT_PROFILING_LABELS, lang)\n  );\n  const consequenceLabel = escapeHTML(getLocalizedString(CONSEQUENCE_TREES_LABELS, lang));\n  const disruptionLabel = escapeHTML(getLocalizedString(LEGISLATIVE_DISRUPTION_LABELS, lang));\n  const riskMatrixLabel = escapeHTML(getLocalizedString(RISK_MATRIX_LABELS, lang));\n  const quantSwotLabel = escapeHTML(getLocalizedString(QUANTITATIVE_SWOT_LABELS, lang));\n  const politicalCapitalLabel = escapeHTML(getLocalizedString(POLITICAL_CAPITAL_RISK_LABELS, lang));\n  const legVelocityLabel = escapeHTML(getLocalizedString(LEGISLATIVE_VELOCITY_RISK_LABELS, lang));\n  const agentRiskLabel = escapeHTML(getLocalizedString(AGENT_RISK_WORKFLOW_LABELS, lang));\n  const deepAnalysisFileLabel = escapeHTML(getLocalizedString(DEEP_ANALYSIS_LABELS, lang));\n  const stakeholderLabel = escapeHTML(getLocalizedString(STAKEHOLDER_IMPACT_LABELS, lang));\n  const coalitionLabel = escapeHTML(getLocalizedString(COALITION_DYNAMICS_LABELS, lang));\n  const votingPatternsLabel = escapeHTML(getLocalizedString(VOTING_PATTERNS_LABELS, lang));\n  const crossSessionLabel = escapeHTML(getLocalizedString(CROSS_SESSION_INTELLIGENCE_LABELS, lang));\n  const synthesisSummaryLabel = escapeHTML(getLocalizedString(SYNTHESIS_SUMMARY_LABELS, lang));\n\n  return `        <h3><span aria-hidden=\"true\">🏷️</span> ${classificationLabel}</h3>\n        <ul>\n          <li><a href=\"${analysisFileBase}/classification/significance-classification.md\" target=\"_blank\" rel=\"noopener noreferrer\">${significanceLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/classification/significance-scoring.md\" target=\"_blank\" rel=\"noopener noreferrer\">${significanceScoringLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/classification/actor-mapping.md\" target=\"_blank\" rel=\"noopener noreferrer\">${actorMappingLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/classification/forces-analysis.md\" target=\"_blank\" rel=\"noopener noreferrer\">${forcesLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/classification/impact-matrix.md\" target=\"_blank\" rel=\"noopener noreferrer\">${impactMatrixLabel}</a></li>\n        </ul>\n        <h3><span aria-hidden=\"true\">🛡️</span> ${threatLabel}</h3>\n        <ul>\n          <li><a href=\"${analysisFileBase}/threat-assessment/political-threat-landscape.md\" target=\"_blank\" rel=\"noopener noreferrer\">${threatLandscapeLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/threat-assessment/actor-threat-profiling.md\" target=\"_blank\" rel=\"noopener noreferrer\">${actorThreatProfilingLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/threat-assessment/consequence-trees.md\" target=\"_blank\" rel=\"noopener noreferrer\">${consequenceLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/threat-assessment/legislative-disruption.md\" target=\"_blank\" rel=\"noopener noreferrer\">${disruptionLabel}</a></li>\n        </ul>\n        <h3><span aria-hidden=\"true\">⚖️</span> ${riskLabel}</h3>\n        <ul>\n          <li><a href=\"${analysisFileBase}/risk-scoring/risk-matrix.md\" target=\"_blank\" rel=\"noopener noreferrer\">${riskMatrixLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/risk-scoring/quantitative-swot.md\" target=\"_blank\" rel=\"noopener noreferrer\">${quantSwotLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/risk-scoring/political-capital-risk.md\" target=\"_blank\" rel=\"noopener noreferrer\">${politicalCapitalLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/risk-scoring/legislative-velocity-risk.md\" target=\"_blank\" rel=\"noopener noreferrer\">${legVelocityLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/risk-scoring/agent-risk-workflow.md\" target=\"_blank\" rel=\"noopener noreferrer\">${agentRiskLabel}</a></li>\n        </ul>\n        <h3><span aria-hidden=\"true\">🔍</span> ${deepLabel}</h3>\n        <ul>\n          <li><a href=\"${analysisFileBase}/existing/deep-analysis.md\" target=\"_blank\" rel=\"noopener noreferrer\">${deepAnalysisFileLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/existing/stakeholder-impact.md\" target=\"_blank\" rel=\"noopener noreferrer\">${stakeholderLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/existing/coalition-dynamics.md\" target=\"_blank\" rel=\"noopener noreferrer\">${coalitionLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/existing/voting-patterns.md\" target=\"_blank\" rel=\"noopener noreferrer\">${votingPatternsLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/existing/cross-session-intelligence.md\" target=\"_blank\" rel=\"noopener noreferrer\">${crossSessionLabel}</a></li>\n          <li><a href=\"${analysisFileBase}/synthesis-summary.md\" target=\"_blank\" rel=\"noopener noreferrer\">${synthesisSummaryLabel}</a></li>\n        </ul>\n        <h3><span aria-hidden=\"true\">📄</span> ${documentLabel}</h3>\n        <ul>\n          <li><a href=\"${analysisFileBase}/documents/document-analysis-index.md\" target=\"_blank\" rel=\"noopener noreferrer\">${documentLabel}</a></li>\n        </ul>`;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/templates/section-builders.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":288,"column":35,"endLine":288,"endColumn":44},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":289,"column":34,"endLine":289,"endColumn":42}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Templates/SectionBuilders\n * @description Reusable section builder utilities for article template architecture.\n * Provides quality scoring, table of contents generation, quality badge rendering,\n * timeline sections, comparison tables, and key figures bars.\n */\n\nimport { escapeHTML } from '../utils/file-utils.js';\nimport type { ArticleQualityScore, TOCEntry, LanguageCode } from '../types/index.js';\nimport {\n  ALL_LANGUAGES,\n  LANGUAGE_FLAGS,\n  LANGUAGE_NAMES,\n  getLocalizedString,\n  TOC_ARIA_LABELS,\n  TIMELINE_HEADINGS,\n  COMPARISON_BEFORE_LABELS,\n  COMPARISON_AFTER_LABELS,\n  KEY_FIGURES_HEADINGS,\n  FOOTER_ABOUT_HEADING_LABELS,\n  FOOTER_ABOUT_TEXT_LABELS,\n  FOOTER_QUICK_LINKS_LABELS,\n  FOOTER_BUILT_BY_LABELS,\n  FOOTER_LANGUAGES_LABELS,\n  FOOTER_HOME_LABELS,\n  FOOTER_SITEMAP_LABELS,\n  FOOTER_RSS_LABELS,\n  FOOTER_GITHUB_REPO_LABELS,\n  FOOTER_LICENSE_LABELS,\n  FOOTER_EUROPARL_LABELS,\n  FOOTER_LINKEDIN_LABELS,\n  FOOTER_SECURITY_POLICY_LABELS,\n  FOOTER_CONTACT_LABELS,\n  FOOTER_DISCLAIMER_LABELS,\n  FOOTER_REPORT_ISSUES_LABELS,\n  FOOTER_ARTICLES_AVAILABLE_LABELS,\n} from '../constants/languages.js';\nimport { APP_VERSION } from '../constants/config.js';\nimport { stripScriptBlocks, stripHtmlTags } from '../utils/html-sanitize.js';\n\n// ─── New section builder interfaces ─────────────────────────────────────────\n\n/**\n * A single item in a legislative or procedural timeline.\n */\nexport interface TimelineItem {\n  /** Date label (e.g. \"2026-03-15\" or human-readable) */\n  date: string;\n  /** Short event label */\n  label: string;\n  /** Optional extended description */\n  description?: string | undefined;\n}\n\n/**\n * A numeric highlight figure for a key-figures bar.\n */\nexport interface KeyFigure {\n  /** Descriptive label for the figure */\n  label: string;\n  /** Formatted value string (e.g. \"42\", \"78%\") */\n  value: string;\n  /** Optional unit suffix (e.g. \"votes\", \"days\", \"%\") */\n  unit?: string | undefined;\n  /** Optional longer description for screen readers / tooltips */\n  description?: string | undefined;\n}\n\n/**\n * Count occurrences of a regex pattern in a string.\n *\n * @param content - String to search.\n * @param pattern - Global regex pattern to match.\n * @returns Number of matches found.\n */\nfunction countMatches(content: string, pattern: RegExp): number {\n  const matches = content.match(pattern);\n  return matches !== null ? matches.length : 0;\n}\n\n/**\n * Count elements whose `class` attribute contains a given CSS class token.\n *\n * Extracts every `class=\"…\"` attribute, splits the value into tokens, and\n * checks for an exact match — so `\"dashboard\"` will NOT match nested\n * classes like `\"dashboard-grid\"` or `\"dashboard-panel\"`.\n *\n * @param content - HTML string to search.\n * @param token - Exact CSS class name to look for.\n * @returns Number of elements that have the given class token.\n */\nfunction countClassToken(content: string, token: string): number {\n  let count = 0;\n  for (const m of content.matchAll(/class=\"([^\"]*)\"/g)) {\n    const value = m[1] ?? '';\n    if (value.split(/\\s+/).includes(token)) {\n      count += 1;\n    }\n  }\n  return count;\n}\n\n// stripScriptBlocks is imported from html-sanitize.ts\n\n/**\n * Compute an article quality score by analysing the rendered HTML content.\n *\n * @param content - Full HTML content string of the article body.\n * @returns {@link ArticleQualityScore} with word count, section counts, and overall rating.\n */\nexport function computeArticleQualityScore(content: string): ArticleQualityScore {\n  // Remove script blocks before tag-stripping to avoid inflating word count.\n  // Uses iterative scanning instead of regex to avoid CodeQL js/bad-tag-filter.\n  const noScripts = stripScriptBlocks(content);\n  // Strip HTML tags to get plain text, then count words\n  const plainText = stripHtmlTags(noScripts).replace(/\\s+/g, ' ').trim();\n  const wordCount =\n    plainText.length > 0 ? plainText.split(' ').filter((w) => w.length > 0).length : 0;\n\n  // All further counting uses script-stripped HTML to avoid false positives\n  // from embedded JSON-LD or interactive script blocks.\n  const totalSections = countMatches(noScripts, /<section\\b/g);\n\n  // Count data visualizations using exact class-token matching.\n  // countClassToken splits the class attribute value into tokens, so nested\n  // classes like \"dashboard-grid\" or \"dashboard-panel\" are NOT counted.\n  const chartCount = countMatches(noScripts, /data-chart-config/g);\n  const dashboardCount = countClassToken(noScripts, 'dashboard');\n  const mindmapCount = countClassToken(noScripts, 'mindmap-section');\n  const swotCount = countClassToken(noScripts, 'swot-analysis');\n  const visualizationCount = chartCount + dashboardCount + mindmapCount + swotCount;\n\n  // Exclude visualization sections from analysis section count\n  const analysisSections = totalSections - dashboardCount - mindmapCount - swotCount;\n\n  // Count EP document links (with a real path, not just the bare homepage).\n  // This excludes the generic footer link `https://www.europarl.europa.eu/`\n  // while counting links to specific EP resources like /doceo/, /plenary/, etc.\n  const evidenceReferences = countMatches(\n    noScripts,\n    /href=\"https:\\/\\/www\\.europarl\\.europa\\.eu\\/\\w[^\"]*\"/g\n  );\n\n  // Determine overall quality score\n  let overallScore: ArticleQualityScore['overallScore'];\n  if (wordCount >= 800 && analysisSections >= 3 && visualizationCount >= 2) {\n    overallScore = 'excellent';\n  } else if (wordCount >= 500 && analysisSections >= 2) {\n    overallScore = 'good';\n  } else if (wordCount >= 200 && analysisSections >= 1) {\n    overallScore = 'adequate';\n  } else {\n    overallScore = 'needs-improvement';\n  }\n\n  return { wordCount, analysisSections, visualizationCount, evidenceReferences, overallScore };\n}\n\n/**\n * Build an HTML table of contents navigation element from a list of entries.\n *\n * @param entries - Ordered list of {@link TOCEntry} items to render.\n * @param lang - Language code used for the localised aria-label.\n * @returns HTML string for the TOC `<nav>` element, or empty string when entries is empty.\n */\nexport function buildTableOfContents(entries: TOCEntry[], lang: LanguageCode): string {\n  if (entries.length === 0) {\n    return '';\n  }\n\n  const ariaLabel = escapeHTML(getLocalizedString(TOC_ARIA_LABELS, lang));\n\n  const items = entries\n    .map((entry) => {\n      const safeLabel = escapeHTML(entry.label);\n      // Strip leading # to prevent href=\"##foo\"\n      const safeId = escapeHTML(entry.id.replace(/^#/, ''));\n      const classAttr = entry.level === 2 ? ' class=\"toc-sub\"' : '';\n      return `<li${classAttr}><a href=\"#${safeId}\">${safeLabel}</a></li>`;\n    })\n    .join('\\n      ');\n\n  return `<nav class=\"article-toc\" aria-label=\"${ariaLabel}\">\n  <ol>\n      ${items}\n  </ol>\n</nav>`;\n}\n\n/**\n * Build an HTML quality score badge element for an article.\n *\n * The badge is `aria-hidden` since it conveys metadata, not primary content.\n * Returns an empty string for articles with a 'needs-improvement' score to avoid\n * surfacing poor-quality signals to readers.\n *\n * @param score - {@link ArticleQualityScore} to render.\n * @returns HTML string for the badge `<div>`, or empty string for needs-improvement.\n */\nexport function buildQualityScoreBadge(score: ArticleQualityScore): string {\n  if (score.overallScore === 'needs-improvement') {\n    return '';\n  }\n\n  const safeScore = escapeHTML(score.overallScore);\n  return `<div class=\"article-quality-score\" data-score=\"${safeScore}\" aria-hidden=\"true\">\n  <span class=\"qs-words\">${score.wordCount}</span>\n  <span class=\"qs-sections\">${score.analysisSections}</span>\n  <span class=\"qs-visuals\">${score.visualizationCount}</span>\n  <span class=\"qs-evidence\">${score.evidenceReferences}</span>\n</div>`;\n}\n\n// ─── New section builders ────────────────────────────────────────────────────\n\n/**\n * Build an HTML timeline section for legislative or procedural events.\n *\n * Renders an ordered list of dated events. Each item includes a date badge\n * and a label. An optional description is included as visible text when\n * provided. Empty items array returns an empty string.\n *\n * @param items - Ordered list of {@link TimelineItem} events to render.\n * @param lang - Language code used for the section heading.\n * @returns HTML string for the timeline `<section>`, or empty string when items is empty.\n */\nexport function buildTimelineSection(\n  items: ReadonlyArray<TimelineItem>,\n  lang: LanguageCode\n): string {\n  if (items.length === 0) return '';\n\n  const heading = escapeHTML(getLocalizedString(TIMELINE_HEADINGS, lang));\n\n  const listItems = items\n    .map((item) => {\n      const safeDate = escapeHTML(item.date);\n      const safeLabel = escapeHTML(item.label);\n      const descPart = item.description\n        ? `<span class=\"timeline-description\">${escapeHTML(item.description)}</span>`\n        : '';\n      return (\n        `<li class=\"timeline-item\">` +\n        `<span class=\"timeline-date\">${safeDate}</span>` +\n        `<span class=\"timeline-label\">${safeLabel}</span>` +\n        descPart +\n        `</li>`\n      );\n    })\n    .join('\\n      ');\n\n  return `<section class=\"timeline-section\" aria-label=\"${heading}\">\n  <h2>${heading}</h2>\n  <ol class=\"timeline-list\" role=\"list\">\n      ${listItems}\n  </ol>\n</section>`;\n}\n\n/**\n * Build an HTML before/after comparison table for legislative changes.\n *\n * Renders a two-column table comparing the state of something before and after\n * a legislative action. When the input arrays have different lengths, the\n * table uses the longer length and renders missing cells as empty strings.\n * Returns an empty string when either array is empty.\n *\n * @param before - Array of \"before\" state descriptions for the first column.\n * @param after - Array of \"after\" state descriptions for the second column.\n * @param lang - Language code used for column headings.\n * @returns HTML string for the comparison `<table>`, or empty string when either array is empty.\n */\nexport function buildComparisonTable(\n  before: ReadonlyArray<string>,\n  after: ReadonlyArray<string>,\n  lang: LanguageCode\n): string {\n  if (before.length === 0 || after.length === 0) return '';\n\n  const beforeLabel = escapeHTML(getLocalizedString(COMPARISON_BEFORE_LABELS, lang));\n  const afterLabel = escapeHTML(getLocalizedString(COMPARISON_AFTER_LABELS, lang));\n  const maxRows = Math.max(before.length, after.length);\n\n  const rows = Array.from({ length: maxRows }, (_, i) => {\n    const beforeCell = escapeHTML(before[i] ?? '');\n    const afterCell = escapeHTML(after[i] ?? '');\n    return (\n      `<tr>` +\n      `<td class=\"comparison-before\">${beforeCell}</td>` +\n      `<td class=\"comparison-after\">${afterCell}</td>` +\n      `</tr>`\n    );\n  }).join('\\n      ');\n\n  return `<div class=\"comparison-table-wrapper\" role=\"region\" aria-label=\"${beforeLabel} / ${afterLabel}\">\n  <table class=\"comparison-table\">\n    <caption class=\"sr-only\">${beforeLabel} / ${afterLabel}</caption>\n    <thead>\n      <tr>\n        <th scope=\"col\">${beforeLabel}</th>\n        <th scope=\"col\">${afterLabel}</th>\n      </tr>\n    </thead>\n    <tbody>\n      ${rows}\n    </tbody>\n  </table>\n</div>`;\n}\n\n/**\n * Build an HTML key figures bar for quick-scan numeric highlights.\n *\n * Renders a horizontal strip of numeric summary cards. Each card shows a\n * value (with optional unit), a label, and an optional screen-reader-only\n * description. Empty figures array returns an empty string.\n *\n * @param figures - Array of {@link KeyFigure} items to render.\n * @param lang - Language code used for the section heading.\n * @returns HTML string for the key figures `<section>`, or empty string when figures is empty.\n */\nexport function buildKeyFiguresBar(figures: ReadonlyArray<KeyFigure>, lang: LanguageCode): string {\n  if (figures.length === 0) return '';\n\n  const heading = escapeHTML(getLocalizedString(KEY_FIGURES_HEADINGS, lang));\n\n  const cards = figures\n    .map((fig) => {\n      const safeLabel = escapeHTML(fig.label);\n      const safeValue = escapeHTML(fig.value);\n      const safeUnit = fig.unit ? escapeHTML(fig.unit) : '';\n      const unitSpan = safeUnit\n        ? ` <span class=\"kf-unit\" aria-hidden=\"true\">${safeUnit}</span>`\n        : '';\n      const descriptionPart = fig.description\n        ? `<span class=\"sr-only\">${escapeHTML(fig.description)}</span>`\n        : '';\n      return (\n        `<div class=\"key-figure-card\" role=\"listitem\" aria-label=\"${safeLabel}: ${safeValue}${safeUnit ? ' ' + safeUnit : ''}\">` +\n        `<span class=\"kf-value\">${safeValue}${unitSpan}</span>` +\n        `<span class=\"kf-label\">${safeLabel}</span>` +\n        descriptionPart +\n        `</div>`\n      );\n    })\n    .join('\\n      ');\n\n  return `<section class=\"key-figures-bar\" aria-label=\"${heading}\">\n  <h2 class=\"sr-only\">${heading}</h2>\n  <div class=\"key-figures-grid\" role=\"list\">\n      ${cards}\n  </div>\n</section>`;\n}\n\n/* ─── Shared site footer builder ─────────────────────────────────── */\n\n/**\n * Options for building the shared site footer.\n */\nexport interface SiteFooterOptions {\n  /** Language code used for localization. */\n  lang: LanguageCode;\n  /**\n   * URL path prefix prepended to relative links.\n   * Use `''` for root (index) pages and `'../'` for pages inside `news/`.\n   */\n  pathPrefix: string;\n  /**\n   * Optional article count shown in the About section.\n   * When omitted the articles-available line is hidden.\n   */\n  articleCount?: number | undefined;\n}\n\n/**\n * Build the language grid links used inside the footer Languages section.\n *\n * @param currentLang - The currently active language code.\n * @param pathPrefix - Path prefix for index page hrefs ('' or '../').\n * @returns HTML string of anchor elements.\n */\nfunction buildFooterLangGrid(currentLang: LanguageCode, pathPrefix: string): string {\n  return ALL_LANGUAGES.map((code) => {\n    const flag = getLocalizedString(LANGUAGE_FLAGS, code);\n    const safeName = escapeHTML(getLocalizedString(LANGUAGE_NAMES, code));\n    const href = code === 'en' ? `${pathPrefix}index.html` : `${pathPrefix}index-${code}.html`;\n    const active = code === currentLang ? ' class=\"active\"' : '';\n    return `<a href=\"${escapeHTML(href)}\"${active} hreflang=\"${code}\">${flag} ${safeName}</a>`;\n  }).join('\\n            ');\n}\n\n/**\n * Build the shared site footer HTML used by both article pages and index pages.\n *\n * Renders four sections (About, Quick Links, Built by Hack23, Languages) plus a\n * footer-bottom bar with copyright, version, and a localized disclaimer.\n *\n * @param options - {@link SiteFooterOptions} controlling lang, pathPrefix, and articleCount.\n * @returns HTML string for `<footer class=\"site-footer\">…</footer>`.\n */\nexport function buildSiteFooter(options: SiteFooterOptions): string {\n  const { lang, pathPrefix, articleCount } = options;\n  const year = new Date().getFullYear();\n\n  const aboutHeading = escapeHTML(getLocalizedString(FOOTER_ABOUT_HEADING_LABELS, lang));\n  const aboutText = escapeHTML(getLocalizedString(FOOTER_ABOUT_TEXT_LABELS, lang));\n  const quickLinksHeading = escapeHTML(getLocalizedString(FOOTER_QUICK_LINKS_LABELS, lang));\n  const builtByHeading = escapeHTML(getLocalizedString(FOOTER_BUILT_BY_LABELS, lang));\n  const languagesHeading = escapeHTML(getLocalizedString(FOOTER_LANGUAGES_LABELS, lang));\n\n  const homeLabel = escapeHTML(getLocalizedString(FOOTER_HOME_LABELS, lang));\n  const sitemapLabel = escapeHTML(getLocalizedString(FOOTER_SITEMAP_LABELS, lang));\n  const rssLabel = escapeHTML(getLocalizedString(FOOTER_RSS_LABELS, lang));\n  const githubLabel = escapeHTML(getLocalizedString(FOOTER_GITHUB_REPO_LABELS, lang));\n  const licenseLabel = escapeHTML(getLocalizedString(FOOTER_LICENSE_LABELS, lang));\n  const europarlLabel = escapeHTML(getLocalizedString(FOOTER_EUROPARL_LABELS, lang));\n  const linkedinLabel = escapeHTML(getLocalizedString(FOOTER_LINKEDIN_LABELS, lang));\n  // Security & Privacy Policy label already contains safe &amp; entities — do not double-escape\n  const securityLabel = getLocalizedString(FOOTER_SECURITY_POLICY_LABELS, lang);\n  const contactLabel = escapeHTML(getLocalizedString(FOOTER_CONTACT_LABELS, lang));\n  const disclaimerText = escapeHTML(getLocalizedString(FOOTER_DISCLAIMER_LABELS, lang));\n  const reportIssuesLabel = escapeHTML(getLocalizedString(FOOTER_REPORT_ISSUES_LABELS, lang));\n\n  const articlesLine =\n    typeof articleCount === 'number'\n      ? `\\n        <p class=\"footer-stats\">${escapeHTML(getLocalizedString(FOOTER_ARTICLES_AVAILABLE_LABELS, lang).replace('{count}', String(articleCount)))}</p>`\n      : '';\n\n  const langGrid = buildFooterLangGrid(lang, pathPrefix);\n\n  return `<footer class=\"site-footer\" role=\"contentinfo\">\n    <div class=\"footer-content\">\n      <div class=\"footer-section\">\n        <h3>${aboutHeading}</h3>\n        <p>${aboutText}</p>${articlesLine}\n      </div>\n      <div class=\"footer-section\">\n        <h3>${quickLinksHeading}</h3>\n        <ul>\n          <li><a href=\"${pathPrefix}index.html\">${homeLabel}</a></li>\n          <li><a href=\"${pathPrefix}sitemap.html\">${sitemapLabel}</a></li>\n          <li><a href=\"${pathPrefix}rss.xml\">${rssLabel}</a></li>\n          <li><a href=\"https://github.com/Hack23/euparliamentmonitor\">${githubLabel}</a></li>\n          <li><a href=\"https://github.com/Hack23/euparliamentmonitor/blob/main/LICENSE\">${licenseLabel}</a></li>\n          <li><a href=\"https://www.europarl.europa.eu/\">${europarlLabel}</a></li>\n        </ul>\n      </div>\n      <div class=\"footer-section\">\n        <h3>${builtByHeading}</h3>\n        <ul>\n          <li><a href=\"https://hack23.com\">hack23.com</a></li>\n          <li><a href=\"https://www.linkedin.com/company/hack23\">${linkedinLabel}</a></li>\n          <li><a href=\"https://github.com/Hack23/ISMS-PUBLIC\">${securityLabel}</a></li>\n          <li><a href=\"mailto:james@hack23.com\">${contactLabel}</a></li>\n        </ul>\n      </div>\n      <div class=\"footer-section\">\n        <h3>${languagesHeading}</h3>\n        <div class=\"language-grid\">\n          ${langGrid}\n        </div>\n      </div>\n    </div>\n    <div class=\"footer-bottom\">\n      <p>&copy; 2008-${year} <a href=\"https://hack23.com\">Hack23 AB</a> (Org.nr 5595347807) | Gothenburg, Sweden | v${escapeHTML(APP_VERSION)}</p>\n      <p class=\"footer-disclaimer\"><span aria-hidden=\"true\">⚠️</span> ${disclaimerText} <a href=\"https://github.com/Hack23/euparliamentmonitor/issues\">${reportIssuesLabel}</a>.</p>\n    </div>\n  </footer>`;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/analysis.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/common.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/generation.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/imf.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/intelligence.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/mcp.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/parliament.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/political-classification.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/political-risk.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/political-threats.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/quality.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/significance.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/stakeholder.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/visualization.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/types/world-bank.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/article-category.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/article-quality-scorer.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":312,"column":14,"endLine":312,"endColumn":34},{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":394,"column":21,"endLine":394,"endColumn":54},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":554,"column":30,"endLine":554,"endColumn":47},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":652,"column":22,"endLine":652,"endColumn":31}],"suppressedMessages":[{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":571,"column":26,"endLine":571,"endColumn":67,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/ArticleQualityScorer\n * @description Comprehensive quality assessment engine for generated EU Parliament Monitor articles.\n *\n * Analyses HTML article content across five weighted dimensions aligned with\n * the AI-driven analysis methodology guide (`analysis/methodologies/ai-driven-analysis-guide.md`):\n * - **Analysis depth** (25%) — political context, coalition dynamics, historical evidence, scenarios\n * - **Evidence density** (25%) — document references, citations, data-backed claims\n * - **Structural compliance** (20%) — SWOT, dashboard metrics, mindmap branches, deep-analysis\n * - **Actionable intelligence** (15%) — article length via word count\n * - **Stakeholder balance** (15%) — breadth of perspectives from MEPs, Commission, civil society, etc.\n *\n * Produces an {@link ArticleQualityReport} with a 0–100 overall score, letter grade (A–F),\n * pass/fail quality gate (≥ 70, per methodology minimum 7.0/10), and actionable recommendations.\n */\n\nimport type {\n  ArticleQualityReport,\n  AnalysisDepthScore,\n  StakeholderCoverage,\n  VisualizationQuality,\n  ArticleGrade,\n  TemporalCoverageScore,\n  CrossReferenceDensityScore,\n} from '../types/quality.js';\n\nimport { stripScriptBlocks } from './html-sanitize.js';\n\n// ─── Scoring constants ────────────────────────────────────────────────────────\n//\n// Weights aligned with analysis/methodologies/ai-driven-analysis-guide.md §Score\n// the Analysis:\n//   Evidence density      → WEIGHT_EVIDENCE        = 0.25\n//   Analytical depth      → WEIGHT_ANALYSIS_DEPTH   = 0.25\n//   Structural compliance → WEIGHT_VISUALIZATION    = 0.20\n//   Actionable intel      → WEIGHT_WORD_COUNT       = 0.15\n//   Political neutrality  → WEIGHT_STAKEHOLDER      = 0.15\n\n/** Weight applied to analysis depth score in overall calculation (methodology: Analytical depth 25%) */\nconst WEIGHT_ANALYSIS_DEPTH = 0.25;\n/** Weight applied to stakeholder balance score in overall calculation (methodology: Political neutrality 15%) */\nconst WEIGHT_STAKEHOLDER = 0.15;\n/** Weight applied to visualization quality score in overall calculation (methodology: Structural compliance 20%) */\nconst WEIGHT_VISUALIZATION = 0.2;\n/** Weight applied to word-count score in overall calculation (methodology: Actionable intelligence 15%) */\nconst WEIGHT_WORD_COUNT = 0.15;\n/** Weight applied to evidence-reference score in overall calculation (methodology: Evidence density 25%) */\nconst WEIGHT_EVIDENCE = 0.25;\n\n/** Minimum word count to score 0 on the word-count dimension */\nconst WORD_COUNT_MIN = 0;\n/** Word count that earns the maximum word-count dimension score */\nconst WORD_COUNT_MAX = 1500;\n\n/** Evidence-reference count that earns the maximum evidence dimension score */\nconst EVIDENCE_MAX = 10;\n\n/**\n * Overall score threshold for passing the quality gate.\n *\n * Aligned with analysis/methodologies/ai-driven-analysis-guide.md which\n * mandates a minimum 7.0/10 (≡ 70/100) quality score.\n */\nconst QUALITY_GATE_THRESHOLD = 70;\n\n/** Grade boundary — score >= this earns an A */\nconst GRADE_A_MIN = 90;\n/** Grade boundary — score >= this earns a B */\nconst GRADE_B_MIN = 80;\n/** Grade boundary — score >= this earns a C (matches quality gate threshold) */\nconst GRADE_C_MIN = 70;\n/** Grade boundary — score >= this earns a D */\nconst GRADE_D_MIN = 50;\n\n// ─── Analysis-depth keyword sets ─────────────────────────────────────────────\n\n/** Keywords indicating political context discussion */\nconst POLITICAL_CONTEXT_KEYWORDS: ReadonlyArray<string> = [\n  'political',\n  'coalition',\n  'majority',\n  'opposition',\n  'parliament',\n];\n\n/** Keywords indicating coalition-dynamics analysis */\nconst COALITION_DYNAMICS_KEYWORDS: ReadonlyArray<string> = [\n  'coalition',\n  'alliance',\n  'EPP',\n  'S&D',\n  'Renew',\n  'Greens',\n];\n\n/** Keywords indicating historical context */\nconst HISTORICAL_CONTEXT_KEYWORDS: ReadonlyArray<string> = [\n  'historically',\n  'since 2019',\n  'previous term',\n  'compared to',\n];\n\n/** Keywords indicating evidence-based reasoning */\nconst EVIDENCE_BASED_KEYWORDS: ReadonlyArray<string> = [\n  'according to',\n  'data shows',\n  'evidence suggests',\n  'figures',\n];\n\n/** Keywords indicating scenario planning or projections */\nconst SCENARIO_PLANNING_KEYWORDS: ReadonlyArray<string> = [\n  'if ',\n  'could',\n  'scenario',\n  'projection',\n  'forecast',\n];\n\n/** Keywords indicating stated confidence levels */\nconst CONFIDENCE_LEVEL_KEYWORDS: ReadonlyArray<string> = [\n  'likely',\n  'probably',\n  'uncertain',\n  'confidence',\n];\n\n// ─── Coalition analysis depth keywords ────────────────────────────────────────\n\n/**\n * Extended keywords specifically for deep coalition analysis sub-scoring.\n * These go beyond basic coalition detection to identify in-depth alliance\n * and cross-group dynamics analysis.\n */\nconst COALITION_ANALYSIS_DEPTH_KEYWORDS: ReadonlyArray<string> = [\n  'inter-group',\n  'cross-party',\n  'grand coalition',\n  'qualified majority',\n  'blocking minority',\n  'rapporteur',\n  'shadow rapporteur',\n  'trilogue',\n  'coreper',\n  'vote margin',\n];\n\n// ─── Temporal coverage keyword sets ──────────────────────────────────────────\n\n/** Keywords indicating past context or historical references */\nconst TEMPORAL_PAST_KEYWORDS: ReadonlyArray<string> = [\n  'historically',\n  'previous',\n  'since 2019',\n  'past',\n  'earlier',\n  'last year',\n  'former',\n  'compared to',\n  'traditionally',\n];\n\n/** Keywords indicating current state or present analysis */\nconst TEMPORAL_CURRENT_KEYWORDS: ReadonlyArray<string> = [\n  'currently',\n  'present',\n  'today',\n  'ongoing',\n  'at present',\n  'this week',\n  'now',\n  'recent',\n  'latest',\n];\n\n/** Keywords indicating forward outlook, forecasts, or scenarios */\nconst TEMPORAL_FORWARD_KEYWORDS: ReadonlyArray<string> = [\n  'forecast',\n  'projection',\n  'expected',\n  'anticipated',\n  'upcoming',\n  'future',\n  'scenario',\n  'could',\n  'will be',\n  'will likely',\n  'is expected to',\n  'outlook',\n  'predict',\n];\n\n// ─── Procedure reference pattern ──────────────────────────────────────────────\n\n/**\n * Pattern matching EP legislative procedure references.\n * Covers formats like 2024/0001(COD), 2023/0123(NLE), 2022/0456(APP).\n */\nconst PROCEDURE_REF_PATTERN = /\\b\\d{4}\\/\\d+\\([A-Z]{2,4}\\)/gu;\n\n/** Pattern matching TA-format adopted text references specifically */\nconst TA_NUMBER_PATTERN = /\\bTA-\\d+-\\d+-\\d+\\b/gu;\n\n// ─── Stakeholder detection sets ───────────────────────────────────────────────\n\n/** All known stakeholder categories and their keyword signals */\nconst STAKEHOLDER_KEYWORDS: ReadonlyArray<{ name: string; keywords: ReadonlyArray<string> }> = [\n  { name: 'MEPs/Parliament', keywords: ['MEP', 'parliament', 'parliamentarian', 'deputy'] },\n  {\n    name: 'Commission',\n    keywords: ['Commission', 'commissioner', 'European Commission'],\n  },\n  { name: 'Council', keywords: ['Council', 'presidency', 'member states'] },\n  {\n    name: 'member states/governments',\n    keywords: ['government', 'national', 'member state', 'minister'],\n  },\n  {\n    name: 'civil society/NGOs',\n    keywords: ['civil society', 'NGO', 'non-governmental', 'advocacy'],\n  },\n  {\n    name: 'industry/business',\n    keywords: ['industry', 'business', 'corporate', 'sector', 'company'],\n  },\n  { name: 'citizens', keywords: ['citizen', 'public', 'voter', 'constituent'] },\n  { name: 'media', keywords: ['media', 'press', 'journalist', 'outlet'] },\n];\n\n// ─── Placeholder / generic-phrase patterns ────────────────────────────────────\n\n/** Patterns indicating vague or un-replaced generic phrases */\nconst GENERIC_PHRASE_PATTERNS: ReadonlyArray<RegExp> = [\n  /various committees/iu,\n  /several MEPs/iu,\n  /multiple documents/iu,\n  /some countries/iu,\n];\n\n// ─── EP document-reference pattern ───────────────────────────────────────────\n\n/**\n * Patterns matching known EP document reference formats.\n * Uses separate patterns to avoid alternation complexity flagged by security/detect-unsafe-regex.\n * Covers: TA-10-2026-0123, PE-123, PE-123.456, A9-0123, B9-0123, C9-0123, P9_TA(2024)0001\n * Excludes broad matches like EU-27 or EEA-32.\n */\nconst EP_DOC_PATTERNS: ReadonlyArray<RegExp> = [\n  /\\bTA-\\d+-\\d+-\\d+\\b/gu, // TA-10-2026-0001 (TA prefix + three numeric segments)\n  /\\bPE-\\d+\\.\\d+\\b/gu, // PE-123.456 (dotted PE reference)\n  /\\bPE-\\d+(?!\\.\\d)\\b/gu, // PE-123 (simple PE reference, excludes dotted)\n  /\\b[A-C]\\d-\\d+\\b/gu, // A9-0123, B9-0002, C9-0003 (variable-length digits)\n  /\\bP\\d_TA\\(\\d{4}\\)\\d+\\b/gu, // P9_TA(2024)0001\n];\n\n/** CSS class selector for deep-analysis sections (extracted to avoid duplication) */\nconst CLASS_DEEP_ANALYSIS = 'class=\"deep-analysis\"';\n\n/** Pattern to extract a leading ISO date (YYYY-MM-DD) from an article identifier */\nconst ARTICLE_DATE_PATTERN = /^(\\d{4}-\\d{2}-\\d{2})/u;\n\n// ─── HTML entity map ──────────────────────────────────────────────────────────\n\n/** Common HTML entities to decode when extracting plain text */\nconst HTML_ENTITY_MAP: Readonly<Record<string, string>> = {\n  '&amp;': '&',\n  '&lt;': '<',\n  '&gt;': '>',\n  '&quot;': '\"',\n  '&#39;': \"'\",\n  '&apos;': \"'\",\n  '&nbsp;': ' ',\n};\n\n/** Pattern matching named and numeric HTML entities */\nconst HTML_ENTITY_PATTERN = /&(?:#(\\d+)|#x([0-9a-fA-F]+)|([a-zA-Z]+));/gu;\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\n\n/**\n * Decode HTML entities in a string.\n * Handles named entities (&amp;, &lt;, &gt;, &quot;, &#39;, &apos;, &nbsp;)\n * and numeric references (&#123;, &#x7B;).\n *\n * @param text - Text possibly containing HTML entities\n * @returns Text with entities replaced by their character equivalents\n */\nfunction decodeHtmlEntities(text: string): string {\n  return text.replace(HTML_ENTITY_PATTERN, (match, decimal, hex, named) => {\n    if (decimal !== undefined) {\n      const cp = parseInt(decimal, 10);\n      try {\n        return String.fromCodePoint(cp);\n      } catch {\n        return match;\n      }\n    }\n    if (hex !== undefined) {\n      const cp = parseInt(hex, 16);\n      try {\n        return String.fromCodePoint(cp);\n      } catch {\n        return match;\n      }\n    }\n    if (named !== undefined) {\n      const key = `&${named};`;\n      return HTML_ENTITY_MAP[key] ?? match;\n    }\n    return match;\n  });\n}\n\n/**\n * Count non-overlapping occurrences of a CSS class or id string in HTML.\n *\n * @param html - HTML string to search\n * @param selector - CSS class or id token to count (e.g. `class=\"metric\"`)\n * @returns Number of occurrences found\n */\nfunction countOccurrences(html: string, selector: string): number {\n  let count = 0;\n  let index = html.indexOf(selector);\n  while (index !== -1) {\n    count++;\n    index = html.indexOf(selector, index + selector.length);\n  }\n  return count;\n}\n\n/** Pattern matching all class attribute values in HTML */\nconst CLASS_ATTR_PATTERN = /class=\"([^\"]*)\"/gu;\n\n/**\n * Check whether any `class=\"…\"` attribute in the HTML contains the given token\n * as an exact CSS class (whitespace-delimited). Unlike `\\b` word-boundary matching,\n * this prevents false positives from hyphenated classes (e.g. `dashboard-grid` does\n * not match `dashboard`).\n *\n * @param html - HTML string to scan\n * @param token - Exact CSS class name to detect\n * @returns true if an exact class token match is found\n */\nfunction hasExactClassToken(html: string, token: string): boolean {\n  CLASS_ATTR_PATTERN.lastIndex = 0;\n  let match: RegExpExecArray | null;\n  while ((match = CLASS_ATTR_PATTERN.exec(html)) !== null) {\n    const value = match[1] ?? '';\n    const classes = value.split(/\\s+/);\n    if (classes.includes(token)) return true;\n  }\n  return false;\n}\n\n/**\n * Count the number of elements whose `class` attribute contains the given token\n * as an exact CSS class (whitespace-delimited). Unlike simple substring counting,\n * this correctly counts multi-class attributes like `class=\"metric-card pipeline-on-track\"`.\n *\n * @param html - HTML string to scan\n * @param token - Exact CSS class name to count\n * @returns Number of elements with the given class token\n */\nfunction countExactClassToken(html: string, token: string): number {\n  CLASS_ATTR_PATTERN.lastIndex = 0;\n  let count = 0;\n  let match: RegExpExecArray | null;\n  while ((match = CLASS_ATTR_PATTERN.exec(html)) !== null) {\n    const value = match[1] ?? '';\n    const classes = value.split(/\\s+/);\n    if (classes.includes(token)) count++;\n  }\n  return count;\n}\n\n/**\n * Check whether at least one keyword from a list is present in a text string.\n *\n * Uses a leading word-boundary anchor (`\\b`) so that keywords like \"national\"\n * do not false-match inside longer words like \"international\", while still\n * matching inflected forms such as \"citizens\" for the keyword \"citizen\".\n *\n * @param text - Text to search (comparison is case-insensitive)\n * @param keywords - Keywords to look for\n * @returns true if any keyword is found\n */\nfunction containsAnyKeyword(text: string, keywords: ReadonlyArray<string>): boolean {\n  return keywords.some((kw) => {\n    const escaped = kw.replace(/[.*+?^${}()|[\\]\\\\]/gu, '\\\\$&');\n    const pattern = new RegExp(`\\\\b${escaped}`, 'iu');\n    return pattern.test(text);\n  });\n}\n\n// stripScriptBlocks is imported from html-sanitize.ts\n\n/**\n * Extract the plain text content from the `<main>` element of an HTML string.\n * Falls back to the full document when no `<main>` is found.\n * Decodes HTML entities so keyword detection works on real article HTML.\n *\n * @param html - Raw HTML string\n * @returns Plain text stripped of tags, scripts, and HTML entities\n */\nfunction extractPlainText(html: string): string {\n  const mainMatch = /<main[^>]*>([\\s\\S]*?)<\\/main>/u.exec(html);\n  const source = mainMatch?.[1] ?? html;\n  const stripped = stripScriptBlocks(source)\n    .replace(/<[^>]+>/gu, ' ')\n    .replace(/\\s+/gu, ' ')\n    .trim();\n  return decodeHtmlEntities(stripped);\n}\n\n/**\n * Tokens that identify a `<section>` element as analysis content.\n * Non-analysis sections (e.g. `article-sources`, `sitemap-section`) are excluded.\n */\nconst ANALYSIS_SECTION_TOKENS: ReadonlyArray<string> = [\n  'analysis',\n  'analysis-section',\n  'deep-analysis',\n  'swot-analysis',\n  'dashboard',\n  'mindmap-section',\n  'sankey-section',\n];\n\n/** Pattern to extract the class attribute value from a single HTML tag */\nconst CLASS_VALUE_PATTERN = /class=\"([^\"]*)\"/iu;\n\n/**\n * Count structural analysis sections in HTML.\n * Only counts `<section>` elements whose class attribute contains a known\n * analysis-related token, preventing inflation from non-analysis sections\n * like sources or footer wrappers.\n *\n * @param html - Raw HTML string\n * @returns Number of analysis-content sections found\n */\nfunction countAnalysisSections(html: string): number {\n  const SECTION_TAG = /<section\\b[^>]*>/giu;\n  let count = 0;\n  let m: RegExpExecArray | null;\n  while ((m = SECTION_TAG.exec(html)) !== null) {\n    const tag = m[0];\n    const cv = CLASS_VALUE_PATTERN.exec(tag);\n    if (cv?.[1]) {\n      const tokens = cv[1].split(/\\s+/).filter(Boolean);\n      if (tokens.some((t) => ANALYSIS_SECTION_TOKENS.includes(t))) {\n        count++;\n      }\n    }\n  }\n  return count;\n}\n\n/**\n * Count `<li>` elements inside containers matching the given class attribute.\n * Used to count evidence items in `<ul class=\"perspective-evidence\"><li>…</li></ul>`\n * and `<ul class=\"evidence-refs\"><li lang=\"…\">…</li></ul>` structures produced by\n * the deep-analysis and stakeholder perspective generators.\n *\n * Locates each container by its class attribute, determines the enclosing element\n * tag name, finds the end of the opening tag (`>`), and extracts content up to\n * the balanced closing tag for that specific element — ensuring correct scoping\n * even when the container is a `<ul>` (which `findBalancedContent` does not track).\n *\n * Counts both `<li>` (bare) and `<li ` (with attributes like `lang=\"…\"`) to handle\n * attributed list items while avoiding false matches on `<link>` or `<listing>` tags.\n *\n * @param html - HTML string to search\n * @param containerClass - Class attribute string to match (e.g. `class=\"perspective-evidence\"`)\n * @returns Number of `<li>` children found across all matching containers\n */\nfunction countListItemsInClass(html: string, containerClass: string): number {\n  let total = 0;\n  let idx = html.indexOf(containerClass);\n  while (idx !== -1) {\n    const content = extractContainerContent(html, idx, containerClass);\n    if (content) {\n      // Count both bare <li> and attributed <li ...> (e.g. <li lang=\"en\">)\n      // Using '<li>' and '<li ' avoids false matches on <link> or <listing>\n      total += countOccurrences(content, '<li>') + countOccurrences(content, '<li ');\n    }\n    idx = html.indexOf(containerClass, idx + 1);\n  }\n  return total;\n}\n\n/**\n * Count only direct (top-level) `<li>` children of the first container matching\n * the given class attribute prefix. Tracks nesting depth of list elements\n * (`<ul>`, `<ol>`) so that `<li>` tags inside nested sublists are excluded.\n *\n * This avoids inflating the count for deep-but-narrow structures where nested\n * subnodes are also wrapped in `<li>` elements.\n *\n * @param html - HTML string to search\n * @param containerClassPrefix - Class attribute prefix to locate (e.g. `class=\"mindmap-branches`)\n * @returns Number of direct `<li>` children in the first matching container\n */\nfunction countDirectListChildren(html: string, containerClassPrefix: string): number {\n  const idx = html.indexOf(containerClassPrefix);\n  if (idx < 0) return 0;\n  // Complete the attribute value to find the closing quote\n  const quoteEnd = html.indexOf('\"', idx + containerClassPrefix.length);\n  if (quoteEnd < 0) return 0;\n  const fullAttr = html.slice(idx, quoteEnd + 1);\n  const content = extractContainerContent(html, idx, fullAttr);\n  if (!content) return 0;\n\n  // Scan through the container content tracking list nesting depth.\n  // Only count <li> tags at depth 0 (direct children of the container).\n  let count = 0;\n  let listDepth = 0;\n  const tagPattern = /<(\\/?)(?:ul|ol|li)(?=[\\s>/])/giu;\n  let m = tagPattern.exec(content);\n  while (m) {\n    const isClosing = m[1] === '/';\n    const tagLower = m[0].replace(/^<\\/?/u, '').toLowerCase();\n    if (tagLower === 'ul' || tagLower === 'ol') {\n      if (isClosing) {\n        listDepth = Math.max(0, listDepth - 1);\n      } else {\n        listDepth++;\n      }\n    } else if (tagLower === 'li' && !isClosing && listDepth === 0) {\n      count++;\n    }\n    m = tagPattern.exec(content);\n  }\n  return count;\n}\n\n/**\n * attribute match at the given position. Identifies the tag name by searching\n * backwards for `<tagname`, then finds the end of the opening tag (`>`), and\n * uses balanced tag matching on that specific element to locate the matching\n * closing tag.\n *\n * @param html - Full HTML string\n * @param attrIdx - Index where the matched attribute starts within `html`\n * @param attr - The attribute string that was matched\n * @returns Inner HTML of the container, or empty string if extraction fails\n */\nfunction extractContainerContent(html: string, attrIdx: number, attr: string): string {\n  // Search backwards from the attribute to find the opening `<`\n  let openBracket = attrIdx - 1;\n  while (openBracket >= 0 && html[openBracket] !== '<') openBracket--;\n  if (openBracket < 0) return '';\n\n  // Extract the tag name (e.g. \"ul\", \"div\", \"section\")\n  const tagSlice = html.slice(openBracket + 1, attrIdx).trim();\n  const tagNameMatch = /^([a-z][a-z0-9]*)/iu.exec(tagSlice);\n  if (!tagNameMatch) return '';\n  const tagName = tagNameMatch[1];\n\n  // Find the end of the opening tag\n  const closeAngle = html.indexOf('>', attrIdx + attr.length);\n  if (closeAngle < 0) return '';\n  const contentStart = closeAngle + 1;\n\n  // Balanced matching for this specific tag name\n  // tagName is validated by /^([a-z][a-z0-9]*)/ — alphanumeric only, safe for RegExp\n  // eslint-disable-next-line security/detect-non-literal-regexp\n  const balancePattern = new RegExp(`</?${tagName}[\\\\s>/]`, 'giu');\n  balancePattern.lastIndex = contentStart;\n  let depth = 1;\n  let m = balancePattern.exec(html);\n  while (m) {\n    if (m[0].startsWith('</')) {\n      depth--;\n      if (depth === 0) return html.slice(contentStart, m.index);\n    } else {\n      depth++;\n    }\n    m = balancePattern.exec(html);\n  }\n  return '';\n}\n\n/**\n * Count evidence and document references in HTML.\n * Detects evidence markers from the actual generator output:\n * - `<ul class=\"perspective-evidence\"><li>…</li></ul>` — deep-analysis evidence items\n * - `<ul class=\"evidence-refs\"><li lang=\"…\">…</li></ul>` — reasoning-chain evidence items\n * - `class=\"swot-ref-evidence\"` — SWOT cross-reference evidence markers\n * - `class=\"evidence\"` — generic evidence markers (legacy / tests)\n * - `data-reference` attributes\n * - EP document reference codes (TA-, PE-, A9-, P9_TA patterns)\n *\n * Strips `<script>` blocks once up front so that JSON-LD metadata and other\n * inline scripts do not inflate any evidence counts.\n *\n * @param html - Raw HTML string\n * @returns Number of evidence references found\n */\nfunction countEvidenceRefs(html: string): number {\n  // Strip script blocks (e.g. JSON-LD) once for all evidence counting\n  // to avoid inflated counts from matching substrings inside scripts.\n  const htmlNoScripts = stripScriptBlocks(html);\n  // Count <li> items inside perspective-evidence containers (deep-analysis generator)\n  const perspectiveEvidenceItems = countListItemsInClass(\n    htmlNoScripts,\n    'class=\"perspective-evidence\"'\n  );\n  // Count <li> items inside evidence-refs containers (reasoning-chain generator)\n  const evidenceRefsItems = countListItemsInClass(htmlNoScripts, 'class=\"evidence-refs\"');\n  // Count SWOT cross-reference evidence markers (swot-content generator)\n  const swotRefEvidence = countOccurrences(htmlNoScripts, 'class=\"swot-ref-evidence\"');\n  // Legacy / generic evidence markers\n  const evidenceClasses = countOccurrences(htmlNoScripts, 'class=\"evidence\"');\n  const dataRefs = countOccurrences(htmlNoScripts, 'data-reference');\n  // EP document reference codes\n  const matched = new Set<string>();\n  for (const pattern of EP_DOC_PATTERNS) {\n    pattern.lastIndex = 0;\n    const hits = htmlNoScripts.match(pattern);\n    if (hits) {\n      for (const hit of hits) {\n        matched.add(hit);\n      }\n    }\n  }\n  const epRefs = matched.size;\n  return (\n    perspectiveEvidenceItems +\n    evidenceRefsItems +\n    swotRefEvidence +\n    evidenceClasses +\n    dataRefs +\n    epRefs\n  );\n}\n\n/**\n * Search backwards from a given index to find the opening `<` of the\n * enclosing HTML tag.\n *\n * @param html - HTML string to search\n * @param fromIdx - Index to start searching backwards from\n * @param fallback - Value to return if no `<` is found\n * @returns Index of the opening `<`, or `fallback` if none is found\n */\nfunction findTagStartBefore(html: string, fromIdx: number, fallback: number): number {\n  let pos = fromIdx - 1;\n  while (pos >= 0 && html[pos] !== '<') pos--;\n  return pos >= 0 ? pos : fallback;\n}\n\n/**\n * Count evidence markers inside deep-analysis sections only, preventing\n * inflation from evidence markers elsewhere in the article.\n *\n * Iterates ALL matching deep-analysis sections (not just the first),\n * so articles with multiple deep-analysis blocks are fully counted.\n * Deduplicates matches across class and id patterns using the opening-tag\n * boundary (`<` position) so that a tag matching both class and id is counted\n * only once.\n *\n * Detects:\n * - `<li>` items inside `<ul class=\"perspective-evidence\">` — real generator output\n * - `class=\"swot-ref-evidence\"` — SWOT cross-reference evidence\n * - `class=\"evidence\"` — generic/legacy evidence markers\n * - `data-reference` attributes\n *\n * @param html - Raw HTML string\n * @returns Evidence count restricted to deep-analysis section(s)\n */\nfunction countDeepAnalysisSectionEvidence(html: string): number {\n  const openPatterns = [/class=\"deep-analysis\"[^>]*>/giu, /id=\"[^\"]*deep[^\"]*\"[^>]*>/giu];\n  let total = 0;\n  const countedTagStarts = new Set<number>();\n  for (const pattern of openPatterns) {\n    pattern.lastIndex = 0;\n    let openMatch = pattern.exec(html);\n    while (openMatch) {\n      const tagStart = findTagStartBefore(html, openMatch.index, openMatch.index);\n      if (!countedTagStarts.has(tagStart)) {\n        countedTagStarts.add(tagStart);\n        const startIdx = openMatch.index + openMatch[0].length;\n        const sectionContent = findBalancedContent(html, startIdx);\n        if (sectionContent) {\n          total +=\n            countListItemsInClass(sectionContent, 'class=\"perspective-evidence\"') +\n            countOccurrences(sectionContent, 'class=\"swot-ref-evidence\"') +\n            countOccurrences(sectionContent, 'class=\"evidence\"') +\n            countOccurrences(sectionContent, 'data-reference');\n        }\n      }\n      openMatch = pattern.exec(html);\n    }\n  }\n  return total;\n}\n\n/**\n * Compute the mindmap branch count.\n *\n * Priority order:\n * 1. `data-branch-count=\"N\"` attribute on `.mindmap-container` — the generators\n *    set this to the exact number of top-level branches (`config.branches.length`\n *    or `domainNodes.length`), so it is the most reliable source.\n * 2. `class=\"mindmap-branch\"` element count.\n * 3. Direct `<li>` children of the first `.mindmap-branches` container (layer-1\n *    only), using nesting-depth tracking to exclude nested subnodes.\n *\n * @param html - Raw HTML string\n * @returns Number of mindmap branches detected\n */\nfunction computeMindmapBranches(html: string): number {\n  // 1. Prefer the explicit data-branch-count attribute set by the generators\n  const attrMatch = /data-branch-count=\"(\\d+)\"/u.exec(html);\n  if (attrMatch?.[1]) {\n    const parsed = parseInt(attrMatch[1], 10);\n    if (parsed > 0) return parsed;\n  }\n\n  // 2. Count class=\"mindmap-branch\" elements\n  const branchCount = countOccurrences(html, 'class=\"mindmap-branch\"');\n  if (branchCount > 0) return branchCount;\n\n  // 3. Count only direct <li> children of the mindmap-branches container\n  return countDirectListChildren(html, 'class=\"mindmap-branches');\n}\n\n/**\n * Walk forward from a starting position to find balanced closing tag content.\n *\n * @param html - HTML string\n * @param startIdx - Position right after the opening tag\n * @returns Content between the opening and its balanced closing tag, or empty string\n */\nfunction findBalancedContent(html: string, startIdx: number): string {\n  let depth = 1;\n  const closeTagPattern = /<\\/?(?:div|section|article)[\\s>/]/giu;\n  closeTagPattern.lastIndex = startIdx;\n  let tagMatch = closeTagPattern.exec(html);\n  while (tagMatch) {\n    if (tagMatch[0].startsWith('</')) {\n      depth--;\n      if (depth === 0) return html.slice(startIdx, tagMatch.index);\n    } else {\n      depth++;\n    }\n    tagMatch = closeTagPattern.exec(html);\n  }\n  return '';\n}\n\n/**\n * Check whether generic/placeholder phrases appear in the article text.\n *\n * @param html - Raw HTML string\n * @returns true if any generic phrase pattern is detected\n */\nfunction hasGenericPhrases(html: string): boolean {\n  return GENERIC_PHRASE_PATTERNS.some((pattern) => pattern.test(html));\n}\n\n/**\n * Clamp a numeric value between 0 and 100.\n *\n * @param value - Value to clamp\n * @returns Value clamped to [0, 100]\n */\nfunction clamp100(value: number): number {\n  return Math.max(0, Math.min(100, value));\n}\n\n// ─── Analysis depth assessment ────────────────────────────────────────────────\n\n/**\n * Compute a coalition analysis depth sub-score (0–100) from plain text.\n *\n * Scans for extended coalition analysis keywords beyond basic coalition\n * detection, reflecting how deeply inter-group dynamics are analysed.\n *\n * @param text - Pre-extracted plain text\n * @returns Coalition analysis sub-score 0–100\n */\nfunction computeCoalitionAnalysisScore(text: string): number {\n  const present = COALITION_ANALYSIS_DEPTH_KEYWORDS.filter((kw) =>\n    containsAnyKeyword(text, [kw])\n  ).length;\n  return clamp100(Math.round((present / COALITION_ANALYSIS_DEPTH_KEYWORDS.length) * 100));\n}\n\n/**\n * Score temporal coverage across three dimensions: past context, current state,\n * and forward outlook. An article covering all three temporal dimensions\n * demonstrates comprehensive, time-aware analysis.\n *\n * @param htmlOrText - Raw HTML string or pre-extracted plain text\n * @param preExtracted - If true, treat `htmlOrText` as already-extracted plain text\n * @returns Temporal coverage score with per-dimension flags and composite\n */\nexport function scoreTemporalCoverage(\n  htmlOrText: string,\n  preExtracted = false\n): TemporalCoverageScore {\n  const text = preExtracted ? htmlOrText : extractPlainText(htmlOrText);\n\n  const pastContextPresent = containsAnyKeyword(text, TEMPORAL_PAST_KEYWORDS);\n  const currentStatePresent = containsAnyKeyword(text, TEMPORAL_CURRENT_KEYWORDS);\n  const forwardOutlookPresent = containsAnyKeyword(text, TEMPORAL_FORWARD_KEYWORDS);\n\n  const presentCount = [pastContextPresent, currentStatePresent, forwardOutlookPresent].filter(\n    Boolean\n  ).length;\n  const score = clamp100(Math.round((presentCount / 3) * 100));\n\n  return { pastContextPresent, currentStatePresent, forwardOutlookPresent, score };\n}\n\n/**\n * Score the cross-reference density of an article by counting unique EP document\n * IDs, procedure references, and TA-numbers. Higher density indicates stronger\n * grounding in official EP documents.\n *\n * @param html - Raw HTML string of the article\n * @returns Cross-reference density score with per-category counts and composite\n */\nexport function scoreCrossReferenceDensity(html: string): CrossReferenceDensityScore {\n  const htmlNoScripts = stripScriptBlocks(html);\n\n  // Count unique EP document IDs (reuses EP_DOC_PATTERNS)\n  const epDocSet = new Set<string>();\n  for (const pattern of EP_DOC_PATTERNS) {\n    pattern.lastIndex = 0;\n    const hits = htmlNoScripts.match(pattern);\n    if (hits) {\n      for (const hit of hits) epDocSet.add(hit);\n    }\n  }\n  const epDocumentIds = epDocSet.size;\n\n  // Count TA-format references specifically\n  TA_NUMBER_PATTERN.lastIndex = 0;\n  const taMatches = htmlNoScripts.match(TA_NUMBER_PATTERN) ?? [];\n  const taNumbers = new Set(taMatches).size;\n\n  // Count procedure references (e.g. 2024/0001(COD))\n  PROCEDURE_REF_PATTERN.lastIndex = 0;\n  const procMatches = htmlNoScripts.match(PROCEDURE_REF_PATTERN) ?? [];\n  const procedureReferences = new Set(procMatches).size;\n\n  // Avoid double-counting TA references already present in EP_DOC_PATTERNS by\n  // scoring against the union of all matched reference identifiers.\n  const totalReferenceSet = new Set<string>([...epDocSet, ...taMatches, ...procMatches]);\n  const totalReferences = totalReferenceSet.size;\n\n  // Score: 10 references = 100 points (same scale as EVIDENCE_MAX)\n  const score = clamp100(Math.round((totalReferences / EVIDENCE_MAX) * 100));\n\n  return { epDocumentIds, procedureReferences, taNumbers, totalReferences, score };\n}\n\n/**\n * Assess the analytical depth of an article by detecting keyword signals.\n *\n * Accepts either raw HTML or pre-extracted plain text. When called from\n * {@link scoreArticleQuality} the text is already extracted, avoiding\n * redundant HTML stripping.\n *\n * @param htmlOrText - Raw HTML string or pre-extracted plain text\n * @param preExtracted - If true, treat `htmlOrText` as already-extracted plain text\n * @returns Analysis depth score with per-dimension flags and composite score\n */\nexport function assessAnalysisDepth(htmlOrText: string, preExtracted = false): AnalysisDepthScore {\n  const text = preExtracted ? htmlOrText : extractPlainText(htmlOrText);\n\n  const politicalContextPresent = containsAnyKeyword(text, POLITICAL_CONTEXT_KEYWORDS);\n  const coalitionDynamicsAnalyzed = containsAnyKeyword(text, COALITION_DYNAMICS_KEYWORDS);\n  const historicalContextProvided = containsAnyKeyword(text, HISTORICAL_CONTEXT_KEYWORDS);\n  const evidenceBasedConclusions = containsAnyKeyword(text, EVIDENCE_BASED_KEYWORDS);\n  const scenarioPlanning = containsAnyKeyword(text, SCENARIO_PLANNING_KEYWORDS);\n  const confidenceLevelsIndicated = containsAnyKeyword(text, CONFIDENCE_LEVEL_KEYWORDS);\n\n  const dimensions = [\n    politicalContextPresent,\n    coalitionDynamicsAnalyzed,\n    historicalContextProvided,\n    evidenceBasedConclusions,\n    scenarioPlanning,\n    confidenceLevelsIndicated,\n  ];\n  const presentCount = dimensions.filter(Boolean).length;\n  const score = clamp100(Math.round((presentCount / dimensions.length) * 100));\n\n  // Coalition analysis depth sub-score: bonus metric beyond basic flag\n  const coalitionAnalysisScore = computeCoalitionAnalysisScore(text);\n\n  return {\n    politicalContextPresent,\n    coalitionDynamicsAnalyzed,\n    historicalContextProvided,\n    evidenceBasedConclusions,\n    scenarioPlanning,\n    confidenceLevelsIndicated,\n    coalitionAnalysisScore,\n    score,\n  };\n}\n\n// ─── Stakeholder coverage assessment ─────────────────────────────────────────\n\n/**\n * Assess how many stakeholder perspectives are covered in the article text.\n *\n * Accepts either raw HTML or pre-extracted plain text. When called from\n * {@link scoreArticleQuality} the text is already extracted, avoiding\n * redundant HTML stripping.\n *\n * @param htmlOrText - Raw HTML string or pre-extracted plain text\n * @param preExtracted - If true, treat `htmlOrText` as already-extracted plain text\n * @returns Stakeholder coverage assessment with present/missing lists and scores\n */\nexport function assessStakeholderCoverage(\n  htmlOrText: string,\n  preExtracted = false\n): StakeholderCoverage {\n  const text = preExtracted ? htmlOrText : extractPlainText(htmlOrText);\n\n  const perspectivesPresent: string[] = [];\n  const perspectivesMissing: string[] = [];\n\n  for (const stakeholder of STAKEHOLDER_KEYWORDS) {\n    if (containsAnyKeyword(text, stakeholder.keywords)) {\n      perspectivesPresent.push(stakeholder.name);\n    } else {\n      perspectivesMissing.push(stakeholder.name);\n    }\n  }\n\n  const total = STAKEHOLDER_KEYWORDS.length;\n  const balanceScore = clamp100(Math.round((perspectivesPresent.length / total) * 100));\n\n  // Reasoning quality: bonus for not using generic phrases, penalty if they are present\n  const genericPenalty = hasGenericPhrases(text) ? 20 : 0;\n  const baseReasoningScore = balanceScore;\n  const reasoningQuality = clamp100(baseReasoningScore - genericPenalty);\n\n  return {\n    perspectivesPresent,\n    perspectivesMissing,\n    balanceScore,\n    reasoningQuality,\n  };\n}\n\n// ─── Visualization quality assessment ────────────────────────────────────────\n\n/**\n * Assess the quality of embedded visual elements (SWOT, dashboard, mindmap, deep analysis).\n *\n * @param html - Raw HTML string of the article\n * @returns Visualization quality assessment with per-element flags and composite score\n */\nexport function assessVisualizationQuality(html: string): VisualizationQuality {\n  // SWOT: exact class-token match supports multi-class attributes\n  // (e.g. class=\"swot-analysis swot-multidimensional\")\n  const swotPresent =\n    hasExactClassToken(html, 'swot-analysis') || html.includes('id=\"swot-analysis\"');\n  // Partial match: quadrant classes include a variant suffix (e.g. \"swot-quadrant swot-strengths\")\n  const swotDimensions =\n    countOccurrences(html, 'swot-quadrant') + countOccurrences(html, 'data-dimension');\n\n  // Dashboard: exact class-token match prevents false positives from hyphenated\n  // classes like \"dashboard-grid\", \"dashboard-panel\", \"dashboard-chart\"\n  const dashboardPresent = hasExactClassToken(html, 'dashboard') || html.includes('id=\"dashboard\"');\n  const dashboardMetrics =\n    countExactClassToken(html, 'metric-card') + countExactClassToken(html, 'dashboard-metric');\n  // Trend indicators: metric-trend-up/-down/-stable classes, or arrow symbols\n  const dashboardTrends =\n    html.includes('class=\"metric-trend-') || html.includes('↑') || html.includes('↓');\n\n  // Mindmap: exact class-token match supports multi-class attributes\n  const mindmapPresent =\n    hasExactClassToken(html, 'mindmap-section') ||\n    hasExactClassToken(html, 'mindmap-container') ||\n    html.includes('id=\"mindmap\"');\n  const mindmapBranches = mindmapPresent ? computeMindmapBranches(html) : 0;\n\n  const deepAnalysisPresent =\n    html.includes(CLASS_DEEP_ANALYSIS) || /id=\"[^\"]*deep[^\"]*\"/iu.test(html);\n  // Restrict evidence counting to deep-analysis section(s) only to avoid\n  // inflating the metric with evidence markers elsewhere in the article.\n  const deepAnalysisEvidence = deepAnalysisPresent ? countDeepAnalysisSectionEvidence(html) : 0;\n\n  const score = computeVisualizationScore({\n    swotPresent,\n    swotDimensions,\n    dashboardPresent,\n    dashboardMetrics,\n    dashboardTrends,\n    mindmapPresent,\n    mindmapBranches,\n    deepAnalysisPresent,\n    deepAnalysisEvidence,\n  });\n\n  return {\n    swotPresent,\n    swotDimensions,\n    dashboardPresent,\n    dashboardMetrics,\n    dashboardTrends,\n    mindmapPresent,\n    mindmapBranches,\n    deepAnalysisPresent,\n    deepAnalysisEvidence,\n    score,\n  };\n}\n\n/**\n * Compute a 0–100 composite visualization score from individual element assessments.\n *\n * @param v - Visualization dimensions (without the score field)\n * @returns Composite visualization score clamped to [0, 100]\n */\nfunction computeVisualizationScore(v: Omit<VisualizationQuality, 'score'>): number {\n  let score = 0;\n\n  // SWOT contribution (max 25 points)\n  if (v.swotPresent) {\n    score += 10;\n    score += Math.min(15, v.swotDimensions * 5);\n  }\n\n  // Dashboard contribution (max 25 points)\n  if (v.dashboardPresent) {\n    score += 10;\n    score += Math.min(10, v.dashboardMetrics * 2);\n    if (v.dashboardTrends) score += 5;\n  }\n\n  // Mindmap contribution (max 25 points)\n  if (v.mindmapPresent) {\n    score += 10;\n    score += Math.min(15, v.mindmapBranches * 5);\n  }\n\n  // Deep analysis contribution (max 25 points)\n  if (v.deepAnalysisPresent) {\n    score += 10;\n    score += Math.min(15, v.deepAnalysisEvidence * 3);\n  }\n\n  return clamp100(score);\n}\n\n// ─── Overall score calculation ────────────────────────────────────────────────\n\n/**\n * Compute the weighted overall quality score (0–100) from component scores.\n *\n * Weights (aligned with ai-driven-analysis-guide.md):\n * - Analysis depth: 25 %\n * - Evidence references: 25 %\n * - Visualization / structural compliance: 20 %\n * - Word count / actionable intelligence: 15 %\n * - Stakeholder balance / political neutrality: 15 %\n *\n * @param depth - Analysis depth score object\n * @param coverage - Stakeholder coverage score object\n * @param viz - Visualization quality score object\n * @param wordCount - Plain-text word count of the article\n * @param evidenceRefs - Number of evidence/document references\n * @returns Overall quality score clamped to [0, 100]\n */\nexport function calculateOverallScore(\n  depth: AnalysisDepthScore,\n  coverage: StakeholderCoverage,\n  viz: VisualizationQuality,\n  wordCount: number,\n  evidenceRefs: number\n): number {\n  const wordCountScore = clamp100(\n    Math.round(((wordCount - WORD_COUNT_MIN) / (WORD_COUNT_MAX - WORD_COUNT_MIN)) * 100)\n  );\n  const evidenceScore = clamp100(Math.round((evidenceRefs / EVIDENCE_MAX) * 100));\n\n  const overall =\n    depth.score * WEIGHT_ANALYSIS_DEPTH +\n    coverage.balanceScore * WEIGHT_STAKEHOLDER +\n    viz.score * WEIGHT_VISUALIZATION +\n    wordCountScore * WEIGHT_WORD_COUNT +\n    evidenceScore * WEIGHT_EVIDENCE;\n\n  return clamp100(Math.round(overall));\n}\n\n// ─── Grade assignment ─────────────────────────────────────────────────────────\n\n/**\n * Convert an overall score to a letter grade.\n *\n * @param score - Overall quality score (0–100)\n * @returns Letter grade A–F\n */\nfunction scoreToGrade(score: number): ArticleGrade {\n  if (score >= GRADE_A_MIN) return 'A';\n  if (score >= GRADE_B_MIN) return 'B';\n  if (score >= GRADE_C_MIN) return 'C';\n  if (score >= GRADE_D_MIN) return 'D';\n  return 'F';\n}\n\n// ─── Non-English language adjustments ─────────────────────────────────────────\n\n/**\n * Baseline score assigned to keyword-based analysis dimensions for non-English\n * articles, so that translated content is not systematically penalised by the\n * English-only keyword lists.\n */\nconst NON_ENGLISH_BASELINE = 50;\n\n/**\n * Adjust analysis depth for non-English articles.\n *\n * English keyword lists do not apply to translated text, so we raise the\n * composite score to at least a baseline when the raw keyword scan scored low.\n * This prevents non-English articles from being systematically under-scored.\n *\n * @param depth - Raw analysis depth from keyword scanning\n * @returns Adjusted analysis depth with a baseline floor\n */\nfunction adjustNonEnglishAnalysisDepth(depth: AnalysisDepthScore): AnalysisDepthScore {\n  return {\n    ...depth,\n    score: Math.max(depth.score, NON_ENGLISH_BASELINE),\n  };\n}\n\n/**\n * Adjust stakeholder coverage for non-English articles.\n *\n * English stakeholder keyword lists may not match translated terms, so we apply\n * a baseline floor to prevent systematically low balance and reasoning scores.\n *\n * @param coverage - Raw stakeholder coverage from keyword scanning\n * @returns Adjusted coverage with baseline floors on balance and reasoning scores\n */\nfunction adjustNonEnglishStakeholderCoverage(coverage: StakeholderCoverage): StakeholderCoverage {\n  return {\n    ...coverage,\n    balanceScore: Math.max(coverage.balanceScore, NON_ENGLISH_BASELINE),\n    reasoningQuality: Math.max(coverage.reasoningQuality, NON_ENGLISH_BASELINE),\n  };\n}\n\n// ─── Recommendation generation ────────────────────────────────────────────────\n\n/**\n * Generate actionable improvement recommendations based on a partial quality report.\n *\n * For non-English articles, keyword-based analysis-depth and stakeholder recommendations\n * are omitted because the underlying boolean flags derive from English-only keyword\n * lists, making them unreliable for translated content.\n *\n * @param report - Quality report without the recommendations field\n * @returns Array of recommendation strings (may be empty for high-quality articles)\n */\nexport function generateRecommendations(\n  report: Omit<ArticleQualityReport, 'recommendations'>\n): string[] {\n  const recs: string[] = [];\n  const isEnglish = report.lang === 'en';\n\n  addWordCountRecommendations(report, recs);\n  // Only emit keyword-dependent recommendations for English articles; non-English\n  // articles have baseline-adjusted scores and keyword detection is not reliable.\n  if (isEnglish) {\n    addAnalysisDepthRecommendations(report.analysisDepth, recs);\n    addStakeholderRecommendations(report.stakeholderCoverage, recs);\n  }\n  addVisualizationRecommendations(report.visualizationQuality, recs);\n  addEvidenceRecommendations(report, recs);\n\n  return recs;\n}\n\n/**\n * Add word-count related recommendations.\n *\n * @param report - Partial quality report\n * @param recs - Mutable array to push recommendations into\n */\nfunction addWordCountRecommendations(\n  report: Omit<ArticleQualityReport, 'recommendations'>,\n  recs: string[]\n): void {\n  if (report.wordCount < 500) {\n    recs.push('Expand article length to at least 500 words to improve word-count score');\n  } else if (report.wordCount < WORD_COUNT_MAX) {\n    recs.push(\n      `Increase article depth to ${WORD_COUNT_MAX} words for maximum word-count score (currently ${report.wordCount})`\n    );\n  }\n}\n\n/**\n * Add analysis-depth recommendations.\n *\n * @param depth - Analysis depth score\n * @param recs - Mutable array to push recommendations into\n */\nfunction addAnalysisDepthRecommendations(depth: AnalysisDepthScore, recs: string[]): void {\n  if (!depth.politicalContextPresent) {\n    recs.push(\n      'Add political context: discuss coalitions, vote margins, and current parliamentary dynamics'\n    );\n  }\n  if (!depth.coalitionDynamicsAnalyzed) {\n    recs.push(\n      'Analyse coalition dynamics — name specific inter-group alliances (e.g. EPP+Renew, S&D+Greens) and their vote margins'\n    );\n  }\n  if (!depth.historicalContextProvided) {\n    recs.push(\n      'Provide historical context: compare to previous parliamentary terms, cite precedents or earlier decisions'\n    );\n  }\n  if (!depth.evidenceBasedConclusions) {\n    recs.push(\n      'Support conclusions with specific data: cite vote counts, attendance figures, or referenced EP documents'\n    );\n  }\n  if (!depth.scenarioPlanning) {\n    recs.push(\n      'Include forward-looking analysis: describe at least two scenarios (e.g. \"if the amendment passes\" vs \"if rejected\")'\n    );\n  }\n  if (!depth.confidenceLevelsIndicated) {\n    recs.push(\n      'State confidence levels explicitly (e.g. \"likely\", \"uncertain\") to reflect analytical rigour'\n    );\n  }\n  if (depth.coalitionAnalysisScore !== undefined && depth.coalitionAnalysisScore < 30) {\n    recs.push(\n      'Deepen coalition analysis: add rapporteur names, qualified-majority thresholds, or trilogue references'\n    );\n  }\n}\n\n/**\n * Add stakeholder coverage recommendations.\n *\n * @param coverage - Stakeholder coverage assessment\n * @param recs - Mutable array to push recommendations into\n */\nfunction addStakeholderRecommendations(coverage: StakeholderCoverage, recs: string[]): void {\n  if (coverage.perspectivesMissing.length > 0) {\n    recs.push(\n      `Add perspectives from missing stakeholders: ${coverage.perspectivesMissing.join(', ')}`\n    );\n  }\n  if (coverage.reasoningQuality < 60) {\n    recs.push(\n      'Replace generic phrases (e.g. \"several MEPs\", \"some countries\") with specific named entities'\n    );\n  }\n}\n\n/**\n * Add visualization quality recommendations.\n *\n * @param viz - Visualization quality assessment\n * @param recs - Mutable array to push recommendations into\n */\nfunction addVisualizationRecommendations(viz: VisualizationQuality, recs: string[]): void {\n  if (!viz.swotPresent) {\n    recs.push('Add a SWOT analysis section to strengthen political assessment');\n  } else if (viz.swotDimensions < 3) {\n    recs.push(`Expand SWOT dimensions to at least 3 (currently ${viz.swotDimensions})`);\n  }\n\n  if (!viz.dashboardPresent) {\n    recs.push('Add a data dashboard with key metrics for quantitative support');\n  } else if (viz.dashboardMetrics < 5) {\n    recs.push(`Add more dashboard metrics to reach 5 (currently ${viz.dashboardMetrics})`);\n  }\n\n  if (!viz.mindmapPresent) {\n    recs.push('Add a mindmap to illustrate relationships and conceptual structure');\n  } else if (viz.mindmapBranches < 3) {\n    recs.push(`Add more mindmap branches to reach 3 (currently ${viz.mindmapBranches})`);\n  }\n\n  if (!viz.deepAnalysisPresent) {\n    recs.push('Add deep-analysis sections to provide substantive investigative content');\n  } else if (viz.deepAnalysisEvidence < 3) {\n    recs.push(\n      `Include more evidence items in deep-analysis sections (currently ${viz.deepAnalysisEvidence})`\n    );\n  }\n}\n\n/**\n * Add evidence-reference recommendations.\n *\n * @param report - Partial quality report\n * @param recs - Mutable array to push recommendations into\n */\nfunction addEvidenceRecommendations(\n  report: Omit<ArticleQualityReport, 'recommendations'>,\n  recs: string[]\n): void {\n  if (report.evidenceReferences < 3) {\n    recs.push('Add at least 3 evidence references or EP document citations');\n  } else if (report.evidenceReferences < 10) {\n    recs.push(\n      `Increase evidence references to 10 for maximum evidence score (currently ${report.evidenceReferences})`\n    );\n  }\n}\n\n// ─── Public API ───────────────────────────────────────────────────────────────\n\n/**\n * Score the quality of a generated article and produce a comprehensive report.\n *\n * This is the primary entry point for the quality assessment pipeline.\n *\n * @param html - Complete HTML string of the generated article\n * @param articleId - Unique identifier for the article (typically the filename slug)\n * @param lang - Language code of the article (e.g. `\"en\"`, `\"de\"`)\n * @param articleType - Article category string (e.g. `\"week-ahead\"`)\n * @returns Comprehensive quality report including grade, score and recommendations\n */\nexport function scoreArticleQuality(\n  html: string,\n  articleId: string,\n  lang: string,\n  articleType: string\n): ArticleQualityReport {\n  // Extract plain text once to avoid redundant HTML stripping in sub-assessors\n  const plainText = extractPlainText(html);\n\n  const wordCount = plainText ? plainText.split(' ').length : 0;\n  const analysisSections = countAnalysisSections(html);\n  const evidenceReferences = countEvidenceRefs(html);\n\n  // For non-English articles, keyword-based analysis-depth and stakeholder scoring\n  // uses English keywords which may not appear in translated text. Weight structural\n  // signals (visualization, word count, evidence) more heavily by treating keyword\n  // dimensions as partially present when the language is not English.\n  const isEnglish = lang === 'en';\n\n  const analysisDepth = isEnglish\n    ? assessAnalysisDepth(plainText, true)\n    : adjustNonEnglishAnalysisDepth(assessAnalysisDepth(plainText, true));\n  const stakeholderCoverage = isEnglish\n    ? assessStakeholderCoverage(plainText, true)\n    : adjustNonEnglishStakeholderCoverage(assessStakeholderCoverage(plainText, true));\n  const visualizationQuality = assessVisualizationQuality(html);\n\n  const overallScore = calculateOverallScore(\n    analysisDepth,\n    stakeholderCoverage,\n    visualizationQuality,\n    wordCount,\n    evidenceReferences\n  );\n\n  const grade = scoreToGrade(overallScore);\n  const passesQualityGate = overallScore >= QUALITY_GATE_THRESHOLD;\n\n  // Derive the article's date from its ID when possible (slug format: YYYY-MM-DD-…),\n  // falling back to the current execution date only if the ID does not contain a date prefix.\n  const dateMatch = ARTICLE_DATE_PATTERN.exec(articleId);\n  const date = dateMatch?.[1] ?? new Date().toISOString().split('T')[0] ?? '';\n\n  const partial: Omit<ArticleQualityReport, 'recommendations'> = {\n    articleId,\n    date,\n    type: articleType,\n    lang,\n    wordCount,\n    analysisSections,\n    evidenceReferences,\n    analysisDepth,\n    stakeholderCoverage,\n    visualizationQuality,\n    overallScore,\n    grade,\n    passesQualityGate,\n  };\n\n  const recommendations = generateRecommendations(partial);\n\n  // Wire extended scoring dimensions into recommendations for English articles.\n  // These use keyword/regex detection that is only reliable for English text.\n  if (isEnglish) {\n    const temporal = scoreTemporalCoverage(plainText, true);\n    if (temporal.score < 67) {\n      recommendations.push(\n        'Improve temporal coverage: include past context, current state, and forward-looking outlook for comprehensive time-aware analysis'\n      );\n    }\n    const crossRef = scoreCrossReferenceDensity(html);\n    if (crossRef.totalReferences < 3) {\n      recommendations.push(\n        'Increase cross-reference density: cite EP document IDs, procedure references, or TA numbers to strengthen grounding'\n      );\n    }\n  }\n\n  return { ...partial, recommendations };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/content-metadata.ts","messages":[{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":164,"column":25,"endLine":164,"endColumn":71}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/ContentMetadata\n * @description Content-based metadata analysis for articles.\n *\n * Analyses the **rendered article HTML** to extract insightful titles,\n * descriptions, and keywords.  This runs *after* {@link buildContent}\n * produces the article body so that metadata truly reflects what the\n * reader will see — not mechanical counts from the raw data payload.\n *\n * The analysis extracts:\n * - Headings (h2/h3) as topic indicators\n * - The lede paragraph for a content-based description\n * - Key statistics (numbers, percentages) for title highlights\n * - Entity names (committees, legislation titles) for keywords\n * - Section counts for a structural overview\n */\n\nimport type { ArticleMetadata } from '../generators/strategies/article-strategy.js';\n\n/** Maximum length for the enriched description */\nconst MAX_DESCRIPTION_LENGTH = 200;\n\n/**\n * Minimum position (as fraction of MAX_DESCRIPTION_LENGTH) for a\n * sentence-boundary truncation point.  If the last sentence break\n * is before this threshold, we fall back to hard truncation with `...`.\n * This ensures the truncated description retains at least half its\n * intended content.\n */\nconst MIN_SENTENCE_TRUNCATION_RATIO = 0.5;\n\n/** Maximum number of keywords to emit */\nconst MAX_KEYWORDS = 15;\n\n/** Minimum heading length to include as keyword */\nconst MIN_HEADING_KEYWORD_LENGTH = 4;\n\n/** Maximum heading length to include as keyword */\nconst MAX_HEADING_KEYWORD_LENGTH = 80;\n\n/**\n * Strip HTML tags and decode common HTML entities to plain text.\n *\n * @param html - HTML string\n * @returns Plain-text string\n */\nfunction stripHtml(html: string): string {\n  return html\n    .replace(/<[^>]+>/gu, ' ')\n    .replace(/&lt;/gu, '<')\n    .replace(/&gt;/gu, '>')\n    .replace(/&quot;/gu, '\"')\n    .replace(/&#39;/gu, \"'\")\n    .replace(/&mdash;/gu, '\\u2014')\n    .replace(/&ndash;/gu, '\\u2013')\n    .replace(/&amp;/gu, '&')\n    .replace(/\\s+/gu, ' ')\n    .trim();\n}\n\n/**\n * Extract all h2 and h3 heading texts from article content.\n *\n * @param content - Article HTML body\n * @returns Array of heading text strings\n */\nfunction extractHeadings(content: string): string[] {\n  const headingRegex = /<h([23])\\b[^>]*>([\\s\\S]*?)<\\/h\\1>/giu;\n  const headings: string[] = [];\n  let match: RegExpExecArray | null = headingRegex.exec(content);\n  while (match) {\n    const text = stripHtml(match[2] ?? '').trim();\n    if (text.length > 0) headings.push(text);\n    match = headingRegex.exec(content);\n  }\n  return headings;\n}\n\n/**\n * Extract the lede from article content as a plain-text description base.\n *\n * Prefers a <p class=\"lede\">...</p>, then a <section class=\"lede\">...</section>\n * (using its first paragraph or full text), and finally falls back to\n * the first <p> in the content if no lede-specific markup is found.\n *\n * @param content - Article HTML body\n * @returns Plain-text lede string, or empty string\n */\nfunction extractLede(content: string): string {\n  // Try explicit lede paragraph first: <p class=\"lede\">...</p>\n  const ledeParagraphMatch = /<p[^>]*class=\"[^\"]*\\blede\\b[^\"]*\"[^>]*>([\\s\\S]*?)<\\/p>/iu.exec(\n    content\n  );\n  if (ledeParagraphMatch?.[1]) {\n    const text = stripHtml(ledeParagraphMatch[1]).trim();\n    if (text.length > 20) return text;\n  }\n\n  // Try section-based lede: <section class=\"lede\"> ... <p>...</p> ... </section>\n  const ledeSectionMatch =\n    /<section[^>]*class=\"[^\"]*\\blede\\b[^\"]*\"[^>]*>([\\s\\S]*?)<\\/section>/iu.exec(content);\n  if (ledeSectionMatch?.[1]) {\n    const sectionParagraphMatch = /<p[^>]*>([\\s\\S]*?)<\\/p>/iu.exec(ledeSectionMatch[1]);\n    if (sectionParagraphMatch?.[1]) {\n      const text = stripHtml(sectionParagraphMatch[1]).trim();\n      if (text.length > 20) return text;\n    }\n    const sectionText = stripHtml(ledeSectionMatch[1]).trim();\n    if (sectionText.length > 20) return sectionText;\n  }\n\n  // Fall back to first paragraph in article-content\n  const paraMatch = /<p[^>]*>([\\s\\S]*?)<\\/p>/iu.exec(content);\n  if (paraMatch?.[1]) {\n    const text = stripHtml(paraMatch[1]).trim();\n    if (text.length > 20) return text;\n  }\n\n  return '';\n}\n\n/**\n * Extract key statistics (numbers with context) from article content.\n * Looks for patterns like \"42 adopted texts\", \"85% pipeline health\", etc.\n *\n * @param content - Article HTML body\n * @returns Array of statistic highlight strings\n */\nfunction extractStatistics(content: string): string[] {\n  const text = stripHtml(content);\n  const stats: string[] = [];\n\n  // Match \"N adopted texts\" / \"N documents\" / \"N procedures\" / \"N events\" etc.\n  // Use a simple alternation list — no nested quantifiers.\n  const countWords = [\n    'adopted texts',\n    'adopted text',\n    'documents',\n    'document',\n    'procedures',\n    'procedure',\n    'events',\n    'event',\n    'votes',\n    'vote',\n    'questions',\n    'question',\n    'anomalies',\n    'anomaly',\n    'committees',\n    'committee',\n    'resolutions',\n    'resolution',\n    'MEPs',\n    'MEP',\n    'sessions',\n    'session',\n    'meetings',\n    'meeting',\n  ].join('|');\n  const countPatterns = new RegExp(`(\\\\d+)\\\\s+(${countWords})`, 'giu');\n  let match: RegExpExecArray | null = countPatterns.exec(text);\n  while (match) {\n    stats.push(`${match[1]} ${match[2]}`);\n    match = countPatterns.exec(text);\n  }\n\n  // Match percentages — integer or decimal followed by %\n  const pctPatterns = /(\\d[\\d.]*\\d|\\d)%/gu;\n  match = pctPatterns.exec(text);\n  while (match) {\n    stats.push(`${match[1]}%`);\n    match = pctPatterns.exec(text);\n  }\n\n  // Deduplicate\n  return [...new Set(stats)].slice(0, 5);\n}\n\n/**\n * Extract content-derived keywords from headings and prominent terms.\n *\n * @param content - Article HTML body\n * @param baseKeywords - Keywords from the strategy (preserved)\n * @returns Deduplicated keyword array\n */\nfunction extractContentKeywords(content: string, baseKeywords: readonly string[]): string[] {\n  const keywords: string[] = [...baseKeywords];\n\n  // Add headings as keywords\n  const headings = extractHeadings(content);\n  for (const h of headings) {\n    if (h.length >= MIN_HEADING_KEYWORD_LENGTH && h.length <= MAX_HEADING_KEYWORD_LENGTH) {\n      keywords.push(h);\n    }\n  }\n\n  // Work against plain text for entity extraction to avoid false positives from markup\n  const plainText = stripHtml(content);\n\n  // Extract committee abbreviations (ENVI, ECON, AFET, etc.)\n  const abbrRegex =\n    /\\b(ENVI|ECON|AFET|LIBE|AGRI|ITRE|IMCO|TRAN|REGI|PECH|CULT|JURI|BUDG|CONT|EMPL|INTA|DEVE|DROI|SEDE)\\b/gu;\n  let match: RegExpExecArray | null = abbrRegex.exec(plainText);\n  while (match) {\n    keywords.push(match[1] ?? '');\n    match = abbrRegex.exec(plainText);\n  }\n\n  // Extract political group names\n  const groupRegex = /\\b(EPP|S&D|Renew|Greens\\/EFA|ECR|The Left|ID|PfE)\\b/gu;\n  match = groupRegex.exec(plainText);\n  while (match) {\n    keywords.push(match[1] ?? '');\n    match = groupRegex.exec(plainText);\n  }\n\n  return [...new Set(keywords)].slice(0, MAX_KEYWORDS);\n}\n\n/**\n * Patterns that indicate a heading is a generic section label (not\n * analytical content suitable for a title suffix).\n */\nconst GENERIC_HEADING_PATTERN =\n  /^(introduction|overview|analysis|conclusion|summary|background|context|key\\s+findings|methodology|data\\s+sources|voting\\s+records|parliamentary\\s+questions|about|feed\\s+health|analysis\\s+pipeline|analysis\\s+&\\s+transparency|stakeholder|dashboard|pipeline\\s+snapshot|political\\s+intelligence|further\\s+reading|related|appendix|table\\s+of\\s+contents|deep\\s+analysis)/iu;\n\n/**\n * Patterns indicating a heading contains analytical/political content\n * (e.g., specific legislation names, political dynamics, policy topics).\n */\nconst ANALYTICAL_HEADING_PATTERN =\n  /(?:directive|regulation|resolution|reform|crisis|alliance|coalition|division|bloc|breakthrough|deadlock|amendment|trilogue|committee|parliament|council|commission|veto|mandate|sovereignty|trade|climate|digital|security|defense|defence|budget|migration|energy|sanctions|treaty|accession|withdrawal|election|referendum|impeach|censure|confidence|no.confidence)/iu;\n\n/**\n * Build a content-aware title by extracting the most politically\n * significant heading or analytical finding from the article body.\n *\n * **Priority order** (per ai-driven-analysis-guide Rule 9):\n * 1. Analytical headings containing political/legislative substance\n * 2. Non-generic section headings with meaningful length\n * 3. Data statistics as a last resort only\n *\n * This ensures titles reflect AI-analysed political intelligence\n * rather than mechanical data counts like \"5 Votes, 2 Anomalies\".\n *\n * @param content - Article HTML body\n * @param baseTitle - Localized base title from the strategy\n * @returns Enriched title string\n */\nfunction buildContentTitle(content: string, baseTitle: string): string {\n  // If the strategy already appended a suffix (contains em-dash), do not double-suffix\n  if (baseTitle.includes('—')) return baseTitle;\n\n  const headings = extractHeadings(content);\n\n  // Priority 1: Find a heading with real political/legislative substance\n  const analyticalHeading = headings.find(\n    (h) =>\n      h.length > 12 &&\n      h.length <= 80 &&\n      ANALYTICAL_HEADING_PATTERN.test(h) &&\n      !GENERIC_HEADING_PATTERN.test(h)\n  );\n\n  if (analyticalHeading) {\n    return `${baseTitle} — ${analyticalHeading}`;\n  }\n\n  // Priority 2: Find any non-generic heading with meaningful length\n  const topHeading = headings.find(\n    (h) => h.length > 12 && h.length <= 80 && !GENERIC_HEADING_PATTERN.test(h)\n  );\n\n  if (topHeading) {\n    return `${baseTitle} — ${topHeading}`;\n  }\n\n  // Priority 3 (last resort): Use a key statistic — but only when no\n  // analytical heading is available\n  const stats = extractStatistics(content);\n  const topStat = stats[0];\n  if (topStat) {\n    return `${baseTitle} — ${topStat}`;\n  }\n\n  return baseTitle;\n}\n\n/**\n * Build a content-aware description by extracting the AI-written lede\n * paragraph from the article body.  The lede should contain the\n * political significance of the article content — not data counts.\n *\n * Falls back to the strategy-provided subtitle only when no\n * substantive lede paragraph is found.\n *\n * @param content - Article HTML body\n * @param baseSubtitle - Subtitle from the strategy as fallback\n * @returns SEO-friendly description string (≤ {@link MAX_DESCRIPTION_LENGTH} chars)\n */\nfunction buildContentDescription(content: string, baseSubtitle: string): string {\n  const lede = extractLede(content);\n  if (lede.length > 30) {\n    // Truncate at sentence boundary when possible for clean SEO descriptions\n    if (lede.length > MAX_DESCRIPTION_LENGTH) {\n      const truncated = lede.slice(0, MAX_DESCRIPTION_LENGTH - 3);\n      // Find the last sentence boundary (period, exclamation, or question mark followed by space)\n      const lastSentence = Math.max(\n        truncated.lastIndexOf('. '),\n        truncated.lastIndexOf('! '),\n        truncated.lastIndexOf('? ')\n      );\n      if (lastSentence > MAX_DESCRIPTION_LENGTH * MIN_SENTENCE_TRUNCATION_RATIO) {\n        return truncated.slice(0, lastSentence + 1);\n      }\n      return truncated + '...';\n    }\n    return lede;\n  }\n  return baseSubtitle;\n}\n\n/**\n * Enrich article metadata by analysing the rendered article content.\n *\n * This function is the main entry point — called by the generation pipeline\n * **after** {@link buildContent} produces the article HTML body but\n * **before** the HTML is wrapped in the full page template.\n *\n * It refines the strategy-provided base metadata with content-derived\n * insights so that titles, descriptions, and keywords reflect the\n * actual article coverage rather than generic template text.\n *\n * @param content - Rendered article HTML body (from strategy.buildContent)\n * @param baseMetadata - Base metadata from strategy.getMetadata\n * @returns Enriched metadata with content-aware title, description, and keywords\n */\nexport function enrichMetadataFromContent(\n  content: string,\n  baseMetadata: ArticleMetadata\n): ArticleMetadata {\n  const title = buildContentTitle(content, baseMetadata.title);\n  const subtitle = buildContentDescription(content, baseMetadata.subtitle);\n  const keywords = extractContentKeywords(content, baseMetadata.keywords);\n\n  return {\n    ...baseMetadata,\n    title,\n    subtitle,\n    keywords,\n  };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/content-validator.ts","messages":[{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":384,"column":20,"endLine":384,"endColumn":88},{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":385,"column":20,"endLine":385,"endColumn":88},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":416,"column":22,"endLine":416,"endColumn":60},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":467,"column":16,"endLine":467,"endColumn":20},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":572,"column":6,"endLine":572,"endColumn":14},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":572,"column":26,"endLine":572,"endColumn":34},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":572,"column":47,"endLine":572,"endColumn":55},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":572,"column":68,"endLine":572,"endColumn":76},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":575,"column":28,"endLine":575,"endColumn":36},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":580,"column":6,"endLine":580,"endColumn":14},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":580,"column":26,"endLine":580,"endColumn":34},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":580,"column":47,"endLine":580,"endColumn":55},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":580,"column":68,"endLine":580,"endColumn":76},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":584,"column":17,"endLine":584,"endColumn":25},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":720,"column":16,"endLine":720,"endColumn":24},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":902,"column":42,"endLine":902,"endColumn":55},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":923,"column":45,"endLine":923,"endColumn":51},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":924,"column":26,"endLine":924,"endColumn":32},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":926,"column":45,"endLine":926,"endColumn":51},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":928,"column":17,"endLine":928,"endColumn":23},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1201,"column":45,"endLine":1201,"endColumn":59},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1424,"column":20,"endLine":1424,"endColumn":48},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1811,"column":5,"endLine":1811,"endColumn":18},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1818,"column":24,"endLine":1818,"endColumn":37}],"suppressedMessages":[{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":1808,"column":21,"endLine":1808,"endColumn":58,"suppressions":[{"kind":"directive","justification":""}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":24,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/ContentValidator\n * @description Post-generation content quality validation for news articles.\n *\n * Validates generated article HTML for minimum word counts, placeholder text,\n * required structural HTML elements, language consistency, meta tag alignment,\n * read-time accuracy, and keyword localization. Produces a structured quality report.\n */\n\nimport { ArticleCategory } from '../types/index.js';\nimport { stripScriptBlocks } from './html-sanitize.js';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\n/** Quality metrics collected during content validation */\nexport interface ContentValidationMetrics {\n  /** Approximate word count of plain text extracted from HTML */\n  wordCount: number;\n  /** Whether all required structural HTML elements are present */\n  htmlValid: boolean;\n  /** Whether placeholder/template markers were detected in the output */\n  hasPlaceholders: boolean;\n  /** Computed read-time based on actual word count (words / 250, min 1) */\n  computedReadTime: number;\n  /** Claimed read-time extracted from the article (0 if not found) */\n  claimedReadTime: number;\n  /** Whether the html lang attribute matches the expected language */\n  langAttributeValid: boolean;\n  /** Whether the dir attribute is correctly set for RTL languages */\n  dirAttributeValid: boolean;\n  /** Whether meta tags (title, og:title, twitter:title) are synchronized */\n  metaTagsSynced: boolean;\n  /** Whether keywords contain at least some localized terms for non-English articles */\n  keywordsLocalized: boolean;\n}\n\n/** Result returned by {@link validateArticleContent} */\nexport interface ContentValidationResult {\n  /** true when no errors were found (warnings are allowed) */\n  valid: boolean;\n  /** Non-fatal quality notices */\n  warnings: string[];\n  /** Fatal quality failures */\n  errors: string[];\n  /** Collected numeric and boolean quality metrics */\n  metrics: ContentValidationMetrics;\n}\n\n/** Quality metrics collected during translation completeness validation */\nexport interface TranslationValidationMetrics {\n  /** Ratio of ASCII printable characters to total text (0–1). High values in CJK articles suggest untranslated content. */\n  asciiRatio: number;\n  /** Ratio of CJK characters to total text (0–1). Low values in ja/ko/zh articles suggest untranslated content. */\n  cjkCharRatio: number;\n  /** Whether the `dir=\"rtl\"` attribute is present on the `<html>` element */\n  hasRtlDir: boolean;\n  /** Whether Unicode bidi control characters or `&lrm;`/`&rlm;` markers are present */\n  hasBidiMarkers: boolean;\n  /** English phrases found in non-English articles that likely should have been translated */\n  untranslatedPhrases: readonly string[];\n}\n\n/**\n * Result returned by {@link validateTranslationCompleteness}.\n *\n * Translation validation is non-blocking — it produces warnings only.\n */\nexport interface TranslationValidationResult {\n  /** true when no translation quality issues were detected */\n  valid: boolean;\n  /** Non-fatal translation quality notices */\n  warnings: string[];\n  /** Collected translation quality metrics */\n  metrics: TranslationValidationMetrics;\n}\n\n// ─── Constants ────────────────────────────────────────────────────────────────\n\n/** Minimum word counts (plain text) required per article category */\nconst MIN_WORD_COUNTS: Readonly<Record<string, number>> = {\n  [ArticleCategory.WEEK_AHEAD]: 500,\n  [ArticleCategory.MONTH_AHEAD]: 500,\n  [ArticleCategory.BREAKING_NEWS]: 300,\n  [ArticleCategory.COMMITTEE_REPORTS]: 400,\n  [ArticleCategory.PROPOSITIONS]: 300,\n  [ArticleCategory.MOTIONS]: 300,\n  [ArticleCategory.WEEK_IN_REVIEW]: 300,\n  [ArticleCategory.MONTH_IN_REVIEW]: 600,\n} as const;\n\n/** Default minimum word count when category is not listed */\nconst DEFAULT_MIN_WORDS = 300;\n\n/** Words per minute for read-time calculation */\nconst WORDS_PER_MINUTE = 250;\n\n/** Maximum read-time deviation (minutes) before a warning is triggered */\nconst READ_TIME_TOLERANCE = 2;\n\n/** RTL language codes requiring dir=\"rtl\" */\nconst RTL_LANGUAGES: ReadonlySet<string> = new Set(['ar', 'he']);\n\n/** Patterns that indicate un-replaced template markers */\nconst PLACEHOLDER_PATTERNS: ReadonlyArray<RegExp> = [\n  /\\{\\{[^}]+\\}\\}/u,\n  /\\[TODO[^\\]]*\\]/iu,\n  /\\bPLACEHOLDER\\b/u,\n] as const;\n\n/** Required structural HTML elements that every article must contain */\nconst REQUIRED_HTML_ELEMENTS: ReadonlyArray<{\n  selector: string | readonly string[];\n  label: string;\n}> = [\n  {\n    selector: ['class=\"site-header__langs\"', 'class=\"language-switcher\"'],\n    label: 'language switcher nav',\n  },\n  { selector: 'class=\"article-top-nav\"', label: 'article-top-nav (back button)' },\n  { selector: 'class=\"site-header\"', label: 'site-header' },\n  { selector: '<main id=\"main\"', label: 'main content wrapper' },\n] as const;\n\n/**\n * Localized keyword indicators per language.\n * If a non-English article's keywords contain at least one of these\n * language-specific terms, keyword localization is considered acceptable.\n * This catches the common issue of English-only keywords in translated articles.\n */\nconst LOCALIZED_KEYWORD_INDICATORS: Readonly<Record<string, ReadonlyArray<string>>> = {\n  sv: ['parlamentet', 'lagstiftning', 'EU', 'europeisk', 'utskott', 'omröstning', 'förordning'],\n  da: ['parlamentet', 'lovgivning', 'udvalg', 'afstemning', 'forordning', 'europæisk'],\n  no: ['parlamentet', 'lovgivning', 'komité', 'avstemning', 'forordning', 'europeisk'],\n  fi: ['parlamentti', 'lainsäädäntö', 'valiokunta', 'äänestys', 'asetus', 'eurooppalainen'],\n  de: ['Parlament', 'Gesetzgebung', 'Ausschuss', 'Abstimmung', 'Verordnung', 'europäisch'],\n  fr: ['parlement', 'législation', 'commission', 'vote', 'règlement', 'européen'],\n  es: ['parlamento', 'legislación', 'comisión', 'votación', 'reglamento', 'europeo'],\n  nl: ['parlement', 'wetgeving', 'commissie', 'stemming', 'verordening', 'Europees'],\n  ar: ['البرلمان', 'التشريع', 'اللجنة', 'التصويت', 'الأوروبي', 'القرار'],\n  he: ['הפרלמנט', 'חקיקה', 'ועדה', 'הצבעה', 'האירופי', 'תקנה'],\n  ja: ['議会', '立法', '委員会', '投票', '規則', '欧州'],\n  ko: ['의회', '입법', '위원회', '투표', '규정', '유럽'],\n  zh: ['议会', '立法', '委员会', '投票', '条例', '欧洲'],\n} as const;\n\n/** CJK language codes requiring ideographic character density checks */\nconst CJK_LANGUAGES: ReadonlySet<string> = new Set(['ja', 'ko', 'zh']);\n\n/**\n * ASCII ratio threshold above which CJK articles are considered likely untranslated.\n * Real CJK content typically has <50 % ASCII (mostly HTML entities and punctuation).\n */\nconst CJK_ASCII_RATIO_THRESHOLD = 0.85;\n\n/**\n * Minimum CJK character ratio expected in properly translated CJK articles.\n * Below this value the content is likely still English.\n */\nconst CJK_CHAR_RATIO_THRESHOLD = 0.05;\n\n/**\n * Common English phrases that should not appear (un-translated) in non-English articles.\n * These are generic header/label/placeholder phrases that a proper translation would replace.\n */\nconst ENGLISH_PLACEHOLDER_PHRASES: ReadonlyArray<string> = [\n  'European Parliament',\n  'Read more',\n  'Table of Contents',\n  'Key Takeaways',\n  'Executive Summary',\n  'Click here',\n  'Learn more',\n  'Subscribe',\n  'Pipeline health',\n  'Throughput rate',\n  'legislative processing capacity',\n  'Bottlenecked procedures',\n  'coalition-building strategies',\n  'regulatory implications',\n  'democratic participation',\n  'inter-institutional relations',\n  'Likely scenario',\n  'Possible scenario',\n  'Earlier intervention',\n  'political group dynamics',\n  'committee coordinators',\n] as const;\n\n// ─── Article Quality Gate Constants ───────────────────────────────────────────\n\n/**\n * Section headings that MUST NOT appear as article keywords.\n * These leak into meta tags when AI agents copy their section headers\n * into the keywords field instead of using policy terms.\n *\n * @see SHARED_PROMPT_PATTERNS.md § Keywords Quality Rules\n */\nconst BANNED_KEYWORD_PATTERNS: ReadonlyArray<string> = [\n  'Deep Political Analysis',\n  'What Happened',\n  'Key Actors',\n  'Timeline',\n  'Why It Matters',\n  'Why This Matters',\n  'Legislative Pipeline Overview',\n  'Impact Assessment',\n  'Actions → Consequences',\n  'Miscalculations & Missed Opportunities',\n  'Winners & Losers',\n  'Root Causes',\n  'Stakeholder Perspectives',\n  'Multi-Stakeholder Perspectives',\n  'Stakeholder Outcome Matrix',\n  'Intelligence Policy Map',\n  'Strategic Outlook',\n  'SWOT Analysis',\n  'Dashboard',\n  'Pipeline Health',\n  'Analysis Pipeline Insights',\n  'Plenary Sessions',\n  'Executive Summary',\n  'Table of Contents',\n  'Political Context',\n] as const;\n\n/**\n * Minimum number of non-whitespace characters for a `<section>` to be\n * considered non-empty. Below this threshold the section is treated as empty.\n */\nconst MIN_SECTION_CONTENT_LENGTH = 10;\n\n/**\n * How many characters to look back from a tag position when checking\n * whether the tag is inside a pipeline-health/pipeline-metrics container.\n */\nconst PIPELINE_CONTEXT_LOOKBEHIND_CHARS = 2000;\n\n/**\n * Pre-computed normalized banned-keyword map for exact-match comparison.\n * Built once at module init from BANNED_KEYWORD_PATTERNS + normalizeKeywordToken.\n *\n * Keys are normalized tokens; values are original patterns.\n */\nlet _bannedNormalizedCache: Map<string, string> | undefined;\n\n/**\n * Return (and lazily compute once) the normalized banned-keyword map.\n * Lazy initialization avoids a forward-reference to `normalizeKeywordToken`\n * which is defined later in this module.\n *\n * @returns Map from normalized token to original banned pattern\n */\nfunction getBannedNormalized(): Map<string, string> {\n  if (!_bannedNormalizedCache) {\n    _bannedNormalizedCache = new Map<string, string>();\n    for (const pattern of BANNED_KEYWORD_PATTERNS) {\n      _bannedNormalizedCache.set(normalizeKeywordToken(pattern), pattern);\n    }\n  }\n  return _bannedNormalizedCache;\n}\n\n/**\n * HTML entity → decoded character pairs used by the single-pass decoder.\n * Longest entities are listed first so that `&amp;` doesn't greedily match\n * inside `&amp;lt;` before the full entity `&amp;lt;` is checked.\n */\nconst ENTITY_PAIRS: ReadonlyArray<readonly [string, string]> = [\n  ['&mdash;', '—'],\n  ['&ndash;', '–'],\n  ['&rarr;', '→'],\n  ['&quot;', '\"'],\n  ['&amp;', '&'],\n  ['&#39;', \"'\"],\n  ['&lt;', '<'],\n  ['&gt;', '>'],\n] as const;\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\n\n// stripScriptBlocks is imported from html-sanitize.ts\n\n/**\n * Extract plain text from the `<main>` element of an article and count words.\n *\n * Restricts counting to the main content area so that JSON-LD scripts,\n * navigation, and header/footer boilerplate do not inflate the word count.\n * Falls back to the full document when no `<main>` element is found.\n *\n * @param html - Raw HTML string\n * @returns Approximate word count of the main content area\n */\nfunction countWordsInHtml(html: string): number {\n  const mainMatch = /<main[^>]*>([\\s\\S]*?)<\\/main>/u.exec(html);\n  const source = mainMatch?.[1] ?? html;\n  const plainText = stripScriptBlocks(source)\n    .replace(/<[^>]+>/gu, ' ')\n    .replace(/\\s+/gu, ' ')\n    .trim();\n  if (!plainText) return 0;\n  return plainText.split(' ').length;\n}\n\n/**\n * Detect whether any un-replaced template placeholder patterns remain in the content.\n *\n * @param html - HTML string to inspect\n * @returns true if at least one placeholder pattern is found\n */\nfunction detectPlaceholders(html: string): boolean {\n  return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(html));\n}\n\n/**\n * Check that all required structural HTML elements are present.\n *\n * @param html - HTML string to inspect\n * @returns Array of labels for missing elements (empty when all present)\n */\nfunction findMissingElements(html: string): string[] {\n  return REQUIRED_HTML_ELEMENTS.filter((el) => {\n    const sel = el.selector;\n    if (Array.isArray(sel)) {\n      return !sel.some((s) => html.includes(s));\n    }\n    return !html.includes(sel as string);\n  }).map((el) => el.label);\n}\n\n/**\n * Extract the value of the `lang` attribute from the `<html>` tag.\n *\n * @param html - HTML string to inspect\n * @returns The lang value or empty string if not found\n */\nfunction extractLangAttribute(html: string): string {\n  const match = /<html[^>]*\\slang=\"([^\"]+)\"/iu.exec(html);\n  return match?.[1] ?? '';\n}\n\n/**\n * Extract the value of the `dir` attribute from the `<html>` tag.\n *\n * @param html - HTML string to inspect\n * @returns The dir value or empty string if not found\n */\nfunction extractDirAttribute(html: string): string {\n  const match = /<html[^>]*\\sdir=\"([^\"]+)\"/iu.exec(html);\n  return match?.[1] ?? '';\n}\n\n/**\n * Extract the claimed read-time from the article.\n * Looks for patterns like \"5 min read\", \"3分で読了\", \"5分钟阅读\", etc.\n *\n * @param html - HTML string to inspect\n * @returns Claimed read time in minutes or 0 if not found\n */\nfunction extractClaimedReadTime(html: string): number {\n  // Look for read-time inside the article meta section\n  const readTimeMatch =\n    /class=\"article-read-time\"[^>]*>([^<]*)/iu.exec(html) ??\n    /article-read-time[^>]*>([^<]*)/iu.exec(html);\n  if (!readTimeMatch?.[1]) return 0;\n  const text = readTimeMatch[1].trim();\n  // Extract the numeric portion — handles \"5 min read\", \"5分で読了\", \"٥ دقائق قراءة\"\n  const numMatch = /(\\d+)/u.exec(text);\n  return numMatch?.[1] ? parseInt(numMatch[1], 10) : 0;\n}\n\n/**\n * Extract meta tag content by name or property.\n *\n * @param html - HTML string\n * @param attr - Attribute name ('name' or 'property')\n * @param value - Attribute value to match (e.g. 'og:title')\n * @returns The content attribute value or empty string\n */\nfunction extractMetaContent(html: string, attr: string, value: string): string {\n  // Handle both orderings: <meta name=\"x\" content=\"y\"> and <meta content=\"y\" name=\"x\">\n  const pattern1 = new RegExp(`<meta\\\\s+${attr}=\"${value}\"\\\\s+content=\"([^\"]*)\"`, 'iu');\n  const pattern2 = new RegExp(`<meta\\\\s+content=\"([^\"]*)\"\\\\s+${attr}=\"${value}\"`, 'iu');\n  return pattern1.exec(html)?.[1] ?? pattern2.exec(html)?.[1] ?? '';\n}\n\n/**\n * Extract the page title from the `<title>` tag.\n *\n * @param html - HTML string\n * @returns Title text or empty string\n */\nfunction extractTitle(html: string): string {\n  const match = /<title>([^<]*)<\\/title>/iu.exec(html);\n  return match?.[1]?.trim() ?? '';\n}\n\n/**\n * Check whether keywords contain language-specific localized terms.\n * For English articles, always returns true.\n * For non-English articles, checks if at least one keyword matches\n * a known localized indicator for that language.\n *\n * @param html - HTML string to extract keywords from\n * @param language - Expected language code\n * @returns true if keywords appear localized for the given language\n */\nfunction checkKeywordLocalization(html: string, language: string): boolean {\n  if (language === 'en') return true;\n\n  const keywordsMeta = extractMetaContent(html, 'name', 'keywords');\n  if (!keywordsMeta) return true; // No keywords = no localization issue\n\n  const indicators = LOCALIZED_KEYWORD_INDICATORS[language];\n  if (!indicators) return true; // Unknown language = skip check\n\n  const keywordsLower = keywordsMeta.toLowerCase();\n  return indicators.some((indicator) => keywordsLower.includes(indicator.toLowerCase()));\n}\n\n/**\n * Check whether meta tags (title, OG, Twitter) are synchronized.\n *\n * @param html - HTML string to inspect\n * @returns true if the core meta tags are reasonably aligned\n */\nfunction checkMetaTagSync(html: string): boolean {\n  const pageTitle = extractTitle(html);\n  const ogTitle = extractMetaContent(html, 'property', 'og:title');\n  const twitterTitle = extractMetaContent(html, 'name', 'twitter:title');\n\n  // If OG or Twitter title is present, it should match the page title\n  // (stripping the \" | EU Parliament Monitor\" suffix from page title)\n  const coreTitle = pageTitle.replace(/\\s*\\|\\s*EU Parliament Monitor$/iu, '').trim();\n\n  if (ogTitle && ogTitle !== coreTitle) return false;\n  if (twitterTitle && twitterTitle !== coreTitle) return false;\n\n  // Also check description alignment\n  const description = extractMetaContent(html, 'name', 'description');\n  const ogDescription = extractMetaContent(html, 'property', 'og:description');\n  const twitterDescription = extractMetaContent(html, 'name', 'twitter:description');\n\n  if (ogDescription && description && ogDescription !== description) return false;\n  if (twitterDescription && description && twitterDescription !== description) return false;\n\n  return true;\n}\n\n/**\n * Decode common HTML entities that appear in meta keyword values.\n * Only covers the entities actually used by the article template engine.\n *\n * Uses a single-pass scan to avoid double-unescaping (e.g. `&amp;lt;`\n * becomes `&lt;`, NOT `<`). Each `&` in the input is checked once;\n * decoded replacements are never re-scanned.\n *\n * @param s - String potentially containing HTML entities\n * @returns The string with common entities decoded\n */\nfunction decodeKeywordEntities(s: string): string {\n  const parts: string[] = [];\n  let i = 0;\n  while (i < s.length) {\n    const ch = s[i] ?? '';\n    if (ch === '&') {\n      const rest = s.slice(i).toLowerCase();\n      let matched = false;\n      for (const [entity, replacement] of ENTITY_PAIRS) {\n        if (rest.startsWith(entity)) {\n          parts.push(replacement);\n          i += entity.length;\n          matched = true;\n          break;\n        }\n      }\n      if (!matched) {\n        parts.push(ch);\n        i++;\n      }\n    } else {\n      parts.push(ch);\n      i++;\n    }\n  }\n  return parts.join('');\n}\n\n/**\n * Normalize a keyword token for comparison: decode HTML entities,\n * collapse arrow/dash variants, and normalize whitespace.\n *\n * @param s - Raw keyword token to normalize\n * @returns Lowercased, entity-decoded, dash-normalized token\n */\nfunction normalizeKeywordToken(s: string): string {\n  let decoded = decodeKeywordEntities(s);\n  // Normalize arrow/dash variants → single canonical form\n  decoded = decoded.replace(/→/gu, '->');\n  decoded = decoded.replace(/—/gu, '-');\n  decoded = decoded.replace(/–/gu, '-');\n  // Collapse whitespace and lowercase\n  return decoded.replace(/\\s+/gu, ' ').trim().toLowerCase();\n}\n\n/**\n * Detect section-heading keywords that leaked into the article's meta keywords.\n * Returns the list of banned keywords found.\n *\n * Decodes HTML entities (e.g. `&amp;` → `&`) and normalizes dash/arrow\n * variants so that exact comma-separated tokens can be matched after\n * normalization, for example \"Winners &amp; Losers\" matching\n * \"Winners & Losers\". Combined phrases are not split on dash or arrow\n * separators and therefore only match if the full normalized token is banned.\n *\n * @param html - HTML string to inspect\n * @returns Array of section-heading keywords found in the meta tag\n */\nfunction detectBannedKeywords(html: string): string[] {\n  const keywordsMeta = extractMetaContent(html, 'name', 'keywords');\n  if (!keywordsMeta) return [];\n\n  // Parse comma-separated keywords and normalize each token\n  const tokens = keywordsMeta\n    .split(',')\n    .map((k) => normalizeKeywordToken(k))\n    .filter((k) => k.length > 0);\n\n  const bannedNormalized = getBannedNormalized();\n\n  const found: string[] = [];\n  for (const token of tokens) {\n    const original = bannedNormalized.get(token);\n    if (original) {\n      found.push(original);\n    }\n  }\n  return found;\n}\n\n/**\n * Test whether a character is a boundary before/after the word \"class\"\n * in an HTML attribute context.\n *\n * @param ch - The character to test (or undefined if at string edge)\n * @param side - Whether to check as a 'before' or 'after' boundary\n * @returns true if the character is a valid boundary\n */\nfunction isAttrBoundary(ch: string | undefined, side: 'before' | 'after'): boolean {\n  if (!ch || ch === '') return true;\n  if (ch === ' ' || ch === '\\t' || ch === '\\n' || ch === '\\r') return true;\n  if (side === 'before') return ch === '\"' || ch === \"'\";\n  return ch === '=';\n}\n\n/**\n * Extract the quoted value of the `class` attribute starting at a given cursor\n * position (immediately after the word \"class\"). Returns `null` if the syntax\n * is not `class = \"...\"` or `class = '...'`.\n *\n * @param tag - The full start-tag string\n * @param cursor - Index right after the word \"class\" in `tag`\n * @returns `{ value, end }` or `null`\n */\nfunction extractClassValue(tag: string, cursor: number): { value: string; end: number } | null {\n  let pos = cursor;\n  // Skip whitespace before '=' (space, tab, newline, carriage return)\n  while (\n    pos < tag.length &&\n    (tag[pos] === ' ' || tag[pos] === '\\t' || tag[pos] === '\\n' || tag[pos] === '\\r')\n  )\n    pos++;\n  if (pos >= tag.length || tag[pos] !== '=') return null;\n  pos++; // skip '='\n  // Skip whitespace before opening quote\n  while (\n    pos < tag.length &&\n    (tag[pos] === ' ' || tag[pos] === '\\t' || tag[pos] === '\\n' || tag[pos] === '\\r')\n  )\n    pos++;\n  if (pos >= tag.length) return null;\n  const quote = tag[pos];\n  if (quote !== '\"' && quote !== \"'\") return null;\n  const valueStart = pos + 1;\n  const valueEnd = tag.indexOf(quote, valueStart);\n  if (valueEnd === -1) return null;\n  return { value: tag.slice(valueStart, valueEnd), end: valueEnd + 1 };\n}\n\n/**\n * Check whether an HTML start tag has a specific class token (whitespace-tokenized).\n * Handles both single-quoted and double-quoted class attributes.\n *\n * @param startTag - An opening HTML tag string (e.g. `<span class=\"metric-value foo\">`)\n * @param token - The class token to look for (e.g. `metric-value`)\n * @returns true if the class attribute contains the exact token\n */\nfunction hasClassToken(startTag: string, token: string): boolean {\n  const lowerTag = startTag.toLowerCase();\n  let searchFrom = 0;\n\n  while (searchFrom < lowerTag.length) {\n    const classPos = lowerTag.indexOf('class', searchFrom);\n    if (classPos === -1) return false;\n\n    const before = classPos > 0 ? lowerTag[classPos - 1] : undefined;\n    const after = classPos + 5 < lowerTag.length ? lowerTag[classPos + 5] : undefined;\n\n    if (!isAttrBoundary(before, 'before') || !isAttrBoundary(after, 'after')) {\n      searchFrom = classPos + 5;\n      continue;\n    }\n\n    const extracted = extractClassValue(startTag, classPos + 5);\n    if (!extracted) {\n      searchFrom = classPos + 5;\n      continue;\n    }\n\n    const tokens = extracted.value.split(/\\s+/u).filter((t) => t.length > 0);\n    if (tokens.includes(token)) return true;\n\n    searchFrom = extracted.end;\n  }\n\n  return false;\n}\n\n/**\n * Detect metric values showing \"0%\" in pipeline-health / pipeline-metrics\n * containers, which indicate no-data conditions that should not be rendered\n * as real dashboard metrics.\n *\n * Only flags `0%` inside elements whose surrounding context includes a\n * `pipeline-metrics` or `pipeline-health` class, avoiding false positives\n * on legitimate trend-panel change indicators (e.g. week-over-week 0%).\n *\n * @param html - HTML string to inspect\n * @returns Number of 0% pipeline metric values found\n */\nfunction detectZeroPercentMetrics(html: string): number {\n  // Use indexOf-based search to avoid regex backtracking (ReDoS-safe)\n  let count = 0;\n  let searchFrom = 0;\n  const zeroValue = '0%';\n  const lowerHtml = html.toLowerCase();\n\n  while (searchFrom < html.length) {\n    const tagStart = html.indexOf('<', searchFrom);\n    if (tagStart === -1) break;\n\n    const tagClose = html.indexOf('>', tagStart);\n    if (tagClose === -1) break;\n\n    const startTag = html.slice(tagStart, tagClose + 1);\n\n    // Only check elements that have the 'metric-value' class token\n    if (hasClassToken(startTag, 'metric-value')) {\n      const contentStart = tagClose + 1;\n      const nextTag = html.indexOf('<', contentStart);\n      if (nextTag === -1) break;\n\n      const textContent = html.slice(contentStart, nextTag).trim();\n      if (textContent === zeroValue && isInPipelineContext(lowerHtml, tagStart)) {\n        count++;\n      }\n      searchFrom = nextTag;\n      continue;\n    }\n\n    searchFrom = tagClose + 1;\n  }\n  return count;\n}\n\n/**\n * Check whether a position in the HTML is inside a pipeline-health/metrics context.\n * Looks backward up to 2000 chars for pipeline marker class names.\n *\n * If a `trend-panel` marker appears *after* the nearest pipeline marker,\n * the element is inside a trend panel (not pipeline), so return false.\n * This avoids flagging legitimate WoW/MoM 0% deltas rendered by\n * `buildTrendPanel` that happen to fall within the look-behind window.\n *\n * @param lowerHtml - Lowercase HTML string\n * @param position - Current scan position\n * @returns true if inside a pipeline context\n */\nfunction isInPipelineContext(lowerHtml: string, position: number): boolean {\n  const precedingHtml = lowerHtml.slice(\n    Math.max(0, position - PIPELINE_CONTEXT_LOOKBEHIND_CHARS),\n    position\n  );\n  const pipelineMetricsPos = precedingHtml.lastIndexOf('pipeline-metrics');\n  const pipelineHealthPos = precedingHtml.lastIndexOf('pipeline-health');\n  const lastPipelinePos = Math.max(pipelineMetricsPos, pipelineHealthPos);\n  if (lastPipelinePos === -1) return false;\n\n  // If a trend-panel marker appears after the pipeline marker, the element\n  // is inside a trend panel, not the pipeline panel.\n  const trendPanelPos = precedingHtml.lastIndexOf('trend-panel');\n  if (trendPanelPos !== -1 && trendPanelPos > lastPipelinePos) return false;\n\n  return true;\n}\n\n/**\n * Strip HTML tags from a string using a simple character scanner.\n * ReDoS-safe alternative to regex-based tag removal.\n *\n * @param input - HTML string to strip tags from\n * @returns Plain text content with tags removed\n */\nfunction stripHtmlTags(input: string): string {\n  const parts: string[] = [];\n  let inTag = false;\n  for (let i = 0; i < input.length; i++) {\n    const ch = input[i] ?? '';\n    if (ch === '<') {\n      inTag = true;\n    } else if (ch === '>') {\n      inTag = false;\n    } else if (!inTag) {\n      parts.push(ch);\n    }\n  }\n  return parts.join('');\n}\n\n/**\n * Evaluate whether a section's inner HTML has enough meaningful content.\n *\n * @param innerHtml - The HTML content between `<section>` and `</section>` tags\n * @returns true if the section is empty or near-empty\n */\nfunction isSectionEmpty(innerHtml: string): boolean {\n  const plainText = stripHtmlTags(innerHtml).replace(/\\s+/gu, ' ').trim();\n  return plainText.length < MIN_SECTION_CONTENT_LENGTH;\n}\n\n/**\n * Find the next `<section` open or `</section>` close tag from a given cursor.\n * Returns `{ type, pos }` or `null` if no more section tags found.\n *\n * @param lowerHtml - Lowercase HTML string\n * @param cursor - Start position\n * @returns Tag event or null\n */\nfunction findNextSectionTag(\n  lowerHtml: string,\n  cursor: number\n): { type: 'open' | 'close'; pos: number } | null {\n  const nextOpen = lowerHtml.indexOf('<section', cursor);\n  const nextClose = lowerHtml.indexOf('</section>', cursor);\n\n  if (nextOpen === -1 && nextClose === -1) return null;\n\n  const openFirst = nextOpen !== -1 && (nextClose === -1 || nextOpen < nextClose);\n  return openFirst ? { type: 'open', pos: nextOpen } : { type: 'close', pos: nextClose };\n}\n\n/**\n * Count empty `<section>` elements — those with little or no visible content.\n * An empty section contains only whitespace or very short boilerplate text.\n * Uses a stack-based scanner to correctly handle nested `<section>` elements.\n *\n * @param html - HTML string to inspect\n * @returns Number of empty sections found\n */\nfunction countEmptySections(html: string): number {\n  const lowerHtml = html.toLowerCase();\n  let count = 0;\n  const stack: number[] = [];\n  let cursor = 0;\n\n  let event = findNextSectionTag(lowerHtml, cursor);\n  while (event) {\n    if (event.type === 'open') {\n      const tagEnd = html.indexOf('>', event.pos);\n      if (tagEnd === -1) break;\n      stack.push(tagEnd + 1);\n      cursor = tagEnd + 1;\n    } else {\n      if (stack.length > 0) {\n        const contentStart = stack[stack.length - 1] ?? 0;\n        stack.pop();\n        if (isSectionEmpty(html.slice(contentStart, event.pos))) {\n          count++;\n        }\n      }\n      cursor = event.pos + '</section>'.length;\n    }\n    event = findNextSectionTag(lowerHtml, cursor);\n  }\n\n  return count;\n}\n\n// ─── Public API ───────────────────────────────────────────────────────────────\n\n/**\n * Collect warnings from machine-enforceable article quality gates.\n * Extracted to keep `validateArticleContent` within cognitive-complexity limits.\n *\n * @param html - Complete HTML string\n * @param warnings - Mutable warnings array to append to\n */\nfunction collectQualityGateWarnings(html: string, warnings: string[]): void {\n  // Keyword quality: detect section-heading keywords leaked into meta tags\n  const bannedKeywords = detectBannedKeywords(html);\n  if (bannedKeywords.length > 0) {\n    warnings.push(\n      `Keywords contain ${bannedKeywords.length} section heading(s) that should not be used as keywords: ${bannedKeywords.join(', ')}`\n    );\n  }\n\n  // Dashboard metric quality: detect 0% metrics rendered as real data\n  const zeroMetricCount = detectZeroPercentMetrics(html);\n  if (zeroMetricCount > 0) {\n    warnings.push(\n      `Dashboard renders ${zeroMetricCount} metric(s) showing \"0%\" — this likely indicates no-data, not a real score. Omit the dashboard when data is unavailable.`\n    );\n  }\n\n  // Empty section detection: flag sections with no meaningful content\n  const emptySectionCount = countEmptySections(html);\n  if (emptySectionCount > 0) {\n    warnings.push(\n      `Article contains ${emptySectionCount} empty or near-empty <section> element(s) that should be removed`\n    );\n  }\n\n  // Chart presence gate\n  if (!articleHasChart(html)) {\n    warnings.push(\n      'Missing required Chart.js visualization: no <canvas data-chart-config=\"…\"> element with a valid type found (≥1 required, see ai-first-quality.md quality gates)'\n    );\n  }\n\n  // Structural integrity gates — catch hand-written HTML bypassing the template\n  const langSwitcherCount = countLanguageSwitcherLinks(html);\n  if (langSwitcherCount < MIN_LANG_SWITCHER_LINKS) {\n    warnings.push(\n      `Language switcher has only ${langSwitcherCount} link(s); the template always emits ${MIN_LANG_SWITCHER_LINKS} — this article may have been hand-written and skipped the template`\n    );\n  }\n\n  if (!hasStandardFooterContent(html)) {\n    warnings.push(\n      'Footer is missing the standard `.footer-content` + `.footer-bottom` blocks — the template always emits these; article may have been hand-written'\n    );\n  }\n}\n\n/** Minimum number of language switcher links the template always emits (14 languages). */\nconst MIN_LANG_SWITCHER_LINKS = 14;\n\n/** Chart.js types accepted by the `data-chart-config` declarative pattern. */\nconst CHART_JS_TYPES = /\"type\"\\s*:\\s*\"(bar|line|pie|doughnut|radar|polarArea|scatter|bubble)\"/u;\n\n/**\n * Check whether a character is HTML whitespace per the WHATWG spec\n * (space, tab, LF, CR, FF).\n *\n * @param ch - Single character to test (may be empty string)\n * @returns `true` when `ch` is one of the recognised whitespace chars\n */\nfunction isHtmlWhitespace(ch: string): boolean {\n  return ch === ' ' || ch === '\\t' || ch === '\\n' || ch === '\\r' || ch === '\\f';\n}\n\n/**\n * Decode the five entity escapes that `escapeHTML` emits into literal chars.\n *\n * @param raw - Entity-encoded substring extracted from an attribute value\n * @returns Decoded literal string\n */\nfunction decodeHtmlEntities(raw: string): string {\n  return raw\n    .replace(/&quot;/gu, '\"')\n    .replace(/&#39;/gu, \"'\")\n    .replace(/&gt;/gu, '>')\n    .replace(/&lt;/gu, '<')\n    .replace(/&amp;/gu, '&');\n}\n\n/**\n * Check that the positions immediately before and after an attribute name\n * form valid HTML word-boundary characters. Prevents `xdata-chart-config`\n * from being treated as the `data-chart-config` attribute.\n *\n * @param tag - Full opening-tag text (without trailing `>`)\n * @param attrIdx - Index where the attribute name was found\n * @param attrLen - Length of the attribute name\n * @returns `true` when both boundaries are whitespace / `<` / `=` / start-of-tag\n */\nfunction hasAttributeBoundaries(tag: string, attrIdx: number, attrLen: number): boolean {\n  const before = attrIdx === 0 ? '' : (tag[attrIdx - 1] ?? '');\n  const afterIdx = attrIdx + attrLen;\n  const after = afterIdx < tag.length ? (tag[afterIdx] ?? '') : '';\n  const leadOk = before === '' || isHtmlWhitespace(before) || before === '<';\n  const trailOk = after === '' || isHtmlWhitespace(after) || after === '=';\n  return leadOk && trailOk;\n}\n\n/**\n * Starting just after an attribute name, locate the opening quote character\n * (either `\"` or `'`) that begins the attribute value, tolerating optional\n * HTML whitespace on either side of the `=`.\n *\n * @param tag - Full opening-tag text\n * @param from - Index immediately after the attribute name\n * @returns `{quote, valueStart}` when a proper `=<whitespace?><quote>` run is\n *   present; `null` when the attribute is malformed or unquoted\n */\nfunction findAttributeValueStart(\n  tag: string,\n  from: number\n): { quote: string; valueStart: number } | null {\n  let i = from;\n  while (i < tag.length && isHtmlWhitespace(tag[i] ?? '')) i++;\n  if (i >= tag.length || tag[i] !== '=') return null;\n  i++;\n  while (i < tag.length && isHtmlWhitespace(tag[i] ?? '')) i++;\n  if (i >= tag.length) return null;\n  const quote = tag[i] ?? '';\n  if (quote !== '\"' && quote !== \"'\") return null;\n  return { quote, valueStart: i + 1 };\n}\n\n/**\n * Scan an HTML attribute value in a single `<canvas>` tag starting at\n * `tagStart`. Returns the decoded value of `attr` or `null` if not present.\n * Uses only `indexOf` + single-character look-arounds so runtime is strictly\n * linear in input length — this avoids the polynomial-ReDoS class of regex\n * that CodeQL flags when nested character classes match the same tag prefix.\n *\n * Tolerates all HTML-compliant attribute forms:\n *  - double-quoted: `data-chart-config=\"...\"`\n *  - single-quoted: `data-chart-config='...'`\n *  - optional whitespace around `=`: `data-chart-config = \"...\"`\n *\n * @param html - Full article HTML\n * @param tagStart - Byte offset of the `<` that opens the canvas tag\n * @param attr - Attribute name (e.g. `data-chart-config`)\n * @returns Decoded attribute value, or `null` when the attribute is missing\n */\nfunction extractCanvasAttribute(html: string, tagStart: number, attr: string): string | null {\n  const tagEnd = html.indexOf('>', tagStart);\n  if (tagEnd === -1) return null;\n  const tag = html.slice(tagStart, tagEnd);\n\n  let searchFrom = 0;\n  while (searchFrom < tag.length) {\n    const attrIdx = tag.indexOf(attr, searchFrom);\n    if (attrIdx === -1) return null;\n\n    // Keep scanning past false matches with bad boundaries or without a\n    // proper `=<quote>` run; this keeps the function linear in tag length.\n    if (!hasAttributeBoundaries(tag, attrIdx, attr.length)) {\n      searchFrom = attrIdx + attr.length;\n      continue;\n    }\n    const valueHead = findAttributeValueStart(tag, attrIdx + attr.length);\n    if (!valueHead) {\n      searchFrom = attrIdx + attr.length;\n      continue;\n    }\n    const valueEnd = tag.indexOf(valueHead.quote, valueHead.valueStart);\n    if (valueEnd === -1) return null;\n    return decodeHtmlEntities(tag.slice(valueHead.valueStart, valueEnd));\n  }\n\n  return null;\n}\n\n/**\n * Detect whether the article contains at least one Chart.js canvas with a\n * well-formed `data-chart-config` JSON payload.\n *\n * A valid chart must:\n *  - be rendered via `<canvas data-chart-config=\"…\">` (the declarative\n *    CSP-safe pattern hydrated by `js/chart-init.js`)\n *  - declare a supported Chart.js `type`\n *  - carry at least 3 data points in the first dataset (single-point charts\n *    are rejected by `SHARED_PROMPT_PATTERNS.md` anti-patterns)\n *\n * @param html - Raw article HTML\n * @returns `true` when ≥1 chart meeting the rules is present\n */\nexport function articleHasChart(html: string): boolean {\n  let cursor = 0;\n  while (cursor < html.length) {\n    const tagStart = html.indexOf('<canvas', cursor);\n    if (tagStart === -1) return false;\n    const decoded = extractCanvasAttribute(html, tagStart, 'data-chart-config');\n    if (decoded !== null && CHART_JS_TYPES.test(decoded) && countFirstDatasetPoints(decoded) >= 3) {\n      return true;\n    }\n    // Advance past `<canvas` so overlapping matches cannot occur.\n    cursor = tagStart + '<canvas'.length;\n  }\n  return false;\n}\n\n/** Minimal subset of a Chart.js config used by `countFirstDatasetPoints`. */\ninterface ChartJsDatasetConfig {\n  readonly data?: readonly unknown[];\n}\ninterface ChartJsDataConfig {\n  readonly datasets?: readonly ChartJsDatasetConfig[];\n}\ninterface ChartJsConfig {\n  readonly data?: ChartJsDataConfig;\n}\n\n/**\n * Count data points in the first dataset of a Chart.js config JSON payload.\n *\n * Parses the decoded `data-chart-config` as JSON and returns the length of\n * `config.data.datasets[0].data`. Handles both numeric-array datasets\n * (`[1, 2, 3]`) and object-point datasets (`[{x:0,y:1}, …]`) correctly —\n * the previous indexOf-based implementation miscounted scatter/bubble\n * configs and accidentally looked at `data.labels` for typical layouts.\n *\n * @param json - Decoded Chart.js config JSON string\n * @returns Number of data points in `data.datasets[0].data`, or 0 when absent/invalid\n */\nfunction countFirstDatasetPoints(json: string): number {\n  try {\n    const config = JSON.parse(json) as ChartJsConfig;\n    const firstDataset = config.data?.datasets?.[0];\n    return Array.isArray(firstDataset?.data) ? firstDataset.data.length : 0;\n  } catch {\n    return 0;\n  }\n}\n\n/**\n * Count distinct language switcher links emitted in the article header.\n *\n * @param html - Complete article HTML\n * @returns Number of `.lang-link` anchors inside the header `site-header__langs` nav\n */\nfunction countLanguageSwitcherLinks(html: string): number {\n  // Linear scan: locate the nav element by its unique class, then count\n  // `.lang-link` classes inside. Avoids the nested `[^\">]*` regex pattern\n  // that CodeQL flags as polynomial-ReDoS-prone.\n  const marker = 'site-header__langs';\n  const markerIdx = html.indexOf(marker);\n  const NAV_CLOSE = '</nav>';\n  let scope = html;\n  if (markerIdx !== -1) {\n    // Find the closing `</nav>` of the enclosing nav (simple assumption:\n    // the next `</nav>` after the marker is the one we want). Falls back to\n    // the whole HTML if not found.\n    const endIdx = html.indexOf(NAV_CLOSE, markerIdx);\n    if (endIdx !== -1) {\n      // Walk backwards to find the opening `<nav`.\n      const startIdx = html.lastIndexOf('<nav', markerIdx);\n      if (startIdx !== -1) {\n        scope = html.slice(startIdx, endIdx);\n      }\n    }\n  }\n  // Count `lang-link` class tokens — bounded linear count.\n  const matches = scope.match(/\\blang-link\\b/gu);\n  return matches ? matches.length : 0;\n}\n\n/**\n * Detect the two standard footer blocks always produced by `article-template.ts`.\n *\n * @param html - Complete article HTML\n * @returns `true` when both `.footer-content` and `.footer-bottom` classes are present\n */\nfunction hasStandardFooterContent(html: string): boolean {\n  return /class=\"footer-content\"/u.test(html) && /class=\"footer-bottom\"/u.test(html);\n}\n\n/** Slugs for article types that MUST include World Bank economic context. */\nconst POLICY_SLUGS_REQUIRING_WORLD_BANK = new Set<string>([\n  'committee-reports',\n  'propositions',\n  'motions',\n  'weekly-review',\n  'monthly-review',\n  'week-in-review',\n  'month-in-review',\n  'month-ahead',\n]);\n\n/**\n * Strong World Bank evidence tokens — plain substring match is enough to\n * satisfy the gate because each is specific (the literal attribution phrase\n * or an MCP tool name). Kept aligned with\n * `analysis/methodologies/worldbank-indicator-mapping.md`.\n */\nexport const WORLD_BANK_STRONG_FINGERPRINTS: readonly string[] = [\n  'World Bank',\n  'world bank',\n  'worldbank',\n  'get-economic-data',\n  'get-social-data',\n  'get-education-data',\n  'get-health-data',\n  'get-country-info',\n  'get-countries',\n  'search-indicators',\n];\n\n/**\n * Short indicator codes published by the World Bank MCP server. These are\n * matched with a word boundary (`[^A-Z0-9_]` look-arounds) so that prose like\n * \"GDP growth slowed\" does NOT count as World Bank evidence, but an analysis\n * file line like `INDICATOR: GDP` does. All codes are uppercase, so the match\n * is case-sensitive — case-insensitive mentions in English prose are intentionally\n * rejected.\n */\nexport const WORLD_BANK_INDICATOR_CODES: readonly string[] = [\n  'GDP',\n  'GDP_GROWTH',\n  'GDP_PER_CAPITA',\n  'GNI',\n  'GNI_PER_CAPITA',\n  'UNEMPLOYMENT',\n  'INFLATION',\n  'EXPORTS',\n  'EXPORTS_GDP',\n  'FDI',\n  'FDI_NET',\n  'POPULATION',\n  'LIFE_EXPECTANCY',\n  'BIRTH_RATE',\n  'DEATH_RATE',\n  'INTERNET_USERS',\n  'LITERACY_RATE',\n  'SCHOOL_ENROLLMENT',\n  'SCHOOL_COMPLETION',\n  'TEACHERS_PRIMARY',\n  'EDUCATION_EXPENDITURE',\n  'HEALTH_EXPENDITURE',\n  'PHYSICIANS',\n  'HOSPITAL_BEDS',\n  'IMMUNIZATION',\n  'HIV_PREVALENCE',\n  'MALNUTRITION',\n  'TUBERCULOSIS',\n];\n\n/**\n * Backwards-compatible union of strong + short fingerprints. Kept exported so\n * callers that only need a flat list (e.g. existing consumers that shipped\n * before the strong/short split) continue to compile. New code SHOULD prefer\n * {@link hasWorldBankEvidence}, which enforces the stricter word-boundary rule\n * for short codes.\n */\nexport const WORLD_BANK_FINGERPRINTS: readonly string[] = [\n  ...WORLD_BANK_STRONG_FINGERPRINTS,\n  ...WORLD_BANK_INDICATOR_CODES,\n];\n\n/**\n * Return true when any WORLD_BANK_INDICATOR_CODES entry appears in `text` with\n * word-boundary isolation on both sides. We treat `[A-Z0-9_]` as \"identifier\"\n * characters — that keeps `GDP_GROWTH` from accidentally matching inside the\n * shorter `GDP` scan, and keeps the English word \"gdp\" out of the match set.\n */\n\n/** Characters that count as part of an identifier-style token for the word-boundary check. */\nconst WORD_BOUNDARY_PATTERN = /[A-Z0-9_]/u;\n\n/**\n * Check whether `ch` is NOT an identifier-style character (so it qualifies\n * as a word boundary on either side of a World Bank indicator code).\n *\n * @param ch - Single character (may be empty string for start/end-of-string)\n * @returns `true` when `ch` is empty or a non-identifier character\n */\nfunction isIdentifierBoundary(ch: string): boolean {\n  return ch === '' || !WORD_BOUNDARY_PATTERN.test(ch);\n}\n\n/**\n * Return `true` when `code` appears in `text` surrounded by identifier\n * boundaries on both sides. Linear scan over `text`.\n *\n * @param text - Text to scan\n * @param code - Indicator code to look for (all uppercase)\n * @returns `true` when a word-bounded occurrence is present\n */\nfunction textContainsIndicatorCode(text: string, code: string): boolean {\n  let from = 0;\n  while (from < text.length) {\n    const idx = text.indexOf(code, from);\n    if (idx === -1) return false;\n    const before = idx === 0 ? '' : (text[idx - 1] ?? '');\n    const afterIdx = idx + code.length;\n    const after = afterIdx < text.length ? (text[afterIdx] ?? '') : '';\n    if (isIdentifierBoundary(before) && isIdentifierBoundary(after)) return true;\n    from = idx + 1;\n  }\n  return false;\n}\n\n/**\n * Return true when any `WORLD_BANK_INDICATOR_CODES` entry appears in `text`\n * with word-boundary isolation on both sides. We treat `[A-Z0-9_]` as\n * \"identifier\" characters — that keeps `GDP_GROWTH` from accidentally matching\n * inside the shorter `GDP` scan, and keeps the English word \"gdp\" out of the\n * match set.\n *\n * @param text - Article body or analysis markdown to scan\n * @returns `true` when at least one canonical indicator code is present\n */\nfunction hasIndicatorCodeWithBoundary(text: string): boolean {\n  for (const code of WORLD_BANK_INDICATOR_CODES) {\n    if (textContainsIndicatorCode(text, code)) return true;\n  }\n  return false;\n}\n\n/**\n * Detect World Bank sourcing in any piece of text (article body OR analysis\n * markdown). Returns `true` when the text contains either a strong fingerprint\n * (the phrase \"World Bank\", an MCP tool name, etc.) or an indicator code with\n * clean word boundaries.\n *\n * This is the single source of truth for the policy quality gate — both the\n * content validator and the CLI validator's filesystem fallback use it so a\n * legitimate evidence trail on either side satisfies the rule, and generic\n * prose mentions of economic terms do not.\n *\n * @param text - Text to scan\n * @returns `true` when at least one strong or word-bounded fingerprint matches\n */\nexport function hasWorldBankEvidence(text: string): boolean {\n  for (const fp of WORLD_BANK_STRONG_FINGERPRINTS) {\n    if (text.includes(fp)) return true;\n  }\n  return hasIndicatorCodeWithBoundary(text);\n}\n\n/**\n * Verify that a policy article (or the linked analysis artifacts) contains at\n * least one World Bank fingerprint — indicator code (word-bounded), MCP\n * tool-trace token, or the phrase \"World Bank\" itself. Returns `true` if the\n * gate is satisfied OR the article type is not on the mandatory list.\n *\n * @param html - Article HTML\n * @param articleType - Slug of the article category (e.g. `\"committee-reports\"`)\n * @param _analysisDir - Reserved for API symmetry; filesystem recursion is\n *   performed by the caller in `validate-articles.ts` to keep this module pure.\n * @returns `true` when the World Bank evidence requirement is met or not applicable\n */\nexport function articlePolicyHasWorldBank(\n  html: string,\n  articleType: string,\n  _analysisDir?: string\n): boolean {\n  if (!POLICY_SLUGS_REQUIRING_WORLD_BANK.has(articleType)) return true;\n  return hasWorldBankEvidence(html);\n}\n\n// ─── IMF Evidence (Wave 1 additive) ───────────────────────────────────────────\n\n/**\n * Strong IMF evidence tokens. Any one of these is sufficient evidence\n * that the article or analysis file references IMF macro/fiscal\n * context.\n *\n * Matching rules applied by {@link hasIMFEvidence}:\n * - Short all-caps tokens listed in {@link IMF_SHORT_ALLCAPS_TOKENS}\n *   (`IMF`, `WEO`) are matched **word-bounded and case-sensitive** via\n *   the same identifier-boundary rule used for indicator codes, so they\n *   do not false-positive inside tokens like `IMF_API_BASE_URL` or\n *   `WEO_VERSION`.\n * - All other entries — multi-word phrases, URL hosts, and MCP tool\n *   identifiers — are matched as **case-insensitive substrings**, so\n *   variations like `imf`, `Imf`, or `international monetary fund` all\n *   satisfy the gate.\n *\n * Kept aligned with `analysis/methodologies/imf-indicator-mapping.md`\n * and `IMF_MCP_TOOLS` in `src/mcp/imf-mcp-client.ts`.\n */\nexport const IMF_STRONG_FINGERPRINTS: readonly string[] = [\n  'IMF',\n  'International Monetary Fund',\n  'World Economic Outlook',\n  'WEO',\n  'Fiscal Monitor',\n  'data.imf.org',\n  'imf-list-databases',\n  'imf-search-databases',\n  'imf-get-parameter-defs',\n  'imf-get-parameter-codes',\n  'imf-fetch-data',\n];\n\n/**\n * Short all-caps IMF tokens that must be matched with identifier-style\n * word boundaries. Keeps `IMF` from matching inside `IMF_API_BASE_URL`\n * and `WEO` from matching inside `WEO_VERSION` or `NWEOF`.\n */\nconst IMF_SHORT_ALLCAPS_TOKENS: ReadonlySet<string> = new Set(['IMF', 'WEO']);\n\n/**\n * SDMX indicator codes published by IMF databases (WEO, IFS, FM, BOP,\n * ER) that the EU Parliament Monitor cites. Matched with the same\n * word-boundary rule as World Bank codes so English prose like \"debt\n * is high\" does not accidentally satisfy the IMF gate.\n *\n * Kept in sync with `IMF_POLICY_INDICATORS` in `src/utils/imf-data.ts`\n * via the `IMF_INDICATOR_SDMX_CODES` re-export — duplicated here as a\n * literal array so the content-validator module has zero runtime deps\n * on `imf-data.ts` (prevents circular imports through `file-utils`).\n */\nexport const IMF_INDICATOR_CODES: readonly string[] = [\n  'NGDPD',\n  'NGDP_RPCH',\n  'NGDPDPC',\n  'PCPIPCH',\n  'LUR',\n  'LP',\n  'BCA_NGDPD',\n  'TX_RPCH',\n  'GGXWDG_NGDP',\n  'GGXONLB_NGDP',\n  'GGSB_NPGDP',\n  'BFD_BP6_USD',\n  'EREER_IX',\n  'FPOLM_PA',\n];\n\n/**\n * Detect IMF sourcing in any piece of text (article body OR analysis\n * markdown). Returns `true` when the text contains either a strong\n * fingerprint (word-bounded `IMF`/`WEO` matched case-insensitively, a\n * case-insensitive match for `International Monetary Fund` / `World\n * Economic Outlook` / `Fiscal Monitor` / `data.imf.org` / any IMF MCP\n * tool id) or a word-bounded (case-sensitive) SDMX indicator code.\n *\n * Matching is delegated per-fingerprint via the rules documented on\n * {@link IMF_STRONG_FINGERPRINTS}. Short all-caps tokens (`IMF`,\n * `WEO`) use the identifier-boundary rule against an uppercased copy\n * of the text so lowercase/mixed-case citations still match, while\n * still excluding occurrences inside larger identifiers such as\n * `IMF_API_BASE_URL` or `WEO_VERSION_2026`.\n *\n * @param text - Text to scan.\n * @returns `true` when at least one strong or word-bounded IMF fingerprint matches.\n */\nexport function hasIMFEvidence(text: string): boolean {\n  if (text.length === 0) return false;\n  const lower = text.toLowerCase();\n  const upper = text.toUpperCase();\n  for (const fp of IMF_STRONG_FINGERPRINTS) {\n    if (IMF_SHORT_ALLCAPS_TOKENS.has(fp)) {\n      // All-caps short token — word-bounded, case-INsensitive match.\n      // Scan the uppercased text so variants like `imf`/`Imf`/`weo` match\n      // while still rejecting occurrences inside larger identifiers like\n      // `IMF_API_BASE_URL` or `WEO_VERSION_2026` (the `_`/alnum neighbour\n      // fails the identifier-boundary check regardless of original case).\n      if (textContainsIndicatorCode(upper, fp)) return true;\n    } else if (lower.includes(fp.toLowerCase())) {\n      return true;\n    }\n  }\n  // Indicator-code scan stays case-sensitive — SDMX codes are uppercase by\n  // spec and lowercasing would false-positive on English prose.\n  for (const code of IMF_INDICATOR_CODES) {\n    if (textContainsIndicatorCode(text, code)) return true;\n  }\n  return false;\n}\n\n/**\n * OR-gate: verify that a policy article (or its linked analysis\n * artefacts) cites **either** World Bank OR IMF evidence. Wired into\n * the strict CLI validator (`src/utils/validate-articles.ts`) as the\n * default economic-context gate — an article satisfies the rule when\n * {@link hasWorldBankEvidence} OR {@link hasIMFEvidence} returns\n * `true`, or when `articleType` is not on the mandatory list.\n *\n * @param html - Article HTML or aggregated text including analysis files.\n * @param articleType - Slug of the article category (e.g. `\"committee-reports\"`).\n * @returns `true` when at least one of WB or IMF evidence is present,\n *   or when the article type is not on the mandatory list.\n */\nexport function articlePolicyHasEconomicContext(html: string, articleType: string): boolean {\n  if (!POLICY_SLUGS_REQUIRING_WORLD_BANK.has(articleType)) return true;\n  return hasWorldBankEvidence(html) || hasIMFEvidence(html);\n}\n\n/**\n * Validate the quality of a generated article.\n *\n * Checks performed:\n * - Minimum word count threshold for the given article type\n * - Presence of un-replaced placeholder/template markers\n * - Existence of required structural HTML elements\n * - Language attribute consistency (`lang` and `dir`)\n * - Read-time accuracy (computed vs claimed)\n * - Meta tag synchronization (title/OG/Twitter alignment)\n * - Keyword localization for non-English articles\n *\n * @param html - Complete HTML string of the generated article\n * @param language - Language code of the article (e.g. `\"en\"`, `\"de\"`, `\"ar\"`)\n * @param articleType - Article category string (e.g. `\"week-ahead\"`)\n * @returns Structured validation result with errors, warnings and metrics\n */\nexport function validateArticleContent(\n  html: string,\n  language: string,\n  articleType: string\n): ContentValidationResult {\n  const warnings: string[] = [];\n  const errors: string[] = [];\n\n  // Word count check\n  const wordCount = countWordsInHtml(html);\n  const minWords = MIN_WORD_COUNTS[articleType] ?? DEFAULT_MIN_WORDS;\n\n  if (wordCount < minWords) {\n    warnings.push(\n      `Content too short: ${wordCount} words (minimum ${minWords} for \"${articleType}\")`\n    );\n  }\n\n  // Placeholder detection\n  const hasPlaceholders = detectPlaceholders(html);\n  if (hasPlaceholders) {\n    errors.push('Un-replaced template placeholder(s) detected in generated content');\n  }\n\n  // Required HTML elements\n  const missingElements = findMissingElements(html);\n  const htmlValid = missingElements.length === 0;\n  if (!htmlValid) {\n    errors.push(`Missing required HTML element(s): ${missingElements.join(', ')}`);\n  }\n\n  // Read-time accuracy\n  const computedReadTime = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE));\n  const claimedReadTime = extractClaimedReadTime(html);\n  if (claimedReadTime > 0 && Math.abs(computedReadTime - claimedReadTime) > READ_TIME_TOLERANCE) {\n    warnings.push(\n      `Read-time mismatch: claimed ${claimedReadTime} min but content is ~${computedReadTime} min (${wordCount} words)`\n    );\n  }\n\n  // Language attribute check\n  const langAttr = extractLangAttribute(html);\n  const langAttributeValid = langAttr === language;\n  if (!langAttributeValid && langAttr) {\n    warnings.push(\n      `Language attribute mismatch: <html lang=\"${langAttr}\"> but expected \"${language}\"`\n    );\n  } else if (!langAttr) {\n    warnings.push('Missing lang attribute on <html> element');\n  }\n\n  // Dir attribute check for RTL languages\n  const dirAttr = extractDirAttribute(html);\n  const isRtl = RTL_LANGUAGES.has(language);\n  const dirAttributeValid = isRtl ? dirAttr === 'rtl' : dirAttr !== 'rtl';\n  if (isRtl && dirAttr !== 'rtl') {\n    warnings.push(\n      `RTL language \"${language}\" should have dir=\"rtl\" but found dir=\"${dirAttr || '(none)'}\"`\n    );\n  }\n\n  // Meta tag synchronization\n  const metaTagsSynced = checkMetaTagSync(html);\n  if (!metaTagsSynced) {\n    warnings.push(\n      'Meta tag mismatch: title, og:title, twitter:title, or descriptions are not synchronized'\n    );\n  }\n\n  // Keyword localization\n  const keywordsLocalized = checkKeywordLocalization(html, language);\n  if (!keywordsLocalized) {\n    warnings.push(\n      `Keywords for \"${language}\" article appear to be entirely in English — consider localizing`\n    );\n  }\n\n  // Extended validation: cross-reference density, stakeholder balance, temporal coverage\n  collectExtendedValidationWarnings(html, warnings);\n\n  // Machine-enforceable article quality gates\n  collectQualityGateWarnings(html, warnings);\n\n  return {\n    valid: errors.length === 0,\n    warnings,\n    errors,\n    metrics: {\n      wordCount,\n      htmlValid,\n      hasPlaceholders,\n      computedReadTime,\n      claimedReadTime,\n      langAttributeValid,\n      dirAttributeValid,\n      metaTagsSynced,\n      keywordsLocalized,\n    },\n  };\n}\n\n// ─── Translation validation helpers ───────────────────────────────────────────\n\n/**\n * Extract plain body text from `<main>` for character-class analysis.\n * Strips all HTML tags and normalises whitespace.\n *\n * @param html - Raw HTML string\n * @returns Plain text content from the main element\n */\nfunction extractMainPlainText(html: string): string {\n  const mainMatch = /<main[^>]*>([\\s\\S]*?)<\\/main>/u.exec(html);\n  const source = mainMatch?.[1] ?? html;\n  return stripScriptBlocks(source)\n    .replace(/<[^>]+>/gu, ' ')\n    .replace(/&(?:[a-z][a-z0-9]+|#\\d+|#x[0-9a-f]+);/giu, ' ')\n    .replace(/\\s+/gu, ' ')\n    .trim();\n}\n\n/**\n * Compute the ratio of ASCII printable characters (0x20–0x7E) in a string.\n *\n * @param text - Plain text string\n * @returns Ratio from 0 to 1 (1 = all ASCII)\n */\nfunction computeAsciiRatio(text: string): number {\n  if (text.length === 0) return 0;\n  const asciiCount = (text.match(/[\\x20-\\x7E]/gu) ?? []).length;\n  // Use Array.from to correctly count Unicode characters (handles surrogate pairs)\n  const charCount = Array.from(text).length;\n  return asciiCount / charCount;\n}\n\n/**\n * Compute the ratio of CJK Unified Ideograph characters in a string.\n * Covers CJK Unified Ideographs (U+4E00–U+9FFF), Extension A (U+3400–U+4DBF),\n * Hiragana, Katakana, and Hangul Syllables.\n *\n * @param text - Plain text string\n * @returns Ratio from 0 to 1\n */\nfunction computeCjkCharRatio(text: string): number {\n  if (text.length === 0) return 0;\n  // CJK Unified Ideographs + Extension A, Hiragana, Katakana, Hangul Syllables\n  // Note: Extension B (U+20000–U+2A6DF) is omitted as it triggers unsafe-regex lint\n  // and is extremely rare in EU Parliament content.\n  const cjkPattern = /[\\u4E00-\\u9FFF\\u3400-\\u4DBF\\u3040-\\u309F\\u30A0-\\u30FF\\uAC00-\\uD7AF]/gu;\n  const matches = text.match(cjkPattern);\n  // Use Array.from to correctly count Unicode characters (handles surrogate pairs)\n  const charCount = Array.from(text).length;\n  return (matches?.length ?? 0) / charCount;\n}\n\n/**\n * Detect common English phrases that should have been translated in non-English articles.\n *\n * @param text - Plain text content\n * @returns Array of detected untranslated English phrases\n */\nfunction findUntranslatedPhrases(text: string): string[] {\n  const lowerText = text.toLowerCase();\n  return ENGLISH_PLACEHOLDER_PHRASES.filter((phrase) => lowerText.includes(phrase.toLowerCase()));\n}\n\n/**\n * Check whether Unicode bidirectional control characters or HTML bidi markers are present.\n *\n * @param html - Raw HTML string\n * @returns true if bidi markers or control characters are found\n */\nfunction detectBidiMarkers(html: string): boolean {\n  // Unicode bidi control characters: LRM, RLM, LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI\n  const bidiControlPattern = /[\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]/u;\n  // HTML entities: &lrm; &rlm;\n  const bidiEntityPattern = /&(?:lrm|rlm);/iu;\n  return bidiControlPattern.test(html) || bidiEntityPattern.test(html);\n}\n\n/**\n * Validate translation completeness and cultural adaptation for a generated article.\n *\n * Checks performed:\n * - **RTL languages (ar, he)**: Verify `dir=\"rtl\"` on `<html>`, detect bidi control markers\n * - **CJK languages (ja, ko, zh)**: Check that content has sufficient CJK character density\n *   (high ASCII ratio suggests the article was not actually translated)\n * - **All non-English**: Detect common English phrases that should have been translated\n *\n * This function is purely analytical — no AI calls. It produces warnings only\n * and never blocks article generation.\n *\n * @param html - Complete HTML string of the generated article\n * @param lang - Language code of the article (e.g. `\"ar\"`, `\"ja\"`, `\"en\"`)\n * @returns Structured translation validation result with warnings and metrics\n */\nexport function validateTranslationCompleteness(\n  html: string,\n  lang: string\n): TranslationValidationResult {\n  const warnings: string[] = [];\n\n  const plainText = extractMainPlainText(html);\n  const asciiRatio = computeAsciiRatio(plainText);\n  const cjkCharRatio = computeCjkCharRatio(plainText);\n  const htmlDir = extractDirAttribute(html);\n  const hasRtlDir = htmlDir === 'rtl';\n  const hasBidiMarkers = detectBidiMarkers(html);\n  const untranslatedPhrases = findUntranslatedPhrases(plainText);\n\n  // Skip validation warnings for English — it is the source language,\n  // but still compute and return real metrics for telemetry/reporting.\n  if (lang === 'en') {\n    return {\n      valid: true,\n      warnings: [],\n      metrics: {\n        asciiRatio,\n        cjkCharRatio,\n        hasRtlDir,\n        hasBidiMarkers,\n        untranslatedPhrases,\n      },\n    };\n  }\n\n  // ── RTL validation ──────────────────────────────────────────────────────\n  if (RTL_LANGUAGES.has(lang) && !hasRtlDir) {\n    if (!htmlDir) {\n      warnings.push(\n        `Translation quality: RTL language \"${lang}\" missing dir=\"rtl\" on <html> element`\n      );\n    } else {\n      warnings.push(\n        `Translation quality: RTL language \"${lang}\" expected dir=\"rtl\" on <html> element but found dir=\"${htmlDir}\"`\n      );\n    }\n  }\n\n  // ── CJK density check ──────────────────────────────────────────────────\n  if (CJK_LANGUAGES.has(lang) && plainText.length > 0) {\n    if (asciiRatio > CJK_ASCII_RATIO_THRESHOLD) {\n      warnings.push(\n        `Translation quality: ${lang.toUpperCase()} article has ${(asciiRatio * 100).toFixed(0)}% ASCII characters — content may be untranslated`\n      );\n    }\n    if (cjkCharRatio < CJK_CHAR_RATIO_THRESHOLD) {\n      warnings.push(\n        `Translation quality: ${lang.toUpperCase()} article has only ${(cjkCharRatio * 100).toFixed(1)}% CJK characters — expected native script content`\n      );\n    }\n  }\n\n  // ── Untranslated English phrase detection ───────────────────────────────\n  if (untranslatedPhrases.length > 0) {\n    warnings.push(\n      `Translation quality: found ${untranslatedPhrases.length} likely untranslated English phrase(s): ${untranslatedPhrases.slice(0, 3).join(', ')}`\n    );\n  }\n\n  return {\n    valid: warnings.length === 0,\n    warnings,\n    metrics: {\n      asciiRatio,\n      cjkCharRatio,\n      hasRtlDir,\n      hasBidiMarkers,\n      untranslatedPhrases,\n    },\n  };\n}\n\n// ─── Extended validation rules ────────────────────────────────────────────────\n\n/**\n * Collect warnings from extended validation rules (cross-reference density,\n * stakeholder group balance, temporal coverage). Extracted to keep\n * {@link validateArticleContent} within cognitive-complexity limits.\n *\n * @param html - Complete article HTML\n * @param warnings - Mutable array to push warning strings into\n */\nfunction collectExtendedValidationWarnings(html: string, warnings: string[]): void {\n  const crossRefWarning = validateCrossReferenceDensity(html);\n  if (crossRefWarning) warnings.push(crossRefWarning);\n\n  const balanceWarning = validateStakeholderGroupBalance(html);\n  if (balanceWarning) warnings.push(balanceWarning);\n\n  const temporalWarning = validateTemporalCoverage(html);\n  if (temporalWarning) warnings.push(temporalWarning);\n}\n\n/**\n * Patterns matching known EP document reference formats.\n * Uses separate patterns to avoid alternation complexity.\n * Covers: TA-10-2026-0123, PE-123, PE-123.456, A9-0123, B9-0123, C9-0123, P9_TA(2024)0001\n */\nconst CV_EP_DOC_PATTERNS: ReadonlyArray<RegExp> = [\n  /\\bTA-\\d+-\\d+-\\d+\\b/gu,\n  /\\bPE-\\d+\\.\\d+\\b/gu,\n  /\\bPE-\\d+(?!\\.\\d)\\b/gu,\n  /\\b[A-C]\\d-\\d+\\b/gu,\n  /\\bP\\d_TA\\(\\d{4}\\)\\d+\\b/gu,\n];\n\n/** Pattern matching EP legislative procedure references (e.g. 2024/0001(COD)) */\nconst CV_PROCEDURE_REF_PATTERN = /\\b\\d{4}\\/\\d+\\([A-Z]{2,4}\\)/gu;\n\n/**\n * Known EP political groups for stakeholder balance validation.\n * Each entry must be a single canonical, non-overlapping group name so\n * independent matching does not double-count aliases such as \"Renew Europe\"\n * and \"Renew\".\n */\nconst EP_POLITICAL_GROUPS: ReadonlyArray<string> = [\n  'EPP',\n  'S&D',\n  'Renew Europe',\n  'Greens/EFA',\n  'ECR',\n  'Identity and Democracy',\n  'The Left',\n  'Patriots for Europe',\n];\n\n/** Patterns indicating forward-looking content (temporal coverage validation) */\nconst FORWARD_LOOKING_PATTERNS: ReadonlyArray<RegExp> = [\n  /\\bforecast\\b/iu,\n  /\\bprojection\\b/iu,\n  /\\bexpected\\b/iu,\n  /\\banticipated\\b/iu,\n  /\\bupcoming\\b/iu,\n  /\\bfuture\\b/iu,\n  /\\bscenario\\b/iu,\n  /\\bcould\\b/iu,\n  /\\bwill\\b/iu,\n  /\\boutlook\\b/iu,\n  /\\bpredict\\b/iu,\n  /\\bnext\\b/iu,\n  /\\bforthcoming\\b/iu,\n];\n\n/**\n * Validate that an article cites a minimum number of EP document references\n * (TA-, PE-, A9-, procedure IDs). Articles lacking document citations may rely\n * on unsupported assertions.\n *\n * @param html - Complete article HTML\n * @param minRefs - Minimum number of unique EP references required (default: 2)\n * @returns Warning message if density is insufficient, or null if acceptable\n */\nexport function validateCrossReferenceDensity(html: string, minRefs = 2): string | null {\n  const htmlNoScripts = stripScriptBlocks(html);\n  const found = new Set<string>();\n\n  for (const pattern of CV_EP_DOC_PATTERNS) {\n    pattern.lastIndex = 0;\n    const hits = htmlNoScripts.match(pattern);\n    if (hits) {\n      for (const hit of hits) found.add(hit);\n    }\n  }\n\n  CV_PROCEDURE_REF_PATTERN.lastIndex = 0;\n  const procHits = htmlNoScripts.match(CV_PROCEDURE_REF_PATTERN);\n  if (procHits) {\n    for (const hit of procHits) found.add(hit);\n  }\n\n  if (found.size < minRefs) {\n    return `Cross-reference density too low: ${found.size} EP document reference(s) found (minimum ${minRefs} required)`;\n  }\n  return null;\n}\n\n/**\n * Validate that no single EP political group dominates the article's coverage.\n * Single-group dominance (one group with > 60% of all group mentions) may indicate\n * bias or unbalanced perspective coverage.\n *\n * @param html - Complete article HTML\n * @returns Warning message if one group dominates, or null if balanced\n */\nexport function validateStakeholderGroupBalance(html: string): string | null {\n  const text = stripScriptBlocks(html)\n    .replace(/<[^>]+>/gu, ' ')\n    .replace(/\\s+/gu, ' ');\n\n  const counts: Record<string, number> = Object.create(null) as Record<string, number>;\n  let total = 0;\n  for (const group of EP_POLITICAL_GROUPS) {\n    const escaped = group.replace(/[.*+?^${}()|[\\]\\\\]/gu, '\\\\$&');\n    // eslint-disable-next-line security/detect-non-literal-regexp\n    const pattern = new RegExp(`\\\\b${escaped}\\\\b`, 'giu');\n    const matches = text.match(pattern);\n    const count = matches?.length ?? 0;\n    counts[group] = count;\n    total += count;\n  }\n\n  if (total < 3) return null; // Too few mentions to assess balance\n\n  for (const group of EP_POLITICAL_GROUPS) {\n    const groupCount = counts[group] ?? 0;\n    if (groupCount / total > 0.6) {\n      return `Stakeholder balance concern: \"${group}\" accounts for ${Math.round((groupCount / total) * 100)}% of political group mentions — consider covering other groups`;\n    }\n  }\n  return null;\n}\n\n/**\n * Validate that an article includes forward-looking content (temporal coverage).\n * Articles lacking any forward-looking language may not provide actionable intelligence.\n *\n * @param html - Complete article HTML\n * @returns Warning message if no forward-looking content is detected, or null if present\n */\nexport function validateTemporalCoverage(html: string): string | null {\n  const text = stripScriptBlocks(html)\n    .replace(/<[^>]+>/gu, ' ')\n    .replace(/\\s+/gu, ' ')\n    .toLowerCase();\n\n  const hasForwardLooking = FORWARD_LOOKING_PATTERNS.some((pattern) => pattern.test(text));\n\n  if (!hasForwardLooking) {\n    return 'Temporal coverage: article lacks forward-looking content — add forecasts, scenarios, or outlook sections for actionable intelligence';\n  }\n  return null;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/copy-test-reports.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":145,"column":5,"endLine":145,"endColumn":17}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"#!/usr/bin/env node\n\n// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/CopyTestReports\n * @description Copies test reports and coverage data to the docs/ directory\n * for inclusion in the documentation bundle. Generates comprehensive HTML\n * index pages for test results with links to all available reports.\n */\n\nimport { promises as fs } from 'fs';\nimport { join, resolve } from 'path';\nimport { pathToFileURL } from 'url';\nimport { PROJECT_ROOT } from '../constants/config.js';\n\nconst DOCS_DIR = join(PROJECT_ROOT, 'docs');\nconst BUILDS_DIR = join(PROJECT_ROOT, 'builds');\nconst TEST_RESULTS_DIRNAME = 'test-results';\nconst INDEX_FILENAME = 'index.html';\nconst ESLINT_REPORT_BASE = 'eslint-report';\nconst E2E_PREFIX = 'e2e-';\nconst PLAYWRIGHT_REPORT_DIRNAME = 'playwright-report';\n\n/**\n * Recursively copy directory\n *\n * @param src - Source directory\n * @param dest - Destination directory\n */\nexport async function copyDirectory(src: string, dest: string): Promise<void> {\n  try {\n    await fs.mkdir(dest, { recursive: true });\n\n    const entries = await fs.readdir(src, { withFileTypes: true });\n\n    for (const entry of entries) {\n      const srcPath = join(src, entry.name);\n      const destPath = join(dest, entry.name);\n\n      if (entry.isDirectory()) {\n        await copyDirectory(srcPath, destPath);\n      } else {\n        await fs.copyFile(srcPath, destPath);\n      }\n    }\n  } catch (error) {\n    const nodeError = error as NodeJS.ErrnoException;\n    if (nodeError.code === 'ENOENT') {\n      console.warn(`  ⚠️  Source directory not found (skipped): ${src}`);\n    } else {\n      throw error;\n    }\n  }\n}\n\n/**\n * Copy a single file safely\n *\n * @param src - Source file path\n * @param dest - Destination file path\n * @returns Whether the copy succeeded\n */\nasync function copyFileSafe(src: string, dest: string): Promise<boolean> {\n  try {\n    await fs.copyFile(src, dest);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/**\n * Check if a file or directory exists\n *\n * @param path - Path to check\n * @returns Whether the path exists\n */\nasync function pathExists(path: string): Promise<boolean> {\n  try {\n    await fs.access(path);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\n/** Report file names within test-results directory */\nconst REPORT_FILES = {\n  vitestHtml: join('html', INDEX_FILENAME),\n  vitestJson: 'results.json',\n  vitestJunit: 'junit.xml',\n  e2eJson: `${E2E_PREFIX}results.json`,\n  e2eJunit: `${E2E_PREFIX}junit.xml`,\n  eslintHtml: `${ESLINT_REPORT_BASE}.html`,\n  eslintJson: `${ESLINT_REPORT_BASE}.json`,\n} as const;\n\n/** Shape of vitest JSON results (subset) */\ninterface VitestJsonResults {\n  numTotalTests?: number;\n  numPassedTests?: number;\n  numFailedTests?: number;\n  testResults?: Array<{ perfStats?: { end?: number; start?: number } }>;\n}\n\n/** Shape of Playwright JSON results (subset) */\ninterface PlaywrightJsonResults {\n  stats?: { expected?: number; unexpected?: number; flaky?: number; skipped?: number };\n}\n\n/** Summary of test run counts */\ninterface TestSummary {\n  tests: number;\n  passed: number;\n  failed: number;\n  duration?: number;\n}\n\n/** Report availability and summary data */\ninterface ReportInfo {\n  hasVitestHtml: boolean;\n  hasVitestJson: boolean;\n  hasVitestJunit: boolean;\n  hasE2eJson: boolean;\n  hasE2eJunit: boolean;\n  hasEslintHtml: boolean;\n  hasEslintJson: boolean;\n  hasCoverage: boolean;\n  hasPlaywright: boolean;\n  vitestSummary: TestSummary | null;\n  e2eSummary: TestSummary | null;\n}\n\n/**\n * Check which report files exist in the test-results directory\n *\n * @param testResultsDir - Path to test-results directory\n * @returns Map of report key to existence boolean\n */\nasync function checkReportFiles(testResultsDir: string): Promise<Record<string, boolean>> {\n  const results: Record<string, boolean> = {};\n  for (const [key, relativePath] of Object.entries(REPORT_FILES)) {\n    results[key] = await pathExists(join(testResultsDir, relativePath));\n  }\n  return results;\n}\n\n/**\n * Parse vitest JSON results file for summary data\n *\n * @param filePath - Path to vitest results.json\n * @returns Summary or null if unavailable\n */\nasync function parseVitestSummary(filePath: string): Promise<TestSummary | null> {\n  try {\n    const raw = await fs.readFile(filePath, 'utf8');\n    const data = JSON.parse(raw) as VitestJsonResults;\n    const total = data.numTotalTests ?? 0;\n    const passed = data.numPassedTests ?? 0;\n    const failed = data.numFailedTests ?? 0;\n    let duration = 0;\n    if (Array.isArray(data.testResults)) {\n      for (const r of data.testResults) {\n        if (r.perfStats?.end && r.perfStats?.start) {\n          duration += r.perfStats.end - r.perfStats.start;\n        }\n      }\n    }\n    return { tests: total, passed, failed, duration: Math.round(duration) };\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Parse Playwright JSON results file for summary data\n *\n * @param filePath - Path to e2e-results.json\n * @returns Summary or null if unavailable\n */\nasync function parseE2eSummary(filePath: string): Promise<TestSummary | null> {\n  try {\n    const raw = await fs.readFile(filePath, 'utf8');\n    const data = JSON.parse(raw) as PlaywrightJsonResults;\n    if (data.stats) {\n      const passed = data.stats.expected ?? 0;\n      const failed = data.stats.unexpected ?? 0;\n      const flaky = data.stats.flaky ?? 0;\n      return { tests: passed + failed + flaky, passed, failed };\n    }\n    return null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Gather report availability info for index generation\n *\n * @returns Object describing which reports are available\n */\nasync function gatherReportInfo(): Promise<ReportInfo> {\n  const testResultsDir = join(DOCS_DIR, TEST_RESULTS_DIRNAME);\n  const fileStatus = await checkReportFiles(testResultsDir);\n\n  const info: ReportInfo = {\n    hasVitestHtml: fileStatus['vitestHtml'] ?? false,\n    hasVitestJson: fileStatus['vitestJson'] ?? false,\n    hasVitestJunit: fileStatus['vitestJunit'] ?? false,\n    hasE2eJson: fileStatus['e2eJson'] ?? false,\n    hasE2eJunit: fileStatus['e2eJunit'] ?? false,\n    hasEslintHtml: fileStatus['eslintHtml'] ?? false,\n    hasEslintJson: fileStatus['eslintJson'] ?? false,\n    hasCoverage: await pathExists(join(DOCS_DIR, 'coverage', INDEX_FILENAME)),\n    hasPlaywright: await pathExists(join(DOCS_DIR, PLAYWRIGHT_REPORT_DIRNAME, INDEX_FILENAME)),\n    vitestSummary: null,\n    e2eSummary: null,\n  };\n\n  if (info.hasVitestJson) {\n    info.vitestSummary = await parseVitestSummary(join(testResultsDir, REPORT_FILES.vitestJson));\n  }\n\n  if (info.hasE2eJson) {\n    info.e2eSummary = await parseE2eSummary(join(testResultsDir, REPORT_FILES.e2eJson));\n  }\n\n  return info;\n}\n\n/**\n * Build stat-card HTML for a test summary\n *\n * @param summary - Test summary data\n * @param isE2e - Whether this is an E2E summary\n * @returns HTML string for stat cards\n */\nfunction buildStatCards(summary: TestSummary, isE2e: boolean): string {\n  const failedClass = summary.failed > 0 ? 'failed' : 'passed';\n  const durationCard =\n    summary.duration !== undefined\n      ? `<div class=\"stat-card\">\n        <div class=\"stat-number\">${(summary.duration / 1000).toFixed(1)}s</div>\n        <div class=\"stat-label\">Duration</div>\n      </div>`\n      : '';\n  const totalLabel = isE2e ? 'Total E2E' : 'Total Tests';\n  return `<div class=\"stat-card passed\">\n        <div class=\"stat-number\">${summary.passed}</div>\n        <div class=\"stat-label\">Passed</div>\n      </div>\n      <div class=\"stat-card ${failedClass}\">\n        <div class=\"stat-number\">${summary.failed}</div>\n        <div class=\"stat-label\">Failed</div>\n      </div>\n      <div class=\"stat-card\">\n        <div class=\"stat-number\">${summary.tests}</div>\n        <div class=\"stat-label\">${totalLabel}</div>\n      </div>${durationCard}`;\n}\n\n/**\n * Create a comprehensive HTML index for test results\n *\n * @param info - Report availability information\n * @returns HTML content\n */\nfunction createTestResultsIndex(info: ReportInfo): string {\n  const currentDate = new Date().toISOString().split('T')[0] ?? '';\n\n  const vitestStats = info.vitestSummary ? buildStatCards(info.vitestSummary, false) : '';\n\n  const e2eStats = info.e2eSummary ? buildStatCards(info.e2eSummary, true) : '';\n\n  return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <meta name=\"description\" content=\"EU Parliament Monitor - Comprehensive Test Results and Quality Reports\">\n  <title>Test Results - EU Parliament Monitor</title>\n  <style>\n    * { margin: 0; padding: 0; box-sizing: border-box; }\n    body {\n      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;\n      line-height: 1.6;\n      color: #333;\n      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n      min-height: 100vh;\n      padding: 2rem;\n    }\n    .container {\n      max-width: 1200px;\n      margin: 0 auto;\n      background: white;\n      border-radius: 12px;\n      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n      overflow: hidden;\n    }\n    header {\n      background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);\n      color: white;\n      padding: 2.5rem 2rem;\n      text-align: center;\n    }\n    h1 { font-size: 2.2rem; margin-bottom: 0.5rem; font-weight: 700; }\n    .subtitle { font-size: 1.1rem; opacity: 0.9; }\n    .last-updated { font-size: 0.85rem; opacity: 0.7; margin-top: 0.75rem; }\n    main { padding: 2rem; }\n    h2 { color: #1e3c72; font-size: 1.5rem; margin: 2rem 0 1rem; border-bottom: 2px solid #e9ecef; padding-bottom: 0.5rem; }\n    .stats-grid {\n      display: flex;\n      gap: 1rem;\n      flex-wrap: wrap;\n      margin-bottom: 1.5rem;\n    }\n    .stat-card {\n      background: #f8f9fa;\n      border: 2px solid #e9ecef;\n      border-radius: 8px;\n      padding: 1rem 1.5rem;\n      text-align: center;\n      min-width: 100px;\n      flex: 1;\n    }\n    .stat-card.passed { border-color: #28a745; background: #d4edda; }\n    .stat-card.failed { border-color: #dc3545; background: #f8d7da; }\n    .stat-number { font-size: 1.8rem; font-weight: 700; color: #1e3c72; }\n    .stat-label { font-size: 0.85rem; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }\n    .reports-grid {\n      display: grid;\n      grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\n      gap: 1rem;\n      margin: 1rem 0;\n    }\n    .report-card {\n      background: #f8f9fa;\n      border: 2px solid #e9ecef;\n      border-radius: 8px;\n      padding: 1.25rem;\n      transition: all 0.3s ease;\n      text-decoration: none;\n      color: inherit;\n      display: block;\n    }\n    .report-card:hover {\n      transform: translateY(-3px);\n      box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);\n      border-color: #667eea;\n    }\n    .report-card.unavailable {\n      opacity: 0.5;\n      pointer-events: none;\n    }\n    .report-card h3 { color: #1e3c72; font-size: 1.1rem; margin-bottom: 0.25rem; }\n    .report-card .icon { font-size: 1.5rem; margin-bottom: 0.25rem; }\n    .report-card p { color: #666; font-size: 0.85rem; line-height: 1.4; }\n    .badge {\n      display: inline-block;\n      padding: 0.15rem 0.5rem;\n      border-radius: 10px;\n      font-size: 0.7rem;\n      font-weight: 600;\n      margin-top: 0.5rem;\n    }\n    .badge-html { background: #667eea; color: white; }\n    .badge-json { background: #28a745; color: white; }\n    .badge-xml { background: #fd7e14; color: white; }\n    .badge-vitest { background: #fcc72b; color: #333; }\n    .badge-playwright { background: #2ead33; color: white; }\n    .badge-eslint { background: #4b32c3; color: white; }\n    footer {\n      background: #f8f9fa;\n      padding: 1.5rem 2rem;\n      text-align: center;\n      color: #666;\n      border-top: 1px solid #e9ecef;\n    }\n    footer a { color: #667eea; text-decoration: none; }\n    footer a:hover { text-decoration: underline; }\n    @media (max-width: 768px) {\n      body { padding: 1rem; }\n      h1 { font-size: 1.8rem; }\n      .reports-grid { grid-template-columns: 1fr; }\n      .stats-grid { flex-direction: column; }\n    }\n  </style>\n</head>\n<body>\n  <div class=\"container\">\n    <header>\n      <h1>📊 Test Results &amp; Quality Reports</h1>\n      <div class=\"subtitle\">EU Parliament Monitor - Comprehensive Test Dashboard</div>\n      <div class=\"last-updated\">Generated: ${currentDate}</div>\n    </header>\n\n    <main>\n      ${vitestStats ? `<h2>🧪 Unit &amp; Integration Tests (Vitest)</h2><div class=\"stats-grid\">${vitestStats}</div>` : ''}\n\n      ${e2eStats ? `<h2>🎭 End-to-End Tests (Playwright)</h2><div class=\"stats-grid\">${e2eStats}</div>` : ''}\n\n      <h2>📋 Interactive Reports</h2>\n      <div class=\"reports-grid\">\n        <a href=\"html/index.html\" class=\"report-card${info.hasVitestHtml ? '' : ' unavailable'}\">\n          <div class=\"icon\">🧪</div>\n          <h3>Vitest HTML Report</h3>\n          <p>Interactive test explorer with detailed results for all unit and integration tests.</p>\n          <span class=\"badge badge-vitest\">Vitest</span>\n          <span class=\"badge badge-html\">HTML</span>\n        </a>\n\n        <a href=\"../coverage/index.html\" class=\"report-card${info.hasCoverage ? '' : ' unavailable'}\">\n          <div class=\"icon\">📊</div>\n          <h3>Code Coverage Report</h3>\n          <p>Line, branch, function, and statement coverage with per-file drill-down.</p>\n          <span class=\"badge badge-vitest\">Vitest V8</span>\n          <span class=\"badge badge-html\">HTML</span>\n        </a>\n\n        <a href=\"../playwright-report/index.html\" class=\"report-card${info.hasPlaywright ? '' : ' unavailable'}\">\n          <div class=\"icon\">🎭</div>\n          <h3>Playwright E2E Report</h3>\n          <p>End-to-end test results with screenshots, traces, and accessibility checks.</p>\n          <span class=\"badge badge-playwright\">Playwright</span>\n          <span class=\"badge badge-html\">HTML</span>\n        </a>\n\n        <a href=\"eslint-report.html\" class=\"report-card${info.hasEslintHtml ? '' : ' unavailable'}\">\n          <div class=\"icon\">🔍</div>\n          <h3>ESLint Report</h3>\n          <p>Static analysis results showing code quality issues, warnings, and style compliance.</p>\n          <span class=\"badge badge-eslint\">ESLint</span>\n          <span class=\"badge badge-html\">HTML</span>\n        </a>\n      </div>\n\n      <h2>📁 Machine-Readable Reports</h2>\n      <div class=\"reports-grid\">\n        <a href=\"results.json\" class=\"report-card${info.hasVitestJson ? '' : ' unavailable'}\">\n          <div class=\"icon\">📄</div>\n          <h3>Vitest Results (JSON)</h3>\n          <p>Machine-readable unit test results in JSON format for CI/CD integration.</p>\n          <span class=\"badge badge-vitest\">Vitest</span>\n          <span class=\"badge badge-json\">JSON</span>\n        </a>\n\n        <a href=\"junit.xml\" class=\"report-card${info.hasVitestJunit ? '' : ' unavailable'}\">\n          <div class=\"icon\">📄</div>\n          <h3>Vitest Results (JUnit XML)</h3>\n          <p>JUnit XML format for CI dashboard integration and test trend analysis.</p>\n          <span class=\"badge badge-vitest\">Vitest</span>\n          <span class=\"badge badge-xml\">XML</span>\n        </a>\n\n        <a href=\"e2e-results.json\" class=\"report-card${info.hasE2eJson ? '' : ' unavailable'}\">\n          <div class=\"icon\">📄</div>\n          <h3>E2E Results (JSON)</h3>\n          <p>Playwright end-to-end test results in JSON format with detailed timing data.</p>\n          <span class=\"badge badge-playwright\">Playwright</span>\n          <span class=\"badge badge-json\">JSON</span>\n        </a>\n\n        <a href=\"e2e-junit.xml\" class=\"report-card${info.hasE2eJunit ? '' : ' unavailable'}\">\n          <div class=\"icon\">📄</div>\n          <h3>E2E Results (JUnit XML)</h3>\n          <p>E2E test results in JUnit XML format for cross-platform CI integration.</p>\n          <span class=\"badge badge-playwright\">Playwright</span>\n          <span class=\"badge badge-xml\">XML</span>\n        </a>\n\n        <a href=\"eslint-report.json\" class=\"report-card${info.hasEslintJson ? '' : ' unavailable'}\">\n          <div class=\"icon\">📄</div>\n          <h3>ESLint Report (JSON)</h3>\n          <p>Detailed linting results in JSON format for automated quality gates.</p>\n          <span class=\"badge badge-eslint\">ESLint</span>\n          <span class=\"badge badge-json\">JSON</span>\n        </a>\n      </div>\n    </main>\n\n    <footer>\n      <p><a href=\"../index.html\">← Back to Documentation Index</a></p>\n      <p style=\"margin-top: 0.5rem;\">\n        <strong>EU Parliament Monitor</strong> -\n        European Parliament Intelligence Platform\n      </p>\n    </footer>\n  </div>\n</body>\n</html>`;\n}\n\n/**\n * Main execution function\n */\nasync function main(): Promise<void> {\n  console.log('📋 Copying test reports to documentation directory...');\n\n  try {\n    await fs.mkdir(DOCS_DIR, { recursive: true });\n\n    // 1. Copy coverage report (from vitest)\n    const coverageSrc = join(BUILDS_DIR, 'coverage');\n    const coverageDest = join(DOCS_DIR, 'coverage');\n    console.log('  📊 Copying coverage report...');\n    await copyDirectory(coverageSrc, coverageDest);\n    console.log('  ✅ Coverage report copied');\n\n    // 2. Copy API documentation (from typedoc)\n    const apiSrc = join(BUILDS_DIR, 'api');\n    const apiDest = join(DOCS_DIR, 'api');\n    console.log('  📖 Copying API docs...');\n    await copyDirectory(apiSrc, apiDest);\n    console.log('  ✅ API docs copied');\n\n    // 3. Copy Playwright E2E report\n    const playwrightSrc = join(BUILDS_DIR, PLAYWRIGHT_REPORT_DIRNAME);\n    const playwrightDest = join(DOCS_DIR, PLAYWRIGHT_REPORT_DIRNAME);\n    console.log('  🎭 Copying Playwright report...');\n    await copyDirectory(playwrightSrc, playwrightDest);\n    console.log('  ✅ Playwright report copied');\n\n    // 4. Copy test results directory (vitest HTML, JSON, JUnit, ESLint reports)\n    const buildTestResultsDir = join(BUILDS_DIR, TEST_RESULTS_DIRNAME);\n    const docsTestResultsDir = join(DOCS_DIR, TEST_RESULTS_DIRNAME);\n    await fs.mkdir(docsTestResultsDir, { recursive: true });\n\n    // 4a. Copy vitest HTML test report\n    console.log('  🧪 Copying Vitest HTML report...');\n    await copyDirectory(join(buildTestResultsDir, 'html'), join(docsTestResultsDir, 'html'));\n    console.log('  ✅ Vitest HTML report copied');\n\n    // 4b-4g. Copy individual report files\n    const reportCopyTasks: ReadonlyArray<{ file: string; label: string; icon: string }> = [\n      { file: REPORT_FILES.vitestJson, label: 'Vitest JSON results', icon: '📄' },\n      { file: REPORT_FILES.vitestJunit, label: 'Vitest JUnit XML results', icon: '📄' },\n      { file: REPORT_FILES.e2eJson, label: 'E2E JSON results', icon: '📄' },\n      { file: REPORT_FILES.e2eJunit, label: 'E2E JUnit XML results', icon: '📄' },\n      { file: REPORT_FILES.eslintHtml, label: 'ESLint HTML report', icon: '🔍' },\n      { file: REPORT_FILES.eslintJson, label: 'ESLint JSON report', icon: '🔍' },\n    ];\n\n    for (const task of reportCopyTasks) {\n      console.log(`  ${task.icon} Copying ${task.label}...`);\n      if (\n        await copyFileSafe(\n          join(buildTestResultsDir, task.file),\n          join(docsTestResultsDir, task.file)\n        )\n      ) {\n        console.log(`  ✅ ${task.label} copied`);\n      } else {\n        console.warn(`  ⚠️  ${task.label} not found (skipped)`);\n      }\n    }\n\n    // 5. Gather report info and generate comprehensive index\n    console.log('  📊 Generating test results index...');\n    const reportInfo = await gatherReportInfo();\n    await fs.writeFile(\n      join(docsTestResultsDir, INDEX_FILENAME),\n      createTestResultsIndex(reportInfo),\n      'utf8'\n    );\n    console.log('  ✅ Test results index generated');\n\n    console.log('✅ All test reports copied successfully');\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.error('❌ Error copying test reports:', message);\n    process.exit(1);\n  }\n}\n\n// Only run main when executed directly (not when imported)\nif (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {\n  main();\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/file-utils.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":72,"column":5,"endLine":72,"endColumn":18},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":87,"column":20,"endLine":87,"endColumn":33},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":561,"column":10,"endLine":561,"endColumn":38}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/FileUtils\n * @description Shared file system utilities for news article operations\n */\n\nimport { randomUUID } from 'crypto';\nimport fs from 'fs';\nimport path from 'path';\nimport { NEWS_DIR, ARTICLE_FILENAME_PATTERN } from '../constants/config.js';\nimport { ALL_LANGUAGES } from '../constants/language-core.js';\nimport type { AnalysisFileEntry, LanguageCode, ParsedArticle } from '../types/index.js';\n\n/**\n * Get all news article HTML files from the news directory\n *\n * @param newsDir - News directory path (defaults to NEWS_DIR)\n * @returns List of article filenames\n */\nexport function getNewsArticles(newsDir: string = NEWS_DIR): string[] {\n  if (!fs.existsSync(newsDir)) {\n    console.log('📁 News directory does not exist yet');\n    return [];\n  }\n\n  const files = fs.readdirSync(newsDir);\n  return files.filter((f) => f.endsWith('.html') && !f.startsWith('index-'));\n}\n\n/**\n * Parse article filename to extract metadata\n *\n * @param filename - Article filename (e.g., \"2025-01-15-week-ahead-en.html\")\n * @returns Parsed metadata or null if filename doesn't match pattern\n */\nexport function parseArticleFilename(filename: string): ParsedArticle | null {\n  const match = filename.match(ARTICLE_FILENAME_PATTERN);\n\n  if (!match) {\n    return null;\n  }\n\n  const langCandidate = match[3] as string;\n  if (!ALL_LANGUAGES.includes(langCandidate as LanguageCode)) {\n    return null;\n  }\n\n  return {\n    date: match[1] as string,\n    slug: match[2] as string,\n    lang: langCandidate as LanguageCode,\n    filename,\n  };\n}\n\n/**\n * Group articles by language code\n *\n * @param articles - List of article filenames\n * @param languages - Supported language codes\n * @returns Articles grouped by language, sorted newest first\n */\nexport function groupArticlesByLanguage(\n  articles: string[],\n  languages: readonly string[]\n): Record<string, ParsedArticle[]> {\n  const grouped: Record<string, ParsedArticle[]> = {};\n\n  for (const lang of languages) {\n    grouped[lang] = [];\n  }\n\n  for (const article of articles) {\n    const parsed = parseArticleFilename(article);\n    if (parsed) {\n      const bucket = grouped[parsed.lang];\n      if (bucket) {\n        bucket.push(parsed);\n      }\n    }\n  }\n\n  // Sort by date (newest first)\n  for (const lang in grouped) {\n    const bucket = grouped[lang];\n    if (bucket) {\n      bucket.sort((a, b) => b.date.localeCompare(a.date));\n    }\n  }\n\n  return grouped;\n}\n\n/**\n * Format slug for display (hyphen-separated to Title Case)\n *\n * @param slug - Hyphen-separated slug string\n * @returns Formatted title string\n */\nexport function formatSlug(slug: string): string {\n  return slug\n    .split('-')\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n    .join(' ');\n}\n\n/**\n * Get file modification time as YYYY-MM-DD string\n *\n * @param filepath - Path to file\n * @returns Last modified date in YYYY-MM-DD format\n */\nexport function getModifiedDate(filepath: string): string {\n  const stats = fs.statSync(filepath);\n  return stats.mtime.toISOString().slice(0, 10);\n}\n\n/**\n * Format date for article slug\n *\n * @param date - Date to format (defaults to now)\n * @returns Formatted date string (YYYY-MM-DD)\n */\nexport function formatDateForSlug(date: Date = new Date()): string {\n  return date.toISOString().slice(0, 10);\n}\n\n/**\n * Calculate read time estimate from content\n *\n * @param content - Article content text\n * @param wordsPerMinute - Reading speed (default 250)\n * @returns Estimated read time in minutes\n */\nexport function calculateReadTime(content: string, wordsPerMinute: number = 250): number {\n  const words = content.split(/\\s+/).length;\n  return Math.ceil(words / wordsPerMinute);\n}\n\n/**\n * Ensure a directory exists, creating it recursively if needed\n *\n * @param dirPath - Directory path to ensure\n */\nexport function ensureDirectoryExists(dirPath: string): void {\n  if (!fs.existsSync(dirPath)) {\n    fs.mkdirSync(dirPath, { recursive: true });\n  }\n}\n\n/**\n * Attempt to atomically claim a directory by creating it non-recursively.\n *\n * @param dirPath - Directory path to claim\n * @returns `true` when the directory was created by this call, otherwise `false`\n */\nfunction claimDir(dirPath: string): boolean {\n  // Ensure parent exists (recursive: true never throws EEXIST)\n  fs.mkdirSync(path.dirname(dirPath), { recursive: true });\n  try {\n    // Non-recursive create: EEXIST means another run already claimed it\n    fs.mkdirSync(dirPath, { recursive: false });\n    return true;\n  } catch (err: unknown) {\n    if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n      return false;\n    }\n    throw err;\n  }\n}\n\n/**\n * Resolve a unique directory path by appending a numeric suffix (-2, -3, …)\n * when the preferred directory has already been claimed by a completed run.\n *\n * The base directory is treated as occupied when it contains `manifest.json`\n * (written at the end of a successful analysis run).  A directory without\n * `manifest.json` is considered available — this allows the `skipCompleted`\n * feature to resume an incomplete run in the same directory.\n *\n * Suffixed candidates (-2, -3, …) are claimed atomically via non-recursive\n * `mkdirSync`, preventing TOCTOU races when concurrent workflow runs\n * attempt to claim the same candidate.\n *\n * @param baseDir - The preferred directory path (e.g. `analysis/daily/2026-04-02/breaking`)\n * @returns The original `baseDir` when no completed run exists there, or a\n *          suffixed variant (e.g. `analysis/daily/2026-04-02/breaking-2`) otherwise.\n */\nexport function resolveUniqueAnalysisDir(baseDir: string): string {\n  // If the directory doesn't exist yet or has no manifest from a prior\n  // completed run, use it as-is.  This supports the skipCompleted feature\n  // which resumes an incomplete run in the same directory.\n  if (!fs.existsSync(path.join(baseDir, 'manifest.json'))) {\n    return baseDir;\n  }\n\n  // Directory already has a completed run — find the next available suffix.\n  // Use atomic mkdirSync to prevent TOCTOU races when parallel workflow\n  // runs attempt to claim the same suffixed candidate concurrently.\n  let suffix = 2;\n  const MAX_SUFFIX = 100;\n  while (suffix <= MAX_SUFFIX) {\n    const candidate = `${baseDir}-${suffix}`;\n    if (claimDir(candidate)) {\n      return candidate;\n    }\n    suffix++;\n  }\n\n  // Fallback: use UUID-suffixed directory to guarantee uniqueness\n  const candidate = `${baseDir}-${randomUUID().slice(0, 8)}`;\n  fs.mkdirSync(candidate, { recursive: true });\n  return candidate;\n}\n\n/**\n * Resolve a unique filename by appending a numeric suffix (-2, -3, …) before\n * the file extension when the file already exists.\n *\n * This prevents repeated workflow runs from overwriting previously committed\n * news articles.\n *\n * @param filepath - The preferred file path (e.g. `news/2026-04-02-breaking-en.html`)\n * @returns The original path when the file doesn't exist, or a suffixed\n *          variant (e.g. `news/2026-04-02-breaking-en-2.html`) otherwise.\n */\nexport function resolveUniqueFilePath(filepath: string): string {\n  if (!fs.existsSync(filepath)) {\n    return filepath;\n  }\n\n  const dir = path.dirname(filepath);\n  const ext = path.extname(filepath);\n  const base = path.basename(filepath, ext);\n\n  let suffix = 2;\n  const MAX_SUFFIX = 100;\n  while (suffix <= MAX_SUFFIX) {\n    const candidate = path.join(dir, `${base}-${suffix}${ext}`);\n    if (!fs.existsSync(candidate)) {\n      return candidate;\n    }\n    suffix++;\n  }\n  return path.join(dir, `${base}-${randomUUID().slice(0, 8)}${ext}`);\n}\n\n/**\n * Write content to a file with UTF-8 encoding\n *\n * @param filepath - Output file path\n * @param content - File content\n */\nexport function writeFileContent(filepath: string, content: string): void {\n  const dir = path.dirname(filepath);\n  ensureDirectoryExists(dir);\n  fs.writeFileSync(filepath, content, 'utf-8');\n}\n\n/**\n * Remove a file, ignoring ENOENT (file already deleted by another writer).\n *\n * @param filepath - Path to the file to remove\n */\nfunction unlinkIfExists(filepath: string): void {\n  try {\n    fs.unlinkSync(filepath);\n  } catch (err: unknown) {\n    const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : '';\n    if (code !== 'ENOENT') {\n      throw err;\n    }\n  }\n}\n\n/**\n * Attempt to rename `src` to `dest` with a bounded retry loop.\n *\n * On each attempt the existing destination is removed first, then\n * `renameSync` is retried.  `EEXIST`/`EPERM` failures from concurrent\n * writers are tolerated for up to `maxRetries` attempts.\n *\n * @param src - Source (temp) file path\n * @param dest - Final destination path\n * @param maxRetries - Maximum number of unlink-then-rename attempts\n */\nfunction renameWithRetry(src: string, dest: string, maxRetries: number): void {\n  for (let attempt = 0; attempt < maxRetries; attempt++) {\n    unlinkIfExists(dest);\n    try {\n      fs.renameSync(src, dest);\n      return;\n    } catch (retryErr: unknown) {\n      const retryCode = retryErr instanceof Error ? (retryErr as NodeJS.ErrnoException).code : '';\n      if ((retryCode === 'EEXIST' || retryCode === 'EPERM') && attempt < maxRetries - 1) {\n        continue;\n      }\n      throw retryErr;\n    }\n  }\n}\n\n/**\n * Best-effort removal of a temporary file.  Ignores ENOENT (the file was\n * already renamed or never created) but logs a warning for other errors\n * (e.g. EBUSY, EACCES) so operators can detect leaked temp files.\n *\n * @param tempPath - Path to the temp file to remove\n */\nfunction cleanupTempFile(tempPath: string): void {\n  try {\n    fs.unlinkSync(tempPath);\n  } catch (unlinkErr: unknown) {\n    const errno =\n      unlinkErr && typeof unlinkErr === 'object' ? (unlinkErr as NodeJS.ErrnoException) : undefined;\n    if (errno?.code !== 'ENOENT') {\n      const message =\n        errno && typeof errno.message === 'string' ? errno.message : String(unlinkErr);\n      const code = errno?.code ?? 'UNKNOWN';\n      console.warn(\n        `atomicWrite: failed to remove temporary file \"${tempPath}\" (code: ${code}): ${message}`\n      );\n    }\n  }\n}\n\n/**\n * Write content to a file atomically.\n *\n * Writes to a uniquely-named temporary file in the same directory first, then\n * renames it to the final path. The temp filename includes the PID and a random\n * UUID so that concurrent callers targeting the same destination never collide\n * on the intermediate file. If the rename fails the temp file is cleaned up in\n * a `finally` block. On platforms where `renameSync` does not overwrite an\n * existing destination (e.g. Windows), the error is caught and the target is\n * removed before retrying the rename.\n *\n * @param filepath - Final output file path\n * @param content - File content to write\n */\nexport function atomicWrite(filepath: string, content: string): void {\n  const dir = path.dirname(filepath);\n  ensureDirectoryExists(dir);\n  const uniqueSuffix = `${process.pid}-${randomUUID()}`;\n  const tempPath = `${filepath}.${uniqueSuffix}.tmp`;\n  try {\n    fs.writeFileSync(tempPath, content, 'utf-8');\n    try {\n      fs.renameSync(tempPath, filepath);\n    } catch (err: unknown) {\n      const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : '';\n      if (code === 'EEXIST' || code === 'EPERM') {\n        renameWithRetry(tempPath, filepath, 3);\n      } else {\n        throw err;\n      }\n    }\n  } finally {\n    cleanupTempFile(tempPath);\n  }\n}\n\n/**\n * Check whether a news article file already exists on disk.\n *\n * This is used by generation pipelines to skip work when a prior workflow run\n * (or the same run) has already produced the article, avoiding unnecessary\n * regeneration and potential merge conflicts.\n *\n * @param slug - Article slug including date prefix (e.g. `\"2025-01-15-week-ahead\"`)\n * @param lang - Language code (e.g. `\"en\"`)\n * @param newsDir - Absolute path to the news output directory (defaults to NEWS_DIR)\n * @returns `true` when the article file exists\n */\nexport function checkArticleExists(\n  slug: string,\n  lang: string,\n  newsDir: string = NEWS_DIR\n): boolean {\n  const filename = `${slug}-${lang}.html`;\n  return fs.existsSync(path.join(newsDir, filename));\n}\n\n/**\n * Decode the 5 HTML entities produced by escapeHTML() back to plain text.\n * Used when extracting text from our own generated HTML to obtain unescaped values.\n *\n * IMPORTANT: `&amp;` MUST be decoded last. Decoding it first would convert\n * `&amp;lt;` to `&lt;` before the `&lt;` → `<` replacement runs, causing\n * double-decoding. The correct order is: decode all specific entities first,\n * then decode `&amp;` as the final step.\n *\n * @param str - HTML string with entities\n * @returns Plain text with entities decoded\n */\nfunction decodeHtmlEntities(str: string): string {\n  return str\n    .replace(/&quot;/g, '\"')\n    .replace(/&#39;/g, \"'\")\n    .replace(/&lt;/g, '<')\n    .replace(/&gt;/g, '>')\n    .replace(/&amp;/g, '&');\n}\n\n/**\n * Extract title and description from a generated article HTML file.\n * Reads the predictable template structure produced by article-template.ts.\n * Falls back to empty strings when the file cannot be read.\n * HTML entities from the template are decoded to produce plain text.\n *\n * NOTE: The meta description regex relies on the template's use of escapeHTML(),\n * which converts `\"` to `&quot;`. Because descriptions are always stored with\n * double-quote delimiters and inner quotes are HTML-encoded, the `[^\"]+` pattern\n * safely captures the full value. If the template ever changes its quoting\n * convention this regex must be updated accordingly.\n *\n * @param filepath - Path to the article HTML file\n * @returns Object with title (from first h1) and description (from meta description)\n */\nexport function extractArticleMeta(filepath: string): { title: string; description: string } {\n  let title = '';\n  let description = '';\n  try {\n    const content = fs.readFileSync(filepath, 'utf-8');\n    // Matches h1 with any attributes but only plain-text content (no nested tags).\n    // The template always writes plain escaped text in h1, so this is correct.\n    const titleMatch = content.match(/<h1[^>]*>([^<]+)<\\/h1>/u);\n    if (titleMatch?.[1]) {\n      title = decodeHtmlEntities(titleMatch[1].trim());\n    }\n    const descMatch = content.match(/<meta name=\"description\" content=\"([^\"]+)\"/u);\n    if (descMatch?.[1]) {\n      description = decodeHtmlEntities(descMatch[1]);\n    }\n  } catch {\n    // File not readable – return empty strings\n  }\n  return { title, description };\n}\n\n/**\n * Escape special HTML characters to prevent XSS\n *\n * @param str - Raw string to escape\n * @returns HTML-safe string\n */\nexport function escapeHTML(str: string): string {\n  return str\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;');\n}\n\n/**\n * Validate that a URL uses a safe scheme (http or https)\n *\n * @param url - URL string to validate\n * @returns true if URL has a safe scheme\n */\nexport function isSafeURL(url: string): boolean {\n  try {\n    const parsed = new URL(url);\n    return parsed.protocol === 'http:' || parsed.protocol === 'https:';\n  } catch {\n    return false;\n  }\n}\n\n/** Result of article HTML validation */\nexport interface ArticleValidationResult {\n  /** Whether the article passes all structural checks */\n  valid: boolean;\n  /** List of missing elements */\n  errors: readonly string[];\n}\n\n/** Required structural elements that every article must contain */\nconst REQUIRED_ARTICLE_ELEMENTS: ReadonlyArray<{\n  selector: string | readonly string[];\n  label: string;\n}> = [\n  {\n    selector: ['class=\"site-header__langs\"', 'class=\"language-switcher\"'],\n    label: 'language switcher nav',\n  },\n  { selector: 'class=\"article-top-nav\"', label: 'article-top-nav (back button)' },\n  { selector: 'class=\"site-header\"', label: 'site-header' },\n  { selector: 'class=\"skip-link\"', label: 'skip-link' },\n  { selector: 'class=\"reading-progress\"', label: 'reading-progress bar' },\n  { selector: '<main id=\"main\"', label: 'main content wrapper' },\n  { selector: 'class=\"site-footer\"', label: 'site-footer' },\n] as const;\n\n/**\n * Validate that generated article HTML includes all required structural elements.\n *\n * This is the primary validation gate — articles must be generated correctly\n * by the template. The fix-articles script is only a fallback for legacy articles.\n *\n * @param html - Complete HTML string of the article\n * @returns Validation result with errors list (empty if valid)\n */\nexport function validateArticleHTML(html: string): ArticleValidationResult {\n  const errors: string[] = [];\n\n  for (const element of REQUIRED_ARTICLE_ELEMENTS) {\n    const sel = element.selector;\n    const found = Array.isArray(sel)\n      ? sel.some((s) => html.includes(s))\n      : html.includes(sel as string);\n    if (!found) {\n      errors.push(`Missing required element: ${element.label}`);\n    }\n  }\n\n  return { valid: errors.length === 0, errors };\n}\n\n/**\n * Well-known analysis subdirectories scanned for transparency links.\n * Matches the subdirectory structure created by agentic workflows.\n */\nconst DISCOVERY_SUBDIRS = [\n  'classification',\n  'threat-assessment',\n  'risk-scoring',\n  'existing',\n  'documents',\n  'intelligence',\n] as const;\n\n/**\n * Maps canonical analysis filenames (without `.md`) to their canonical\n * analysis method IDs used by `METHOD_LABEL_MAP` in `article-template.ts`.\n *\n * Per `analysis/README.md`, some canonical filenames differ from the method\n * identifier (e.g. the `stakeholder-analysis` method produces\n * `stakeholder-impact.md`). This mapping ensures localized labels render\n * correctly in the analysis transparency section.\n */\nconst FILENAME_TO_METHOD: Readonly<Record<string, string>> = {\n  'stakeholder-impact': 'stakeholder-analysis',\n  'coalition-dynamics': 'coalition-analysis',\n  'document-analysis-index': 'document-analysis',\n};\n\n/**\n * Resolve the canonical analysis method ID for a given filename (without `.md`).\n *\n * Uses the {@link FILENAME_TO_METHOD} mapping for known mismatches; falls back\n * to the filename itself when no mapping exists.\n *\n * @param baseName - Filename without extension (e.g. `stakeholder-impact`)\n * @returns Canonical method ID (e.g. `stakeholder-analysis`)\n */\nfunction resolveCanonicalMethod(baseName: string): string {\n  return FILENAME_TO_METHOD[baseName] ?? baseName;\n}\n\n/**\n * Discover analysis file entries by scanning the analysis directory on disk.\n *\n * Scans known subdirectories plus root-level `.md` files to produce a\n * complete list of {@link AnalysisFileEntry} objects suitable for the\n * article template's dynamic analysis transparency section.\n *\n * This provides a robust fallback when the manifest.json doesn't contain\n * a standard `methods[]` array (e.g. manifests written by agentic workflows\n * use a different structure).\n *\n * @param analysisDirPath - Absolute path to the analysis directory on disk\n * @returns Array of discovered analysis file entries, or empty array when directory doesn't exist\n */\nexport function discoverAnalysisFileEntries(analysisDirPath: string): AnalysisFileEntry[] {\n  if (!fs.existsSync(analysisDirPath)) return [];\n\n  const entries: AnalysisFileEntry[] = [];\n\n  // Scan known subdirectories\n  for (const subdir of DISCOVERY_SUBDIRS) {\n    scanSubdirectory(path.join(analysisDirPath, subdir), subdir, entries);\n  }\n\n  // Scan root-level .md files (e.g. synthesis-summary.md, weekly-intelligence-brief.md)\n  scanRootMarkdownFiles(analysisDirPath, entries);\n\n  return entries;\n}\n\n/**\n * Scan a single subdirectory for .md files and add them to the entries list.\n *\n * @param subdirPath - Absolute path to the subdirectory\n * @param subdir - Subdirectory name for the output file path prefix\n * @param entries - Mutable array to push discovered entries into\n */\nfunction scanSubdirectory(subdirPath: string, subdir: string, entries: AnalysisFileEntry[]): void {\n  try {\n    if (!fs.existsSync(subdirPath) || !fs.statSync(subdirPath).isDirectory()) return;\n    const files = fs.readdirSync(subdirPath);\n    for (const file of files) {\n      if (!file.endsWith('.md')) continue;\n      const baseName = file.replace(/\\.md$/u, '');\n      entries.push({\n        method: resolveCanonicalMethod(baseName),\n        outputFile: `${subdir}/${file}`,\n      });\n    }\n  } catch {\n    // Skip unreadable directories\n  }\n}\n\n/**\n * Scan root-level .md files in the analysis directory.\n *\n * @param dirPath - Analysis directory path\n * @param entries - Mutable array to push discovered entries into\n */\nfunction scanRootMarkdownFiles(dirPath: string, entries: AnalysisFileEntry[]): void {\n  try {\n    const rootFiles = fs.readdirSync(dirPath);\n    for (const file of rootFiles) {\n      if (!file.endsWith('.md')) continue;\n      const filePath = path.join(dirPath, file);\n      if (!fs.statSync(filePath).isFile()) continue;\n      const baseName = file.replace(/\\.md$/u, '');\n      entries.push({\n        method: resolveCanonicalMethod(baseName),\n        outputFile: file,\n      });\n    }\n  } catch {\n    // Skip if unreadable\n  }\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/fix-articles.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/generate-docs-index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/html-sanitize.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/imf-data.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":256,"column":20,"endLine":256,"endColumn":53},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":258,"column":10,"endLine":258,"endColumn":37},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":330,"column":16,"endLine":330,"endColumn":54},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":392,"column":10,"endLine":392,"endColumn":43},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":428,"column":51,"endLine":428,"endColumn":74},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":627,"column":21,"endLine":627,"endColumn":47}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":6,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/IMFData\n * @description Utility functions for IMF economic data integration.\n *\n * Provides EU member state → IMF country code mapping (mostly the same\n * ISO-3166-1 alpha-3 codes as the World Bank, with a few diffs flagged\n * in `IMF_COUNTRY_CODE_OVERRIDES`), SDMX-JSON response parsing,\n * indicator formatting with forecast awareness, and the\n * `IMFEconomicContext` builder for EU Parliament article enrichment.\n *\n * Functions in this module are designed to be stateless and avoid\n * observable side effects, matching the pattern established by\n * `world-bank-data.ts`. A raw IMF SDMX response may include attribute\n * flags (`OBS_STATUS=F` for forecast), multi-dimensional keyed\n * observations, and partial data — see {@link parseSDMXJSON} for the\n * normalisation rules.\n *\n * ## ⚠️ For AI Agents / Agentic Workflows\n *\n * The constants below ({@link IMF_POLICY_INDICATORS}, {@link IMF_EU_COUNTRY_CODES})\n * are a **convenience subset** used by TypeScript code for formatting\n * and parsing. They do **NOT** represent the full IMF database\n * inventory. For indicator selection in articles and analysis:\n *\n * 1. Read `analysis/methodologies/imf-indicator-mapping.md` — canonical\n *    committee → IMF indicator mapping enforced by the validator.\n * 2. Read `analysis/imf/indicator-catalog.md` — IMF WEO/IFS/FM/BOP/ER\n *    indicators by EP policy domain.\n * 3. Use `imf-search-databases` / `imf-get-parameter-codes` to discover\n *    additional series on demand.\n */\n\nimport type {\n  IMFDatabaseId,\n  IMFEconomicContext,\n  IMFEconomicIndicatorSummary,\n  IMFForecastPoint,\n  IMFFrequency,\n  IMFMacroIndicatorKey,\n  IMFObservation,\n  IMFPolicyIndicatorMapping,\n  IMFSeries,\n} from '../types/imf.js';\nimport { escapeHTML } from './file-utils.js';\n\n// ─── EU Member State → IMF Country Code Mapping ───────────────────────────────\n\n/**\n * Maps EU member state ISO 3166-1 alpha-2 codes to IMF country codes.\n *\n * The IMF SDMX 3.0 API uses ISO-3166-1 alpha-3 codes for every EU\n * member state, which matches the World Bank code set exactly. This\n * constant is kept as a standalone copy (rather than re-exporting\n * `EU_COUNTRY_CODES` from `world-bank-data.ts`) so the IMF module\n * remains self-contained and so future IMF-specific overrides\n * (e.g. Kosovo = `UVK` at IMF vs. `XKX` at WB) can land without\n * disturbing the World Bank map.\n */\nexport const IMF_EU_COUNTRY_CODES: Readonly<Record<string, string>> = {\n  AT: 'AUT',\n  BE: 'BEL',\n  BG: 'BGR',\n  HR: 'HRV',\n  CY: 'CYP',\n  CZ: 'CZE',\n  DK: 'DNK',\n  EE: 'EST',\n  FI: 'FIN',\n  FR: 'FRA',\n  DE: 'DEU',\n  GR: 'GRC',\n  HU: 'HUN',\n  IE: 'IRL',\n  IT: 'ITA',\n  LV: 'LVA',\n  LT: 'LTU',\n  LU: 'LUX',\n  MT: 'MLT',\n  NL: 'NLD',\n  PL: 'POL',\n  PT: 'PRT',\n  RO: 'ROU',\n  SK: 'SVK',\n  SI: 'SVN',\n  ES: 'ESP',\n  SE: 'SWE',\n} as const;\n\n/**\n * IMF-specific country-code overrides — cases where the IMF's codelist\n * differs from the World Bank's. Keep this documented explicitly so\n * the drift surface stays visible in one place.\n */\nexport const IMF_COUNTRY_CODE_OVERRIDES: Readonly<Record<string, string>> = {\n  // Kosovo: WB uses XKX, IMF uses UVK on some datasets; add more entries here as drift is discovered.\n  XK: 'UVK',\n} as const;\n\n/** IMF aggregate code for the Euro Area (most widely used EU aggregation on WEO). */\nexport const IMF_EURO_AREA_CODE = 'EA19'; // Historical; `EA` covers the current membership.\n\n/**\n * IMF aggregate labels used in EU Parliament article headings.\n */\nexport const IMF_AGGREGATE_LABELS: Readonly<Record<string, string>> = {\n  EU: 'European Union',\n  EA: 'Euro Area',\n  EA19: 'Euro Area (19 members)',\n  OECD: 'OECD members',\n  WLD: 'World',\n  G7: 'G7',\n  G20: 'G20',\n} as const;\n\n/**\n * Curated IMF indicator mapping — policy-relevant macro/fiscal/trade\n * series that the EU Parliament Monitor uses across article types.\n *\n * Keyed by the stable {@link IMFMacroIndicatorKey} so prompts and\n * templates reference semantic names (\"gdpGrowth\", \"govDebt\") rather\n * than raw SDMX codes. When an indicator has forecasts in WEO/FM,\n * `hasForecast` is `true` and the default query horizon should extend\n * to at least `currentYear + 5`.\n */\nexport const IMF_POLICY_INDICATORS: Readonly<\n  Record<IMFMacroIndicatorKey, IMFPolicyIndicatorMapping>\n> = {\n  gdp: {\n    database: 'WEO',\n    indicator: 'NGDPD',\n    frequency: 'A',\n    label: 'GDP (current USD)',\n    hasForecast: true,\n  },\n  gdpGrowth: {\n    database: 'WEO',\n    indicator: 'NGDP_RPCH',\n    frequency: 'A',\n    label: 'Real GDP growth',\n    hasForecast: true,\n  },\n  gdpPerCapita: {\n    database: 'WEO',\n    indicator: 'NGDPDPC',\n    frequency: 'A',\n    label: 'GDP per capita (current USD)',\n    hasForecast: true,\n  },\n  inflation: {\n    database: 'WEO',\n    indicator: 'PCPIPCH',\n    frequency: 'A',\n    label: 'Consumer price inflation',\n    hasForecast: true,\n  },\n  unemployment: {\n    database: 'WEO',\n    indicator: 'LUR',\n    frequency: 'A',\n    label: 'Unemployment rate',\n    hasForecast: true,\n  },\n  population: {\n    database: 'WEO',\n    indicator: 'LP',\n    frequency: 'A',\n    label: 'Population (millions)',\n    hasForecast: true,\n  },\n  currentAccount: {\n    database: 'WEO',\n    indicator: 'BCA_NGDPD',\n    frequency: 'A',\n    label: 'Current account balance (% of GDP)',\n    hasForecast: true,\n  },\n  exportsGdp: {\n    database: 'WEO',\n    indicator: 'TX_RPCH',\n    frequency: 'A',\n    label: 'Export volume growth',\n    hasForecast: true,\n  },\n  govDebt: {\n    database: 'FM',\n    indicator: 'GGXWDG_NGDP',\n    frequency: 'A',\n    label: 'General government gross debt (% of GDP)',\n    hasForecast: true,\n  },\n  primaryBalance: {\n    database: 'FM',\n    indicator: 'GGXONLB_NGDP',\n    frequency: 'A',\n    label: 'Primary balance (% of GDP)',\n    hasForecast: true,\n  },\n  structuralBalance: {\n    database: 'FM',\n    indicator: 'GGSB_NPGDP',\n    frequency: 'A',\n    label: 'Structural balance (% of potential GDP)',\n    hasForecast: true,\n  },\n  fdiInflow: {\n    database: 'BOP_AGG',\n    indicator: 'BFD_BP6_USD',\n    frequency: 'Q',\n    label: 'FDI inflow (BoP, current USD)',\n    hasForecast: false,\n  },\n  realEffectiveExchangeRate: {\n    database: 'ER',\n    indicator: 'EREER_IX',\n    frequency: 'M',\n    label: 'Real effective exchange rate',\n    hasForecast: false,\n  },\n  policyRate: {\n    database: 'IFS',\n    indicator: 'FPOLM_PA',\n    frequency: 'M',\n    label: 'Monetary policy rate',\n    hasForecast: false,\n  },\n};\n\n/**\n * Short indicator codes — the single source of truth for the content\n * validator's IMF fingerprints list. Derived from the SDMX codes in\n * {@link IMF_POLICY_INDICATORS}. Consumers should prefer this constant\n * over hand-rolled string lists so validator drift is impossible.\n */\nexport const IMF_INDICATOR_SDMX_CODES: readonly string[] = Array.from(\n  new Set(Object.values(IMF_POLICY_INDICATORS).map((m) => m.indicator))\n);\n\n// ─── Country Code Lookup ───────────────────────────────────────────────────────\n\n/**\n * Resolve an ISO 3166-1 alpha-2 code to the IMF country code.\n *\n * Applies {@link IMF_COUNTRY_CODE_OVERRIDES} first so IMF-specific\n * overrides win, then falls back to the EU alpha-3 map, then returns\n * `null` when the code is not recognised.\n *\n * @param iso2Code - Country ISO 3166-1 alpha-2 code (case-insensitive).\n * @returns IMF country code or `null`.\n */\nexport function getIMFCountryCode(iso2Code: string): string | null {\n  if (!iso2Code) return null;\n  const upper = iso2Code.toUpperCase();\n  const override = IMF_COUNTRY_CODE_OVERRIDES[upper];\n  if (override) return override;\n  return IMF_EU_COUNTRY_CODES[upper] ?? null;\n}\n\n/**\n * Check whether `iso2Code` is one of the 27 EU member states covered\n * by the IMF codelist.\n *\n * @param iso2Code - Country ISO 3166-1 alpha-2 code.\n * @returns `true` when the code maps to an EU member state.\n */\nexport function isIMFEUMemberState(iso2Code: string): boolean {\n  if (!iso2Code) return false;\n  return iso2Code.toUpperCase() in IMF_EU_COUNTRY_CODES;\n}\n\n// ─── SDMX-JSON Parser ─────────────────────────────────────────────────────────\n\n/**\n * Narrowed shape of an SDMX-JSON 2.0 / 3.0 data response. The IMF API\n * returns `dataSets[0].series` keyed by concatenated dimension indices\n * with an `observations` map. We only consume the fields we need so\n * new SDMX attributes (reserved by future IMF releases) are ignored\n * silently rather than raising parse errors.\n */\ninterface SDMXJSONResponse {\n  data?: {\n    dataSets?: Array<{\n      series?: Record<string, { observations?: Record<string, Array<number | string | null>> }>;\n    }>;\n    structure?: {\n      dimensions?: {\n        series?: Array<{\n          id: string;\n          values?: Array<{ id: string; name?: string }>;\n        }>;\n        observation?: Array<{\n          id: string;\n          values?: Array<{ id: string; name?: string }>;\n        }>;\n      };\n      attributes?: {\n        observation?: Array<{\n          id: string;\n          values?: Array<{ id: string; name?: string }>;\n        }>;\n      };\n    };\n  };\n}\n\n/**\n * Return `true` when the raw SDMX observation attributes flag the\n * observation as a forecast (`OBS_STATUS=F`) — the IMF convention.\n * Tolerates missing/partial attribute arrays so malformed responses\n * simply default to `isForecast=false`.\n *\n * @param obsAttributes - Attribute values for the observation (the\n *   slice after the observation value in the SDMX `observations` array).\n * @param obsStatusAttributeIndex - Position of `OBS_STATUS` in the\n *   observation-attribute structure (or `-1` when absent).\n * @param forecastCodeIndex - Position of the `F` code in the\n *   `OBS_STATUS` code list (or `-1` when absent).\n * @returns `true` when the observation is flagged as a forecast.\n * @internal\n */\nfunction observationIsForecast(\n  obsAttributes: ReadonlyArray<number | string | null> | undefined,\n  obsStatusAttributeIndex: number,\n  forecastCodeIndex: number\n): boolean {\n  if (!obsAttributes) return false;\n  if (obsStatusAttributeIndex < 0) return false;\n  const flag = obsAttributes[obsStatusAttributeIndex];\n  if (flag === null || flag === undefined) return false;\n  // The IMF SDMX response encodes attribute values either as the code\n  // index (numeric) or the literal code string. `F` → forecast.\n  if (typeof flag === 'number') {\n    return flag === forecastCodeIndex;\n  }\n  return String(flag).toUpperCase() === 'F';\n}\n\n/**\n * Deserialise a raw SDMX payload (string or already-parsed object)\n * into a typed {@link SDMXJSONResponse}. Returns `null` when the\n * payload is missing, empty, or not valid JSON.\n *\n * @param raw - Raw payload.\n * @returns Parsed object or `null`.\n * @internal\n */\nfunction deserialiseSDMXPayload(\n  raw: string | SDMXJSONResponse | null | undefined\n): SDMXJSONResponse | null {\n  if (!raw) return null;\n  if (typeof raw === 'string') {\n    try {\n      return JSON.parse(raw) as SDMXJSONResponse;\n    } catch {\n      return null;\n    }\n  }\n  return raw;\n}\n\n/**\n * Context extracted once per response: the time-label map and the\n * precomputed OBS_STATUS attribute indexes used to detect forecasts.\n *\n * @internal\n */\ninterface SDMXDecodingContext {\n  timeLabels: ReadonlyArray<{ id: string; name?: string }>;\n  obsStatusAttrIndex: number;\n  forecastCodeIndex: number;\n}\n\n/**\n * Build the one-response decoding context: time labels and the\n * OBS_STATUS attribute indexes.\n *\n * @param structure - `structure` block of the SDMX payload.\n * @returns Decoding context used by {@link decodeObservations}.\n * @internal\n */\nfunction buildDecodingContext(\n  structure: NonNullable<NonNullable<SDMXJSONResponse['data']>['structure']>\n): SDMXDecodingContext {\n  const timeDimension = structure.dimensions?.observation?.[0];\n  const timeLabels = timeDimension?.values ?? [];\n  const obsAttributes = structure.attributes?.observation ?? [];\n  const obsStatusAttrIndex = obsAttributes.findIndex((a) => a.id === 'OBS_STATUS');\n  const forecastCodeIndex =\n    obsStatusAttrIndex >= 0\n      ? (obsAttributes[obsStatusAttrIndex]?.values ?? []).findIndex(\n          (v) => v.id.toUpperCase() === 'F'\n        )\n      : -1;\n  return { timeLabels, obsStatusAttrIndex, forecastCodeIndex };\n}\n\n/**\n * Coerce a raw SDMX observation cell to a finite number or `null`.\n *\n * @param rawValue - First element of the SDMX observation array.\n * @returns Number or `null`.\n * @internal\n */\nfunction coerceObservationValue(rawValue: unknown): number | null {\n  if (rawValue === null || rawValue === undefined || rawValue === '') return null;\n  const n = typeof rawValue === 'number' ? rawValue : Number(rawValue);\n  return Number.isFinite(n) ? n : null;\n}\n\n/**\n * Decode every observation within a single SDMX series into the\n * normalised {@link IMFObservation} shape.\n *\n * @param seriesObservations - `observations` map keyed by observation index.\n * @param ctx - Decoding context from {@link buildDecodingContext}.\n * @returns Ordered array of normalised observations.\n * @internal\n */\nfunction decodeObservations(\n  seriesObservations: Record<string, Array<number | string | null>>,\n  ctx: SDMXDecodingContext\n): IMFObservation[] {\n  const out: IMFObservation[] = [];\n  for (const [obsIdx, obsArr] of Object.entries(seriesObservations)) {\n    const timeIdx = Number.parseInt(obsIdx, 10);\n    const labelEntry = Number.isFinite(timeIdx) ? ctx.timeLabels[timeIdx] : undefined;\n    const period = labelEntry?.id ?? String(obsIdx);\n    const year = parsePeriodYear(period);\n    if (year === null) continue;\n    const arr = Array.isArray(obsArr) ? obsArr : [];\n    const value = coerceObservationValue(arr[0]);\n    const attrs = arr.slice(1);\n    out.push({\n      period,\n      year,\n      value,\n      isForecast: observationIsForecast(attrs, ctx.obsStatusAttrIndex, ctx.forecastCodeIndex),\n    });\n  }\n  out.sort((a, b) => a.period.localeCompare(b.period));\n  return out;\n}\n\n/**\n * Parse an SDMX-JSON response into a map of series key → ordered\n * observations.\n *\n * This parser is intentionally tolerant: missing observations,\n * attribute arrays, or structure blocks all degrade to an empty result\n * rather than throwing. That lets the caller pipeline treat a broken\n * response as \"no data\" and fall through to a World Bank fallback.\n *\n * @param raw - Raw SDMX-JSON payload as returned by the IMF MCP server\n *   (accepts string, object, null, or undefined).\n * @returns Map of series key → ordered observations.\n */\nexport function parseSDMXJSON(\n  raw: string | SDMXJSONResponse | null | undefined\n): Map<string, IMFObservation[]> {\n  const result = new Map<string, IMFObservation[]>();\n  const payload = deserialiseSDMXPayload(raw);\n  if (!payload) return result;\n  const dataSet = payload.data?.dataSets?.[0];\n  const structure = payload.data?.structure;\n  const series = dataSet?.series;\n  if (!series || !structure) return result;\n  const ctx = buildDecodingContext(structure);\n  for (const [seriesKey, seriesPayload] of Object.entries(series)) {\n    const obs = seriesPayload.observations ?? {};\n    result.set(seriesKey, decodeObservations(obs, ctx));\n  }\n  return result;\n}\n\n/**\n * Extract the year component from an IMF period label. Supports\n * annual (`2026`), quarterly (`2026-Q1`), and monthly (`2026-04`)\n * formats. Returns `null` when the label is unparseable so callers\n * can skip corrupt rows.\n *\n * @param period - Period label (e.g. `\"2026\"`, `\"2026-Q1\"`, `\"2026-04\"`).\n * @returns Numeric year or `null`.\n * @internal\n */\nfunction parsePeriodYear(period: string): number | null {\n  if (!period) return null;\n  const match = /^(\\d{4})/u.exec(period);\n  if (!match) return null;\n  const year = Number.parseInt(match[1] ?? '', 10);\n  return Number.isFinite(year) ? year : null;\n}\n\n// ─── Series / Observation Helpers ─────────────────────────────────────────────\n\n/**\n * Extract the most recent observation (by year) from a series,\n * preferring published actuals over forecasts when both are present\n * for the same latest year.\n *\n * Returns `null` when the series contains only null values or is empty.\n *\n * @param observations - Series observations (in any order).\n * @returns Most recent observation with a non-null value, or `null`.\n */\nexport function getMostRecentObservation(\n  observations: readonly IMFObservation[]\n): IMFObservation | null {\n  if (observations.length === 0) return null;\n  const withValues = observations.filter((o) => o.value !== null);\n  if (withValues.length === 0) return null;\n  withValues.sort((a, b) => {\n    if (b.year !== a.year) return b.year - a.year;\n    // Prefer actual (isForecast=false) over forecast at the same year.\n    if (a.isForecast !== b.isForecast) return a.isForecast ? 1 : -1;\n    return b.period.localeCompare(a.period);\n  });\n  return withValues[0] ?? null;\n}\n\n/**\n * Return just the forecast points from a series, oldest first.\n * Useful for Chart.js dashed-line overlays.\n *\n * @param observations - Series observations.\n * @param vintage - Optional IMF vintage label to stamp on each point.\n * @returns Ordered forecast points.\n */\nexport function getForecastPoints(\n  observations: readonly IMFObservation[],\n  vintage?: string\n): IMFForecastPoint[] {\n  return observations\n    .filter((o) => o.isForecast && o.value !== null)\n    .map((o) => {\n      const point: IMFForecastPoint = {\n        period: o.period,\n        year: o.year,\n        value: o.value as number,\n      };\n      if (vintage !== undefined) {\n        point.vintage = vintage;\n      }\n      return point;\n    })\n    .sort((a, b) => a.year - b.year);\n}\n\n// ─── Value Formatting ────────────────────────────────────────────────────────\n\n/** Magnitude threshold for trillion formatting. */\nconst TRILLION = 1e12;\n/** Magnitude threshold for billion formatting. */\nconst BILLION = 1e9;\n/** Magnitude threshold for million formatting. */\nconst MILLION = 1e6;\n\n/**\n * Format a numeric IMF value for display, based on the indicator\n * mapping's label hints.\n *\n * Percentage-family indicators render as `X.Y %`; GDP renders with\n * the T/B/M magnitude suffix; population renders in millions to\n * match the WEO publication convention. Unknown indicators fall back\n * to two decimal places.\n *\n * @param value - Numeric value, or `null` for N/A.\n * @param mapping - Indicator mapping entry (from {@link IMF_POLICY_INDICATORS}).\n * @returns A human-readable formatted string, or `'N/A'` for `null` / non-finite inputs.\n */\nexport function formatIMFValue(value: number | null, mapping: IMFPolicyIndicatorMapping): string {\n  if (value === null || !Number.isFinite(value)) return 'N/A';\n  const label = mapping.label.toLowerCase();\n  if (\n    label.includes('% of gdp') ||\n    label.includes('growth') ||\n    label.includes('inflation') ||\n    label.includes('unemployment')\n  ) {\n    return `${value.toFixed(1)}%`;\n  }\n  if (label.includes('gdp') && !label.includes('per capita')) {\n    const abs = Math.abs(value);\n    if (abs >= TRILLION) return `$${(value / TRILLION).toFixed(1)}T`;\n    if (abs >= BILLION) return `$${(value / BILLION).toFixed(1)}B`;\n    if (abs >= MILLION) return `$${(value / MILLION).toFixed(1)}M`;\n    return `$${value.toFixed(0)}`;\n  }\n  if (label.includes('population')) {\n    return `${value.toFixed(1)}M`;\n  }\n  if (label.includes('exchange rate') || label.includes('policy rate')) {\n    return value.toFixed(2);\n  }\n  return value.toFixed(2);\n}\n\n// ─── Economic Context Builder ─────────────────────────────────────────────────\n\n/**\n * Build an {@link IMFEconomicContext} from an indexed series map.\n *\n * Each entry in `seriesByKey` should be keyed by the {@link IMFMacroIndicatorKey}\n * so the builder can look up the mapping and produce a stable display row.\n * Rows with no observations are skipped silently. When ANY indicator\n * carries a forecast, `forecastHorizonYear` is set to the maximum\n * forecast year so the caller can render a dashed overlay region\n * starting from that year.\n *\n * @param countryCode - EU member state ISO2 code or IMF aggregate code.\n * @param countryName - Country display name.\n * @param seriesByKey - Map of {@link IMFMacroIndicatorKey} to series.\n * @param vintage - IMF data vintage label (e.g. `WEO-April-2026`).\n * @returns Populated {@link IMFEconomicContext} (may have an empty `indicators` array).\n */\nexport function buildIMFEconomicContext(\n  countryCode: string,\n  countryName: string,\n  seriesByKey: ReadonlyMap<IMFMacroIndicatorKey, IMFSeries>,\n  vintage?: string\n): IMFEconomicContext {\n  const indicators: IMFEconomicIndicatorSummary[] = [];\n  let forecastHorizonYear: number | undefined;\n\n  for (const [key, series] of seriesByKey) {\n    const mapping = IMF_POLICY_INDICATORS[key];\n    if (!mapping) continue;\n    const latest = getMostRecentObservation(series.observations);\n    if (!latest) continue;\n    const row: IMFEconomicIndicatorSummary = {\n      name: mapping.label,\n      indicatorId: mapping.indicator,\n      database: mapping.database,\n      value: latest.value,\n      period: latest.period,\n      year: latest.year,\n      isForecast: latest.isForecast,\n      formatted: formatIMFValue(latest.value, mapping),\n    };\n    if (vintage !== undefined) {\n      row.vintage = vintage;\n    }\n    indicators.push(row);\n\n    const forecasts = series.observations.filter((o) => o.isForecast);\n    for (const f of forecasts) {\n      if (forecastHorizonYear === undefined || f.year > forecastHorizonYear) {\n        forecastHorizonYear = f.year;\n      }\n    }\n  }\n\n  const context: IMFEconomicContext = {\n    countryCode,\n    countryName,\n    indicators,\n    dataTimestamp: new Date().toISOString(),\n  };\n  if (forecastHorizonYear !== undefined) {\n    context.forecastHorizonYear = forecastHorizonYear;\n  }\n  return context;\n}\n\n// ─── HTML Context Section ─────────────────────────────────────────────────────\n\n/**\n * Build a WCAG-compliant HTML `<section>` summarising the IMF\n * economic context for a single country.\n *\n * Forecast rows are marked with a `data-forecast=\"true\"` attribute on\n * the `<tr>` so CSS/Chart.js can visually differentiate them from\n * actuals. Source attribution cites the IMF WEO/FM vintage when\n * available. Safe against XSS via `escapeHTML`.\n *\n * @param context - Economic context payload.\n * @returns HTML string with the economic context section, or `''` when there are no indicators.\n */\nexport function buildIMFEconomicContextHTML(context: IMFEconomicContext): string {\n  if (context.indicators.length === 0) return '';\n  const rows = context.indicators\n    .map((ind) => {\n      const forecastAttr = ind.isForecast ? ' data-forecast=\"true\"' : '';\n      const forecastLabel = ind.isForecast ? ' <span class=\"forecast-flag\">(forecast)</span>' : '';\n      const vintageCell = ind.vintage ? `<td>${escapeHTML(ind.vintage)}</td>` : '<td></td>';\n      return `<tr${forecastAttr}><td>${escapeHTML(ind.name)}${forecastLabel}</td><td>${escapeHTML(ind.formatted)}</td><td>${escapeHTML(ind.period)}</td>${vintageCell}</tr>`;\n    })\n    .join('\\n');\n\n  const horizonNote = context.forecastHorizonYear\n    ? ` <span class=\"forecast-horizon\">Projections extend through ${escapeHTML(String(context.forecastHorizonYear))}.</span>`\n    : '';\n\n  return `<section class=\"economic-context imf-economic-context\" aria-label=\"IMF economic indicators for ${escapeHTML(context.countryName)}\">\n<h2>Economic Context (IMF): ${escapeHTML(context.countryName)}</h2>\n<table>\n<caption>IMF economic indicators for ${escapeHTML(context.countryName)}.${horizonNote}</caption>\n<thead><tr><th scope=\"col\">Indicator</th><th scope=\"col\">Value</th><th scope=\"col\">Period</th><th scope=\"col\">Vintage</th></tr></thead>\n<tbody>\n${rows}\n</tbody>\n</table>\n<p class=\"data-source\">Source: IMF (<a href=\"https://data.imf.org/\" rel=\"noopener noreferrer\">data.imf.org</a>)</p>\n</section>`;\n}\n\n// ─── Type Re-Exports (convenience) ────────────────────────────────────────────\n\nexport type {\n  IMFDatabaseId,\n  IMFEconomicContext,\n  IMFEconomicIndicatorSummary,\n  IMFForecastPoint,\n  IMFFrequency,\n  IMFMacroIndicatorKey,\n  IMFObservation,\n  IMFPolicyIndicatorMapping,\n  IMFSeries,\n};\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/intelligence-analysis.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":360,"column":19,"endLine":360,"endColumn":40},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":426,"column":21,"endLine":426,"endColumn":40},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1012,"column":21,"endLine":1012,"endColumn":29},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1068,"column":5,"endLine":1068,"endColumn":26},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1068,"column":30,"endLine":1068,"endColumn":51},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1119,"column":10,"endLine":1119,"endColumn":38}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":6,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/IntelligenceAnalysis\n * @description Pure intelligence analysis utility functions for structured\n * assessment of European Parliament data. All functions are stateless and\n * safely handle malformed or missing MCP data. No side effects.\n */\n\nimport { escapeHTML } from './file-utils.js';\nimport { AI_MARKER } from '../constants/analysis-constants.js';\nimport type {\n  VotingAnomalyIntelligence,\n  CoalitionIntelligence,\n  MEPInfluenceScore,\n  LegislativeVelocity,\n  StakeholderPerspective,\n  StakeholderOutcomeMatrix,\n  AnalysisStakeholderType,\n  VotingRecord,\n  VotingPattern,\n  VotingIntensity,\n  CoalitionShift,\n  CoalitionShiftSignal,\n  PolarizationIndex,\n  VotingTrend,\n  CoalitionStabilityReport,\n  LegislativeVelocityReport,\n  LegislativeDocument,\n  StakeholderInfluenceTrajectory,\n} from '../types/index.js';\nimport { ALL_STAKEHOLDER_TYPES } from '../types/index.js';\n\n// ─── Internal type aliases ────────────────────────────────────────────────────\n\ntype SignificanceLevel = VotingAnomalyIntelligence['significance'];\ntype RiskLevel = CoalitionIntelligence['riskLevel'];\ntype AlignmentTrend = CoalitionIntelligence['alignmentTrend'];\n\n// ─── Validation constants ─────────────────────────────────────────────────────\n\n/** Valid significance levels in descending priority order */\nconst SIGNIFICANCE_LEVELS: readonly string[] = ['critical', 'high', 'medium', 'low'];\n\n/** Valid risk levels for coalition and bottleneck assessment */\nconst RISK_LEVELS: readonly string[] = ['high', 'medium', 'low'];\n\n/** Valid alignment trend values */\nconst ALIGNMENT_TRENDS: readonly string[] = ['strengthening', 'weakening', 'stable'];\n\n/** Priority weights for significance-based ranking */\nconst SIGNIFICANCE_WEIGHTS: Readonly<Record<string, number>> = {\n  critical: 4,\n  high: 3,\n  medium: 2,\n  low: 1,\n};\n\n// ─── Private value-extraction helpers ────────────────────────────────────────\n\n/**\n * Safely extract a string from an unknown field value\n *\n * @param val - Unknown value to coerce\n * @returns The string value or empty string if not a string\n */\nfunction asStr(val: unknown): string {\n  return typeof val === 'string' ? val : '';\n}\n\n/**\n * Safely extract a finite number from an unknown field value\n *\n * @param val - Unknown value to coerce\n * @param fallback - Value returned when input is not a finite number\n * @returns Finite number or fallback\n */\nfunction asNum(val: unknown, fallback = 0): number {\n  return typeof val === 'number' && Number.isFinite(val) ? val : fallback;\n}\n\n/**\n * Safely extract an array of strings from an unknown field value\n *\n * @param val - Unknown value to coerce\n * @returns Array of strings (empty array if input is not an array)\n */\nfunction asStrArr(val: unknown): string[] {\n  if (!Array.isArray(val)) return [];\n  return (val as unknown[]).filter((v): v is string => typeof v === 'string');\n}\n\n/**\n * Coerce an unknown value to a non-null Record or return null\n *\n * @param input - Value to cast\n * @returns Record or null for null/undefined/non-object input\n */\nfunction toRecord(input: unknown): Record<string, unknown> | null {\n  if (input === null || input === undefined || typeof input !== 'object') return null;\n  return input as Record<string, unknown>;\n}\n\n// ─── Private parsing validators ───────────────────────────────────────────────\n\n/**\n * Validate and normalise a raw significance level string\n *\n * @param raw - Raw string from MCP data\n * @returns Validated SignificanceLevel, defaulting to 'low'\n */\nfunction parseSignificance(raw: string): SignificanceLevel {\n  const lower = raw.toLowerCase();\n  return SIGNIFICANCE_LEVELS.includes(lower) ? (lower as SignificanceLevel) : 'low';\n}\n\n/**\n * Validate and normalise a raw risk level string\n *\n * @param raw - Raw string from MCP data\n * @returns Validated RiskLevel, defaulting to 'medium'\n */\nfunction parseRiskLevel(raw: string): RiskLevel {\n  const lower = raw.toLowerCase();\n  return RISK_LEVELS.includes(lower) ? (lower as RiskLevel) : 'medium';\n}\n\n/**\n * Validate and normalise a raw alignment trend string\n *\n * @param raw - Raw string from MCP data\n * @returns Validated AlignmentTrend, defaulting to 'stable'\n */\nfunction parseAlignmentTrend(raw: string): AlignmentTrend {\n  const lower = raw.toLowerCase();\n  return ALIGNMENT_TRENDS.includes(lower) ? (lower as AlignmentTrend) : 'stable';\n}\n\n// ─── Exported intelligence functions ─────────────────────────────────────────\n\n/**\n * Parse and score a voting anomaly from raw MCP data.\n * Returns null for null, undefined, non-object, or inputs missing a valid\n * anomaly identifier.\n *\n * @param rawAnomaly - Raw MCP anomaly data (unknown shape)\n * @returns Structured VotingAnomalyIntelligence or null if input is invalid\n */\nexport function scoreVotingAnomaly(rawAnomaly: unknown): VotingAnomalyIntelligence | null {\n  const a = toRecord(rawAnomaly);\n  if (!a) return null;\n  const anomalyId = asStr(a['anomalyId']) || asStr(a['id']);\n  if (!anomalyId) return null;\n  return {\n    anomalyId,\n    significance: parseSignificance(asStr(a['significance'])),\n    description: asStr(a['description']),\n    affectedGroups: asStrArr(a['affectedGroups']),\n    deviationPercentage: asNum(a['deviationPercentage']),\n    historicalContext: asStr(a['historicalContext']),\n    implication: asStr(a['implication']),\n  };\n}\n\n/**\n * Analyse a coalition's cohesion from raw MCP coalition data.\n * Returns null for null, undefined, non-object, or inputs missing a valid\n * coalition identifier.\n *\n * @param rawCoalition - Raw MCP coalition data (unknown shape)\n * @returns Structured CoalitionIntelligence or null if input is invalid\n */\nexport function analyzeCoalitionCohesion(rawCoalition: unknown): CoalitionIntelligence | null {\n  const c = toRecord(rawCoalition);\n  if (!c) return null;\n  const coalitionId = asStr(c['coalitionId']) || asStr(c['id']);\n  if (!coalitionId) return null;\n  return {\n    coalitionId,\n    groups: asStrArr(c['groups']),\n    cohesionScore: Math.min(1, Math.max(0, asNum(c['cohesionScore']))),\n    alignmentTrend: parseAlignmentTrend(asStr(c['alignmentTrend'])),\n    keyVotes: Math.max(0, Math.round(asNum(c['keyVotes']))),\n    riskLevel: parseRiskLevel(asStr(c['riskLevel'])),\n  };\n}\n\n/**\n * Extract and score MEP influence from raw MCP influence data.\n * Returns null for null, undefined, non-object, or inputs missing both a\n * valid MEP identifier and display name.\n *\n * @param rawInfluence - Raw MCP MEP influence data (unknown shape)\n * @returns Structured MEPInfluenceScore or null if input is invalid\n */\nexport function scoreMEPInfluence(rawInfluence: unknown): MEPInfluenceScore | null {\n  const m = toRecord(rawInfluence);\n  if (!m) return null;\n  const mepId = asStr(m['mepId']) || asStr(m['id']);\n  const mepName = asStr(m['mepName']) || asStr(m['name']);\n  if (!mepId || !mepName) return null;\n  return {\n    mepId,\n    mepName,\n    overallScore: Math.min(100, Math.max(0, asNum(m['overallScore']))),\n    votingActivity: Math.min(100, Math.max(0, asNum(m['votingActivity']))),\n    legislativeOutput: Math.min(100, Math.max(0, asNum(m['legislativeOutput']))),\n    committeeEngagement: Math.min(100, Math.max(0, asNum(m['committeeEngagement']))),\n    rank: asStr(m['rank']),\n  };\n}\n\n/**\n * Calculate legislative velocity from raw MCP procedure data.\n * Returns null for null, undefined, non-object, or inputs missing a valid\n * procedure identifier or title.\n *\n * @param rawProcedure - Raw MCP procedure data (unknown shape)\n * @returns Structured LegislativeVelocity or null if input is invalid\n */\nexport function calculateLegislativeVelocity(rawProcedure: unknown): LegislativeVelocity | null {\n  const p = toRecord(rawProcedure);\n  if (!p) return null;\n  const procedureId = asStr(p['procedureId']) || asStr(p['id']);\n  const title = asStr(p['title']);\n  if (!procedureId || !title) return null;\n  return {\n    procedureId,\n    title,\n    stage: asStr(p['stage']) || 'Unknown',\n    daysInCurrentStage: Math.max(0, Math.round(asNum(p['daysInCurrentStage']))),\n    velocityScore: Math.min(1, Math.max(0, asNum(p['velocityScore']))),\n    bottleneckRisk: parseRiskLevel(asStr(p['bottleneckRisk'])),\n    predictedCompletion: asStr(p['predictedCompletion']),\n  };\n}\n\n/**\n * Sort items by significance level descending, with numeric score as\n * tie-breaker. Items with higher significance or scores appear first.\n * The original array is not mutated.\n *\n * @param items - Array of items with optional significance and score fields\n * @returns New sorted array ordered by significance then score\n */\nexport function rankBySignificance<\n  T extends {\n    significance?: string | undefined;\n    overallScore?: number | undefined;\n    cohesionScore?: number | undefined;\n  },\n>(items: T[]): T[] {\n  return [...items].sort((a, b) => {\n    const sigA = SIGNIFICANCE_WEIGHTS[a.significance ?? ''] ?? 0;\n    const sigB = SIGNIFICANCE_WEIGHTS[b.significance ?? ''] ?? 0;\n    if (sigA !== sigB) return sigB - sigA;\n    const scoreA = a.overallScore ?? a.cohesionScore ?? 0;\n    const scoreB = b.overallScore ?? b.cohesionScore ?? 0;\n    return scoreB - scoreA;\n  });\n}\n\n/**\n * Build an HTML section element for displaying intelligence items as a list.\n * All title, className, and item strings are HTML-escaped to prevent XSS.\n * Returns an empty string when the items array is empty.\n *\n * @param title - Section heading text (will be HTML-escaped)\n * @param items - Array of text items to display as list entries (will be HTML-escaped)\n * @param className - CSS class name for the section element (will be HTML-escaped)\n * @returns HTML string for the intelligence section, or empty string if no items\n */\nexport function buildIntelligenceSection(\n  title: string,\n  items: string[],\n  className: string\n): string {\n  if (items.length === 0) return '';\n  const safeClass = escapeHTML(className);\n  const safeTitle = escapeHTML(title);\n  const itemsHtml = items.map((item) => `<li>${escapeHTML(item)}</li>`).join('\\n          ');\n  return `<section class=\"${safeClass}\">\n        <h2>${safeTitle}</h2>\n        <ul>\n          ${itemsHtml}\n        </ul>\n      </section>`;\n}\n\n// ─── Stakeholder scoring functions ───────────────────────────────────────────\n\n/**\n * Derive a severity level from a numeric 0-1 importance score.\n *\n * @param score - Normalised importance score (0 = least important, 1 = most)\n * @returns Severity level\n */\nfunction severityFromScore(score: number): StakeholderPerspective['severity'] {\n  if (score >= 0.7) return 'high';\n  if (score >= 0.4) return 'medium';\n  return 'low';\n}\n\n/**\n * Fallback stakeholder reasoning — returns the `AI_MARKER` sentinel.\n *\n * **AI-First principle** (see `.github/skills/ai-first-quality.md`): all\n * narrative/interpretive text — including per-stakeholder reasoning — is\n * authored by the AI agent after it reads the run's analysis markdown as\n * context.  This function deliberately returns the `AI_MARKER` so that:\n *\n * 1. The agentic workflow's article-rewriter step detects the slot and fills\n *    it with AI-authored prose drawn from `intelligence/stakeholder-map.md`,\n *    `existing/stakeholder-impact.md`, or equivalent AI-produced analysis.\n * 2. {@link module:Utils/ValidateAnalysisCompleteness} refuses to publish any\n *    article in which these markers leak through unreplaced, converting the\n *    previous silent template-junk regression into a loud CI failure.\n *\n * Earlier revisions of this function returned plausible-looking template text\n * (e.g. *\"This parliamentary activity on 'voting period 2026-03-21–2026-04-20'\n * has moderate implications for political group dynamics …\"*).  That text\n * read like AI-authored content but was entirely script-generated — the\n * motions-run46 regression documented in the Analysis-to-Article Data Contract\n * (`.github/prompts/SHARED_PROMPT_PATTERNS.md`).  Returning `AI_MARKER` fails\n * loud instead of failing silent.\n *\n * Signature is preserved (stakeholder/topic/score inputs) for back-compat\n * with any caller-constructed severity/impact scoring.  The marker output is\n * independent of these inputs.\n *\n * @param _stakeholder - Stakeholder group identifier (unused — kept for API stability)\n * @param _topic - Short topic description (unused — kept for API stability)\n * @param _score - Importance score (unused — kept for API stability)\n * @returns The `AI_MARKER` sentinel for AI-agent replacement.\n */\nfunction deriveStakeholderReasoning(\n  _stakeholder: AnalysisStakeholderType,\n  _topic: string,\n  _score: number\n): string {\n  return AI_MARKER;\n}\n\n/**\n * Build a default set of stakeholder perspectives for a parliamentary action.\n * Each perspective is seeded with a data-driven reasoning string and evidence\n * items derived from the provided topic and impact scores. All six stakeholder\n * groups receive a perspective entry.\n *\n * @param topic - Short description of the parliamentary action (e.g. vote title)\n * @param scores - Optional per-stakeholder importance scores (0-1); defaults to 0.5\n * @returns Array of six StakeholderPerspective objects, one per stakeholder group\n */\nexport function buildDefaultStakeholderPerspectives(\n  topic: string,\n  scores?: Partial<Record<AnalysisStakeholderType, number>>\n): StakeholderPerspective[] {\n  return ALL_STAKEHOLDER_TYPES.map((stakeholder) => {\n    const score = scores?.[stakeholder] ?? 0.5;\n    const severity = severityFromScore(score);\n    return {\n      stakeholder,\n      impact:\n        score >= 0.6\n          ? ('positive' as const)\n          : score <= 0.3\n            ? ('negative' as const)\n            : ('neutral' as const),\n      severity,\n      reasoning: deriveStakeholderReasoning(stakeholder, topic, score),\n      evidence: [topic],\n    };\n  });\n}\n\n/**\n * Score stakeholder influence from raw MCP data.\n * Returns null for null, undefined, non-object, or missing stakeholder type.\n *\n * @param rawData - Raw stakeholder influence data (unknown shape)\n * @returns Structured StakeholderPerspective or null if input is invalid\n */\nexport function scoreStakeholderInfluence(rawData: unknown): StakeholderPerspective | null {\n  const d = toRecord(rawData);\n  if (!d) return null;\n  const stakeholderRaw = asStr(d['stakeholder']);\n  if (!(ALL_STAKEHOLDER_TYPES as readonly string[]).includes(stakeholderRaw)) return null;\n  const stakeholder = stakeholderRaw as AnalysisStakeholderType;\n  const impactRaw = asStr(d['impact']).toLowerCase();\n  const validImpacts = ['positive', 'negative', 'neutral', 'mixed'] as const;\n  const impact = (validImpacts as readonly string[]).includes(impactRaw)\n    ? (impactRaw as StakeholderPerspective['impact'])\n    : 'neutral';\n  const severityRaw = asStr(d['severity']).toLowerCase();\n  const validSeverities = ['high', 'medium', 'low'] as const;\n  const severity = (validSeverities as readonly string[]).includes(severityRaw)\n    ? (severityRaw as StakeholderPerspective['severity'])\n    : 'medium';\n  return {\n    stakeholder,\n    impact,\n    severity,\n    reasoning: asStr(d['reasoning']),\n    evidence: asStrArr(d['evidence']),\n  };\n}\n\n/**\n * Build a StakeholderOutcomeMatrix row for a single parliamentary action.\n * Derives outcomes from per-stakeholder scores: score > 0.6 → winner,\n * score < 0.4 → loser, otherwise neutral.\n *\n * @param action - The parliamentary action being assessed\n * @param scores - Per-stakeholder importance scores (0-1); defaults to 0.5\n * @param confidence - Confidence level for the outcome assessments\n * @returns A StakeholderOutcomeMatrix row\n */\nexport function buildStakeholderOutcomeMatrix(\n  action: string,\n  scores: Partial<Record<AnalysisStakeholderType, number>> = {},\n  confidence: StakeholderOutcomeMatrix['confidence'] = 'medium'\n): StakeholderOutcomeMatrix {\n  const outcomes = Object.fromEntries(\n    ALL_STAKEHOLDER_TYPES.map((stakeholder) => {\n      const score = scores[stakeholder] ?? 0.5;\n      const outcome: 'winner' | 'loser' | 'neutral' =\n        score > 0.6 ? 'winner' : score < 0.4 ? 'loser' : 'neutral';\n      return [stakeholder, outcome];\n    })\n  ) as Record<AnalysisStakeholderType, 'winner' | 'loser' | 'neutral'>;\n  return { action, outcomes, confidence };\n}\n\n/**\n * Map an array of StakeholderPerspective objects to a simple influence ranking.\n * Returns stakeholder types sorted by severity (high → medium → low), then by\n * impact direction (negative before positive, as negative impacts require more\n * political attention).\n *\n * @param perspectives - Array of stakeholder perspectives to rank\n * @returns Stakeholder types sorted by influence priority\n */\nexport function rankStakeholdersByInfluence(\n  perspectives: readonly StakeholderPerspective[]\n): AnalysisStakeholderType[] {\n  const severityWeight: Record<StakeholderPerspective['severity'], number> = {\n    high: 3,\n    medium: 2,\n    low: 1,\n  };\n  const impactWeight: Record<StakeholderPerspective['impact'], number> = {\n    negative: 3,\n    mixed: 2,\n    positive: 1,\n    neutral: 0,\n  };\n  return [...perspectives]\n    .sort((a, b) => {\n      const sw = severityWeight[b.severity] - severityWeight[a.severity];\n      if (sw !== 0) return sw;\n      const iw = impactWeight[b.impact] - impactWeight[a.impact];\n      if (iw !== 0) return iw;\n      // Deterministic tie-breaker: canonical ALL_STAKEHOLDER_TYPES order\n      return (\n        ALL_STAKEHOLDER_TYPES.indexOf(a.stakeholder) - ALL_STAKEHOLDER_TYPES.indexOf(b.stakeholder)\n      );\n    })\n    .map((p) => p.stakeholder);\n}\n\n// ─── Advanced political intelligence functions ──────────────────────────────\n\n/**\n * Compute voting intensity metrics from a set of voting records.\n * Analyses the distribution of for/against/abstain votes to determine\n * unanimity, polarization, and margin characteristics.\n *\n * @param records - Voting records to analyse\n * @returns VotingIntensity metrics, or null if the records array is empty or contains no\n * valid vote counts (for example, when all records have a total vote count of 0)\n */\nexport function computeVotingIntensity(records: readonly VotingRecord[]): VotingIntensity | null {\n  if (records.length === 0) return null;\n\n  let totalUnanimity = 0;\n  let totalPolarization = 0;\n  let totalMargin = 0;\n  let closeVoteCount = 0;\n  let decisiveVoteCount = 0;\n\n  let validCount = 0;\n  let polarizationCount = 0;\n  for (const record of records) {\n    const total = record.votes.for + record.votes.against + record.votes.abstain;\n    if (total === 0) continue;\n    validCount++;\n\n    const forPct = record.votes.for / total;\n    const againstPct = record.votes.against / total;\n    const abstainPct = record.votes.abstain / total;\n    const margin = Math.abs(forPct - againstPct);\n\n    // Largest-position share: max share among for/against/abstain\n    const maxPct = Math.max(forPct, againstPct, abstainPct);\n    totalUnanimity += maxPct;\n\n    // Margin, close/decisive, and polarization only meaningful when for+against > 0\n    const forAgainstTotal = record.votes.for + record.votes.against;\n    if (forAgainstTotal > 0) {\n      polarizationCount++;\n      const balance = Math.min(record.votes.for, record.votes.against) / forAgainstTotal;\n      totalPolarization += balance * 2; // normalise: 0 = one-sided, 1 = perfectly split\n\n      totalMargin += margin;\n      if (margin < 0.1) closeVoteCount++;\n      if (margin > 0.6) decisiveVoteCount++;\n    }\n  }\n\n  if (validCount === 0) return null;\n\n  return {\n    unanimity: Math.round((totalUnanimity / validCount) * 100) / 100,\n    polarization:\n      polarizationCount > 0 ? Math.round((totalPolarization / polarizationCount) * 100) / 100 : 0,\n    averageMargin:\n      polarizationCount > 0 ? Math.round((totalMargin / polarizationCount) * 100) / 100 : 0,\n    closeVoteCount,\n    decisiveVoteCount,\n  };\n}\n\n/**\n * Detect coalition shifts by comparing current cohesion patterns against\n * a baseline. Stability threshold is ±5%; severity tiers are >5% (medium),\n * >10% (high), and >20% (critical).\n *\n * @param currentPatterns - Current period voting patterns\n * @param baselinePatterns - Previous period patterns (or estimated baseline)\n * @returns Array of detected coalition shifts, sorted by significance\n */\nexport function detectCoalitionShifts(\n  currentPatterns: readonly VotingPattern[],\n  baselinePatterns: readonly VotingPattern[]\n): CoalitionShift[] {\n  const baselineMap = new Map<string, number>();\n  for (const bp of baselinePatterns) {\n    baselineMap.set(bp.group, bp.cohesion);\n  }\n\n  const shifts: CoalitionShift[] = [];\n  for (const current of currentPatterns) {\n    const previous = baselineMap.get(current.group) ?? current.cohesion;\n    const delta = current.cohesion - previous;\n    const absDelta = Math.abs(delta);\n\n    let direction: CoalitionShift['direction'];\n    if (delta > 0.05) direction = 'strengthening';\n    else if (delta < -0.05) direction = 'weakening';\n    else direction = 'stable';\n\n    let significance: CoalitionShift['significance'];\n    if (absDelta > 0.2) significance = 'critical';\n    else if (absDelta > 0.1) significance = 'high';\n    else if (absDelta > 0.05) significance = 'medium';\n    else significance = 'low';\n\n    shifts.push({\n      group: current.group,\n      previousCohesion: Math.round(previous * 100) / 100,\n      currentCohesion: Math.round(current.cohesion * 100) / 100,\n      cohesionDelta: Math.round(delta * 100) / 100,\n      direction,\n      significance,\n    });\n  }\n\n  // Sort by significance (critical first), then by absolute delta descending\n  const sigOrder: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };\n  return shifts.sort((a, b) => {\n    const sigDiff = (sigOrder[b.significance] ?? 0) - (sigOrder[a.significance] ?? 0);\n    if (sigDiff !== 0) return sigDiff;\n    return Math.abs(b.cohesionDelta) - Math.abs(a.cohesionDelta);\n  });\n}\n\n/**\n * Classify cohesion groups into high-cohesion and fragmented categories.\n *\n * @param patterns - Voting patterns\n * @returns Tuple of [highCohesionGroups, fragmentedGroups]\n */\nfunction classifyCohesionGroups(patterns: readonly VotingPattern[]): [string[], string[]] {\n  const high: string[] = [];\n  const fragmented: string[] = [];\n  for (const p of patterns) {\n    if (p.cohesion > 0.8) high.push(p.group);\n    if (p.cohesion < 0.5) fragmented.push(p.group);\n  }\n  return [high, fragmented];\n}\n\n/**\n * Compute effective number of voting blocs using Laakso-Taagepera style.\n *\n * @param patterns - Voting patterns\n * @returns Effective number of blocs\n */\nfunction computeEffectiveBlocs(patterns: readonly VotingPattern[]): number {\n  let totalParticipation = 0;\n  for (const p of patterns) totalParticipation += p.participation;\n  if (totalParticipation <= 0) return patterns.length;\n  let sumSquares = 0;\n  for (const p of patterns) {\n    const share = p.participation / totalParticipation;\n    sumSquares += share * share;\n  }\n  return sumSquares > 0 ? 1 / sumSquares : patterns.length;\n}\n\n/**\n * Map an overall index to a polarization assessment label.\n *\n * @param index - Polarization index (0-1)\n * @returns Assessment label\n */\nfunction assessPolarization(index: number): PolarizationIndex['assessment'] {\n  if (index >= 0.75) return 'highly-polarized';\n  if (index >= 0.5) return 'polarized';\n  if (index >= 0.25) return 'moderate';\n  return 'consensus';\n}\n\n/**\n * Compute a polarization index for a parliamentary period based on\n * voting pattern cohesion data. Uses a Laakso-Taagepera–inspired\n * \"effective number of blocs\" calculation alongside cohesion analysis.\n *\n * @param patterns - Voting patterns for the period\n * @returns PolarizationIndex assessment, or null if patterns are empty\n */\nexport function computePolarizationIndex(\n  patterns: readonly VotingPattern[]\n): PolarizationIndex | null {\n  if (patterns.length === 0) return null;\n\n  const [highCohesionGroups, fragmentedGroups] = classifyCohesionGroups(patterns);\n  const effectiveBlocs = computeEffectiveBlocs(patterns);\n\n  const extremeCount = highCohesionGroups.length + fragmentedGroups.length;\n  const overallIndex = Math.round((extremeCount / patterns.length) * 100) / 100;\n\n  return {\n    overallIndex,\n    effectiveBlocs: Math.round(effectiveBlocs * 100) / 100,\n    highCohesionGroups,\n    fragmentedGroups,\n    assessment: assessPolarization(overallIndex),\n  };\n}\n\n// ─── Cross-session analysis functions ─────────────────────────────────────────\n\n/**\n * Compute average of a numeric array.\n *\n * @param values - Array of numbers\n * @returns Arithmetic mean, or 0 for empty arrays\n */\nfunction avg(values: readonly number[]): number {\n  return values.length > 0 ? values.reduce((s, v) => s + v, 0) / values.length : 0;\n}\n\n/**\n * Extract valid vote margins and result tallies from voting records.\n * Skips records with missing/malformed vote data, non-finite or negative\n * vote counts, or where for + against is zero (abstain-only votes) to avoid\n * skewing margin and polarization calculations. Only records where\n * `votes.for` and `votes.against` are finite, non-negative numbers are used;\n * numeric-string encodings and other non-numeric values are ignored.\n *\n * @param records - Voting records to process\n * @returns Object containing margins array and per-record result classifications\n */\nfunction extractMarginData(records: readonly VotingRecord[]): {\n  margins: number[];\n  results: Array<'adopted' | 'rejected' | 'other'>;\n} {\n  const margins: number[] = [];\n  const results: Array<'adopted' | 'rejected' | 'other'> = [];\n\n  for (const r of records) {\n    const votes = r.votes;\n    if (!votes || typeof votes !== 'object') continue;\n\n    // Require actual finite numbers — asNum() would silently map non-numbers to 0,\n    // which would include malformed records and skew margins/polarization metrics.\n    const forCount = votes.for;\n    const againstCount = votes.against;\n    if (typeof forCount !== 'number' || !Number.isFinite(forCount) || forCount < 0) continue;\n    if (typeof againstCount !== 'number' || !Number.isFinite(againstCount) || againstCount < 0)\n      continue;\n\n    const forAgainstTotal = forCount + againstCount;\n    if (forAgainstTotal <= 0) continue;\n\n    margins.push(Math.abs(forCount - againstCount) / forAgainstTotal);\n    const result = asStr(r.result).toLowerCase();\n    if (result === 'adopted' || result === 'approved') {\n      results.push('adopted');\n    } else if (result === 'rejected') {\n      results.push('rejected');\n    } else {\n      results.push('other');\n    }\n  }\n\n  return { margins, results };\n}\n\n/**\n * Compute adoption rate from a results slice.\n *\n * @param results - Array of result classifications\n * @returns Adoption rate (0-1), or 0 if no decided records\n */\nfunction computeAdoptionRate(results: ReadonlyArray<'adopted' | 'rejected' | 'other'>): number {\n  const adopted = results.filter((r) => r === 'adopted').length;\n  const decided = results.filter((r) => r === 'adopted' || r === 'rejected').length;\n  return decided > 0 ? adopted / decided : 0;\n}\n\n/**\n * Derive adoption-rate direction by comparing first-half and second-half rates.\n *\n * @param firstRate - Adoption rate of the first chronological half\n * @param secondRate - Adoption rate of the second chronological half\n * @returns Direction label based on delta between halves\n */\nfunction adoptionDirection(firstRate: number, secondRate: number): VotingTrend['direction'] {\n  const delta = secondRate - firstRate;\n  if (delta > 0.05) return 'increasing';\n  if (delta < -0.05) return 'decreasing';\n  return 'stable';\n}\n\n/**\n * Build a margin-shift trend if the delta exceeds 5%.\n *\n * @param firstHalf - Margins from the first half of records\n * @param secondHalf - Margins from the second half of records\n * @param total - Total number of valid records\n * @returns VotingTrend or null if delta is within threshold\n */\nfunction buildMarginTrend(\n  firstHalf: number[],\n  secondHalf: number[],\n  total: number\n): VotingTrend | null {\n  const marginDelta = avg(secondHalf) - avg(firstHalf);\n  if (Math.abs(marginDelta) <= 0.05) return null;\n  const isIncreasing = marginDelta > 0;\n  return {\n    trendId: isIncreasing ? 'increasing-margins' : 'decreasing-margins',\n    description: isIncreasing\n      ? 'Voting margins are widening — greater decisiveness'\n      : 'Voting margins are narrowing — increasing contention',\n    direction: isIncreasing ? 'increasing' : 'decreasing',\n    confidence: Math.min(1, Math.round(Math.abs(marginDelta) * 5 * 100) / 100),\n    recordCount: total,\n    metricValue: Math.round(marginDelta * 100) / 100,\n  };\n}\n\n/**\n * Build a polarization trend if the close-vote frequency delta exceeds 10%.\n *\n * @param firstHalf - Margins from the first half of records\n * @param secondHalf - Margins from the second half of records\n * @param total - Total number of valid records\n * @returns VotingTrend or null if delta is within threshold\n */\nfunction buildPolarizationTrend(\n  firstHalf: number[],\n  secondHalf: number[],\n  total: number\n): VotingTrend | null {\n  const closeFirst = firstHalf.filter((m) => m < 0.1).length / firstHalf.length;\n  const closeSecond = secondHalf.filter((m) => m < 0.1).length / secondHalf.length;\n  const closeDelta = closeSecond - closeFirst;\n  if (Math.abs(closeDelta) <= 0.1) return null;\n  const isIncreasing = closeDelta > 0;\n  return {\n    trendId: isIncreasing ? 'increasing-polarization' : 'decreasing-polarization',\n    description: isIncreasing\n      ? 'More close votes detected — increasing polarization'\n      : 'Fewer close votes — declining polarization',\n    direction: isIncreasing ? 'increasing' : 'decreasing',\n    confidence: Math.min(1, Math.round(Math.abs(closeDelta) * 3 * 100) / 100),\n    recordCount: total,\n    metricValue: Math.round(closeDelta * 100) / 100,\n  };\n}\n\n/**\n * Detect voting trends across multiple voting records by analysing\n * margin distribution, polarization patterns, and result consistency.\n * Records are sorted by date (ascending) before analysis to ensure\n * chronological trend detection. Returns an array of detected trends\n * sorted by confidence.\n *\n * @param records - Voting records to analyse across sessions\n * @returns Array of detected VotingTrend objects (empty if fewer than 2 valid records)\n */\nexport function detectVotingTrends(records: readonly VotingRecord[]): VotingTrend[] {\n  if (records.length < 2) return [];\n\n  const toTimestamp = (d: string | undefined): number => {\n    const t = Date.parse(d ?? '');\n    return Number.isFinite(t) ? t : Infinity;\n  };\n  const sorted = [...records].sort((a, b) => {\n    const ta = toTimestamp(a.date);\n    const tb = toTimestamp(b.date);\n    if (ta === tb) return 0;\n    return ta < tb ? -1 : 1;\n  });\n\n  const { margins, results } = extractMarginData(sorted);\n  if (margins.length < 2) return [];\n\n  const mid = Math.floor(margins.length / 2);\n  const firstHalf = margins.slice(0, mid);\n  const secondHalf = margins.slice(mid);\n\n  const trends: VotingTrend[] = [];\n\n  const marginTrend = buildMarginTrend(firstHalf, secondHalf, margins.length);\n  if (marginTrend) trends.push(marginTrend);\n\n  const polTrend = buildPolarizationTrend(firstHalf, secondHalf, margins.length);\n  if (polTrend) trends.push(polTrend);\n\n  const firstResults = results.slice(0, mid);\n  const secondResults = results.slice(mid);\n  const totalDecided = results.filter((r) => r === 'adopted' || r === 'rejected').length;\n  if (totalDecided > 0) {\n    const overallRate = computeAdoptionRate(results);\n    const firstRate = computeAdoptionRate(firstResults);\n    const secondRate = computeAdoptionRate(secondResults);\n    trends.push({\n      trendId: 'adoption-rate',\n      description: `Adoption rate is ${Math.round(overallRate * 100)}% across ${totalDecided} decided votes`,\n      direction: adoptionDirection(firstRate, secondRate),\n      confidence: Math.min(1, Math.round((totalDecided / margins.length) * 100) / 100),\n      recordCount: totalDecided,\n      metricValue: Math.round(overallRate * 100) / 100,\n    });\n  }\n\n  return trends.sort((a, b) => b.confidence - a.confidence);\n}\n\n/**\n * Compute cross-session coalition stability by analysing average cohesion\n * across a set of voting patterns. Groups with high cohesion are reported\n * as stable; those with low cohesion are flagged.\n *\n * @param patterns - Voting patterns from multiple sessions\n * @returns CoalitionStabilityReport (empty report if no patterns)\n */\nexport function computeCrossSessionCoalitionStability(\n  patterns: readonly VotingPattern[]\n): CoalitionStabilityReport {\n  if (patterns.length === 0) {\n    return {\n      overallStability: 0,\n      patternCount: 0,\n      stableGroups: [],\n      decliningGroups: [],\n      forecast: 'volatile',\n    };\n  }\n\n  // Aggregate cohesion per group, coercing and clamping to [0, 1]\n  const groupCohesions = new Map<string, number[]>();\n  let includedPatterns = 0;\n  for (const p of patterns) {\n    const groupKey = asStr(p.group).trim();\n    if (groupKey.length === 0) continue;\n    includedPatterns++;\n    const raw = asNum(p.cohesion);\n    const clamped = Math.max(0, Math.min(1, raw));\n    const existing = groupCohesions.get(groupKey);\n    if (existing) {\n      existing.push(clamped);\n    } else {\n      groupCohesions.set(groupKey, [clamped]);\n    }\n  }\n\n  const stableGroups: string[] = [];\n  const decliningGroups: string[] = [];\n  let totalAvgCohesion = 0;\n  let groupCount = 0;\n\n  for (const [group, cohesions] of groupCohesions) {\n    const avgCohesion = cohesions.reduce((s, v) => s + v, 0) / cohesions.length;\n    totalAvgCohesion += avgCohesion;\n    groupCount++;\n\n    if (avgCohesion >= 0.7) {\n      stableGroups.push(group);\n    } else if (avgCohesion < 0.5) {\n      decliningGroups.push(group);\n    }\n  }\n\n  const overallStability =\n    groupCount > 0 ? Math.round((totalAvgCohesion / groupCount) * 100) / 100 : 0;\n\n  let forecast: CoalitionStabilityReport['forecast'];\n  if (overallStability >= 0.7) forecast = 'stable';\n  else if (overallStability >= 0.5) forecast = 'at-risk';\n  else forecast = 'volatile';\n\n  return {\n    overallStability,\n    patternCount: includedPatterns,\n    stableGroups,\n    decliningGroups,\n    forecast,\n  };\n}\n\n/**\n * Rank MEP influence scores filtered by topic relevance.\n * Matches scores whose mepName, mepId, or rank contains the topic substring\n * (case-insensitive). Returns the filtered list sorted by overallScore\n * descending. If topic is empty or no matches found, returns all scores\n * sorted by overallScore.\n *\n * @param scores - MEP influence scores to rank\n * @param topic - Topic keyword to filter by; if null/undefined/empty, all\n * scores are returned sorted by overallScore\n * @returns Sorted array of matching MEPInfluenceScore entries\n */\nexport function rankMEPInfluenceByTopic(\n  scores: readonly MEPInfluenceScore[],\n  topic: string | null | undefined\n): MEPInfluenceScore[] {\n  if (scores.length === 0) return [];\n\n  const lowerTopic = String(topic ?? '')\n    .toLowerCase()\n    .trim();\n\n  const getSafeScore = (entry: MEPInfluenceScore): number => {\n    const raw = asNum(entry.overallScore);\n    return Number.isFinite(raw) ? raw : 0;\n  };\n\n  // If topic is empty, return all sorted by score\n  if (lowerTopic.length === 0) {\n    return [...scores].sort((a, b) => getSafeScore(b) - getSafeScore(a));\n  }\n\n  const matched = scores.filter((s) => {\n    const safeName = typeof s.mepName === 'string' ? s.mepName.toLowerCase() : '';\n    const safeRank = typeof s.rank === 'string' ? s.rank.toLowerCase() : '';\n    const safeId = typeof s.mepId === 'string' ? s.mepId.toLowerCase() : '';\n    return (\n      safeName.includes(lowerTopic) || safeRank.includes(lowerTopic) || safeId.includes(lowerTopic)\n    );\n  });\n\n  // If no matches, return all sorted\n  const pool = matched.length > 0 ? matched : [...scores];\n  return pool.sort((a, b) => getSafeScore(b) - getSafeScore(a));\n}\n\n/**\n * Count stages whose document count exceeds 1.5× the average (bottlenecks).\n *\n * @param stageValues - Array of per-stage document counts\n * @returns Number of bottleneck stages\n */\nfunction countBottleneckStages(stageValues: readonly number[]): number {\n  if (stageValues.length === 0) return 0;\n  const avgPerStage = stageValues.reduce((s, v) => s + v, 0) / stageValues.length;\n  let count = 0;\n  for (const val of stageValues) {\n    if (val > avgPerStage * 1.5 && val > 1) count++;\n  }\n  return count;\n}\n\n/**\n * Compute average days per stage from a set of valid timestamps and the\n * number of stages. Returns 0 when fewer than 2 dates are available.\n *\n * @param dates - Array of valid timestamp numbers (ms since epoch)\n * @param stageCount - Number of distinct stages\n * @returns Estimated average days per stage (rounded)\n */\nfunction computeDaysPerStage(dates: readonly number[], stageCount: number): number {\n  if (dates.length < 2 || stageCount <= 0) return 0;\n  let minDate = dates[0] as number;\n  let maxDate = dates[0] as number;\n  for (let i = 1; i < dates.length; i++) {\n    const current = dates[i] as number;\n    if (current < minDate) minDate = current;\n    if (current > maxDate) maxDate = current;\n  }\n  const spanDays = (maxDate - minDate) / (1000 * 60 * 60 * 24);\n  return Math.round(spanDays / stageCount);\n}\n\n/**\n * Determine throughput assessment label based on date availability and\n * average days per stage.\n *\n * @param hasDateData - Whether sufficient date data was available\n * @param avgDays - Average days per stage\n * @returns Throughput label: 'fast', 'normal', or 'slow'\n */\nfunction assessThroughput(\n  hasDateData: boolean,\n  avgDays: number\n): LegislativeVelocityReport['throughputAssessment'] {\n  if (!hasDateData) return 'normal';\n  if (avgDays <= 30) return 'fast';\n  if (avgDays <= 90) return 'normal';\n  return 'slow';\n}\n\n/**\n * Build a legislative velocity report with stage-by-stage breakdown from\n * a set of legislative documents. Analyses document status distribution\n * and identifies potential bottlenecks.\n *\n * @param docs - Legislative documents to analyse\n * @returns LegislativeVelocityReport summary\n */\nexport function buildLegislativeVelocityReport(\n  docs: readonly LegislativeDocument[]\n): LegislativeVelocityReport {\n  if (docs.length === 0) {\n    return {\n      documentCount: 0,\n      stageBreakdown: Object.create(null) as Record<string, number>,\n      averageDaysPerStage: 0,\n      bottleneckCount: 0,\n      throughputAssessment: assessThroughput(false, 0),\n    };\n  }\n\n  const stageBreakdown: Record<string, number> = Object.create(null) as Record<string, number>;\n  for (const doc of docs) {\n    const status = typeof doc.status === 'string' ? doc.status.trim() : '';\n    const type = typeof doc.type === 'string' ? doc.type.trim() : '';\n    const rawStage = status || type || 'Unknown';\n    const stage =\n      rawStage === '__proto__' || rawStage === 'constructor' || rawStage === 'prototype'\n        ? 'Unknown'\n        : rawStage;\n    stageBreakdown[stage] = (stageBreakdown[stage] ?? 0) + 1;\n  }\n\n  const bottleneckCount = countBottleneckStages(Object.values(stageBreakdown));\n\n  const dates = docs\n    .map((d) => (d.date ? new Date(d.date).getTime() : NaN))\n    .filter((t) => !Number.isNaN(t));\n\n  const hasDateData = dates.length >= 2;\n  const averageDaysPerStage = computeDaysPerStage(dates, Object.keys(stageBreakdown).length);\n\n  return {\n    documentCount: docs.length,\n    stageBreakdown,\n    averageDaysPerStage,\n    bottleneckCount,\n    throughputAssessment: assessThroughput(hasDateData, averageDaysPerStage),\n  };\n}\n\n// ─── Coalition shift signal detection ────────────────────────────────────────\n\n/**\n * Traditional EP political groups used as baseline for cross-party alignment detection.\n * Groups from different blocs (centre-left vs centre-right vs far-right) are tracked\n * to identify non-traditional coalition patterns.\n */\nconst TRADITIONAL_LEFT_GROUPS: readonly string[] = ['S&D', 'Greens/EFA', 'The Left'];\nconst TRADITIONAL_RIGHT_GROUPS: readonly string[] = ['ECR', 'ID', 'PfE'];\nconst TRADITIONAL_CENTRE_GROUPS: readonly string[] = ['EPP', 'Renew'];\n\n/** Map known EP group label variants to a canonical short form. */\nconst GROUP_LABEL_ALIASES: Readonly<Record<string, string>> = {\n  Patriots: 'PfE',\n  'Patriots for Europe': 'PfE',\n  'Renew Europe': 'Renew',\n  'Identity and Democracy': 'ID',\n};\n\n/** Signal type constant for cross-party alignment detection */\nconst CROSS_PARTY_ALIGNMENT = 'cross-party-alignment' as const;\n\n/**\n * Normalize known EP political group label variants to a canonical form.\n *\n * @param group - Political group name\n * @returns Canonical political group label\n */\nfunction normalizeGroupLabel(group: string): string {\n  const trimmed = group.trim();\n  return GROUP_LABEL_ALIASES[trimmed] ?? trimmed;\n}\n\n/**\n * Classify a group into its traditional EP bloc.\n *\n * @param group - Political group name\n * @returns 'left' | 'right' | 'centre' | 'unknown'\n */\nfunction classifyBloc(group: string): 'left' | 'right' | 'centre' | 'unknown' {\n  const normalized = normalizeGroupLabel(group);\n  if (TRADITIONAL_LEFT_GROUPS.includes(normalized)) return 'left';\n  if (TRADITIONAL_RIGHT_GROUPS.includes(normalized)) return 'right';\n  if (TRADITIONAL_CENTRE_GROUPS.includes(normalized)) return 'centre';\n  return 'unknown';\n}\n\n/**\n * Check if two traditional blocs are opposing each other.\n *\n * @param blocA - First bloc classification\n * @param blocB - Second bloc classification\n * @returns true if the two blocs are traditionally opposing\n */\nfunction areOpposingBlocs(blocA: string, blocB: string): boolean {\n  if (blocA === 'unknown' || blocB === 'unknown') return false;\n  return blocA !== blocB;\n}\n\n/**\n * Classify a single voting pattern into a coalition shift signal (if any).\n *\n * @param pattern - Current voting pattern for one group\n * @param avgCohesion - Average cohesion across all groups\n * @param currentPatterns - All current patterns (for cross-party detection)\n * @returns A signal or null if no anomaly detected\n */\nfunction classifyPatternSignal(\n  pattern: VotingPattern,\n  avgCohesion: number,\n  currentPatterns: readonly VotingPattern[]\n): CoalitionShiftSignal | null {\n  const { group, cohesion } = pattern;\n\n  // Bloc fragmentation: internal cohesion critically low\n  if (cohesion < 0.4) {\n    return {\n      group,\n      patternType: 'bloc-fragmentation',\n      cohesion,\n      confidence: cohesion < 0.25 ? 'high' : 'medium',\n      description: `${group} internal cohesion at ${(cohesion * 100).toFixed(0)}% — bloc may be fragmenting`,\n    };\n  }\n\n  // Isolation: group cohesion significantly below peers\n  if (cohesion < avgCohesion * 0.6 && avgCohesion > 0.6) {\n    return {\n      group,\n      patternType: 'isolation',\n      cohesion,\n      confidence: 'medium',\n      description: `${group} cohesion (${(cohesion * 100).toFixed(0)}%) significantly below group average (${(avgCohesion * 100).toFixed(0)}%) — possible isolation`,\n    };\n  }\n\n  // Cross-party alignment: high-cohesion group with traditionally opposing partners\n  if (cohesion > 0.8) {\n    const bloc = classifyBloc(group);\n    const opposingHighCohesion = currentPatterns.filter(\n      (p) => p.group !== group && p.cohesion > 0.8 && areOpposingBlocs(bloc, classifyBloc(p.group))\n    );\n    if (opposingHighCohesion.length > 0) {\n      return {\n        group,\n        patternType: CROSS_PARTY_ALIGNMENT,\n        cohesion,\n        confidence: cohesion > 0.9 ? 'high' : 'medium',\n        description: `${group} (${(cohesion * 100).toFixed(0)}% cohesion) may be aligning with ${opposingHighCohesion.map((p) => p.group).join(', ')}`,\n      };\n    }\n  }\n\n  return null;\n}\n\n/**\n * Detect new-bloc-formation signals across high-cohesion groups spanning\n * multiple traditional blocs. Returns new signals and a set of group names\n * whose cross-party signals should be replaced.\n *\n * @param currentPatterns - All voting patterns\n * @param existingSignals - Signals already detected per group (read-only)\n * @returns Object with new-bloc signals and groups whose cross-party signals to remove\n */\nfunction detectNewBlocFormation(\n  currentPatterns: readonly VotingPattern[],\n  existingSignals: readonly CoalitionShiftSignal[]\n): { signals: CoalitionShiftSignal[]; upgradedGroups: ReadonlySet<string> } {\n  const highCohesionGroups = currentPatterns.filter((p) => p.cohesion > 0.8);\n  const hasLeft = highCohesionGroups.some((p) => classifyBloc(p.group) === 'left');\n  const hasRight = highCohesionGroups.some((p) => classifyBloc(p.group) === 'right');\n  const hasCentre = highCohesionGroups.some((p) => classifyBloc(p.group) === 'centre');\n  const distinctBlocsHigh = [hasLeft, hasRight, hasCentre].filter(Boolean).length;\n\n  if (distinctBlocsHigh < 2 || highCohesionGroups.length < 3) {\n    return { signals: [], upgradedGroups: new Set() };\n  }\n\n  // Upgrade cross-party signals to new-bloc-formation when a broad coalition is forming\n  const results: CoalitionShiftSignal[] = [];\n  const upgraded = new Set<string>();\n  for (const pattern of highCohesionGroups) {\n    const hasCrossParty = existingSignals.some(\n      (s) => s.group === pattern.group && s.patternType === CROSS_PARTY_ALIGNMENT\n    );\n    results.push({\n      group: pattern.group,\n      patternType: 'new-bloc-formation',\n      cohesion: pattern.cohesion,\n      confidence: highCohesionGroups.length >= 4 ? 'high' : 'medium',\n      description: `${pattern.group} part of an emerging cross-bloc coalition (${highCohesionGroups.length} groups with >80% cohesion)`,\n    });\n    if (hasCrossParty) {\n      upgraded.add(pattern.group);\n    }\n  }\n  return { signals: results, upgradedGroups: upgraded };\n}\n\n/**\n * Derive coalition shift signals from current voting patterns, detecting patterns\n * that diverge from traditional EP political group alignments.\n *\n * Signals detected:\n * - `cross-party-alignment`: a group is voting consistently with a traditionally\n *   opposing bloc (e.g. EPP aligning with S&D)\n * - `bloc-fragmentation`: low cohesion (< 0.4) indicating internal breakdown\n * - `isolation`: group voting against all others (only pattern with very low cohesion\n *   while all others have high cohesion)\n * - `new-bloc-formation`: multiple groups from different blocs all showing high cohesion\n *\n * @param currentPatterns - Current voting patterns with cohesion scores\n * @returns Array of CoalitionShiftSignal objects ordered by confidence (high → low)\n */\nexport function deriveCoalitionShiftSignals(\n  currentPatterns: readonly VotingPattern[]\n): CoalitionShiftSignal[] {\n  if (currentPatterns.length === 0) return [];\n\n  const avgCohesion = currentPatterns.reduce((s, p) => s + p.cohesion, 0) / currentPatterns.length;\n\n  // Classify individual patterns\n  const signals: CoalitionShiftSignal[] = [];\n  for (const pattern of currentPatterns) {\n    const signal = classifyPatternSignal(pattern, avgCohesion, currentPatterns);\n    if (signal) signals.push(signal);\n  }\n\n  // Detect new-bloc-formation across the full set\n  const { signals: blocSignals, upgradedGroups } = detectNewBlocFormation(currentPatterns, signals);\n\n  // Filter out cross-party signals that have been upgraded to new-bloc-formation\n  const filtered =\n    upgradedGroups.size > 0\n      ? signals.filter(\n          (s) => !(s.patternType === CROSS_PARTY_ALIGNMENT && upgradedGroups.has(s.group))\n        )\n      : signals;\n  filtered.push(...blocSignals);\n\n  // Sort by confidence (high → medium → low)\n  const confidenceOrder: Record<string, number> = { high: 3, medium: 2, low: 1 };\n  return filtered.sort(\n    (a, b) => (confidenceOrder[b.confidence] ?? 0) - (confidenceOrder[a.confidence] ?? 0)\n  );\n}\n\n// ─── Stakeholder influence trajectory ────────────────────────────────────────\n\n/**\n * Raw input data for computing a stakeholder's influence trajectory.\n */\nexport interface StakeholderInfluenceInput {\n  /** MEP or stakeholder identifier */\n  readonly stakeholderId: string;\n  /** Display name */\n  readonly name: string;\n  /** Current influence score (0–100) */\n  readonly currentScore: number;\n  /** Historical influence score for baseline comparison (0–100, optional) */\n  readonly historicalScore?: number;\n  /** Number of active committee assignments */\n  readonly committeeAssignments: number;\n  /** Number of rapporteur roles held */\n  readonly rapporteurRoles: number;\n  /** Number of shadow rapporteur roles held */\n  readonly shadowRapporteurRoles: number;\n}\n\n/**\n * Collect driving factor descriptions from stakeholder engagement metrics.\n *\n * @param input - Stakeholder engagement metrics\n * @param scoreDelta - Score difference vs historical baseline\n * @param hasHistorical - Whether historical score is available\n * @returns Array of descriptive factor strings\n */\nfunction collectDrivingFactors(\n  input: StakeholderInfluenceInput,\n  scoreDelta: number,\n  hasHistorical: boolean\n): string[] {\n  const factors: string[] = [];\n  if (input.committeeAssignments > 0) {\n    factors.push(`${input.committeeAssignments} active committee assignment(s)`);\n  }\n  if (input.rapporteurRoles > 0) {\n    factors.push(`${input.rapporteurRoles} rapporteur role(s)`);\n  }\n  if (input.shadowRapporteurRoles > 0) {\n    factors.push(`${input.shadowRapporteurRoles} shadow rapporteur role(s)`);\n  }\n  if (hasHistorical) {\n    factors.push(`Score ${scoreDelta >= 0 ? '+' : ''}${scoreDelta.toFixed(1)} vs baseline`);\n  }\n  return factors;\n}\n\n/**\n * Determine trajectory direction from score delta and engagement level.\n *\n * @param hasHistorical - Whether historical score is available\n * @param scoreDelta - Score difference vs historical baseline\n * @param totalEngagement - Sum of committee + rapporteur + shadow roles\n * @returns Trajectory direction\n */\nfunction determineTrajectory(\n  hasHistorical: boolean,\n  scoreDelta: number,\n  totalEngagement: number\n): StakeholderInfluenceTrajectory['trajectory'] {\n  if (hasHistorical && scoreDelta >= 5 && totalEngagement > 0) return 'rising';\n  if (hasHistorical && scoreDelta <= -5) return 'declining';\n  if (!hasHistorical && totalEngagement >= 3) return 'rising';\n  return 'stable';\n}\n\n/**\n * Determine confidence level from data availability.\n *\n * @param hasHistorical - Whether historical score is available\n * @param totalEngagement - Sum of committee + rapporteur + shadow roles\n * @returns Confidence level\n */\nfunction determineTrajectoryConfidence(\n  hasHistorical: boolean,\n  totalEngagement: number\n): StakeholderInfluenceTrajectory['confidence'] {\n  if (hasHistorical && totalEngagement > 0) return 'high';\n  if (hasHistorical || totalEngagement > 0) return 'medium';\n  return 'low';\n}\n\n/**\n * Compute the influence trajectory for a stakeholder based on their current\n * score, committee engagement, and comparison to historical baseline.\n *\n * Trajectory logic:\n * - If current score > historical by ≥ 5 points AND has committee/rapporteur roles → rising\n * - If current score < historical by ≥ 5 points → declining\n * - Otherwise → stable\n *\n * Confidence depends on whether historical data is available and role count.\n *\n * @param input - Stakeholder engagement metrics\n * @returns StakeholderInfluenceTrajectory with direction and driving factors\n */\nexport function computeStakeholderInfluenceTrajectory(\n  input: StakeholderInfluenceInput\n): StakeholderInfluenceTrajectory {\n  const hasHistorical = typeof input.historicalScore === 'number';\n  const scoreDelta = hasHistorical ? input.currentScore - (input.historicalScore as number) : 0;\n  const totalEngagement =\n    input.committeeAssignments + input.rapporteurRoles + input.shadowRapporteurRoles;\n\n  return {\n    stakeholderId: input.stakeholderId,\n    name: input.name,\n    trajectory: determineTrajectory(hasHistorical, scoreDelta, totalEngagement),\n    currentScore: input.currentScore,\n    confidence: determineTrajectoryConfidence(hasHistorical, totalEngagement),\n    drivingFactors: collectDrivingFactors(input, scoreDelta, hasHistorical),\n  };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/intelligence-index.ts","messages":[],"suppressedMessages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":104,"column":39,"endLine":104,"endColumn":66,"suppressions":[{"kind":"directive","justification":"existingIdx from findIndex"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":586,"column":17,"endLine":586,"endColumn":28,"suppressions":[{"kind":"directive","justification":"key validated via isSafeKey"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":589,"column":7,"endLine":589,"endColumn":16,"suppressions":[{"kind":"directive","justification":"key validated via isSafeKey"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":752,"column":18,"endLine":752,"endColumn":26,"suppressions":[{"kind":"directive","justification":"key validated via isSafeKey"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":757,"column":14,"endLine":757,"endColumn":22,"suppressions":[{"kind":"directive","justification":"key validated via isSafeKey"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":760,"column":7,"endLine":760,"endColumn":15,"suppressions":[{"kind":"directive","justification":"key validated via isSafeKey"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":781,"column":22,"endLine":781,"endColumn":30,"suppressions":[{"kind":"directive","justification":"key validated via isSafeKey"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":784,"column":7,"endLine":784,"endColumn":15,"suppressions":[{"kind":"directive","justification":"key validated via isSafeKey"}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/metadata-utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/news-metadata.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/political-classification.ts","messages":[],"suppressedMessages":[{"ruleId":"no-redeclare","severity":2,"message":"'safeArray' is already defined.","line":103,"column":10,"messageId":"redeclared","endLine":103,"endColumn":19,"suppressions":[{"kind":"directive","justification":"TypeScript function overload"}]},{"ruleId":"no-redeclare","severity":2,"message":"'safeArray' is already defined.","line":105,"column":10,"messageId":"redeclared","endLine":105,"endColumn":19,"suppressions":[{"kind":"directive","justification":"TypeScript function overload"}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/political-risk-assessment.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1085,"column":21,"endLine":1085,"endColumn":29},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1090,"column":21,"endLine":1090,"endColumn":29}],"suppressedMessages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":237,"column":27,"endLine":237,"endColumn":56,"suppressions":[{"kind":"directive","justification":"key validated via Object.hasOwn above"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":239,"column":23,"endLine":239,"endColumn":44,"suppressions":[{"kind":"directive","justification":"key validated via Object.hasOwn above"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":353,"column":86,"endLine":353,"endColumn":97,"suppressions":[{"kind":"directive","justification":"ti is array index from forEach"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":367,"column":76,"endLine":367,"endColumn":87,"suppressions":[{"kind":"directive","justification":"ti is array index from forEach"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":419,"column":26,"endLine":419,"endColumn":59,"suppressions":[{"kind":"directive","justification":"key validated by isLegislativeStage"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":815,"column":21,"endLine":815,"endColumn":50,"suppressions":[{"kind":"directive","justification":"keys are typed PoliticalRiskLikelihood/Impact from const arrays"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":815,"column":53,"endLine":815,"endColumn":74,"suppressions":[{"kind":"directive","justification":"keys are typed PoliticalRiskLikelihood/Impact from const arrays"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":818,"column":14,"endLine":818,"endColumn":35,"suppressions":[{"kind":"directive","justification":"key is a typed PoliticalRiskLevel"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1011,"column":24,"endLine":1011,"endColumn":53,"suppressions":[{"kind":"directive","justification":"keys validated via Object.hasOwn above"}]},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1011,"column":56,"endLine":1011,"endColumn":77,"suppressions":[{"kind":"directive","justification":"keys validated via Object.hasOwn above"}]}],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/PoliticalRiskAssessment\n * @description Pure political risk assessment utility functions adapted from ISMS risk\n * methodologies (Likelihood × Impact, Value at Risk, Annual Rate of Occurrence) for\n * European Parliament political intelligence.\n *\n * All functions are stateless and produce no side effects.\n *\n * Inspiration: Hack23 ISMS Risk Assessment Methodology\n * (https://github.com/Hack23/ISMS-PUBLIC/blob/main/Risk_Assessment_Methodology.md)\n */\n\nimport type { ConfidenceLevel } from '../types/analysis.js';\nimport type { ArticleCategory } from '../types/common.js';\nimport type { PoliticalActorType } from '../types/political-classification.js';\nimport type {\n  PoliticalRiskLikelihood,\n  PoliticalRiskImpact,\n  PoliticalRiskLevel,\n  PoliticalRiskScore,\n  PoliticalCapitalAtRisk,\n  PoliticalRiskDriver,\n  PoliticalThreatCategory,\n  LegislativeVelocityRisk,\n  LegislativeStage,\n  QuantitativeSWOT,\n  ScoredSWOTItem,\n  CrossImpactEntry,\n  SwotItemTrend,\n  AgentRiskAssessmentWorkflow,\n  RiskAssessmentStep,\n  PoliticalRiskSummary,\n  RiskLevelCounts,\n} from '../types/political-risk.js';\n\n// ─── Likelihood & Impact lookup tables ───────────────────────────────────────\n\n/** Numeric values for each likelihood level */\nconst LIKELIHOOD_VALUES: Readonly<Record<PoliticalRiskLikelihood, number>> = {\n  rare: 0.1,\n  unlikely: 0.3,\n  possible: 0.5,\n  likely: 0.7,\n  almost_certain: 0.9,\n};\n\n/** Numeric values for each impact level */\nconst IMPACT_VALUES: Readonly<Record<PoliticalRiskImpact, number>> = {\n  negligible: 1,\n  minor: 2,\n  moderate: 3,\n  major: 4,\n  severe: 5,\n};\n\n/** Expected stage durations in days (historical parliamentary averages) */\nconst EXPECTED_STAGE_DAYS: Readonly<Record<LegislativeStage, number>> = {\n  proposal: 90,\n  committee: 180,\n  plenary_first: 60,\n  trilogue: 120,\n  plenary_second: 45,\n  adopted: 0,\n  stalled: 365,\n};\n\n// ─── Risk level thresholds ───────────────────────────────────────────────────\n\n/** Score thresholds for risk level bands (score = likelihood × impact) */\nconst RISK_LEVEL_THRESHOLDS = {\n  LOW_MAX: 1.0,\n  MEDIUM_MAX: 2.0,\n  HIGH_MAX: 3.5,\n} as const;\n\n/**\n * Cross-impact: fraction of strength score that reduces a threat's net effect.\n * A strength with score 5 reduces a threat by 5 × 0.2 = 1.0 units.\n */\nconst STRENGTH_MITIGATION_COEFFICIENT = 0.2;\n\n/**\n * Cross-impact: fraction of weakness score that amplifies a threat's net effect.\n * A weakness with score 5 amplifies a threat by 5 × 0.15 = 0.75 units.\n */\nconst WEAKNESS_AMPLIFICATION_COEFFICIENT = 0.15;\n\n/**\n * Default legislative stage used when the raw stage string is not recognised.\n * Committee is chosen as the most common intermediate stage in EP procedures.\n */\nconst DEFAULT_LEGISLATIVE_STAGE: LegislativeStage = 'committee';\n\n// ─── Private helpers ─────────────────────────────────────────────────────────\n\n/**\n * Derive risk level from a composite score.\n *\n * @param score - Risk score (likelihood × impact)\n * @returns Risk level band\n */\nfunction deriveRiskLevel(score: number): PoliticalRiskLevel {\n  if (score <= RISK_LEVEL_THRESHOLDS.LOW_MAX) return 'low';\n  if (score <= RISK_LEVEL_THRESHOLDS.MEDIUM_MAX) return 'medium';\n  if (score <= RISK_LEVEL_THRESHOLDS.HIGH_MAX) return 'high';\n  return 'critical';\n}\n\n/**\n * Clamp a number to [min, max].\n *\n * Non-finite values are normalised deterministically to avoid leaking\n * NaN/Infinity into downstream calculations:\n * - NaN → min\n * - +Infinity → max\n * - -Infinity → min\n *\n * @param value - Number to clamp\n * @param min - Lower bound\n * @param max - Upper bound\n * @returns Clamped value\n */\nfunction clamp(value: number, min: number, max: number): number {\n  if (!Number.isFinite(value)) {\n    if (value === Infinity) return max;\n    // NaN or -Infinity\n    return min;\n  }\n  return Math.min(max, Math.max(min, value));\n}\n\n/**\n * Round to two decimal places.\n *\n * @param value - Number to round\n * @returns Rounded value\n */\nfunction round2(value: number): number {\n  return Math.round(value * 100) / 100;\n}\n\n/**\n * Safely coerce an unknown value to a string.\n *\n * @param val - Unknown value\n * @returns String or empty string\n */\nfunction asStr(val: unknown): string {\n  return typeof val === 'string' ? val : '';\n}\n\n/**\n * Safely coerce an unknown value to a finite number.\n *\n * @param val - Unknown value\n * @param fallback - Default when not a finite number\n * @returns Finite number or fallback\n */\nfunction asNum(val: unknown, fallback = 0): number {\n  if (typeof val === 'number' && Number.isFinite(val)) {\n    return val;\n  }\n\n  if (typeof val === 'string') {\n    const parsed = Number(val.trim());\n    if (Number.isFinite(parsed)) {\n      return parsed;\n    }\n  }\n\n  return fallback;\n}\n\n/**\n * Coerce an unknown value to a Record or null.\n *\n * @param input - Value to coerce\n * @returns Record or null\n */\nfunction toRecord(input: unknown): Record<string, unknown> | null {\n  if (input === null || input === undefined || typeof input !== 'object') return null;\n  return input as Record<string, unknown>;\n}\n\n// ─── Risk heat map emoji ─────────────────────────────────────────────────────\n\n/** Emoji cell for the risk heat map table */\nconst HEAT_MAP_CELLS: Readonly<Record<PoliticalRiskLevel, string>> = {\n  low: '🟢',\n  medium: '🟡',\n  high: '🟠',\n  critical: '🔴',\n};\n\n// ─── Exported core scoring functions ─────────────────────────────────────────\n\n/**\n * Calculate a political risk score from likelihood and impact levels.\n * Implements the ISMS-inspired Likelihood × Impact framework adapted for\n * European Parliament political intelligence.\n *\n * Risk Score = likelihoodValue × impactValue\n * - low: 0–≤1.0 | medium: >1.0–≤2.0 | high: >2.0–≤3.5 | critical: >3.5\n *\n * @param likelihood - Likelihood level of the political risk\n * @param impact - Impact level if the risk occurs\n * @param riskId - Optional risk identifier (defaults to \"RISK-AUTO\")\n * @param description - Optional risk description\n * @param evidence - Optional supporting evidence strings\n * @param mitigatingFactors - Optional factors that reduce likelihood or impact\n * @param confidence - Optional confidence level (defaults to 'medium')\n * @returns Fully populated PoliticalRiskScore\n */\nexport function calculatePoliticalRiskScore(\n  likelihood: PoliticalRiskLikelihood,\n  impact: PoliticalRiskImpact,\n  riskId = 'RISK-AUTO',\n  description = '',\n  evidence: readonly string[] = [],\n  mitigatingFactors: readonly string[] = [],\n  confidence: ConfidenceLevel = 'medium'\n): PoliticalRiskScore {\n  if (!Object.hasOwn(LIKELIHOOD_VALUES, likelihood)) {\n    throw new Error(\n      `Invalid likelihood: \"${String(likelihood)}\". Expected one of: ${Object.keys(LIKELIHOOD_VALUES).join(', ')}`\n    );\n  }\n  if (!Object.hasOwn(IMPACT_VALUES, impact)) {\n    throw new Error(\n      `Invalid impact: \"${String(impact)}\". Expected one of: ${Object.keys(IMPACT_VALUES).join(', ')}`\n    );\n  }\n  // eslint-disable-next-line security/detect-object-injection -- key validated via Object.hasOwn above\n  const likelihoodValue = LIKELIHOOD_VALUES[likelihood];\n  // eslint-disable-next-line security/detect-object-injection -- key validated via Object.hasOwn above\n  const impactValue = IMPACT_VALUES[impact];\n  const riskScore = round2(likelihoodValue * impactValue);\n  const riskLevel = deriveRiskLevel(riskScore);\n\n  return {\n    riskId,\n    description,\n    likelihood,\n    likelihoodValue,\n    impact,\n    impactValue,\n    riskScore,\n    riskLevel,\n    confidence,\n    evidence: [...evidence],\n    mitigatingFactors: [...mitigatingFactors],\n  };\n}\n\n/**\n * Assess Political Capital at Risk (PCaR) for a named political actor.\n * Adapted from ISMS Value at Risk: quantifies political capital exposure\n * given observable risk drivers derived from parliamentary data.\n *\n * Capital at Risk is estimated as: sum(driver contributions) * currentCapital / 100\n * clamped to [0, currentCapital].\n *\n * @param actor - Name or identifier of the political actor\n * @param actorType - Type classification of the actor\n * @param currentCapital - Current political capital score (0–100)\n * @param riskDrivers - List of risk drivers affecting this actor\n * @param timeHorizon - Assessment time horizon\n * @param confidenceInterval - Statistical confidence interval (e.g. 95)\n * @returns Populated PoliticalCapitalAtRisk structure\n */\nexport function assessPoliticalCapitalAtRisk(\n  actor: string,\n  actorType: PoliticalActorType,\n  currentCapital: number,\n  riskDrivers: readonly PoliticalRiskDriver[],\n  timeHorizon: PoliticalCapitalAtRisk['timeHorizon'] = 'quarter',\n  confidenceInterval = 95\n): PoliticalCapitalAtRisk {\n  const cappedCapital = clamp(currentCapital, 0, 100);\n  const totalContribution = riskDrivers.reduce((sum, d) => {\n    const numericContribution = Number(d.contribution);\n\n    if (!Number.isFinite(numericContribution)) {\n      return sum;\n    }\n\n    const safeContribution = clamp(numericContribution, -100, 100);\n    return sum + safeContribution;\n  }, 0);\n  const capitalAtRisk = round2(clamp((totalContribution / 100) * cappedCapital, 0, cappedCapital));\n\n  return {\n    actor,\n    actorType,\n    currentCapital: round2(cappedCapital),\n    capitalAtRisk,\n    riskDrivers: [...riskDrivers],\n    timeHorizon,\n    confidenceInterval: clamp(confidenceInterval, 0, 100),\n  };\n}\n\n/**\n * Build a quantitative SWOT analysis from scored items.\n * Extends the existing SwotAnalysis pattern with numerical scoring and\n * a cross-impact matrix showing how strengths/weaknesses interact with threats.\n *\n * Strategic Position Score = (sumStrengths + sumOpportunities) /\n *                            ((sumStrengths + sumOpportunities + sumWeaknesses + sumThreats) / 10)\n * Range: 0–10; above 5 = net-positive strategic position.\n *\n * @param title - Optional title for the analysis\n * @param strengths - Scored strength items\n * @param weaknesses - Scored weakness items\n * @param opportunities - Scored opportunity items\n * @param threats - Scored threat items\n * @returns QuantitativeSWOT with cross-impact matrix and strategic position score\n */\nexport function buildQuantitativeSWOT(\n  title: string | undefined,\n  strengths: readonly ScoredSWOTItem[],\n  weaknesses: readonly ScoredSWOTItem[],\n  opportunities: readonly ScoredSWOTItem[],\n  threats: readonly ScoredSWOTItem[]\n): QuantitativeSWOT {\n  const sumStrengths = strengths.reduce((s, i) => s + i.score, 0);\n  const sumWeaknesses = weaknesses.reduce((s, i) => s + i.score, 0);\n  const sumOpportunities = opportunities.reduce((s, i) => s + i.score, 0);\n  const sumThreats = threats.reduce((s, i) => s + i.score, 0);\n\n  const totalScore = sumStrengths + sumWeaknesses + sumOpportunities + sumThreats;\n  const positiveScore = sumStrengths + sumOpportunities;\n\n  // Strategic position: 0–10 scale; 5 = neutral\n  const strategicPositionScore =\n    totalScore > 0 ? round2(clamp((positiveScore / totalScore) * 10, 0, 10)) : 5;\n\n  // Build cross-impact matrix: each strength/weakness × each threat\n  const crossImpactMatrix: CrossImpactEntry[] = [];\n  strengths.forEach((strength, si) => {\n    threats.forEach((_threat, ti) => {\n      // Strengths reduce threat impact; contribution proportional to strength score\n      const netEffect = round2(-(strength.score * STRENGTH_MITIGATION_COEFFICIENT));\n      crossImpactMatrix.push({\n        swotIndex: si,\n        swotType: 'strength',\n        threatIndex: ti,\n        netEffect,\n        // eslint-disable-next-line security/detect-object-injection -- ti is array index from forEach\n        rationale: `Strength \"${strength.description}\" partially mitigates threat \"${threats[ti]?.description ?? ''}\"`,\n      });\n    });\n  });\n  weaknesses.forEach((weakness, wi) => {\n    threats.forEach((_threat, ti) => {\n      // Weaknesses amplify threat impact; contribution proportional to weakness score\n      const netEffect = round2(weakness.score * WEAKNESS_AMPLIFICATION_COEFFICIENT);\n      crossImpactMatrix.push({\n        swotIndex: wi,\n        swotType: 'weakness',\n        threatIndex: ti,\n        netEffect,\n        // eslint-disable-next-line security/detect-object-injection -- ti is array index from forEach\n        rationale: `Weakness \"${weakness.description}\" amplifies threat \"${threats[ti]?.description ?? ''}\"`,\n      });\n    });\n  });\n\n  const overallAssessment =\n    strategicPositionScore >= 7\n      ? 'Strong strategic position: strengths and opportunities outweigh weaknesses and threats.'\n      : strategicPositionScore >= 5\n        ? 'Moderate strategic position: balanced strengths and risks requiring careful monitoring.'\n        : 'Weak strategic position: weaknesses and threats dominate — urgent mitigation needed.';\n\n  const result: QuantitativeSWOT = {\n    strengths,\n    weaknesses,\n    opportunities,\n    threats,\n    crossImpactMatrix,\n    strategicPositionScore,\n    overallAssessment,\n    ...(title !== undefined ? { title } : {}),\n  };\n  return result;\n}\n\n/**\n * Assess legislative velocity risks for a set of procedures.\n * Adapted from ISMS Annual Rate of Occurrence: procedures spending significantly\n * longer than the historical average in a stage are assigned higher risk scores.\n *\n * @param procedures - Array of raw procedure objects (from MCP or fallback data)\n * @returns Array of LegislativeVelocityRisk objects sorted by risk score (highest first)\n */\nexport function assessLegislativeVelocityRisk(\n  procedures: readonly unknown[]\n): LegislativeVelocityRisk[] {\n  const results: LegislativeVelocityRisk[] = [];\n\n  for (const raw of procedures) {\n    const p = toRecord(raw);\n    if (!p) continue;\n\n    const procedureId = asStr(p['procedureId']) || asStr(p['id']);\n    const title = asStr(p['title']);\n    if (!procedureId || !title) continue;\n\n    const stageRaw = asStr(p['stage']).toLowerCase().replace(/\\s+/g, '_');\n    const currentStage: LegislativeStage = isLegislativeStage(stageRaw)\n      ? stageRaw\n      : DEFAULT_LEGISLATIVE_STAGE;\n    const daysInCurrentStage = Math.max(0, Math.round(asNum(p['daysInCurrentStage'])));\n    // eslint-disable-next-line security/detect-object-injection -- key validated by isLegislativeStage\n    const expectedDays = EXPECTED_STAGE_DAYS[currentStage];\n\n    // Velocity ratio: how many times the expected duration has passed\n    const velocityRatio = expectedDays > 0 ? daysInCurrentStage / expectedDays : 0;\n\n    // Map velocity ratio to likelihood/impact for the risk score\n    const likelihood = velocityRatioToLikelihood(velocityRatio);\n    const impact = stageToImpact(currentStage);\n\n    const velocityRisk = calculatePoliticalRiskScore(\n      likelihood,\n      impact,\n      `VEL-${procedureId}`,\n      `Legislative velocity risk: ${title} has been in ${currentStage} stage for ${daysInCurrentStage} days (expected: ${expectedDays})`,\n      [\n        `Stage: ${currentStage}`,\n        `Days in stage: ${daysInCurrentStage}`,\n        `Expected: ${expectedDays} days`,\n      ],\n      velocityRatio < 1 ? ['Procedure is within expected timeline'] : [],\n      velocityRatio < 0.5 ? 'high' : 'medium'\n    );\n\n    const predictedCompletion = asStr(p['predictedCompletion']) || null;\n\n    results.push({\n      procedureId,\n      title,\n      currentStage,\n      daysInCurrentStage,\n      expectedDaysForStage: expectedDays,\n      velocityRisk,\n      predictedCompletion,\n    });\n  }\n\n  // Sort by risk score descending\n  return results.sort((a, b) => b.velocityRisk.riskScore - a.velocityRisk.riskScore);\n}\n\n/**\n * Run a full agentic risk assessment workflow (identify → analyze → evaluate → treat).\n * Inspired by ISMS AI Agent-Driven Risk Assessment methodology, providing a\n * structured, auditable trace for agentic processes.\n *\n * @param assessmentId - Unique identifier for this assessment run\n * @param date - ISO date string for the assessment\n * @param articleType - Article category this assessment is produced for\n * @param identifiedRisks - Risks identified in the identify step\n * @param riskDrivers - Risk drivers analysed in the analyze step\n * @param mitigations - Mitigations recommended in the treat step\n * @returns Fully populated AgentRiskAssessmentWorkflow\n */\nexport function runAgentRiskAssessment(\n  assessmentId: string,\n  date: string,\n  articleType: ArticleCategory,\n  identifiedRisks: readonly PoliticalRiskScore[],\n  riskDrivers: readonly PoliticalRiskDriver[],\n  mitigations: readonly string[]\n): AgentRiskAssessmentWorkflow {\n  // Step 1: Identify — clone to prevent external mutation of the audit trace\n  const identifyStep: RiskAssessmentStep = { type: 'identify', risks: [...identifiedRisks] };\n\n  // Step 2: Analyze — clone to prevent external mutation\n  const analyzeStep: RiskAssessmentStep = { type: 'analyze', drivers: [...riskDrivers] };\n\n  // Step 3: Evaluate — sort risks by score to build evaluation matrix\n  const evaluateMatrix = [...identifiedRisks].sort((a, b) => b.riskScore - a.riskScore);\n  const evaluateStep: RiskAssessmentStep = { type: 'evaluate', matrix: evaluateMatrix };\n\n  // Step 4: Treat — clone to prevent external mutation\n  const treatStep: RiskAssessmentStep = { type: 'treat', mitigations: [...mitigations] };\n\n  const steps: RiskAssessmentStep[] = [identifyStep, analyzeStep, evaluateStep, treatStep];\n\n  // Synthesise an overall risk profile from all identified risks\n  const overallRiskProfile = synthesiseOverallRisk(identifiedRisks, assessmentId, date);\n\n  return {\n    assessmentId,\n    date,\n    articleType,\n    steps,\n    overallRiskProfile,\n    recommendations: [...mitigations],\n  };\n}\n\n/**\n * Generate a structured markdown document from an agent risk assessment workflow.\n * Produces a YAML-frontmatter header and all risk sections in markdown format\n * suitable for writing to `analysis/daily/{date}/risk-scoring/agent-risk-workflow.md`.\n *\n * @param assessment - Completed agent risk assessment workflow\n * @returns Markdown string with YAML frontmatter and full risk analysis\n */\nexport function generateRiskAssessmentMarkdown(assessment: AgentRiskAssessmentWorkflow): string {\n  const { assessmentId, date, articleType, overallRiskProfile, steps, recommendations } =\n    assessment;\n\n  const riskCounts = countRisks(steps);\n\n  const frontmatter = [\n    '---',\n    `title: \"Political Risk Assessment\"`,\n    `date: \"${sanitizeYamlValue(date)}\"`,\n    `assessmentId: \"${sanitizeYamlValue(assessmentId)}\"`,\n    `articleType: \"${sanitizeYamlValue(articleType)}\"`,\n    `analysisType: \"risk-scoring\"`,\n    `overallRiskLevel: \"${sanitizeYamlValue(overallRiskProfile.riskLevel)}\"`,\n    `confidence: \"${sanitizeYamlValue(overallRiskProfile.confidence)}\"`,\n    `methods: [\"risk-matrix\"]`,\n    `riskCount: { low: ${riskCounts.low}, medium: ${riskCounts.medium}, high: ${riskCounts.high}, critical: ${riskCounts.critical} }`,\n    '---',\n  ].join('\\n');\n\n  const safeAssessmentId = sanitizeMarkdownContent(assessmentId);\n  const safeDate = sanitizeMarkdownContent(date);\n  const safeArticleType = sanitizeMarkdownContent(articleType);\n  const safeOverallRiskLevel = sanitizeMarkdownContent(overallRiskProfile.riskLevel).toUpperCase();\n  const safeConfidence = sanitizeMarkdownContent(overallRiskProfile.confidence);\n\n  const safeOverallRiskScore = sanitizeMarkdownContent(String(overallRiskProfile.riskScore));\n\n  const header = `\\n# Political Risk Assessment\\n\\n**Assessment ID**: ${safeAssessmentId}  \\n**Date**: ${safeDate}  \\n**Article Type**: ${safeArticleType}  \\n**Overall Risk Level**: ${safeOverallRiskLevel} (score: ${safeOverallRiskScore})  \\n**Confidence**: ${safeConfidence}\\n`;\n\n  const heatMap = buildRiskHeatMapMarkdown();\n\n  const identifyStep = steps.find((s) => s.type === 'identify');\n  const risksSection =\n    identifyStep?.type === 'identify' ? buildRisksMarkdown(identifyStep.risks) : '';\n\n  const evaluateStep = steps.find((s) => s.type === 'evaluate');\n  const evaluateSection =\n    evaluateStep?.type === 'evaluate' ? buildEvaluateMarkdown(evaluateStep.matrix) : '';\n\n  const treatStep = steps.find((s) => s.type === 'treat');\n  const treatSection = treatStep?.type === 'treat' ? buildTreatMarkdown(treatStep.mitigations) : '';\n\n  const recommendationsSection =\n    recommendations.length > 0\n      ? `\\n## Recommendations\\n\\n${recommendations.map((r) => `- ${sanitizeMarkdownContent(r)}`).join('\\n')}\\n`\n      : '';\n\n  return [\n    frontmatter,\n    header,\n    heatMap,\n    risksSection,\n    evaluateSection,\n    treatSection,\n    recommendationsSection,\n  ]\n    .filter(Boolean)\n    .join('\\n');\n}\n\n/**\n * Generate a complete political risk summary combining all assessment outputs.\n *\n * @param date - ISO date string for the summary\n * @param topRisks - Top identified political risks (sorted by score)\n * @param capitalAtRisk - Political capital at risk for key actors\n * @param quantitativeSwot - Quantitative SWOT analysis\n * @param legislativeVelocityRisks - Legislative velocity risk indicators\n * @returns PoliticalRiskSummary with aggregated metrics\n */\nexport function generatePoliticalRiskSummary(\n  date: string,\n  topRisks: readonly PoliticalRiskScore[],\n  capitalAtRisk: readonly PoliticalCapitalAtRisk[],\n  quantitativeSwot: QuantitativeSWOT,\n  legislativeVelocityRisks: readonly LegislativeVelocityRisk[]\n): PoliticalRiskSummary {\n  const riskCount = countRisksFromArray(topRisks);\n  const overallRiskLevel = deriveOverallRiskLevel(topRisks);\n  const confidence = deriveOverallConfidence(topRisks);\n\n  return {\n    date,\n    overallRiskLevel,\n    confidence,\n    riskCount,\n    topRisks,\n    capitalAtRisk,\n    quantitativeSwot,\n    legislativeVelocityRisks,\n  };\n}\n\n// ─── Private helper functions ─────────────────────────────────────────────────\n\n/**\n * Check whether a string is a valid LegislativeStage.\n *\n * @param s - String to check\n * @returns True if valid stage\n */\nfunction isLegislativeStage(s: string): s is LegislativeStage {\n  if (s === '__proto__' || s === 'constructor' || s === 'prototype') {\n    return false;\n  }\n  return Object.hasOwn(EXPECTED_STAGE_DAYS, s);\n}\n\n/**\n * Map a velocity ratio to a risk likelihood level.\n *\n * @param ratio - daysInStage / expectedDays\n * @returns Likelihood level\n */\nfunction velocityRatioToLikelihood(ratio: number): PoliticalRiskLikelihood {\n  if (ratio < 0.5) return 'rare';\n  if (ratio < 1.0) return 'unlikely';\n  if (ratio < 1.5) return 'possible';\n  if (ratio < 2.0) return 'likely';\n  return 'almost_certain';\n}\n\n/**\n * Map a legislative stage to a default risk impact level.\n *\n * @param stage - Legislative stage\n * @returns Impact level\n */\nfunction stageToImpact(stage: LegislativeStage): PoliticalRiskImpact {\n  switch (stage) {\n    case 'adopted':\n      return 'negligible';\n    case 'proposal':\n      return 'minor';\n    case 'committee':\n    case 'plenary_first':\n      return 'moderate';\n    case 'trilogue':\n    case 'plenary_second':\n      return 'major';\n    case 'stalled':\n      return 'severe';\n    default:\n      return 'moderate';\n  }\n}\n\n/**\n * Synthesise an overall risk profile from a set of individual risk scores.\n * Uses the highest risk score as the representative profile.\n *\n * @param risks - All identified risk scores\n * @param assessmentId - Assessment identifier\n * @param date - Assessment date\n * @returns Overall composite PoliticalRiskScore\n */\nfunction synthesiseOverallRisk(\n  risks: readonly PoliticalRiskScore[],\n  assessmentId: string,\n  date: string\n): PoliticalRiskScore {\n  if (risks.length === 0) {\n    return calculatePoliticalRiskScore(\n      'rare',\n      'negligible',\n      `OVERALL-${assessmentId}`,\n      `Overall risk profile for assessment ${assessmentId} on ${date}`,\n      [],\n      [],\n      'low'\n    );\n  }\n\n  // Safe: risks.length > 0 is guaranteed by the guard above\n  const firstRisk = risks[0];\n  if (!firstRisk) {\n    throw new Error(\n      `Invariant violation: risks[0] was undefined for non-empty risks array in assessment ${assessmentId} on ${date}`\n    );\n  }\n  const maxRisk = risks.reduce((max, r) => (r.riskScore > max.riskScore ? r : max), firstRisk);\n\n  // Count confidence levels to pick the dominant one\n  const confidenceCounts: Record<ConfidenceLevel, number> = { high: 0, medium: 0, low: 0 };\n  for (const r of risks) {\n    confidenceCounts[r.confidence]++;\n  }\n  const dominantConfidence: ConfidenceLevel =\n    confidenceCounts.high >= confidenceCounts.medium &&\n    confidenceCounts.high >= confidenceCounts.low\n      ? 'high'\n      : confidenceCounts.medium >= confidenceCounts.low\n        ? 'medium'\n        : 'low';\n\n  // Use maxRisk fields to maintain the invariant: riskScore = likelihoodValue × impactValue\n  return {\n    riskId: `OVERALL-${assessmentId}`,\n    description: `Overall risk profile: ${risks.length} risks identified; highest: ${maxRisk.description}`,\n    likelihood: maxRisk.likelihood,\n    likelihoodValue: maxRisk.likelihoodValue,\n    impact: maxRisk.impact,\n    impactValue: maxRisk.impactValue,\n    riskScore: maxRisk.riskScore,\n    riskLevel: maxRisk.riskLevel,\n    confidence: dominantConfidence,\n    evidence: risks.flatMap((r) => r.evidence).slice(0, 5),\n    mitigatingFactors: risks.flatMap((r) => r.mitigatingFactors).slice(0, 5),\n  };\n}\n\n/**\n * Count risks by level from workflow steps.\n *\n * @param steps - Risk assessment steps\n * @returns Counts per risk level\n */\nfunction countRisks(\n  steps: readonly { type: string; risks?: readonly PoliticalRiskScore[] }[]\n): RiskLevelCounts {\n  const identifyStep = steps.find((s) => s.type === 'identify');\n  const risks: readonly PoliticalRiskScore[] =\n    identifyStep && 'risks' in identifyStep ? (identifyStep.risks ?? []) : [];\n  return countRisksFromArray(risks);\n}\n\n/**\n * Count risks by level from an array of risk scores.\n *\n * @param risks - Array of political risk scores\n * @returns Counts per risk level\n */\nfunction countRisksFromArray(risks: readonly PoliticalRiskScore[]): RiskLevelCounts {\n  let low = 0;\n  let medium = 0;\n  let high = 0;\n  let critical = 0;\n  for (const r of risks) {\n    if (r.riskLevel === 'low') low++;\n    else if (r.riskLevel === 'medium') medium++;\n    else if (r.riskLevel === 'high') high++;\n    else if (r.riskLevel === 'critical') critical++;\n  }\n  return { low, medium, high, critical };\n}\n\n/**\n * Derive overall risk level from a set of risk scores.\n * Takes the highest level present.\n *\n * @param risks - Array of political risk scores\n * @returns Highest risk level present, or 'low' if empty\n */\nfunction deriveOverallRiskLevel(risks: readonly PoliticalRiskScore[]): PoliticalRiskLevel {\n  if (risks.length === 0) return 'low';\n  const ORDER: PoliticalRiskLevel[] = ['low', 'medium', 'high', 'critical'];\n  return risks.reduce<PoliticalRiskLevel>((max, r) => {\n    return ORDER.indexOf(r.riskLevel) > ORDER.indexOf(max) ? r.riskLevel : max;\n  }, 'low');\n}\n\n/**\n * Derive an overall confidence level from a set of risk scores.\n *\n * @param risks - Array of political risk scores\n * @returns Dominant confidence level\n */\nfunction deriveOverallConfidence(risks: readonly PoliticalRiskScore[]): ConfidenceLevel {\n  if (risks.length === 0) return 'low';\n  const counts: Record<ConfidenceLevel, number> = { high: 0, medium: 0, low: 0 };\n  for (const r of risks) {\n    counts[r.confidence]++;\n  }\n  if (counts.high >= counts.medium && counts.high >= counts.low) return 'high';\n  if (counts.medium >= counts.low) return 'medium';\n  return 'low';\n}\n\n/**\n * Build a markdown risk heat map table.\n *\n * @returns Markdown string with the risk heat map\n */\nfunction buildRiskHeatMapMarkdown(): string {\n  const impacts: PoliticalRiskImpact[] = ['severe', 'major', 'moderate', 'minor', 'negligible'];\n  const likelihoods: PoliticalRiskLikelihood[] = [\n    'rare',\n    'unlikely',\n    'possible',\n    'likely',\n    'almost_certain',\n  ];\n\n  const header = `## Risk Heat Map\\n\\n| Impact ↓ / Likelihood → | Rare | Unlikely | Possible | Likely | Almost Certain |\\n|--------------------------|------|----------|----------|--------|----------------|`;\n\n  const rows = impacts.map((impact) => {\n    const cells = likelihoods.map((likelihood) => {\n      // eslint-disable-next-line security/detect-object-injection -- keys are typed PoliticalRiskLikelihood/Impact from const arrays\n      const score = LIKELIHOOD_VALUES[likelihood] * IMPACT_VALUES[impact];\n      const level = deriveRiskLevel(round2(score));\n      // eslint-disable-next-line security/detect-object-injection -- key is a typed PoliticalRiskLevel\n      return HEAT_MAP_CELLS[level];\n    });\n    const impactLabel = `**${impact.charAt(0).toUpperCase() + impact.slice(1)}**`;\n    return `| ${impactLabel} | ${cells.join(' | ')} |`;\n  });\n\n  return `${header}\\n${rows.join('\\n')}\\n`;\n}\n\n/**\n * Build markdown for identified risks.\n *\n * @param risks - Identified risk scores\n * @returns Markdown string\n */\nfunction buildRisksMarkdown(risks: readonly PoliticalRiskScore[]): string {\n  if (risks.length === 0) return '';\n  const lines = risks.map((r) => {\n    const safeRiskId = sanitizeMarkdownContent(r.riskId);\n    const headingId = safeRiskId.length > 0 ? safeRiskId : 'RISK-UNKNOWN';\n    const safeDescription = sanitizeMarkdownContent(r.description);\n    const headingDescription = safeDescription.length > 0 ? safeDescription : headingId;\n    const safeEvidence = r.evidence.map((e) => sanitizeMarkdownContent(e)).filter(Boolean);\n    const evidence = safeEvidence.length > 0 ? `\\n- **Evidence**: ${safeEvidence.join('; ')}` : '';\n    const safeMitigations = r.mitigatingFactors\n      .map((m) => sanitizeMarkdownContent(m))\n      .filter(Boolean);\n    const mitigations =\n      safeMitigations.length > 0 ? `\\n- **Mitigating Factors**: ${safeMitigations.join('; ')}` : '';\n    const safeLikelihood = sanitizeMarkdownContent(String(r.likelihood));\n    const safeLikelihoodValue = sanitizeMarkdownContent(String(r.likelihoodValue));\n    const safeImpact = sanitizeMarkdownContent(String(r.impact));\n    const safeImpactValue = sanitizeMarkdownContent(String(r.impactValue));\n    const safeRiskScore = sanitizeMarkdownContent(String(r.riskScore));\n    const safeRiskLevel = sanitizeMarkdownContent(String(r.riskLevel)).toUpperCase();\n    const safeConfidence = sanitizeMarkdownContent(String(r.confidence));\n\n    return [\n      `### ${headingId}: ${headingDescription}`,\n      `- **Likelihood**: ${safeLikelihood} (${safeLikelihoodValue}) | **Impact**: ${safeImpact} (${safeImpactValue}) | **Score**: ${safeRiskScore} (${safeRiskLevel}) | **Confidence**: ${safeConfidence}${evidence}${mitigations}`,\n    ].join('\\n');\n  });\n\n  return `\\n## Identified Risks\\n\\n${lines.join('\\n\\n')}\\n`;\n}\n\n/**\n * Sanitize a value for safe inclusion in a Markdown table cell.\n * Escapes backslash and pipe characters and replaces newlines with spaces.\n *\n * @param value - Raw cell value\n * @returns Sanitized string safe for Markdown tables\n */\nfunction sanitizeMarkdownTableCell(value: string | undefined | null): string {\n  const normalized = (value ?? '').trim();\n  if (normalized === '') {\n    return 'N/A';\n  }\n  const withoutNewlines = normalized.replace(/[\\r\\n]+/g, ' ');\n  const escapedBackslashes = withoutNewlines.replace(/\\\\/g, '\\\\\\\\');\n  return escapedBackslashes.replace(/\\|/g, '\\\\|');\n}\n\n/**\n * Sanitize a value for safe inclusion in Markdown headings and bullet content.\n * Strips newlines to prevent document structure injection.\n *\n * @param value - Raw value\n * @returns Sanitized string safe for Markdown headings/bullets\n */\nfunction sanitizeMarkdownContent(value: unknown): string {\n  const normalized = String(value ?? '').trim();\n  if (normalized === '') {\n    return '';\n  }\n  return normalized.replace(/[\\r\\n]+/g, ' ');\n}\n\n/**\n * Sanitize a YAML scalar value for safe inclusion in YAML frontmatter.\n * Escapes double quotes and strips newlines to prevent YAML injection.\n *\n * @param value - Raw value\n * @returns Sanitized string safe for YAML double-quoted scalars\n */\nfunction sanitizeYamlValue(value: unknown): string {\n  const normalized = String(value ?? '').trim();\n  if (normalized === '') {\n    return '';\n  }\n  const withoutNewlines = normalized.replace(/[\\r\\n]+/g, ' ');\n  const escapedBackslashes = withoutNewlines.replace(/\\\\/g, '\\\\\\\\');\n  return escapedBackslashes.replace(/\"/g, '\\\\\"');\n}\n\n/**\n * Build markdown for the evaluation matrix (risks ranked by score).\n *\n * @param matrix - Risks sorted by score\n * @returns Markdown string\n */\nfunction buildEvaluateMarkdown(matrix: readonly PoliticalRiskScore[]): string {\n  if (matrix.length === 0) return '';\n  const header = `\\n## Risk Evaluation Matrix\\n\\n| Rank | Risk ID | Description | Score | Level | Confidence |\\n|------|---------|-------------|-------|-------|------------|`;\n  const rows = matrix.map((r, i) => {\n    const riskId = sanitizeMarkdownTableCell(r.riskId);\n    const rawDesc = r.description ?? '';\n    const truncatedDesc = rawDesc.length > 60 ? `${rawDesc.substring(0, 60)}…` : rawDesc;\n    const descCell = sanitizeMarkdownTableCell(truncatedDesc);\n    const levelCell = sanitizeMarkdownTableCell(String(r.riskLevel).toUpperCase());\n    const confidenceCell = sanitizeMarkdownTableCell(String(r.confidence));\n    const riskScoreCell = sanitizeMarkdownTableCell(String(r.riskScore));\n    return `| ${i + 1} | ${riskId} | ${descCell} | ${riskScoreCell} | ${levelCell} | ${confidenceCell} |`;\n  });\n  return `${header}\\n${rows.join('\\n')}\\n`;\n}\n\n/**\n * Build markdown for the risk treatment / mitigation section.\n *\n * @param mitigations - List of mitigation actions\n * @returns Markdown string\n */\nfunction buildTreatMarkdown(mitigations: readonly string[]): string {\n  if (!Array.isArray(mitigations) || mitigations.length === 0) return '';\n  const sanitizedItems = mitigations\n    .map((m) => sanitizeMarkdownContent(String(m ?? '')))\n    .filter((m) => m.length > 0);\n  if (sanitizedItems.length === 0) return '';\n  const items = sanitizedItems.map((m) => `- ${m}`).join('\\n');\n  return `\\n## Risk Treatment Plan\\n\\n${items}\\n`;\n}\n\n// ─── Factory helpers for creating scored SWOT items ──────────────────────────\n\n/**\n * Create a scored SWOT item for a strength or weakness (score 0–5).\n * A score of 0 represents a neutral or not-currently-relevant factor.\n *\n * @param description - Description of the factor\n * @param score - Magnitude score (0–5; clamped)\n * @param evidence - Supporting evidence\n * @param confidence - Confidence level\n * @param trend - Trend direction\n * @returns ScoredSWOTItem\n */\nexport function createScoredSWOTItem(\n  description: string,\n  score: number,\n  evidence: readonly string[] = [],\n  confidence: ConfidenceLevel = 'medium',\n  trend: SwotItemTrend = 'stable'\n): ScoredSWOTItem {\n  return {\n    description,\n    score: round2(clamp(score, 0, 5)),\n    evidence: [...evidence],\n    confidence,\n    trend,\n  };\n}\n\n/**\n * Create a scored SWOT item for an opportunity or threat\n * (score = probability × impact, range 0–4.5).\n *\n * @param description - Description of the factor\n * @param likelihood - Likelihood of occurrence\n * @param impact - Impact if it occurs\n * @param evidence - Supporting evidence\n * @param confidence - Confidence level\n * @param trend - Trend direction\n * @returns ScoredSWOTItem\n */\nexport function createScoredOpportunityOrThreat(\n  description: string,\n  likelihood: PoliticalRiskLikelihood,\n  impact: PoliticalRiskImpact,\n  evidence: readonly string[] = [],\n  confidence: ConfidenceLevel = 'medium',\n  trend: SwotItemTrend = 'stable'\n): ScoredSWOTItem {\n  if (!Object.hasOwn(LIKELIHOOD_VALUES, likelihood)) {\n    throw new Error(\n      `Invalid likelihood: \"${String(likelihood)}\". Expected one of: ${Object.keys(LIKELIHOOD_VALUES).join(', ')}`\n    );\n  }\n  if (!Object.hasOwn(IMPACT_VALUES, impact)) {\n    throw new Error(\n      `Invalid impact: \"${String(impact)}\". Expected one of: ${Object.keys(IMPACT_VALUES).join(', ')}`\n    );\n  }\n  // eslint-disable-next-line security/detect-object-injection -- keys validated via Object.hasOwn above\n  const score = round2(LIKELIHOOD_VALUES[likelihood] * IMPACT_VALUES[impact]);\n  return {\n    description,\n    score,\n    evidence: [...evidence],\n    confidence,\n    trend,\n  };\n}\n\n/**\n * Create a political risk driver.\n *\n * @param description - Description of the driver\n * @param category - Threat category\n * @param contribution - Percentage contribution to total risk (0–100)\n * @param trend - Whether risk is increasing, stable, or decreasing\n * @returns PoliticalRiskDriver\n */\nexport function createRiskDriver(\n  description: string,\n  category: PoliticalThreatCategory,\n  contribution: number,\n  trend: PoliticalRiskDriver['trend'] = 'stable'\n): PoliticalRiskDriver {\n  return {\n    description,\n    category,\n    contribution: round2(clamp(contribution, 0, 100)),\n    trend,\n  };\n}\n\n// ─── Risk interconnection scoring ────────────────────────────────────────────\n\nimport type {\n  RiskCascadePair,\n  RiskInterconnection,\n  RiskVelocity,\n  HistoricalRiskComparison,\n} from '../types/political-risk.js';\n\n/**\n * Compute how interconnected a set of political risks are.\n *\n * Cascade potential between two risks is estimated by comparing their\n * threat categories and numeric risk scores. Risks of the same category\n * with high scores are considered more likely to cascade.\n *\n * Cascade score formula:\n * - Same category: baseScore = 0.6 + (avgScore / 10) * 0.4\n * - Different category: baseScore = 0.2 + (avgScore / 10) * 0.2\n *\n * The overall interconnection score is the mean of all pair cascade scores.\n *\n * @param risks - Array of objects with riskId, category, and numericScore (0–10)\n * @returns RiskInterconnection with cascade pairs and overall assessment\n */\nexport function computeRiskInterconnection(\n  risks: readonly { riskId: string; category: PoliticalThreatCategory; numericScore: number }[]\n): RiskInterconnection {\n  if (risks.length < 2) {\n    return {\n      riskCount: risks.length,\n      cascadingPairs: [],\n      interconnectionScore: 0,\n      assessment: 'isolated',\n    };\n  }\n\n  const cascadingPairs: RiskCascadePair[] = [];\n\n  for (let i = 0; i < risks.length; i++) {\n    for (let j = i + 1; j < risks.length; j++) {\n      const riskA = risks[i] as {\n        riskId: string;\n        category: PoliticalThreatCategory;\n        numericScore: number;\n      };\n      const riskB = risks[j] as {\n        riskId: string;\n        category: PoliticalThreatCategory;\n        numericScore: number;\n      };\n      const clampedA = Math.max(0, Math.min(10, riskA.numericScore));\n      const clampedB = Math.max(0, Math.min(10, riskB.numericScore));\n      const avgScore = (clampedA + clampedB) / 2;\n      const sameCategory = riskA.category === riskB.category;\n\n      const cascadeScore = sameCategory\n        ? Math.min(1, 0.6 + (avgScore / 10) * 0.4)\n        : Math.min(1, 0.2 + (avgScore / 10) * 0.2);\n\n      const roundedScore = Math.round(cascadeScore * 100) / 100;\n      cascadingPairs.push({\n        riskAId: riskA.riskId,\n        riskBId: riskB.riskId,\n        cascadeScore: roundedScore,\n      });\n    }\n  }\n\n  const interconnectionScore =\n    cascadingPairs.length > 0\n      ? Math.round(\n          (cascadingPairs.reduce((sum, p) => sum + p.cascadeScore, 0) / cascadingPairs.length) * 100\n        ) / 100\n      : 0;\n\n  let assessment: RiskInterconnection['assessment'];\n  if (interconnectionScore >= 0.75) assessment = 'systemic';\n  else if (interconnectionScore >= 0.5) assessment = 'high-interconnection';\n  else if (interconnectionScore >= 0.25) assessment = 'moderate-interconnection';\n  else assessment = 'isolated';\n\n  return { riskCount: risks.length, cascadingPairs, interconnectionScore, assessment };\n}\n\n/**\n * Compute the velocity (rate and direction of change) of a political risk score.\n *\n * Velocity thresholds (scoreDelta):\n * - > 1.5  → rapidly-escalating\n * - > 0.5  → escalating\n * - ≥ -0.5 → stable\n * - ≥ -1.5 → de-escalating\n * - < -1.5 → rapidly-de-escalating\n *\n * @param riskId - Identifier of the risk being assessed\n * @param previousScore - Score from the previous assessment run\n * @param currentScore - Score from the current assessment run\n * @returns RiskVelocity with signed delta and assessment labels\n */\nexport function computeRiskVelocity(\n  riskId: string,\n  previousScore: number,\n  currentScore: number\n): RiskVelocity {\n  const scoreDelta = Math.round((currentScore - previousScore) * 100) / 100;\n\n  let levelChange: RiskVelocity['levelChange'];\n  if (scoreDelta > 0.5) levelChange = 'escalating';\n  else if (scoreDelta < -0.5) levelChange = 'de-escalating';\n  else levelChange = 'stable';\n\n  let assessment: RiskVelocity['assessment'];\n  if (scoreDelta > 1.5) assessment = 'rapidly-escalating';\n  else if (scoreDelta > 0.5) assessment = 'escalating';\n  else if (scoreDelta >= -0.5) assessment = 'stable';\n  else if (scoreDelta >= -1.5) assessment = 'de-escalating';\n  else assessment = 'rapidly-de-escalating';\n\n  return { riskId, scoreDelta, levelChange, assessment };\n}\n\n/**\n * Compare the current risk score against historical baselines (7-day and 30-day averages).\n *\n * Scores are considered 'above' when current > average by more than 0.1,\n * 'below' when current < average by more than 0.1, and 'at' otherwise.\n *\n * @param riskId - Identifier of the risk being assessed\n * @param currentScore - Current risk score\n * @param sevenDayScores - Historical scores from the past 7 days\n * @param thirtyDayScores - Historical scores from the past 30 days\n * @returns HistoricalRiskComparison with averages and relative position\n */\nexport function compareRiskHistorical(\n  riskId: string,\n  currentScore: number,\n  sevenDayScores: readonly number[],\n  thirtyDayScores: readonly number[]\n): HistoricalRiskComparison {\n  const sevenDayAverage =\n    sevenDayScores.length > 0\n      ? Math.round((sevenDayScores.reduce((s, v) => s + v, 0) / sevenDayScores.length) * 100) / 100\n      : currentScore;\n\n  const thirtyDayAverage =\n    thirtyDayScores.length > 0\n      ? Math.round((thirtyDayScores.reduce((s, v) => s + v, 0) / thirtyDayScores.length) * 100) /\n        100\n      : currentScore;\n\n  const THRESHOLD = 0.1;\n\n  const vsSevenDayAverage: HistoricalRiskComparison['vsSevenDayAverage'] =\n    currentScore > sevenDayAverage + THRESHOLD\n      ? 'above'\n      : currentScore < sevenDayAverage - THRESHOLD\n        ? 'below'\n        : 'at';\n\n  const vsThirtyDayAverage: HistoricalRiskComparison['vsThirtyDayAverage'] =\n    currentScore > thirtyDayAverage + THRESHOLD\n      ? 'above'\n      : currentScore < thirtyDayAverage - THRESHOLD\n        ? 'below'\n        : 'at';\n\n  return {\n    riskId,\n    currentScore,\n    sevenDayAverage,\n    thirtyDayAverage,\n    vsSevenDayAverage,\n    vsThirtyDayAverage,\n  };\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/political-threat-assessment.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":183,"column":10,"endLine":183,"endColumn":30},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":183,"column":33,"endLine":183,"endColumn":53},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":183,"column":56,"endLine":183,"endColumn":77},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":211,"column":47,"endLine":211,"endColumn":64},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1129,"column":68,"endLine":1129,"endColumn":97},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1643,"column":20,"endLine":1643,"endColumn":33},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1644,"column":20,"endLine":1644,"endColumn":33},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1648,"column":9,"endLine":1648,"endColumn":45},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1648,"column":9,"endLine":1648,"endColumn":37},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1648,"column":49,"endLine":1648,"endColumn":85},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":1648,"column":49,"endLine":1648,"endColumn":77},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":1729,"column":27,"endLine":1729,"endColumn":55}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":12,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n// NOTE: This file is compiled to scripts/utils/political-threat-assessment.js. DO NOT EDIT the compiled output directly.\n\n/**\n * @module Utils/PoliticalThreatAssessment\n * @description Pure political threat assessment functions using the Political\n * Threat Landscape framework (6 dimensions: Coalition Shifts, Transparency\n * Deficit, Policy Reversal, Institutional Pressure, Legislative Obstruction,\n * Democratic Erosion), supplemented by attack trees and kill chain analysis.\n * All functions are stateless and safely handle malformed or missing input data.\n * No side effects.\n *\n * @see {@link https://github.com/Hack23/ISMS-PUBLIC/blob/main/Threat_Modeling.md} ISMS Threat Modeling Policy\n */\n\nimport type {\n  ThreatAssessmentInput,\n  ConsequenceNode,\n  DisruptionPoint,\n  ImpactLevel,\n  LegislativeDisruptionAnalysis,\n  LegislativeStage,\n  PoliticalActorThreatProfile,\n  PoliticalActorType,\n  PoliticalConsequenceTree,\n  PoliticalThreatDimension,\n  PoliticalThreatAssessment,\n  PoliticalThreatCategory,\n} from '../types/political-threats.js';\n\n// ─── Constants ─────────────────────────────────────────────────────────────────\n\n/** All threat landscape dimensions in canonical order */\nconst ALL_THREAT_DIMENSIONS: readonly PoliticalThreatCategory[] = [\n  'shift',\n  'transparency',\n  'reversal',\n  'institutional',\n  'delay',\n  'erosion',\n];\n\n/** All legislative stages in procedural order */\nconst ALL_LEGISLATIVE_STAGES: readonly LegislativeStage[] = [\n  'proposal',\n  'committee',\n  'plenary_first_reading',\n  'council_position',\n  'plenary_second_reading',\n  'conciliation',\n  'adoption',\n];\n\n/** Numeric weights for impact levels (used in composite scoring) */\nconst IMPACT_WEIGHTS: Readonly<Record<ImpactLevel, number>> = {\n  critical: 4,\n  high: 3,\n  moderate: 2,\n  low: 1,\n  none: 0,\n};\n\n/** Display labels for threat landscape dimensions */\nconst DIMENSION_LABELS: Readonly<Record<PoliticalThreatCategory, string>> = {\n  shift: 'Coalition Shifts',\n  transparency: 'Transparency Deficit',\n  reversal: 'Policy Reversal',\n  institutional: 'Institutional Pressure',\n  delay: 'Legislative Obstruction',\n  erosion: 'Democratic Erosion',\n};\n\n/** Emoji threat level indicators for markdown output */\nconst THREAT_EMOJIS: Readonly<Record<ImpactLevel, string>> = {\n  critical: '🔴',\n  high: '🟠',\n  moderate: '⚠️',\n  low: '🟢',\n  none: '⚪',\n};\n\n// ─── Private helpers ───────────────────────────────────────────────────────────\n\n/**\n * Safely extract a string from an unknown value.\n *\n * @param val - Unknown value to coerce\n * @returns String value or empty string\n */\nfunction asStr(val: unknown): string {\n  return typeof val === 'string' ? val : '';\n}\n\n/**\n * Safely extract a finite number from an unknown value.\n *\n * @param val - Unknown value to coerce\n * @param fallback - Default value when input is not a finite number\n * @returns Finite number or fallback\n */\nfunction asNum(val: unknown, fallback = 0): number {\n  return typeof val === 'number' && Number.isFinite(val) ? val : fallback;\n}\n\n/**\n * Safely extract an array of strings from an unknown value.\n *\n * @param val - Unknown value to coerce\n * @returns Array of strings, empty if input is not an array\n */\nfunction asStrArr(val: unknown): string[] {\n  if (!Array.isArray(val)) return [];\n  return (val as unknown[]).filter((v): v is string => typeof v === 'string');\n}\n\n/**\n * Coerce an unknown value to a non-null Record or return null.\n *\n * @param input - Value to cast\n * @returns Record or null\n */\nfunction toRecord(input: unknown): Record<string, unknown> | null {\n  if (input === null || input === undefined || typeof input !== 'object' || Array.isArray(input))\n    return null;\n  return input as Record<string, unknown>;\n}\n\n/**\n * Coerce a value to an array, returning `[]` if it is not an actual array.\n *\n * Guards against malformed input where a field expected to be an array is\n * instead a string, object, number, or other non-array type.\n *\n * @param val - Value that should be an array\n * @returns The value itself if it is an array, otherwise `[]`\n */\nfunction safeArray(val: unknown): readonly unknown[] {\n  return Array.isArray(val) ? val : [];\n}\n\n/**\n * Resolve the voting anomaly array from a `ThreatAssessmentInput`.\n *\n * Reads the `anomalies` field and validates it as an array.\n * Non-array values are treated as empty.\n *\n * @param data - Article / threat-assessment data (may be null)\n * @returns Readonly array of anomaly items, never null\n */\nfunction resolveAnomalies(data: ThreatAssessmentInput | null | undefined): readonly unknown[] {\n  return safeArray(data?.anomalies);\n}\n\n/**\n * Create a type-safe readonly array of {@link PoliticalActorType} values.\n *\n * Replaces `[...] as PoliticalActorType[]` casts with compile-time validation\n * so that invalid stakeholder strings are caught during development.\n *\n * @param actors - One or more actor type literals\n * @returns Readonly array of validated PoliticalActorType values\n */\nfunction stakeholders(...actors: readonly PoliticalActorType[]): readonly PoliticalActorType[] {\n  return actors;\n}\n\n/**\n * Compute the summed composite of CMO (capability + motivation + opportunity).\n * Each dimension is scored: high=3, medium=2, low=1. Max composite = 9, min = 3.\n *\n * @param capability - Capability level\n * @param motivation - Motivation level\n * @param opportunity - Opportunity level\n * @returns Composite score (3–9)\n */\nfunction cmoScore(\n  capability: 'high' | 'medium' | 'low',\n  motivation: 'high' | 'medium' | 'low',\n  opportunity: 'high' | 'medium' | 'low'\n): number {\n  const levelMap: Record<'high' | 'medium' | 'low', number> = { high: 3, medium: 2, low: 1 };\n  return levelMap[capability] + levelMap[motivation] + levelMap[opportunity];\n}\n\n/**\n * Derive overall threat level from a CMO composite score.\n *\n * @param score - CMO composite score (3–9)\n * @returns Impact level classification\n */\nfunction cmoToThreatLevel(score: number): ImpactLevel {\n  if (score >= 8) return 'critical';\n  if (score >= 6) return 'high';\n  if (score >= 4) return 'moderate';\n  return 'low';\n}\n\n/**\n * Derive overall threat level from weighted average of impact levels.\n *\n * Computes the arithmetic mean of numeric impact weights and maps the result\n * back to an ImpactLevel using threshold bands: ≥3.5→critical, ≥2.5→high,\n * ≥1.5→medium, else→low.\n *\n * @param levels - Array of impact levels to aggregate\n * @returns Weighted-average impact level\n */\nfunction aggregateImpactLevels(levels: readonly ImpactLevel[]): ImpactLevel {\n  if (levels.length === 0) return 'low';\n  const avg = levels.reduce((sum, l) => sum + IMPACT_WEIGHTS[l], 0) / levels.length;\n  if (avg >= 3.5) return 'critical';\n  if (avg >= 2.5) return 'high';\n  if (avg >= 1.5) return 'moderate';\n  return 'low';\n}\n\n/**\n * Clamp a probability value to the valid range [0, 1].\n *\n * @param p - Probability to clamp\n * @returns Clamped value in [0, 1]\n */\nfunction clampProbability(p: number): number {\n  return Math.max(0, Math.min(1, p));\n}\n\n// ─── Threat landscape helper extractors ──────────────────────────────────────────────\n\n/**\n * Convert a raw numeric threat score (1–3) to an ImpactLevel.\n *\n * Threat landscape scanner helpers operate on a 1–3 scale where 1=low, 2=medium,\n * 3=high. Any value ≥3 is treated as `high` in this layer; the `critical`\n * impact level is reserved for higher-level analyses (e.g., actor profiles\n * or aggregated assessments) that assign `critical` directly.\n *\n * @param threatScore - Numeric score: 1=low, 2=moderate, ≥3=high\n * @returns Corresponding impact level\n */\nfunction scoreToImpact(threatScore: number): ImpactLevel {\n  if (threatScore >= 3) return 'high';\n  if (threatScore === 2) return 'moderate';\n  return 'low';\n}\n\n/**\n * Build threat dimension result object with default evidence fallback.\n *\n * @param category - threat dimension\n * @param threatScore - Raw numeric threat score\n * @param evidence - Collected evidence items\n * @param defaultEvidence - Fallback evidence when array is empty\n * @param lowAnalysis - Analysis text when threat is low\n * @param elevatedAnalysis - Analysis text when threat is elevated\n * @returns PoliticalThreatDimension result\n */\nfunction buildDimensionResult(\n  category: PoliticalThreatCategory,\n  threatScore: number,\n  evidence: string[],\n  defaultEvidence: string,\n  lowAnalysis: string,\n  elevatedAnalysis: (level: ImpactLevel) => string\n): PoliticalThreatDimension {\n  const threatLevel = scoreToImpact(threatScore);\n  return {\n    category,\n    threatLevel,\n    evidence: evidence.length > 0 ? evidence : [defaultEvidence],\n    analysis: threatLevel === 'low' ? lowAnalysis : elevatedAnalysis(threatLevel),\n  };\n}\n\n/**\n * Scan anomalies for shift signals and update evidence and score.\n *\n * @param anomalies - Voting anomaly data\n * @param evidence - Evidence array to mutate\n * @returns Updated threat score contribution\n */\nfunction scanAnomaliesForShift(anomalies: readonly unknown[], evidence: string[]): number {\n  if (anomalies.length === 0) return 1;\n  evidence.push(\n    `${anomalies.length} voting anomal${anomalies.length === 1 ? 'y' : 'ies'} detected`\n  );\n  return 2;\n}\n\n/**\n * Scan coalitions for shift signals and update evidence and score.\n *\n * @param coalitions - Coalition data\n * @param evidence - Evidence array to mutate\n * @returns Maximum threat score detected\n */\nfunction scanCoalitionsForShift(coalitions: readonly unknown[], evidence: string[]): number {\n  let maxScore = 1;\n  for (const coalition of coalitions) {\n    const rec = toRecord(coalition);\n    if (!rec) continue;\n    const cohesion = asNum(rec['cohesionScore'], 1);\n    const trend = asStr(rec['alignmentTrend']);\n    if (cohesion < 0.7) {\n      evidence.push(`Coalition cohesion below 70%: ${(cohesion * 100).toFixed(0)}%`);\n      maxScore = Math.max(maxScore, 3);\n    }\n    if (trend === 'weakening') {\n      evidence.push('Coalition alignment trend weakening');\n      maxScore = Math.max(maxScore, 2);\n    }\n  }\n  return maxScore;\n}\n\n/**\n * Scan committees for transparency signals and update evidence and score.\n *\n * @param committees - Committee data\n * @param evidence - Evidence array to mutate\n * @returns Maximum threat score detected\n */\nfunction scanCommitteesForTransparency(committees: readonly unknown[], evidence: string[]): number {\n  if (committees.length === 0) {\n    evidence.push('No committee activity data available — potential information gap');\n    return 2;\n  }\n  let maxScore = 1;\n  for (const committee of committees) {\n    const rec = toRecord(committee);\n    if (!rec) continue;\n    if (asNum(rec['meetingCount'], 0) === 0) {\n      evidence.push(\n        `Committee with no recorded meetings: ${asStr(rec['committeeId'] ?? rec['name'])}`\n      );\n      maxScore = Math.max(maxScore, 2);\n    }\n  }\n  return maxScore;\n}\n\n/**\n * Scan procedures for reversal signals and update evidence and score.\n *\n * @param procedures - Procedure data\n * @param evidence - Evidence array to mutate\n * @returns Maximum threat score detected\n */\nfunction scanProceduresForReversal(procedures: readonly unknown[], evidence: string[]): number {\n  let maxScore = 1;\n  for (const proc of procedures) {\n    const rec = toRecord(proc);\n    if (!rec) continue;\n    const stage = asStr(rec['currentStage'] ?? rec['stage']);\n    const status = asStr(rec['status']);\n    if (status === 'stalled' || status === 'blocked') {\n      evidence.push(`Procedure stalled: ${asStr(rec['procedureId'] ?? rec['id'])}`);\n      maxScore = Math.max(maxScore, 3);\n    }\n    if (stage === 'conciliation') {\n      evidence.push('Procedure in conciliation — risk of legislative compromise or reversal');\n      maxScore = Math.max(maxScore, 2);\n    }\n  }\n  return maxScore;\n}\n\n/**\n * Check feed data for adopted texts blockage signals.\n *\n * @param feedData - Feed data record (may be null)\n * @param procedureCount - Number of active procedures\n * @param evidence - Evidence array to mutate\n * @returns Threat score contribution\n */\nfunction checkFeedAdoptedTexts(\n  feedData: Record<string, unknown> | null,\n  procedureCount: number,\n  evidence: string[]\n): number {\n  if (!feedData) return 1;\n  const adoptedTexts = Array.isArray(feedData['adoptedTexts']) ? feedData['adoptedTexts'] : [];\n  if (adoptedTexts.length === 0 && procedureCount > 0) {\n    evidence.push('Legislative procedures active but no adopted texts — possible blockage');\n    return 2;\n  }\n  return 1;\n}\n\n/**\n * Compute MEP influence concentration score and update evidence.\n *\n * @param mepInfluence - MEP influence data\n * @param evidence - Evidence array to mutate\n * @returns Threat score contribution\n */\nfunction checkMEPInfluenceConcentration(\n  mepInfluence: readonly unknown[],\n  evidence: string[]\n): number {\n  const scores: number[] = [];\n  for (const mep of mepInfluence) {\n    const rec = toRecord(mep);\n    if (rec) scores.push(asNum(rec['overallScore'], 0));\n  }\n  if (scores.length === 0) return 1;\n  const max = scores.reduce(\n    (currentMax, value) => (value > currentMax ? value : currentMax),\n    scores[0] ?? 0\n  );\n  const avg = scores.reduce((s, v) => s + v, 0) / scores.length;\n  if (max > avg * 2.5) {\n    evidence.push(\n      `Highly concentrated MEP influence detected — top score ${max.toFixed(0)} vs avg ${avg.toFixed(0)}`\n    );\n    return 2;\n  }\n  return 1;\n}\n\n/**\n * Check feed data for MEP turnover risk.\n *\n * @param feedData - Feed data record (may be null)\n * @param evidence - Evidence array to mutate\n * @returns Threat score contribution\n */\nfunction checkMEPTurnover(feedData: Record<string, unknown> | null, evidence: string[]): number {\n  if (!feedData) return 1;\n  const incoming = Array.isArray(feedData['incomingMEPs']) ? feedData['incomingMEPs'] : [];\n  if (incoming.length > 20) {\n    evidence.push(\n      `High MEP turnover: ${incoming.length} incoming MEPs — institutional continuity risk`\n    );\n    return 2;\n  }\n  return 1;\n}\n\n/**\n * Scan procedures for delay/stall signals and update evidence.\n *\n * @param procedures - Procedure data\n * @param evidence - Evidence array to mutate\n * @returns Maximum threat score detected\n */\nfunction scanProceduresForDelay(procedures: readonly unknown[], evidence: string[]): number {\n  let maxScore = 1;\n  for (const proc of procedures) {\n    const rec = toRecord(proc);\n    if (!rec) continue;\n    if (asStr(rec['status']) === 'stalled') {\n      const stage = asStr(rec['currentStage'] ?? rec['stage']);\n      evidence.push(`Stalled procedure: ${asStr(rec['procedureId'] ?? rec['id'])} at ${stage}`);\n      maxScore = Math.max(maxScore, 3);\n    }\n  }\n  return maxScore;\n}\n\n/**\n * Scan anomalies for abstention-based delay signals.\n *\n * @param anomalies - Voting anomaly data\n * @param evidence - Evidence array to mutate\n * @returns Threat score contribution\n */\nfunction scanAbstentionAnomalies(anomalies: readonly unknown[], evidence: string[]): number {\n  const abstentionAnomalies = anomalies.filter((a) => {\n    const rec = toRecord(a);\n    return rec ? asStr(rec['description']).toLowerCase().includes('abstention') : false;\n  });\n  if (abstentionAnomalies.length > 0) {\n    evidence.push(\n      `${abstentionAnomalies.length} abstention anomal${abstentionAnomalies.length === 1 ? 'y' : 'ies'} — potential procedural obstruction`\n    );\n    return 2;\n  }\n  return 1;\n}\n\n/**\n * Count coalitions with critically low cohesion and update evidence.\n *\n * @param coalitions - Coalition data\n * @param evidence - Evidence array to mutate\n * @returns Threat score contribution\n */\nfunction countWeakCohesionCoalitions(coalitions: readonly unknown[], evidence: string[]): number {\n  let weakCount = 0;\n  for (const coalition of coalitions) {\n    const rec = toRecord(coalition);\n    if (rec && asNum(rec['cohesionScore'], 1) < 0.6) weakCount++;\n  }\n  if (weakCount >= 3) {\n    evidence.push(\n      `${weakCount} coalitions with critically low cohesion (<60%) — systemic erosion signal`\n    );\n    return 3;\n  }\n  if (weakCount >= 1) {\n    evidence.push(`${weakCount} coalition(s) with below-threshold cohesion`);\n    return 2;\n  }\n  return 1;\n}\n\n/**\n * Scan for high-significance anomalies as erosion signals.\n *\n * @param anomalies - Voting anomaly data\n * @param evidence - Evidence array to mutate\n * @returns Threat score contribution\n */\nfunction scanCriticalAnomaliesForErosion(\n  anomalies: readonly unknown[],\n  evidence: string[]\n): number {\n  const critical = anomalies.filter((a) => {\n    const rec = toRecord(a);\n    if (!rec) return false;\n    const sig = asStr(rec['significance']);\n    return sig === 'critical' || sig === 'high';\n  });\n  if (critical.length > 0) {\n    evidence.push(\n      `${critical.length} high-significance voting anomal${critical.length === 1 ? 'y' : 'ies'} — norm deviation signals`\n    );\n    return 2;\n  }\n  return 1;\n}\n\n// ─── Threat Landscape Analysis ───────────────────────────────────────────────────────────\n\n/**\n * Assess coalition shift threats from voting and coalition data.\n *\n * @param data - Article data containing voting records and coalition data\n * @returns threat landscape dimension assessment for coalition shifts\n */\nfunction assessShiftThreats(data: ThreatAssessmentInput): PoliticalThreatDimension {\n  const records = safeArray(data.votingRecords);\n  const coalitions = safeArray(data.coalitionData);\n  const anomalies = resolveAnomalies(data);\n  const evidence: string[] = [];\n\n  const anomalyScore = scanAnomaliesForShift(anomalies, evidence);\n  const coalitionScore = scanCoalitionsForShift(coalitions, evidence);\n  const threatScore = Math.max(anomalyScore, coalitionScore);\n\n  if (records.length > 10 && evidence.length === 0) {\n    evidence.push(`${records.length} voting records analysed; no major shift signals detected`);\n  }\n\n  return buildDimensionResult(\n    'shift',\n    threatScore,\n    evidence,\n    'No coalition shift signals detected in available data',\n    'Coalition stability appears maintained. No significant realignment signals.',\n    (level) => `Coalition dynamics show ${level}-level shift risk. Monitor cohesion trends closely.`\n  );\n}\n\n/**\n * Assess transparency threats from procedural and committee data.\n *\n * @param data - Article data containing committee and procedural data\n * @returns threat landscape dimension assessment for transparency concerns\n */\nfunction assessTransparencyThreats(data: ThreatAssessmentInput): PoliticalThreatDimension {\n  const committees = safeArray(data.committees);\n  const questions = safeArray(data.questions);\n  const evidence: string[] = [];\n\n  const threatScore = scanCommitteesForTransparency(committees, evidence);\n\n  if (questions.length > 0) {\n    evidence.push(\n      `${questions.length} parliamentary question${questions.length === 1 ? '' : 's'} submitted — active oversight`\n    );\n  }\n\n  return buildDimensionResult(\n    'transparency',\n    threatScore,\n    evidence,\n    'Procedural transparency within normal parameters',\n    'Procedural transparency appears adequate. Committee activity within normal parameters.',\n    (level) =>\n      `Transparency concerns at ${level} level. Review committee meeting records and public documentation.`\n  );\n}\n\n/**\n * Assess policy reversal threats from legislative procedure data.\n *\n * @param data - Article data containing procedure and feed data\n * @returns threat landscape dimension assessment for policy reversals\n */\nfunction assessReversalThreats(data: ThreatAssessmentInput): PoliticalThreatDimension {\n  const procedures = safeArray(data.procedures);\n  const evidence: string[] = [];\n\n  const procScore = scanProceduresForReversal(procedures, evidence);\n  const feedData = data.feedData ? toRecord(data.feedData) : null;\n  const feedScore = checkFeedAdoptedTexts(feedData, procedures.length, evidence);\n  const threatScore = Math.max(procScore, feedScore);\n\n  return buildDimensionResult(\n    'reversal',\n    threatScore,\n    evidence,\n    'No significant policy reversal signals detected',\n    'Legislative trajectory appears stable. No major reversal signals.',\n    (level) =>\n      `Policy reversal risk at ${level} level. Monitor stalled procedures and conciliation stages.`\n  );\n}\n\n/**\n * Assess institutional threats from MEP influence and procedural data.\n *\n * @param data - Article data containing MEP influence and committee data\n * @returns threat landscape dimension assessment for institutional threats\n */\nfunction assessInstitutionalThreats(data: ThreatAssessmentInput): PoliticalThreatDimension {\n  const mepInfluence = safeArray(data.mepInfluence);\n  const evidence: string[] = [];\n\n  const concentrationScore = checkMEPInfluenceConcentration(mepInfluence, evidence);\n  const feedData = data.feedData ? toRecord(data.feedData) : null;\n  const turnoverScore = checkMEPTurnover(feedData, evidence);\n  const threatScore = Math.max(concentrationScore, turnoverScore);\n\n  return buildDimensionResult(\n    'institutional',\n    threatScore,\n    evidence,\n    'No institutional threat signals detected',\n    'Institutional balance appears maintained. Power distribution within normal parameters.',\n    (level) =>\n      `Institutional threats at ${level} level. Monitor concentration of influence and procedural manipulation signals.`\n  );\n}\n\n/**\n * Assess legislative delay threats from procedure and feed data.\n *\n * @param data - Article data containing procedures and timeline data\n * @returns threat landscape dimension assessment for legislative delays\n */\nfunction assessDelayThreats(data: ThreatAssessmentInput): PoliticalThreatDimension {\n  const procedures = safeArray(data.procedures);\n  const anomalies = resolveAnomalies(data);\n  const evidence: string[] = [];\n\n  const procScore = scanProceduresForDelay(procedures, evidence);\n  const abstentionScore = scanAbstentionAnomalies(anomalies, evidence);\n  const threatScore = Math.max(procScore, abstentionScore);\n\n  return buildDimensionResult(\n    'delay',\n    threatScore,\n    evidence,\n    'No significant legislative delay signals detected',\n    'Legislative pace within normal parameters. No obstruction signals.',\n    (level) =>\n      `Delay threats at ${level} level. Monitor stalled procedures and abstention patterns.`\n  );\n}\n\n/**\n * Assess democratic erosion threats from coalition and anomaly data.\n *\n * @param data - Article data containing coalition and voting data\n * @returns threat landscape dimension assessment for democratic erosion\n */\nfunction assessErosionThreats(data: ThreatAssessmentInput): PoliticalThreatDimension {\n  const coalitions = safeArray(data.coalitionData);\n  const anomalies = resolveAnomalies(data);\n  const evidence: string[] = [];\n\n  const cohesionScore = countWeakCohesionCoalitions(coalitions, evidence);\n  const anomalyScore = scanCriticalAnomaliesForErosion(anomalies, evidence);\n  const threatScore = Math.max(cohesionScore, anomalyScore);\n\n  return buildDimensionResult(\n    'erosion',\n    threatScore,\n    evidence,\n    'Democratic norms appear stable. No systematic erosion signals.',\n    'Democratic norms appear stable. Institutional processes functioning within expected parameters.',\n    (level) =>\n      `Democratic erosion signals at ${level} level. Monitor norm deviations and coalition fragmentation patterns.`\n  );\n}\n\n// ─── Exported assessment functions ────────────────────────────────────────────\n\n/**\n * Assess political threats across all six Political Threat Landscape categories.\n *\n * Pure function that analyses article data and produces a complete political\n * threat assessment with threat landscape analysis, actor profiles, consequence trees,\n * and legislative disruption analysis.\n *\n * The function is null-safe and tolerates missing or malformed input data.\n *\n * @param data - Article data from MCP pipeline, or null/undefined for missing data\n * @returns Complete political threat assessment\n */\nexport function assessPoliticalThreats(\n  data: ThreatAssessmentInput | null | undefined\n): PoliticalThreatAssessment {\n  const safeData: ThreatAssessmentInput = data ?? {};\n  const date = new Date().toISOString().slice(0, 10);\n\n  const threatDimensions: PoliticalThreatDimension[] = [\n    assessShiftThreats(safeData),\n    assessTransparencyThreats(safeData),\n    assessReversalThreats(safeData),\n    assessInstitutionalThreats(safeData),\n    assessDelayThreats(safeData),\n    assessErosionThreats(safeData),\n  ];\n\n  const actorProfiles = buildActorThreatProfiles(safeData);\n  const consequenceTrees = buildConsequenceTrees(safeData);\n  const legislativeDisruptions = buildLegislativeDisruptions(safeData);\n\n  const aggregatedThreatLevels: ImpactLevel[] = [\n    ...threatDimensions.map((c) => c.threatLevel),\n    ...actorProfiles.map((p) => p.overallThreatLevel),\n  ];\n  const overallThreatLevel = aggregateImpactLevels(aggregatedThreatLevels);\n\n  const keyFindings: string[] = threatDimensions\n    .filter((c) => c.threatLevel === 'high' || c.threatLevel === 'critical')\n    .map((c) => `${DIMENSION_LABELS[c.category]}: ${c.analysis}`);\n\n  for (const profile of actorProfiles) {\n    if (profile.overallThreatLevel === 'high' || profile.overallThreatLevel === 'critical') {\n      keyFindings.push(\n        `High-threat actor: ${profile.actor} (${profile.actorType}) — CMO: ${profile.capability}/${profile.motivation}/${profile.opportunity}`\n      );\n    }\n  }\n\n  if (keyFindings.length === 0) {\n    keyFindings.push('No high-priority threats detected across threat landscape dimensions');\n  }\n\n  const recommendations: string[] = [];\n  for (const cat of threatDimensions) {\n    if (cat.threatLevel === 'critical' || cat.threatLevel === 'high') {\n      recommendations.push(\n        `Monitor ${DIMENSION_LABELS[cat.category].toLowerCase()} — ${cat.evidence[0] ?? 'elevated threat level'}`\n      );\n    }\n  }\n  for (const profile of actorProfiles) {\n    if (profile.overallThreatLevel === 'critical' || profile.overallThreatLevel === 'high') {\n      recommendations.push(\n        `Track ${profile.actor} (${profile.actorType}) — ${profile.threatCategories.join(', ')} threat categories`\n      );\n    }\n  }\n  if (recommendations.length === 0) {\n    recommendations.push('Continue routine monitoring of parliamentary activity');\n  }\n\n  const hasStrongActorSignals = actorProfiles.length > 0;\n  const hasRichDimensionEvidence = threatDimensions.some((c) => c.evidence.length > 1);\n  let confidence: 'high' | 'medium' | 'low' = 'low';\n  if (hasStrongActorSignals && hasRichDimensionEvidence) {\n    confidence = 'high';\n  } else if (hasStrongActorSignals || hasRichDimensionEvidence) {\n    confidence = 'medium';\n  }\n\n  return {\n    date,\n    overallThreatLevel,\n    confidence,\n    threatDimensions,\n    actorProfiles,\n    consequenceTrees,\n    legislativeDisruptions,\n    keyFindings,\n    recommendations,\n  };\n}\n\n/**\n * Derive CMO levels for a coalition actor.\n *\n * @param groups - Coalition group names\n * @param cohesion - Coalition cohesion score\n * @param riskLevel - Coalition risk level string\n * @returns Object with capability, motivation, opportunity levels\n */\nfunction coalitionCMO(\n  groups: string[],\n  cohesion: number,\n  riskLevel: string\n): {\n  capability: 'high' | 'medium' | 'low';\n  motivation: 'high' | 'medium' | 'low';\n  opportunity: 'high' | 'medium' | 'low';\n} {\n  const capability: 'high' | 'medium' | 'low' =\n    groups.length >= 3 ? 'high' : groups.length === 2 ? 'medium' : 'low';\n  const motivation: 'high' | 'medium' | 'low' =\n    riskLevel === 'high' ? 'high' : riskLevel === 'medium' ? 'medium' : 'low';\n  const opportunity: 'high' | 'medium' | 'low' =\n    cohesion < 0.6 ? 'high' : cohesion < 0.8 ? 'medium' : 'low';\n  return { capability, motivation, opportunity };\n}\n\n/**\n * Build a coalition actor threat profile from a coalition record.\n *\n * @param rec - Coalition data record\n * @returns Actor threat profile or null if record is invalid\n */\nfunction buildCoalitionProfile(rec: Record<string, unknown>): PoliticalActorThreatProfile | null {\n  const groups = asStrArr(rec['groups']);\n  const cohesion = asNum(rec['cohesionScore'], 1);\n  const riskLevel = asStr(rec['riskLevel']);\n  const trend = asStr(rec['alignmentTrend']);\n  const { capability, motivation, opportunity } = coalitionCMO(groups, cohesion, riskLevel);\n  const score = cmoScore(capability, motivation, opportunity);\n  const overallThreatLevel = cmoToThreatLevel(score);\n\n  const threatCategories: PoliticalThreatCategory[] = ['shift'];\n  if (riskLevel === 'high') threatCategories.push('erosion');\n  if (trend === 'weakening') threatCategories.push('delay');\n\n  return {\n    actor: groups.join('-') || 'Unknown Coalition',\n    actorType: 'political_group',\n    capability,\n    motivation,\n    opportunity,\n    trackRecord: trend === 'weakening' ? ['Coalition alignment weakening trend detected'] : [],\n    threatCategories,\n    overallThreatLevel,\n  };\n}\n\n/**\n * Derive CMO levels for a MEP actor.\n *\n * @param mepScore - MEP overall influence score\n * @param rank - MEP rank string\n * @returns Object with capability, motivation, opportunity levels\n */\nfunction mepCMO(\n  mepScore: number,\n  rank: string\n): {\n  capability: 'high' | 'medium' | 'low';\n  motivation: 'high' | 'medium' | 'low';\n  opportunity: 'high' | 'medium' | 'low';\n} {\n  const capability: 'high' | 'medium' | 'low' =\n    mepScore >= 80 ? 'high' : mepScore >= 60 ? 'medium' : 'low';\n  const motivation: 'high' | 'medium' | 'low' = rank === 'top-25%' ? 'high' : 'medium';\n  return { capability, motivation, opportunity: 'medium' };\n}\n\n/**\n * Build a MEP actor threat profile from an influence record.\n *\n * @param rec - MEP influence data record\n * @returns Actor threat profile or null if MEP is not high-influence\n */\nfunction buildMEPProfile(rec: Record<string, unknown>): PoliticalActorThreatProfile | null {\n  const mepScore = asNum(rec['overallScore'], 0);\n  const rank = asStr(rec['rank']);\n  if (mepScore < 60 && rank !== 'top-25%') return null;\n\n  const { capability, motivation, opportunity } = mepCMO(mepScore, rank);\n  const score = cmoScore(capability, motivation, opportunity);\n  const overallThreatLevel = cmoToThreatLevel(score);\n\n  return {\n    actor: asStr(rec['mepName']) || asStr(rec['name']) || 'Unknown MEP',\n    actorType: 'mep',\n    capability,\n    motivation,\n    opportunity,\n    trackRecord: [`MEP influence score: ${mepScore.toFixed(0)}, rank: ${rank}`],\n    threatCategories: ['institutional'],\n    overallThreatLevel,\n  };\n}\n\n/**\n * Build political actor threat profiles from article data.\n *\n * Extracts actors from voting, coalition, and MEP influence data and applies\n * CMO (capability + motivation + opportunity) threat scoring, adapted from\n * ISMS threat agent classification methodology.\n *\n * @param data - Article data from MCP pipeline, or null/undefined for missing data\n * @returns Array of actor threat profiles, sorted by overall threat level\n */\nexport function buildActorThreatProfiles(\n  data: ThreatAssessmentInput | null | undefined\n): PoliticalActorThreatProfile[] {\n  const profiles: PoliticalActorThreatProfile[] = [];\n  const coalitions = safeArray(data?.coalitionData);\n\n  for (const coalition of coalitions) {\n    const rec = toRecord(coalition);\n    if (!rec) continue;\n    const profile = buildCoalitionProfile(rec);\n    if (profile) profiles.push(profile);\n  }\n\n  const mepInfluence = safeArray(data?.mepInfluence);\n  for (const mep of mepInfluence) {\n    const rec = toRecord(mep);\n    if (!rec) continue;\n    const profile = buildMEPProfile(rec);\n    if (profile) profiles.push(profile);\n  }\n\n  return profiles.sort(\n    (a, b) => IMPACT_WEIGHTS[b.overallThreatLevel] - IMPACT_WEIGHTS[a.overallThreatLevel]\n  );\n}\n\n/**\n * Build a political consequence tree for a given root action.\n *\n * Models how a political action cascades through institutions, adapted from\n * attack tree methodology in ISMS threat modeling.\n *\n * @param action - The initiating political action to analyse, or null/undefined\n * @param data - Article data providing context for consequence assessment, or null/undefined\n * @returns Political consequence tree with immediate, secondary, and long-term effects\n */\nexport function buildConsequenceTree(\n  action: string | null | undefined,\n  data: ThreatAssessmentInput | null | undefined\n): PoliticalConsequenceTree {\n  const safeAction =\n    typeof action === 'string' && action.trim().length > 0\n      ? action.trim()\n      : 'Unknown political action';\n  const coalitions = safeArray(data?.coalitionData);\n  const anomalies = resolveAnomalies(data);\n\n  const mitigatingFactors: string[] = [\n    'Institutional resilience mechanisms',\n    'Cross-party dialogue channels',\n  ];\n  const amplifyingFactors: string[] = [];\n\n  // Assess coalition strain amplifier\n  const weakCoalitions = coalitions.filter((c) => {\n    const rec = toRecord(c);\n    return rec ? asNum(rec['cohesionScore'], 1) < 0.7 : false;\n  });\n\n  if (weakCoalitions.length > 0) {\n    amplifyingFactors.push(`${weakCoalitions.length} weakened coalition(s) reduce buffer capacity`);\n  }\n\n  if (anomalies.length > 0) {\n    amplifyingFactors.push(\n      `${anomalies.length} existing voting anomal${anomalies.length === 1 ? 'y' : 'ies'} amplify instability`\n    );\n  }\n\n  // Build immediate consequences\n  const immediateConsequences: ConsequenceNode[] = [\n    {\n      description: 'Legislative process disruption requiring procedural recalibration',\n      probability: clampProbability(0.4 + anomalies.length * 0.05),\n      impact: anomalies.length > 2 ? 'high' : 'moderate',\n      affectedStakeholders: stakeholders('political_group', 'eu_institution'),\n      timeframe: 'immediate',\n    },\n    {\n      description: 'Coalition communication and coordination burden increases',\n      probability: clampProbability(0.3 + weakCoalitions.length * 0.1),\n      impact: weakCoalitions.length > 1 ? 'high' : 'moderate',\n      affectedStakeholders: stakeholders('political_group'),\n      timeframe: 'immediate',\n    },\n  ];\n\n  // Build secondary effects\n  const secondaryEffects: ConsequenceNode[] = [\n    {\n      description: 'Stakeholder confidence shifts in legislative outcome predictability',\n      probability: 0.5,\n      impact: 'moderate',\n      affectedStakeholders: stakeholders('civil_society', 'industry', 'member_state'),\n      timeframe: 'short-term',\n    },\n    {\n      description: 'Political group internal pressure and positioning adjustments',\n      probability: clampProbability(0.35 + weakCoalitions.length * 0.08),\n      impact: weakCoalitions.length > 0 ? 'high' : 'moderate',\n      affectedStakeholders: stakeholders('political_group', 'mep'),\n      timeframe: 'short-term',\n    },\n  ];\n\n  // Build long-term implications\n  const longTermImplications: ConsequenceNode[] = [\n    {\n      description: 'Precedent set for similar procedural challenges in future legislative cycles',\n      probability: 0.4,\n      impact: 'moderate',\n      affectedStakeholders: stakeholders('eu_institution', 'political_group'),\n      timeframe: 'long-term',\n    },\n    {\n      description: 'Structural adjustment of coalition formation strategies',\n      probability: weakCoalitions.length > 1 ? 0.6 : 0.3,\n      impact: weakCoalitions.length > 1 ? 'high' : 'low',\n      affectedStakeholders: stakeholders('political_group'),\n      timeframe: 'long-term',\n    },\n  ];\n\n  return {\n    rootAction: safeAction,\n    immediateConsequences,\n    secondaryEffects,\n    longTermImplications,\n    mitigatingFactors,\n    amplifyingFactors:\n      amplifyingFactors.length > 0\n        ? amplifyingFactors\n        : ['No significant amplifying factors identified'],\n  };\n}\n\n/**\n * Build consequence trees for all identified high-risk actions.\n * Internal helper for assessPoliticalThreats.\n *\n * @param data - Article data from MCP pipeline\n * @returns Array of consequence trees for significant political actions\n */\nfunction buildConsequenceTrees(data: ThreatAssessmentInput): PoliticalConsequenceTree[] {\n  const MAX_TREES = 3;\n  const trees: PoliticalConsequenceTree[] = [];\n  const procedures = safeArray(data.procedures);\n\n  // Build trees for stalled procedures\n  for (const proc of procedures) {\n    if (trees.length >= MAX_TREES) break;\n    const rec = toRecord(proc);\n    if (!rec) continue;\n    const status = asStr(rec['status']);\n    if (status === 'stalled' || status === 'blocked') {\n      const id = asStr(rec['procedureId'] ?? rec['id'] ?? 'Unknown procedure');\n      trees.push(buildConsequenceTree(`Stalled procedure: ${id}`, data));\n    }\n  }\n\n  // Build trees for high-significance anomalies\n  const anomalies = resolveAnomalies(data);\n  for (const anomaly of anomalies) {\n    if (trees.length >= MAX_TREES) break;\n    const rec = toRecord(anomaly);\n    if (!rec) continue;\n    const significance = asStr(rec['significance']);\n    if (significance === 'critical' || significance === 'high') {\n      const desc = asStr(rec['description'] ?? 'Significant voting anomaly');\n      trees.push(buildConsequenceTree(desc, data));\n    }\n  }\n\n  // Always include at least one default tree\n  if (trees.length === 0) {\n    trees.push(buildConsequenceTree('Standard legislative activity assessment', data));\n  }\n\n  return trees;\n}\n\n/** Stage-level risk multipliers for the legislative kill chain */\nconst STAGE_RISK_MULTIPLIERS: Readonly<Record<LegislativeStage, number>> = {\n  proposal: 0.5,\n  committee: 1.2,\n  plenary_first_reading: 1.5,\n  council_position: 0.8,\n  plenary_second_reading: 1.4,\n  conciliation: 1.1,\n  adoption: 0.3,\n};\n\n/**\n * Determine the Political Threat Landscape threat category for a legislative stage.\n *\n * @param stage - Legislative stage\n * @returns Corresponding threat category\n */\nfunction stageThreatCategory(stage: LegislativeStage): PoliticalThreatCategory {\n  if (stage === 'committee') return 'transparency';\n  if (stage === 'conciliation') return 'reversal';\n  if (stage === 'plenary_first_reading' || stage === 'plenary_second_reading') return 'shift';\n  return 'delay';\n}\n\n/**\n * Build a single disruption point for a legislative stage.\n *\n * @param stage - Legislative stage\n * @param baseRisk - Base disruption risk\n * @param coalitionRisk - Additional coalition-derived risk\n * @returns Disruption point\n */\nfunction buildDisruptionPoint(\n  stage: LegislativeStage,\n  baseRisk: number,\n  coalitionRisk: number\n): DisruptionPoint {\n  const likelihood = clampProbability((baseRisk + coalitionRisk) * STAGE_RISK_MULTIPLIERS[stage]);\n  const isHighRisk = likelihood > 0.3;\n  return {\n    stage,\n    threatCategory: stageThreatCategory(stage),\n    likelihood,\n    potentialDisruptors: isHighRisk\n      ? ['Opposing political groups', 'Veto-seeking member states']\n      : ['Standard procedural opposition'],\n    countermeasures: isHighRisk\n      ? [\n          'Reinforce coalition commitments',\n          'Engage rapporteur for compromise',\n          'Activate trilogue early',\n        ]\n      : ['Monitor voting intentions', 'Maintain procedural compliance'],\n  };\n}\n\n/**\n * Find the current legislative stage for a procedure from data.\n *\n * @param safeProcedure - Sanitised procedure identifier\n * @param procedures - Procedure data items\n * @returns Current legislative stage or 'proposal' as default\n */\nfunction findCurrentStage(safeProcedure: string, procedures: readonly unknown[]): LegislativeStage {\n  const normalizedSafeProcedure = safeProcedure.trim();\n  if (normalizedSafeProcedure.length === 0) {\n    return 'proposal';\n  }\n  for (const proc of procedures) {\n    const rec = toRecord(proc);\n    if (!rec) continue;\n    const rawId = asStr(rec['procedureId'] ?? rec['id']);\n    const id = rawId.trim();\n    if (id.length === 0) {\n      continue;\n    }\n    if (id === normalizedSafeProcedure) {\n      const stage = asStr(rec['currentStage'] ?? rec['stage']);\n      if ((ALL_LEGISLATIVE_STAGES as string[]).includes(stage)) {\n        return stage as LegislativeStage;\n      }\n    }\n  }\n  return 'proposal';\n}\n\n/**\n * Calculate coalition risk contribution for disruption likelihood.\n *\n * @param coalitions - Coalition data\n * @returns Coalition risk value (0 or 0.15)\n */\nfunction calcCoalitionRisk(coalitions: readonly unknown[]): number {\n  const hasWeakCoalition = coalitions.some((c) => {\n    const rec = toRecord(c);\n    return rec ? asNum(rec['cohesionScore'], 1) < 0.7 : false;\n  });\n  return hasWeakCoalition ? 0.15 : 0;\n}\n\n/**\n * Analyse legislative process disruption risk for a specific procedure.\n *\n * Maps the complete legislative kill chain to identify vulnerability points,\n * adapted from ISMS kill chain analysis applied to parliamentary procedures.\n *\n * @param procedure - Name or ID of the legislative procedure, or null/undefined\n * @param data - Article data providing context for disruption assessment, or null/undefined\n * @returns Legislative disruption analysis for all stages of the procedure\n */\nexport function analyzeLegislativeDisruption(\n  procedure: string | null | undefined,\n  data: ThreatAssessmentInput | null | undefined\n): LegislativeDisruptionAnalysis {\n  const safeProcedure =\n    typeof procedure === 'string' && procedure.trim().length > 0\n      ? procedure.trim()\n      : 'Unknown procedure';\n\n  const procedures = safeArray(data?.procedures);\n  const coalitions = safeArray(data?.coalitionData);\n  const anomalies = resolveAnomalies(data);\n\n  const currentStage = findCurrentStage(safeProcedure, procedures);\n  const baseRisk = anomalies.length > 0 ? 0.2 + Math.min(anomalies.length * 0.05, 0.3) : 0.15;\n  const coalitionRisk = calcCoalitionRisk(coalitions);\n\n  const disruptionPoints = ALL_LEGISLATIVE_STAGES.map((stage) =>\n    buildDisruptionPoint(stage, baseRisk, coalitionRisk)\n  );\n\n  const maxLikelihood = Math.max(...disruptionPoints.map((d) => d.likelihood));\n  const resilience: 'high' | 'medium' | 'low' =\n    maxLikelihood < 0.25 ? 'high' : maxLikelihood < 0.45 ? 'medium' : 'low';\n\n  const alternativePathways: string[] = [\n    'Commission resubmission with revised proposal',\n    'Enhanced informal trilogue engagement',\n    'Interim resolution as procedural bridge',\n  ];\n\n  if (coalitionRisk > 0) {\n    alternativePathways.push('Cross-group rapporteur team to broaden coalition base');\n  }\n\n  return {\n    procedure: safeProcedure,\n    currentStage,\n    disruptionPoints,\n    resilience,\n    alternativePathways,\n  };\n}\n\n/**\n * Build legislative disruption analyses for active procedures.\n * Internal helper for assessPoliticalThreats.\n *\n * @param data - Article data from MCP pipeline\n * @returns Array of legislative disruption analyses\n */\nfunction buildLegislativeDisruptions(data: ThreatAssessmentInput): LegislativeDisruptionAnalysis[] {\n  const procedures = safeArray(data.procedures);\n  const analyses: LegislativeDisruptionAnalysis[] = [];\n\n  for (const proc of procedures) {\n    const rec = toRecord(proc);\n    if (!rec) continue;\n    const id = asStr(rec['procedureId'] ?? rec['id'] ?? '');\n    if (id) {\n      analyses.push(analyzeLegislativeDisruption(id, data));\n    }\n    if (analyses.length >= 3) break; // Limit for manageability\n  }\n\n  if (analyses.length === 0) {\n    analyses.push(analyzeLegislativeDisruption('General legislative pipeline', data));\n  }\n\n  return analyses;\n}\n\n// ─── Markdown generation ───────────────────────────────────────────────────────\n\n/**\n * Sanitize untrusted text for safe use as a Mermaid diagram node label.\n *\n * Removes control characters/newlines and escapes Mermaid-reserved characters\n * that can break label syntax or allow diagram injection.\n *\n * @param input - Untrusted label text\n * @returns Sanitized label safe for Mermaid node definitions\n */\nfunction sanitizeMermaidLabel(input: string): string {\n  const withoutControlChars = Array.from(input)\n    .map((ch) => {\n      const code = ch.charCodeAt(0);\n      return code <= 0x1f || code === 0x7f ? ' ' : ch;\n    })\n    .join('');\n  const escaped = withoutControlChars\n    .replace(/\\\\/g, '\\\\\\\\')\n    .replace(/\"/g, \"'\")\n    .replace(/\\[/g, '\\\\[')\n    .replace(/\\]/g, '\\\\]')\n    .replace(/\\|/g, '\\\\|');\n  return escaped.trim();\n}\n\n/**\n * Sanitize untrusted text for safe use in a Markdown table cell.\n *\n * Escapes pipe characters and normalizes whitespace to prevent\n * table layout corruption from external data.\n *\n * @param input - Untrusted cell text\n * @returns Sanitized text safe for Markdown table cells\n */\nfunction sanitizeTableCell(input: string): string {\n  return input\n    .replace(/\\\\/g, '\\\\\\\\')\n    .replace(/\\|/g, '\\\\|')\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/[\\r\\n]+/g, ' ')\n    .trim();\n}\n\n/**\n * Sanitize untrusted text for safe embedding in Markdown prose or headings.\n *\n * Strips control characters, escapes Markdown link/image metacharacters, and\n * escapes HTML entities to prevent Markdown structure corruption and HTML/script\n * injection from external MCP data.\n *\n * @param input - Untrusted text to sanitize\n * @returns Sanitized text safe for Markdown prose\n */\nfunction sanitizeMarkdownText(input: string): string {\n  const normalizedChars: string[] = [];\n  for (const char of input) {\n    const code = char.charCodeAt(0);\n    if (code <= 31 || code === 127) {\n      normalizedChars.push(' ');\n      continue;\n    }\n    switch (char) {\n      case '\\\\':\n      case '!':\n      case '[':\n      case ']':\n      case '(':\n      case ')':\n        normalizedChars.push(`\\\\${char}`);\n        break;\n      default:\n        normalizedChars.push(char);\n    }\n  }\n\n  return normalizedChars\n    .join('')\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .trim();\n}\n\n/**\n * Generate a risk heat map row for an actor profile.\n *\n * @param profile - Actor threat profile\n * @returns Markdown table row string\n */\nfunction buildActorTableRow(profile: PoliticalActorThreatProfile): string {\n  const emoji = THREAT_EMOJIS[profile.overallThreatLevel];\n  const actor = sanitizeTableCell(profile.actor);\n  return `| ${actor} | ${profile.capability} | ${profile.motivation} | ${profile.opportunity} | ${emoji} ${profile.overallThreatLevel} |`;\n}\n\n/**\n * Generate a consequence tree section in Mermaid diagram format.\n *\n * @param tree - Political consequence tree\n * @returns Markdown string with Mermaid diagram\n */\nfunction buildConsequenceTreeMarkdown(tree: PoliticalConsequenceTree): string {\n  const rootLabel = sanitizeMermaidLabel(tree.rootAction);\n  const lines: string[] = [\n    `### Consequence Tree: ${rootLabel}`,\n    '',\n    '```mermaid',\n    'graph TD',\n    `    A[\"${rootLabel}\"]`,\n  ];\n\n  tree.immediateConsequences.forEach((c, i) => {\n    const nodeId = `B${i}`;\n    const rawDescription = c.description;\n    const truncated =\n      rawDescription.length > 40 ? rawDescription.slice(0, 40) + '...' : rawDescription;\n    const label = sanitizeMermaidLabel(truncated);\n    lines.push(`    ${nodeId}[\"${label}\"]`);\n    lines.push(`    A --> ${nodeId}`);\n  });\n\n  tree.secondaryEffects.forEach((c, i) => {\n    const nodeId = `C${i}`;\n    const hasImmediateNodes = tree.immediateConsequences.length > 0;\n    const parentId = hasImmediateNodes ? `B${i % tree.immediateConsequences.length}` : 'A';\n    const rawDescription = c.description;\n    const truncated =\n      rawDescription.length > 40 ? rawDescription.slice(0, 40) + '...' : rawDescription;\n    const label = sanitizeMermaidLabel(truncated);\n    lines.push(`    ${nodeId}[\"${label}\"]`);\n    lines.push(`    ${parentId} --> ${nodeId}`);\n  });\n\n  tree.longTermImplications.forEach((c, i) => {\n    const nodeId = `D${i}`;\n    const hasSecondaryNodes = tree.secondaryEffects.length > 0;\n    const hasImmediateNodes = tree.immediateConsequences.length > 0;\n    const parentId = hasSecondaryNodes\n      ? `C${i % tree.secondaryEffects.length}`\n      : hasImmediateNodes\n        ? `B${i % tree.immediateConsequences.length}`\n        : 'A';\n    const rawDescription = c.description;\n    const truncated =\n      rawDescription.length > 40 ? rawDescription.slice(0, 40) + '...' : rawDescription;\n    const label = sanitizeMermaidLabel(truncated);\n    lines.push(`    ${nodeId}[\"${label}\"]`);\n    lines.push(`    ${parentId} --> ${nodeId}`);\n  });\n\n  lines.push('```', '');\n\n  if (tree.mitigatingFactors.length > 0) {\n    lines.push('**Mitigating Factors:**');\n    tree.mitigatingFactors.forEach((f) => lines.push(`- ${sanitizeMarkdownText(f)}`));\n    lines.push('');\n  }\n\n  if (tree.amplifyingFactors.length > 0) {\n    lines.push('**Amplifying Factors:**');\n    tree.amplifyingFactors.forEach((f) => lines.push(`- ${sanitizeMarkdownText(f)}`));\n    lines.push('');\n  }\n\n  return lines.join('\\n');\n}\n\n/**\n * Generate a legislative disruption analysis table.\n *\n * @param analysis - Legislative disruption analysis\n * @returns Markdown string with disruption table\n */\nfunction buildDisruptionTableMarkdown(analysis: LegislativeDisruptionAnalysis): string {\n  const safeProcedure = sanitizeMarkdownText(analysis.procedure);\n  const lines: string[] = [\n    `### Procedure: ${safeProcedure}`,\n    '',\n    `**Current Stage**: ${analysis.currentStage} | **Resilience**: ${analysis.resilience}`,\n    '',\n    '| Stage | Threat Category | Likelihood | Risk Level |',\n    '|-------|----------------|------------|------------|',\n  ];\n\n  for (const point of analysis.disruptionPoints) {\n    const riskLevel =\n      point.likelihood > 0.4 ? '🔴 High' : point.likelihood > 0.25 ? '⚠️ Medium' : '🟢 Low';\n    lines.push(\n      `| ${point.stage.replace(/_/g, ' ')} | ${point.threatCategory} | ${(point.likelihood * 100).toFixed(0)}% | ${riskLevel} |`\n    );\n  }\n\n  lines.push('', '**Alternative Pathways:**');\n  analysis.alternativePathways.forEach((p) => {\n    const safePathway = sanitizeMarkdownText(p);\n    lines.push(`- ${safePathway}`);\n  });\n  lines.push('');\n\n  return lines.join('\\n');\n}\n\n/**\n * Generate structured markdown analysis from a political threat assessment.\n *\n * Produces a complete markdown document with YAML frontmatter, Political Threat Landscape\n * analysis, actor threat profiles table, consequence trees with Mermaid diagrams,\n * and legislative disruption analysis.\n *\n * If {@link assessment} is `null` or `undefined`, a default low-threat,\n * low-confidence assessment is generated using {@link assessPoliticalThreats}\n * to preserve the module's null-safe contract.\n *\n * @param assessment - Complete political threat assessment to render, or null/undefined to use defaults\n * @returns Markdown string suitable for writing to analysis directory\n */\nexport function generateThreatAssessmentMarkdown(\n  assessment: PoliticalThreatAssessment | null | undefined\n): string {\n  const safeAssessment: PoliticalThreatAssessment = assessment ?? assessPoliticalThreats(null);\n\n  const lines: string[] = [\n    '---',\n    'title: \"Political Threat Assessment\"',\n    `date: \"${safeAssessment.date}\"`,\n    'analysisType: \"threat-assessment\"',\n    `threatLevel: \"${safeAssessment.overallThreatLevel}\"`,\n    `confidence: \"${safeAssessment.confidence}\"`,\n    'methods: [\"political-threat-landscape\", \"actor-profiling\", \"consequence-trees\", \"disruption-analysis\"]',\n    '---',\n    '',\n    '# Political Threat Assessment',\n    '',\n    `**Overall Threat Level**: ${THREAT_EMOJIS[safeAssessment.overallThreatLevel]} ${safeAssessment.overallThreatLevel.toUpperCase()}  `,\n    `**Confidence**: ${safeAssessment.confidence}  `,\n    `**Date**: ${safeAssessment.date}`,\n    '',\n    '## Political Threat Landscape Analysis',\n    '',\n  ];\n\n  for (const cat of safeAssessment.threatDimensions) {\n    const emoji = THREAT_EMOJIS[cat.threatLevel];\n    lines.push(\n      `### ${DIMENSION_LABELS[cat.category]}`,\n      `**Threat Level**: ${emoji} ${cat.threatLevel.charAt(0).toUpperCase() + cat.threatLevel.slice(1)}`,\n      '',\n      sanitizeMarkdownText(cat.analysis),\n      ''\n    );\n    if (cat.evidence.length > 0) {\n      lines.push('**Evidence:**');\n      cat.evidence.forEach((e) => lines.push(`- ${sanitizeMarkdownText(e)}`));\n      lines.push('');\n    }\n  }\n\n  lines.push('## Actor Threat Profiles', '');\n\n  if (safeAssessment.actorProfiles.length > 0) {\n    lines.push(\n      '| Actor | Capability | Motivation | Opportunity | Overall |',\n      '|-------|-----------|------------|-------------|---------|'\n    );\n    safeAssessment.actorProfiles.forEach((p) => lines.push(buildActorTableRow(p)));\n    lines.push('');\n  } else {\n    lines.push('*No actor threat profiles generated from available data.*', '');\n  }\n\n  lines.push('## Consequence Trees', '');\n  safeAssessment.consequenceTrees.forEach((tree) => lines.push(buildConsequenceTreeMarkdown(tree)));\n\n  lines.push('## Legislative Disruption Analysis', '');\n  safeAssessment.legislativeDisruptions.forEach((analysis) =>\n    lines.push(buildDisruptionTableMarkdown(analysis))\n  );\n\n  lines.push('## Key Findings', '');\n  safeAssessment.keyFindings.forEach((f) => lines.push(`- ${sanitizeMarkdownText(f)}`));\n  lines.push('');\n\n  lines.push('## Recommendations', '');\n  safeAssessment.recommendations.forEach((r) => lines.push(`- ${sanitizeMarkdownText(r)}`));\n  lines.push('');\n\n  lines.push(\n    '---',\n    '*Assessment generated by EU Parliament Monitor Political Threat Assessment Pipeline.*  ',\n    '*Based on public European Parliament data. GDPR-compliant.*'\n  );\n\n  return lines.join('\\n');\n}\n\n// ─── All threat landscape dimensions constant (for external use) ──────────────\n\n/**\n * All Political Threat Landscape dimensions in canonical order.\n * Useful for iterating over all dimensions without hardcoding the list.\n */\nexport const ALL_THREAT_LANDSCAPE_DIMENSIONS: readonly PoliticalThreatCategory[] =\n  ALL_THREAT_DIMENSIONS;\n\n// ─── Threat correlation matrix ────────────────────────────────────────────────\n\nimport type { ThreatCorrelation, EmergingThreat } from '../types/political-threats.js';\n\n/**\n * Pre-computed correlation scores between EP political threat dimensions.\n * Positive values = mutually reinforcing threats.\n * Negative values = counteracting threats.\n * Structure: MAP[dimA][dimB] — only upper triangle defined; lower triangle mirrors it.\n */\nconst THREAT_CORRELATION_MAP: Readonly<Record<string, Readonly<Record<string, number>>>> = {\n  shift: { transparency: 0.6, reversal: 0.5, institutional: 0.4, delay: 0.3, erosion: 0.7 },\n  transparency: { reversal: 0.4, institutional: 0.7, delay: 0.3, erosion: 0.8 },\n  reversal: { institutional: 0.3, delay: 0.4, erosion: 0.5 },\n  institutional: { delay: 0.6, erosion: 0.7 },\n  delay: { erosion: 0.4 },\n  erosion: {},\n};\n\n/**\n * Generate a description for a pair of correlated threat dimensions.\n *\n * @param dimA - First dimension\n * @param dimB - Second dimension\n * @param score - Correlation score (-1 to +1)\n * @returns Human-readable description of the interaction\n */\nfunction describeThreatCorrelation(\n  dimA: PoliticalThreatCategory,\n  dimB: PoliticalThreatCategory,\n  score: number\n): string {\n  if (score > 0.6)\n    return `${dimA} and ${dimB} are strongly mutually reinforcing — escalation in one typically accelerates the other`;\n  if (score > 0.3)\n    return `${dimA} and ${dimB} show moderate positive correlation — they often co-occur`;\n  if (score > 0) return `${dimA} and ${dimB} have weak positive correlation`;\n  if (score < -0.3)\n    return `${dimA} and ${dimB} are partially counteracting — mitigation of one may reduce the other`;\n  return `${dimA} and ${dimB} are largely independent`;\n}\n\n/**\n * Compute a threat correlation matrix for a set of active threat dimensions.\n *\n * Uses pre-calibrated EP-specific correlation scores to identify which threats\n * reinforce each other. Only pairs with |correlationScore| > 0.2 are returned\n * as significant correlations.\n *\n * @param dimensions - Threat categories to analyse (e.g. currently active dimensions)\n * @returns Array of ThreatCorrelation pairs sorted by absolute correlation descending\n */\nexport function computeThreatCorrelationMatrix(\n  dimensions: readonly PoliticalThreatCategory[]\n): ThreatCorrelation[] {\n  if (dimensions.length < 2) return [];\n\n  const correlations: ThreatCorrelation[] = [];\n\n  for (let i = 0; i < dimensions.length; i++) {\n    for (let j = i + 1; j < dimensions.length; j++) {\n      const dimA = dimensions[i] as PoliticalThreatCategory;\n      const dimB = dimensions[j] as PoliticalThreatCategory;\n\n      // Look up correlation in upper triangle, then try reverse\n      const score =\n        THREAT_CORRELATION_MAP[dimA]?.[dimB] ?? THREAT_CORRELATION_MAP[dimB]?.[dimA] ?? 0;\n\n      if (Math.abs(score) > 0.2) {\n        correlations.push({\n          dimensionA: dimA,\n          dimensionB: dimB,\n          correlationScore: Math.round(score * 100) / 100,\n          mutuallyReinforcing: score > 0,\n          description: describeThreatCorrelation(dimA, dimB, score),\n        });\n      }\n    }\n  }\n\n  // Sort by absolute correlation score descending\n  return correlations.sort((a, b) => Math.abs(b.correlationScore) - Math.abs(a.correlationScore));\n}\n\n// ─── Emerging threat detection ────────────────────────────────────────────────\n\n/**\n * Numeric weights for ImpactLevel — used to detect escalation rate.\n */\nconst IMPACT_LEVEL_ORDER: Readonly<Record<ImpactLevel, number>> = {\n  none: 0,\n  low: 1,\n  moderate: 2,\n  high: 3,\n  critical: 4,\n};\n\n/**\n * Detect emerging threats by comparing current threat levels against a baseline.\n *\n * A threat is considered \"emerging\" when:\n * - The category was not present in the baseline (new threat), OR\n * - The current impact level is higher than the baseline level\n *\n * Escalation rate:\n * - `rapid`: escalated by ≥ 2 impact levels\n * - `moderate`: escalated by 1 level\n * - `slow`: new (no baseline) or minimal change\n *\n * @param currentThreats - Current threat dimensions with their impact levels\n * @param baselineThreats - Baseline (historical) threat dimensions for comparison\n * @param detectionDate - ISO date string for the detection timestamp (defaults to today)\n * @returns Array of EmergingThreat objects sorted by escalation rate (rapid first)\n */\nexport function detectEmergingThreats(\n  currentThreats: readonly {\n    category: PoliticalThreatCategory;\n    level: ImpactLevel;\n    evidence: readonly string[];\n  }[],\n  baselineThreats: readonly { category: PoliticalThreatCategory; level: ImpactLevel }[],\n  detectionDate?: string\n): EmergingThreat[] {\n  const date = detectionDate ?? new Date().toISOString().split('T')[0] ?? '';\n  const baselineMap = new Map<string, ImpactLevel>();\n  for (const bt of baselineThreats) {\n    baselineMap.set(bt.category, bt.level);\n  }\n\n  const emerging: EmergingThreat[] = [];\n\n  for (const current of currentThreats) {\n    const baseline = baselineMap.get(current.category);\n\n    if (!baseline) {\n      // New threat — not seen in baseline\n      emerging.push({\n        category: current.category,\n        firstDetected: date,\n        currentLevel: current.level,\n        escalationRate: 'slow',\n        evidence: current.evidence,\n      });\n      continue;\n    }\n\n    const currentOrder = IMPACT_LEVEL_ORDER[current.level];\n    const baselineOrder = IMPACT_LEVEL_ORDER[baseline];\n    const levelDiff = currentOrder - baselineOrder;\n\n    if (levelDiff >= 2) {\n      emerging.push({\n        category: current.category,\n        firstDetected: date,\n        currentLevel: current.level,\n        escalationRate: 'rapid',\n        evidence: current.evidence,\n      });\n    } else if (levelDiff === 1) {\n      emerging.push({\n        category: current.category,\n        firstDetected: date,\n        currentLevel: current.level,\n        escalationRate: 'moderate',\n        evidence: current.evidence,\n      });\n    }\n    // No change or improvement — not emerging\n  }\n\n  // Sort: rapid first, then moderate, then slow\n  const rateOrder: Record<string, number> = { rapid: 3, moderate: 2, slow: 1 };\n  return emerging.sort(\n    (a, b) => (rateOrder[b.escalationRate] ?? 0) - (rateOrder[a.escalationRate] ?? 0)\n  );\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/retrofit-analysis-links.ts","messages":[{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":75,"column":21,"endLine":75,"endColumn":67}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/RetrofitAnalysisLinks\n * @description Retroactively injects analysis transparency sections into existing\n * news articles that have matching analysis directories on disk but lack the\n * `<section class=\"analysis-transparency\">` section.\n *\n * This tool scans all articles in `news/`, matches them against available analysis\n * directories under `analysis/daily/{date}/{type}*`, discovers all `.md` analysis\n * files on disk, and injects the rendered section before the `<nav class=\"article-nav\">`\n * element.\n *\n * Usage: npx tsx src/utils/retrofit-analysis-links.ts [--dry-run]\n */\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { NEWS_DIR, ARTICLE_FILENAME_PATTERN } from '../constants/config.js';\nimport { ALL_LANGUAGES } from '../constants/language-core.js';\nimport type { LanguageCode } from '../types/index.js';\nimport { discoverAnalysisFileEntries } from './file-utils.js';\nimport { renderAnalysisTransparencySection } from '../templates/article-template.js';\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst ANALYSIS_BASE_DIR = 'analysis/daily';\n\n/** Regex to detect analysis transparency section already present */\nconst ANALYSIS_SECTION_REGEX = /<section\\s+class=\"analysis-transparency\"/;\n\n/** Regex to match the full analysis transparency section for replacement */\nconst ANALYSIS_SECTION_FULL_REGEX =\n  /\\s*<section\\s+class=\"analysis-transparency\"[\\s\\S]*?<\\/section>/;\n\n/** Injection point — insert the analysis section just before the article-nav */\nconst INJECTION_REGEX = /(\\s*<nav\\s+class=\"article-nav\")/;\n\n// ─── Analysis directory resolution ──────────────────────────────────────────\n\n/**\n * Parse a directory suffix into a numeric priority.\n * Exact match (no suffix) = 0, numeric suffix = its value, runN = N+1000.\n *\n * @param suffixStr - The suffix string from the regex capture, or undefined\n * @returns Numeric priority value\n */\nfunction parseSuffixPriority(suffixStr: string | undefined): number {\n  if (!suffixStr) return 0;\n  if (suffixStr.startsWith('run')) {\n    return parseInt(suffixStr.slice(3), 10) + 1000;\n  }\n  return parseInt(suffixStr, 10);\n}\n\n/**\n * Find the best matching analysis directory for a given date and article type.\n *\n * Checks for exact match first (e.g. `propositions`), then scans for suffixed\n * variants (e.g. `propositions-2`, `propositions-run12`) and returns the one\n * with the highest suffix number.\n *\n * @param date - Article date (YYYY-MM-DD)\n * @param articleType - Article type slug (e.g. 'committee-reports')\n * @returns Absolute path to the best matching analysis directory, or null\n */\nfunction findBestAnalysisDir(date: string, articleType: string): string | null {\n  const dateDir = path.resolve(ANALYSIS_BASE_DIR, date);\n  if (!fs.existsSync(dateDir)) return null;\n\n  try {\n    const entries = fs.readdirSync(dateDir, { withFileTypes: true });\n    const escaped = articleType.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const pattern = new RegExp(`^${escaped}(?:-(\\\\d+|run\\\\d+))?$`);\n\n    let bestPath: string | null = null;\n    let bestSuffix = -1;\n\n    for (const entry of entries) {\n      if (!entry.isDirectory()) continue;\n      const match = pattern.exec(entry.name);\n      if (!match) continue;\n      const suffix = parseSuffixPriority(match[1]);\n      if (suffix > bestSuffix) {\n        bestSuffix = suffix;\n        bestPath = path.join(dateDir, entry.name);\n      }\n    }\n\n    return bestPath;\n  } catch {\n    return null;\n  }\n}\n\n// ─── Article parsing ────────────────────────────────────────────────────────\n\n/**\n * Parse a news article filename into its components.\n *\n * @param filename - Filename like '2026-04-01-propositions-en.html'\n * @returns Parsed components or null if filename doesn't match\n */\nfunction parseArticleComponents(\n  filename: string\n): { date: string; articleType: string; lang: LanguageCode } | null {\n  const match = ARTICLE_FILENAME_PATTERN.exec(filename);\n  if (!match?.[1] || !match[2] || !match[3]) return null;\n\n  const date = match[1];\n  const articleType = match[2];\n  const lang = match[3] as LanguageCode;\n\n  if (!ALL_LANGUAGES.includes(lang)) return null;\n  return { date, articleType, lang };\n}\n\n// ─── Retrofit logic ─────────────────────────────────────────────────────────\n\ninterface RetrofitResult {\n  readonly file: string;\n  readonly analysisDir: string;\n  readonly fileCount: number;\n}\n\n/**\n * Retrofit a single article file with an analysis transparency section.\n *\n * @param filePath - Absolute path to the article HTML file\n * @param date - Article date\n * @param articleType - Article type slug\n * @param lang - Language code\n * @param analysisDirPath - Absolute path to the analysis directory\n * @param dryRun - If true, don't write changes\n * @param force - If true, replace existing analysis sections\n * @returns Retrofit result or null if no changes needed\n */\nfunction retrofitArticle(\n  filePath: string,\n  date: string,\n  articleType: string,\n  lang: LanguageCode,\n  analysisDirPath: string,\n  dryRun: boolean,\n  force: boolean\n): RetrofitResult | null {\n  let html = fs.readFileSync(filePath, 'utf-8');\n\n  const hasExisting = ANALYSIS_SECTION_REGEX.test(html);\n\n  // Skip if already has analysis section (unless force mode)\n  if (hasExisting && !force) return null;\n\n  // In force mode, remove existing section before re-injecting\n  if (hasExisting && force) {\n    html = html.replace(ANALYSIS_SECTION_FULL_REGEX, '');\n  }\n\n  // Find the injection point\n  const injectionMatch = INJECTION_REGEX.exec(html);\n  if (!injectionMatch) {\n    console.warn(`  ⚠️  No injection point found in ${path.basename(filePath)}`);\n    return null;\n  }\n\n  // Discover analysis files on disk\n  const analysisFiles = discoverAnalysisFileEntries(analysisDirPath);\n  const analysisDirName = path.basename(analysisDirPath);\n\n  // Render the analysis section\n  const sectionHtml = renderAnalysisTransparencySection(\n    date,\n    articleType,\n    lang,\n    analysisDirName,\n    analysisFiles.length > 0 ? analysisFiles : undefined\n  );\n\n  // Inject before the article-nav\n  const injectionIndex = injectionMatch.index;\n  const newHtml =\n    html.slice(0, injectionIndex) +\n    '\\n    ' +\n    sectionHtml.trim() +\n    '\\n    ' +\n    html.slice(injectionIndex);\n\n  if (!dryRun) {\n    fs.writeFileSync(filePath, newHtml, 'utf-8');\n  }\n\n  return {\n    file: path.basename(filePath),\n    analysisDir: analysisDirName,\n    fileCount: analysisFiles.length,\n  };\n}\n\n// ─── Main execution ─────────────────────────────────────────────────────────\n\n/**\n * Log the result of a single article retrofit operation.\n *\n * @param result - The retrofit result, or null if skipped\n * @param filename - Article filename\n * @param analysisDirName - Analysis directory name\n * @param dryRun - Whether this is a dry run\n */\nfunction logRetrofitResult(\n  result: RetrofitResult | null,\n  filename: string,\n  analysisDirName: string,\n  dryRun: boolean\n): void {\n  if (!result) return;\n  const prefix = dryRun ? '🔍 Would retrofit' : '✅ Retrofitted';\n  console.log(`  ${prefix}: ${filename} → ${analysisDirName} (${result.fileCount} analysis files)`);\n}\n\n/**\n * Process a single article group (one date+type with all its language variants).\n *\n * @param group - The article group containing date, type, and language variants\n * @param group.date - Article date\n * @param group.articleType - Article type slug\n * @param group.files - Array of filename + language code tuples\n * @param newsDir - Absolute path to the news directory\n * @param dryRun - Whether to skip writing changes\n * @param force - Whether to replace existing analysis sections\n * @returns Count of retrofitted, skipped, and errored articles\n */\nfunction processArticleGroup(\n  group: {\n    date: string;\n    articleType: string;\n    files: Array<{ filename: string; lang: LanguageCode }>;\n  },\n  newsDir: string,\n  dryRun: boolean,\n  force: boolean\n): { total: number; retrofitted: number; skipped: number; errors: number } {\n  const analysisDirPath = findBestAnalysisDir(group.date, group.articleType);\n  if (!analysisDirPath) return { total: 0, retrofitted: 0, skipped: 0, errors: 0 };\n\n  const analysisDirName = path.basename(analysisDirPath);\n  let total = 0;\n  let retrofitted = 0;\n  let skipped = 0;\n  let errors = 0;\n\n  for (const { filename, lang } of group.files) {\n    total++;\n    try {\n      const result = retrofitArticle(\n        path.join(newsDir, filename),\n        group.date,\n        group.articleType,\n        lang,\n        analysisDirPath,\n        dryRun,\n        force\n      );\n      if (result) {\n        retrofitted++;\n        logRetrofitResult(result, filename, analysisDirName, dryRun);\n      } else {\n        skipped++;\n      }\n    } catch (err) {\n      errors++;\n      console.error(\n        `  ❌ Error processing ${filename}: ${err instanceof Error ? err.message : String(err)}`\n      );\n    }\n  }\n\n  return { total, retrofitted, skipped, errors };\n}\n\n/**\n * Retrofit all articles that have matching analysis directories.\n *\n * @param dryRun - If true, report what would be changed without writing\n * @param force - If true, replace existing analysis sections\n * @returns Summary statistics\n */\nexport function retrofitAllArticles(\n  dryRun: boolean = false,\n  force: boolean = false\n): {\n  total: number;\n  retrofitted: number;\n  skipped: number;\n  errors: number;\n} {\n  const newsDir = path.resolve(NEWS_DIR);\n  if (!fs.existsSync(newsDir)) {\n    console.log('📁 News directory does not exist');\n    return { total: 0, retrofitted: 0, skipped: 0, errors: 0 };\n  }\n\n  const files = fs.readdirSync(newsDir).filter((f) => f.endsWith('.html'));\n  const counts = { total: 0, retrofitted: 0, skipped: 0, errors: 0 };\n\n  // Group by date+type to avoid redundant analysis dir lookups\n  const articleGroups = new Map<\n    string,\n    { date: string; articleType: string; files: Array<{ filename: string; lang: LanguageCode }> }\n  >();\n\n  for (const filename of files) {\n    const parsed = parseArticleComponents(filename);\n    if (!parsed) continue;\n\n    const key = `${parsed.date}/${parsed.articleType}`;\n    const group = articleGroups.get(key);\n    if (group) {\n      group.files.push({ filename, lang: parsed.lang });\n    } else {\n      articleGroups.set(key, {\n        date: parsed.date,\n        articleType: parsed.articleType,\n        files: [{ filename, lang: parsed.lang }],\n      });\n    }\n  }\n\n  for (const [, group] of articleGroups) {\n    const groupResult = processArticleGroup(group, newsDir, dryRun, force);\n    counts.total += groupResult.total;\n    counts.retrofitted += groupResult.retrofitted;\n    counts.skipped += groupResult.skipped;\n    counts.errors += groupResult.errors;\n  }\n\n  return counts;\n}\n\n// ─── CLI entry point ────────────────────────────────────────────────────────\n\nconst isDryRun = process.argv.includes('--dry-run');\nconst isForce = process.argv.includes('--force');\n\nconsole.log('');\nconsole.log('🔗 Analysis Transparency Retrofit Tool');\nconsole.log(\n  `   Mode: ${isDryRun ? 'DRY RUN (no files will be modified)' : 'LIVE (files will be modified)'}${isForce ? ' [FORCE: replacing existing sections]' : ''}`\n);\nconsole.log('');\n\nconst result = retrofitAllArticles(isDryRun, isForce);\n\nconsole.log('');\nconsole.log('📊 Summary:');\nconsole.log(`   Total articles with analysis dirs: ${result.total}`);\nconsole.log(`   Retrofitted: ${result.retrofitted}`);\nconsole.log(`   Already had section (skipped): ${result.skipped}`);\nconsole.log(`   Errors: ${result.errors}`);\nconsole.log('');\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/significance-scoring.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":251,"column":15,"endLine":251,"endColumn":24},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":345,"column":21,"endLine":345,"endColumn":30}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/SignificanceScoring\n * @description 5-dimension composite significance scoring engine for EP events.\n *\n * Scores each event across five dimensions (0–10 each) and computes a weighted\n * composite score used for publication prioritisation decisions:\n *\n * | Dimension                  | Weight |\n * |----------------------------|--------|\n * | Parliamentary Significance | 0.25   |\n * | Policy Impact              | 0.25   |\n * | Public Interest            | 0.20   |\n * | Temporal Urgency           | 0.15   |\n * | Institutional Relevance    | 0.15   |\n *\n * Decision thresholds follow the analysis template:\n *\n * | Composite | Decision |\n * |-----------|----------|\n * | 0.0 – 3.4 | skip     |\n * | 3.5 – 5.4 | hold     |\n * | ≥ 5.5     | publish  |\n *\n * @see analysis/templates/significance-scoring.md\n */\n\nimport type {\n  SignificanceScore,\n  SignificanceScoringInput,\n  SignificanceBatchResult,\n  PublicationDecision,\n  ComparativeSignificance,\n  SignificanceTrend,\n} from '../types/significance.js';\n\n// ─── Markdown sanitization ────────────────────────────────────────────────────\n\n/**\n * Sanitize untrusted text for safe use in a Markdown table cell.\n *\n * Escapes pipe characters, backslashes, and HTML entities, then normalizes\n * whitespace to prevent table layout corruption from external EP data.\n *\n * @param input - Untrusted cell text\n * @returns Sanitized text safe for Markdown table cells\n */\nfunction sanitizeMdCell(input: string): string {\n  return input\n    .replace(/\\\\/gu, '\\\\\\\\')\n    .replace(/\\|/gu, '\\\\|')\n    .replace(/&/gu, '&amp;')\n    .replace(/</gu, '&lt;')\n    .replace(/>/gu, '&gt;')\n    .replace(/[\\r\\n]+/gu, ' ')\n    .trim();\n}\n\n/**\n * Normalize a reference string: treat empty / whitespace-only values as\n * missing so that the table cell shows a placeholder instead of blank.\n *\n * @param ref - Optional reference string\n * @returns The trimmed reference, or undefined if empty/missing\n */\nfunction normalizeRef(ref: string | undefined): string | undefined {\n  if (!ref) return undefined;\n  const trimmed = ref.trim();\n  return trimmed.length > 0 ? trimmed : undefined;\n}\n\n// ─── Scoring constants ────────────────────────────────────────────────────────\n\n/** Weight applied to Parliamentary Significance dimension */\nexport const WEIGHT_PARLIAMENTARY = 0.25;\n/** Weight applied to Policy Impact dimension */\nexport const WEIGHT_POLICY = 0.25;\n/** Weight applied to Public Interest dimension */\nexport const WEIGHT_PUBLIC_INTEREST = 0.2;\n/** Weight applied to Temporal Urgency dimension */\nexport const WEIGHT_URGENCY = 0.15;\n/** Weight applied to Institutional / Cross-Group Relevance dimension */\nexport const WEIGHT_INSTITUTIONAL = 0.15;\n\n/** Minimum score floor (dimension and composite) */\nconst SCORE_MIN = 0;\n/** Maximum score ceiling (dimension and composite) */\nconst SCORE_MAX = 10;\n\n/** Composite score at or above which the decision is \"publish\" */\nexport const THRESHOLD_PUBLISH = 5.5;\n/** Composite score at or above which the decision is \"hold\" (below publish) */\nexport const THRESHOLD_HOLD = 3.5;\n\n// ─── Helpers ──────────────────────────────────────────────────────────────────\n\n/**\n * Clamp a numeric value to the 0–10 scoring range.\n *\n * @param value - Raw numeric input\n * @returns Value clamped to [0, 10]\n */\nexport function clampScore(value: number): number {\n  if (!Number.isFinite(value)) return SCORE_MIN;\n  return Math.min(SCORE_MAX, Math.max(SCORE_MIN, value));\n}\n\n/**\n * Derive a publication decision from a composite score.\n *\n * @param composite - Weighted composite score (0–10)\n * @returns Publication decision\n */\nexport function deriveDecision(composite: number): PublicationDecision {\n  if (composite >= THRESHOLD_PUBLISH) return 'publish';\n  if (composite >= THRESHOLD_HOLD) return 'hold';\n  return 'skip';\n}\n\n// ─── Core scoring ─────────────────────────────────────────────────────────────\n\n/**\n * Compute a composite significance score for a single event.\n *\n * All dimension values are clamped to [0, 10].  The composite is the\n * weighted average using the standard template weights.\n *\n * @param input - Dimension scores for one event\n * @returns Significance score with composite and publication decision\n */\nexport function scoreSignificance(input: SignificanceScoringInput): SignificanceScore {\n  const parliamentarySignificance = clampScore(input.parliamentarySignificance);\n  const policyImpact = clampScore(input.policyImpact);\n  const publicInterest = clampScore(input.publicInterest);\n  const temporalUrgency = clampScore(input.temporalUrgency);\n  const institutionalRelevance = clampScore(input.institutionalRelevance);\n\n  const composite =\n    parliamentarySignificance * WEIGHT_PARLIAMENTARY +\n    policyImpact * WEIGHT_POLICY +\n    publicInterest * WEIGHT_PUBLIC_INTEREST +\n    temporalUrgency * WEIGHT_URGENCY +\n    institutionalRelevance * WEIGHT_INSTITUTIONAL;\n\n  const roundedComposite = Math.round(composite * 100) / 100;\n\n  return {\n    parliamentarySignificance,\n    policyImpact,\n    publicInterest,\n    temporalUrgency,\n    institutionalRelevance,\n    composite: roundedComposite,\n    decision: deriveDecision(roundedComposite),\n  };\n}\n\n/**\n * Score a batch of events and return ranked results with a summary.\n *\n * Events are scored individually then sorted by composite score descending.\n *\n * @param inputs - Array of event scoring inputs\n * @returns Batch result with ranked scores and decision summary counts\n */\nexport function scoreBatch(inputs: readonly SignificanceScoringInput[]): SignificanceBatchResult {\n  const scores = inputs.map(scoreSignificance);\n\n  // Sort by composite descending (stable sort preserves input order for ties)\n  const ranked = [...scores].sort((a, b) => b.composite - a.composite);\n\n  const summary = { publish: 0, hold: 0, skip: 0 };\n  for (const s of ranked) {\n    summary[s.decision]++;\n  }\n\n  return { scores: ranked, summary };\n}\n\n/**\n * Generate a markdown report for a single significance score.\n *\n * Produces a table matching the template format with dimension breakdown,\n * composite calculation, and publication decision.\n *\n * @param score - Computed significance score\n * @param title - Event title\n * @param reference - Optional EP reference identifier\n * @returns Markdown string\n */\nexport function formatScoreMarkdown(\n  score: SignificanceScore,\n  title: string,\n  reference?: string\n): string {\n  const safeTitle = sanitizeMdCell(title);\n  const safeRef = normalizeRef(reference);\n  const refLine = safeRef ? `| **EP Reference** | \\`${sanitizeMdCell(safeRef)}\\` |\\n` : '';\n  const decisionEmoji =\n    score.decision === 'publish' ? '📰' : score.decision === 'hold' ? '📋' : '🗄️';\n  const decisionLabel =\n    score.decision === 'publish' ? 'Publish' : score.decision === 'hold' ? 'Hold' : 'Skip';\n\n  return `### ${safeTitle}\n\n| Field | Value |\n|-------|-------|\n| **Event** | ${safeTitle} |\n${refLine}\n| Dimension | Raw Score | Weight | Weighted Score |\n|-----------|:---------:|:------:|:--------------:|\n| Parliamentary Significance | ${score.parliamentarySignificance.toFixed(1)} | ${WEIGHT_PARLIAMENTARY} | ${(score.parliamentarySignificance * WEIGHT_PARLIAMENTARY).toFixed(2)} |\n| Policy Impact | ${score.policyImpact.toFixed(1)} | ${WEIGHT_POLICY} | ${(score.policyImpact * WEIGHT_POLICY).toFixed(2)} |\n| Public Interest | ${score.publicInterest.toFixed(1)} | ${WEIGHT_PUBLIC_INTEREST} | ${(score.publicInterest * WEIGHT_PUBLIC_INTEREST).toFixed(2)} |\n| Temporal Urgency | ${score.temporalUrgency.toFixed(1)} | ${WEIGHT_URGENCY} | ${(score.temporalUrgency * WEIGHT_URGENCY).toFixed(2)} |\n| Institutional Relevance | ${score.institutionalRelevance.toFixed(1)} | ${WEIGHT_INSTITUTIONAL} | ${(score.institutionalRelevance * WEIGHT_INSTITUTIONAL).toFixed(2)} |\n| **COMPOSITE SCORE** | — | — | **${score.composite.toFixed(2)} / 10** |\n\n**Decision:** ${decisionEmoji} **${decisionLabel}**\n`;\n}\n\n/**\n * Generate a batch scoring markdown table.\n *\n * Produces the Section 2 batch table from the template format.\n * Scores must be in the same order as inputs (one score per input).\n *\n * @param inputs - Scoring inputs with titles and references\n * @param scores - Pre-computed significance scores (same order as inputs)\n * @returns Markdown table string\n */\nexport function formatBatchMarkdown(\n  inputs: readonly SignificanceScoringInput[],\n  scores: readonly SignificanceScore[]\n): string {\n  const header =\n    '| Event | EP Reference | Parl. | Policy | Public | Urgency | Instit. | **Composite** | Decision |';\n  const separator =\n    '|-------|-------------|:-----:|:------:|:------:|:-------:|:-------:|:-------------:|----------|';\n\n  if (inputs.length !== scores.length) {\n    throw new Error(\n      `formatBatchMarkdown: inputs.length (${inputs.length}) !== scores.length (${scores.length}). Arrays must be aligned.`\n    );\n  }\n\n  const rows = inputs.map((input, i) => {\n    const s = scores[i] as SignificanceScore;\n    const decisionLabel =\n      s.decision === 'publish' ? 'Publish' : s.decision === 'hold' ? 'Hold' : 'Skip';\n    const safeTitle = sanitizeMdCell(input.title);\n    const safeRef = normalizeRef(input.reference);\n    return `| ${safeTitle} | ${safeRef ? sanitizeMdCell(safeRef) : '—'} | ${s.parliamentarySignificance.toFixed(1)} | ${s.policyImpact.toFixed(1)} | ${s.publicInterest.toFixed(1)} | ${s.temporalUrgency.toFixed(1)} | ${s.institutionalRelevance.toFixed(1)} | **${s.composite.toFixed(2)}** | ${decisionLabel} |`;\n  });\n\n  return [header, separator, ...rows].join('\\n');\n}\n\n// ─── Comparative & Trend Scoring ──────────────────────────────────────────────\n\n/**\n * Compute comparative significance for a single item within a peer group.\n *\n * Ranks the target score among all peers, computes a percentile position,\n * and compares against the peer average. Peers are sorted descending by\n * composite score; rank 1 = highest composite.\n *\n * @param target - The significance score of the item to rank\n * @param peers - All scores in the comparison group (including the target)\n * @returns ComparativeSignificance result with rank, percentile, and average\n */\nexport function computeComparativeSignificance(\n  target: SignificanceScore,\n  peers: readonly SignificanceScore[]\n): ComparativeSignificance {\n  if (peers.length === 0) {\n    return {\n      rank: 1,\n      total: 1,\n      percentile: 100,\n      aboveAverage: true,\n      peerAverage: target.composite,\n    };\n  }\n\n  const total = peers.length;\n  const peerAverage =\n    Math.round((peers.reduce((sum, p) => sum + p.composite, 0) / total) * 100) / 100;\n\n  // Count peers with strictly higher composite score for rank (1-based)\n  const strictlyHigher = peers.filter((p) => p.composite > target.composite).length;\n  const rank = strictlyHigher + 1;\n\n  // Percentile: 0 = lowest, 100 = highest.\n  // Handle tied extremes explicitly so all highest-scoring peers receive 100\n  // and all lowest-scoring peers receive 0.\n  const strictlyLower = peers.filter((p) => p.composite < target.composite).length;\n  const percentile =\n    total <= 1\n      ? 100\n      : strictlyHigher === 0\n        ? 100\n        : strictlyLower === 0\n          ? 0\n          : Math.round((strictlyLower / (total - 1)) * 100);\n\n  return {\n    rank,\n    total,\n    percentile,\n    aboveAverage: target.composite > peerAverage,\n    peerAverage,\n  };\n}\n\n/**\n * Detect significance trend from a sequence of composite scores over time.\n *\n * Computes the average signed change per step to determine whether\n * significance is increasing, decreasing, or stable. At least 2 data points\n * are required; for 1 or 0, the trend is 'stable' with 'low' confidence.\n *\n * Confidence is calibrated by data volume:\n * - ≥ 5 data points → high\n * - 3–4 data points → medium\n * - < 3 data points → low\n *\n * A trend is considered 'stable' when |averageChange| ≤ 0.1.\n *\n * @param scores - Ordered sequence of composite scores (oldest → newest)\n * @returns SignificanceTrend with direction, average change, and confidence\n */\nexport function detectSignificanceTrend(scores: readonly number[]): SignificanceTrend {\n  const dataPoints = scores.length;\n\n  if (dataPoints < 2) {\n    return { direction: 'stable', averageChange: 0, confidence: 'low', dataPoints };\n  }\n\n  let totalChange = 0;\n  for (let i = 1; i < dataPoints; i++) {\n    totalChange += (scores[i] as number) - (scores[i - 1] as number);\n  }\n  const averageChange = Math.round((totalChange / (dataPoints - 1)) * 1000) / 1000;\n\n  let direction: SignificanceTrend['direction'];\n  if (averageChange > 0.1) direction = 'increasing';\n  else if (averageChange < -0.1) direction = 'decreasing';\n  else direction = 'stable';\n\n  let confidence: SignificanceTrend['confidence'];\n  if (dataPoints >= 5) confidence = 'high';\n  else if (dataPoints >= 3) confidence = 'medium';\n  else confidence = 'low';\n\n  return { direction, averageChange, confidence, dataPoints };\n}\n\n/**\n * Compute a novelty bonus (0 or 5) for items appearing for the first time in\n * a monitoring window.\n *\n * An item is considered novel when its identifier does not appear in the set\n * of previously seen identifiers. Novel items receive a bonus equal to half\n * the maximum score, calibrated to reward fresh intelligence signals.\n *\n * @param itemId - Unique identifier for the item being scored\n * @param previouslySeenIds - Set of identifiers already observed in the window\n * @returns Novelty bonus value (0 = previously seen, 5 = novel)\n */\nexport function computeNoveltyBonus(\n  itemId: string,\n  previouslySeenIds: ReadonlySet<string>\n): number {\n  return previouslySeenIds.has(itemId) ? 0 : 5;\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/validate-analysis-completeness.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":84,"column":17,"endLine":84,"endColumn":49},{"ruleId":"security/detect-object-injection","severity":1,"message":"Variable Assigned to Object Injection Sink","line":899,"column":21,"endLine":899,"endColumn":50},{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":902,"column":20,"endLine":905,"endColumn":6}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/ValidateAnalysisCompleteness\n * @description Pre-article-generation blocking gate that enforces\n * `analysis/methodologies/ai-driven-analysis-guide.md` §Reference-Quality Depth\n * Requirements and Rule 19 (Mandatory Pre-Flight Analysis Reading).\n *\n * This validator is the hard precondition that agentic news workflows MUST pass\n * before invoking any article generator. It verifies that the analysis run\n * directory contains the mandatory intelligence artifacts with sufficient depth,\n * no placeholder markers, and a well-formed `manifest.json` listing every\n * artifact under `files.*`.\n *\n * Exit codes:\n * - 0 — all mandatory artifacts present, each ≥ `--min-lines` (default 30),\n *       no placeholder markers, manifest lists every on-disk artifact.\n * - 1 — one or more mandatory artifacts missing, too short, contain\n *       placeholder markers, or manifest omits an on-disk artifact.\n * - 2 — usage error (missing `--analysis-dir`, unreadable directory, invalid\n *       `manifest.json`, etc.).\n *\n * Usage:\n *   npx tsx src/utils/validate-analysis-completeness.ts --analysis-dir=analysis/daily/2026-04-18/breaking-run184\n *   npx tsx src/utils/validate-analysis-completeness.ts --analysis-dir=<dir> --article-type=week-in-review\n *   npx tsx src/utils/validate-analysis-completeness.ts --analysis-dir=<dir> --json\n */\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { PROJECT_ROOT } from '../constants/config.js';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\n/** Minimum line count below which an artifact is considered a stub */\nconst DEFAULT_MIN_LINES = 30;\n\n/**\n * Load the Rule 22 per-artifact threshold catalogue for a given article type.\n *\n * Returns a `Map<relativePath, minLines>` containing every per-file floor\n * defined under `thresholds.<articleType>` in\n * `analysis/methodologies/reference-quality-thresholds.json`. When the\n * catalogue file is missing, unreadable, malformed, or lacks an entry for the\n * article type, an empty map is returned and the caller's flat\n * `DEFAULT_MIN_LINES` floor applies to every artifact.\n *\n * This load is deliberately tolerant: a missing catalogue must not break\n * existing article types whose depth is still enforced only by the flat floor.\n *\n * @param articleType - Article category slug (e.g. `breaking`).\n * @param overrideFile - Optional absolute path to a thresholds JSON file,\n *                       overriding the default `THRESHOLDS_FILE`. Intended for\n *                       tests that need fixture thresholds without touching the\n *                       repo-wide catalogue.\n * @returns Map from `relativePath` → per-file `minLines` threshold.\n */\nfunction loadPerArtifactThresholds(\n  articleType: string,\n  overrideFile?: string\n): ReadonlyMap<string, number> {\n  // `overrideFile` may be absolute or relative. Relative paths are resolved\n  // against `PROJECT_ROOT` (matching how `--analysis-dir` is resolved) so that\n  // callers invoking the CLI from any working directory get consistent\n  // behaviour; otherwise the file is silently treated as missing and Rule 22\n  // floors fall back to the flat `--min-lines` value.\n  let file: string;\n  if (overrideFile === undefined) {\n    file = THRESHOLDS_FILE;\n  } else if (path.isAbsolute(overrideFile)) {\n    file = overrideFile;\n  } else {\n    file = path.join(PROJECT_ROOT, overrideFile);\n  }\n  if (!fs.existsSync(file)) return new Map();\n  let parsed: ReferenceQualityThresholds;\n  try {\n    parsed = JSON.parse(fs.readFileSync(file, 'utf-8')) as ReferenceQualityThresholds;\n  } catch {\n    return new Map();\n  }\n  const entry = parsed.thresholds?.[articleType];\n  if (!entry || typeof entry !== 'object') return new Map();\n  const result = new Map<string, number>();\n  for (const [rel, n] of Object.entries(entry)) {\n    if (typeof n === 'number' && Number.isFinite(n) && n > 0) {\n      result.set(rel, n);\n    }\n  }\n  return result;\n}\n\n/**\n * Resolve the effective `minLines` floor for a specific artifact.\n *\n * When a Rule 22 per-artifact threshold is defined for the active\n * `articleType`, the effective floor is `max(perArtifactFloor, flatFallback)`\n * so `--min-lines` can raise (but never silently lower) a per-artifact floor.\n * This keeps behaviour consistent between required-set artifacts and\n * supplemental (manifest-listed) artifacts — both paths apply the same rule.\n *\n * @param relPath - Artifact path relative to the run directory.\n * @param perArtifact - Per-artifact threshold map for the active article type.\n * @param fallback - Flat floor supplied by the CLI (`--min-lines`, default\n *                   `DEFAULT_MIN_LINES`). Used directly when no per-artifact\n *                   entry exists; otherwise combined via `max` with the\n *                   per-artifact entry.\n * @returns Effective `minLines` threshold.\n */\nfunction effectiveMinLines(\n  relPath: string,\n  perArtifact: ReadonlyMap<string, number>,\n  fallback: number\n): number {\n  const configured = perArtifact.get(relPath);\n  if (configured === undefined) return fallback;\n  return Math.max(configured, fallback);\n}\n\n/** Placeholder markers that indicate an incomplete analysis artifact */\nconst PLACEHOLDER_MARKERS: readonly string[] = [\n  '[AI_ANALYSIS_REQUIRED]',\n  'AI_ANALYSIS_PENDING',\n  '[TO BE FILLED BY AI AGENT]',\n  '[TBD]',\n  'TODO:',\n] as const;\n\n/**\n * Location of the Rule 22 per-artifact depth-floor catalogue.\n * When present, per-artifact thresholds defined here override the flat\n * `DEFAULT_MIN_LINES` floor for matching `articleType × relativePath` tuples.\n */\nconst THRESHOLDS_FILE = path.join(\n  PROJECT_ROOT,\n  'analysis',\n  'methodologies',\n  'reference-quality-thresholds.json'\n);\n\n/**\n * Rule 22 threshold catalogue shape. Keys under `thresholds.<articleType>`\n * map artifact `relativePath` strings (e.g. `intelligence/pestle-analysis.md`)\n * to minimum line-count floors. Missing entries fall back to `DEFAULT_MIN_LINES`.\n */\ninterface ReferenceQualityThresholds {\n  version?: string;\n  description?: string;\n  thresholds?: Record<string, Record<string, number>>;\n}\n\n/**\n * The seven reference-quality intelligence artifacts per\n * `analysis/methodologies/ai-driven-analysis-guide.md` §Reference-Quality Depth\n * Requirements (basis: breaking-run184).\n */\nconst REFERENCE_QUALITY_INTELLIGENCE: readonly string[] = [\n  'intelligence/pestle-analysis.md',\n  'intelligence/stakeholder-map.md',\n  'intelligence/scenario-forecast.md',\n  'intelligence/threat-model.md',\n  'intelligence/historical-baseline.md',\n  'intelligence/economic-context.md',\n  'intelligence/wildcards-blackswans.md',\n] as const;\n\n/**\n * Artifacts required on top of the reference-quality seven.\n * These provide the pre-flight entry point (analysis-index) and the\n * composition layer (synthesis-summary) per Rule 19.\n */\nconst COMMON_REQUIRED: readonly string[] = [\n  'intelligence/analysis-index.md',\n  'intelligence/synthesis-summary.md',\n] as const;\n\n/**\n * Per-article-type additional mandatory artifacts.\n * Weekly / monthly reviews require a historical-baseline (already in the seven);\n * breaking additionally requires coalition-dynamics and an MCP reliability audit\n * during plenary-recess windows when API availability is degraded.\n */\nconst ARTICLE_TYPE_EXTRAS: Record<string, readonly string[]> = {\n  breaking: ['intelligence/coalition-dynamics.md'],\n  'week-in-review': [],\n  'month-in-review': [],\n  'week-ahead': [],\n  'month-ahead': [],\n  'committee-reports': [],\n  motions: [],\n  propositions: [],\n};\n\ninterface CliOptions {\n  analysisDir: string;\n  articleType?: string | undefined;\n  minLines: number;\n  /** Optional override for the Rule 22 thresholds file (used by tests). */\n  thresholdsFile?: string | undefined;\n  json: boolean;\n  warnOnly: boolean;\n  /** Optional article HTML paths to scan for fallback-template leaks. */\n  articleHtmlPaths: string[];\n}\n\ninterface ArtifactCheck {\n  relativePath: string;\n  present: boolean;\n  lineCount: number;\n  /** Effective `minLines` floor applied to this artifact (Rule 22). */\n  minLines: number;\n  placeholdersFound: readonly string[];\n  listedInManifest: boolean;\n}\n\ninterface ValidationResult {\n  analysisDir: string;\n  articleType: string;\n  required: readonly string[];\n  checks: readonly ArtifactCheck[];\n  orphanedOnDisk: readonly string[];\n  manifestValid: boolean;\n  manifestErrors: readonly string[];\n  passed: boolean;\n  errorCount: number;\n}\n\n// ─── CLI parsing ──────────────────────────────────────────────────────────────\n\n/**\n * Apply a single CLI argument token to an in-progress options object.\n *\n * @param arg - The raw CLI token.\n * @param opts - Mutable options being built.\n * @returns `true` when the arg is recognised, `false` otherwise.\n */\nfunction applyArg(arg: string, opts: CliOptions): boolean {\n  if (arg.startsWith('--analysis-dir=')) {\n    opts.analysisDir = arg.slice('--analysis-dir='.length);\n    return true;\n  }\n  if (arg.startsWith('--article-type=')) {\n    opts.articleType = arg.slice('--article-type='.length);\n    return true;\n  }\n  if (arg.startsWith('--min-lines=')) {\n    const parsed = parseInt(arg.slice('--min-lines='.length), 10);\n    if (Number.isFinite(parsed) && parsed > 0) opts.minLines = parsed;\n    return true;\n  }\n  if (arg.startsWith('--thresholds-file=')) {\n    opts.thresholdsFile = arg.slice('--thresholds-file='.length);\n    return true;\n  }\n  if (arg.startsWith('--article-html=')) {\n    opts.articleHtmlPaths.push(arg.slice('--article-html='.length));\n    return true;\n  }\n  if (arg === '--json') {\n    opts.json = true;\n    return true;\n  }\n  if (arg === '--warn-only') {\n    opts.warnOnly = true;\n    return true;\n  }\n  return false;\n}\n\n/**\n * Parse command-line arguments into a `CliOptions` record.\n *\n * @param argv - CLI arguments excluding the `node` + script entries.\n * @returns Parsed options; exits with code 2 if required args are missing.\n */\nfunction parseArgs(argv: readonly string[]): CliOptions {\n  const opts: CliOptions = {\n    analysisDir: '',\n    minLines: DEFAULT_MIN_LINES,\n    json: false,\n    warnOnly: false,\n    articleHtmlPaths: [],\n  };\n\n  for (const arg of argv) {\n    if (arg === '--help' || arg === '-h') {\n      printHelp();\n      process.exit(0);\n    }\n    applyArg(arg, opts);\n  }\n\n  if (!opts.analysisDir && opts.articleHtmlPaths.length === 0) {\n    console.error('❌ Missing required argument: --analysis-dir=<path> or --article-html=<path>');\n    printHelp();\n    process.exit(2);\n  }\n\n  return opts;\n}\n\nfunction printHelp(): void {\n  console.log(`\nvalidate-analysis-completeness — pre-article-generation blocking gate\n\nUsage:\n  npx tsx src/utils/validate-analysis-completeness.ts \\\\\n      --analysis-dir=analysis/daily/<date>/<type>-run<id> \\\\\n      [--article-type=<slug>] \\\\\n      [--min-lines=30] \\\\\n      [--article-html=<path>]... \\\\\n      [--json] \\\\\n      [--warn-only]\n\nOptions:\n  --analysis-dir=<path>    Run directory to validate (required unless only\n                           --article-html is used). Resolved relative to\n                           PROJECT_ROOT.\n  --article-type=<slug>    Article category slug (breaking, week-in-review, …).\n                           When omitted, inferred from manifest.json.\n  --min-lines=<n>          Minimum line count per artifact (default 30).\n                           Used as fallback when no Rule 22 per-artifact\n                           threshold is defined for this article type × path.\n  --thresholds-file=<path> Override the Rule 22 thresholds catalogue (default:\n                           analysis/methodologies/reference-quality-thresholds.json).\n                           Primarily for tests.\n  --article-html=<path>    Scan a rendered HTML article for AI-First fallback-\n                           template leaks (AI_MARKER sentinels, generic\n                           stakeholder-reasoning phrases, date-only topic\n                           placeholders). May be repeated. Each file is\n                           checked independently and any match fails the run.\n  --json                   Emit a JSON report on stdout instead of text.\n  --warn-only              Exit 0 on validation failure (report only). Use for\n                           local exploration; workflows MUST NOT pass this flag.\n\nExit codes:\n  0 = all mandatory artifacts present, no placeholders, manifest consistent,\n      and (when --article-html is given) no fallback-template leaks in HTML\n  1 = validation failed\n  2 = usage error (missing args, unreadable dir, invalid manifest)\n`);\n}\n\n// ─── Manifest handling ────────────────────────────────────────────────────────\n\ninterface ManifestFiles {\n  classification?: readonly string[];\n  risk_scoring?: readonly string[];\n  intelligence?: readonly string[];\n  documents?: readonly string[];\n  [key: string]: readonly string[] | undefined;\n}\n\ninterface Manifest {\n  runId?: string | number;\n  date?: string;\n  articleType?: string;\n  files?: ManifestFiles | Record<string, string>;\n}\n\ninterface ParsedManifest {\n  raw: Manifest;\n  allListedPaths: readonly string[];\n  errors: readonly string[];\n}\n\n/**\n * Extract all analysis file paths from the manifest's `files` field.\n * Supports two shapes: nested `{ intelligence: [...] }` or flat `{ \"path\": \"desc\" }`.\n *\n * @param filesField - The `files` object from manifest.json.\n * @returns Array of relative artifact paths listed in the manifest.\n */\nfunction extractListedPaths(filesField: Manifest['files']): readonly string[] {\n  if (!filesField || typeof filesField !== 'object') return [];\n\n  const allListed: string[] = [];\n  const firstValue = Object.values(filesField)[0];\n\n  if (Array.isArray(firstValue)) {\n    // Nested shape: { category: string[] }\n    for (const arr of Object.values(filesField as ManifestFiles)) {\n      if (!Array.isArray(arr)) continue;\n      for (const rel of arr) {\n        if (typeof rel === 'string') allListed.push(rel);\n      }\n    }\n    return allListed;\n  }\n\n  if (firstValue !== undefined) {\n    // Flat shape: { \"path\": \"description\" }\n    return Object.keys(filesField);\n  }\n\n  return allListed;\n}\n\n/**\n * Load and parse `manifest.json` from a run directory, returning any schema\n * errors and the set of listed artifact paths.\n *\n * @param runDir - Absolute path to the analysis run directory.\n * @returns Parsed manifest, list of artifact paths, and any schema errors.\n */\nfunction loadManifest(runDir: string): ParsedManifest {\n  const manifestPath = path.join(runDir, 'manifest.json');\n  const errors: string[] = [];\n\n  if (!fs.existsSync(manifestPath)) {\n    return { raw: {}, allListedPaths: [], errors: ['manifest.json is missing'] };\n  }\n\n  let raw: Manifest;\n  try {\n    const text = fs.readFileSync(manifestPath, 'utf-8');\n    raw = JSON.parse(text) as Manifest;\n  } catch (err) {\n    return {\n      raw: {},\n      allListedPaths: [],\n      errors: [`manifest.json is not valid JSON: ${(err as Error).message}`],\n    };\n  }\n\n  if (!raw.articleType || typeof raw.articleType !== 'string') {\n    errors.push('manifest.json is missing top-level \"articleType\" (Rule 6)');\n  }\n\n  const filesField = raw.files;\n  if (!filesField || typeof filesField !== 'object') {\n    errors.push('manifest.json is missing \"files\" object');\n    return { raw, allListedPaths: [], errors };\n  }\n\n  return { raw, allListedPaths: extractListedPaths(filesField), errors };\n}\n\n// ─── Artifact inspection ─────────────────────────────────────────────────────\n\n/**\n * Read and inspect a single artifact, producing the data needed by the\n * aggregate pass/fail logic in `countErrors` / `artifactIssues`.\n *\n * @param runDir - Absolute path to the analysis run directory.\n * @param relPath - Path relative to `runDir` of the artifact to inspect.\n * @param listedInManifest - Whether the artifact appears under `manifest.files.*`.\n * @param minLines - Effective `minLines` floor (Rule 22 per-artifact threshold\n *                   when defined for this path, or the flat fallback otherwise).\n * @returns Presence, line count, placeholder findings, and manifest-listing flag.\n */\nfunction inspectArtifact(\n  runDir: string,\n  relPath: string,\n  listedInManifest: boolean,\n  minLines: number\n): ArtifactCheck {\n  const abs = path.join(runDir, relPath);\n  if (!fs.existsSync(abs)) {\n    return {\n      relativePath: relPath,\n      present: false,\n      lineCount: 0,\n      minLines,\n      placeholdersFound: [],\n      listedInManifest,\n    };\n  }\n\n  const text = fs.readFileSync(abs, 'utf-8');\n  const lines = text.split('\\n');\n  const lineCount = lines.length;\n  const placeholders = findUnfilledPlaceholders(lines);\n\n  // NOTE: `lineCount < minLines` is intentionally not flagged here — the caller\n  // (`countErrors` / `artifactIssues`) is the single source of truth for\n  // short-file failures so the validator can report them with the correct\n  // formatting and exit semantics.\n\n  return {\n    relativePath: relPath,\n    present: true,\n    lineCount,\n    minLines,\n    placeholdersFound: placeholders,\n    listedInManifest,\n  };\n}\n\n/**\n * Tests whether a given line is a meta-documentation reference to the placeholder\n * marker (rather than a real unfilled slot). Table rows, negation sentences, and\n * backtick-quoted marker names are considered documentation.\n *\n * @param raw - Raw line from the artifact file (with original indentation).\n * @param trimmed - The same line after leading whitespace is stripped.\n * @param marker - The placeholder marker being checked.\n * @returns `true` when the line describes the marker rather than requiring it.\n */\nfunction isMetaDocumentationLine(raw: string, trimmed: string, marker: string): boolean {\n  if (trimmed.startsWith('|')) return true;\n  if (/\\b(zero|no|none|without|absent|replaced|replace every)\\b/i.test(trimmed)) return true;\n  if (raw.includes('`' + marker + '`')) return true;\n  return false;\n}\n\n/**\n * Detect placeholder markers that represent an unfilled content slot, while\n * ignoring lines where the marker is referenced in a meta-documentation context\n * (e.g. \"Zero [AI_ANALYSIS_REQUIRED] markers\", table rows that document absence,\n * or code fences quoting the marker for illustration).\n *\n * @param lines - Lines of the artifact file.\n * @returns Sorted array of placeholder markers that were found as real slots.\n */\nfunction findUnfilledPlaceholders(lines: readonly string[]): readonly string[] {\n  const found = new Set<string>();\n  let inCodeFence = false;\n\n  for (const raw of lines) {\n    const line = raw.trimStart();\n\n    if (line.startsWith('```')) {\n      inCodeFence = !inCodeFence;\n      continue;\n    }\n    if (inCodeFence) continue;\n\n    for (const marker of PLACEHOLDER_MARKERS) {\n      if (!raw.includes(marker)) continue;\n      if (isMetaDocumentationLine(raw, line, marker)) continue;\n      found.add(marker);\n    }\n  }\n  return Array.from(found).sort();\n}\n\n/**\n * List all `.md` files directly inside `<runDir>/intelligence/`.\n *\n * @param runDir - Absolute path to the analysis run directory.\n * @returns Array of paths relative to `runDir` (POSIX-style).\n */\nfunction walkIntelligenceDir(runDir: string): readonly string[] {\n  const intelDir = path.join(runDir, 'intelligence');\n  if (!fs.existsSync(intelDir) || !fs.statSync(intelDir).isDirectory()) {\n    return [];\n  }\n  const out: string[] = [];\n  for (const entry of fs.readdirSync(intelDir)) {\n    if (entry.endsWith('.md')) out.push(path.posix.join('intelligence', entry));\n  }\n  return out;\n}\n\n// ─── Validation orchestration ────────────────────────────────────────────────\n\n/** Type-safe Map view of `ARTICLE_TYPE_EXTRAS` that avoids property-access injection. */\nconst ARTICLE_TYPE_EXTRAS_MAP: ReadonlyMap<string, readonly string[]> = new Map(\n  Object.entries(ARTICLE_TYPE_EXTRAS)\n);\n\n/**\n * Compute the set of mandatory artifacts for a given article type.\n *\n * Combines the common `COMMON_REQUIRED` set, the seven reference-quality\n * intelligence artifacts, and any article-type-specific extras.\n *\n * @param articleType - The article category slug (e.g. `breaking`).\n * @returns Sorted list of required relative artifact paths.\n */\nfunction computeRequired(articleType: string): readonly string[] {\n  const extras = ARTICLE_TYPE_EXTRAS_MAP.get(articleType) ?? [];\n  const set = new Set<string>([...COMMON_REQUIRED, ...REFERENCE_QUALITY_INTELLIGENCE, ...extras]);\n  return Array.from(set).sort();\n}\n\n/**\n * Count how many artifact checks failed, combined with any manifest errors.\n *\n * Each check carries its own `minLines` threshold (Rule 22 per-artifact floor\n * or the flat fallback), so no single `minLines` argument is needed here.\n *\n * @param checks - Per-artifact inspection results.\n * @param manifestErrorCount - Number of manifest-level errors.\n * @returns Total error count used for the pass/fail decision.\n */\nfunction countErrors(checks: readonly ArtifactCheck[], manifestErrorCount: number): number {\n  let errorCount = manifestErrorCount;\n  for (const c of checks) {\n    if (!c.present) errorCount++;\n    else if (c.lineCount < c.minLines) errorCount++;\n    else if (c.placeholdersFound.length > 0) errorCount++;\n    else if (!c.listedInManifest) errorCount++;\n  }\n  return errorCount;\n}\n\n/**\n * Thrown by `validate()` for usage errors (missing/unreadable run directory).\n * Carries the exit code that `main()` should use, so all `process.exit(…)`\n * calls live in one place and the `validate()` function stays unit-testable.\n */\nclass ValidationUsageError extends Error {\n  public readonly exitCode: number;\n  constructor(message: string, exitCode = 2) {\n    super(message);\n    this.name = 'ValidationUsageError';\n    this.exitCode = exitCode;\n  }\n}\n\n/**\n * Run the full validation pipeline for a given analysis run directory.\n *\n * @param options - Parsed CLI options.\n * @returns Validation result with per-artifact checks and pass/fail flag.\n * @throws {ValidationUsageError} When the analysis directory does not exist\n *         or is not a directory — the CLI entrypoint translates this into\n *         `process.exit(2)`.\n */\nfunction validate(options: CliOptions): ValidationResult {\n  const absRunDir = path.isAbsolute(options.analysisDir)\n    ? options.analysisDir\n    : path.join(PROJECT_ROOT, options.analysisDir);\n\n  if (!fs.existsSync(absRunDir) || !fs.statSync(absRunDir).isDirectory()) {\n    throw new ValidationUsageError(\n      `Analysis directory not found or not a directory: ${absRunDir}`,\n      2\n    );\n  }\n\n  const manifest = loadManifest(absRunDir);\n  const articleType = options.articleType ?? manifest.raw.articleType ?? 'unknown';\n  const required = computeRequired(articleType);\n  const listedSet = new Set<string>(manifest.allListedPaths);\n  const perArtifactThresholds = loadPerArtifactThresholds(articleType, options.thresholdsFile);\n\n  const checks: ArtifactCheck[] = required.map((rel) =>\n    inspectArtifact(\n      absRunDir,\n      rel,\n      listedSet.has(rel),\n      effectiveMinLines(rel, perArtifactThresholds, options.minLines)\n    )\n  );\n\n  // Rule 22 supplemental enforcement: any manifest-listed file that has a\n  // per-artifact threshold entry but is NOT in the mandatory `required` set\n  // (e.g. `risk-scoring/*`, `documents/*`, `classification/*`) also has its\n  // depth floor enforced. This keeps `reference-quality-thresholds.json` and\n  // `.github/prompts/SHARED_PROMPT_PATTERNS.md §Per-Artifact Budgets` truthful\n  // about which files are machine-enforced.\n  const requiredSet = new Set<string>(required);\n  const supplementalChecks: ArtifactCheck[] = [];\n  for (const rel of perArtifactThresholds.keys()) {\n    if (requiredSet.has(rel)) continue;\n    if (!listedSet.has(rel)) continue;\n    supplementalChecks.push(\n      inspectArtifact(\n        absRunDir,\n        rel,\n        true,\n        effectiveMinLines(rel, perArtifactThresholds, options.minLines)\n      )\n    );\n  }\n  supplementalChecks.sort((a, b) => a.relativePath.localeCompare(b.relativePath));\n  checks.push(...supplementalChecks);\n\n  const onDiskIntel = walkIntelligenceDir(absRunDir);\n  // O(1)-per-path lookup: build a lookup set that includes both required and\n  // supplemental-threshold artifacts so the orphan filter doesn't flag them.\n  const inspectedSet = new Set<string>(checks.map((c) => c.relativePath));\n  const orphaned = onDiskIntel.filter((rel) => !listedSet.has(rel) && !inspectedSet.has(rel));\n\n  // Orphaned files are warnings, not errors (per Rule 6 \"contamination risk\"\n  // they're a signal but not a blocker — a second workflow may legitimately add files)\n  const errorCount = countErrors(checks, manifest.errors.length);\n\n  return {\n    analysisDir: absRunDir,\n    articleType,\n    required,\n    checks,\n    orphanedOnDisk: orphaned,\n    manifestValid: manifest.errors.length === 0,\n    manifestErrors: manifest.errors,\n    passed: errorCount === 0,\n    errorCount,\n  };\n}\n\n// ─── Reporting ────────────────────────────────────────────────────────────────\n\n/**\n * Build a list of issue labels for a single artifact check.\n *\n * Uses the per-check `minLines` (Rule 22 per-artifact floor or flat fallback).\n *\n * @param c - The artifact check result.\n * @returns Array of short issue labels; empty if the artifact passes.\n */\nfunction artifactIssues(c: ArtifactCheck): readonly string[] {\n  if (!c.present) return ['MISSING'];\n  const parts: string[] = [];\n  if (c.lineCount < c.minLines) parts.push(`SHORT (${c.lineCount} < ${c.minLines} lines)`);\n  if (c.placeholdersFound.length > 0) {\n    parts.push(`PLACEHOLDERS (${c.placeholdersFound.join(', ')})`);\n  }\n  if (!c.listedInManifest) parts.push('NOT_LISTED_IN_MANIFEST');\n  return parts;\n}\n\n/**\n * Print the header block of a text-mode report.\n *\n * @param result - Validation result.\n * @param minLines - Flat fallback line floor (displayed as the default).\n */\nfunction printHeader(result: ValidationResult, minLines: number): void {\n  console.log('━'.repeat(72));\n  console.log('🔍 Analysis Completeness Validator (Rule 19 + Rule 22 pre-flight gate)');\n  console.log('━'.repeat(72));\n  console.log(`📁 Run dir        : ${path.relative(PROJECT_ROOT, result.analysisDir)}`);\n  console.log(`🏷️  Article type   : ${result.articleType}`);\n  console.log(`📋 Required count : ${result.required.length}`);\n  console.log(\n    `🧾 Min lines/file : ${minLines} (default) — per-artifact floors from Rule 22 thresholds`\n  );\n  console.log('');\n}\n\n/**\n * Print the pass/fail footer of a text-mode report.\n *\n * @param result - Validation result.\n */\nfunction printFooter(result: ValidationResult): void {\n  console.log('');\n  if (result.passed) {\n    console.log('✅ Pre-flight gate PASSED — article generation may proceed.');\n  } else {\n    console.log(\n      `❌ Pre-flight gate FAILED — ${result.errorCount} error(s). ` +\n        'Article generation MUST NOT proceed.'\n    );\n    console.log('   See analysis/methodologies/ai-driven-analysis-guide.md §Rule 19 / Rule 22 and');\n    console.log('   .github/prompts/SHARED_PROMPT_PATTERNS.md §Article Generation Pre-Flight.');\n  }\n  console.log('━'.repeat(72));\n}\n\n/**\n * Render the full text-mode report to stdout.\n *\n * @param result - Validation result.\n * @param minLines - Flat fallback line floor (per-artifact floors live on each check).\n */\nfunction renderTextReport(result: ValidationResult, minLines: number): void {\n  printHeader(result, minLines);\n\n  if (!result.manifestValid) {\n    console.log('❌ Manifest errors:');\n    for (const err of result.manifestErrors) console.log(`   • ${err}`);\n    console.log('');\n  }\n\n  console.log('📊 Artifact checks:');\n  for (const c of result.checks) {\n    const issues = artifactIssues(c);\n    const status = issues.length === 0 ? '✅ ok' : `❌ ${issues.join('; ')}`;\n    const lineInfo = c.present ? ` (${c.lineCount}/${c.minLines} lines)` : '';\n    console.log(`   ${status.padEnd(60)} ${c.relativePath}${lineInfo}`);\n  }\n\n  if (result.orphanedOnDisk.length > 0) {\n    console.log('');\n    console.log('⚠️  Intelligence files on disk not listed in manifest.files.*:');\n    for (const rel of result.orphanedOnDisk) console.log(`   • ${rel}`);\n    console.log('   → Update manifest.files.intelligence[] to include these');\n    console.log('     (or delete them if they are leftovers from a prior run).');\n  }\n\n  printFooter(result);\n}\n\n// ─── Article HTML validation (AI-First fallback-template leak detector) ─────\n\n/**\n * Regex patterns matching script-generated fallback text that must never\n * appear in a published article.\n *\n * **Source of each pattern** (grep-anchored to the code that could emit it):\n *\n * - `AI_MARKER` — the canonical unfilled-slot sentinel\n *   (`src/constants/analysis-constants.ts`).  If this leaks through, the AI\n *   agent failed to author the slot.\n * - *\"This parliamentary activity on ...\"*, *\"Civil society organisations\n *   monitoring ...\"*, *\"Industry and business stakeholders observe ...\"*,\n *   *\"National governments assess ...\"*, *\"EU citizens experience ...\"*,\n *   *\"EU institutional dynamics show ...\"* — legacy `deriveStakeholderReasoning`\n *   template sentences.  New code emits `AI_MARKER` instead, so any match is\n *   an article that regenerated against the old logic or copied the text\n *   verbatim from a template.\n * - *\"Voting outcomes 2026-MM-DD–2026-MM-DD\"* and *\"voting period\n *   2026-MM-DD–2026-MM-DD\"* — `buildVotingAnalysis` date-range topic fallback\n *   (`src/generators/builders/voting-builders.ts`).  Indicates the AI did not\n *   supply a substantive topic label for the stakeholder outcome matrix.\n * - *\"EP activity 2026-MM-DD\"* — `buildBreakingAnalysis` date-only topic\n *   fallback (`src/generators/builders/breaking-builders.ts`).\n * - *\"Stakeholder impact assessment for ... indicates ... relevance.\"* —\n *   the legacy `default` branch of `deriveStakeholderReasoning`.\n *\n * The sentinel patterns `[AI_ANALYSIS_REQUIRED]`, `[REQUIRED]`, and\n * `AI_ANALYSIS_PENDING` are matched with their exact casing — the generators\n * emit these literal strings and we do NOT want to match user-authored prose\n * that happens to mention them in a different case. The prose and date-range\n * patterns are case-insensitive (`i` flag). All patterns are evaluated against\n * a whitespace-normalised copy of the HTML (see `normalizeHtmlForScan`), so\n * they are tolerant of HTML reflow, newlines, and inter-tag whitespace\n * without needing dotAll.\n *\n * Update this list whenever a new fallback-sentinel is introduced in the\n * generators; the test suite asserts that every new sentinel is added here\n * so that the validator can detect it.\n */\nexport const FALLBACK_TEMPLATE_PATTERNS: readonly RegExp[] = [\n  /\\[AI_ANALYSIS_REQUIRED\\]/,\n  /\\[REQUIRED\\]/,\n  /\\bAI_ANALYSIS_PENDING\\b/,\n  /This parliamentary activity on .{0,200}?has (?:significant|moderate|limited) implications for political group dynamics/i,\n  /Civil society organisations monitoring .{0,200}?face (?:significant|moderate|limited) impact on transparency/i,\n  /Industry and business stakeholders observe (?:significant|moderate|limited) regulatory implications from/i,\n  /National governments assess (?:significant|moderate|limited) impact from .{0,200}?on subsidiarity/i,\n  /EU citizens experience (?:significant|moderate|limited) consequences from/i,\n  /EU institutional dynamics show (?:significant|moderate|limited) effects from/i,\n  /Stakeholder impact assessment for .{0,200}?indicates (?:significant|moderate|limited) relevance/i,\n  /\\bVoting outcomes \\d{4}-\\d{2}-\\d{2}[–-]\\d{4}-\\d{2}-\\d{2}\\b/i,\n  /\\bvoting period \\d{4}-\\d{2}-\\d{2}[–-]\\d{4}-\\d{2}-\\d{2}\\b/i,\n  /\\bEP activity \\d{4}-\\d{2}-\\d{2}\\b/i,\n  /\\bEP breaking news \\d{4}-\\d{2}-\\d{2}\\b/i,\n] as const;\n\n/**\n * Normalise HTML for fallback-pattern scanning.\n *\n * Collapses all whitespace (including newlines, tabs, and runs of spaces) to\n * a single space so that sentence-level patterns like\n * *\"This parliamentary activity on … has moderate implications …\"*\n * still match when the rendered HTML wraps the prose across multiple lines or\n * inserts inter-tag whitespace. Keeps the transformation purely lexical — we\n * do not strip tags, because several sentinels (e.g. `[AI_ANALYSIS_REQUIRED]`)\n * can legitimately appear inside attribute text.\n *\n * @param html - Raw HTML document or fragment.\n * @returns Whitespace-normalised copy suitable for regex scanning.\n */\nfunction normalizeHtmlForScan(html: string): string {\n  return html.replace(/\\s+/g, ' ');\n}\n\n/**\n * A single leak detected by {@link scanHtmlForFallbackLeaks}.\n */\nexport interface FallbackLeak {\n  /** The pattern index within {@link FALLBACK_TEMPLATE_PATTERNS} */\n  readonly patternIndex: number;\n  /** The matching substring, truncated to 240 characters for display */\n  readonly match: string;\n  /** Match start index within the scanned HTML string (JavaScript UTF-16 code units, measured on the whitespace-normalised copy) */\n  readonly offset: number;\n}\n\n/**\n * Scan rendered HTML for AI-First fallback-template leaks.\n *\n * This enforces the\n * [Analysis-to-Article Data Contract](../../.github/prompts/SHARED_PROMPT_PATTERNS.md#analysis-to-article-data-contract)\n * at publish time: the AI agent must have authored every stakeholder /\n * outcome / impact slot by reading the run's analysis markdown as context.\n * If any {@link FALLBACK_TEMPLATE_PATTERNS} pattern matches, the AI did not\n * do its job and publication must fail.\n *\n * The HTML is whitespace-normalised (all runs of whitespace collapsed to a\n * single space) before scanning so that sentence-level patterns still match\n * across HTML reflow and newline boundaries.\n *\n * @param html - Full HTML document or `.article-content` fragment.\n * @returns Array of leak records (empty when clean).\n */\nexport function scanHtmlForFallbackLeaks(html: string): readonly FallbackLeak[] {\n  const normalized = normalizeHtmlForScan(html);\n  const leaks: FallbackLeak[] = [];\n  for (let i = 0; i < FALLBACK_TEMPLATE_PATTERNS.length; i++) {\n    const pattern = FALLBACK_TEMPLATE_PATTERNS[i];\n    if (!pattern) continue;\n    // Create a new global regex each iteration so we can use exec() with lastIndex\n    const global = new RegExp(\n      pattern.source,\n      pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'\n    );\n    let m: RegExpExecArray | null;\n    while ((m = global.exec(normalized)) !== null) {\n      leaks.push({\n        patternIndex: i,\n        match: m[0].length > 240 ? m[0].slice(0, 237) + '…' : m[0],\n        offset: m.index,\n      });\n      if (m[0].length === 0) global.lastIndex++;\n    }\n  }\n  return leaks;\n}\n\n/**\n * Scan one or more article HTML files for fallback-template leaks.\n *\n * @param paths - File paths. Absolute paths are used as-is; relative paths\n *   are resolved against the repository root (`PROJECT_ROOT`), matching the\n *   repo-relative invocations emitted by the news workflows (e.g.\n *   `news/2026-04-20-breaking-en.html`).\n * @returns Map of `path → leaks`.  Paths that do not exist or cannot be read\n *   yield a synthetic leak describing the read error.\n */\nexport function scanArticleHtmlFiles(\n  paths: readonly string[]\n): ReadonlyMap<string, readonly FallbackLeak[]> {\n  const result = new Map<string, readonly FallbackLeak[]>();\n  for (const p of paths) {\n    const abs = path.isAbsolute(p) ? p : path.resolve(PROJECT_ROOT, p);\n    if (!fs.existsSync(abs)) {\n      result.set(p, [{ patternIndex: -1, match: `File not found: ${abs}`, offset: 0 }]);\n      continue;\n    }\n    try {\n      const html = fs.readFileSync(abs, 'utf-8');\n      result.set(p, scanHtmlForFallbackLeaks(html));\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : String(err);\n      result.set(p, [{ patternIndex: -1, match: `Read error: ${msg}`, offset: 0 }]);\n    }\n  }\n  return result;\n}\n\n// ─── Main ─────────────────────────────────────────────────────────────────────\n\n/**\n * CLI entrypoint — parses args, runs validation, renders output, and owns\n * every `process.exit(…)` decision for this module.\n */\n/**\n * Run the Rule 22 analysis-dir validation when `--analysis-dir=<path>` is\n * supplied, handling `ValidationUsageError` as a CLI-level exit.\n *\n * @param options - Parsed CLI options.\n * @returns Validation result, or `null` when no analysis dir was supplied.\n */\nfunction runAnalysisValidation(options: CliOptions): ValidationResult | null {\n  if (!options.analysisDir) return null;\n  try {\n    return validate(options);\n  } catch (err) {\n    if (err instanceof ValidationUsageError) {\n      console.error(`❌ ${err.message}`);\n      process.exit(err.exitCode);\n    }\n    throw err;\n  }\n}\n\n/**\n * Render the per-file HTML-scan report to stdout in text mode.\n *\n * @param htmlScan - Map of scanned HTML paths to detected leaks.\n */\nfunction renderHtmlScanReport(htmlScan: ReadonlyMap<string, readonly FallbackLeak[]>): void {\n  console.log('');\n  console.log('🔍 Article HTML fallback-template scan');\n  for (const [p, leaks] of htmlScan.entries()) {\n    if (leaks.length === 0) {\n      console.log(`  ✅ ${p} (clean)`);\n      continue;\n    }\n    console.log(`  ❌ ${p} — ${leaks.length} leak(s):`);\n    for (const leak of leaks.slice(0, 10)) {\n      console.log(`     • [pattern ${leak.patternIndex} @${leak.offset}] ${leak.match}`);\n    }\n    if (leaks.length > 10) {\n      console.log(`     • … and ${leaks.length - 10} more`);\n    }\n  }\n}\n\n/**\n * Emit JSON or text output for the combined analysis + HTML scan result.\n *\n * @param options - Parsed CLI options (controls `--json` vs text output).\n * @param result - Analysis-dir validation result, or `null` when skipped.\n * @param htmlScan - Per-file HTML scan map, or `null` when `--article-html`\n *   was not supplied.\n */\nfunction renderCombinedReport(\n  options: CliOptions,\n  result: ValidationResult | null,\n  htmlScan: ReadonlyMap<string, readonly FallbackLeak[]> | null\n): void {\n  if (options.json) {\n    console.log(\n      JSON.stringify(\n        {\n          analysis: result,\n          htmlScan: htmlScan ? Object.fromEntries(htmlScan.entries()) : null,\n        },\n        null,\n        2\n      )\n    );\n    return;\n  }\n  if (result) renderTextReport(result, options.minLines);\n  if (htmlScan) renderHtmlScanReport(htmlScan);\n}\n\n/**\n * CLI entrypoint — parses args, runs validation, renders output, and owns\n * every `process.exit(…)` decision for this module.\n */\nfunction main(): void {\n  const options = parseArgs(process.argv.slice(2));\n\n  // HTML-only mode: skip analysis-dir validation, just scan rendered articles.\n  const htmlScan =\n    options.articleHtmlPaths.length > 0 ? scanArticleHtmlFiles(options.articleHtmlPaths) : null;\n\n  const result = runAnalysisValidation(options);\n  renderCombinedReport(options, result, htmlScan);\n\n  const htmlFailed = htmlScan ? Array.from(htmlScan.values()).some((v) => v.length > 0) : false;\n  const analysisPassed = result ? result.passed : true;\n  const passed = analysisPassed && !htmlFailed;\n  if (!passed && !options.warnOnly) {\n    process.exit(1);\n  }\n}\n\n// Only run the CLI when this file is executed directly (not when imported by\n// tests or other modules).  Matches the conventional guard used by sibling\n// CLI utilities in `src/utils/` (e.g. `validate-ep-api.ts`).\nif (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {\n  main();\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/validate-articles.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":102,"column":10,"endLine":102,"endColumn":23}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/ValidateArticles\n * @description CI-ready article validation tool that checks all news articles\n * for quality, structural correctness, language consistency, and meta tag alignment.\n *\n * Can be run standalone or integrated into CI pipelines as a quality gate.\n * Exits with code 1 if any article has validation errors.\n *\n * Usage:\n * - Validate all articles: `npx tsx src/utils/validate-articles.ts`\n * - Validate specific date: `npx tsx src/utils/validate-articles.ts --date=2026-03-04`\n * - Strict mode (warnings are errors): `npx tsx src/utils/validate-articles.ts --strict`\n * - Dry run (no exit code): `npx tsx src/utils/validate-articles.ts --dry-run`\n * - Quality scoring: `npx tsx src/utils/validate-articles.ts --quality`\n * - JSON output: `npx tsx src/utils/validate-articles.ts --quality --output=json`\n */\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { NEWS_DIR, ARTICLE_FILENAME_PATTERN, PROJECT_ROOT } from '../constants/config.js';\nimport {\n  validateArticleContent,\n  articlePolicyHasEconomicContext,\n  hasWorldBankEvidence,\n  hasIMFEvidence,\n} from './content-validator.js';\nimport { scoreArticleQuality } from './article-quality-scorer.js';\nimport type { ArticleQualityReport, ArticleGrade } from '../types/quality.js';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\n/** Validation summary for a single article */\ninterface ArticleValidationSummary {\n  filename: string;\n  lang: string;\n  slug: string;\n  date: string;\n  valid: boolean;\n  errors: string[];\n  warnings: string[];\n  wordCount: number;\n  qualityReport?: ArticleQualityReport;\n}\n\n/** Overall validation report */\ninterface ValidationReport {\n  totalArticles: number;\n  passed: number;\n  failed: number;\n  warnings: number;\n  articles: ArticleValidationSummary[];\n  gradeDistribution?: Record<ArticleGrade, number>;\n}\n\n// ─── CLI argument parsing ─────────────────────────────────────────────────────\n\nconst args = process.argv.slice(2);\n\n/**\n * Extract a CLI flag value: --flag=value\n *\n * @param name - Flag name to extract\n * @returns Flag value or undefined if not found\n */\nfunction getArg(name: string): string | undefined {\n  const prefix = `--${name}=`;\n  const arg = args.find((a) => a.startsWith(prefix));\n  return arg?.slice(prefix.length);\n}\n\nconst filterDate = getArg('date');\nconst strictMode = args.includes('--strict');\nconst dryRun = args.includes('--dry-run');\nconst qualityMode = args.includes('--quality');\nconst outputFormat = getArg('output');\n\n// ─── Slug-to-article-type mapping ─────────────────────────────────────────────\n\n/**\n * Map article slug to article type for validator word-count thresholds.\n * The slug is the middle portion of the filename: {date}-{slug}-{lang}.html\n *\n * @param slug - Article slug (e.g. \"week-ahead\", \"breaking\", \"committee-reports\")\n * @returns Article type string matching ArticleCategory values\n */\nfunction slugToArticleType(slug: string): string {\n  const mapping: Record<string, string> = {\n    'week-ahead': 'week-ahead',\n    'month-ahead': 'month-ahead',\n    breaking: 'breaking',\n    'committee-reports': 'committee-reports',\n    propositions: 'propositions',\n    motions: 'motions',\n    'week-in-review': 'week-in-review',\n    'month-in-review': 'month-in-review',\n    'weekly-review': 'week-in-review',\n    'monthly-review': 'month-in-review',\n  };\n  return mapping[slug] ?? slug;\n}\n\n// ─── Main validation logic ────────────────────────────────────────────────────\n\n/**\n * For policy article types, verify that either **World Bank** or **IMF**\n * economic context is cited in the article body OR in any `.md` file under\n * the article's `analysis/daily/{date}/{slug}*` directory. Non-policy article\n * types are always considered satisfied.\n *\n * This is the Wave-2 OR-gate (see IMF migration plan §5 Wave 2) that\n * replaces the prior World-Bank-only strict gate. WB-only articles remain\n * green (backward compatible); IMF-only or dual-sourced articles are now\n * also accepted.\n *\n * @param html - Full HTML of the article being validated\n * @param articleType - Article category slug (e.g. `\"committee-reports\"`)\n * @param date - Article publication date (`YYYY-MM-DD`)\n * @param slug - Article slug used to locate the matching analysis directory\n * @returns Warning string when the gate fails, or `null` when satisfied.\n */\nfunction checkEconomicContextEvidence(\n  html: string,\n  articleType: string,\n  date: string,\n  slug: string\n): string | null {\n  // Short-circuit for non-policy article types or when article body already\n  // cites either World Bank or IMF evidence.\n  if (articlePolicyHasEconomicContext(html, articleType)) return null;\n\n  // Sweep sibling analysis directories: analysis/daily/{date}/{slug}*\n  const analysisRoot = path.join(PROJECT_ROOT, 'analysis', 'daily', date);\n  if (!fs.existsSync(analysisRoot)) {\n    return `Missing required economic context (World Bank or IMF) for \"${articleType}\" article; analysis directory ${analysisRoot} does not exist`;\n  }\n\n  const candidates = safeReaddir(analysisRoot).filter(\n    (entry) => entry === slug || entry.startsWith(`${slug}-`) || entry.startsWith(`${slug}_`)\n  );\n\n  for (const dirName of candidates) {\n    if (directoryContainsEconomicContextFingerprint(path.join(analysisRoot, dirName))) {\n      return null;\n    }\n  }\n\n  return `Missing required economic context (World Bank or IMF) for \"${articleType}\" article; neither article body nor analysis files under ${analysisRoot} reference any World Bank or IMF indicator`;\n}\n\n/**\n * List directory entries, returning `[]` on any error (tolerate missing paths).\n *\n * @param dir - Directory to list\n * @returns Array of entry names or `[]` when the directory cannot be read\n */\nfunction safeReaddir(dir: string): string[] {\n  try {\n    return fs.readdirSync(dir);\n  } catch {\n    return [];\n  }\n}\n\n/**\n * Maximum recursion depth when searching an analysis directory for economic\n * context fingerprints (World Bank OR IMF). The starting directory is\n * depth 0; the guard `depth > ANALYSIS_SEARCH_MAX_DEPTH` stops recursion\n * once it would exceed this depth. With `ANALYSIS_SEARCH_MAX_DEPTH = 3` the\n * scanner reads files at depths 0, 1, 2 and 3 — enough to cover the expected\n * layout `analysis/daily/{date}/{slug}/<subdir>/<file>.md` (depth 2) with one\n * level of tolerance for deeper run artefacts. Trees deeper than this are\n * truncated to guarantee bounded I/O during validator runs.\n */\nconst ANALYSIS_SEARCH_MAX_DEPTH = 3;\n\n/**\n * Depth-limited recursive search for any World Bank OR IMF fingerprint in\n * `.md` files. Uses {@link hasWorldBankEvidence} and {@link hasIMFEvidence}\n * so the gate enforces the same strong-phrase / word-bounded-indicator rule\n * used on article bodies, for either economic-data provider.\n *\n * @param dir - Directory to scan\n * @param depth - Current recursion depth (callers should omit; max is\n *   {@link ANALYSIS_SEARCH_MAX_DEPTH}, inclusive)\n * @returns `true` when at least one `.md` file contains a WB or IMF fingerprint\n */\nfunction directoryContainsEconomicContextFingerprint(dir: string, depth = 0): boolean {\n  if (depth > ANALYSIS_SEARCH_MAX_DEPTH) return false;\n  let entries: fs.Dirent[];\n  try {\n    entries = fs.readdirSync(dir, { withFileTypes: true });\n  } catch {\n    return false;\n  }\n  for (const entry of entries) {\n    if (entryContainsEconomicContextFingerprint(dir, entry, depth)) return true;\n  }\n  return false;\n}\n\n/**\n * Test a single directory entry for World Bank OR IMF fingerprints, recursing\n * into subdirectories up to the shared depth cap.\n *\n * @param dir - Parent directory of `entry`\n * @param entry - Directory entry to test\n * @param depth - Current recursion depth of the caller\n * @returns `true` when this entry (or any descendant) matches a fingerprint\n */\nfunction entryContainsEconomicContextFingerprint(\n  dir: string,\n  entry: fs.Dirent,\n  depth: number\n): boolean {\n  const full = path.join(dir, entry.name);\n  if (entry.isDirectory()) {\n    return directoryContainsEconomicContextFingerprint(full, depth + 1);\n  }\n  if (!entry.isFile() || !entry.name.endsWith('.md')) return false;\n  let content: string;\n  try {\n    content = fs.readFileSync(full, 'utf-8');\n  } catch {\n    return false;\n  }\n  return hasWorldBankEvidence(content) || hasIMFEvidence(content);\n}\n\n/**\n * Validate a single article file and return a summary.\n *\n * @param filename - Filename of the article to validate\n * @returns Article validation summary or null if the filename does not match\n */\nfunction validateSingleFile(filename: string): ArticleValidationSummary | null {\n  const match = ARTICLE_FILENAME_PATTERN.exec(filename);\n  if (!match) return null;\n\n  const date = match[1] ?? '';\n  const slug = match[2] ?? '';\n  const lang = match[3] ?? '';\n  const filePath = path.join(NEWS_DIR, filename);\n  const html = fs.readFileSync(filePath, 'utf-8');\n  const articleType = slugToArticleType(slug);\n\n  const result = validateArticleContent(html, lang, articleType);\n\n  // Economic context gate — accepts World Bank OR IMF evidence (Wave 2 OR-gate);\n  // extends search to linked analysis markdown files.\n  const econWarning = checkEconomicContextEvidence(html, articleType, date, slug);\n  if (econWarning) {\n    result.warnings.push(econWarning);\n  }\n\n  const summary: ArticleValidationSummary = {\n    filename,\n    lang,\n    slug,\n    date,\n    valid: strictMode ? result.valid && result.warnings.length === 0 : result.valid,\n    errors: [...result.errors],\n    warnings: [...result.warnings],\n    wordCount: result.metrics.wordCount,\n  };\n\n  if (strictMode && result.warnings.length > 0) {\n    summary.errors.push(...result.warnings.map((w) => `[strict] ${w}`));\n  }\n\n  if (qualityMode) {\n    summary.qualityReport = scoreArticleQuality(html, `${date}-${slug}`, lang, articleType);\n  }\n\n  return summary;\n}\n\n/**\n * Validate all news articles in the news directory.\n *\n * @returns Validation report with per-article summaries\n */\nfunction validateAllArticles(): ValidationReport {\n  if (!fs.existsSync(NEWS_DIR)) {\n    console.error(`❌ News directory not found: ${NEWS_DIR}`);\n    return { totalArticles: 0, passed: 0, failed: 0, warnings: 0, articles: [] };\n  }\n\n  const files = fs\n    .readdirSync(NEWS_DIR)\n    .filter((f) => f.endsWith('.html'))\n    .filter((f) => ARTICLE_FILENAME_PATTERN.test(f))\n    .filter((f) => (filterDate ? f.startsWith(filterDate) : true))\n    .sort();\n\n  const articles: ArticleValidationSummary[] = [];\n  let passed = 0;\n  let failed = 0;\n  let warningCount = 0;\n\n  for (const filename of files) {\n    const summary = validateSingleFile(filename);\n    if (!summary) continue;\n\n    if (summary.valid) {\n      passed++;\n    } else {\n      failed++;\n    }\n    if (summary.warnings.length > 0) {\n      warningCount++;\n    }\n    articles.push(summary);\n  }\n\n  const report: ValidationReport = {\n    totalArticles: files.length,\n    passed,\n    failed,\n    warnings: warningCount,\n    articles,\n  };\n\n  if (qualityMode) {\n    report.gradeDistribution = buildGradeDistribution(articles);\n  }\n\n  return report;\n}\n\n/**\n * Build a grade distribution map from article quality reports.\n *\n * @param articles - Array of article validation summaries\n * @returns Grade distribution counts\n */\nfunction buildGradeDistribution(\n  articles: ArticleValidationSummary[]\n): Record<ArticleGrade, number> {\n  const distribution: Record<ArticleGrade, number> = { A: 0, B: 0, C: 0, D: 0, F: 0 };\n  for (const article of articles) {\n    if (article.qualityReport) {\n      distribution[article.qualityReport.grade]++;\n    }\n  }\n  return distribution;\n}\n\n/**\n * Print a formatted validation report to the console.\n *\n * @param report - Validation report to print\n */\nfunction printReport(report: ValidationReport): void {\n  console.log('\\n════════════════════════════════════════════════════════════════');\n  console.log('  EU Parliament Monitor — Article Validation Report');\n  console.log('════════════════════════════════════════════════════════════════\\n');\n\n  if (filterDate) {\n    console.log(`  Filter: articles from ${filterDate}`);\n  }\n  if (strictMode) {\n    console.log('  Mode: STRICT (warnings treated as errors)');\n  }\n  if (qualityMode) {\n    console.log('  Mode: QUALITY scoring enabled');\n  }\n\n  console.log(`  Total articles:  ${report.totalArticles}`);\n  console.log(`  ✅ Passed:       ${report.passed}`);\n  console.log(`  ❌ Failed:       ${report.failed}`);\n  console.log(`  ⚠️  With warnings: ${report.warnings}\\n`);\n\n  printFailures(report);\n  printWarnings(report);\n\n  if (qualityMode) {\n    printQualityScores(report);\n    printGradeDistribution(report);\n  }\n\n  console.log('══════════════════════════════════════════════════════════════\\n');\n}\n\n/**\n * Print failure details from the validation report.\n *\n * @param report - Validation report\n */\nfunction printFailures(report: ValidationReport): void {\n  const failures = report.articles.filter((a) => !a.valid);\n  if (failures.length === 0) return;\n\n  console.log('── FAILURES ──────────────────────────────────────────────────\\n');\n  for (const article of failures) {\n    console.log(`  ❌ ${article.filename} (${article.wordCount} words)`);\n    for (const error of article.errors) {\n      console.log(`     ERROR: ${error}`);\n    }\n    for (const warning of article.warnings) {\n      console.log(`     WARN:  ${warning}`);\n    }\n    console.log('');\n  }\n}\n\n/**\n * Print warning details from the validation report.\n *\n * @param report - Validation report\n */\nfunction printWarnings(report: ValidationReport): void {\n  const withWarnings = report.articles.filter((a) => a.valid && a.warnings.length > 0);\n  if (withWarnings.length === 0) return;\n\n  console.log('── WARNINGS ──────────────────────────────────────────────────\\n');\n  for (const article of withWarnings) {\n    console.log(`  ⚠️  ${article.filename} (${article.wordCount} words)`);\n    for (const warning of article.warnings) {\n      console.log(`     WARN:  ${warning}`);\n    }\n    console.log('');\n  }\n}\n\n/**\n * Print quality scores for all articles (only in --quality mode).\n *\n * @param report - Validation report\n */\nfunction printQualityScores(report: ValidationReport): void {\n  const articlesWithQuality = report.articles.filter((a) => a.qualityReport);\n  if (articlesWithQuality.length === 0) return;\n\n  console.log('── QUALITY SCORES ────────────────────────────────────────────\\n');\n  for (const article of articlesWithQuality) {\n    const qr = article.qualityReport;\n    if (!qr) continue;\n    const gate = qr.passesQualityGate ? '✅' : '⚠️ ';\n    console.log(\n      `  ${gate} ${article.filename} — Grade: ${qr.grade} (${qr.overallScore}/100, ${qr.wordCount} words)`\n    );\n    if (!qr.passesQualityGate && qr.recommendations.length > 0) {\n      for (const rec of qr.recommendations.slice(0, 3)) {\n        console.log(`       💡 ${rec}`);\n      }\n    }\n  }\n  console.log('');\n}\n\n/**\n * Print the grade distribution summary (only in --quality mode).\n *\n * @param report - Validation report\n */\nfunction printGradeDistribution(report: ValidationReport): void {\n  if (!report.gradeDistribution) return;\n  const dist = report.gradeDistribution;\n  console.log('── GRADE DISTRIBUTION ────────────────────────────────────────\\n');\n  console.log(\n    `  A (≥80): ${dist['A']}   B (≥65): ${dist['B']}   C (≥40): ${dist['C']}   D (≥25): ${dist['D']}   F (<25): ${dist['F']}`\n  );\n  console.log('');\n}\n\n// ─── CLI execution ────────────────────────────────────────────────────────────\n\nconst report = validateAllArticles();\nprintReport(report);\n\nif (qualityMode && outputFormat === 'json') {\n  const outputPath = path.join(process.cwd(), 'quality-report.json');\n  fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');\n  console.log(`📄 Quality report written to ${outputPath}`);\n}\n\nif (!dryRun && report.failed > 0) {\n  console.error(`❌ Validation failed: ${report.failed} article(s) have errors`);\n  process.exit(1);\n} else if (report.failed === 0) {\n  console.log('✅ All articles passed validation');\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/validate-ep-api.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/runner/work/euparliamentmonitor/euparliamentmonitor/src/utils/world-bank-data.ts","messages":[{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":211,"column":22,"endLine":211,"endColumn":31},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":228,"column":16,"endLine":228,"endColumn":23},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":274,"column":31,"endLine":274,"endColumn":39},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":430,"column":15,"endLine":430,"endColumn":42},{"ruleId":"security/detect-object-injection","severity":1,"message":"Generic Object Injection Sink","line":457,"column":10,"endLine":457,"endColumn":33}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// SPDX-FileCopyrightText: 2024-2026 Hack23 AB\n// SPDX-License-Identifier: Apache-2.0\n\n/**\n * @module Utils/WorldBankData\n * @description Utility functions for World Bank economic data integration.\n *\n * Provides EU member state → World Bank country code mapping,\n * CSV parsing for MCP responses, indicator formatting, and economic\n * context building for EU Parliament article enrichment.\n *\n * Functions in this module are designed to be stateless and avoid observable\n * side effects, with the exception of explicitly recording metadata such as\n * data timestamps in returned objects.\n *\n * ## ⚠️ For AI Agents / Agentic Workflows\n *\n * The constants below ({@link POLICY_INDICATORS}, {@link EU_COUNTRY_CODES},\n * {@link COMPARISON_COUNTRIES}) are a **convenience subset** used by TypeScript\n * code for formatting and parsing. They do **NOT** represent the full World Bank\n * indicator inventory.\n *\n * **For indicator selection in articles and analysis:**\n * 1. Read `analysis/worldbank/indicator-catalog.md` — **200+ indicators** by EP policy domain\n * 2. Use `search-indicators` MCP tool to **discover indicators on demand** by keyword\n * 3. Read `analysis/worldbank/eu-country-mapping.md` for country codes + comparison groups\n * 4. Read `analysis/worldbank/chart-integration-guide.md` for Chart.js + Mermaid templates\n *\n * The World Bank MCP server has thousands of indicators beyond the ones listed\n * here. Use `search-indicators` to find the best match for any policy topic.\n */\n\nimport type {\n  EconomicContext,\n  EconomicIndicatorSummary,\n  PolicyRelevantIndicators,\n  WorldBankIndicator,\n} from '../types/world-bank.js';\nimport { escapeHTML } from './file-utils.js';\n\n// ─── EU Member State → World Bank Country Code Mapping ───────────────────────\n\n/**\n * Maps EU member state ISO 3166-1 alpha-2 codes to World Bank alpha-3 codes.\n * Covers all 27 EU member states.\n */\nexport const EU_COUNTRY_CODES: Readonly<Record<string, string>> = {\n  AT: 'AUT', // Austria\n  BE: 'BEL', // Belgium\n  BG: 'BGR', // Bulgaria\n  HR: 'HRV', // Croatia\n  CY: 'CYP', // Cyprus\n  CZ: 'CZE', // Czech Republic\n  DK: 'DNK', // Denmark\n  EE: 'EST', // Estonia\n  FI: 'FIN', // Finland\n  FR: 'FRA', // France\n  DE: 'DEU', // Germany\n  GR: 'GRC', // Greece\n  HU: 'HUN', // Hungary\n  IE: 'IRL', // Ireland\n  IT: 'ITA', // Italy\n  LV: 'LVA', // Latvia\n  LT: 'LTU', // Lithuania\n  LU: 'LUX', // Luxembourg\n  MT: 'MLT', // Malta\n  NL: 'NLD', // Netherlands\n  PL: 'POL', // Poland\n  PT: 'PRT', // Portugal\n  RO: 'ROU', // Romania\n  SK: 'SVK', // Slovakia\n  SI: 'SVN', // Slovenia\n  ES: 'ESP', // Spain\n  SE: 'SWE', // Sweden\n} as const;\n\n/** EU aggregate code in World Bank (European Union) */\nexport const EU_AGGREGATE_CODE = 'EUU';\n\n/**\n * Comparison country codes for benchmarking EU performance against global peers.\n * Organized by geopolitical relevance to EU Parliament policy analysis.\n */\nexport const COMPARISON_COUNTRIES: Readonly<Record<string, string>> = {\n  // ── G7 Non-EU ──\n  US: 'USA', // United States\n  GB: 'GBR', // United Kingdom (post-Brexit benchmark)\n  JP: 'JPN', // Japan\n  CA: 'CAN', // Canada\n  // ── BRICS ──\n  CN: 'CHN', // China\n  IN: 'IND', // India\n  BR: 'BRA', // Brazil\n  RU: 'RUS', // Russia\n  ZA: 'ZAF', // South Africa\n  // ── EU Candidate States ──\n  UA: 'UKR', // Ukraine\n  TR: 'TUR', // Türkiye\n  RS: 'SRB', // Serbia\n  ME: 'MNE', // Montenegro\n  AL: 'ALB', // Albania\n  MK: 'MKD', // North Macedonia\n  MD: 'MDA', // Moldova\n  BA: 'BIH', // Bosnia & Herzegovina\n  GE: 'GEO', // Georgia\n  // ── Key Trade Partners ──\n  KR: 'KOR', // South Korea\n  AU: 'AUS', // Australia\n  NO: 'NOR', // Norway (EEA)\n  CH: 'CHE', // Switzerland (EFTA)\n  IL: 'ISR', // Israel\n} as const;\n\n/**\n * Aggregate/region codes useful for EU benchmarking.\n * Keys are World Bank group codes; values are human-readable labels.\n */\nexport const WB_AGGREGATE_LABELS: Readonly<Record<string, string>> = {\n  EUU: 'European Union',\n  EMU: 'Euro area',\n  OED: 'OECD members',\n  WLD: 'World',\n  ECS: 'Europe & Central Asia',\n  NAC: 'North America',\n  EAS: 'East Asia & Pacific',\n  SSF: 'Sub-Saharan Africa',\n} as const;\n\n/**\n * World Bank indicator IDs relevant to EU Parliament policy analysis.\n *\n * ⚠️ **AI Agents**: This is a convenience subset of 25 core indicators used by\n * TypeScript formatting code. The World Bank has **thousands** of indicators.\n * For article/analysis generation:\n * - Read `analysis/worldbank/indicator-catalog.md` for the full **200+ indicator** reference\n * - Use `search-indicators` MCP tool to discover indicators on demand by keyword\n * - See `analysis/worldbank/use-cases.md` for when each indicator type adds value\n */\nexport const POLICY_INDICATORS: PolicyRelevantIndicators = {\n  // Macro-economic (get-economic-data)\n  gdp: 'NY.GDP.MKTP.CD',\n  gdpGrowth: 'NY.GDP.MKTP.KD.ZG',\n  gdpPerCapita: 'NY.GDP.PCAP.CD',\n  gniPerCapita: 'NY.GNP.PCAP.CD',\n  inflation: 'FP.CPI.TOTL.ZG',\n  unemployment: 'SL.UEM.TOTL.ZS',\n  exportsGdp: 'NE.EXP.GNFS.ZS',\n  fdiNet: 'BN.KLT.DINV.CD',\n\n  // Trade & fiscal\n  trade: 'NE.TRD.GNFS.ZS',\n  taxRevenue: 'GC.TAX.TOTL.GD.ZS',\n  govExpenditure: 'NE.CON.GOVT.ZS',\n  militaryExpenditure: 'MS.MIL.XPND.GD.ZS',\n\n  // Social (get-social-data)\n  population: 'SP.POP.TOTL',\n  lifeExpectancy: 'SP.DYN.LE00.IN',\n  birthRate: 'SP.DYN.CBRT.IN',\n  deathRate: 'SP.DYN.CDRT.IN',\n  internetUsers: 'IT.NET.USER.ZS',\n\n  // Health (get-health-data)\n  healthExpenditure: 'SH.XPD.CHEX.GD.ZS',\n  physicians: 'SH.MED.PHYS.ZS',\n  hospitalBeds: 'SH.MED.BEDS.ZS',\n\n  // Education (get-education-data)\n  educationExpenditure: 'SE.XPD.TOTL.GD.ZS',\n\n  // Environment & energy\n  co2Emissions: 'EN.ATM.CO2E.PC',\n  renewableEnergy: 'EG.FEC.RNEW.ZS',\n\n  // Research & innovation\n  rdExpenditure: 'GB.XPD.RSDV.GD.ZS',\n  hightechExports: 'TX.VAL.TECH.MF.ZS',\n} as const;\n\n// ─── CSV Parsing ─────────────────────────────────────────────────────────────\n\n/** Known CSV header aliases for each World Bank field */\nconst HEADER_ALIASES: Readonly<Record<string, readonly string[]>> = {\n  country: ['country.id', 'countryiso3code', 'country_id'],\n  countryName: ['country.value', 'country_name', 'country'],\n  indicator: ['indicator.id', 'indicator_id'],\n  indicatorName: ['indicator.value', 'indicator_name', 'indicator'],\n  date: ['date', 'year'],\n  value: ['value'],\n};\n\n/**\n * Find the column index for a field by matching header aliases.\n *\n * @param headers - Lowercase header names\n * @param aliases - Allowed header aliases for the field\n * @returns Column index or -1 if not found\n */\nfunction findColumnIndex(headers: readonly string[], aliases: readonly string[]): number {\n  return headers.findIndex((h) => aliases.includes(h));\n}\n\n/**\n * Safely read a column value from a row.\n *\n * @param cols - Split row columns\n * @param idx - Column index (-1 means absent)\n * @returns Column value or empty string\n */\nfunction readCol(cols: readonly string[], idx: number): string {\n  return idx >= 0 ? (cols[idx] ?? '') : '';\n}\n\n/**\n * Split a CSV line respecting quoted fields (RFC 4180).\n * Commas inside double-quoted values are preserved as part of the field.\n * Escaped quotes (`\"\"`) inside a quoted field are treated as a literal `\"`.\n *\n * @param line - A single CSV row\n * @returns Array of field values with surrounding quotes removed\n */\nfunction splitCSVLine(line: string): string[] {\n  const fields: string[] = [];\n  let current = '';\n  let inQuotes = false;\n\n  for (let i = 0; i < line.length; i++) {\n    const ch = line[i] ?? '';\n    if (ch === '\"') {\n      if (inQuotes && i + 1 < line.length && line[i + 1] === '\"') {\n        // RFC 4180 escaped quote: \"\" → literal \"\n        current += '\"';\n        i++; // skip the second quote\n      } else {\n        inQuotes = !inQuotes;\n      }\n    } else if (ch === ',' && !inQuotes) {\n      fields.push(current.trim());\n      current = '';\n    } else {\n      current += ch;\n    }\n  }\n  fields.push(current.trim());\n  return fields;\n}\n\n/**\n * Parse CSV data from World Bank MCP response into structured indicator objects.\n * The World Bank MCP server returns indicator data as CSV text via pandas.\n * Handles quoted fields that may contain commas (e.g., indicator names).\n *\n * @param csvText - Raw CSV text from the MCP tool response (accepts null/undefined for convenience)\n * @returns Array of parsed World Bank indicator data points\n */\nexport function parseWorldBankCSV(csvText: string | null | undefined): WorldBankIndicator[] {\n  if (!csvText || typeof csvText !== 'string') {\n    return [];\n  }\n\n  const lines = csvText.trim().split(/\\r?\\n/);\n  if (lines.length < 2) {\n    return [];\n  }\n\n  const headers = splitCSVLine(lines[0] ?? '').map((h) => h.toLowerCase());\n  const colMap = Object.fromEntries(\n    Object.entries(HEADER_ALIASES).map(([key, aliases]) => [key, findColumnIndex(headers, aliases)])\n  );\n\n  const results: WorldBankIndicator[] = [];\n\n  for (let i = 1; i < lines.length; i++) {\n    const cols = splitCSVLine(lines[i] ?? '');\n    const rawValue = readCol(cols, colMap['value'] ?? -1);\n    const parsedValue = rawValue !== '' ? Number(rawValue) : null;\n    const yearStr = readCol(cols, colMap['date'] ?? -1);\n    const year = yearStr ? parseInt(yearStr, 10) : 0;\n\n    if (year > 0) {\n      results.push({\n        countryId: readCol(cols, colMap['country'] ?? -1),\n        countryName: readCol(cols, colMap['countryName'] ?? -1),\n        indicatorId: readCol(cols, colMap['indicator'] ?? -1),\n        indicatorName: readCol(cols, colMap['indicatorName'] ?? -1),\n        year,\n        value: Number.isFinite(parsedValue) ? parsedValue : null,\n      });\n    }\n  }\n\n  return results;\n}\n\n// ─── Value Formatting ────────────────────────────────────────────────────────\n\n/**\n * Format a numeric value for display based on the indicator type.\n *\n * @param value - The numeric value to format\n * @param indicatorId - The World Bank indicator ID (determines formatting style)\n * @returns Formatted display string\n */\nexport function formatIndicatorValue(value: number | null, indicatorId: string): string {\n  if (value === null || !Number.isFinite(value)) {\n    return 'N/A';\n  }\n\n  // GDP - format as currency with magnitude suffix\n  if (indicatorId === POLICY_INDICATORS.gdp) {\n    if (Math.abs(value) >= 1e12) {\n      return `$${(value / 1e12).toFixed(1)}T`;\n    }\n    if (Math.abs(value) >= 1e9) {\n      return `$${(value / 1e9).toFixed(1)}B`;\n    }\n    if (Math.abs(value) >= 1e6) {\n      return `$${(value / 1e6).toFixed(1)}M`;\n    }\n    return `$${value.toFixed(0)}`;\n  }\n\n  // Population - format with magnitude suffix\n  if (indicatorId === POLICY_INDICATORS.population) {\n    if (Math.abs(value) >= 1e9) {\n      return `${(value / 1e9).toFixed(2)}B`;\n    }\n    if (Math.abs(value) >= 1e6) {\n      return `${(value / 1e6).toFixed(1)}M`;\n    }\n    return `${value.toFixed(0)}`;\n  }\n\n  // Percentage indicators\n  if (\n    indicatorId === POLICY_INDICATORS.gdpGrowth ||\n    indicatorId === POLICY_INDICATORS.inflation ||\n    indicatorId === POLICY_INDICATORS.unemployment ||\n    indicatorId === POLICY_INDICATORS.trade ||\n    indicatorId === POLICY_INDICATORS.taxRevenue ||\n    indicatorId === POLICY_INDICATORS.govExpenditure ||\n    indicatorId === POLICY_INDICATORS.militaryExpenditure ||\n    indicatorId === POLICY_INDICATORS.exportsGdp ||\n    indicatorId === POLICY_INDICATORS.healthExpenditure ||\n    indicatorId === POLICY_INDICATORS.educationExpenditure ||\n    indicatorId === POLICY_INDICATORS.internetUsers ||\n    indicatorId === POLICY_INDICATORS.renewableEnergy ||\n    indicatorId === POLICY_INDICATORS.rdExpenditure ||\n    indicatorId === POLICY_INDICATORS.hightechExports\n  ) {\n    return `${value.toFixed(1)}%`;\n  }\n\n  // CO2 emissions - metric tons per capita\n  if (indicatorId === POLICY_INDICATORS.co2Emissions) {\n    return `${value.toFixed(1)} t/cap`;\n  }\n\n  return value.toFixed(2);\n}\n\n// ─── Most Recent Value ───────────────────────────────────────────────────────\n\n/**\n * Extract the most recent non-null data point from a series of World Bank indicators.\n *\n * @param indicators - Array of indicator data points\n * @returns The most recent data point with a non-null value, or null if none found\n */\nexport function getMostRecentValue(\n  indicators: readonly WorldBankIndicator[]\n): WorldBankIndicator | null {\n  const withValues = indicators.filter((i) => i.value !== null);\n  if (withValues.length === 0) {\n    return null;\n  }\n  withValues.sort((a, b) => b.year - a.year);\n  return withValues[0] ?? null;\n}\n\n// ─── Economic Context Builder ────────────────────────────────────────────────\n\n/**\n * Build an economic context summary from raw World Bank indicator data.\n *\n * @param countryCode - EU member state ISO2 code\n * @param countryName - Country display name\n * @param indicatorData - Map of indicator ID to parsed data points\n * @returns Structured economic context for article enrichment\n */\nexport function buildEconomicContext(\n  countryCode: string,\n  countryName: string,\n  indicatorData: ReadonlyMap<string, readonly WorldBankIndicator[]>\n): EconomicContext {\n  const indicators: EconomicIndicatorSummary[] = [];\n\n  const indicatorNames: Record<string, string> = {\n    [POLICY_INDICATORS.gdp]: 'GDP',\n    [POLICY_INDICATORS.gdpGrowth]: 'GDP Growth',\n    [POLICY_INDICATORS.gdpPerCapita]: 'GDP per Capita',\n    [POLICY_INDICATORS.gniPerCapita]: 'GNI per Capita',\n    [POLICY_INDICATORS.inflation]: 'Inflation',\n    [POLICY_INDICATORS.unemployment]: 'Unemployment',\n    [POLICY_INDICATORS.exportsGdp]: 'Exports (% of GDP)',\n    [POLICY_INDICATORS.fdiNet]: 'FDI Net Inflows',\n    [POLICY_INDICATORS.trade]: 'Trade (% of GDP)',\n    [POLICY_INDICATORS.taxRevenue]: 'Tax Revenue (% of GDP)',\n    [POLICY_INDICATORS.govExpenditure]: 'Gov. Expenditure (% of GDP)',\n    [POLICY_INDICATORS.militaryExpenditure]: 'Military Expenditure (% of GDP)',\n    [POLICY_INDICATORS.population]: 'Population',\n    [POLICY_INDICATORS.lifeExpectancy]: 'Life Expectancy',\n    [POLICY_INDICATORS.birthRate]: 'Birth Rate',\n    [POLICY_INDICATORS.deathRate]: 'Death Rate',\n    [POLICY_INDICATORS.internetUsers]: 'Internet Users (%)',\n    [POLICY_INDICATORS.healthExpenditure]: 'Health Expenditure (% of GDP)',\n    [POLICY_INDICATORS.physicians]: 'Physicians (per 1,000)',\n    [POLICY_INDICATORS.hospitalBeds]: 'Hospital Beds (per 1,000)',\n    [POLICY_INDICATORS.educationExpenditure]: 'Education Expenditure (% of GDP)',\n    [POLICY_INDICATORS.co2Emissions]: 'CO₂ Emissions',\n    [POLICY_INDICATORS.renewableEnergy]: 'Renewable Energy (%)',\n    [POLICY_INDICATORS.rdExpenditure]: 'R&D Expenditure (% of GDP)',\n    [POLICY_INDICATORS.hightechExports]: 'High-Tech Exports (%)',\n  };\n\n  for (const [indicatorId, dataPoints] of indicatorData) {\n    const recent = getMostRecentValue(dataPoints);\n    if (recent) {\n      indicators.push({\n        name: indicatorNames[indicatorId] ?? indicatorId,\n        indicatorId,\n        value: recent.value,\n        year: recent.year,\n        formatted: formatIndicatorValue(recent.value, indicatorId),\n      });\n    }\n  }\n\n  return {\n    countryCode,\n    countryName,\n    indicators,\n    dataTimestamp: new Date().toISOString(),\n  };\n}\n\n// ─── EU Country Lookup ───────────────────────────────────────────────────────\n\n/**\n * Get the World Bank country code for an EU member state.\n *\n * @param iso2Code - EU member state ISO 3166-1 alpha-2 code (e.g., 'DE', 'FR')\n * @returns World Bank alpha-3 code or null if not an EU member state\n */\nexport function getWorldBankCountryCode(iso2Code: string): string | null {\n  const upper = iso2Code.toUpperCase();\n  return EU_COUNTRY_CODES[upper] ?? null;\n}\n\n/**\n * Check if a country code corresponds to an EU member state.\n *\n * @param iso2Code - ISO 3166-1 alpha-2 country code\n * @returns True if the country is an EU member state\n */\nexport function isEUMemberState(iso2Code: string): boolean {\n  return iso2Code.toUpperCase() in EU_COUNTRY_CODES;\n}\n\n// ─── HTML Context Section ────────────────────────────────────────────────────\n\n/**\n * Generate an HTML section with economic context data for article embedding.\n *\n * Note: UI strings are currently in English. A future enhancement should accept\n * a `lang` parameter and use localized string maps (similar to `WEEK_AHEAD_STRINGS`)\n * to support all 14 article languages.\n *\n * @param context - Economic context data\n * @returns Sanitized HTML string for the economic context section\n */\nexport function buildEconomicContextHTML(context: EconomicContext): string {\n  if (context.indicators.length === 0) {\n    return '';\n  }\n\n  const rows = context.indicators\n    .map(\n      (ind) =>\n        `<tr><td>${escapeHTML(ind.name)}</td><td>${escapeHTML(ind.formatted)}</td><td>${escapeHTML(String(ind.year))}</td></tr>`\n    )\n    .join('\\n');\n\n  return `<section class=\"economic-context\" aria-label=\"Economic indicators for ${escapeHTML(context.countryName)}\">\n<h2>Economic Context: ${escapeHTML(context.countryName)}</h2>\n<table>\n<caption>Economic indicators for ${escapeHTML(context.countryName)}</caption>\n<thead><tr><th scope=\"col\">Indicator</th><th scope=\"col\">Value</th><th scope=\"col\">Year</th></tr></thead>\n<tbody>\n${rows}\n</tbody>\n</table>\n<p class=\"data-source\">Source: World Bank Open Data</p>\n</section>`;\n}\n","usedDeprecatedRules":[]}]