From 1034777d6b1753166b73d40ec269b45bcd4ed8b9 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 3 May 2026 19:29:18 +0200 Subject: [PATCH] feat(ms-ai-architect): playground v3 markdown parsers (14 archetypes) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 tolerant parsers per kanonisk archetype-routing-tabell (Step 11) + 3 helpers (parseTable, parseSections, extractField). Each parser returns {ok:true, data} or {ok:false, errors:[{section, reason}]} — never throws on bad input. PARSERS routing-objekt eksponert via window.__PARSERS. Verified against all 17 fixtures: every parser produces expected shape. Empty input returns structured error per Verify-asserts. --- .../playground/ms-ai-architect-v3.html | 531 ++++++++++++++++++ 1 file changed, 531 insertions(+) diff --git a/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html b/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html index 660ad56..bd93c02 100644 --- a/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html +++ b/plugins/ms-ai-architect/playground/ms-ai-architect-v3.html @@ -2080,6 +2080,537 @@ ); } + // ============================================================ + // MARKDOWN PARSERS (Step 11) + // ============================================================ + // + // 14 parser-arketyper per kanonisk routing-tabell. Hver parser tar + // markdown-streng og returnerer { ok: true, data: {...} } eller + // { ok: false, errors: [{section, reason}] }. Parsers er tolerante + // (kaster aldri unntak) — tom/uventet input gir strukturert feil. + // + // Routing: PARSERS[archetype] for oppslag i handlePasteImport. + + // ---- Felles helpers ---- + + function parseTableRow(line) { + const inner = line.replace(/^\|/, '').replace(/\|$/, ''); + return inner.split('|').map(function (c) { return c.trim(); }); + } + + function parseTable(md, anchorRegex) { + if (typeof md !== 'string') return null; + let body = md; + if (anchorRegex) { + const m = anchorRegex.exec(md); + if (!m) return null; + body = md.slice(m.index + m[0].length); + } + const lines = body.split(/\r?\n/); + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i].trim(); + const next = (lines[i + 1] || '').trim(); + if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) { + const headers = parseTableRow(line); + const rows = []; + for (let j = i + 2; j < lines.length; j++) { + const rowLine = lines[j].trim(); + if (rowLine.indexOf('|') !== 0) break; + const cells = parseTableRow(rowLine); + if (cells.length === 0) break; + const row = {}; + for (let k = 0; k < headers.length; k++) { + row[headers[k]] = (cells[k] || '').trim(); + } + rows.push(row); + } + return { headers: headers, rows: rows }; + } + } + return null; + } + + function parseSections(md) { + if (typeof md !== 'string') return []; + const sections = []; + const lines = md.split(/\r?\n/); + let current = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const m = /^##\s+(.+)$/.exec(line); + if (m && line.charAt(2) === ' ') { // exactly two # + if (current) sections.push(current); + current = { heading: m[1].trim(), body: '' }; + } else if (current) { + current.body += (current.body ? '\n' : '') + line; + } + } + if (current) sections.push(current); + return sections.map(function (s) { + return { heading: s.heading, body: s.body.trim() }; + }); + } + + function extractField(md, label) { + if (typeof md !== 'string') return null; + const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp('^\\s*' + escaped + '\\s*:\\s*(.+)$', 'mi'); + const m = re.exec(md); + return m ? m[1].trim() : null; + } + + function intOrZero(s) { + if (typeof s !== 'string') return 0; + const v = parseInt(s.replace(/[^\d-]/g, ''), 10); + return isNaN(v) ? 0 : v; + } + + function emptyInput(md) { + return !md || typeof md !== 'string' || !md.trim(); + } + + // ---- 14 archetype parsers ---- + + function parseAiAct(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const errors = []; + const sections = parseSections(md); + + let risk_level = extractField(md, 'Risk-level') || extractField(md, 'Risikonivå'); + if (!risk_level) { + const sec = sections.find(function (s) { return /risikoniv|risk.level/i.test(s.heading); }); + if (sec) { + const firstLine = sec.body.split(/\r?\n/)[0] || ''; + risk_level = firstLine.replace(/^Risk-level:\s*/i, '').replace(/^Risikonivå:\s*/i, '').trim(); + } + } + if (!risk_level) errors.push({ section: 'risk_level', reason: 'Fant ikke risikonivå' }); + + const role = extractField(md, 'Rolle') || extractField(md, 'Role') || ''; + if (!role) errors.push({ section: 'role', reason: 'Fant ikke rolle' }); + + let reasoning = extractField(md, 'Reasoning') || extractField(md, 'Begrunnelse') || ''; + if (!reasoning) { + const sec = sections.find(function (s) { return /begrunnelse|reasoning/i.test(s.heading); }); + if (sec) reasoning = sec.body; + } + + const obligations = []; + const oblSec = sections.find(function (s) { return /forpliktelser|obligations/i.test(s.heading); }); + if (oblSec) { + oblSec.body.split(/\r?\n/).forEach(function (line) { + const m = /^[-*]\s+(.+)$/.exec(line.trim()); + if (m) obligations.push(m[1].trim()); + }); + } + + if (errors.length > 0) return { ok: false, errors: errors }; + return { + ok: true, + data: { + risk_level: (risk_level || '').toLowerCase(), + role: role, + reasoning: reasoning, + obligations: obligations + } + }; + } + + function parseRequirements(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const tbl = parseTable(md); + if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen krav-tabell funnet' }] }; + const reqKey = tbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || tbl.headers[0]; + const statusKey = tbl.headers.find(function (h) { return /status/i.test(h); }) || tbl.headers[1]; + const sourceKey = tbl.headers.find(function (h) { return /kilde|source|art/i.test(h); }) || tbl.headers[2]; + const items = tbl.rows.map(function (row) { + return { + requirement: row[reqKey] || '', + status: (row[statusKey] || '').toLowerCase().trim(), + source_article: row[sourceKey] || '' + }; + }); + return { ok: true, data: { items: items } }; + } + + function parseTextDocument(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const sections = parseSections(md); + if (!sections.length) { + return { ok: true, data: { sections: [{ heading: 'Innhold', body: md.trim() }] } }; + } + return { ok: true, data: { sections: sections } }; + } + + function parseFria(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const tbl = parseTable(md); + if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen rettighet-tabell funnet' }] }; + const nameKey = tbl.headers.find(function (h) { return /rettighet|right/i.test(h); }) || tbl.headers[0]; + const impactKey = tbl.headers.find(function (h) { return /impact|påvirkning/i.test(h); }) || tbl.headers[1]; + const mitigKey = tbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); }) || tbl.headers[2]; + const rights = tbl.rows.map(function (row) { + return { + name: row[nameKey] || '', + impact: intOrZero(row[impactKey] || '0'), + mitigation: row[mitigKey] || '' + }; + }); + return { ok: true, data: { rights: rights } }; + } + + function parseConformityChecklist(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const checklistTbl = parseTable(md, /##\s*Sjekkliste/i) || parseTable(md); + if (!checklistTbl) return { ok: false, errors: [{ section: 'checklist', reason: 'Ingen sjekkliste-tabell funnet' }] }; + const reqKey = checklistTbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || checklistTbl.headers[0]; + const statusKey = checklistTbl.headers.find(function (h) { return /status/i.test(h); }) || checklistTbl.headers[1]; + const evidKey = checklistTbl.headers.find(function (h) { return /bevis|evidence/i.test(h); }) || checklistTbl.headers[2]; + const checklist = checklistTbl.rows.map(function (row) { + return { + requirement: row[reqKey] || '', + status: (row[statusKey] || '').toLowerCase().trim(), + evidence: row[evidKey] || '' + }; + }); + const deadlinesTbl = parseTable(md, /##\s*Frister/i); + const deadlines = deadlinesTbl ? deadlinesTbl.rows.map(function (row) { + const dateKey = deadlinesTbl.headers.find(function (h) { return /dato|date/i.test(h); }) || deadlinesTbl.headers[0]; + const mileKey = deadlinesTbl.headers.find(function (h) { return /milepæl|milestone/i.test(h); }) || deadlinesTbl.headers[1]; + const stKey = deadlinesTbl.headers.find(function (h) { return /status/i.test(h); }) || deadlinesTbl.headers[2]; + return { + date: row[dateKey] || '', + milestone: row[mileKey] || '', + status: (row[stKey] || '').toLowerCase().trim() + }; + }) : []; + return { ok: true, data: { checklist: checklist, deadlines: deadlines } }; + } + + function parseMatrixRisk(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const matrixTbl = parseTable(md, /Risikomatrise.*5/i) || parseTable(md); + if (!matrixTbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen risikomatrise funnet' }] }; + const labelKey = matrixTbl.headers[0]; + const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); }); + const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); }); + const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); }); + const matrix_cells = matrixTbl.rows.map(function (row) { + return { + label: row[labelKey] || '', + prob: intOrZero(row[sannKey] || '0'), + cons: intOrZero(row[konsKey] || '0'), + score: intOrZero(row[scoreKey] || '0') + }; + }); + const threatsTbl = parseTable(md, /##\s*Trusler/i); + const threats = threatsTbl ? threatsTbl.rows.map(function (row) { + const idKey = threatsTbl.headers[0]; + const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1]; + const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); }); + const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); }); + return { + id: row[idKey] || '', + description: row[descKey] || '', + severity: (row[sevKey] || '').toLowerCase().trim(), + mitigation: row[mitKey] || '' + }; + }) : []; + const radarTbl = parseTable(md, /Radar.akser/i); + const radar_axes = radarTbl ? radarTbl.rows.map(function (row) { + const akseKey = radarTbl.headers.find(function (h) { return /akse|axis/i.test(h); }) || radarTbl.headers[0]; + const scKey = radarTbl.headers.find(function (h) { return /score/i.test(h); }) || radarTbl.headers[1]; + return { + name: row[akseKey] || '', + score: intOrZero(row[scKey] || '0') + }; + }) : null; + return { ok: true, data: { matrix_cells: matrix_cells, threats: threats, radar_axes: radar_axes } }; + } + + function parseMatrixRisk6x5(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const dimsTbl = parseTable(md, /Score per dimensjon/i); + if (!dimsTbl) return { ok: false, errors: [{ section: 'dimensions', reason: 'Ingen dimensjon-tabell funnet' }] }; + const dimNameKey = dimsTbl.headers.find(function (h) { return /dimensjon/i.test(h); }) || dimsTbl.headers[0]; + const dimScoreKey = dimsTbl.headers.find(function (h) { return /score/i.test(h); }) || dimsTbl.headers[1]; + const dimVurdKey = dimsTbl.headers.find(function (h) { return /vurdering/i.test(h); }); + const dimensions = dimsTbl.rows.map(function (row) { + return { + name: row[dimNameKey] || '', + score: intOrZero(row[dimScoreKey] || '0'), + assessment: row[dimVurdKey] || '' + }; + }); + const matrixTbl = parseTable(md, /Risikomatrise.*6/i); + const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) { + const labelKey = matrixTbl.headers[0]; + const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); }); + const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); }); + const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); }); + return { + label: row[labelKey] || '', + prob: intOrZero(row[sannKey] || '0'), + cons: intOrZero(row[konsKey] || '0'), + score: intOrZero(row[scoreKey] || '0') + }; + }) : []; + const findingsTbl = parseTable(md, /##\s*Funn/i); + const findings = findingsTbl ? findingsTbl.rows.map(function (row) { + const idKey = findingsTbl.headers[0]; + const sevKey = findingsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); }); + const locKey = findingsTbl.headers.find(function (h) { return /lokasjon|location/i.test(h); }); + const recKey = findingsTbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); }); + return { + id: row[idKey] || '', + severity: (row[sevKey] || '').toLowerCase().trim(), + location: row[locKey] || '', + recommendation: row[recKey] || '' + }; + }) : []; + return { + ok: true, + data: { + dimensions: dimensions, + matrix_cells: matrix_cells, + findings: findings, + scores: dimensions.map(function (d) { return d.score; }) + } + }; + } + + function parseFindings(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const tbl = parseTable(md, /##\s*Funn/i) || parseTable(md); + if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen funn-tabell funnet' }] }; + const idKey = tbl.headers[0]; + const sevKey = tbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); }); + const locKey = tbl.headers.find(function (h) { return /lokasjon|location/i.test(h); }); + const recKey = tbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); }); + const findings = tbl.rows.map(function (row) { + return { + id: row[idKey] || '', + severity: (row[sevKey] || '').toLowerCase().trim(), + location: row[locKey] || '', + recommendation: row[recKey] || '' + }; + }); + return { ok: true, data: { findings: findings } }; + } + + function parseCostDistribution(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const distTbl = parseTable(md, /Distribusjon/i); + if (!distTbl) return { ok: false, errors: [{ section: 'distribution', reason: 'Ingen distribusjons-tabell funnet' }] }; + const persKey = distTbl.headers.find(function (h) { return /persentil|percentile/i.test(h); }) || distTbl.headers[0]; + const monthlyKey = distTbl.headers.find(function (h) { return /månedlig|monthly/i.test(h); }) || distTbl.headers[1]; + const yearlyKey = distTbl.headers.find(function (h) { return /årlig|yearly/i.test(h); }); + let p10 = null, p50 = null, p90 = null; + distTbl.rows.forEach(function (row) { + const monthly = intOrZero(row[monthlyKey] || '0'); + const yearly = yearlyKey ? intOrZero(row[yearlyKey] || '0') : null; + const entry = { monthly: monthly, yearly: yearly }; + const tag = (row[persKey] || '').toUpperCase(); + if (/P10|P\.10|P 10/.test(tag)) p10 = entry; + else if (/P50|P\.50|P 50/.test(tag)) p50 = entry; + else if (/P90|P\.90|P 90/.test(tag)) p90 = entry; + }); + const monthlyTbl = parseTable(md, /Månedlig fordeling/i); + const monthly_breakdown = monthlyTbl ? monthlyTbl.rows.map(function (row) { + const compKey = monthlyTbl.headers[0]; + const costKey = monthlyTbl.headers[1]; + return { + component: row[compKey] || '', + cost: intOrZero(row[costKey] || '0') + }; + }) : []; + const tcoTbl = parseTable(md, /TCO/i); + const tco_table = tcoTbl ? tcoTbl.rows : []; + return { + ok: true, + data: { + p10: p10, p50: p50, p90: p90, + monthly_breakdown: monthly_breakdown, + tco_table: tco_table, + tco_headers: tcoTbl ? tcoTbl.headers : [] + } + }; + } + + function parseCapabilityMatrix(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const tbl = parseTable(md, /##\s*Matrise/i) || parseTable(md); + if (!tbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen matrise funnet' }] }; + const capKey = tbl.headers[0]; + const licenseNames = tbl.headers.slice(1); + const licenses = licenseNames.map(function (name) { + return { name: name, capabilities: [] }; + }); + tbl.rows.forEach(function (row) { + const capName = row[capKey]; + licenseNames.forEach(function (licName, i) { + licenses[i].capabilities.push({ + name: capName, + status: (row[licName] || '').toLowerCase().trim() + }); + }); + }); + return { ok: true, data: { licenses: licenses } }; + } + + function parsePhasedPlan(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const phases = []; + const lines = md.split(/\r?\n/); + let current = null; + let bucket = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const phaseMatch = /^###\s+(?:Fase\s+\d+\s*[—-]\s*)?(.+?)\s*(?:\(.*\))?\s*$/i.exec(line.trim()); + const isH3 = /^###\s+/.test(line); + const isH2 = /^##\s+/.test(line) && !isH3; + if (isH3 && phaseMatch) { + if (current) phases.push(current); + current = { + name: phaseMatch[1].trim(), + milestones: [], + success_criteria: [], + duration_weeks: null + }; + bucket = null; + continue; + } + if (isH2) { + if (current) { phases.push(current); current = null; } + bucket = null; + continue; + } + if (!current) continue; + const trimmed = line.trim(); + const durMatch = /^Varighet:\s*(\d+)\s*uke/i.exec(trimmed); + if (durMatch) { + current.duration_weeks = parseInt(durMatch[1], 10); + continue; + } + if (/^Milep[æa]ler\s*:?\s*$/i.test(trimmed)) { bucket = 'milestones'; continue; } + if (/^Suksesskriterier\s*:?\s*$/i.test(trimmed)) { bucket = 'success_criteria'; continue; } + const bulletMatch = /^[-*]\s+(.+)$/.exec(trimmed); + if (bulletMatch && bucket && current[bucket]) { + current[bucket].push(bulletMatch[1].trim()); + } + } + if (current) phases.push(current); + + const risksTbl = parseTable(md, /##\s*Risiko/i); + const risks = risksTbl ? risksTbl.rows.map(function (row) { + const risikoKey = risksTbl.headers[0]; + const sannKey = risksTbl.headers.find(function (h) { return /sannsynlig/i.test(h); }); + const konsKey = risksTbl.headers.find(function (h) { return /konsekvens/i.test(h); }); + const tiltakKey = risksTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); }); + return { + risk: row[risikoKey] || '', + probability: row[sannKey] || '', + consequence: row[konsKey] || '', + mitigation: row[tiltakKey] || '' + }; + }) : []; + + if (!phases.length) return { ok: false, errors: [{ section: 'phases', reason: 'Ingen faser funnet (### Fase N)' }] }; + return { ok: true, data: { phases: phases, risks: risks } }; + } + + function parseMarkdown(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const titleMatch = /^#\s+(.+)$/m.exec(md); + const title = titleMatch ? titleMatch[1].trim() : ''; + const sections = parseSections(md); + // Frontmatter-style fields (Status, Date, Deciders) — typisk i ADR + const status = extractField(md, 'Status') || ''; + const date = extractField(md, 'Date') || extractField(md, 'Dato') || ''; + const deciders = extractField(md, 'Deciders') || extractField(md, 'Beslutningstakere') || ''; + return { ok: true, data: { title: title, sections: sections, raw: md, status: status, date: date, deciders: deciders } }; + } + + function parseVerdict(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const verdictRaw = extractField(md, 'Verdict') || ''; + const verdict = verdictRaw.toLowerCase().trim(); + const sub = extractField(md, 'Sub') || ''; + const sections = parseSections(md); + const ratSec = sections.find(function (s) { return /rationale|begrunnelse/i.test(s.heading); }); + const rationale = ratSec ? ratSec.body : ''; + const metricsTbl = parseTable(md, /Key Metrics|Nøkkelmetrikker/i); + const key_metrics = metricsTbl ? metricsTbl.rows : []; + const metrics_headers = metricsTbl ? metricsTbl.headers : []; + const nextSec = sections.find(function (s) { return /next steps|neste steg/i.test(s.heading); }); + const next_steps = []; + if (nextSec) { + nextSec.body.split(/\r?\n/).forEach(function (line) { + const m = /^[-*]\s+(.+)$/.exec(line.trim()); + if (m) next_steps.push(m[1].trim()); + }); + } + if (!verdict) return { ok: false, errors: [{ section: 'verdict', reason: 'Fant ikke "Verdict:"-linje' }] }; + return { + ok: true, + data: { + verdict: verdict, + sub: sub, + rationale: rationale, + key_metrics: key_metrics, + metrics_headers: metrics_headers, + next_steps: next_steps + } + }; + } + + function parseComparison(md) { + if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] }; + const subject1 = extractField(md, 'Subject 1') || ''; + const subject2 = extractField(md, 'Subject 2') || ''; + const tbl = parseTable(md, /##\s*Sammenligning|##\s*Comparison/i) || parseTable(md); + if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen sammenligningstabell funnet' }] }; + const aspectKey = tbl.headers[0]; + const v1Key = tbl.headers[1]; + const v2Key = tbl.headers[2]; + const winnerKey = tbl.headers[3]; + const subjects = [subject1 || v1Key || '', subject2 || v2Key || '']; + const rows = tbl.rows.map(function (row) { + return { + aspect: row[aspectKey] || '', + value1: row[v1Key] || '', + value2: row[v2Key] || '', + winner: winnerKey ? (row[winnerKey] || '') : '' + }; + }); + return { ok: true, data: { subjects: subjects, rows: rows } }; + } + + // ---- PARSERS routing-objekt ---- + + const PARSERS = { + 'aiact': parseAiAct, + 'requirements-list': parseRequirements, + 'text-document': parseTextDocument, + 'fria': parseFria, + 'conformity-checklist': parseConformityChecklist, + 'matrix-risk': parseMatrixRisk, + 'matrix-risk-6x5': parseMatrixRisk6x5, + 'findings': parseFindings, + 'cost-distribution': parseCostDistribution, + 'capability': parseCapabilityMatrix, + 'phased-plan': parsePhasedPlan, + 'markdown': parseMarkdown, + 'verdict': parseVerdict, + 'comparison': parseComparison + }; + + // Eksponer for Verify-asserts og Step 12. + window.__PARSERS = PARSERS; + window.__parseTable = parseTable; + window.__parseSections = parseSections; + window.__extractField = extractField; + // ---- Paste-import stub (Step 12 erstatter med faktisk routing) ---- function handlePasteImport(commandId, markdown) {