euparliamentmonitor 0.8.47 → 0.8.48
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/package.json +2 -1
- package/scripts/aggregator/article-metadata.d.ts +17 -0
- package/scripts/aggregator/article-metadata.js +32 -3
- package/scripts/aggregator/forward-statements-registry.js +506 -0
- package/scripts/aggregator/prior-run-diff.js +357 -0
- package/scripts/constants/language-articles.js +14 -14
- package/scripts/mcp/ep-mcp-client.d.ts +64 -2
- package/scripts/mcp/ep-mcp-client.js +190 -4
- package/scripts/mcp/ep-open-data-client.d.ts +265 -0
- package/scripts/mcp/ep-open-data-client.js +446 -0
- package/scripts/mcp/imf-mcp-client.d.ts +12 -0
- package/scripts/mcp/imf-mcp-client.js +44 -1
- package/scripts/validate-analysis-completeness.js +218 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euparliamentmonitor",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.48",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
|
|
6
6
|
"main": "scripts/index.js",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"build:check-tests": "tsc --project tsconfig.test.json --noEmit",
|
|
63
63
|
"copy-vendor": "node scripts/copy-vendor.js",
|
|
64
64
|
"validate-analysis": "node scripts/validate-analysis-completeness.js",
|
|
65
|
+
"prior-run-diff": "node scripts/aggregator/prior-run-diff.js",
|
|
65
66
|
"generate-article": "node scripts/aggregator/article-generator.js",
|
|
66
67
|
"generate-article:all": "node scripts/aggregator/article-generator.js --all",
|
|
67
68
|
"generate-news-indexes": "node scripts/generators/news-indexes.js",
|
|
@@ -165,6 +165,23 @@ export declare function deriveWeekRange(date: string): {
|
|
|
165
165
|
readonly start: string;
|
|
166
166
|
readonly end: string;
|
|
167
167
|
};
|
|
168
|
+
/**
|
|
169
|
+
* Return the D-36 → D-8 reporting window for the `week-in-review`
|
|
170
|
+
* article type. EP roll-call voting data is published with a 2–6 week
|
|
171
|
+
* lag, so using the most-recent 7 days structurally produces a
|
|
172
|
+
* vote-empty dataset. Shifting 8 days back and widening to 28 days
|
|
173
|
+
* (start = D-36, end = D-8) ensures the window always contains at
|
|
174
|
+
* least one full EP plenary week with published roll-call data
|
|
175
|
+
* (ADR-006). Direction is consistent with the workflow's
|
|
176
|
+
* `DATE_FROM` (start = D-36) → `DATE_TO` (end = D-8) variables.
|
|
177
|
+
*
|
|
178
|
+
* @param date - ISO article date string (`YYYY-MM-DD`) — typically TODAY
|
|
179
|
+
* @returns `{ start: D-36, end: D-8 }` both as `YYYY-MM-DD` ISO strings
|
|
180
|
+
*/
|
|
181
|
+
export declare function deriveReportingWindowForWeekInReview(date: string): {
|
|
182
|
+
readonly start: string;
|
|
183
|
+
readonly end: string;
|
|
184
|
+
};
|
|
168
185
|
/**
|
|
169
186
|
* Return a human-friendly month label for an ISO date — English month
|
|
170
187
|
* name + four-digit year (e.g. `April 2026`). The non-English template
|
|
@@ -447,7 +447,12 @@ function safeReaddir(dir) {
|
|
|
447
447
|
*/
|
|
448
448
|
export function buildTemplateFallback(articleType, date, committee) {
|
|
449
449
|
const map = Object.create(null);
|
|
450
|
-
|
|
450
|
+
// week-in-review uses the D-36→D-8 reporting window (ADR-006) so that
|
|
451
|
+
// EP roll-call voting data — published 2–6 weeks after the sitting —
|
|
452
|
+
// is always available in the analysis window.
|
|
453
|
+
const weekRange = articleType === 'week-in-review'
|
|
454
|
+
? deriveReportingWindowForWeekInReview(date)
|
|
455
|
+
: deriveWeekRange(date);
|
|
451
456
|
const monthLabel = deriveMonthLabel(date);
|
|
452
457
|
const committeeLabel = committee && committee.trim().length > 0 ? committee : 'Main Committees';
|
|
453
458
|
for (const lang of ALL_LANGUAGES) {
|
|
@@ -503,6 +508,8 @@ function templateForType(lang, articleType, inputs) {
|
|
|
503
508
|
};
|
|
504
509
|
}
|
|
505
510
|
}
|
|
511
|
+
/** Milliseconds in one UTC day — used by date-window derivation helpers. */
|
|
512
|
+
const MS_PER_DAY = 86_400_000;
|
|
506
513
|
/**
|
|
507
514
|
* Parse an ISO date and return the `[start, end]` week range as ISO
|
|
508
515
|
* strings. Week starts on Monday and ends on the following Sunday.
|
|
@@ -518,10 +525,32 @@ export function deriveWeekRange(date) {
|
|
|
518
525
|
const day = parsed.getUTCDay();
|
|
519
526
|
// Shift so Monday = 0, Sunday = 6.
|
|
520
527
|
const shift = (day + 6) % 7;
|
|
521
|
-
const startMs = parsed.getTime() - shift *
|
|
522
|
-
const endMs = startMs + 6 *
|
|
528
|
+
const startMs = parsed.getTime() - shift * MS_PER_DAY;
|
|
529
|
+
const endMs = startMs + 6 * MS_PER_DAY;
|
|
523
530
|
return { start: formatIsoDate(new Date(startMs)), end: formatIsoDate(new Date(endMs)) };
|
|
524
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Return the D-36 → D-8 reporting window for the `week-in-review`
|
|
534
|
+
* article type. EP roll-call voting data is published with a 2–6 week
|
|
535
|
+
* lag, so using the most-recent 7 days structurally produces a
|
|
536
|
+
* vote-empty dataset. Shifting 8 days back and widening to 28 days
|
|
537
|
+
* (start = D-36, end = D-8) ensures the window always contains at
|
|
538
|
+
* least one full EP plenary week with published roll-call data
|
|
539
|
+
* (ADR-006). Direction is consistent with the workflow's
|
|
540
|
+
* `DATE_FROM` (start = D-36) → `DATE_TO` (end = D-8) variables.
|
|
541
|
+
*
|
|
542
|
+
* @param date - ISO article date string (`YYYY-MM-DD`) — typically TODAY
|
|
543
|
+
* @returns `{ start: D-36, end: D-8 }` both as `YYYY-MM-DD` ISO strings
|
|
544
|
+
*/
|
|
545
|
+
export function deriveReportingWindowForWeekInReview(date) {
|
|
546
|
+
const parsed = parseIsoDate(date);
|
|
547
|
+
if (!parsed)
|
|
548
|
+
return { start: date, end: date };
|
|
549
|
+
return {
|
|
550
|
+
start: formatIsoDate(new Date(parsed.getTime() - 36 * MS_PER_DAY)),
|
|
551
|
+
end: formatIsoDate(new Date(parsed.getTime() - 8 * MS_PER_DAY)),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
525
554
|
/**
|
|
526
555
|
* Return a human-friendly month label for an ISO date — English month
|
|
527
556
|
* name + four-digit year (e.g. `April 2026`). The non-English template
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module Aggregator/ForwardStatementsRegistry
|
|
6
|
+
* @description Append-mostly JSONL registry for tracking forward-looking
|
|
7
|
+
* statements produced by week-ahead and month-ahead analysis runs. Each row
|
|
8
|
+
* records a single statement with its originating run, expected horizon, and
|
|
9
|
+
* current status. Subsequent week-ahead / month-ahead Stage A reads open items
|
|
10
|
+
* to seed synthesis.
|
|
11
|
+
*
|
|
12
|
+
* Schema per row:
|
|
13
|
+
* {
|
|
14
|
+
* id: string — UUID v4 (crypto.randomUUID())
|
|
15
|
+
* topic: string — Short topic slug (e.g. "banking-union", "ai-act")
|
|
16
|
+
* originatingRunId: string — RUN_ID from the originating workflow
|
|
17
|
+
* originatingDate: string — YYYY-MM-DD
|
|
18
|
+
* statement: string — The forward-looking statement text
|
|
19
|
+
* expectedHorizon: string — YYYY-MM-DD or ISO week (e.g. "2026-W18")
|
|
20
|
+
* status: "open" | "implemented" | "superseded" | "abandoned"
|
|
21
|
+
* lastObservedDate: string — YYYY-MM-DD of last run that touched this entry
|
|
22
|
+
* evidenceRefs: string[] — EP document IDs or procedure IDs supporting the claim
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* File layout:
|
|
26
|
+
* analysis/forward-statements/<YYYY-MM>.jsonl (one file per calendar month)
|
|
27
|
+
* analysis/forward-statements/README.md (schema + lifecycle docs — static)
|
|
28
|
+
*
|
|
29
|
+
* The monthly sharding keeps individual files small (typical plenary generates
|
|
30
|
+
* ~5–10 statements per week). Read operations scan every extant JSONL shard,
|
|
31
|
+
* with optional result filtering by status, horizonFrom, and horizonTo.
|
|
32
|
+
*
|
|
33
|
+
* Invocation:
|
|
34
|
+
* node scripts/aggregator/forward-statements-registry.js --help
|
|
35
|
+
* node scripts/aggregator/forward-statements-registry.js append <json-file-or-stdin>
|
|
36
|
+
* node scripts/aggregator/forward-statements-registry.js read [--status open] [--horizon-from YYYY-MM-DD] [--horizon-to YYYY-MM-DD]
|
|
37
|
+
* node scripts/aggregator/forward-statements-registry.js update --id <id> --status <status> [--evidence <ref>] [--date <YYYY-MM-DD>]
|
|
38
|
+
* node scripts/aggregator/forward-statements-registry.js summary
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import crypto from 'node:crypto';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import process from 'node:process';
|
|
45
|
+
|
|
46
|
+
const REGISTRY_DIR = path.resolve(process.cwd(), 'analysis/forward-statements');
|
|
47
|
+
const VALID_STATUSES = /** @type {const} */ (['open', 'implemented', 'superseded', 'abandoned']);
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Public helpers (exported for Vitest)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute the JSONL shard path for a given YYYY-MM-DD date string.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} dateStr - YYYY-MM-DD date string
|
|
57
|
+
* @param {string} [registryDir] - Override registry directory (used in tests)
|
|
58
|
+
* @returns {string} Absolute path to the shard file
|
|
59
|
+
*/
|
|
60
|
+
export function shardPath(dateStr, registryDir) {
|
|
61
|
+
const month = dateStr.slice(0, 7); // "YYYY-MM"
|
|
62
|
+
return path.join(registryDir ?? REGISTRY_DIR, `${month}.jsonl`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse a single JSONL line into a forward-statement entry. Returns `null`
|
|
67
|
+
* when the line is blank or cannot be parsed (corrupt shard lines are skipped
|
|
68
|
+
* rather than throwing so a single bad row does not crash the read operation).
|
|
69
|
+
*
|
|
70
|
+
* @param {string} line - Raw JSONL line
|
|
71
|
+
* @returns {Record<string, unknown> | null}
|
|
72
|
+
*/
|
|
73
|
+
export function parseLine(line) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (!trimmed) return null;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(trimmed);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generate a new UUID v4 string using the Node.js `crypto` module.
|
|
85
|
+
*
|
|
86
|
+
* @returns {string} UUID v4
|
|
87
|
+
*/
|
|
88
|
+
export function newId() {
|
|
89
|
+
return crypto.randomUUID();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return true only when a string is a valid calendar date in YYYY-MM-DD form.
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} value - Candidate date value
|
|
96
|
+
* @returns {boolean} True for valid calendar dates
|
|
97
|
+
*/
|
|
98
|
+
export function isValidDateString(value) {
|
|
99
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
100
|
+
const [year, month, day] = value.split('-').map(Number);
|
|
101
|
+
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
102
|
+
return (
|
|
103
|
+
parsed.getUTCFullYear() === year &&
|
|
104
|
+
parsed.getUTCMonth() === month - 1 &&
|
|
105
|
+
parsed.getUTCDate() === day
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Return true only when a string is a valid ISO week reference (`YYYY-Www`).
|
|
111
|
+
*
|
|
112
|
+
* @param {unknown} value - Candidate ISO week value
|
|
113
|
+
* @returns {boolean} True for ISO week 1–53
|
|
114
|
+
*/
|
|
115
|
+
export function isValidIsoWeekString(value) {
|
|
116
|
+
if (typeof value !== 'string') return false;
|
|
117
|
+
const match = /^(\d{4})-W(\d{2})$/.exec(value);
|
|
118
|
+
if (!match) return false;
|
|
119
|
+
const week = Number(match[2]);
|
|
120
|
+
return week >= 1 && week <= 53;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate a candidate forward-statement entry object, returning a list of
|
|
125
|
+
* validation error strings. An empty array means the entry is valid.
|
|
126
|
+
*
|
|
127
|
+
* @param {unknown} entry - Candidate entry
|
|
128
|
+
* @returns {string[]} Validation errors (empty when valid)
|
|
129
|
+
*/
|
|
130
|
+
export function validateEntry(entry) {
|
|
131
|
+
const errors = [];
|
|
132
|
+
if (!entry || typeof entry !== 'object') {
|
|
133
|
+
return ['Entry must be a non-null object'];
|
|
134
|
+
}
|
|
135
|
+
const e = /** @type {Record<string, unknown>} */ (entry);
|
|
136
|
+
|
|
137
|
+
if (typeof e.topic !== 'string' || !e.topic.trim()) errors.push('topic is required');
|
|
138
|
+
if (typeof e.originatingRunId !== 'string' || !e.originatingRunId.trim())
|
|
139
|
+
errors.push('originatingRunId is required');
|
|
140
|
+
if (!isValidDateString(e.originatingDate))
|
|
141
|
+
errors.push('originatingDate must be YYYY-MM-DD');
|
|
142
|
+
if (typeof e.statement !== 'string' || !e.statement.trim())
|
|
143
|
+
errors.push('statement is required');
|
|
144
|
+
if (
|
|
145
|
+
typeof e.expectedHorizon !== 'string' ||
|
|
146
|
+
(!isValidDateString(e.expectedHorizon) && !isValidIsoWeekString(e.expectedHorizon))
|
|
147
|
+
)
|
|
148
|
+
errors.push('expectedHorizon must be a valid YYYY-MM-DD date or ISO week YYYY-Www');
|
|
149
|
+
if (!VALID_STATUSES.includes(/** @type {never} */ (e.status)))
|
|
150
|
+
errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
|
|
151
|
+
if (!Array.isArray(e.evidenceRefs)) errors.push('evidenceRefs must be an array');
|
|
152
|
+
|
|
153
|
+
return errors;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Append one or more forward-statement entries to the appropriate monthly
|
|
158
|
+
* JSONL shards. Each entry receives a generated `id` and
|
|
159
|
+
* `lastObservedDate` (set to `originatingDate`) if not already present.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown[]} entries - Candidate entries to append
|
|
162
|
+
* @param {string} [registryDir] - Override registry directory (used in tests)
|
|
163
|
+
* @returns {{ written: number; errors: string[] }} Summary
|
|
164
|
+
*/
|
|
165
|
+
export function appendEntries(entries, registryDir) {
|
|
166
|
+
const dir = registryDir ?? REGISTRY_DIR;
|
|
167
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
let written = 0;
|
|
170
|
+
const errors = [];
|
|
171
|
+
|
|
172
|
+
for (const raw of entries) {
|
|
173
|
+
const entry = /** @type {Record<string, unknown>} */ (
|
|
174
|
+
raw && typeof raw === 'object' ? { .../** @type {object} */ (raw) } : {}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Fill in generated fields
|
|
178
|
+
if (!entry.id || typeof entry.id !== 'string') entry.id = newId();
|
|
179
|
+
if (!entry.status) entry.status = 'open';
|
|
180
|
+
if (!Array.isArray(entry.evidenceRefs)) entry.evidenceRefs = [];
|
|
181
|
+
if (!entry.lastObservedDate) entry.lastObservedDate = entry.originatingDate;
|
|
182
|
+
|
|
183
|
+
const errs = validateEntry(entry);
|
|
184
|
+
if (errs.length > 0) {
|
|
185
|
+
errors.push(`Entry "${entry.topic ?? '(no topic)'}" invalid: ${errs.join('; ')}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const shard = shardPath(/** @type {string} */ (entry.originatingDate), dir);
|
|
190
|
+
fs.appendFileSync(shard, JSON.stringify(entry) + '\n', 'utf8');
|
|
191
|
+
written += 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { written, errors };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read all forward-statement entries from every JSONL shard, with optional
|
|
199
|
+
* filters.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} [opts] - Filter options
|
|
202
|
+
* @param {string} [opts.status] - Filter by status (e.g. "open")
|
|
203
|
+
* @param {string} [opts.horizonFrom] - ISO date; entries with expectedHorizon < this are excluded
|
|
204
|
+
* @param {string} [opts.horizonTo] - ISO date; entries with expectedHorizon > this are excluded
|
|
205
|
+
* @param {string} [opts.registryDir] - Override registry directory (used in tests)
|
|
206
|
+
* @returns {Record<string, unknown>[]} All matching entries
|
|
207
|
+
*/
|
|
208
|
+
export function readEntries(opts) {
|
|
209
|
+
const dir = opts?.registryDir ?? REGISTRY_DIR;
|
|
210
|
+
if (!fs.existsSync(dir)) return [];
|
|
211
|
+
|
|
212
|
+
const shards = fs
|
|
213
|
+
.readdirSync(dir)
|
|
214
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
215
|
+
.sort();
|
|
216
|
+
|
|
217
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
218
|
+
const entriesById = new Map();
|
|
219
|
+
for (const shard of shards) {
|
|
220
|
+
const content = fs.readFileSync(path.join(dir, shard), 'utf8');
|
|
221
|
+
for (const line of content.split('\n')) {
|
|
222
|
+
const entry = parseLine(line);
|
|
223
|
+
if (!entry) continue;
|
|
224
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0) {
|
|
225
|
+
process.stderr.write(
|
|
226
|
+
`Skipping forward-statement entry with missing id in shard "${shard}"\n`,
|
|
227
|
+
);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
entriesById.set(entry.id, entry);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const results = [];
|
|
235
|
+
const hasHorizonFilter = Boolean(opts?.horizonFrom || opts?.horizonTo);
|
|
236
|
+
for (const entry of entriesById.values()) {
|
|
237
|
+
if (opts?.status && entry.status !== opts.status) continue;
|
|
238
|
+
|
|
239
|
+
let horizon;
|
|
240
|
+
if (hasHorizonFilter) {
|
|
241
|
+
if (typeof entry.expectedHorizon !== 'string') {
|
|
242
|
+
process.stderr.write(
|
|
243
|
+
`Skipping forward-statement entry "${entry.id}" with missing expectedHorizon for horizon-filtered read\n`,
|
|
244
|
+
);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
// Normalise ISO week to YYYY-MM-DD (first day of week) for comparison.
|
|
250
|
+
horizon = normaliseHorizon(entry.expectedHorizon);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
process.stderr.write(
|
|
253
|
+
`Skipping forward-statement entry "${entry.id}" with invalid expectedHorizon ` +
|
|
254
|
+
`"${entry.expectedHorizon}": ${error instanceof Error ? error.message : String(error)}\n`,
|
|
255
|
+
);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (opts?.horizonFrom && typeof horizon === 'string' && horizon < opts.horizonFrom) continue;
|
|
261
|
+
if (opts?.horizonTo && typeof horizon === 'string' && horizon > opts.horizonTo) continue;
|
|
262
|
+
results.push(entry);
|
|
263
|
+
}
|
|
264
|
+
return results;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Update an existing entry's status, lastObservedDate, and optionally append
|
|
269
|
+
* an evidence reference. The update is written as a new JSONL line in the
|
|
270
|
+
* **current month's** shard (append-mostly pattern — old lines are kept for
|
|
271
|
+
* audit trail; readers use the last occurrence of each `id`).
|
|
272
|
+
*
|
|
273
|
+
* @param {object} opts - Update options
|
|
274
|
+
* @param {string} opts.id - ID of the entry to update
|
|
275
|
+
* @param {string} opts.status - New status
|
|
276
|
+
* @param {string} [opts.evidence] - Evidence reference to append
|
|
277
|
+
* @param {string} [opts.date] - lastObservedDate (defaults to today UTC)
|
|
278
|
+
* @param {string} [opts.registryDir] - Override registry directory (used in tests)
|
|
279
|
+
* @returns {{ updated: boolean; reason?: string }} Result
|
|
280
|
+
*/
|
|
281
|
+
export function updateEntry(opts) {
|
|
282
|
+
const dir = opts?.registryDir ?? REGISTRY_DIR;
|
|
283
|
+
|
|
284
|
+
if (!VALID_STATUSES.includes(/** @type {never} */ (opts.status))) {
|
|
285
|
+
return { updated: false, reason: `Invalid status: ${opts.status}` };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Find the latest version of the entry
|
|
289
|
+
const all = readEntries({ registryDir: dir });
|
|
290
|
+
// Build a map keyed by id — last occurrence wins (append-mostly semantics)
|
|
291
|
+
const byId = new Map();
|
|
292
|
+
for (const e of all) {
|
|
293
|
+
if (typeof e.id === 'string') byId.set(e.id, e);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const existing = byId.get(opts.id);
|
|
297
|
+
if (!existing) {
|
|
298
|
+
return { updated: false, reason: `No entry found with id=${opts.id}` };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const today = opts.date ?? new Date().toISOString().slice(0, 10);
|
|
302
|
+
const existingRefs = Array.isArray(existing.evidenceRefs) ? existing.evidenceRefs : [];
|
|
303
|
+
const updatedRefs = opts.evidence
|
|
304
|
+
? Array.from(new Set([...existingRefs, opts.evidence]))
|
|
305
|
+
: [...existingRefs];
|
|
306
|
+
const updated = {
|
|
307
|
+
...existing,
|
|
308
|
+
status: opts.status,
|
|
309
|
+
lastObservedDate: today,
|
|
310
|
+
evidenceRefs: updatedRefs,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
314
|
+
const shard = shardPath(today, dir);
|
|
315
|
+
fs.appendFileSync(shard, JSON.stringify(updated) + '\n', 'utf8');
|
|
316
|
+
return { updated: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Produce a human-readable summary string of all entries, grouped by status.
|
|
321
|
+
* Useful for Stage A manifests and `--summary` CLI.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} [registryDir] - Override registry directory (used in tests)
|
|
324
|
+
* @returns {string} Formatted summary
|
|
325
|
+
*/
|
|
326
|
+
export function buildSummary(registryDir) {
|
|
327
|
+
const all = readEntries({ registryDir });
|
|
328
|
+
const byStatus = {};
|
|
329
|
+
for (const status of VALID_STATUSES) {
|
|
330
|
+
byStatus[status] = all.filter((e) => e.status === status).length;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const lines = [
|
|
334
|
+
`## Forward-Statements Registry Summary`,
|
|
335
|
+
`Total entries: ${all.length}`,
|
|
336
|
+
...VALID_STATUSES.map((s) => ` ${s}: ${byStatus[s]}`),
|
|
337
|
+
];
|
|
338
|
+
return lines.join('\n');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Generate an array of EP plenary sitting IDs for consecutive session days.
|
|
343
|
+
* The format is `MTG-PL-YYYY-MM-DD` — the canonical sitting ID pattern used
|
|
344
|
+
* by the EP MCP `get_meeting_foreseen_activities` endpoint.
|
|
345
|
+
*
|
|
346
|
+
* @param {string} startDateStr - YYYY-MM-DD date of the first session day
|
|
347
|
+
* @param {number} numDays - Number of consecutive session days (typically 4 for Strasbourg, 2 for Brussels mini)
|
|
348
|
+
* @returns {string[]} Array of sitting IDs
|
|
349
|
+
*/
|
|
350
|
+
export function generateSessionDayIds(startDateStr, numDays) {
|
|
351
|
+
const [year, month, day] = startDateStr.split('-').map(Number);
|
|
352
|
+
const startMs = Date.UTC(year, month - 1, day);
|
|
353
|
+
const ids = [];
|
|
354
|
+
for (let i = 0; i < numDays; i++) {
|
|
355
|
+
const ms = startMs + i * 24 * 60 * 60 * 1000;
|
|
356
|
+
const d = new Date(ms);
|
|
357
|
+
const y = d.getUTCFullYear();
|
|
358
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
359
|
+
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
360
|
+
ids.push(`MTG-PL-${y}-${m}-${dd}`);
|
|
361
|
+
}
|
|
362
|
+
return ids;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Return `true` when the given date string falls on a Monday (UTC).
|
|
367
|
+
* Used to determine whether the Monday urgency motion sweep should run.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} dateStr - YYYY-MM-DD
|
|
370
|
+
* @returns {boolean}
|
|
371
|
+
*/
|
|
372
|
+
export function isMondayRun(dateStr) {
|
|
373
|
+
const [y, m, d] = dateStr.split('-').map(Number);
|
|
374
|
+
return new Date(Date.UTC(y, m - 1, d)).getUTCDay() === 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// Internal helpers
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Normalise an ISO week string (`YYYY-Www`) to its Monday date as `YYYY-MM-DD`
|
|
383
|
+
* for lexicographic horizon comparisons. Valid `YYYY-MM-DD` dates pass through
|
|
384
|
+
* unchanged.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} horizon - expectedHorizon value
|
|
387
|
+
* @returns {string} YYYY-MM-DD date string
|
|
388
|
+
* @throws {Error} When the horizon is not a valid `YYYY-MM-DD` date or ISO week
|
|
389
|
+
*/
|
|
390
|
+
export function normaliseHorizon(horizon) {
|
|
391
|
+
if (isValidDateString(horizon)) return horizon;
|
|
392
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(horizon)) {
|
|
393
|
+
throw new Error(`Invalid calendar date in expectedHorizon: "${horizon}"`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const isoWeek = /^(\d{4})-W(\d{2})$/.exec(horizon);
|
|
397
|
+
if (!isoWeek) {
|
|
398
|
+
throw new Error(`expectedHorizon must be YYYY-MM-DD or YYYY-Www: "${horizon}"`);
|
|
399
|
+
}
|
|
400
|
+
const year = Number(isoWeek[1]);
|
|
401
|
+
const week = Number(isoWeek[2]);
|
|
402
|
+
if (week < 1 || week > 53) {
|
|
403
|
+
throw new Error(`Invalid ISO week number: ${week} in "${horizon}" (must be 1–53)`);
|
|
404
|
+
}
|
|
405
|
+
// ISO week 1 starts on the Monday of the week containing Jan 4.
|
|
406
|
+
const jan4 = new Date(Date.UTC(year, 0, 4));
|
|
407
|
+
const monday = new Date(jan4);
|
|
408
|
+
// Move back to the Monday of the Jan-4 week, then forward by (week - 1) weeks.
|
|
409
|
+
monday.setUTCDate(jan4.getUTCDate() - ((jan4.getUTCDay() + 6) % 7) + (week - 1) * 7);
|
|
410
|
+
return monday.toISOString().slice(0, 10);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// CLI entry point
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Parse and dispatch CLI sub-commands.
|
|
419
|
+
*
|
|
420
|
+
* @param {string[]} argv - `process.argv` (slice from index 2 for args)
|
|
421
|
+
*/
|
|
422
|
+
export function cli(argv) {
|
|
423
|
+
const [cmd, ...rest] = argv;
|
|
424
|
+
|
|
425
|
+
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
426
|
+
process.stdout.write(
|
|
427
|
+
[
|
|
428
|
+
'Usage: node scripts/aggregator/forward-statements-registry.js <command> [options]',
|
|
429
|
+
'',
|
|
430
|
+
'Commands:',
|
|
431
|
+
' append [--file <path>] Append entries from a JSON array file (or stdin)',
|
|
432
|
+
' read [--status open|implemented|superseded|abandoned]',
|
|
433
|
+
' [--horizon-from YYYY-MM-DD] [--horizon-to YYYY-MM-DD]',
|
|
434
|
+
' Read and print matching entries as JSON array',
|
|
435
|
+
' update --id <id> --status <status> [--evidence <ref>] [--date YYYY-MM-DD]',
|
|
436
|
+
' Update an existing entry',
|
|
437
|
+
' summary Print status counts',
|
|
438
|
+
'',
|
|
439
|
+
].join('\n'),
|
|
440
|
+
);
|
|
441
|
+
process.exit(0);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (cmd === 'append') {
|
|
445
|
+
const fileFlag = rest.indexOf('--file');
|
|
446
|
+
let rawJson;
|
|
447
|
+
if (fileFlag !== -1 && rest[fileFlag + 1]) {
|
|
448
|
+
rawJson = fs.readFileSync(rest[fileFlag + 1], 'utf8');
|
|
449
|
+
} else {
|
|
450
|
+
rawJson = fs.readFileSync(0, 'utf8');
|
|
451
|
+
}
|
|
452
|
+
const entries = JSON.parse(rawJson);
|
|
453
|
+
const result = appendEntries(Array.isArray(entries) ? entries : [entries]);
|
|
454
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
455
|
+
if (result.errors.length > 0) process.exit(1);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (cmd === 'read') {
|
|
460
|
+
const statusFlag = rest.indexOf('--status');
|
|
461
|
+
const fromFlag = rest.indexOf('--horizon-from');
|
|
462
|
+
const toFlag = rest.indexOf('--horizon-to');
|
|
463
|
+
const opts = {};
|
|
464
|
+
if (statusFlag !== -1 && rest[statusFlag + 1]) opts.status = rest[statusFlag + 1];
|
|
465
|
+
if (fromFlag !== -1 && rest[fromFlag + 1]) opts.horizonFrom = rest[fromFlag + 1];
|
|
466
|
+
if (toFlag !== -1 && rest[toFlag + 1]) opts.horizonTo = rest[toFlag + 1];
|
|
467
|
+
process.stdout.write(JSON.stringify(readEntries(opts), null, 2) + '\n');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (cmd === 'update') {
|
|
472
|
+
const parseFlag = (flag) => {
|
|
473
|
+
const idx = rest.indexOf(flag);
|
|
474
|
+
return idx !== -1 ? rest[idx + 1] : undefined;
|
|
475
|
+
};
|
|
476
|
+
const id = parseFlag('--id');
|
|
477
|
+
const status = parseFlag('--status');
|
|
478
|
+
const evidence = parseFlag('--evidence');
|
|
479
|
+
const date = parseFlag('--date');
|
|
480
|
+
if (!id || !status) {
|
|
481
|
+
process.stderr.write('update requires --id and --status\n');
|
|
482
|
+
process.exit(2);
|
|
483
|
+
}
|
|
484
|
+
const result = updateEntry({ id, status, evidence, date });
|
|
485
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
486
|
+
if (!result.updated) process.exit(1);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (cmd === 'summary') {
|
|
491
|
+
process.stdout.write(buildSummary() + '\n');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
process.stderr.write(`Unknown command: ${cmd}\n`);
|
|
496
|
+
process.exit(2);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Run CLI when invoked directly
|
|
500
|
+
if (
|
|
501
|
+
process.argv[1] &&
|
|
502
|
+
(process.argv[1].endsWith('forward-statements-registry.js') ||
|
|
503
|
+
process.argv[1].endsWith('forward-statements-registry'))
|
|
504
|
+
) {
|
|
505
|
+
cli(process.argv.slice(2));
|
|
506
|
+
}
|