diff --git a/.github/skills/legislative-monitoring/SKILL.md b/.github/skills/legislative-monitoring/SKILL.md index 2c31fffeb..883d8359c 100644 --- a/.github/skills/legislative-monitoring/SKILL.md +++ b/.github/skills/legislative-monitoring/SKILL.md @@ -28,6 +28,9 @@ Apply this skill when: - ✅ Detecting legislative obstruction or procedural manipulation - ✅ Measuring government vs. opposition effectiveness - ✅ Tracking amendment success rates and strategic positioning +- ✅ Tracking Riksrevisionen (RiR) audit findings and constitutional skrivelse deadlines +- ✅ Monitoring government accountability for audit recommendations +- ✅ Generating overdue-deadline alerts for unanswered RiR reports Do NOT use for: - ❌ Manipulating legislative processes through intelligence @@ -1105,6 +1108,133 @@ ORDER BY intensity_score DESC, collaboration_count DESC; | **CIS Control 8 - Audit Log Management** | Legislative activity audit logging | | **CIS Control 11 - Data Recovery** | Parliamentary data backup and recovery | +## 7. Riksrevisionen (RiR) Follow-Up Tracking + +### Constitutional Framework + +Swedish constitutional practice (Riksdagsordningen ch. 10 and Government Offices' established conventions) requires the government to formally respond to each Riksrevisionen audit report with a **skrivelse** (written communication to the Riksdag) within **4 calendar months** of the report's publication. + +Failure to respond within this deadline is a constitutional accountability gap that creates political risk and potential for opposition interpellations, motions, and media pressure. + +### Data Model + +The authoritative dataset lives at `data/rir-followups.json` (JSON schema: `schemas/rir-followups-schema.json`). Each record captures: + +| Field | Description | +|-------|-------------| +| `rir_report_id` | Riksdag document ID (e.g. `HD01JuU31`) | +| `rir_number` | Official RiR number (e.g. `RiR 2026:6`) | +| `title` / `title_en` | Swedish and English titles | +| `agency` | Primary audited government agency | +| `committees` | Riksdag committee codes (e.g. `JuU`, `FöU`) | +| `publish_date` | ISO 8601 publication date | +| `skrivelse_deadline` | Calculated 4-month constitutional deadline | +| `gov_response_status` | `PENDING` \| `RESPONDED` \| `OVERDUE` \| `PARTIAL` | +| `response_skrivelse_id` | Reference if government has responded | +| `parliamentary_followup_doc_ids` | Related Riksdag documents | +| `open_recommendations` | Count of unresolved audit recommendations | +| `risk_level` | `LOW` \| `MEDIUM` \| `HIGH` \| `CRITICAL` | + +### Status Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> PENDING : RiR report published + PENDING --> RESPONDED : Government delivers skrivelse + PENDING --> OVERDUE : 4-month deadline elapsed, no response + PENDING --> PARTIAL : Government responds partially + OVERDUE --> RESPONDED : Belated skrivelse delivered + PARTIAL --> RESPONDED : Full response delivered + RESPONDED --> [*] : Follow-up complete +``` + +### Library: `scripts/rir-followups-client.ts` + +The `rir-followups-client` module provides all necessary tools: + +```typescript +import { + calculateSkrivelseDeadline, // Deadline from publish date (canonical spelling) + deriveResponseStatus, // Re-derive status vs today's date + detectOverdueAlerts, // Scan dataset for overdue records + renderRirFollowUpTable, // Render Markdown table + injectRirTableIntoDocument, // Inject/replace table in a document + filterByCommittee, // Filter by Riksdag committee + filterByStatus, // Filter by response status + filterByMinRiskLevel, // Filter by minimum risk level + validateRirRecord, // Validate a single record + validateRirDataset, // Validate full dataset + loadRirDataset, // Load JSON from file (injectable I/O) + saveRirDataset, // Save JSON to file (injectable I/O) +} from './rir-followups-client.js'; +``` + +### CLI: `scripts/fetch-rir-followups.ts` + +Run daily (or as part of the news workflow) to: +1. Fetch recent government skrivelser from `data.riksdagen.se` +2. Match against known RiR follow-up records +3. Update `gov_response_status` and `response_skrivelse_id` +4. Emit overdue alerts (exit code 1 with `--alert` flag) + +```bash +# Check for overdue alerts (exit 1 if found) +npx tsx scripts/fetch-rir-followups.ts --alert + +# Dry-run against a specific reference date +npx tsx scripts/fetch-rir-followups.ts --dry-run --date 2026-05-01 +``` + +### Injecting RiR Follow-Up Tables into Intelligence Assessments + +Every intelligence-assessment document that cites a Riksrevisionen finding **MUST** include a follow-up status table. Use the `injectRirTableIntoDocument` helper or add the HTML markers manually: + +```markdown + + +## 🔍 Riksrevisionen Follow-Up Status + +| RiR # | Title | Agency | Published | Skrivelse Deadline | Status | Days Overdue | +|-------|-------|--------|-----------|-------------------|--------|--------------| +| RiR 2026:6 | [Polisreform…](https://…) | Polismyndigheten | 2026-01-15 | 2026-05-15 | ⏳ PENDING | — | +| RiR 2025:18 | [Civilt försvar…](https://…) | MSB | 2025-12-10 | 2026-04-10 | 🚨 OVERDUE | ⚠️ 17 | + + +``` + +### Riksdag API Search for Skrivelser + +To fetch government responses manually via the riksdag-regering-mcp tools: + +```typescript +// Via search_dokument MCP tool +riksdag-regering-search_dokument({ + doktyp: 'skr', // skrivelse (government communication) + from_date: '2025-01-01', + to_date: '2026-12-31', + titel: 'Riksrevisionen' // or specific RiR number +}) +``` + +### Workflow Integration + +Wire the RiR follow-up tracker into intelligence-assessment generation by: + +1. **Daily fetch**: Run `fetch-rir-followups.ts` before news generation to ensure `data/rir-followups.json` is up to date +2. **Automatic injection**: For any intelligence assessment document citing a RiR finding (detected via `rir_report_id` or `rir_number` keywords), call `injectRirTableIntoDocument` with the filtered relevant records +3. **KJ propagation**: When a record transitions to `OVERDUE`, update all active Key Judgements (KJs) that reference that RiR finding to reflect the accountability gap + +### Alert Thresholds + +| Days Overdue | Alert Level | Recommended Action | +|--------------|-------------|-------------------| +| 0 | None | Normal monitoring | +| 1–30 | LOW | Note in intelligence assessment | +| 31–90 | MEDIUM | Create GitHub issue, escalate in weekly-review | +| 91+ | HIGH | Create GitHub issue immediately, flag in KJs | + +--- + ## Hack23 ISMS Policy References This skill implements requirements from: diff --git a/data/rir-followups.json b/data/rir-followups.json new file mode 100644 index 000000000..be3ee16f9 --- /dev/null +++ b/data/rir-followups.json @@ -0,0 +1,87 @@ +{ + "$schema": "../schemas/rir-followups-schema.json", + "version": "1.0", + "description": "Riksrevisionen (RiR) follow-up tracker — constitutional skrivelse deadlines and government response status", + "last_updated": "2026-04-27", + "constitutional_deadline_months": 4, + "notes": [ + "Swedish constitutional and parliamentary practice cites RO 10:4 for Riksdag handling of Riksrevisionen reports, together with RF 5:4 and, in some assessments, RF ch. 13:7 for the government's accountability and follow-up context; this dataset treats those provisions as complementary rather than conflicting bases for the formal skrivelse response expectation.", + "The conventional deadline is 4 calendar months after the RiR report publication date.", + "A skrivelse_deadline of null means the deadline has not yet been confirmed in the dataset.", + "gov_response_status values: PENDING | RESPONDED | OVERDUE | PARTIAL" + ], + "records": [ + { + "rir_report_id": "HD01JuU31", + "rir_number": "RiR 2026:6", + "title": "Polisreform — granskning av effektivitet och genomförande", + "title_en": "Police reform — audit of efficiency and implementation", + "agency": "Polismyndigheten", + "policy_area": "Justitia och inrikes frågor", + "committees": ["JuU"], + "publish_date": "2026-01-15", + "skrivelse_deadline": "2026-05-15", + "gov_response_status": "PENDING", + "response_skrivelse_id": null, + "parliamentary_followup_doc_ids": ["HD01JuU31"], + "open_recommendations": 9, + "risk_level": "HIGH", + "notes": "9 open recommendations; potential for follow-up RiR investigation before 2026 election. Cited in realtime-pulse 2026-04-26 analysis.", + "riksdag_url": "https://www.riksdagen.se/sv/dokument-och-lagar/dokument/HD01JuU31/" + }, + { + "rir_report_id": "HC03206", + "rir_number": "RiR 2025:18", + "title": "Civilt försvar — granskning av beredskapsförmåga", + "title_en": "Civil defence — audit of emergency preparedness capacity", + "agency": "MSB (Myndigheten för samhällsskydd och beredskap)", + "policy_area": "Försvar och säkerhet", + "committees": ["FöU"], + "publish_date": "2025-12-10", + "skrivelse_deadline": "2026-04-10", + "gov_response_status": "OVERDUE", + "response_skrivelse_id": null, + "parliamentary_followup_doc_ids": ["HC03206"], + "open_recommendations": 7, + "risk_level": "HIGH", + "notes": "Deadline elapsed 2026-04-10 without formal government skrivelse response. Cited in weekly-review and motions analysis 2026-04-26.", + "riksdag_url": "https://www.riksdagen.se/sv/dokument-och-lagar/dokument/HC03206/" + }, + { + "rir_report_id": "HB01NU20", + "rir_number": "RiR 2025:12", + "title": "Exportfrämjande — granskning av Business Sweden", + "title_en": "Export promotion — audit of Business Sweden", + "agency": "Business Sweden", + "policy_area": "Näringsliv och handel", + "committees": ["NU"], + "publish_date": "2025-09-22", + "skrivelse_deadline": "2026-01-22", + "gov_response_status": "RESPONDED", + "response_skrivelse_id": "Skr. 2025/26:78", + "parliamentary_followup_doc_ids": ["HB01NU20"], + "open_recommendations": 0, + "risk_level": "LOW", + "notes": "Government responded with Skr. 2025/26:78. Recommendations accepted in full.", + "riksdag_url": "https://www.riksdagen.se/sv/dokument-och-lagar/dokument/HB01NU20/" + }, + { + "rir_report_id": "HA01AU15", + "rir_number": "RiR 2025:7", + "title": "Arbetsmarknadspolitiken — granskning av Arbetsförmedlingens matchningsuppdrag", + "title_en": "Labour market policy — audit of Arbetsförmedlingen matching assignment", + "agency": "Arbetsförmedlingen", + "policy_area": "Arbetsmarknad", + "committees": ["AU"], + "publish_date": "2025-06-05", + "skrivelse_deadline": "2025-10-05", + "gov_response_status": "PARTIAL", + "response_skrivelse_id": "Skr. 2025/26:22", + "parliamentary_followup_doc_ids": ["HA01AU15"], + "open_recommendations": 2, + "risk_level": "MEDIUM", + "notes": "Government responded but left 2 recommendations partially unaddressed; follow-up audit possible.", + "riksdag_url": "https://www.riksdagen.se/sv/dokument-och-lagar/dokument/HA01AU15/" + } + ] +} diff --git a/schemas/rir-followups-schema.json b/schemas/rir-followups-schema.json new file mode 100644 index 000000000..fb9f294e2 --- /dev/null +++ b/schemas/rir-followups-schema.json @@ -0,0 +1,169 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://riksdagsmonitor.com/schemas/rir-followups-schema.json", + "title": "RiR Follow-ups Dataset", + "description": "Schema for the Riksrevisionen (RiR) follow-up tracker — constitutional skrivelse deadlines and government response status", + "type": "object", + "required": ["version", "description", "last_updated", "constitutional_deadline_months", "records"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "type": "string", + "description": "Dataset schema version" + }, + "description": { + "type": "string" + }, + "last_updated": { + "type": "string", + "format": "date", + "description": "ISO 8601 date when the dataset was last updated" + }, + "constitutional_deadline_months": { + "type": "integer", + "minimum": 1, + "maximum": 24, + "description": "Default deadline in calendar months (Swedish constitutional practice: 4 months)" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "records": { + "type": "array", + "minItems": 0, + "items": { + "$ref": "#/definitions/RirFollowUpRecord" + } + } + }, + "definitions": { + "RirFollowUpRecord": { + "type": "object", + "required": [ + "rir_report_id", + "rir_number", + "title", + "agency", + "publish_date", + "skrivelse_deadline", + "gov_response_status", + "response_skrivelse_id", + "parliamentary_followup_doc_ids" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "gov_response_status": { "const": "RESPONDED" } }, + "required": ["gov_response_status"] + }, + "then": { + "properties": { + "response_skrivelse_id": { "type": "string", "minLength": 1 } + }, + "required": ["response_skrivelse_id"] + } + } + ], + "properties": { + "rir_report_id": { + "type": "string", + "description": "Riksdag document ID for the RiR report (e.g. HD01JuU31)" + }, + "rir_number": { + "type": "string", + "pattern": "^RiR \\d{4}:\\d+$", + "description": "Official RiR publication number (e.g. RiR 2026:6)" + }, + "title": { + "type": "string", + "description": "Swedish title of the RiR report" + }, + "title_en": { + "type": "string", + "description": "English translation of the title" + }, + "agency": { + "type": "string", + "description": "Primary government agency audited" + }, + "policy_area": { + "type": "string", + "description": "Swedish policy area" + }, + "committees": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Riksdag committee codes handling the report (e.g. JuU, FöU)" + }, + "publish_date": { + "type": "string", + "format": "date", + "description": "ISO 8601 date when the RiR report was published" + }, + "skrivelse_deadline": { + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ], + "description": "Deadline for the government skrivelse response (null if not confirmed)" + }, + "gov_response_status": { + "type": "string", + "enum": ["PENDING", "RESPONDED", "OVERDUE", "PARTIAL"], + "description": "Government response status: PENDING=awaiting response, RESPONDED=fully responded, OVERDUE=deadline elapsed without response, PARTIAL=partial response only" + }, + "response_skrivelse_id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The Riksdag document ID or reference of the government skrivelse response (null if not yet responded)" + }, + "parliamentary_followup_doc_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of Riksdag document IDs for parliamentary follow-up documents (motions, betänkanden, interpellationer)" + }, + "open_recommendations": { + "type": "integer", + "minimum": 0, + "description": "Number of open/unresolved RiR recommendations" + }, + "risk_level": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], + "description": "Political accountability risk level" + }, + "notes": { + "type": "string", + "description": "Additional context or intelligence notes" + }, + "riksdag_url": { + "type": "string", + "format": "uri", + "description": "URL to the document on riksdagen.se" + } + } + } + } +} diff --git a/scripts/fetch-rir-followups.ts b/scripts/fetch-rir-followups.ts new file mode 100644 index 000000000..a3c888720 --- /dev/null +++ b/scripts/fetch-rir-followups.ts @@ -0,0 +1,350 @@ +#!/usr/bin/env tsx +/** + * @module scripts/fetch-rir-followups + * @description CLI script to fetch and update Riksrevisionen (RiR) follow-up + * records from the Riksdag API. + * + * Fetches new skrivelse responses from `riksdag-regering-search_dokument` using + * `doktyp=skr` (government skrivelse) and matches them against the known RiR + * follow-up records in `data/rir-followups.json`. Updates record status and + * emits alerts for overdue deadlines. + * + * ## Usage + * + * npx tsx scripts/fetch-rir-followups.ts [--dry-run] [--date YYYY-MM-DD] [--alert] + * + * Options: + * --dry-run Print updates without writing to disk + * --date YYYY-MM-DD Reference date for deadline calculations (default: today) + * --alert Exit with code 1 if any OVERDUE records detected + * + * Environment variables: + * RIR_DATA_FILE Override path to data/rir-followups.json + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + loadRirDataset, + saveRirDataset, + detectOverdueAlerts, + deriveResponseStatus, + validateRirDataset, +} from './rir-followups-client.js'; +import type { RirFollowUpRecord, RirFollowUpsDataset } from './rir-followups-client.js'; + +// --------------------------------------------------------------------------- +// Repo root +// --------------------------------------------------------------------------- +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..'); +const DEFAULT_DATA_FILE = path.join(REPO_ROOT, 'data', 'rir-followups.json'); + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +interface CliOptions { + readonly dryRun: boolean; + readonly asOf: Date; + readonly alertOnOverdue: boolean; + readonly dataFile: string; +} + +function parseArgs(argv: string[]): CliOptions { + const args = argv.slice(2); + let dryRun = false; + let dateStr: string | null = null; + let alertOnOverdue = false; + const dataFile = process.env['RIR_DATA_FILE'] ?? DEFAULT_DATA_FILE; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--dry-run') dryRun = true; + else if (arg === '--alert') alertOnOverdue = true; + else if (arg === '--date') { + const nextArg = args[i + 1]; + if (!nextArg) { + console.error('[fetch-rir-followups] Missing value for --date. Expected YYYY-MM-DD.'); + process.exit(2); + } + dateStr = nextArg; + i++; + } + } + + const asOf = dateStr ? new Date(`${dateStr}T00:00:00Z`) : new Date(); + + if (Number.isNaN(asOf.getTime())) { + console.error('[fetch-rir-followups] Invalid --date value. Expected YYYY-MM-DD.'); + process.exit(1); + } + + return { dryRun, asOf, alertOnOverdue, dataFile }; +} + +// --------------------------------------------------------------------------- +// Riksdag API fetch helpers +// --------------------------------------------------------------------------- + +/** + * Minimal Riksdag API client for fetching skrivelse documents. + * Uses the public riksdagen.se data API directly (no MCP server required for CLI). + */ +async function fetchRiksdagSkrivelser( + fromDate: string, + toDate: string, + pageSize = 50, +): Promise { + const documents: RiksdagDocumentResult[] = []; + let page = 1; + + // Page through results until exhausted, so 90-day windows with > pageSize + // skrivelser do not silently drop matches. + for (;;) { + const params = new URLSearchParams({ + doktyp: 'skr', + from: fromDate, + tom: toDate, + sz: String(pageSize), + p: String(page), + utformat: 'json', + sort: 'datum', + sortorder: 'desc', + }); + + const url = `https://data.riksdagen.se/dokumentlista/?${params.toString()}`; + + let response: Response; + try { + response = await fetch(url, { + signal: AbortSignal.timeout(15_000), + headers: { Accept: 'application/json' }, + }); + } catch (err) { + console.error(`[fetch-rir-followups] Network error fetching Riksdag API: ${err}`); + return documents; + } + + if (!response.ok) { + console.error(`[fetch-rir-followups] Riksdag API returned ${response.status}`); + return documents; + } + + let json: RiksdagDocumentListResponse; + try { + json = (await response.json()) as RiksdagDocumentListResponse; + } catch { + console.error('[fetch-rir-followups] Failed to parse Riksdag API JSON response'); + return documents; + } + + const pageDocuments = json?.dokumentlista?.dokument ?? []; + documents.push(...pageDocuments); + + // Stop when the page is short or empty (last/only page). + if (pageDocuments.length === 0 || pageDocuments.length < pageSize) { + break; + } + + page += 1; + // Hard safety cap to avoid runaway loops on misbehaving APIs. + if (page > 50) break; + } + + return documents; +} + +interface RiksdagDocumentResult { + readonly id?: string; + readonly dok_id?: string; + readonly titel?: string; + readonly datum?: string; + readonly organ?: string; + readonly typ?: string; + readonly subtyp?: string; + readonly beteckning?: string; + readonly rm?: string; + readonly url?: string; +} + +interface RiksdagDocumentListResponse { + readonly dokumentlista?: { + readonly dokument?: RiksdagDocumentResult[]; + }; +} + +// --------------------------------------------------------------------------- +// Matching logic +// --------------------------------------------------------------------------- + +/** + * Normalise a skrivelse reference for tolerant equality comparison. + * Strips the "Skr." prefix, whitespace, and lowercases — so + * "Skr. 2025/26:78" and "skr2025/26:78" match. + */ +function normalizeSkrivelseRef(value: string): string { + return value.replace(/^skr\.?\s*/i, '').replace(/\s+/g, '').toLowerCase(); +} + +/** + * Attempt to match a Riksdag skrivelse document against known RiR follow-up + * records by looking for the RiR report ID / number in the document title, + * or by comparing the skrivelse `beteckning` to a previously stored + * `response_skrivelse_id` (after normalisation). + */ +function matchSkrivelse( + skrivelse: RiksdagDocumentResult, + records: readonly RirFollowUpRecord[], +): RirFollowUpRecord | null { + const title = (skrivelse.titel ?? '').toLowerCase(); + // beteckning is the human reference (e.g. "2025/26:78"); compare against + // record.response_skrivelse_id ("Skr. 2025/26:78") after normalisation. + const beteckningNorm = skrivelse.beteckning ? normalizeSkrivelseRef(skrivelse.beteckning) : ''; + + for (const record of records) { + // Match on rir_report_id in title + if (title.includes(record.rir_report_id.toLowerCase())) return record; + // Match on rir_number (e.g. "RiR 2026:6") in title + const rirNum = record.rir_number.toLowerCase(); + if (title.includes(rirNum)) return record; + // Match on response_skrivelse_id via beteckning (normalised both sides). + if (record.response_skrivelse_id && beteckningNorm) { + const recordRefNorm = normalizeSkrivelseRef(record.response_skrivelse_id); + if (recordRefNorm && beteckningNorm === recordRefNorm) return record; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const opts = parseArgs(process.argv); + + console.log(`[fetch-rir-followups] Loading dataset from: ${opts.dataFile}`); + let dataset: RirFollowUpsDataset; + try { + dataset = loadRirDataset(opts.dataFile); + } catch (err) { + console.error(`[fetch-rir-followups] Failed to load dataset: ${err}`); + process.exit(1); + } + + // Validate dataset on load + const validationErrors = validateRirDataset(dataset); + if (validationErrors.size > 0) { + console.warn(`[fetch-rir-followups] Validation warnings in dataset:`); + for (const [id, errors] of validationErrors) { + for (const e of errors) { + console.warn(` ${id}: ${e}`); + } + } + } + + // Fetch recent skrivelser (last 90 days, UTC-safe arithmetic) + const toDate = opts.asOf.toISOString().slice(0, 10); + const fromDate90 = new Date(opts.asOf); + fromDate90.setUTCDate(fromDate90.getUTCDate() - 90); + const fromDate = fromDate90.toISOString().slice(0, 10); + + console.log(`[fetch-rir-followups] Fetching skrivelser from ${fromDate} to ${toDate} ...`); + const skrivelser = await fetchRiksdagSkrivelser(fromDate, toDate); + console.log(`[fetch-rir-followups] Fetched ${skrivelser.length} skrivelse documents`); + + // Match and update records + let updatedCount = 0; + const updatedRecords = dataset.records.map((record): RirFollowUpRecord => { + // Skip fully-responded records — no further action needed + if (record.gov_response_status === 'RESPONDED' && record.response_skrivelse_id) return record; + + for (const skr of skrivelser) { + if (matchSkrivelse(skr, [record])) { + const newId = skr.beteckning ?? skr.dok_id ?? skr.id ?? null; + if (newId && (!record.response_skrivelse_id || record.gov_response_status === 'PARTIAL')) { + const prevStatus = record.gov_response_status; + // Derive the persisted status from a candidate record so stored data + // stays consistent with the canonical library rules — e.g. a response + // recorded while open_recommendations > 0 remains PARTIAL rather than + // being forced to RESPONDED. + const candidateRecord: RirFollowUpRecord = { + ...record, + response_skrivelse_id: newId, + }; + const newStatus = deriveResponseStatus(candidateRecord, opts.asOf); + console.log( + `[fetch-rir-followups] Matched response for ${record.rir_report_id} (${prevStatus} → ${newStatus}): ${newId}`, + ); + updatedCount++; + return { + ...candidateRecord, + gov_response_status: newStatus, + }; + } + } + } + + // Re-derive status based on deadline + const derivedStatus = deriveResponseStatus(record, opts.asOf); + if (derivedStatus !== record.gov_response_status) { + console.log( + `[fetch-rir-followups] Status update for ${record.rir_report_id}: ${record.gov_response_status} → ${derivedStatus}`, + ); + updatedCount++; + return { ...record, gov_response_status: derivedStatus }; + } + + return record; + }); + + // Detect overdue alerts (derived from current dataset, no mutation) + const updatedDataset: RirFollowUpsDataset = { ...dataset, records: updatedRecords }; + const alerts = detectOverdueAlerts(updatedDataset, opts.asOf); + + if (alerts.length > 0) { + console.warn(`\n⚠️ OVERDUE SKRIVELSE ALERTS (${alerts.length}):`); + for (const alert of alerts) { + console.warn( + ` 🚨 ${alert.rir_number} | ${alert.title} | Deadline: ${alert.skrivelse_deadline} | ${alert.days_overdue} days overdue`, + ); + } + console.warn(''); + } else { + console.log('[fetch-rir-followups] No overdue skrivelse deadlines detected.'); + } + + // Persist only when stored records actually change. + // Overdue alerts are derived from the dataset and do not mutate persisted state, + // so they alone should NOT cause a noisy rewrite of the JSON file. + if (!opts.dryRun) { + if (updatedCount > 0) { + saveRirDataset(updatedDataset, opts.dataFile); + console.log(`[fetch-rir-followups] Dataset saved (${updatedCount} record(s) updated).`); + } else { + console.log('[fetch-rir-followups] No record changes — dataset not rewritten.'); + } + } else { + console.log('[fetch-rir-followups] --dry-run: no changes written to disk.'); + } + + // Alert exit code + if (opts.alertOnOverdue && alerts.length > 0) { + console.error(`[fetch-rir-followups] Exiting with code 1: ${alerts.length} overdue alert(s).`); + process.exit(1); + } + + console.log('[fetch-rir-followups] Done.'); +} + +// Run CLI when invoked directly +if (path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1] ?? '')) { + main().catch((err: unknown) => { + console.error('[fetch-rir-followups] Fatal:', err); + process.exit(1); + }); +} diff --git a/scripts/rir-followups-client.ts b/scripts/rir-followups-client.ts new file mode 100644 index 000000000..273f7264b --- /dev/null +++ b/scripts/rir-followups-client.ts @@ -0,0 +1,585 @@ +/** + * @module RirFollowupsClient + * @description Riksrevisionen (RiR) follow-up tracker library. + * + * Provides: + * - TypeScript interfaces matching `data/rir-followups.json` / `schemas/rir-followups-schema.json` + * - Deadline calculator using Swedish constitutional practice (4-month rule) + * - Status derivation helpers (PENDING / OVERDUE / RESPONDED / PARTIAL) + * - Markdown table injection utilities for intelligence-assessment documents + * - Alert detection for overdue skrivelse deadlines + * - Dataset load/save helpers (Node.js fs; mocked in tests) + * + * Constitutional basis: + * Swedish parliamentary practice (Riksdagsordningen ch. 10:4, with related + * accountability framing in RF 5:4 and RF ch. 13:7) requires the government + * to deliver a formal skrivelse (written communication) to the Riksdag + * within 4 calendar months of a Riksrevisionen (RiR) report publication. + * These provisions are treated as complementary rather than conflicting + * bases for the response expectation. + * + * @see data/rir-followups.json + * @see schemas/rir-followups-schema.json + * @see .github/skills/legislative-monitoring/SKILL.md + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { readFileSync, writeFileSync } from 'node:fs'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Government response status for a Riksrevisionen follow-up. */ +export type RirResponseStatus = 'PENDING' | 'RESPONDED' | 'OVERDUE' | 'PARTIAL'; + +/** Political accountability risk level. */ +export type RirRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + +/** + * A single Riksrevisionen follow-up record. + * Matches the JSON schema at `schemas/rir-followups-schema.json`. + */ +export interface RirFollowUpRecord { + /** Riksdag document ID for the RiR report (e.g. HD01JuU31). */ + readonly rir_report_id: string; + /** Official RiR publication number (e.g. RiR 2026:6). */ + readonly rir_number: string; + /** Swedish title of the RiR report. */ + readonly title: string; + /** English translation of the title. */ + readonly title_en?: string; + /** Primary government agency audited. */ + readonly agency: string; + /** Swedish policy area. */ + readonly policy_area?: string; + /** Riksdag committee codes handling the report (e.g. JuU, FöU). */ + readonly committees?: readonly string[]; + /** ISO 8601 date when the RiR report was published. */ + readonly publish_date: string; + /** Deadline for the government skrivelse response (null if not confirmed). */ + readonly skrivelse_deadline: string | null; + /** Government response status. */ + readonly gov_response_status: RirResponseStatus; + /** Riksdag document ID or reference of the government skrivelse response. */ + readonly response_skrivelse_id: string | null; + /** Riksdag document IDs for parliamentary follow-up documents. */ + readonly parliamentary_followup_doc_ids: readonly string[]; + /** Number of open/unresolved RiR recommendations. */ + readonly open_recommendations?: number; + /** Political accountability risk level. */ + readonly risk_level?: RirRiskLevel; + /** Additional context or intelligence notes. */ + readonly notes?: string; + /** URL to the document on riksdagen.se. */ + readonly riksdag_url?: string; +} + +/** The top-level dataset shape for `data/rir-followups.json`. */ +export interface RirFollowUpsDataset { + readonly $schema?: string; + readonly version: string; + readonly description: string; + readonly last_updated: string; + /** Default constitutional deadline in calendar months (Swedish practice: 4). */ + readonly constitutional_deadline_months: number; + readonly notes?: readonly string[]; + readonly records: readonly RirFollowUpRecord[]; +} + +/** Alert produced when a skrivelse deadline has elapsed without a response. */ +export interface RirDeadlineAlert { + readonly rir_report_id: string; + readonly rir_number: string; + readonly title: string; + readonly agency: string; + readonly skrivelse_deadline: string; + /** Number of days the deadline is overdue (positive integer). */ + readonly days_overdue: number; + readonly risk_level: RirRiskLevel; + readonly riksdag_url?: string; +} + +/** Options for the deadline calculator. */ +export interface DeadlineCalculatorOptions { + /** + * Override the default 4-month deadline. + * Useful for reports with explicitly extended timelines. + */ + readonly monthsOverride?: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Swedish constitutional practice: 4-month skrivelse deadline. */ +export const CONSTITUTIONAL_DEADLINE_MONTHS = 4 as const; + +/** + * Default risk level applied when a record's `risk_level` is missing. + * Used by both alert detection and risk-level filtering so downstream + * logic stays consistent. + */ +export const DEFAULT_RISK_LEVEL = 'MEDIUM' as const; + +/** Riksdag search endpoint for government communications (skrivelser). */ +export const RIR_SKRIVELSE_DOKTYP = 'skr' as const; + +/** Document subtype for government responses to Riksrevisionen. */ +export const RIR_SKRIVELSE_SUBTYP = 'rsk' as const; + +// --------------------------------------------------------------------------- +// Deadline calculator +// --------------------------------------------------------------------------- + +/** + * Calculate the skrivelse deadline from a RiR report publish date. + * + * Swedish constitutional practice: 4 calendar months after publication. + * The returned date is computed by adding the configured number of calendar + * months to the publish date and preserving the original day-of-month when + * possible; if the target month is shorter, the date is clamped to that + * month's last day. + * + * @param publishDate - ISO 8601 date string (YYYY-MM-DD) + * @param options - Optional override for month count + * @returns ISO 8601 date string for the calculated deadline + * + * @example + * calculateSkrivelseDeadline('2026-01-15') // '2026-05-15' + * calculateSkrivelseDeadline('2026-01-31') // '2026-05-31' + * calculateSkrivelseDeadline('2026-10-31') // '2027-02-28' (Feb has no 31st) + */ +export function calculateSkrivelseDeadline( + publishDate: string, + options: DeadlineCalculatorOptions = {}, +): string { + const months = options.monthsOverride ?? CONSTITUTIONAL_DEADLINE_MONTHS; + const pub = new Date(publishDate + 'T00:00:00Z'); + if (isNaN(pub.getTime())) { + throw new RangeError(`Invalid publish_date: ${publishDate}`); + } + const year = pub.getUTCFullYear(); + const month = pub.getUTCMonth(); // 0-indexed + const day = pub.getUTCDate(); + + const deadlineYear = year + Math.floor((month + months) / 12); + const deadlineMonth = (month + months) % 12; // 0-indexed + + // Clamp to last day of deadline month + const daysInDeadlineMonth = new Date(Date.UTC(deadlineYear, deadlineMonth + 1, 0)).getUTCDate(); + const deadlineDay = Math.min(day, daysInDeadlineMonth); + + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${deadlineYear}-${pad(deadlineMonth + 1)}-${pad(deadlineDay)}`; +} + +/** + * @deprecated Use {@link calculateSkrivelseDeadline} (correctly spelled). + * Kept as a thin alias for backwards compatibility. + */ +export const calculateSkrivelsDeadline = calculateSkrivelseDeadline; + +// --------------------------------------------------------------------------- +// Status helpers +// --------------------------------------------------------------------------- + +/** + * Derive the current response status for a record given today's date. + * + * Rules (in priority order): + * 1. If `response_skrivelse_id` is set: + * - If `gov_response_status === 'PARTIAL'` (or `open_recommendations > 0`) → PARTIAL + * - Otherwise → RESPONDED + * 2. If deadline has elapsed and no response → OVERDUE + * 3. Otherwise → PENDING + * + * Status is derived primarily from the presence of `response_skrivelse_id`: + * a record cannot be PENDING/OVERDUE if a skrivelse reference is recorded. + * + * @param record - The RiR follow-up record + * @param asOf - Reference date for "today" (ISO 8601 or Date). Defaults to now. + * @returns Derived status (does NOT mutate the record). + */ +export function deriveResponseStatus( + record: RirFollowUpRecord, + asOf: Date | string = new Date(), +): RirResponseStatus { + const now = typeof asOf === 'string' ? new Date(asOf + 'T00:00:00Z') : asOf; + + // Rule 1: a recorded skrivelse ID is the canonical signal of a response. + if (record.response_skrivelse_id) { + if ( + record.gov_response_status === 'PARTIAL' || + (typeof record.open_recommendations === 'number' && record.open_recommendations > 0) + ) { + return 'PARTIAL'; + } + return 'RESPONDED'; + } + + // Rule 2: no response yet — check deadline. + if (record.skrivelse_deadline) { + const deadline = new Date(record.skrivelse_deadline + 'T00:00:00Z'); + if (now > deadline) { + return 'OVERDUE'; + } + } + + // Rule 3: default. + return 'PENDING'; +} + +/** + * Calculate the number of days a deadline is overdue. + * + * @param deadlineDate - ISO 8601 date string (YYYY-MM-DD) + * @param asOf - Reference date. Defaults to now. + * @throws RangeError when either date is invalid (mirrors {@link calculateSkrivelseDeadline}). + * @returns Positive integer (days overdue) or 0 if not yet overdue. + */ +export function daysOverdue( + deadlineDate: string, + asOf: Date | string = new Date(), +): number { + const now = typeof asOf === 'string' ? new Date(asOf + 'T00:00:00Z') : asOf; + const deadline = new Date(deadlineDate + 'T00:00:00Z'); + if (isNaN(deadline.getTime())) { + throw new RangeError(`Invalid deadlineDate: ${deadlineDate}`); + } + if (isNaN(now.getTime())) { + throw new RangeError(`Invalid asOf: ${String(asOf)}`); + } + const diffMs = now.getTime() - deadline.getTime(); + return diffMs > 0 ? Math.floor(diffMs / (1000 * 60 * 60 * 24)) : 0; +} + +// --------------------------------------------------------------------------- +// Alert detection +// --------------------------------------------------------------------------- + +/** + * Scan a dataset for records whose skrivelse deadline has elapsed without a + * government response, and return alert objects for each. + * + * @param dataset - The full RiR follow-ups dataset + * @param asOf - Reference date for "today". Defaults to now. + * @returns Array of {@link RirDeadlineAlert} objects (empty if none overdue). + */ +export function detectOverdueAlerts( + dataset: RirFollowUpsDataset, + asOf: Date | string = new Date(), +): readonly RirDeadlineAlert[] { + const now = typeof asOf === 'string' ? new Date(asOf + 'T00:00:00Z') : asOf; + const alerts: RirDeadlineAlert[] = []; + + for (const record of dataset.records) { + if (!record.skrivelse_deadline) continue; + if (record.response_skrivelse_id) continue; + + const status = deriveResponseStatus(record, now); + if (status === 'OVERDUE') { + alerts.push({ + rir_report_id: record.rir_report_id, + rir_number: record.rir_number, + title: record.title, + agency: record.agency, + skrivelse_deadline: record.skrivelse_deadline, + days_overdue: daysOverdue(record.skrivelse_deadline, now), + risk_level: record.risk_level ?? DEFAULT_RISK_LEVEL, + riksdag_url: record.riksdag_url, + }); + } + } + + // Sort by days_overdue descending (most overdue first) + return alerts.sort((a, b) => b.days_overdue - a.days_overdue); +} + +// --------------------------------------------------------------------------- +// Markdown table injection +// --------------------------------------------------------------------------- + +/** + * Render a Markdown table of RiR follow-up records for injection into + * intelligence-assessment documents. + * + * The table includes: RiR#, Title, Agency, Published, Deadline, Status, Days Overdue. + * + * @param records - The RiR follow-up records to include in the table + * @param asOf - Reference date for status derivation. Defaults to now. + * @returns Markdown string (including header, divider, and rows). + */ +export function renderRirFollowUpTable( + records: readonly RirFollowUpRecord[], + asOf: Date | string = new Date(), +): string { + const now = typeof asOf === 'string' ? new Date(asOf + 'T00:00:00Z') : asOf; + + const header = + '| RiR # | Title | Agency | Published | Skrivelse Deadline | Status | Days Overdue |'; + const divider = + '|-------|-------|--------|-----------|-------------------|--------|--------------|'; + + const rows = records.map((r) => { + const status = deriveResponseStatus(r, now); + const overdue = status === 'OVERDUE' && r.skrivelse_deadline + ? daysOverdue(r.skrivelse_deadline, now) + : 0; + const overdueStr = overdue > 0 ? `⚠️ ${overdue}` : '—'; + const statusEmoji = { + PENDING: '⏳', + RESPONDED: '✅', + OVERDUE: '🚨', + PARTIAL: '⚠️', + }[status]; + const deadlineStr = r.skrivelse_deadline ?? '(unknown)'; + const titleLink = r.riksdag_url + ? `[${r.title}](${r.riksdag_url})` + : r.title; + return `| ${r.rir_number} | ${titleLink} | ${r.agency} | ${r.publish_date} | ${deadlineStr} | ${statusEmoji} ${status} | ${overdueStr} |`; + }); + + return [header, divider, ...rows].join('\n'); +} + +/** + * Inject (or replace) a RiR follow-up table block in a Markdown document. + * + * The block is delimited by HTML comment markers: + * + * … table … + * + * + * If the markers are absent the table is appended to the end of the document. + * + * @param documentContent - Existing Markdown document content + * @param records - RiR records to render + * @param asOf - Reference date for status derivation. Defaults to now. + * @returns Updated document content with the RiR table injected or replaced. + */ +export function injectRirTableIntoDocument( + documentContent: string, + records: readonly RirFollowUpRecord[], + asOf: Date | string = new Date(), +): string { + const table = renderRirFollowUpTable(records, asOf); + const block = [ + '', + '', + '## 🔍 Riksrevisionen Follow-Up Status', + '', + table, + '', + '', + ].join('\n'); + + const startMarker = ''; + const endMarker = ''; + const startIdx = documentContent.indexOf(startMarker); + const endIdx = documentContent.indexOf(endMarker); + + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + return ( + documentContent.slice(0, startIdx) + + block + + documentContent.slice(endIdx + endMarker.length) + ); + } + + // No existing markers — append to end + return documentContent.trimEnd() + '\n\n' + block + '\n'; +} + +// --------------------------------------------------------------------------- +// Filter helpers +// --------------------------------------------------------------------------- + +/** + * Filter records by committee code (case-insensitive). + * + * @param records - Source records + * @param committee - Committee code to match (e.g. 'JuU', 'FöU') + * @returns Records that involve the given committee. + */ +export function filterByCommittee( + records: readonly RirFollowUpRecord[], + committee: string, +): readonly RirFollowUpRecord[] { + const lc = committee.toLowerCase(); + return records.filter((r) => + (r.committees ?? []).some((c) => c.toLowerCase() === lc), + ); +} + +/** + * Filter records by government response status. + * + * @param records - Source records (status taken from stored field, NOT re-derived) + * @param status - Status to filter by + */ +export function filterByStatus( + records: readonly RirFollowUpRecord[], + status: RirResponseStatus, +): readonly RirFollowUpRecord[] { + return records.filter((r) => r.gov_response_status === status); +} + +/** + * Filter records by risk level. + * + * @param records - Source records + * @param minLevel - Minimum risk level (LOW < MEDIUM < HIGH < CRITICAL) + */ +export function filterByMinRiskLevel( + records: readonly RirFollowUpRecord[], + minLevel: RirRiskLevel, +): readonly RirFollowUpRecord[] { + const order: Record = { + LOW: 0, + MEDIUM: 1, + HIGH: 2, + CRITICAL: 3, + }; + const threshold = order[minLevel]; + return records.filter((r) => order[r.risk_level ?? DEFAULT_RISK_LEVEL] >= threshold); +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Validate a single {@link RirFollowUpRecord} for required fields and + * consistency rules. + * + * @returns Array of validation error strings. Empty array = valid. + */ +export function validateRirRecord(record: RirFollowUpRecord): readonly string[] { + const errors: string[] = []; + + if (!record.rir_report_id || typeof record.rir_report_id !== 'string') { + errors.push('rir_report_id is required and must be a string'); + } + if (!record.rir_number || !/^RiR \d{4}:\d+$/.test(record.rir_number)) { + errors.push(`rir_number must match pattern RiR YYYY:N (e.g. RiR 2026:6), got: ${record.rir_number}`); + } + if (!record.title || typeof record.title !== 'string') { + errors.push('title is required and must be a string'); + } + if (!record.agency || typeof record.agency !== 'string') { + errors.push('agency is required and must be a string'); + } + if (!record.publish_date || !/^\d{4}-\d{2}-\d{2}$/.test(record.publish_date)) { + errors.push(`publish_date must be ISO 8601 (YYYY-MM-DD), got: ${record.publish_date}`); + } + if (record.skrivelse_deadline !== null && !/^\d{4}-\d{2}-\d{2}$/.test(record.skrivelse_deadline ?? '')) { + errors.push(`skrivelse_deadline must be ISO 8601 or null, got: ${record.skrivelse_deadline}`); + } + const validStatuses: RirResponseStatus[] = ['PENDING', 'RESPONDED', 'OVERDUE', 'PARTIAL']; + if (!validStatuses.includes(record.gov_response_status)) { + errors.push(`gov_response_status must be one of ${validStatuses.join(', ')}`); + } + if (!Array.isArray(record.parliamentary_followup_doc_ids)) { + errors.push('parliamentary_followup_doc_ids must be an array'); + } else if (!record.parliamentary_followup_doc_ids.every((id) => typeof id === 'string')) { + errors.push('parliamentary_followup_doc_ids items must all be strings'); + } + if ( + record.committees !== undefined && + (!Array.isArray(record.committees) || + !record.committees.every((c) => typeof c === 'string')) + ) { + errors.push('committees must be an array of strings (when present)'); + } + if ( + record.response_skrivelse_id !== null && + typeof record.response_skrivelse_id !== 'string' + ) { + errors.push('response_skrivelse_id must be a string or null'); + } + + // Consistency: RESPONDED requires a response_skrivelse_id + if (record.gov_response_status === 'RESPONDED' && !record.response_skrivelse_id) { + errors.push('RESPONDED status requires response_skrivelse_id to be set'); + } + + return errors; +} + +/** + * Validate the entire dataset. + * + * @returns Map of `rir_report_id` → validation errors. Empty map = fully valid. + */ +export function validateRirDataset( + dataset: RirFollowUpsDataset, +): Map { + const errorMap = new Map(); + for (const record of dataset.records) { + const errors = validateRirRecord(record); + if (errors.length > 0) { + errorMap.set(record.rir_report_id ?? '(unknown)', errors); + } + } + return errorMap; +} + +// --------------------------------------------------------------------------- +// Dataset I/O helpers (injectable for testing) +// --------------------------------------------------------------------------- + +/** + * Load the RiR follow-ups dataset from a JSON file. + * + * @param filePath - Absolute or relative path to the JSON file + * @param readFileFn - Injectable file reader (default: synchronous fs.readFileSync) + * @returns Parsed dataset + */ +export function loadRirDataset( + filePath: string, + readFileFn: (path: string, encoding: BufferEncoding) => string = defaultReadFileFn, +): RirFollowUpsDataset { + const raw = readFileFn(filePath, 'utf8'); + return JSON.parse(raw) as RirFollowUpsDataset; +} + +/** + * Persist the RiR follow-ups dataset to a JSON file. + * + * @param dataset - Dataset to persist + * @param filePath - Absolute or relative path to write to + * @param writeFileFn - Injectable file writer (default: synchronous fs.writeFileSync) + * @param nowDate - Injectable clock for `last_updated` (default: `new Date()`) + */ +export function saveRirDataset( + dataset: RirFollowUpsDataset, + filePath: string, + writeFileFn: (path: string, data: string, encoding: BufferEncoding) => void = defaultWriteFileFn, + nowDate: Date = new Date(), +): void { + const json = + JSON.stringify( + { ...dataset, last_updated: nowDate.toISOString().slice(0, 10) }, + null, + 2, + ) + '\n'; + writeFileFn(filePath, json, 'utf8'); +} + +// --------------------------------------------------------------------------- +// Default I/O implementations (ESM-compatible static imports) +// --------------------------------------------------------------------------- + +/* istanbul ignore next -- Node.js fs wrapper, not testable in unit tests */ +function defaultReadFileFn(path: string, encoding: BufferEncoding): string { + return readFileSync(path, encoding); +} + +/* istanbul ignore next -- Node.js fs wrapper, not testable in unit tests */ +function defaultWriteFileFn(path: string, data: string, encoding: BufferEncoding): void { + writeFileSync(path, data, encoding); +} diff --git a/tests/rir-followups-client.test.ts b/tests/rir-followups-client.test.ts new file mode 100644 index 000000000..e1c63660f --- /dev/null +++ b/tests/rir-followups-client.test.ts @@ -0,0 +1,668 @@ +/** + * Unit and integration tests for `scripts/rir-followups-client.ts`. + * + * Most tests use synthetic data. The dataset-load/save helpers are primarily + * exercised by injecting mock read/write functions so the unit tests remain + * stable regardless of the content of `data/rir-followups.json`. This file + * also includes an integration check that reads the real dataset from + * `data/rir-followups.json` via file system I/O. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + calculateSkrivelseDeadline, + calculateSkrivelsDeadline, + daysOverdue, + deriveResponseStatus, + detectOverdueAlerts, + renderRirFollowUpTable, + injectRirTableIntoDocument, + filterByCommittee, + filterByStatus, + filterByMinRiskLevel, + validateRirRecord, + validateRirDataset, + loadRirDataset, + saveRirDataset, + CONSTITUTIONAL_DEADLINE_MONTHS, + RIR_SKRIVELSE_DOKTYP, + RIR_SKRIVELSE_SUBTYP, +} from '../scripts/rir-followups-client.js'; +import type { + RirFollowUpRecord, + RirFollowUpsDataset, +} from '../scripts/rir-followups-client.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const RECORD_PENDING: RirFollowUpRecord = { + rir_report_id: 'HD01JuU31', + rir_number: 'RiR 2026:6', + title: 'Polisreform — granskning', + title_en: 'Police reform audit', + agency: 'Polismyndigheten', + policy_area: 'Justitia', + committees: ['JuU'], + publish_date: '2026-01-15', + skrivelse_deadline: '2026-05-15', + gov_response_status: 'PENDING', + response_skrivelse_id: null, + parliamentary_followup_doc_ids: ['HD01JuU31'], + open_recommendations: 9, + risk_level: 'HIGH', + notes: 'Test pending record', + riksdag_url: 'https://www.riksdagen.se/sv/dokument-och-lagar/dokument/HD01JuU31/', +}; + +const RECORD_OVERDUE: RirFollowUpRecord = { + rir_report_id: 'HC03206', + rir_number: 'RiR 2025:18', + title: 'Civilt försvar — granskning', + title_en: 'Civil defence audit', + agency: 'MSB', + policy_area: 'Försvar och säkerhet', + committees: ['FöU'], + publish_date: '2025-12-10', + skrivelse_deadline: '2026-04-10', + gov_response_status: 'OVERDUE', + response_skrivelse_id: null, + parliamentary_followup_doc_ids: ['HC03206'], + open_recommendations: 7, + risk_level: 'HIGH', +}; + +const RECORD_RESPONDED: RirFollowUpRecord = { + rir_report_id: 'HB01NU20', + rir_number: 'RiR 2025:12', + title: 'Exportfrämjande — Business Sweden', + agency: 'Business Sweden', + committees: ['NU'], + publish_date: '2025-09-22', + skrivelse_deadline: '2026-01-22', + gov_response_status: 'RESPONDED', + response_skrivelse_id: 'Skr. 2025/26:78', + parliamentary_followup_doc_ids: ['HB01NU20'], + open_recommendations: 0, + risk_level: 'LOW', +}; + +const RECORD_PARTIAL: RirFollowUpRecord = { + rir_report_id: 'HA01AU15', + rir_number: 'RiR 2025:7', + title: 'Arbetsmarknadspolitiken — Arbetsförmedlingen', + agency: 'Arbetsförmedlingen', + committees: ['AU'], + publish_date: '2025-06-05', + skrivelse_deadline: '2025-10-05', + gov_response_status: 'PARTIAL', + response_skrivelse_id: 'Skr. 2025/26:22', + parliamentary_followup_doc_ids: ['HA01AU15'], + open_recommendations: 2, + risk_level: 'MEDIUM', +}; + +const DATASET: RirFollowUpsDataset = { + version: '1.0', + description: 'Test dataset', + last_updated: '2026-04-27', + constitutional_deadline_months: 4, + records: [RECORD_PENDING, RECORD_OVERDUE, RECORD_RESPONDED, RECORD_PARTIAL], +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +describe('constants', () => { + it('CONSTITUTIONAL_DEADLINE_MONTHS is 4', () => { + expect(CONSTITUTIONAL_DEADLINE_MONTHS).toBe(4); + }); + + it('RIR_SKRIVELSE_DOKTYP is "skr"', () => { + expect(RIR_SKRIVELSE_DOKTYP).toBe('skr'); + }); + + it('RIR_SKRIVELSE_SUBTYP is "rsk"', () => { + expect(RIR_SKRIVELSE_SUBTYP).toBe('rsk'); + }); +}); + +// --------------------------------------------------------------------------- +// calculateSkrivelseDeadline +// --------------------------------------------------------------------------- + +describe('calculateSkrivelseDeadline', () => { + it('adds 4 months to a mid-month date', () => { + expect(calculateSkrivelseDeadline('2026-01-15')).toBe('2026-05-15'); + }); + + it('adds 4 months crossing a year boundary', () => { + expect(calculateSkrivelseDeadline('2025-10-31')).toBe('2026-02-28'); + }); + + it('clamps to last day when target month is shorter', () => { + // Jan 31 + 1 month = Feb 28 (non-leap) + expect(calculateSkrivelseDeadline('2025-01-31', { monthsOverride: 1 })).toBe('2025-02-28'); + }); + + it('handles leap year correctly', () => { + // Oct 31 2023 + 4 months = Feb 29 2024 (leap year) + expect(calculateSkrivelseDeadline('2023-10-31')).toBe('2024-02-29'); + }); + + it('uses custom month override', () => { + expect(calculateSkrivelseDeadline('2026-01-15', { monthsOverride: 6 })).toBe('2026-07-15'); + }); + + it('throws RangeError on invalid date', () => { + expect(() => calculateSkrivelseDeadline('not-a-date')).toThrow(RangeError); + }); + + it('handles end of year wrapping correctly', () => { + expect(calculateSkrivelseDeadline('2026-12-15')).toBe('2027-04-15'); + }); + + it('exposes a backwards-compatible alias `calculateSkrivelsDeadline`', () => { + expect(calculateSkrivelsDeadline).toBe(calculateSkrivelseDeadline); + expect(calculateSkrivelsDeadline('2026-01-15')).toBe('2026-05-15'); + }); +}); + +// --------------------------------------------------------------------------- +// daysOverdue +// --------------------------------------------------------------------------- + +describe('daysOverdue', () => { + it('returns 0 when not yet overdue', () => { + expect(daysOverdue('2026-12-31', '2026-06-01')).toBe(0); + }); + + it('returns positive integer when overdue', () => { + expect(daysOverdue('2026-04-10', '2026-04-27')).toBe(17); + }); + + it('returns 0 exactly on the deadline day', () => { + expect(daysOverdue('2026-04-10', '2026-04-10')).toBe(0); + }); + + it('throws RangeError on invalid deadlineDate', () => { + expect(() => daysOverdue('not-a-date', '2026-04-10')).toThrow(RangeError); + }); + + it('throws RangeError on invalid asOf string', () => { + expect(() => daysOverdue('2026-04-10', 'not-a-date')).toThrow(RangeError); + }); +}); + +// --------------------------------------------------------------------------- +// deriveResponseStatus +// --------------------------------------------------------------------------- + +describe('deriveResponseStatus', () => { + it('returns RESPONDED when skrivelse_id set and status RESPONDED', () => { + expect(deriveResponseStatus(RECORD_RESPONDED, '2026-04-27')).toBe('RESPONDED'); + }); + + it('returns PARTIAL when status PARTIAL and skrivelse_id set', () => { + expect(deriveResponseStatus(RECORD_PARTIAL, '2026-04-27')).toBe('PARTIAL'); + }); + + it('returns OVERDUE when deadline elapsed and no response', () => { + // RECORD_OVERDUE: deadline 2026-04-10, asOf 2026-04-27 + expect(deriveResponseStatus(RECORD_OVERDUE, '2026-04-27')).toBe('OVERDUE'); + }); + + it('returns PENDING when deadline not yet elapsed', () => { + // RECORD_PENDING: deadline 2026-05-15, asOf 2026-04-27 + expect(deriveResponseStatus(RECORD_PENDING, '2026-04-27')).toBe('PENDING'); + }); + + it('returns PENDING when no deadline set', () => { + const noDeadline: RirFollowUpRecord = { + ...RECORD_PENDING, + skrivelse_deadline: null, + }; + expect(deriveResponseStatus(noDeadline, '2030-01-01')).toBe('PENDING'); + }); + + it('uses current date by default', () => { + // Just check that it does not throw + expect(() => deriveResponseStatus(RECORD_PENDING)).not.toThrow(); + }); + + it('returns RESPONDED when stored status is PENDING but response_skrivelse_id is set (rule 1)', () => { + const stalePending: RirFollowUpRecord = { + ...RECORD_PENDING, + gov_response_status: 'PENDING', + response_skrivelse_id: 'Skr. 2026/27:42', + open_recommendations: 0, + }; + expect(deriveResponseStatus(stalePending, '2026-04-27')).toBe('RESPONDED'); + }); + + it('returns PARTIAL when response_skrivelse_id is set but open_recommendations > 0', () => { + const stalePending: RirFollowUpRecord = { + ...RECORD_PENDING, + gov_response_status: 'PENDING', + response_skrivelse_id: 'Skr. 2026/27:42', + open_recommendations: 3, + }; + expect(deriveResponseStatus(stalePending, '2026-04-27')).toBe('PARTIAL'); + }); +}); + +// --------------------------------------------------------------------------- +// detectOverdueAlerts +// --------------------------------------------------------------------------- + +describe('detectOverdueAlerts', () => { + it('returns alerts for OVERDUE records only', () => { + const alerts = detectOverdueAlerts(DATASET, '2026-04-27'); + expect(alerts).toHaveLength(1); + expect(alerts[0].rir_report_id).toBe('HC03206'); + }); + + it('alert has correct days_overdue', () => { + const alerts = detectOverdueAlerts(DATASET, '2026-04-27'); + // deadline: 2026-04-10, asOf: 2026-04-27 → 17 days + expect(alerts[0].days_overdue).toBe(17); + }); + + it('returns empty array when no records are overdue', () => { + const alerts = detectOverdueAlerts(DATASET, '2026-01-01'); + expect(alerts).toHaveLength(0); + }); + + it('sorts alerts by days_overdue descending', () => { + const dataset: RirFollowUpsDataset = { + ...DATASET, + records: [ + { ...RECORD_OVERDUE, rir_report_id: 'A', rir_number: 'RiR 2025:1', skrivelse_deadline: '2026-03-01' }, + { ...RECORD_OVERDUE, rir_report_id: 'B', rir_number: 'RiR 2025:2', skrivelse_deadline: '2026-02-01' }, + ], + }; + const alerts = detectOverdueAlerts(dataset, '2026-04-27'); + expect(alerts[0].rir_report_id).toBe('B'); // Feb 1 → more overdue + expect(alerts[1].rir_report_id).toBe('A'); + }); + + it('skips records with no skrivelse_deadline', () => { + const dataset: RirFollowUpsDataset = { + ...DATASET, + records: [{ ...RECORD_PENDING, skrivelse_deadline: null }], + }; + const alerts = detectOverdueAlerts(dataset, '2030-01-01'); + expect(alerts).toHaveLength(0); + }); + + it('skips records that already have a response_skrivelse_id', () => { + const responded: RirFollowUpRecord = { + ...RECORD_OVERDUE, + response_skrivelse_id: 'Skr. 2026:99', + }; + const dataset: RirFollowUpsDataset = { ...DATASET, records: [responded] }; + const alerts = detectOverdueAlerts(dataset, '2026-04-27'); + expect(alerts).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// renderRirFollowUpTable +// --------------------------------------------------------------------------- + +describe('renderRirFollowUpTable', () => { + it('renders a header row and record rows', () => { + const table = renderRirFollowUpTable([RECORD_RESPONDED], '2026-04-27'); + expect(table).toContain('| RiR #'); + expect(table).toContain('RiR 2025:12'); + expect(table).toContain('✅ RESPONDED'); + }); + + it('shows overdue emoji for overdue records', () => { + const table = renderRirFollowUpTable([RECORD_OVERDUE], '2026-04-27'); + expect(table).toContain('🚨 OVERDUE'); + expect(table).toContain('⚠️ 17'); + }); + + it('shows pending emoji for pending records', () => { + const table = renderRirFollowUpTable([RECORD_PENDING], '2026-04-27'); + expect(table).toContain('⏳ PENDING'); + expect(table).toContain('—'); // no days overdue + }); + + it('uses riksdag_url for title link when available', () => { + const table = renderRirFollowUpTable([RECORD_PENDING], '2026-04-27'); + expect(table).toContain('[Polisreform'); + expect(table).toContain('HD01JuU31/'); + }); + + it('renders empty table gracefully', () => { + const table = renderRirFollowUpTable([], '2026-04-27'); + expect(table).toContain('| RiR #'); + // Only header and divider + const lines = table.split('\n'); + expect(lines).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// injectRirTableIntoDocument +// --------------------------------------------------------------------------- + +describe('injectRirTableIntoDocument', () => { + it('appends a table block to a document without existing markers', () => { + const doc = '# Intelligence Assessment\n\nSome analysis here.'; + const result = injectRirTableIntoDocument(doc, [RECORD_RESPONDED], '2026-04-27'); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('## 🔍 Riksrevisionen Follow-Up Status'); + expect(result).toContain('Some analysis here.'); + }); + + it('replaces an existing table block', () => { + const doc = [ + '# Doc', + '', + '', + '## 🔍 Riksrevisionen Follow-Up Status', + '| old table |', + '', + '', + 'Footer text.', + ].join('\n'); + + const result = injectRirTableIntoDocument(doc, [RECORD_PENDING], '2026-04-27'); + expect(result).not.toContain('| old table |'); + expect(result).toContain('RiR 2026:6'); + expect(result).toContain('Footer text.'); + }); + + it('preserves content before and after the markers', () => { + const doc = [ + 'BEFORE', + '', + 'OLD', + '', + 'AFTER', + ].join('\n'); + const result = injectRirTableIntoDocument(doc, [], '2026-04-27'); + expect(result.startsWith('BEFORE')).toBe(true); + expect(result).toContain('AFTER'); + expect(result).not.toContain('OLD'); + }); +}); + +// --------------------------------------------------------------------------- +// filterByCommittee +// --------------------------------------------------------------------------- + +describe('filterByCommittee', () => { + it('filters records by committee code', () => { + const result = filterByCommittee(DATASET.records, 'JuU'); + expect(result).toHaveLength(1); + expect(result[0].rir_report_id).toBe('HD01JuU31'); + }); + + it('is case-insensitive', () => { + const lower = filterByCommittee(DATASET.records, 'juu'); + const upper = filterByCommittee(DATASET.records, 'JuU'); + expect(lower.map((r) => r.rir_report_id)).toEqual(upper.map((r) => r.rir_report_id)); + }); + + it('returns empty array if no match', () => { + expect(filterByCommittee(DATASET.records, 'KU')).toHaveLength(0); + }); + + it('handles records without committees field', () => { + const noCommittees: RirFollowUpRecord = { ...RECORD_PENDING, committees: undefined }; + expect(filterByCommittee([noCommittees], 'JuU')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// filterByStatus +// --------------------------------------------------------------------------- + +describe('filterByStatus', () => { + it('filters PENDING records', () => { + const result = filterByStatus(DATASET.records, 'PENDING'); + expect(result).toHaveLength(1); + expect(result[0].rir_report_id).toBe('HD01JuU31'); + }); + + it('filters OVERDUE records', () => { + const result = filterByStatus(DATASET.records, 'OVERDUE'); + expect(result).toHaveLength(1); + expect(result[0].rir_report_id).toBe('HC03206'); + }); + + it('filters RESPONDED records', () => { + const result = filterByStatus(DATASET.records, 'RESPONDED'); + expect(result).toHaveLength(1); + expect(result[0].rir_report_id).toBe('HB01NU20'); + }); + + it('filters PARTIAL records', () => { + const result = filterByStatus(DATASET.records, 'PARTIAL'); + expect(result).toHaveLength(1); + expect(result[0].rir_report_id).toBe('HA01AU15'); + }); +}); + +// --------------------------------------------------------------------------- +// filterByMinRiskLevel +// --------------------------------------------------------------------------- + +describe('filterByMinRiskLevel', () => { + it('returns all records at or above LOW', () => { + const result = filterByMinRiskLevel(DATASET.records, 'LOW'); + expect(result).toHaveLength(4); + }); + + it('filters to MEDIUM and above', () => { + const result = filterByMinRiskLevel(DATASET.records, 'MEDIUM'); + const ids = result.map((r) => r.rir_report_id); + expect(ids).toContain('HC03206'); // HIGH + expect(ids).toContain('HD01JuU31'); // HIGH + expect(ids).toContain('HA01AU15'); // MEDIUM + expect(ids).not.toContain('HB01NU20'); // LOW + }); + + it('filters to HIGH and above', () => { + const result = filterByMinRiskLevel(DATASET.records, 'HIGH'); + expect(result).toHaveLength(2); + expect(result.every((r) => r.risk_level === 'HIGH' || r.risk_level === 'CRITICAL')).toBe(true); + }); + + it('defaults missing risk_level to MEDIUM (shared default)', () => { + const noRisk: RirFollowUpRecord = { ...RECORD_RESPONDED, risk_level: undefined }; + // Default MEDIUM ⇒ included when filtering at MEDIUM, excluded when filtering at HIGH. + expect(filterByMinRiskLevel([noRisk], 'MEDIUM')).toHaveLength(1); + expect(filterByMinRiskLevel([noRisk], 'HIGH')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// validateRirRecord +// --------------------------------------------------------------------------- + +describe('validateRirRecord', () => { + it('passes a fully valid record', () => { + expect(validateRirRecord(RECORD_RESPONDED)).toHaveLength(0); + }); + + it('reports missing rir_report_id', () => { + const bad = { ...RECORD_PENDING, rir_report_id: '' }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('rir_report_id'))).toBe(true); + }); + + it('reports invalid rir_number format', () => { + const bad = { ...RECORD_PENDING, rir_number: 'INVALID' }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('rir_number'))).toBe(true); + }); + + it('reports invalid publish_date', () => { + const bad = { ...RECORD_PENDING, publish_date: '15-01-2026' }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('publish_date'))).toBe(true); + }); + + it('reports invalid gov_response_status', () => { + const bad = { ...RECORD_PENDING, gov_response_status: 'UNKNOWN' as never }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('gov_response_status'))).toBe(true); + }); + + it('reports RESPONDED without response_skrivelse_id', () => { + const bad: RirFollowUpRecord = { + ...RECORD_RESPONDED, + response_skrivelse_id: null, + }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('RESPONDED'))).toBe(true); + }); + + it('accepts null skrivelse_deadline', () => { + const noDeadline: RirFollowUpRecord = { ...RECORD_PENDING, skrivelse_deadline: null }; + const errors = validateRirRecord(noDeadline); + expect(errors.filter((e) => e.includes('skrivelse_deadline'))).toHaveLength(0); + }); + + it('reports non-string response_skrivelse_id', () => { + const bad = { ...RECORD_PENDING, response_skrivelse_id: 42 as unknown as null }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('response_skrivelse_id'))).toBe(true); + }); + + it('reports non-string item in parliamentary_followup_doc_ids', () => { + const bad = { + ...RECORD_PENDING, + parliamentary_followup_doc_ids: ['ok', 123 as unknown as string], + }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('parliamentary_followup_doc_ids'))).toBe(true); + }); + + it('reports non-string item in committees', () => { + const bad = { ...RECORD_PENDING, committees: ['JuU', 5 as unknown as string] }; + const errors = validateRirRecord(bad); + expect(errors.some((e) => e.includes('committees'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// validateRirDataset +// --------------------------------------------------------------------------- + +describe('validateRirDataset', () => { + it('returns empty map for valid dataset', () => { + const errors = validateRirDataset(DATASET); + expect(errors.size).toBe(0); + }); + + it('returns entries for each invalid record', () => { + const bad: RirFollowUpsDataset = { + ...DATASET, + records: [ + { ...RECORD_PENDING, rir_report_id: '' }, + { ...RECORD_RESPONDED, response_skrivelse_id: null }, + ], + }; + const errors = validateRirDataset(bad); + expect(errors.size).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// loadRirDataset / saveRirDataset (injectable I/O) +// --------------------------------------------------------------------------- + +describe('loadRirDataset', () => { + it('parses a JSON string via injectable reader', () => { + const raw = JSON.stringify(DATASET); + const mockRead = (_path: string, _enc: BufferEncoding) => raw; + const loaded = loadRirDataset('/fake/path.json', mockRead); + expect(loaded.version).toBe('1.0'); + expect(loaded.records).toHaveLength(4); + }); + + it('throws on malformed JSON', () => { + const mockRead = () => 'not json {{{'; + expect(() => loadRirDataset('/fake/path.json', mockRead)).toThrow(); + }); +}); + +describe('saveRirDataset', () => { + it('calls writeFileFn with pretty JSON', () => { + let written = ''; + const mockWrite = (_path: string, data: string, _enc: BufferEncoding) => { + written = data; + }; + saveRirDataset(DATASET, '/fake/path.json', mockWrite); + const parsed = JSON.parse(written) as RirFollowUpsDataset; + expect(parsed.version).toBe('1.0'); + expect(parsed.records).toHaveLength(4); + }); + + it('updates last_updated using injected clock (deterministic)', () => { + let written = ''; + const mockWrite = (_path: string, data: string) => { written = data; }; + const fixedClock = new Date('2026-04-27T12:34:56Z'); + saveRirDataset(DATASET, '/fake/path.json', mockWrite, fixedClock); + const parsed = JSON.parse(written) as RirFollowUpsDataset; + expect(parsed.last_updated).toBe('2026-04-27'); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: load real data/rir-followups.json +// --------------------------------------------------------------------------- + +const __dirn = dirname(fileURLToPath(import.meta.url)); +const REAL_DATA_FILE = resolve(__dirn, '../data/rir-followups.json'); + +describe('data/rir-followups.json integrity', () => { + it('loads and validates the real dataset file', () => { + const raw = readFileSync(REAL_DATA_FILE, 'utf8'); + const dataset = JSON.parse(raw) as RirFollowUpsDataset; + + expect(dataset.version).toBeTruthy(); + expect(dataset.constitutional_deadline_months).toBe(4); + expect(Array.isArray(dataset.records)).toBe(true); + expect(dataset.records.length).toBeGreaterThan(0); + + const errors = validateRirDataset(dataset); + if (errors.size > 0) { + for (const [id, errs] of errors) { + console.warn(`Validation error for ${id}:`, errs); + } + } + expect(errors.size).toBe(0); + }); + + it('all records in data/rir-followups.json have required fields', () => { + const raw = readFileSync(REAL_DATA_FILE, 'utf8'); + const dataset = JSON.parse(raw) as RirFollowUpsDataset; + + for (const record of dataset.records) { + expect(record.rir_report_id).toBeTruthy(); + expect(record.rir_number).toMatch(/^RiR \d{4}:\d+$/); + expect(record.title).toBeTruthy(); + expect(record.agency).toBeTruthy(); + expect(record.publish_date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(['PENDING', 'RESPONDED', 'OVERDUE', 'PARTIAL']).toContain( + record.gov_response_status, + ); + } + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 637715dc1..323c7b321 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -121,6 +121,7 @@ export default defineConfig({ 'scripts/validate-methodology-reflection.ts', 'scripts/catalog-downloaded-data.ts', 'scripts/download-parliamentary-data.ts', + 'scripts/fetch-rir-followups.ts', 'scripts/fetch-calendar.ts', 'scripts/fetch-voting-records.ts', 'scripts/imf-fetch.ts',