diff --git a/.github/prompts/05-analysis-gate.md b/.github/prompts/05-analysis-gate.md index 7c7a7bb01c..5c5a99befa 100644 --- a/.github/prompts/05-analysis-gate.md +++ b/.github/prompts/05-analysis-gate.md @@ -232,6 +232,80 @@ if [ -s "$ANALYSIS_DIR/coalition-mathematics.md" ]; then || { echo "❌ coalition-mathematics.md: missing seat-count / vote-breakdown table"; FAIL=1; } fi +# Check 9 — PIR status sidecar (`pir-status.json`) +# A valid pir-status.json must be present after every analysis run so that +# open PIRs can be automatically rolled forward to the next cycle. +# Schema: schemas/pir-status.schema.json (v1.0) +# Roll-forward script: scripts/roll-forward-pirs.ts +PIR_FILE="$ANALYSIS_DIR/pir-status.json" +if [ ! -s "$PIR_FILE" ]; then + echo "❌ pir-status.json missing or empty in $ANALYSIS_DIR — create it per schemas/pir-status.schema.json" + FAIL=1 +else + # Structural check: required top-level fields + for PIR_FIELD in schema_version cycle date subfolder pirs generated_at; do + python3 -c " +import json, sys +try: + d = json.load(open('$PIR_FILE')) + sys.exit(0 if '$PIR_FIELD' in d else 1) +except Exception: + sys.exit(1) +" 2>/dev/null || { echo "❌ pir-status.json: missing required field '$PIR_FIELD'"; FAIL=1; } + done + # schema_version must be '1.0' + python3 -c " +import json, sys +try: + d = json.load(open('$PIR_FILE')) + sys.exit(0 if d.get('schema_version') == '1.0' else 1) +except Exception: + sys.exit(1) +" 2>/dev/null || { echo "❌ pir-status.json: schema_version must be '1.0'"; FAIL=1; } + # pirs must be an array + python3 -c " +import json, sys +try: + d = json.load(open('$PIR_FILE')) + sys.exit(0 if isinstance(d.get('pirs'), list) else 1) +except Exception: + sys.exit(1) +" 2>/dev/null || { echo "❌ pir-status.json: 'pirs' field must be a JSON array"; FAIL=1; } + # each PIR (any status) must have valid pir_id, statement, status, confidence; + # answered PIRs must carry answer_summary; non-answered PIRs must not. + python3 -c " +import json, sys, re +try: + d = json.load(open('$PIR_FILE')) + PIR_ID_RE = re.compile(r'^PIR-[A-Za-z0-9]+(-[A-Za-z0-9]+)*$') + VALID_STATUS = {'open','answered','superseded','deferred','cancelled'} + VALID_CONF = {'VERY HIGH','HIGH','MEDIUM','LOW','VERY LOW'} + bad = 0 + # Cross-field invariant: subfolder must equal cycle (not enforceable in pure JSON Schema). + if d.get('subfolder') != d.get('cycle'): + print(f'❌ pir-status.json: subfolder={d.get(\"subfolder\")!r} must equal cycle={d.get(\"cycle\")!r}'); bad = 1 + for p in d.get('pirs', []): + pid = p.get('pir_id') + if not isinstance(pid, str) or not PIR_ID_RE.match(pid): + print(f'❌ pir-status.json: invalid pir_id format: {pid!r}'); bad = 1 + for f in ('statement', 'status', 'confidence'): + if not p.get(f): + print(f'❌ pir-status.json pir={pid!r}: missing required field \"{f}\"'); bad = 1 + if p.get('status') not in VALID_STATUS: + print(f'❌ pir-status.json pir={pid!r}: invalid status {p.get(\"status\")!r}'); bad = 1 + if p.get('confidence') not in VALID_CONF: + print(f'❌ pir-status.json pir={pid!r}: invalid confidence {p.get(\"confidence\")!r}'); bad = 1 + # Conditional: answer_summary required iff status == 'answered'. + if p.get('status') == 'answered' and not p.get('answer_summary'): + print(f'❌ pir-status.json pir={pid!r}: status=answered requires non-empty answer_summary'); bad = 1 + if p.get('status') != 'answered' and 'answer_summary' in p: + print(f'❌ pir-status.json pir={pid!r}: status={p.get(\"status\")!r} must not carry answer_summary'); bad = 1 + sys.exit(bad) +except Exception as e: + print(f'❌ pir-status.json: parse error: {e}'); sys.exit(1) +" 2>&1 || FAIL=1 +fi + [ "$FAIL" -eq 0 ] || exit 1 ``` @@ -264,7 +338,7 @@ Non-blocking for `standard` / `deep` runs; **blocking for `comprehensive` / Tier Inline bash probe — append to the main block after `FAIL=0` bookkeeping completes. Supplementary artifacts have **three independent blocking triggers**, not a single tier-only rule: **aggregation article types** (`weekly-review`, `monthly-review`) require the aggregation artifacts; any run whose **tier** is `comprehensive` (the Tier-C run mode) requires the Tier-C supplementary set; and `cross-run-diff.md` is blocking whenever the workflow has **≥ 2 production runs** of the same article type, including `standard` and `deep` runs. `ARTICLE_TYPE` encodes the workflow family; `ANALYSIS_TIER` (when set) encodes the depth tier (`standard` | `deep` | `comprehensive`); `ANALYSIS_RUN_COUNT` (when set) is the numeric count of runs for the same article-generation cycle (if unset or non-numeric, treated as `1`). ```bash -# Check 9 — supplementary artifacts (blocking for aggregation types, any Tier-C run, and S5 when run-count >= 2) +# Check 10 — supplementary artifacts (blocking for aggregation types, any Tier-C run, and S5 when run-count >= 2) IS_AGGREGATION=0 IS_TIER_C=0 IS_MULTI_RUN=0 diff --git a/analysis/methodologies/ai-driven-analysis-guide.md b/analysis/methodologies/ai-driven-analysis-guide.md index 7ff994b5eb..9aec4eeea0 100644 --- a/analysis/methodologies/ai-driven-analysis-guide.md +++ b/analysis/methodologies/ai-driven-analysis-guide.md @@ -209,6 +209,7 @@ Read every file you produced in Steps 3–5. For each one, **improve every secti - Add one more named actor (MP, minister, official) to every stakeholder and SWOT entry. - Add one more dok_id or vote-record citation to every evidence column that has < 2 citations. - **Tag every key finding to a PIR/EEI** from the catalog in `political-style-guide.md`. +- **Write `pir-status.json`** — every cycle must produce `$ANALYSIS_DIR/pir-status.json` conforming to `schemas/pir-status.schema.json` v1.0 (required fields: `schema_version`, `cycle`, `date`, `subfolder`, `pirs`, `generated_at`). Newly extracted PIRs from `intelligence-assessment.md` default to `status: "open"`; rolled-forward PIRs from a prior cycle preserve their existing status (`open` / `answered` / `superseded` / `deferred` / `cancelled`) and may carry a populated `inherits_from` chain. Open PIRs that are carried forward have their confidence degraded one level (HIGH → MEDIUM, etc.) by `scripts/roll-forward-pirs.ts`; non-open PIRs are preserved unchanged so the historical lineage is never lost. This file is the machine-readable PIR sidecar used for automated roll-forward and CI gate enforcement (Check 9 in `05-analysis-gate.md`). - Add Statskontoret evidence to every implementation-capacity or agency-burden claim where a relevant public report/page exists. - Verify every macro/fiscal/monetary/external-sector claim is IMF-first, vintage-tagged when projected, and represented in `economic-data.json` when charted. - Re-rank the significance scoring if the rewrite reveals a stronger lead. @@ -579,6 +580,53 @@ Every security-relevant control in Family A maps to **ISO 27001:2022**, **NIST C | [`political-threat-framework.md`](political-threat-framework.md) | Attack trees + kill chain + threat taxonomy | | [`political-style-guide.md`](political-style-guide.md) | Writing voice, attribution, evidence density | +### PIR status sidecar — automated roll-forward + +Every analysis cycle writes a `pir-status.json` sidecar alongside the 23 required artifacts: + +| Item | Detail | +|------|--------| +| **Schema** | `schemas/pir-status.schema.json` v1.0 — JSON Schema 2020-12 | +| **Location** | `analysis/daily/YYYY-MM-DD/{subfolder}/pir-status.json` | +| **Fields** | `schema_version`, `cycle`, `date`, `subfolder`, `generated_at`, `inherited_from`, `pirs[]` | +| **PIR entry fields** | `pir_id` (pattern `PIR-*`), `statement`, `status`, `confidence`, `trigger`, `answer_summary`, `inherits_from[]`, `evidence_refs[]`, `horizon`, `admiralty_grade` | +| **Roll-forward script** | `scripts/roll-forward-pirs.ts` — propagates `open` PIRs from the previous cycle to the current cycle, degrading confidence by one level to flag staleness | +| **CI gate** | Check 9 in `.github/prompts/05-analysis-gate.md` — blocks article generation if `pir-status.json` is absent or structurally invalid | + +**How to write `pir-status.json` during analysis (Step 7):** + +```json +{ + "schema_version": "1.0", + "cycle": "month-ahead", + "date": "2026-04-27", + "subfolder": "month-ahead", + "generated_at": "2026-04-27T10:00:00Z", + "inherited_from": null, + "pirs": [ + { + "pir_id": "PIR-1", + "statement": "SD voting discipline on prop. 2025/26:236 (fuel tax)", + "trigger": "May 2026 chamber vote on HD01FiU48", + "status": "open", + "confidence": "HIGH", + "evidence_refs": ["HD01FiU48"], + "horizon": "2026-05-15", + "admiralty_grade": "B2" + } + ] +} +``` + +**Roll-forward usage (next cycle):** + +```bash +npx tsx scripts/roll-forward-pirs.ts \ + --date 2026-04-28 --cycle month-ahead +``` + +--- + ### Templates and platform exemplars | Document | Purpose | @@ -591,7 +639,8 @@ Every security-relevant control in Family A maps to **ISO 27001:2022**, **NIST C **Document Control** - **Path:** `/analysis/methodologies/ai-driven-analysis-guide.md` -- **Version:** 6.6 — Phase 2–5 alignment (worked examples + narrative-voice + Pass-2 self-audit) +- **Version:** 6.7 — PIR status sidecar (`pir-status.json`) integration +- **Key changes in v6.7:** Added mandatory `pir-status.json` sidecar write step to Pass-2 checklist (Step 7); added PIR status sidecar reference section under Related Documents; added roll-forward usage example (`scripts/roll-forward-pirs.ts`) and schema reference (`schemas/pir-status.schema.json`). - **Key changes in v6.6:** Step 3 now points at the v1.3 doctype-variant detector (5 extended types: motion-package, fpm, utskottsbetänkande-variants, KU-anmälan, EU-nämnd) and adds Narrative subsection requirement for ≥ L2 per-file artifacts; Step 4 cross-reference-map row links to the 7 atomic edge types in `structural-metadata-methodology.md` v1.3; Step 7 Pass-2 rewrite checklist adds two binding items — Pass-2 Self-Audit Checklist (10 items) and Narrative 6-axis rubric (18/30 floor); DIW section adds worked-example callout to `synthesis-methodology.md` v1.3 (line-by-line scoring + winner/loser rubric) and Sainte-Laguë walkthrough in `electoral-domain-methodology.md` v1.3; Quality Gate Checklist gains rows 11–12. - **Key changes in v6.5:** source diversity rule integration (political-style-guide.md v3.1) - **Key changes in v6.4:** Updated Step 1 reading list to reference **Source Diversity Rule** in political-style-guide.md v3.1 (multi-source corroboration by claim priority, conflict resolution, worked scenario); added Source Diversity check to Quality Gate Evidence dimension (P0/P1: ≥3 sources required); added source diversity verification to Pass-2 rewrite checklist; added IMF collection tools to referenced Collection Management Matrix. diff --git a/schemas/pir-status.schema.json b/schemas/pir-status.schema.json new file mode 100644 index 0000000000..522e652bfe --- /dev/null +++ b/schemas/pir-status.schema.json @@ -0,0 +1,132 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://riksdagsmonitor.com/schemas/pir-status.schema.json", + "title": "PIR Status Sidecar", + "description": "Machine-readable Priority Intelligence Requirement (PIR) status sidecar produced by every agentic analysis cycle. Written to `analysis/daily/YYYY-MM-DD/{subfolder}/pir-status.json`. Enables automated PIR roll-forward, cross-cycle gap detection, and CI gate enforcement. Schema v1.0.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "cycle", "date", "subfolder", "pirs", "generated_at"], + "properties": { + "schema_version": { + "type": "string", + "description": "Schema version — always '1.0' for this release.", + "enum": ["1.0"] + }, + "cycle": { + "type": "string", + "description": "Analysis cycle type slug (matches the subfolder name convention).", + "enum": [ + "committeeReports", + "propositions", + "motions", + "interpellations", + "evening-analysis", + "realtime-pulse", + "week-ahead", + "month-ahead", + "weekly-review", + "monthly-review" + ] + }, + "date": { + "type": "string", + "description": "ISO calendar date of this analysis run (YYYY-MM-DD).", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "subfolder": { + "type": "string", + "description": "Relative subfolder path under `analysis/daily/YYYY-MM-DD/` — typically equal to `cycle`. Cross-field equality is not enforced by the schema (it would require a verbose `oneOf` per cycle); the CI analysis gate (Check 10 in `.github/prompts/05-analysis-gate.md`) and the `roll-forward-pirs.ts` writer enforce `subfolder === cycle`.", + "minLength": 1 + }, + "generated_at": { + "type": "string", + "description": "ISO-8601 timestamp of when this sidecar was generated (UTC).", + "format": "date-time" + }, + "inherited_from": { + "type": ["string", "null"], + "description": "Source path this file was rolled forward from, e.g. `analysis/daily/2026-04-26/month-ahead/pir-status.json`. Null when this is a freshly authored set (no roll-forward).", + "default": null + }, + "pirs": { + "type": "array", + "description": "Ordered list of PIRs for this cycle. May be empty for short-event cycles that did not define standing PIRs.", + "items": { "$ref": "#/$defs/pirEntry" } + } + }, + "$defs": { + "pirEntry": { + "type": "object", + "additionalProperties": false, + "required": ["pir_id", "statement", "status", "confidence"], + "properties": { + "pir_id": { + "type": "string", + "description": "Stable local identifier within this cycle, e.g. 'PIR-1', 'PIR-A', 'PIR-FiU-1'. Must be unique within the `pirs` array.", + "pattern": "^PIR-[A-Za-z0-9]+([-][A-Za-z0-9]+)*$" + }, + "statement": { + "type": "string", + "description": "Human-readable intelligence requirement statement. Must be specific, falsifiable, and actionable.", + "minLength": 10 + }, + "trigger": { + "type": "string", + "description": "Observable event or threshold that would answer / close this PIR. Optional but strongly recommended.", + "minLength": 5 + }, + "status": { + "type": "string", + "description": "Current disposition of this PIR.", + "enum": ["open", "answered", "superseded", "deferred", "cancelled"] + }, + "confidence": { + "type": "string", + "description": "ODNI-aligned confidence label on the current status assessment.", + "enum": ["VERY HIGH", "HIGH", "MEDIUM", "LOW", "VERY LOW"] + }, + "answer_summary": { + "type": "string", + "description": "Short (≤ 500 chars) summary of the evidence or event that answered this PIR. Populate only when status='answered'.", + "minLength": 1, + "maxLength": 500 + }, + "inherits_from": { + "type": "array", + "description": "Zero or more `pir_id` references from the previous cycle that this PIR continues or inherits from.", + "items": { + "type": "string", + "pattern": "^PIR-[A-Za-z0-9]+([-][A-Za-z0-9]+)*$" + }, + "default": [] + }, + "evidence_refs": { + "type": "array", + "description": "Riksdag `dok_id` references, primary-source URLs, or named artifacts that provide evidence for the current status assessment. At least one reference is recommended for answered PIRs.", + "items": { + "type": "string", + "minLength": 2 + }, + "default": [] + }, + "horizon": { + "type": "string", + "description": "Monitoring horizon for this PIR, expressed as an ISO date (YYYY-MM-DD) or a relative label ('next-session', 'next-week', 'election-day'). Optional.", + "minLength": 4 + }, + "admiralty_grade": { + "type": "string", + "description": "Admiralty Code grade of the best available source for the current status (STANAG 2022). Source reliability A–F + information credibility 1–6.", + "pattern": "^[A-F][1-6]$" + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "answered" } }, "required": ["status"] }, + "then": { "required": ["answer_summary"] }, + "else": { "not": { "required": ["answer_summary"] } } + } + ] + } + } +} diff --git a/scripts/roll-forward-pirs.ts b/scripts/roll-forward-pirs.ts new file mode 100644 index 0000000000..648d6af811 --- /dev/null +++ b/scripts/roll-forward-pirs.ts @@ -0,0 +1,503 @@ +#!/usr/bin/env tsx +/** + * @module roll-forward-pirs + * @description Roll-forward PIR (Priority Intelligence Requirement) status + * sidecars between analysis cycles. + * + * Reads `pir-status.json` from a previous analysis cycle and propagates all + * open PIRs into the target cycle directory, creating a fresh `pir-status.json` + * that inherits the `pir_id` chain. Answered, superseded, cancelled, and + * deferred PIRs are carried forward unchanged (preserving their existing + * `inherits_from` history) so analysts can see the full chain. + * + * Module exports (for unit testing): `degrade`, `validateSource`, `rollForward`, + * `findLatestSource`, `parseArgs`, `subtractDays`, `runMain`. The CLI entry + * point only fires when this file is invoked directly (see isMainModule guard + * at the bottom of the file). + * + * Usage: + * npx tsx scripts/roll-forward-pirs.ts \ + * --from analysis/daily/2026-04-26/month-ahead \ + * --to analysis/daily/2026-04-27/month-ahead + * + * # Auto-detect previous cycle within 14 days: + * npx tsx scripts/roll-forward-pirs.ts \ + * --date 2026-04-27 --cycle month-ahead + * + * # Dry-run (print JSON, do not write): + * npx tsx scripts/roll-forward-pirs.ts \ + * --date 2026-04-27 --cycle month-ahead --dry-run + * + * Exit codes: + * 0 — success (file written or dry-run) + * 1 — no source found, missing args, unknown cycle + * 2 — schema validation error in source file (malformed JSON, unknown + * status/confidence enum, missing required field, bad pir_id) + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// --------------------------------------------------------------------------- +// Types (inlined to keep the module self-contained) +// --------------------------------------------------------------------------- + +export type PirStatus = 'open' | 'answered' | 'superseded' | 'deferred' | 'cancelled'; +export type Confidence = 'VERY HIGH' | 'HIGH' | 'MEDIUM' | 'LOW' | 'VERY LOW'; +export type CycleType = + | 'committeeReports' + | 'propositions' + | 'motions' + | 'interpellations' + | 'evening-analysis' + | 'realtime-pulse' + | 'week-ahead' + | 'month-ahead' + | 'weekly-review' + | 'monthly-review'; + +export interface PirEntry { + pir_id: string; + statement: string; + trigger?: string; + status: PirStatus; + confidence: Confidence; + answer_summary?: string; + inherits_from?: string[]; + evidence_refs?: string[]; + horizon?: string; + admiralty_grade?: string; +} + +export interface PirStatusFile { + schema_version: '1.0'; + cycle: CycleType; + date: string; + subfolder: string; + generated_at: string; + inherited_from?: string | null; + pirs: PirEntry[]; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.resolve(__dirname, '..'); +const ANALYSIS_DIR = path.join(REPO_ROOT, 'analysis', 'daily'); +const PIR_FILE = 'pir-status.json'; + +const VALID_CYCLES = new Set([ + 'committeeReports', + 'propositions', + 'motions', + 'interpellations', + 'evening-analysis', + 'realtime-pulse', + 'week-ahead', + 'month-ahead', + 'weekly-review', + 'monthly-review', +]); + +const VALID_STATUSES = new Set([ + 'open', + 'answered', + 'superseded', + 'deferred', + 'cancelled', +]); + +const CONFIDENCE_ORDER: Confidence[] = [ + 'VERY HIGH', + 'HIGH', + 'MEDIUM', + 'LOW', + 'VERY LOW', +]; +const VALID_CONFIDENCES = new Set(CONFIDENCE_ORDER); +const PIR_ID_PATTERN = /^PIR-[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/; + +// --------------------------------------------------------------------------- +// Pure helpers (exported for unit testing) +// --------------------------------------------------------------------------- + +/** Subtract N calendar days from an ISO date string, returning YYYY-MM-DD. */ +export function subtractDays(isoDate: string, n: number): string { + const d = new Date(`${isoDate}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() - n); + return d.toISOString().slice(0, 10); +} + +/** + * Degrade a confidence label one step toward `VERY LOW`. + * Throws on unknown values rather than silently returning `VERY HIGH`. + */ +export function degrade(c: Confidence): Confidence { + const idx = CONFIDENCE_ORDER.indexOf(c); + if (idx === -1) { + throw new Error(`Unknown confidence value: '${String(c)}'`); + } + if (idx >= CONFIDENCE_ORDER.length - 1) return 'VERY LOW'; + return CONFIDENCE_ORDER[idx + 1] as Confidence; +} + +/** + * Walk backwards up to `maxLookback` days to find the most recent + * `pir-status.json` for the given cycle. + */ +export function findLatestSource( + cycle: string, + beforeDate: string, + maxLookback = 14, + analysisDir: string = ANALYSIS_DIR, +): string | null { + for (let i = 1; i <= maxLookback; i++) { + const candidate = path.join( + analysisDir, + subtractDays(beforeDate, i), + cycle, + PIR_FILE, + ); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Strict structural + enum validation. Validates top-level required fields, + * `schema_version`, `cycle`, `date`, `subfolder`, `generated_at`, optional + * `inherited_from`, `pirs` array shape, and each PIR's `pir_id` pattern, + * `status`, and `confidence` enum membership. Does not call ajv to keep the + * script dependency-free. + * + * @throws Error with descriptive message on any validation failure. + */ +export function validateSource(raw: unknown, filePath: string): PirStatusFile { + if (typeof raw !== 'object' || raw === null) { + throw new Error(`${filePath}: not a JSON object`); + } + const obj = raw as Record; + for (const key of ['schema_version', 'cycle', 'date', 'subfolder', 'generated_at', 'pirs'] as const) { + if (!(key in obj)) throw new Error(`${filePath}: missing required field '${key}'`); + } + if (obj['schema_version'] !== '1.0') { + throw new Error( + `${filePath}: unsupported schema_version '${String(obj['schema_version'])}'`, + ); + } + if (typeof obj['cycle'] !== 'string' || !VALID_CYCLES.has(obj['cycle'] as CycleType)) { + throw new Error(`${filePath}: cycle '${String(obj['cycle'])}' is not a valid cycle`); + } + if (typeof obj['date'] !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(obj['date'])) { + throw new Error(`${filePath}: date '${String(obj['date'])}' must match YYYY-MM-DD`); + } + if (typeof obj['subfolder'] !== 'string' || obj['subfolder'].length === 0) { + throw new Error(`${filePath}: subfolder must be a non-empty string`); + } + if (obj['subfolder'] !== obj['cycle']) { + throw new Error( + `${filePath}: subfolder '${String(obj['subfolder'])}' must equal cycle '${String(obj['cycle'])}'`, + ); + } + if (typeof obj['generated_at'] !== 'string' || Number.isNaN(Date.parse(obj['generated_at']))) { + throw new Error(`${filePath}: generated_at '${String(obj['generated_at'])}' must be a valid date-time string`); + } + if ( + obj['inherited_from'] !== undefined && + obj['inherited_from'] !== null && + typeof obj['inherited_from'] !== 'string' + ) { + throw new Error(`${filePath}: inherited_from must be a string or null when present`); + } + if (!Array.isArray(obj['pirs'])) { + throw new Error(`${filePath}: 'pirs' must be an array`); + } + // Strict per-entry validation. + for (let i = 0; i < (obj['pirs'] as unknown[]).length; i++) { + const p = (obj['pirs'] as unknown[])[i] as Record; + if (typeof p !== 'object' || p === null) { + throw new Error(`${filePath}: pirs[${i}] is not an object`); + } + if (typeof p['pir_id'] !== 'string' || !PIR_ID_PATTERN.test(p['pir_id'])) { + throw new Error( + `${filePath}: pirs[${i}].pir_id '${String(p['pir_id'])}' does not match ${PIR_ID_PATTERN}`, + ); + } + if (typeof p['statement'] !== 'string' || p['statement'].length < 10) { + throw new Error( + `${filePath}: pirs[${i}] (${String(p['pir_id'])}).statement missing or shorter than 10 chars`, + ); + } + if (!VALID_STATUSES.has(p['status'] as PirStatus)) { + throw new Error( + `${filePath}: pirs[${i}] (${String(p['pir_id'])}).status '${String(p['status'])}' is not a valid PIR status`, + ); + } + if (!VALID_CONFIDENCES.has(p['confidence'] as Confidence)) { + throw new Error( + `${filePath}: pirs[${i}] (${String(p['pir_id'])}).confidence '${String(p['confidence'])}' is not a valid confidence value`, + ); + } + if (p['status'] === 'answered') { + if (typeof p['answer_summary'] !== 'string' || p['answer_summary'].length === 0) { + throw new Error( + `${filePath}: pirs[${i}] (${String(p['pir_id'])}) status='answered' requires non-empty answer_summary`, + ); + } + } else if (p['answer_summary'] !== undefined) { + throw new Error( + `${filePath}: pirs[${i}] (${String(p['pir_id'])}) status='${String(p['status'])}' must not carry answer_summary`, + ); + } + } + return obj as unknown as PirStatusFile; +} + +/** + * Build a rolled-forward PIR status file. + * + * - Open PIRs are carried forward with confidence degraded by one level + * (HIGH → MEDIUM, etc.) to signal that staleness must be addressed; the + * prior `pir_id` is appended to the existing `inherits_from` chain so + * inheritance is fully preserved. + * - Non-open PIRs (answered, superseded, deferred, cancelled) are carried + * forward UNCHANGED — including any pre-existing `inherits_from` chain — + * so the historical lineage is never lost. + */ +export function rollForward( + source: PirStatusFile, + sourcePath: string, + targetDate: string, + targetCycle: CycleType, + options: { now?: () => Date; repoRoot?: string } = {}, +): PirStatusFile { + const now = options.now ?? (() => new Date()); + const repoRoot = options.repoRoot ?? REPO_ROOT; + + const pirs: PirEntry[] = source.pirs.map((p) => { + if (p.status !== 'open') { + // Non-open PIRs are carried forward UNCHANGED so prior inherits_from + // chains are preserved in full. + return { ...p }; + } + // Destructure to explicitly drop answer_summary for open (carried-forward) PIRs. + // (An open PIR may have inherited an answer_summary if a workflow ever + // re-opens it; clearing on roll-forward keeps the schema invariant.) + const { answer_summary: _dropped, ...rest } = p; + void _dropped; + return { + ...rest, + // Degrade confidence to signal this PIR needs fresh review. + confidence: degrade(p.confidence), + // Append the prior pir_id to any existing inherits_from chain. + inherits_from: [...(p.inherits_from ?? []), p.pir_id], + }; + }); + + const relativeToRepo = path.relative(repoRoot, sourcePath); + const relativeSourcePath = + relativeToRepo && + !relativeToRepo.startsWith('..') && + // `path.relative()` can still return an absolute path across Windows + // drive roots; keep this guard so only true descendants are relativized. + !path.isAbsolute(relativeToRepo) + ? relativeToRepo.split(path.sep).join('/') + : sourcePath.split(path.sep).join('/'); + + return { + schema_version: '1.0', + cycle: targetCycle, + date: targetDate, + subfolder: targetCycle, + generated_at: now().toISOString(), + inherited_from: relativeSourcePath, + pirs, + }; +} + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +export interface CliArgs { + from?: string; + to?: string; + date?: string; + cycle?: CycleType; + dryRun: boolean; + maxLookback: number; +} + +export function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { dryRun: false, maxLookback: 14 }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--from') args.from = argv[++i]; + else if (arg === '--to') args.to = argv[++i]; + else if (arg === '--date') args.date = argv[++i]; + else if (arg === '--cycle') args.cycle = argv[++i] as CycleType; + else if (arg === '--dry-run') args.dryRun = true; + else if (arg === '--max-lookback') { + const raw = argv[++i]; + if (!raw || raw.startsWith('--')) { + throw new Error('--max-lookback requires a positive integer value'); + } + const parsed = Number.parseInt(raw, 10); + if (!/^[0-9]+$/.test(raw.trim()) || !Number.isFinite(parsed) || parsed < 1) { + throw new Error(`--max-lookback must be a positive integer (received '${raw}')`); + } + args.maxLookback = parsed; + } + } + return args; +} + +// --------------------------------------------------------------------------- +// Main (CLI entry point) +// --------------------------------------------------------------------------- + +export interface RunIO { + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; + cwd?: string; + exit?: (code: number) => never; + now?: () => Date; +} + +export function runMain(argv: string[], io: RunIO = {}): void { + const out = io.stdout ?? process.stdout; + const err = io.stderr ?? process.stderr; + const exit = io.exit ?? ((c: number): never => process.exit(c)); + let args: CliArgs; + try { + args = parseArgs(argv); + } catch (e) { + err.write(`Argument error: ${String(e instanceof Error ? e.message : e)}\n`); + exit(1); + return; + } + + let sourcePath: string; + let targetDir: string; + let targetDate: string; + let targetCycle: CycleType; + + if (args.from && args.to) { + sourcePath = path.isAbsolute(args.from) + ? path.join(args.from, PIR_FILE) + : path.join(REPO_ROOT, args.from, PIR_FILE); + targetDir = path.isAbsolute(args.to) ? args.to : path.join(REPO_ROOT, args.to); + + if (!fs.existsSync(sourcePath)) { + err.write(`Source not found: ${sourcePath}\n`); + exit(1); + return; + } + // Derive targetDate and targetCycle from `--to` path (bounds-checked). + const parts = targetDir.replace(/\\/g, '/').split('/'); + const dailyIdx = parts.indexOf('daily'); + const datePart = dailyIdx >= 0 && dailyIdx + 1 < parts.length ? (parts[dailyIdx + 1] ?? '') : ''; + const cyclePart = dailyIdx >= 0 && dailyIdx + 2 < parts.length ? (parts[dailyIdx + 2] ?? '') : ''; + targetDate = datePart; + targetCycle = cyclePart as CycleType; + if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate) || !VALID_CYCLES.has(targetCycle)) { + err.write(`Cannot derive date/cycle from --to path: ${args.to}\n`); + exit(1); + return; + } + } else if (args.date && args.cycle) { + if (!VALID_CYCLES.has(args.cycle)) { + err.write(`Unknown cycle: ${args.cycle}. Valid: ${[...VALID_CYCLES].join(', ')}\n`); + exit(1); + return; + } + targetDate = args.date; + targetCycle = args.cycle; + targetDir = path.join(ANALYSIS_DIR, targetDate, targetCycle); + + const found = findLatestSource(targetCycle, targetDate, args.maxLookback); + if (!found) { + err.write( + `No previous pir-status.json found for cycle '${targetCycle}' within ${args.maxLookback} days before ${targetDate}. Exiting with code 1.\n`, + ); + exit(1); + return; + } + sourcePath = found; + } else { + err.write( + 'Usage:\n' + + ' roll-forward-pirs --from --to \n' + + ' roll-forward-pirs --date YYYY-MM-DD --cycle [--dry-run] [--max-lookback N]\n', + ); + exit(1); + return; + } + + // Read and validate source. + let rawSource: unknown; + try { + rawSource = JSON.parse(fs.readFileSync(sourcePath, 'utf-8')); + } catch (e) { + err.write(`Failed to read source: ${String(e)}\n`); + exit(2); + return; + } + + let source: PirStatusFile; + try { + source = validateSource(rawSource, sourcePath); + } catch (e) { + err.write(`Schema validation error: ${String(e)}\n`); + exit(2); + return; + } + + const opts: { now?: () => Date } = {}; + if (io.now) opts.now = io.now; + const output = rollForward(source, sourcePath, targetDate, targetCycle, opts); + const json = JSON.stringify(output, null, 2) + '\n'; + + if (args.dryRun) { + out.write(json); + return; + } + + if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + const targetPath = path.join(targetDir, PIR_FILE); + fs.writeFileSync(targetPath, json, 'utf-8'); + + out.write( + `✅ Rolled forward ${source.pirs.filter((p) => p.status === 'open').length} open PIR(s) ` + + `from ${sourcePath.replace(REPO_ROOT + path.sep, '')} → ${targetPath.replace(REPO_ROOT + path.sep, '')}\n`, + ); +} + +// --------------------------------------------------------------------------- +// CLI guard — only run main() when invoked directly. +// --------------------------------------------------------------------------- + +const isMainModule = (() => { + try { + const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''; + const modulePath = fileURLToPath(import.meta.url); + return invokedPath === modulePath; + } catch { + return false; + } +})(); + +if (isMainModule) { + try { + runMain(process.argv.slice(2)); + } catch (e) { + process.stderr.write(`Unexpected error: ${String(e)}\n`); + process.exit(1); + } +} diff --git a/tests/pir-status-contract.test.ts b/tests/pir-status-contract.test.ts new file mode 100644 index 0000000000..df453f6156 --- /dev/null +++ b/tests/pir-status-contract.test.ts @@ -0,0 +1,886 @@ +/** + * Contract & unit tests for the PIR status sidecar feature. + * + * Covers: + * - JSON Schema structural validity (`schemas/pir-status.schema.json`) + * - Direct unit tests of exported helpers in `scripts/roll-forward-pirs.ts` + * (degrade, validateSource, rollForward, findLatestSource, parseArgs, + * subtractDays, runMain) — direct imports give Vitest full coverage. + * - Analysis-gate integration contract (required file presence pattern) + * + * Notes: + * - All temporary file IO uses `os.tmpdir()` rather than the repo `tmp/` + * directory to avoid step-security armour "Source code overwritten" + * warnings on CI runners. + * + * @author Hack23 AB + * @license Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + type CliArgs, + type Confidence, + type CycleType, + type PirEntry, + type PirStatusFile, + degrade, + findLatestSource, + parseArgs, + rollForward, + runMain, + subtractDays, + validateSource, +} from '../scripts/roll-forward-pirs'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const repoRoot = resolve(__dirname, '..'); + +function validFixture(overrides: Partial = {}): PirStatusFile { + return { + schema_version: '1.0', + cycle: 'month-ahead', + date: '2026-04-26', + subfolder: 'month-ahead', + generated_at: '2026-04-26T10:00:00Z', + inherited_from: null, + pirs: [ + { + pir_id: 'PIR-1', + statement: 'SD voting discipline on prop. 2025/26:236 (fuel tax)', + trigger: 'May 2026 chamber vote on HD01FiU48', + status: 'open', + confidence: 'HIGH', + inherits_from: [], + evidence_refs: ['HD01FiU48'], + horizon: '2026-05-15', + admiralty_grade: 'B2', + }, + { + pir_id: 'PIR-2', + statement: 'Riksbank repo-rate decision macroeconomic impact on budget debates', + status: 'answered', + confidence: 'HIGH', + answer_summary: 'Riksbank held rate at 2.25% on 2026-04-23 per press release.', + inherits_from: [], + evidence_refs: ['https://www.riksbank.se/press-release/2026/04/23'], + }, + ], + ...overrides, + }; +} + +interface CapturedIO { + stdout: string; + stderr: string; + exitCode: number | null; +} + +function captureIO() { + const captured: CapturedIO = { stdout: '', stderr: '', exitCode: null }; + const out = { + write: (chunk: string | Uint8Array): boolean => { + captured.stdout += String(chunk); + return true; + }, + } as unknown as NodeJS.WritableStream; + const err = { + write: (chunk: string | Uint8Array): boolean => { + captured.stderr += String(chunk); + return true; + }, + } as unknown as NodeJS.WritableStream; + const exit = ((code: number): never => { + captured.exitCode = code; + throw new Error(`__exit_${code}__`); + }) as (code: number) => never; + return { captured, io: { stdout: out, stderr: err, exit } }; +} + +function runMainSafe(argv: string[], extraIO: Record = {}) { + const { captured, io } = captureIO(); + try { + runMain(argv, { ...io, ...extraIO }); + } catch (e) { + if (!(e instanceof Error) || !/^__exit_/.test(e.message)) throw e; + } + return captured; +} + +// --------------------------------------------------------------------------- +// Section 1 — Schema file existence and structure +// --------------------------------------------------------------------------- + +describe('schemas/pir-status.schema.json', () => { + const schemaPath = resolve(repoRoot, 'schemas', 'pir-status.schema.json'); + const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')) as Record; + + it('schema file exists and is valid JSON', () => { + expect(existsSync(schemaPath)).toBe(true); + expect(typeof schema).toBe('object'); + }); + + it('schema uses JSON Schema 2020-12', () => { + expect(String(schema['$schema'])).toContain('2020-12'); + }); + + it('schema $id is scoped to riksdagsmonitor.com', () => { + expect(String(schema['$id'])).toContain('riksdagsmonitor.com'); + }); + + it('schema requires mandatory top-level fields', () => { + const required = (schema['required'] ?? []) as string[]; + for (const f of [ + 'schema_version', + 'cycle', + 'date', + 'subfolder', + 'pirs', + 'generated_at', + ]) { + expect(required).toContain(f); + } + }); + + it('schema defines pir_id pattern', () => { + const defs = (schema['$defs'] ?? {}) as Record; + const pirEntry = (defs['pirEntry'] ?? {}) as Record; + const props = (pirEntry['properties'] ?? {}) as Record; + expect(props['pir_id']?.pattern).toMatch(/PIR/); + }); + + it('schema lists all valid cycle types', () => { + const props = (schema['properties'] ?? {}) as Record; + const cycleEnum = props['cycle']?.enum ?? []; + for (const c of [ + 'committeeReports', + 'propositions', + 'month-ahead', + 'week-ahead', + 'motions', + 'interpellations', + ]) { + expect(cycleEnum).toContain(c); + } + }); + + it('top-level and pirEntry both enforce additionalProperties: false', () => { + expect(schema['additionalProperties']).toBe(false); + const defs = (schema['$defs'] ?? {}) as Record; + expect(defs['pirEntry']?.additionalProperties).toBe(false); + }); + + it('schema enforces conditional answer_summary via if/then/else', () => { + const defs = (schema['$defs'] ?? {}) as Record; + const allOf = defs['pirEntry']?.allOf ?? []; + expect(Array.isArray(allOf)).toBe(true); + expect(allOf.length).toBeGreaterThan(0); + const conditional = allOf[0] as Record; + expect(conditional).toHaveProperty('if'); + expect(conditional).toHaveProperty('then'); + expect(conditional).toHaveProperty('else'); + }); + + it('schema requires answer_summary to be non-empty when present', () => { + const defs = (schema['$defs'] ?? {}) as Record; + const pirEntry = (defs['pirEntry'] ?? {}) as Record; + const props = (pirEntry['properties'] ?? {}) as Record; + expect(props['answer_summary']?.minLength).toBe(1); + }); + + it('subfolder description acknowledges schema cannot enforce equality with cycle', () => { + const props = (schema['properties'] ?? {}) as Record; + expect(props['subfolder']?.description).toMatch(/not enforced|gate|writer/i); + }); +}); + +// --------------------------------------------------------------------------- +// Section 2 — Pure helper unit tests (direct import → full coverage) +// --------------------------------------------------------------------------- + +describe('subtractDays', () => { + it('subtracts a single day across month boundary', () => { + expect(subtractDays('2026-05-01', 1)).toBe('2026-04-30'); + }); + it('subtracts multiple days across year boundary', () => { + expect(subtractDays('2026-01-02', 5)).toBe('2025-12-28'); + }); + it('zero days is identity', () => { + expect(subtractDays('2026-04-26', 0)).toBe('2026-04-26'); + }); +}); + +describe('degrade', () => { + it('VERY HIGH → HIGH', () => expect(degrade('VERY HIGH')).toBe('HIGH')); + it('HIGH → MEDIUM', () => expect(degrade('HIGH')).toBe('MEDIUM')); + it('MEDIUM → LOW', () => expect(degrade('MEDIUM')).toBe('LOW')); + it('LOW → VERY LOW', () => expect(degrade('LOW')).toBe('VERY LOW')); + it('VERY LOW stays at VERY LOW (floor)', () => + expect(degrade('VERY LOW')).toBe('VERY LOW')); + it('throws on unknown confidence value', () => { + expect(() => degrade('WRONG' as unknown as Confidence)).toThrow(/Unknown confidence/); + }); +}); + +describe('parseArgs', () => { + it('parses --from / --to', () => { + const args = parseArgs(['--from', 'a', '--to', 'b']); + expect(args.from).toBe('a'); + expect(args.to).toBe('b'); + }); + it('parses --date / --cycle', () => { + const args = parseArgs(['--date', '2026-04-27', '--cycle', 'month-ahead']); + expect(args.date).toBe('2026-04-27'); + expect(args.cycle).toBe('month-ahead'); + }); + it('parses --dry-run flag', () => { + expect(parseArgs(['--dry-run']).dryRun).toBe(true); + }); + it('--max-lookback default is 14', () => { + expect(parseArgs([]).maxLookback).toBe(14); + }); + it('--max-lookback overrides default', () => { + expect(parseArgs(['--max-lookback', '7']).maxLookback).toBe(7); + }); + it('--max-lookback accepts zero-padded positive integers', () => { + expect(parseArgs(['--max-lookback', '007']).maxLookback).toBe(7); + }); + it('--max-lookback throws when value is missing', () => { + expect(() => parseArgs(['--max-lookback'])).toThrow(/requires a positive integer/); + }); + it('--max-lookback throws when value is non-numeric', () => { + expect(() => parseArgs(['--max-lookback', 'abc'])).toThrow(/positive integer/); + }); + it('--max-lookback throws when value is zero', () => { + expect(() => parseArgs(['--max-lookback', '0'])).toThrow(/positive integer/); + }); + it('returns CliArgs shape with required fields', () => { + const args: CliArgs = parseArgs([]); + expect(args.dryRun).toBe(false); + expect(typeof args.maxLookback).toBe('number'); + }); +}); + +describe('validateSource', () => { + it('accepts a valid fixture', () => { + const result = validateSource(validFixture(), '/tmp/x'); + expect(result.schema_version).toBe('1.0'); + }); + + it('rejects non-objects', () => { + expect(() => validateSource(null, '/tmp/x')).toThrow(/not a JSON object/); + expect(() => validateSource('string', '/tmp/x')).toThrow(/not a JSON object/); + }); + + it('rejects missing schema_version', () => { + const fix = validFixture() as unknown as Record; + delete fix['schema_version']; + expect(() => validateSource(fix, '/tmp/x')).toThrow(/schema_version/); + }); + + it('rejects unsupported schema_version', () => { + expect(() => + validateSource(validFixture({ schema_version: '2.0' as '1.0' }), '/tmp/x'), + ).toThrow(/unsupported schema_version/); + }); + + it('rejects non-array pirs', () => { + const fix = validFixture() as unknown as Record; + fix['pirs'] = 'not an array'; + expect(() => validateSource(fix, '/tmp/x')).toThrow(/'pirs' must be an array/); + }); + + it('rejects missing required field', () => { + const fix = validFixture() as unknown as Record; + delete fix['cycle']; + expect(() => validateSource(fix, '/tmp/x')).toThrow(/missing required field 'cycle'/); + }); + + it('rejects invalid top-level cycle', () => { + expect(() => + validateSource(validFixture({ cycle: 'not-a-cycle' as CycleType }), '/tmp/x'), + ).toThrow(/is not a valid cycle/); + }); + + it('rejects invalid top-level date format', () => { + expect(() => + validateSource(validFixture({ date: '27-04-2026' }), '/tmp/x'), + ).toThrow(/must match YYYY-MM-DD/); + }); + + it('rejects empty top-level subfolder', () => { + expect(() => + validateSource(validFixture({ subfolder: '' }), '/tmp/x'), + ).toThrow(/subfolder must be a non-empty string/); + }); + + it('rejects top-level subfolder that does not equal cycle', () => { + expect(() => + validateSource(validFixture({ subfolder: 'week-ahead' }), '/tmp/x'), + ).toThrow(/must equal cycle/); + }); + + it('rejects invalid top-level generated_at date-time', () => { + expect(() => + validateSource(validFixture({ generated_at: 'not-a-date' }), '/tmp/x'), + ).toThrow(/must be a valid date-time string/); + }); + + it('rejects invalid inherited_from type', () => { + const fix = validFixture() as unknown as Record; + fix['inherited_from'] = 42; + expect(() => validateSource(fix, '/tmp/x')).toThrow(/inherited_from must be a string or null/); + }); + + it('rejects invalid pir_id pattern', () => { + expect(() => + validateSource( + validFixture({ + pirs: [ + { + pir_id: 'invalid_id', + statement: 'short statement that is long enough', + status: 'open', + confidence: 'HIGH', + }, + ], + }), + '/tmp/x', + ), + ).toThrow(/pir_id 'invalid_id' does not match/); + }); + + it('rejects too-short statement', () => { + expect(() => + validateSource( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'short', + status: 'open', + confidence: 'HIGH', + }, + ], + }), + '/tmp/x', + ), + ).toThrow(/statement missing or shorter than 10 chars/); + }); + + it('rejects unknown status enum', () => { + expect(() => + validateSource( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'wibble' as PirEntry['status'], + confidence: 'HIGH', + }, + ], + }), + '/tmp/x', + ), + ).toThrow(/'wibble' is not a valid PIR status/); + }); + + it('rejects unknown confidence enum', () => { + expect(() => + validateSource( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'open', + confidence: 'EXTREME' as PirEntry['confidence'], + }, + ], + }), + '/tmp/x', + ), + ).toThrow(/is not a valid confidence value/); + }); + + it('rejects answered without answer_summary', () => { + expect(() => + validateSource( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'answered', + confidence: 'HIGH', + }, + ], + }), + '/tmp/x', + ), + ).toThrow(/status='answered' requires non-empty answer_summary/); + }); + + it('rejects non-answered with answer_summary', () => { + expect(() => + validateSource( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'open', + confidence: 'HIGH', + answer_summary: 'should not be here', + }, + ], + }), + '/tmp/x', + ), + ).toThrow(/must not carry answer_summary/); + }); + + it('rejects non-object pir entry', () => { + expect(() => + validateSource( + { ...validFixture(), pirs: ['not-an-object' as unknown as PirEntry] }, + '/tmp/x', + ), + ).toThrow(/pirs\[0\] is not an object/); + }); +}); + +// --------------------------------------------------------------------------- +// Section 3 — rollForward unit tests +// --------------------------------------------------------------------------- + +describe('rollForward', () => { + const fixedNow = () => new Date('2026-04-27T10:00:00Z'); + const sourcePath = '/tmp/fake/analysis/daily/2026-04-26/month-ahead/pir-status.json'; + + it('produces schema_version 1.0 output', () => { + const out = rollForward(validFixture(), sourcePath, '2026-04-27', 'month-ahead', { + now: fixedNow, + }); + expect(out.schema_version).toBe('1.0'); + expect(out.cycle).toBe('month-ahead'); + expect(out.date).toBe('2026-04-27'); + expect(out.subfolder).toBe('month-ahead'); + }); + + it('open PIR confidence is degraded one level', () => { + const out = rollForward(validFixture(), sourcePath, '2026-04-27', 'month-ahead', { + now: fixedNow, + }); + const open = out.pirs.find((p) => p.pir_id === 'PIR-1'); + expect(open?.confidence).toBe('MEDIUM'); // HIGH → MEDIUM + }); + + it('open PIR appends pir_id to existing inherits_from chain', () => { + const out = rollForward( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'open', + confidence: 'HIGH', + inherits_from: ['PIR-prior-1', 'PIR-prior-2'], + }, + ], + }), + sourcePath, + '2026-04-27', + 'month-ahead', + { now: fixedNow }, + ); + expect(out.pirs[0]?.inherits_from).toEqual(['PIR-prior-1', 'PIR-prior-2', 'PIR-1']); + }); + + it('answered PIR carried forward UNCHANGED preserves inherits_from history', () => { + const out = rollForward( + validFixture({ + pirs: [ + { + pir_id: 'PIR-2', + statement: 'A reasonably long statement here', + status: 'answered', + confidence: 'HIGH', + answer_summary: 'Done.', + inherits_from: ['PIR-orig-7', 'PIR-mid-3'], + }, + ], + }), + sourcePath, + '2026-04-27', + 'month-ahead', + { now: fixedNow }, + ); + // Non-open PIRs must NOT have their inherits_from rewritten. + expect(out.pirs[0]?.inherits_from).toEqual(['PIR-orig-7', 'PIR-mid-3']); + expect(out.pirs[0]?.status).toBe('answered'); + expect(out.pirs[0]?.answer_summary).toBe('Done.'); + expect(out.pirs[0]?.confidence).toBe('HIGH'); + }); + + it('open PIR with VERY LOW stays at VERY LOW', () => { + const out = rollForward( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'open', + confidence: 'VERY LOW', + }, + ], + }), + sourcePath, + '2026-04-27', + 'month-ahead', + { now: fixedNow }, + ); + expect(out.pirs[0]?.confidence).toBe('VERY LOW'); + }); + + it('open PIR drops answer_summary on roll-forward', () => { + const out = rollForward( + validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'open', + confidence: 'HIGH', + // hypothetical leftover field — should be dropped + answer_summary: 'leftover', + }, + ], + }), + sourcePath, + '2026-04-27', + 'month-ahead', + { now: fixedNow }, + ); + expect(out.pirs[0]?.answer_summary).toBeUndefined(); + }); + + it('inherited_from is a relative path when source is under repoRoot', () => { + const out = rollForward( + validFixture(), + '/repo/analysis/daily/2026-04-26/month-ahead/pir-status.json', + '2026-04-27', + 'month-ahead', + { now: fixedNow, repoRoot: '/repo' }, + ); + expect(out.inherited_from).toBe( + 'analysis/daily/2026-04-26/month-ahead/pir-status.json', + ); + }); + + it('inherited_from normalizes relative paths via path.relative semantics', () => { + const repo = join(os.tmpdir(), 'pir-path-repo-root'); + const source = join(repo, 'analysis', 'daily', '2026-04-26', 'month-ahead', 'pir-status.json'); + const out = rollForward(validFixture(), source, '2026-04-27', 'month-ahead', { + now: fixedNow, + repoRoot: repo, + }); + expect(out.inherited_from).toBe('analysis/daily/2026-04-26/month-ahead/pir-status.json'); + }); + + it('inherited_from falls back to absolute path when source is outside repoRoot', () => { + const out = rollForward(validFixture(), '/elsewhere/pir-status.json', '2026-04-27', 'month-ahead', { + now: fixedNow, + repoRoot: '/repo', + }); + expect(out.inherited_from).toBe('/elsewhere/pir-status.json'); + }); + + it('uses fixed generated_at from injected now()', () => { + const out = rollForward(validFixture(), sourcePath, '2026-04-27', 'month-ahead', { + now: fixedNow, + }); + expect(out.generated_at).toBe('2026-04-27T10:00:00.000Z'); + }); +}); + +// --------------------------------------------------------------------------- +// Section 4 — findLatestSource (file-system integration) +// --------------------------------------------------------------------------- + +describe('findLatestSource', () => { + let tmpRoot: string; + beforeEach(() => { + tmpRoot = mkdtempSync(join(os.tmpdir(), 'pir-find-')); + }); + afterEach(() => { + if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('returns null when no source exists in lookback window', () => { + const result = findLatestSource('month-ahead', '2026-04-27', 5, tmpRoot); + expect(result).toBeNull(); + }); + + it('finds nearest source within lookback', () => { + const dir = join(tmpRoot, '2026-04-25', 'month-ahead'); + mkdirSync(dir, { recursive: true }); + const file = join(dir, 'pir-status.json'); + writeFileSync(file, '{}'); + const result = findLatestSource('month-ahead', '2026-04-27', 5, tmpRoot); + expect(result).toBe(file); + }); + + it('respects maxLookback limit', () => { + const dir = join(tmpRoot, '2026-04-15', 'month-ahead'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'pir-status.json'), '{}'); + // Only 5 days lookback from 2026-04-27 (looks at 04-26..04-22) — not found. + expect(findLatestSource('month-ahead', '2026-04-27', 5, tmpRoot)).toBeNull(); + // 30 days lookback finds it. + expect(findLatestSource('month-ahead', '2026-04-27', 30, tmpRoot)).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Section 5 — runMain (end-to-end via injected IO) +// --------------------------------------------------------------------------- + +describe('runMain', () => { + let tmpRoot: string; + beforeEach(() => { + tmpRoot = mkdtempSync(join(os.tmpdir(), 'pir-run-')); + }); + afterEach(() => { + if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('prints usage and exits 1 with no args', () => { + const out = runMainSafe([]); + expect(out.exitCode).toBe(1); + expect(out.stderr).toMatch(/Usage:/); + }); + + it('exits 1 when --from source not found', () => { + const out = runMainSafe([ + '--from', + join(tmpRoot, 'no-such-dir'), + '--to', + join(tmpRoot, 'target'), + ]); + expect(out.exitCode).toBe(1); + expect(out.stderr).toMatch(/Source not found/); + }); + + it('exits 1 when --to path cannot derive date/cycle', () => { + const sourceDir = join(tmpRoot, 'src', 'pir-stuff'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + join(sourceDir, 'pir-status.json'), + JSON.stringify(validFixture()), + ); + const out = runMainSafe([ + '--from', + sourceDir, + '--to', + join(tmpRoot, 'not', 'a', 'daily', 'path'), + ]); + expect(out.exitCode).toBe(1); + expect(out.stderr).toMatch(/Cannot derive/); + }); + + it('exits 1 with unknown cycle', () => { + const out = runMainSafe(['--date', '2026-04-27', '--cycle', 'unknown-cycle']); + expect(out.exitCode).toBe(1); + expect(out.stderr).toMatch(/Unknown cycle/); + }); + + it('exits 1 with invalid --max-lookback', () => { + const out = runMainSafe([ + '--date', + '2026-04-27', + '--cycle', + 'month-ahead', + '--max-lookback', + '0', + ]); + expect(out.exitCode).toBe(1); + expect(out.stderr).toMatch(/Argument error: --max-lookback must be a positive integer/); + }); + + it('exits 2 when source JSON is malformed', () => { + const sourceDir = join(tmpRoot, 'analysis', 'daily', '2026-04-26', 'month-ahead'); + const targetDir = join(tmpRoot, 'analysis', 'daily', '2026-04-27', 'month-ahead'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(join(sourceDir, 'pir-status.json'), '{ broken json'); + const out = runMainSafe(['--from', sourceDir, '--to', targetDir]); + expect(out.exitCode).toBe(2); + expect(out.stderr).toMatch(/Failed to read source/); + }); + + it('exits 2 when source fails strict validation', () => { + const sourceDir = join(tmpRoot, 'analysis', 'daily', '2026-04-26', 'month-ahead'); + const targetDir = join(tmpRoot, 'analysis', 'daily', '2026-04-27', 'month-ahead'); + mkdirSync(sourceDir, { recursive: true }); + const bad = validFixture({ + pirs: [ + { + pir_id: 'PIR-1', + statement: 'A reasonably long statement here', + status: 'open', + confidence: 'EXTREME' as Confidence, + }, + ], + }); + writeFileSync(join(sourceDir, 'pir-status.json'), JSON.stringify(bad)); + const out = runMainSafe(['--from', sourceDir, '--to', targetDir]); + expect(out.exitCode).toBe(2); + expect(out.stderr).toMatch(/Schema validation error/); + }); + + it('writes target file successfully on happy path', () => { + const sourceDir = join(tmpRoot, 'analysis', 'daily', '2026-04-26', 'month-ahead'); + const targetDir = join(tmpRoot, 'analysis', 'daily', '2026-04-27', 'month-ahead'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + join(sourceDir, 'pir-status.json'), + JSON.stringify(validFixture(), null, 2), + ); + + const out = runMainSafe(['--from', sourceDir, '--to', targetDir], { + now: () => new Date('2026-04-27T10:00:00Z'), + }); + expect(out.exitCode).toBeNull(); // success → no exit() call + expect(existsSync(join(targetDir, 'pir-status.json'))).toBe(true); + + const result = JSON.parse( + readFileSync(join(targetDir, 'pir-status.json'), 'utf-8'), + ) as PirStatusFile; + expect(result.schema_version).toBe('1.0'); + expect(result.date).toBe('2026-04-27'); + expect(result.cycle).toBe('month-ahead'); + expect(result.subfolder).toBe('month-ahead'); + // Open PIR (PIR-1) confidence degraded HIGH → MEDIUM. + expect(result.pirs.find((p) => p.pir_id === 'PIR-1')?.confidence).toBe('MEDIUM'); + // Answered PIR (PIR-2) preserved. + expect(result.pirs.find((p) => p.pir_id === 'PIR-2')?.status).toBe('answered'); + }); + + it('--dry-run writes JSON to stdout and does not create file', () => { + const sourceDir = join(tmpRoot, 'analysis', 'daily', '2026-04-26', 'month-ahead'); + const targetDir = join(tmpRoot, 'analysis', 'daily', '2026-04-27', 'month-ahead'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + join(sourceDir, 'pir-status.json'), + JSON.stringify(validFixture(), null, 2), + ); + + const out = runMainSafe(['--from', sourceDir, '--to', targetDir, '--dry-run']); + expect(out.exitCode).toBeNull(); + expect(out.stdout).toContain('"schema_version": "1.0"'); + expect(existsSync(join(targetDir, 'pir-status.json'))).toBe(false); + }); + + it('creates target directory when missing', () => { + const sourceDir = join(tmpRoot, 'analysis', 'daily', '2026-04-26', 'month-ahead'); + const targetDir = join(tmpRoot, 'analysis', 'daily', '2026-04-27', 'month-ahead'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync( + join(sourceDir, 'pir-status.json'), + JSON.stringify(validFixture(), null, 2), + ); + expect(existsSync(targetDir)).toBe(false); + runMainSafe(['--from', sourceDir, '--to', targetDir]); + expect(existsSync(join(targetDir, 'pir-status.json'))).toBe(true); + }); + + it('--date / --cycle resolves prior cycle within lookback window', () => { + // We can't easily exercise the auto-discovery branch since it scans the + // real ANALYSIS_DIR; ensure unknown cycle still routes through the args + // branch deterministically. + const out = runMainSafe(['--date', '2099-12-31', '--cycle', 'month-ahead']); + expect(out.exitCode).toBe(1); + expect(out.stderr).toMatch(/No previous pir-status\.json/); + }); +}); + +// --------------------------------------------------------------------------- +// Section 6 — Analysis-gate integration contract +// --------------------------------------------------------------------------- + +describe('analysis-gate pir-status.json contract', () => { + const gate = readFileSync( + resolve(repoRoot, '.github', 'prompts', '05-analysis-gate.md'), + 'utf-8', + ); + const guide = readFileSync( + resolve(repoRoot, 'analysis', 'methodologies', 'ai-driven-analysis-guide.md'), + 'utf-8', + ); + + it('05-analysis-gate.md references pir-status.json', () => { + expect(gate).toContain('pir-status.json'); + }); + it('05-analysis-gate.md references pir-status.schema.json', () => { + expect(gate).toContain('pir-status.schema'); + }); + it('05-analysis-gate.md enforces subfolder === cycle invariant', () => { + expect(gate).toMatch(/subfolder.*equal.*cycle|subfolder.*===.*cycle/); + }); + it('05-analysis-gate.md enforces conditional answer_summary', () => { + expect(gate).toMatch(/status.*answered.*answer_summary/); + }); + it('05-analysis-gate.md keeps PIR and supplementary checks sequential', () => { + expect(gate).toContain('# Check 9 — PIR status sidecar'); + expect(gate).toContain('# Check 10 — supplementary artifacts'); + }); + it('ai-driven-analysis-guide.md references pir-status.json', () => { + expect(guide).toContain('pir-status.json'); + }); + it('ai-driven-analysis-guide.md references roll-forward script', () => { + expect(guide).toContain('roll-forward-pirs'); + }); + it('ai-driven-analysis-guide.md clarifies open vs preserved status semantics', () => { + expect(guide).toMatch(/preserve their existing status|preserve.*status/i); + }); +}); + +// --------------------------------------------------------------------------- +// Section 7 — Module behavior sanity (CycleType / type exports) +// --------------------------------------------------------------------------- + +describe('module export sanity', () => { + it('CycleType values are usable in fixtures', () => { + const cycles: CycleType[] = [ + 'committeeReports', + 'propositions', + 'motions', + 'interpellations', + 'evening-analysis', + 'realtime-pulse', + 'week-ahead', + 'month-ahead', + 'weekly-review', + 'monthly-review', + ]; + expect(cycles).toHaveLength(10); + }); +});