euparliamentmonitor 0.8.58 → 0.8.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -492,8 +492,8 @@ EU Parliament Monitor implements **security-by-design** under the [Hack23 ISMS](
492
492
 
493
493
  ### Requirements
494
494
 
495
- - **Node.js** 25 or higher
496
- - **npm** 10 or higher (ships with Node.js 25)
495
+ - **Node.js** 26 or higher
496
+ - **npm** 10 or higher (ships with Node.js 26)
497
497
  - **Git**
498
498
 
499
499
  ### From source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.8.58",
3
+ "version": "0.8.59",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -168,7 +168,7 @@
168
168
  "vitest": "4.1.5"
169
169
  },
170
170
  "engines": {
171
- "node": ">=25"
171
+ "node": ">=26"
172
172
  },
173
173
  "dependencies": {
174
174
  "european-parliament-mcp-server": "1.2.21",
@@ -157,7 +157,7 @@ export const ARTICLE_HORIZONS = {
157
157
  perspective: CATEGORY_PERSPECTIVE[ArticleCategory.WEEK_AHEAD],
158
158
  timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.WEEK_AHEAD],
159
159
  dataWindow: { direction: 'forward', days: 7, anchor: 'today' },
160
- cadence: { cron: '0 6 * * 0', description: 'Weekly — Sunday 06:00 UTC' },
160
+ cadence: { cron: '0 7 * * 5', description: 'Weekly — Friday 07:00 UTC' },
161
161
  primaryFeeds: [...STANDARD_FEEDS],
162
162
  mandatoryArtifacts: [...PROSPECTIVE_MANDATORY, A_FORWARD_PROJECTION],
163
163
  optionalArtifacts: [A_EXEC_BRIEF],
@@ -171,7 +171,7 @@ export const ARTICLE_HORIZONS = {
171
171
  perspective: CATEGORY_PERSPECTIVE[ArticleCategory.MONTH_AHEAD],
172
172
  timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.MONTH_AHEAD],
173
173
  dataWindow: { direction: 'forward', days: 30, anchor: 'today' },
174
- cadence: { cron: '0 6 1 * *', description: 'Monthly — 1st @ 06:00 UTC' },
174
+ cadence: { cron: '0 8 1 * *', description: 'Monthly — 1st @ 08:00 UTC' },
175
175
  primaryFeeds: [...STANDARD_FEEDS],
176
176
  mandatoryArtifacts: [...PROSPECTIVE_MANDATORY, A_FORWARD_PROJECTION],
177
177
  optionalArtifacts: [A_EXEC_BRIEF],
@@ -222,7 +222,7 @@ export const ARTICLE_HORIZONS = {
222
222
  timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.WEEK_IN_REVIEW],
223
223
  // ADR-006: D-8 → D-36 reporting window for roll-call publication delay.
224
224
  dataWindow: { direction: 'backward', days: 28, anchor: 'today' },
225
- cadence: { cron: '0 6 * * 6', description: 'Weekly — Saturday 06:00 UTC' },
225
+ cadence: { cron: '0 9 * * 6', description: 'Weekly — Saturday 09:00 UTC' },
226
226
  primaryFeeds: [...STANDARD_FEEDS, 'get_voting_records'],
227
227
  mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
228
228
  optionalArtifacts: [A_EXEC_BRIEF],
@@ -236,7 +236,7 @@ export const ARTICLE_HORIZONS = {
236
236
  perspective: CATEGORY_PERSPECTIVE[ArticleCategory.MONTH_IN_REVIEW],
237
237
  timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.MONTH_IN_REVIEW],
238
238
  dataWindow: { direction: 'backward', days: 30, anchor: 'today' },
239
- cadence: { cron: '0 6 5 * *', description: 'Monthly — 5th @ 06:00 UTC' },
239
+ cadence: { cron: '0 10 28 * *', description: 'Monthly — 28th @ 10:00 UTC' },
240
240
  primaryFeeds: [...STANDARD_FEEDS, 'get_voting_records'],
241
241
  mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
242
242
  optionalArtifacts: [A_EXEC_BRIEF],
@@ -328,7 +328,7 @@ export const ARTICLE_HORIZONS = {
328
328
  slug: 'breaking',
329
329
  perspective: CATEGORY_PERSPECTIVE[ArticleCategory.BREAKING_NEWS],
330
330
  dataWindow: { direction: 'point', anchor: 'today' },
331
- cadence: { cron: '0 */4 * * *', description: 'Every 4 hours' },
331
+ cadence: { cron: '0 */6 * * *', description: 'Every 6 hours' },
332
332
  primaryFeeds: [...STANDARD_FEEDS],
333
333
  mandatoryArtifacts: [
334
334
  A_SIGNIFICANCE,
@@ -357,7 +357,7 @@ export const ARTICLE_HORIZONS = {
357
357
  slug: 'committee-reports',
358
358
  perspective: CATEGORY_PERSPECTIVE[ArticleCategory.COMMITTEE_REPORTS],
359
359
  dataWindow: { direction: 'backward', days: 30, anchor: 'today' },
360
- cadence: { cron: '0 7 * * 1', description: 'WeeklyMonday 07:00 UTC' },
360
+ cadence: { cron: '0 4 * * 1-5', description: 'WeekdaysMon–Fri 04:00 UTC' },
361
361
  primaryFeeds: [...STANDARD_FEEDS, 'get_committee_documents'],
362
362
  mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
363
363
  optionalArtifacts: [A_EXEC_BRIEF],
@@ -370,7 +370,7 @@ export const ARTICLE_HORIZONS = {
370
370
  slug: 'motions',
371
371
  perspective: CATEGORY_PERSPECTIVE[ArticleCategory.MOTIONS],
372
372
  dataWindow: { direction: 'backward', days: 30, anchor: 'today' },
373
- cadence: { cron: '0 7 * * 2', description: 'WeeklyTuesday 07:00 UTC' },
373
+ cadence: { cron: '0 6 * * 1-5', description: 'WeekdaysMon–Fri 06:00 UTC' },
374
374
  primaryFeeds: [...STANDARD_FEEDS, 'get_voting_records'],
375
375
  mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
376
376
  optionalArtifacts: [A_EXEC_BRIEF],
@@ -383,7 +383,7 @@ export const ARTICLE_HORIZONS = {
383
383
  slug: 'propositions',
384
384
  perspective: CATEGORY_PERSPECTIVE[ArticleCategory.PROPOSITIONS],
385
385
  dataWindow: { direction: 'forward', days: 90, anchor: 'today' },
386
- cadence: { cron: '0 7 * * 3', description: 'WeeklyWednesday 07:00 UTC' },
386
+ cadence: { cron: '0 5 * * 1-5', description: 'WeekdaysMon–Fri 05:00 UTC' },
387
387
  primaryFeeds: [...STANDARD_FEEDS, 'get_procedures'],
388
388
  mandatoryArtifacts: [...PROSPECTIVE_MANDATORY],
389
389
  optionalArtifacts: [A_PIPELINE_FORECAST, A_EXEC_BRIEF],
@@ -0,0 +1,91 @@
1
+ /** JSON-RPC 2.0 request (minimal surface). */
2
+ export interface JsonRpcRequest {
3
+ jsonrpc: '2.0';
4
+ id: number | string | null;
5
+ method: string;
6
+ params?: Record<string, unknown>;
7
+ }
8
+ /** JSON-RPC 2.0 response (success). */
9
+ export interface JsonRpcSuccess {
10
+ jsonrpc: '2.0';
11
+ id: number | string | null;
12
+ result: unknown;
13
+ }
14
+ /** JSON-RPC 2.0 response (error). */
15
+ export interface JsonRpcError {
16
+ jsonrpc: '2.0';
17
+ id: number | string | null;
18
+ error: {
19
+ code: number;
20
+ message: string;
21
+ };
22
+ }
23
+ /** MCP tool-call content item. */
24
+ export interface McpContentItem {
25
+ type: 'text';
26
+ text: string;
27
+ }
28
+ /** MCP tool-call result envelope. */
29
+ export interface McpToolResult {
30
+ content: McpContentItem[];
31
+ }
32
+ /**
33
+ * Returns `true` when `url` is allowed by the IMF-only fetch-proxy policy.
34
+ *
35
+ * Allowed: `https://dataservices.imf.org/REST/SDMX_3.0/...`
36
+ *
37
+ * @param url - Raw URL string to validate.
38
+ * @returns Whether the URL is permitted.
39
+ */
40
+ export declare function isAllowedImfUrl(url: string): boolean;
41
+ /**
42
+ * Serialize a JSON-RPC response to a newline-terminated string.
43
+ *
44
+ * Uses `String.fromCharCode(10)` instead of `'\n'` so that inlined
45
+ * (minified) versions of this code remain safe in single-quoted strings
46
+ * (the AWF YAML serializer rejects bare newlines in entrypointArgs).
47
+ *
48
+ * @param obj - Serializable object.
49
+ * @returns `JSON.stringify(obj) + '\n'`
50
+ */
51
+ export declare function toWire(obj: unknown): string;
52
+ /**
53
+ * Build a success response for the `initialize` handshake.
54
+ *
55
+ * @param id - Request id to echo.
56
+ * @returns JSON-RPC success with MCP 2024-11-05 capabilities.
57
+ */
58
+ export declare function handleInitialize(id: number | string | null): JsonRpcSuccess;
59
+ /**
60
+ * Build the `tools/list` response advertising the single `fetch_url` tool.
61
+ *
62
+ * @param id - Request id to echo.
63
+ * @returns JSON-RPC success with the tool descriptor array.
64
+ */
65
+ export declare function handleToolsList(id: number | string | null): JsonRpcSuccess;
66
+ /**
67
+ * Execute the `fetch_url` tool call.
68
+ *
69
+ * Only URLs matching the IMF SDMX 3.0 allowlist are permitted. Non-matching
70
+ * or malformed URLs receive a JSON-RPC error response; HTTP errors and network
71
+ * failures also surface as errors.
72
+ *
73
+ * @param id - Request id to echo.
74
+ * @param url - URL to fetch.
75
+ * @param fetchImpl - Injectable `fetch` implementation (defaults to global).
76
+ * @returns JSON-RPC success or error.
77
+ */
78
+ export declare function handleFetchUrl(id: number | string | null, url: string | undefined, fetchImpl?: typeof fetch): Promise<JsonRpcSuccess | JsonRpcError>;
79
+ /**
80
+ * Run the fetch-proxy MCP server, reading JSON-RPC messages from `input` and
81
+ * writing responses to `output`.
82
+ *
83
+ * Does not resolve until the input stream closes.
84
+ *
85
+ * @param input - Readable stream to read JSON-RPC lines from (default: stdin).
86
+ * @param output - Writable stream to write responses to (default: stdout).
87
+ * @param fetchImpl - Injectable fetch (default: global fetch).
88
+ * @returns Promise that resolves when the input stream closes.
89
+ */
90
+ export declare function runServer(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, fetchImpl?: typeof fetch): Promise<void>;
91
+ //# sourceMappingURL=fetch-proxy-server.d.ts.map
@@ -0,0 +1,249 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * @module MCP/FetchProxyServer
5
+ * @description IMF-only MCP fetch-proxy server.
6
+ *
7
+ * Implements the Model Context Protocol (JSON-RPC 2.0 over stdio) with a
8
+ * single tool — `fetch_url` — that proxies HTTPS GET requests to the IMF
9
+ * SDMX 3.0 REST API at `https://dataservices.imf.org/REST/SDMX_3.0/`.
10
+ *
11
+ * ## Why this exists
12
+ *
13
+ * The Agent Workflow Firewall (AWF) runs a Squid proxy that blocks outbound
14
+ * HTTPS even to allowlisted domains such as `dataservices.imf.org`. This
15
+ * server is mounted as an MCP container in gh-aw workflows; because MCP
16
+ * containers run in a Docker network with direct outbound access (bypassing
17
+ * Squid), `fetch_url` can reach the IMF API while the main runner cannot.
18
+ *
19
+ * The server only allows calls to `https://dataservices.imf.org/REST/SDMX_3.0/`
20
+ * — all other URLs are rejected with an error message.
21
+ *
22
+ * ## Usage
23
+ *
24
+ * ```
25
+ * node scripts/mcp/fetch-proxy-server.js
26
+ * ```
27
+ *
28
+ * Or via `node -e <inlined-code>` in the gh-aw `entrypointArgs` (see
29
+ * `.github/workflows/shared/mcp/news-mcp-servers.md`).
30
+ *
31
+ * ## MCP tools exposed
32
+ *
33
+ * - `fetch_url` — fetches an IMF SDMX URL and returns its body as text.
34
+ *
35
+ * @author Hack23 AB
36
+ * @license Apache-2.0
37
+ */
38
+ import * as readline from 'node:readline';
39
+ // ─── Constants ────────────────────────────────────────────────────────────────
40
+ const IMF_ALLOWED_HOSTNAME = 'dataservices.imf.org';
41
+ const IMF_ALLOWED_PATH_PREFIX = '/REST/SDMX_3.0/';
42
+ const IMF_ALLOWED_PROTOCOL = 'https:';
43
+ /** Per-request fetch timeout (ms). */
44
+ const FETCH_TIMEOUT_MS = 180_000;
45
+ // ─── Allowlist check ─────────────────────────────────────────────────────────
46
+ /**
47
+ * Returns `true` when `url` is allowed by the IMF-only fetch-proxy policy.
48
+ *
49
+ * Allowed: `https://dataservices.imf.org/REST/SDMX_3.0/...`
50
+ *
51
+ * @param url - Raw URL string to validate.
52
+ * @returns Whether the URL is permitted.
53
+ */
54
+ export function isAllowedImfUrl(url) {
55
+ let parsed;
56
+ try {
57
+ parsed = new URL(url);
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ return (parsed.protocol === IMF_ALLOWED_PROTOCOL &&
63
+ parsed.hostname === IMF_ALLOWED_HOSTNAME &&
64
+ (parsed.port === '' || parsed.port === '443') &&
65
+ parsed.username === '' &&
66
+ parsed.password === '' &&
67
+ parsed.pathname.startsWith(IMF_ALLOWED_PATH_PREFIX));
68
+ }
69
+ // ─── Transport helpers ───────────────────────────────────────────────────────
70
+ /**
71
+ * Serialize a JSON-RPC response to a newline-terminated string.
72
+ *
73
+ * Uses `String.fromCharCode(10)` instead of `'\n'` so that inlined
74
+ * (minified) versions of this code remain safe in single-quoted strings
75
+ * (the AWF YAML serializer rejects bare newlines in entrypointArgs).
76
+ *
77
+ * @param obj - Serializable object.
78
+ * @returns `JSON.stringify(obj) + '\n'`
79
+ */
80
+ export function toWire(obj) {
81
+ return JSON.stringify(obj) + String.fromCharCode(10);
82
+ }
83
+ // ─── MCP handlers ────────────────────────────────────────────────────────────
84
+ /**
85
+ * Build a success response for the `initialize` handshake.
86
+ *
87
+ * @param id - Request id to echo.
88
+ * @returns JSON-RPC success with MCP 2024-11-05 capabilities.
89
+ */
90
+ export function handleInitialize(id) {
91
+ return {
92
+ jsonrpc: '2.0',
93
+ id,
94
+ result: {
95
+ protocolVersion: '2024-11-05',
96
+ capabilities: { tools: {} },
97
+ },
98
+ };
99
+ }
100
+ /**
101
+ * Build the `tools/list` response advertising the single `fetch_url` tool.
102
+ *
103
+ * @param id - Request id to echo.
104
+ * @returns JSON-RPC success with the tool descriptor array.
105
+ */
106
+ export function handleToolsList(id) {
107
+ return {
108
+ jsonrpc: '2.0',
109
+ id,
110
+ result: {
111
+ tools: [
112
+ {
113
+ name: 'fetch_url',
114
+ description: 'Fetch an IMF SDMX URL and return its content',
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: {
118
+ url: {
119
+ type: 'string',
120
+ description: 'IMF SDMX URL to fetch',
121
+ },
122
+ },
123
+ required: ['url'],
124
+ },
125
+ },
126
+ ],
127
+ },
128
+ };
129
+ }
130
+ /**
131
+ * Execute the `fetch_url` tool call.
132
+ *
133
+ * Only URLs matching the IMF SDMX 3.0 allowlist are permitted. Non-matching
134
+ * or malformed URLs receive a JSON-RPC error response; HTTP errors and network
135
+ * failures also surface as errors.
136
+ *
137
+ * @param id - Request id to echo.
138
+ * @param url - URL to fetch.
139
+ * @param fetchImpl - Injectable `fetch` implementation (defaults to global).
140
+ * @returns JSON-RPC success or error.
141
+ */
142
+ export async function handleFetchUrl(id, url, fetchImpl = globalThis.fetch) {
143
+ if (!url || !isAllowedImfUrl(url)) {
144
+ return {
145
+ jsonrpc: '2.0',
146
+ id,
147
+ error: {
148
+ code: -1,
149
+ message: `fetch_url only allows ${IMF_ALLOWED_PROTOCOL}//${IMF_ALLOWED_HOSTNAME}${IMF_ALLOWED_PATH_PREFIX} URLs`,
150
+ },
151
+ };
152
+ }
153
+ try {
154
+ const response = await fetchImpl(url, {
155
+ headers: { Accept: 'application/json' },
156
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
157
+ });
158
+ if (!response.ok) {
159
+ return {
160
+ jsonrpc: '2.0',
161
+ id,
162
+ error: { code: -1, message: `HTTP ${response.status} ${response.statusText}` },
163
+ };
164
+ }
165
+ const text = await response.text();
166
+ return {
167
+ jsonrpc: '2.0',
168
+ id,
169
+ result: { content: [{ type: 'text', text }] },
170
+ };
171
+ }
172
+ catch (err) {
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ return { jsonrpc: '2.0', id, error: { code: -1, message } };
175
+ }
176
+ }
177
+ // ─── Main server loop ─────────────────────────────────────────────────────────
178
+ /**
179
+ * Run the fetch-proxy MCP server, reading JSON-RPC messages from `input` and
180
+ * writing responses to `output`.
181
+ *
182
+ * Does not resolve until the input stream closes.
183
+ *
184
+ * @param input - Readable stream to read JSON-RPC lines from (default: stdin).
185
+ * @param output - Writable stream to write responses to (default: stdout).
186
+ * @param fetchImpl - Injectable fetch (default: global fetch).
187
+ * @returns Promise that resolves when the input stream closes.
188
+ */
189
+ export function runServer(input = process.stdin, output = process.stdout, fetchImpl = globalThis.fetch) {
190
+ const send = (obj) => {
191
+ output.write(toWire(obj));
192
+ };
193
+ const rl = readline.createInterface({ input, terminal: false });
194
+ return new Promise((resolve) => {
195
+ rl.on('line', (line) => {
196
+ let requestId = null;
197
+ void (async () => {
198
+ try {
199
+ const msg = JSON.parse(line);
200
+ requestId = msg.id ?? null;
201
+ if (msg.method === 'initialize') {
202
+ send(handleInitialize(msg.id ?? null));
203
+ }
204
+ else if (msg.method === 'notifications/initialized') {
205
+ // No-op — notification, no response required.
206
+ }
207
+ else if (msg.method === 'tools/list') {
208
+ send(handleToolsList(msg.id ?? null));
209
+ }
210
+ else if (msg.method === 'tools/call') {
211
+ const params = msg.params;
212
+ if (params?.name === 'fetch_url') {
213
+ const url = params.arguments?.url;
214
+ const result = await handleFetchUrl(msg.id ?? null, url, fetchImpl);
215
+ send(result);
216
+ }
217
+ else {
218
+ send({
219
+ jsonrpc: '2.0',
220
+ id: msg.id ?? null,
221
+ result: { content: [{ type: 'text', text: 'unknown tool' }] },
222
+ });
223
+ }
224
+ }
225
+ else {
226
+ send({
227
+ jsonrpc: '2.0',
228
+ id: msg.id ?? null,
229
+ result: { content: [{ type: 'text', text: 'unknown method' }] },
230
+ });
231
+ }
232
+ }
233
+ catch (err) {
234
+ const message = err instanceof Error ? err.message : String(err);
235
+ send({ jsonrpc: '2.0', id: requestId, error: { code: -1, message } });
236
+ }
237
+ })();
238
+ });
239
+ rl.on('close', resolve);
240
+ });
241
+ }
242
+ // ─── Entry point ─────────────────────────────────────────────────────────────
243
+ // Run when executed directly (not imported as a module).
244
+ if (process.argv[1] !== undefined &&
245
+ (process.argv[1].endsWith('fetch-proxy-server.js') ||
246
+ process.argv[1].endsWith('fetch-proxy-server.ts'))) {
247
+ void runServer();
248
+ }
249
+ //# sourceMappingURL=fetch-proxy-server.js.map
@@ -0,0 +1,48 @@
1
+ /** Parameters for the HTML language patch operation. */
2
+ export interface HtmlLangPatchOptions {
3
+ /** BCP-47 language tag for the target locale, e.g. `"de"`, `"fr"`. */
4
+ readonly lang: string;
5
+ /** Text direction for the target locale: `"ltr"` or `"rtl"`. */
6
+ readonly langDir: 'ltr' | 'rtl';
7
+ /** Open Graph locale string, e.g. `"de_DE"`, `"ar_AR"`. */
8
+ readonly ogLocale: string;
9
+ /** Basename of the source English HTML file, e.g. `"2025-01-01-breaking.html"`. */
10
+ readonly enBasename: string;
11
+ /** Basename of the target language HTML file, e.g. `"2025-01-01-breaking-de.html"`. */
12
+ readonly langBasename: string;
13
+ }
14
+ /**
15
+ * Apply all language patches to the given HTML content string and return the
16
+ * patched string.
17
+ *
18
+ * Scope is intentionally narrow: only document-level lang/dir attributes,
19
+ * JSON-LD language fields, og:locale, and self-referential URL fields are
20
+ * rewritten. Body content is left untouched.
21
+ *
22
+ * @param content - Raw HTML file content.
23
+ * @param opts - Patch parameters.
24
+ * @returns Patched HTML content.
25
+ */
26
+ export declare function patchHtmlContent(content: string, opts: HtmlLangPatchOptions): string;
27
+ /**
28
+ * Read an HTML file, apply language patches, and write the result back to the
29
+ * same file (in-place).
30
+ *
31
+ * @param filePath - Absolute path to the target HTML file.
32
+ * @param opts - Patch parameters.
33
+ * @param readFileImpl - Injectable read function (default: `fs.readFileSync`).
34
+ * @param writeFileImpl - Injectable write function (default: `fs.writeFileSync`).
35
+ * @throws If the file cannot be read or written.
36
+ */
37
+ export declare function patchHtmlLang(filePath: string, opts: HtmlLangPatchOptions, readFileImpl?: (p: string, enc: BufferEncoding) => string, writeFileImpl?: (p: string, data: string, enc: BufferEncoding) => void): void;
38
+ /**
39
+ * CLI entry point. Positional argument order:
40
+ *
41
+ * ```
42
+ * node html-lang-patcher.js <filePath> <lang> <langDir> <ogLocale> <enBasename> <langBasename>
43
+ * ```
44
+ *
45
+ * @param argv - `process.argv` array (or equivalent for testing).
46
+ */
47
+ export declare function runCli(argv?: string[]): void;
48
+ //# sourceMappingURL=html-lang-patcher.d.ts.map
@@ -0,0 +1,138 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * @module MCP/HtmlLangPatcher
5
+ * @description Patches structural HTML metadata in a copied English article
6
+ * file so that the file's language-specific markup matches the target locale.
7
+ *
8
+ * ## What this does
9
+ *
10
+ * When a new article is generated in English and then copied to language-
11
+ * specific placeholder files (e.g. `news/2025-01-01-breaking-de.html`),
12
+ * the copy still contains English-language metadata. This module rewrites
13
+ * only the metadata regions of the file (NOT the article body text, which
14
+ * is translated separately by an AI translation step):
15
+ *
16
+ * - `<html lang="en">` → `<html lang="<lang>">`
17
+ * - `<html dir="ltr|rtl">` → `<html dir="<langDir>">`
18
+ * - `<article lang="en">` → `<article lang="<lang>">`
19
+ * - JSON-LD `"inLanguage": "en"` → `"inLanguage": "<lang>"`
20
+ * - `<meta property="og:locale" content="...">` → target `ogLocale`
21
+ * - Self-referential URLs in `<link rel="canonical">` and
22
+ * `<meta property="og:url">` tags, and JSON-LD `@id`/`url` fields: replaces
23
+ * the English filename component with the language-specific filename.
24
+ * `rel="alternate"` / hreflang links are intentionally NOT rewritten.
25
+ *
26
+ * ## Usage
27
+ *
28
+ * ```typescript
29
+ * import { patchHtmlLang } from './html-lang-patcher.js';
30
+ *
31
+ * patchHtmlLang('/path/to/news/2025-01-01-breaking-de.html', {
32
+ * lang: 'de',
33
+ * langDir: 'ltr',
34
+ * ogLocale: 'de_DE',
35
+ * enBasename: '2025-01-01-breaking.html',
36
+ * langBasename: '2025-01-01-breaking-de.html',
37
+ * });
38
+ * ```
39
+ *
40
+ * Or use the lower-level {@link patchHtmlContent} to work with string content
41
+ * directly (without reading/writing files).
42
+ *
43
+ * @author Hack23 AB
44
+ * @license Apache-2.0
45
+ */
46
+ import * as fs from 'node:fs';
47
+ // ─── Core logic (pure — operates on string content) ──────────────────────────
48
+ /**
49
+ * Apply all language patches to the given HTML content string and return the
50
+ * patched string.
51
+ *
52
+ * Scope is intentionally narrow: only document-level lang/dir attributes,
53
+ * JSON-LD language fields, og:locale, and self-referential URL fields are
54
+ * rewritten. Body content is left untouched.
55
+ *
56
+ * @param content - Raw HTML file content.
57
+ * @param opts - Patch parameters.
58
+ * @returns Patched HTML content.
59
+ */
60
+ export function patchHtmlContent(content, opts) {
61
+ const { lang, langDir, ogLocale, enBasename, langBasename } = opts;
62
+ let c = content;
63
+ // 1. Document-level <html> and <article> lang/dir attributes
64
+ c = c.replace(/(<html\b[^>]*\s)lang="en"/, `$1lang="${lang}"`);
65
+ c = c.replace(/(<html\b[^>]*\s)dir="(?:ltr|rtl)"/, `$1dir="${langDir}"`);
66
+ c = c.replace(/(<article\b[^>]*\s)lang="en"/, `$1lang="${lang}"`);
67
+ // 2. JSON-LD inLanguage
68
+ c = c.replace(/("inLanguage"\s*:\s*")en(")/g, `$1${lang}$2`);
69
+ // 3. og:locale meta tag
70
+ c = c.replace(/(<meta\s+property="og:locale"\s+content=")[^"]*(")/g, `$1${ogLocale}$2`);
71
+ // 3b. Content-Language meta tag
72
+ c = c.replace(/(<meta\s+http-equiv="Content-Language"\s+content=")[^"]*(")/g, `$1${lang}$2`);
73
+ // 4. Self-referential URL fields.
74
+ // Restricted to rel="canonical" links and property="og:url" meta only —
75
+ // rel="alternate"/hreflang links are intentionally excluded.
76
+ const enEsc = enBasename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
+ // 4a. <link rel="canonical" href="..."> (any attribute order; lookahead guards rel value)
78
+ c = c.replace(/(<link\b(?=[^>]*\brel="canonical")[^>]*\shref=")([^"]*)(")/g, (_, p1, p2, p3) => p1 + p2.replace(new RegExp(enEsc, 'g'), langBasename) + p3);
79
+ // 4b. <meta property="og:url" content="..."> (any attribute order)
80
+ c = c.replace(/(<meta\b(?=[^>]*\bproperty="og:url")[^>]*\scontent=")([^"]*)(")/g, (_, p1, p2, p3) => p1 + p2.replace(new RegExp(enEsc, 'g'), langBasename) + p3);
81
+ // 4c. JSON-LD @id, url, mainEntityOfPage fields
82
+ c = c.replace(/("(?:@id|url|mainEntityOfPage)"\s*:\s*")([^"]*)(")/g, (_, j1, j2, j3) => j1 + j2.replace(new RegExp(enEsc, 'g'), langBasename) + j3);
83
+ return c;
84
+ }
85
+ // ─── File I/O wrapper ─────────────────────────────────────────────────────────
86
+ /**
87
+ * Read an HTML file, apply language patches, and write the result back to the
88
+ * same file (in-place).
89
+ *
90
+ * @param filePath - Absolute path to the target HTML file.
91
+ * @param opts - Patch parameters.
92
+ * @param readFileImpl - Injectable read function (default: `fs.readFileSync`).
93
+ * @param writeFileImpl - Injectable write function (default: `fs.writeFileSync`).
94
+ * @throws If the file cannot be read or written.
95
+ */
96
+ export function patchHtmlLang(filePath, opts, readFileImpl = fs.readFileSync, writeFileImpl = fs.writeFileSync) {
97
+ const original = readFileImpl(filePath, 'utf8');
98
+ const patched = patchHtmlContent(original, opts);
99
+ writeFileImpl(filePath, patched, 'utf8');
100
+ }
101
+ // ─── CLI entry point ──────────────────────────────────────────────────────────
102
+ /**
103
+ * CLI entry point. Positional argument order:
104
+ *
105
+ * ```
106
+ * node html-lang-patcher.js <filePath> <lang> <langDir> <ogLocale> <enBasename> <langBasename>
107
+ * ```
108
+ *
109
+ * @param argv - `process.argv` array (or equivalent for testing).
110
+ */
111
+ export function runCli(argv = process.argv) {
112
+ const [, , filePath, lang, langDir, ogLocale, enBasename, langBasename] = argv;
113
+ if (!filePath || !lang || !langDir || !ogLocale || !enBasename || !langBasename) {
114
+ process.stderr.write('Usage: html-lang-patcher <filePath> <lang> <langDir> <ogLocale> <enBasename> <langBasename>' +
115
+ String.fromCharCode(10));
116
+ process.exit(1);
117
+ return;
118
+ }
119
+ if (langDir !== 'ltr' && langDir !== 'rtl') {
120
+ process.stderr.write(`Error: langDir must be "ltr" or "rtl", got "${langDir}"` + String.fromCharCode(10));
121
+ process.exit(1);
122
+ return;
123
+ }
124
+ patchHtmlLang(filePath, {
125
+ lang,
126
+ langDir: langDir,
127
+ ogLocale,
128
+ enBasename,
129
+ langBasename,
130
+ });
131
+ }
132
+ // Run when executed directly
133
+ if (process.argv[1] !== undefined &&
134
+ (process.argv[1].endsWith('html-lang-patcher.js') ||
135
+ process.argv[1].endsWith('html-lang-patcher.ts'))) {
136
+ runCli();
137
+ }
138
+ //# sourceMappingURL=html-lang-patcher.js.map
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * @module MCP/IMFMCPClient
3
3
  * @description Native TypeScript IMF Data client — calls the IMF SDMX 3.0
4
- * REST API at {@link https://dataservices.imf.org/REST/SDMX_3.0/} directly
5
- * via `fetch()`, with no external MCP server process.
4
+ * REST API at {@link https://dataservices.imf.org/REST/SDMX_3.0/} via the
5
+ * shared IMF-only `fetch-proxy` MCP gateway in gh-aw/AWF runs and direct
6
+ * `fetch()` in local/non-AWF contexts.
6
7
  *
7
8
  * Historical note: the first Wave-1 iteration delegated to the Python
8
9
  * `c-cf/imf-data-mcp` MCP server. That dependency blocked Wave 0 rollout
@@ -220,8 +221,8 @@ export declare class IMFMCPClient {
220
221
  }): Promise<MCPToolResult>;
221
222
  /**
222
223
  * Build a full URL and GET it as text, enforcing the client-wide timeout.
223
- * Tries the MCP fetch-proxy gateway first (bypasses AWF Squid proxy in
224
- * agentic workflow sandbox), then falls back to direct fetch.
224
+ * Tries the IMF-only MCP fetch-proxy gateway first (bypasses AWF Squid
225
+ * proxy in agentic workflow sandbox), then falls back to direct fetch.
225
226
  *
226
227
  * @param path - Path (already URL-encoded) to append to the base URL.
227
228
  * @returns Response body (`text/*` or `application/*`) as a string.
@@ -476,8 +476,8 @@ export class IMFMCPClient {
476
476
  // ─── private transport helpers ─────────────────────────────────────────────
477
477
  /**
478
478
  * Build a full URL and GET it as text, enforcing the client-wide timeout.
479
- * Tries the MCP fetch-proxy gateway first (bypasses AWF Squid proxy in
480
- * agentic workflow sandbox), then falls back to direct fetch.
479
+ * Tries the IMF-only MCP fetch-proxy gateway first (bypasses AWF Squid
480
+ * proxy in agentic workflow sandbox), then falls back to direct fetch.
481
481
  *
482
482
  * @param path - Path (already URL-encoded) to append to the base URL.
483
483
  * @returns Response body (`text/*` or `application/*`) as a string.
@@ -487,8 +487,13 @@ export class IMFMCPClient {
487
487
  */
488
488
  async _getText(path) {
489
489
  const url = `${this._apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
490
- // Strategy 1: MCP fetch-proxy gateway (bypasses AWF Squid proxy)
491
- if (this._fetchProxyGatewayUrl && this._fetchProxyApiKey) {
490
+ // Strategy 1: MCP fetch-proxy gateway (bypasses AWF Squid proxy).
491
+ // The API key is optional — the gateway adds the Authorization header only
492
+ // when the key is present. Without a key the request is sent unauthenticated,
493
+ // which is sufficient for local AWF container-to-container traffic (same
494
+ // Docker network). Requiring the key here caused IMF degraded mode whenever
495
+ // EP_MCP_GATEWAY_API_KEY extraction from mcp-config.json failed silently.
496
+ if (this._fetchProxyGatewayUrl) {
492
497
  try {
493
498
  const result = await this._fetchViaGateway(url);
494
499
  if (result !== null)
@@ -525,6 +530,9 @@ export class IMFMCPClient {
525
530
  * @internal
526
531
  */
527
532
  async _fetchViaGateway(url) {
533
+ const gatewayUrl = this._fetchProxyGatewayUrl;
534
+ if (!gatewayUrl)
535
+ return null;
528
536
  const rpcRequest = {
529
537
  jsonrpc: '2.0',
530
538
  id: Date.now(),
@@ -544,7 +552,7 @@ export class IMFMCPClient {
544
552
  const controller = new AbortController();
545
553
  const timer = setTimeout(() => controller.abort(), this._timeoutMs);
546
554
  try {
547
- const response = await this._fetchImpl(this._fetchProxyGatewayUrl, {
555
+ const response = await this._fetchImpl(gatewayUrl, {
548
556
  method: 'POST',
549
557
  headers,
550
558
  body: JSON.stringify(rpcRequest),
@@ -0,0 +1,61 @@
1
+ /** Resolved gateway connection details. */
2
+ export interface GatewayConfig {
3
+ /** Raw API key (without "Bearer " prefix), or `undefined` if absent. */
4
+ readonly apiKey: string | undefined;
5
+ /** Gateway TCP port, or `undefined` if not present in the config. */
6
+ readonly port: number | undefined;
7
+ /** Gateway hostname/domain, or `undefined` if not present. */
8
+ readonly domain: string | undefined;
9
+ }
10
+ /** Internal shape of the JSON the gh-aw runtime writes. */
11
+ interface McpConfigJson {
12
+ gateway?: {
13
+ apiKey?: string;
14
+ port?: number | string;
15
+ domain?: string;
16
+ };
17
+ mcpServers?: Record<string, {
18
+ headers?: Record<string, string>;
19
+ }>;
20
+ }
21
+ /**
22
+ * Strip a leading `Bearer ` prefix (case-insensitive) from an auth string.
23
+ *
24
+ * @param raw - Raw header value that may include the prefix.
25
+ * @returns The bare token.
26
+ */
27
+ export declare function stripBearerPrefix(raw: string): string;
28
+ /**
29
+ * Extract the API key from a parsed config object.
30
+ *
31
+ * Tries four locations in priority order (see module docblock).
32
+ *
33
+ * @param config - Parsed `mcp-config.json` object.
34
+ * @returns The extracted key (Bearer-prefix stripped), or `undefined`.
35
+ */
36
+ export declare function extractApiKey(config: McpConfigJson): string | undefined;
37
+ /**
38
+ * Read and parse the gh-aw MCP config file, returning gateway connection
39
+ * details. Returns `undefined` fields when the config file is absent, the
40
+ * relevant fields are missing, or parsing fails.
41
+ *
42
+ * Never throws — all errors are silently swallowed and result in an empty
43
+ * config so that callers can always fall back to defaults.
44
+ *
45
+ * @param configPath - Absolute path to `mcp-config.json`.
46
+ * @param readFileImpl - Injectable file-read function (default: `fs.readFileSync`).
47
+ * Accepts the same `(path, encoding)` signature. Used for unit-test injection.
48
+ * @returns Parsed gateway config (fields may be `undefined`).
49
+ */
50
+ export declare function readMcpConfig(configPath: string, readFileImpl?: (path: string, encoding: BufferEncoding) => string): GatewayConfig;
51
+ /**
52
+ * Resolve the canonical MCP config file path.
53
+ *
54
+ * Prefers `$GH_AW_MCP_CONFIG` env var, then falls back to
55
+ * `~/.copilot/mcp-config.json`.
56
+ *
57
+ * @returns Absolute config file path.
58
+ */
59
+ export declare function resolveMcpConfigPath(): string;
60
+ export {};
61
+ //# sourceMappingURL=mcp-config-reader.d.ts.map
@@ -0,0 +1,143 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * @module MCP/McpConfigReader
5
+ * @description Reads the gh-aw MCP config JSON (`mcp-config.json`) and
6
+ * extracts the gateway API key and address (port + domain).
7
+ *
8
+ * ## Config format
9
+ *
10
+ * The gh-aw runtime writes a JSON file at
11
+ * `~/.copilot/mcp-config.json` (or `$GH_AW_MCP_CONFIG`) with the
12
+ * following shape (several optional paths across gh-aw versions):
13
+ *
14
+ * ```json
15
+ * {
16
+ * "gateway": {
17
+ * "apiKey": "<key>", // gh-aw <= v0.68 (legacy)
18
+ * "port": 8080,
19
+ * "domain": "host.docker.internal"
20
+ * },
21
+ * "mcpServers": {
22
+ * "european-parliament": {
23
+ * "headers": { "Authorization": "<key>" }
24
+ * },
25
+ * "fetch-proxy": {
26
+ * "headers": { "Authorization": "<key>" }
27
+ * }
28
+ * }
29
+ * }
30
+ * ```
31
+ *
32
+ * The API key is tried at four locations in priority order:
33
+ * 1. `gateway.apiKey` (gh-aw ≤ v0.68)
34
+ * 2. `mcpServers.european-parliament.headers.Authorization` (v0.69–v0.71)
35
+ * 3. `mcpServers.fetch-proxy.headers.Authorization` (v0.72+)
36
+ * 4. First `mcpServers[*].headers.Authorization` found (catch-all)
37
+ *
38
+ * @author Hack23 AB
39
+ * @license Apache-2.0
40
+ */
41
+ import * as fs from 'node:fs';
42
+ import * as path from 'node:path';
43
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
44
+ /**
45
+ * Strip a leading `Bearer ` prefix (case-insensitive) from an auth string.
46
+ *
47
+ * @param raw - Raw header value that may include the prefix.
48
+ * @returns The bare token.
49
+ */
50
+ export function stripBearerPrefix(raw) {
51
+ return raw.replace(/^bearer\s+/i, '');
52
+ }
53
+ /**
54
+ * Extract the API key from a parsed config object.
55
+ *
56
+ * Tries four locations in priority order (see module docblock).
57
+ *
58
+ * @param config - Parsed `mcp-config.json` object.
59
+ * @returns The extracted key (Bearer-prefix stripped), or `undefined`.
60
+ */
61
+ export function extractApiKey(config) {
62
+ const candidates = [
63
+ // Priority 1 — legacy gateway.apiKey
64
+ config.gateway?.apiKey,
65
+ // Priority 2 — EP MCP server header
66
+ config.mcpServers?.['european-parliament']?.headers?.['Authorization'],
67
+ // Priority 3 — fetch-proxy server header
68
+ config.mcpServers?.['fetch-proxy']?.headers?.['Authorization'],
69
+ ];
70
+ for (const candidate of candidates) {
71
+ if (candidate && candidate.trim() !== '') {
72
+ return stripBearerPrefix(candidate.trim());
73
+ }
74
+ }
75
+ // Priority 4 — first server with a non-empty Authorization header
76
+ if (config.mcpServers) {
77
+ for (const server of Object.values(config.mcpServers)) {
78
+ const auth = server?.headers?.['Authorization'];
79
+ if (auth && auth.trim() !== '') {
80
+ return stripBearerPrefix(auth.trim());
81
+ }
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ // ─── Public API ──────────────────────────────────────────────────────────────
87
+ /**
88
+ * Read and parse the gh-aw MCP config file, returning gateway connection
89
+ * details. Returns `undefined` fields when the config file is absent, the
90
+ * relevant fields are missing, or parsing fails.
91
+ *
92
+ * Never throws — all errors are silently swallowed and result in an empty
93
+ * config so that callers can always fall back to defaults.
94
+ *
95
+ * @param configPath - Absolute path to `mcp-config.json`.
96
+ * @param readFileImpl - Injectable file-read function (default: `fs.readFileSync`).
97
+ * Accepts the same `(path, encoding)` signature. Used for unit-test injection.
98
+ * @returns Parsed gateway config (fields may be `undefined`).
99
+ */
100
+ export function readMcpConfig(configPath, readFileImpl = fs.readFileSync) {
101
+ let raw;
102
+ try {
103
+ raw = readFileImpl(configPath, 'utf8');
104
+ }
105
+ catch {
106
+ return { apiKey: undefined, port: undefined, domain: undefined };
107
+ }
108
+ let config;
109
+ try {
110
+ const parsed = JSON.parse(raw);
111
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
112
+ return { apiKey: undefined, port: undefined, domain: undefined };
113
+ }
114
+ config = parsed;
115
+ }
116
+ catch {
117
+ return { apiKey: undefined, port: undefined, domain: undefined };
118
+ }
119
+ const apiKey = extractApiKey(config);
120
+ const rawPort = config.gateway?.port;
121
+ const port = rawPort !== undefined && rawPort !== '' ? Number.parseInt(String(rawPort), 10) : undefined;
122
+ const parsedPort = port !== undefined && Number.isFinite(port) ? port : undefined;
123
+ const domain = config.gateway?.domain !== undefined && config.gateway.domain !== ''
124
+ ? config.gateway.domain
125
+ : undefined;
126
+ return { apiKey, port: parsedPort, domain };
127
+ }
128
+ /**
129
+ * Resolve the canonical MCP config file path.
130
+ *
131
+ * Prefers `$GH_AW_MCP_CONFIG` env var, then falls back to
132
+ * `~/.copilot/mcp-config.json`.
133
+ *
134
+ * @returns Absolute config file path.
135
+ */
136
+ export function resolveMcpConfigPath() {
137
+ const envPath = process.env['GH_AW_MCP_CONFIG'];
138
+ if (envPath && envPath.trim() !== '')
139
+ return envPath.trim();
140
+ const home = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '/home/runner';
141
+ return path.join(home, '.copilot', 'mcp-config.json');
142
+ }
143
+ //# sourceMappingURL=mcp-config-reader.js.map
@@ -88,6 +88,17 @@ const PLACEHOLDER_PATTERNS = [
88
88
  /^TODO:/m,
89
89
  ];
90
90
 
91
+ // dataMode threshold reduction factors — when manifest.dataMode declares a
92
+ // degraded data availability state, line floors are multiplied by this factor.
93
+ // Structural checks (mermaid, WEP, Admiralty, SATs) are never reduced.
94
+ const DATA_MODE_REDUCTION = {
95
+ 'full': 1.0,
96
+ 'title-only': 0.75,
97
+ 'degraded-imf': 0.85,
98
+ 'degraded-voting': 0.85,
99
+ 'minimal': 0.65,
100
+ };
101
+
91
102
  const WEP_BAND_RE =
92
103
  /\b(Almost Certain|Highly Likely|Likely|Roughly Even|Even Chance|About even|Unlikely|Highly Unlikely|Almost No Chance|WEP\s*:)\b/i;
93
104
 
@@ -182,6 +193,7 @@ function parseArgs(argv) {
182
193
  json: false,
183
194
  strict: false,
184
195
  minLines: DEFAULT_MIN_LINES,
196
+ minLinesExplicit: false,
185
197
  thresholdsPath: null,
186
198
  };
187
199
  for (let i = 0; i < args.length; i += 1) {
@@ -193,6 +205,7 @@ function parseArgs(argv) {
193
205
  if (!Number.isFinite(n) || n < 1) usage(2);
194
206
  // The flag may only RAISE the floor — never lower it below DEFAULT_MIN_LINES.
195
207
  opts.minLines = Math.max(DEFAULT_MIN_LINES, n);
208
+ opts.minLinesExplicit = true;
196
209
  i += 1;
197
210
  } else if (a === '--thresholds') {
198
211
  opts.thresholdsPath = args[i + 1];
@@ -472,6 +485,7 @@ function validateArtifact({
472
485
  relativePath,
473
486
  rules,
474
487
  options,
488
+ dataModeReduction = 1.0,
475
489
  }) {
476
490
  const abs = path.join(runDir, relativePath);
477
491
  const result = {
@@ -493,7 +507,16 @@ function validateArtifact({
493
507
  result.lines = countLines(content);
494
508
 
495
509
  const perFloor = rules.perArtifactFloors?.[relativePath] ?? null;
496
- result.minLines = Math.max(options.minLines, perFloor ?? 0);
510
+ // dataMode reduction applies to per-artifact floors AND the default floor,
511
+ // but NOT to the CLI-provided --min-lines value. This preserves the contract
512
+ // that --min-lines can only raise floors, never lower them.
513
+ const baseFloor = perFloor != null ? perFloor : DEFAULT_MIN_LINES;
514
+ const reducedFloor = Math.max(1, Math.floor(baseFloor * dataModeReduction));
515
+ // When --min-lines is explicitly set, it acts as a hard minimum that the
516
+ // reduction cannot breach. When not set, use the reduced floor directly.
517
+ result.minLines = options.minLinesExplicit
518
+ ? Math.max(options.minLines, reducedFloor)
519
+ : reducedFloor;
497
520
  if (result.lines < result.minLines) {
498
521
  result.issues.push(
499
522
  `short:${result.lines}<${result.minLines}`,
@@ -935,6 +958,28 @@ function main() {
935
958
  );
936
959
  }
937
960
 
961
+ // ── Data-mode threshold adjustment (§dataMode) ────────────────────────
962
+ // When manifest.dataMode declares a degraded data availability state,
963
+ // the validator applies a line-floor reduction factor so that structurally
964
+ // constrained runs (missing full text, IMF unavailable, roll-call lag)
965
+ // can still pass Stage C without inflating thresholds that cannot be met
966
+ // with the available data. The reduction ONLY applies to line floors —
967
+ // structural requirements (mermaid, WEP, Admiralty, SATs) remain unchanged.
968
+ const dataMode = manifest.dataMode || 'full';
969
+ const dataModeReduction = DATA_MODE_REDUCTION[dataMode];
970
+ if (dataModeReduction === undefined) {
971
+ process.stderr.write(
972
+ `warning: manifest.dataMode="${dataMode}" is not a recognized value ` +
973
+ `(expected: ${Object.keys(DATA_MODE_REDUCTION).join(', ')}). Treating as "full".\n`,
974
+ );
975
+ }
976
+ const effectiveReduction = dataModeReduction ?? 1.0;
977
+ if (dataMode !== 'full' && dataModeReduction !== undefined) {
978
+ process.stderr.write(
979
+ `info: dataMode="${dataMode}" — applying ${Math.round((1 - effectiveReduction) * 100)}% line-floor reduction\n`,
980
+ );
981
+ }
982
+
938
983
  const thresholdsJson = loadThresholds(opts.thresholdsPath);
939
984
  let rules;
940
985
  try {
@@ -953,7 +998,7 @@ function main() {
953
998
  const mandatory = listMandatoryArtifacts(rules, manifestArtifacts, articleType);
954
999
 
955
1000
  const results = mandatory.map((relativePath) =>
956
- validateArtifact({ runDir, relativePath, rules, options: opts }),
1001
+ validateArtifact({ runDir, relativePath, rules, options: opts, dataModeReduction: effectiveReduction }),
957
1002
  );
958
1003
 
959
1004
  const forwardRegistryResult = validateForwardStatementsRegistryCoverage(runDir, articleType);