Conversation
🏷️ Automatic Labeling SummaryThis PR has been automatically labeled based on the files changed and PR metadata. Applied Labels: size-xs Label Categories
For more information, see |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
…a, schema, tests, skill) Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/9537ad08-73e5-4552-8cbb-3702166ebd61 Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
…arity, PARTIAL→RESPONDED transition, deduplicated imports) Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/9537ad08-73e5-4552-8cbb-3702166ebd61 Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
| export function deriveResponseStatus( | ||
| record: RirFollowUpRecord, | ||
| asOf: Date | string = new Date(), | ||
| ): RirResponseStatus { | ||
| const now = typeof asOf === 'string' ? new Date(asOf + 'T00:00:00Z') : asOf; | ||
|
|
||
| // Already marked responded or partial explicitly — respect it unless no skrivelse ID | ||
| if (record.gov_response_status === 'RESPONDED' && record.response_skrivelse_id) { | ||
| return 'RESPONDED'; | ||
| } | ||
| if (record.gov_response_status === 'PARTIAL' && record.response_skrivelse_id) { | ||
| return 'PARTIAL'; | ||
| } | ||
|
|
||
| // No response yet — check deadline | ||
| if (record.skrivelse_deadline) { | ||
| const deadline = new Date(record.skrivelse_deadline + 'T00:00:00Z'); | ||
| if (now > deadline && !record.response_skrivelse_id) { | ||
| return 'OVERDUE'; | ||
| } | ||
| } | ||
|
|
||
| return 'PENDING'; |
There was a problem hiding this comment.
deriveResponseStatus() does not implement the documented rule that a set response_skrivelse_id implies RESPONDED/PARTIAL. Right now, a record with gov_response_status: 'PENDING' but a non-null response_skrivelse_id will still return PENDING (or OVERDUE), which makes status derivation inconsistent with the data model and CLI updates. Consider deriving primarily from response_skrivelse_id (and optionally open_recommendations/explicit flag) rather than requiring the stored status to already be RESPONDED/PARTIAL.
| export function calculateSkrivelsDeadline( | ||
| 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)}`; | ||
| } |
There was a problem hiding this comment.
Function name calculateSkrivelsDeadline appears to have a typo (“Skrivels” vs “Skrivelse”). Since this is a new public API, consider renaming to calculateSkrivelseDeadline (and optionally keeping the old name as a deprecated alias) to avoid cementing the misspelling across scripts/docs/tests.
| * @license Apache-2.0 | ||
| */ | ||
|
|
||
| import fs from 'node:fs'; |
There was a problem hiding this comment.
fs is imported but never used, which will fail linting in this repo’s ESLint setup. Remove the unused import (or use node:fs via the injected read/write fns if you intended to rely on it).
| import fs from 'node:fs'; |
| } | ||
| } | ||
|
|
||
| const asOf = dateStr ? new Date(dateStr + 'T00:00:00Z') : new Date(); |
There was a problem hiding this comment.
parseArgs() accepts --date but does not validate it. If an invalid value is passed, asOf becomes Invalid Date and later opts.asOf.toISOString() will throw, producing a less actionable failure. Validate asOf (e.g., isNaN(asOf.getTime())) and exit with a clear error message when --date is invalid.
| const asOf = dateStr ? new Date(dateStr + 'T00:00:00Z') : new Date(); | |
| 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); | |
| } |
| // Persist | ||
| if (!opts.dryRun) { | ||
| if (updatedCount > 0 || alerts.length > 0) { | ||
| saveRirDataset(updatedDataset, opts.dataFile); | ||
| console.log(`[fetch-rir-followups] Dataset saved (${updatedCount} record(s) updated).`); | ||
| } else { | ||
| console.log('[fetch-rir-followups] No changes — dataset not rewritten.'); |
There was a problem hiding this comment.
saveRirDataset() is invoked when alerts.length > 0 even if updatedCount === 0, but alert detection does not mutate the dataset. This will rewrite the JSON (and bump last_updated) on every run whenever any overdue item exists, creating noisy diffs/commits. Consider only saving when records actually change, or explicitly mutate stored statuses before saving if the intent is to persist derived OVERDUE state.
| // Persist | |
| if (!opts.dryRun) { | |
| if (updatedCount > 0 || alerts.length > 0) { | |
| saveRirDataset(updatedDataset, opts.dataFile); | |
| console.log(`[fetch-rir-followups] Dataset saved (${updatedCount} record(s) updated).`); | |
| } else { | |
| console.log('[fetch-rir-followups] No changes — dataset not rewritten.'); | |
| // Persist only when stored records actually change. | |
| // Overdue alerts are derived from the dataset and do not mutate persisted state. | |
| 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.'); |
| it('updates last_updated to today', () => { | ||
| let written = ''; | ||
| const mockWrite = (_path: string, data: string) => { written = data; }; | ||
| saveRirDataset(DATASET, '/fake/path.json', mockWrite); | ||
| const parsed = JSON.parse(written) as RirFollowUpsDataset; | ||
| const today = new Date().toISOString().slice(0, 10); | ||
| expect(parsed.last_updated).toBe(today); | ||
| }); |
There was a problem hiding this comment.
Test updates last_updated to today can be flaky around UTC midnight because it compares two separate new Date() calls (one inside saveRirDataset, one in the test). Use Vitest fake timers (vi.useFakeTimers() + vi.setSystemTime(...)) or inject a clock into saveRirDataset to make this deterministic.
| // PARTIAL → RESPONDED when a new (or fuller) skrivelse is found | ||
| const newStatus: RirFollowUpRecord['gov_response_status'] = | ||
| prevStatus === 'PARTIAL' && record.response_skrivelse_id ? 'RESPONDED' : 'RESPONDED'; |
There was a problem hiding this comment.
newStatus is always set to 'RESPONDED' (the conditional expression returns 'RESPONDED' on both branches), which makes the comment about “PARTIAL → RESPONDED” misleading and leaves dead logic. Either simplify this assignment, or implement the intended branching (e.g., only upgrade PARTIAL when a newer/full response is detected).
| // PARTIAL → RESPONDED when a new (or fuller) skrivelse is found | |
| const newStatus: RirFollowUpRecord['gov_response_status'] = | |
| prevStatus === 'PARTIAL' && record.response_skrivelse_id ? 'RESPONDED' : 'RESPONDED'; | |
| // Mark the record as responded when a matching skrivelse is found | |
| const newStatus: RirFollowUpRecord['gov_response_status'] = 'RESPONDED'; |
| "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)" | ||
| }, |
There was a problem hiding this comment.
PR description says the JSON Schema enforces that gov_response_status: "RESPONDED" requires a non-null response_skrivelse_id, but the schema currently has no conditional if/then (or similar) to enforce that relationship. Add a conditional schema rule (draft-07 supports if/then) to ensure CI validation matches the stated contract.
| "last_updated": "2026-04-27", | ||
| "constitutional_deadline_months": 4, | ||
| "notes": [ | ||
| "Swedish constitutional practice (RF 5:4 + RO 10:4) requires the government to formally respond to each Riksrevisionen audit report with a skrivelse (written communication) to the Riksdag.", |
There was a problem hiding this comment.
Constitutional citations are inconsistent across the newly added artifacts: this dataset note cites “RF 5:4 + RO 10:4”, while scripts/rir-followups-client.ts cites “RF ch. 13:7 + RO 10:4”. Align the references (or clarify why multiple provisions apply) so users don’t propagate conflicting constitutional bases into assessments.
| "Swedish constitutional practice (RF 5:4 + RO 10:4) requires the government to formally respond to each Riksrevisionen audit report with a skrivelse (written communication) to the Riksdag.", | |
| "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 returned date is the last day of the 4th full calendar month following | ||
| * the publish month (i.e. add 4 months, keep same day-of-month unless it | ||
| * falls beyond the end of that month, in which case use last day). |
There was a problem hiding this comment.
JSDoc for calculateSkrivelsDeadline says “returned date is the last day of the 4th full calendar month”, but the implementation keeps the same day-of-month and only clamps to month-end when needed. Reword the description to match the actual rule (add N calendar months; clamp to month-end if target month is shorter).
| * The returned date is the last day of the 4th full calendar month following | |
| * the publish month (i.e. add 4 months, keep same day-of-month unless it | |
| * falls beyond the end of that month, in which case use last day). | |
| * 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. |
|
@copilot apply changes based on the comments in this thread and fix issues in failing checks |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
…e, ESM-safe fs imports, fix dead branching, schema if/then, deterministic clock, type validation, UTC arithmetic Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/3a83a573-e176-4b18-a981-d439c3e6596b Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Addressed in fd10161. CI failure (unused |
Signed-off-by: James Pether Sörling <pethers@users.noreply.github.com>
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
| else if (arg === '--date' && args[i + 1]) { | ||
| dateStr = args[++i]; |
There was a problem hiding this comment.
parseArgs silently ignores --date when it is the last argument (no value provided). That can lead to running with today's date unexpectedly. Consider treating --date without a following value as a usage error (print message + exit 1/2).
| else if (arg === '--date' && args[i + 1]) { | |
| dateStr = args[++i]; | |
| 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 params = new URLSearchParams({ | ||
| doktyp: 'skr', | ||
| from: fromDate, | ||
| tom: toDate, | ||
| sz: String(limit), | ||
| 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 []; | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| console.error(`[fetch-rir-followups] Riksdag API returned ${response.status}`); | ||
| return []; | ||
| } | ||
|
|
||
| let json: RiksdagDocumentListResponse; | ||
| try { | ||
| json = (await response.json()) as RiksdagDocumentListResponse; | ||
| } catch { | ||
| console.error('[fetch-rir-followups] Failed to parse Riksdag API JSON response'); | ||
| return []; | ||
| } | ||
|
|
||
| return json?.dokumentlista?.dokument ?? []; |
There was a problem hiding this comment.
fetchRiksdagSkrivelser hard-limits results to 50 (sz) and does not paginate. Over a 90-day window it’s plausible to exceed this and miss matching skrivelser, leaving records stuck as PENDING/OVERDUE. Consider paging through results until exhausted (or increasing sz and still paging as a safety net).
| const params = new URLSearchParams({ | |
| doktyp: 'skr', | |
| from: fromDate, | |
| tom: toDate, | |
| sz: String(limit), | |
| 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 []; | |
| } | |
| if (!response.ok) { | |
| console.error(`[fetch-rir-followups] Riksdag API returned ${response.status}`); | |
| return []; | |
| } | |
| let json: RiksdagDocumentListResponse; | |
| try { | |
| json = (await response.json()) as RiksdagDocumentListResponse; | |
| } catch { | |
| console.error('[fetch-rir-followups] Failed to parse Riksdag API JSON response'); | |
| return []; | |
| } | |
| return json?.dokumentlista?.dokument ?? []; | |
| const documents: RiksdagDocumentResult[] = []; | |
| let page = 1; | |
| while (true) { | |
| const params = new URLSearchParams({ | |
| doktyp: 'skr', | |
| from: fromDate, | |
| tom: toDate, | |
| sz: String(limit), | |
| 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 []; | |
| } | |
| if (!response.ok) { | |
| console.error(`[fetch-rir-followups] Riksdag API returned ${response.status}`); | |
| return []; | |
| } | |
| let json: RiksdagDocumentListResponse; | |
| try { | |
| json = (await response.json()) as RiksdagDocumentListResponse; | |
| } catch { | |
| console.error('[fetch-rir-followups] Failed to parse Riksdag API JSON response'); | |
| return []; | |
| } | |
| const pageDocuments = json?.dokumentlista?.dokument ?? []; | |
| documents.push(...pageDocuments); | |
| if (pageDocuments.length === 0 || pageDocuments.length < limit) { | |
| break; | |
| } | |
| page += 1; | |
| } | |
| return documents; |
| const title = (skrivelse.titel ?? '').toLowerCase(); | ||
| const docId = skrivelse.dok_id ?? skrivelse.id ?? ''; | ||
|
|
||
| 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 (document already matched) | ||
| if (record.response_skrivelse_id && docId.includes(record.response_skrivelse_id)) return record; | ||
| } |
There was a problem hiding this comment.
matchSkrivelse tries to match an already-recorded response_skrivelse_id by checking whether docId (dok_id/id) includes the stored value. In the dataset the field is a human reference like Skr. 2025/26:78, which won’t be contained in a dok_id like H.... Match against skrivelse.beteckning (or normalize both sides) instead, otherwise previously-responded records may never match.
| // Mark the record as responded when a matching skrivelse is found. | ||
| // PARTIAL records are upgraded to RESPONDED when a (presumably fuller) skrivelse appears. | ||
| const newStatus: RirFollowUpRecord['gov_response_status'] = 'RESPONDED'; | ||
| console.log( | ||
| `[fetch-rir-followups] Matched response for ${record.rir_report_id} (${prevStatus} → ${newStatus}): ${newId}`, | ||
| ); | ||
| updatedCount++; | ||
| return { | ||
| ...record, | ||
| gov_response_status: newStatus, | ||
| response_skrivelse_id: newId, |
There was a problem hiding this comment.
When a matching skrivelse is found, the script always writes gov_response_status: 'RESPONDED' even if open_recommendations > 0. That conflicts with the library’s derivation rules (response + open recommendations => PARTIAL) and can persist an incorrect status to data/rir-followups.json. Consider deriving the stored status from open_recommendations (and/or preserving PARTIAL unless you have evidence the new skrivelse closes recommendations).
| // Mark the record as responded when a matching skrivelse is found. | |
| // PARTIAL records are upgraded to RESPONDED when a (presumably fuller) skrivelse appears. | |
| const newStatus: RirFollowUpRecord['gov_response_status'] = 'RESPONDED'; | |
| console.log( | |
| `[fetch-rir-followups] Matched response for ${record.rir_report_id} (${prevStatus} → ${newStatus}): ${newId}`, | |
| ); | |
| updatedCount++; | |
| return { | |
| ...record, | |
| gov_response_status: newStatus, | |
| response_skrivelse_id: newId, | |
| const candidateRecord: RirFollowUpRecord = { | |
| ...record, | |
| response_skrivelse_id: newId, | |
| }; | |
| // Derive persisted status from the updated record so stored data stays | |
| // consistent with the canonical library rules (for example, a response | |
| // with open recommendations remains PARTIAL rather than being forced to | |
| // RESPONDED). | |
| 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, |
| 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'); | ||
| const diffMs = now.getTime() - deadline.getTime(); | ||
| return diffMs > 0 ? Math.floor(diffMs / (1000 * 60 * 60 * 24)) : 0; | ||
| } |
There was a problem hiding this comment.
daysOverdue returns 0 for invalid deadlineDate / asOf values because Invalid Date produces NaN and diffMs > 0 becomes false. Since calculateSkrivelseDeadline throws on invalid input, consider validating here too (throw RangeError when either date is invalid) to avoid silently masking bad data.
| skrivelse_deadline: record.skrivelse_deadline, | ||
| days_overdue: daysOverdue(record.skrivelse_deadline, now), | ||
| risk_level: record.risk_level ?? 'MEDIUM', | ||
| riksdag_url: record.riksdag_url, | ||
| }); |
There was a problem hiding this comment.
detectOverdueAlerts defaults missing risk_level to MEDIUM, while filterByMinRiskLevel treats missing risk_level as LOW. Having two different defaults makes downstream logic harder to reason about (e.g., alerts vs. filtering). Consider using a single shared default (and ideally a constant) for missing risk.
| * @param readFileFn - Injectable file reader (default: synchronous fs.readFileSync) | ||
| * @param filePath - Absolute or relative path to the JSON file |
There was a problem hiding this comment.
JSDoc for loadRirDataset lists parameters in the opposite order of the actual function signature (it documents readFileFn before filePath). This can mislead callers and generated docs; please align the @param order/names with the signature.
| * @param readFileFn - Injectable file reader (default: synchronous fs.readFileSync) | |
| * @param filePath - Absolute or relative path to the JSON file | |
| * @param filePath - Absolute or relative path to the JSON file | |
| * @param readFileFn - Injectable file reader (default: synchronous fs.readFileSync) |
|
@copilot apply changes based on the comments in this thread |
…ived status, daysOverdue validation, shared default risk, JSDoc and --date-arg fixes Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/4d01721c-5d6a-483c-880e-e27413d49302 Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
Addressed in ab9fc42. All 7 round-2 review items: |
🔍 Lighthouse Performance Audit
📥 Download full Lighthouse report Budget Compliance: Performance budgets enforced via |
RiR findings (HD01JuU31, HC03206) were cited passively in intelligence assessments with no tracking of constitutional response deadlines or government accountability status. Swedish constitutional practice requires a government skrivelse within 4 months of each RiR publication — this was invisible in the platform.
New dataset & schema
data/rir-followups.json— seed records for 4 RiR reports withpublish_date,skrivelse_deadline,gov_response_status(PENDING/RESPONDED/OVERDUE/PARTIAL),response_skrivelse_id, committees, andrisk_levelschemas/rir-followups-schema.json— JSON Schema draft-07 for CI validation; uses anif/thenconditional rule to enforce thatgov_response_status: "RESPONDED"requires a non-emptyresponse_skrivelse_id(ajv-verified)Library:
scripts/rir-followups-client.tsPure-TypeScript module with injectable I/O (static
node:fsimports, ESM-safe; no I/O at import time):A shared
DEFAULT_RISK_LEVEL = 'MEDIUM'constant is now used by bothdetectOverdueAlertsandfilterByMinRiskLevelso missingrisk_levelis treated consistently across alerting and filtering.CLI:
scripts/fetch-rir-followups.tsDaily update script: queries
data.riksdagen.sefordoktyp=skrover a 90-day window (UTC-safesetUTCDate/getUTCDatearithmetic), with paginated results (p=Nuntil exhausted, with a hard 50-page safety cap) so 90-day windows containing more than one page of skrivelser no longer silently truncate. Matches against known records viarir_report_id/rir_numberin the title and via tolerant equality betweenskrivelse.beteckningand the storedresponse_skrivelse_id(normalisation strips the "Skr." prefix, whitespace, and case). When a match is found the persisted status is computed viaderiveResponseStatuson a candidate record so a response withopen_recommendations > 0correctly staysPARTIALrather than being forced toRESPONDED. Persists changes only when records actually change (overdue alerts alone do not trigger noisy rewrites).--dateis validated and exits 1 with a clear error on invalid input;--datewithout a following value exits 2 with a clear "missing value" message.--alertflag exits 1 when anyOVERDUErecords are detected.Constitutional citations
References are aligned across
data/rir-followups.jsonandscripts/rir-followups-client.ts: RO 10:4 for Riksdag handling of Riksrevisionen reports, together with RF 5:4 and RF ch. 13:7 for government accountability/follow-up context, treated as complementary rather than conflicting bases.Skill update
legislative-monitoring/SKILL.mdgains a §7 Riksrevisionen Follow-Up Tracking section: constitutional basis, data model table,stateDiagram-v2lifecycle, library API reference (canonicalcalculateSkrivelseDeadline), CLI usage, Markdown injection pattern, MCP search query (doktyp=skr), and alert-threshold table (0/1–30/31–90/91+ days).Tests
68 Vitest tests in
tests/rir-followups-client.test.tscovering all exported functions — including the deprecated alias,deriveResponseStatusrule-1 derivation (skrivelse-id-implies-RESPONDED), PARTIAL detection viaopen_recommendations, type-check validation forresponse_skrivelse_id/committees[]/parliamentary_followup_doc_ids[],daysOverdueRangeErroron invalid input, the sharedDEFAULT_RISK_LEVELsemantics forfilterByMinRiskLevel, and a deterministic-clock test forsaveRirDataset— plus an integration test that loads and validates the realdata/rir-followups.jsonon every test run.