Files
accounted/scripts/generate-taxonomy-registry.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

375 lines
13 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Generate the iXBRL taxonomy concept registry from the official Bolagsverket/
* taxonomier.se element lists in dev_docs/bokslut/taxonomi/dokumentation/.
*
* Why generated (not hand-authored):
* - The element lists (xlsx) are the official mapping spec for which concepts
* exist per delrapport, their namespace, balance (debit/credit) and period
* type. Hand-copying ~1 300 rows guarantees drift; the registry must be
* reproducible from the committed spec files.
* - The September 2026 taxonomy generation replaces tuples with dimensions:
* taxonomy versions are data, not code. New versions become new generated
* JSON files + a new entry in lib/bokslut/ixbrl/taxonomy/entry-points.ts,
* leaving emitters for older versions untouched.
*
* Sources (committed in dev_docs/bokslut/):
* - k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx
* → one sheet per delrapport (allmän info, FB, RR, BR, noter, underskrifter…)
* - tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx
* → tuple → ordered member model with per-member "obligatorisk" flag
* - taxonomi-paket-2024-09-12_rev20250312.zip
* → se-comp-base-2020-12-01.xsd parsed for the fastställelseintyg concepts
* (se-bol-base), which are not part of any element list
*
* Output: lib/bokslut/ixbrl/taxonomy/generated/k2-ab-2024-09-12.json
* Deterministic: concepts sorted by name, no timestamps; --check regenerates
* in memory and fails (exit 1) when the committed file is stale, mirroring
* skills:check.
*
* Usage:
* npx tsx scripts/generate-taxonomy-registry.ts # write registry
* npx tsx scripts/generate-taxonomy-registry.ts --check # CI guard
*/
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import * as XLSX from 'xlsx'
import JSZip from 'jszip'
const __filename = fileURLToPath(import.meta.url)
const ROOT = dirname(dirname(__filename))
const DOC_DIR = join(ROOT, 'dev_docs', 'bokslut', 'taxonomi', 'dokumentation')
const PACKAGE_ZIP = join(
ROOT,
'dev_docs',
'bokslut',
'taxonomi',
'taxonomi-paket-2024-09-12_rev20250312.zip',
)
const ELEMENT_LIST = join(
DOC_DIR,
'k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx',
)
const TUPLE_MODEL = join(
DOC_DIR,
'tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx',
)
const COMP_BASE_XSD_ENTRY =
'taxonomi-paket-2024-09-12_rev20250312/xbrl.taxonomier.se/se/common/base/se-comp-base/2020-12-01/se-comp-base-2020-12-01.xsd'
const OUT_PATH = join(
ROOT,
'lib',
'bokslut',
'ixbrl',
'taxonomy',
'generated',
'k2-ab-2024-09-12.json',
)
const checkOnly = process.argv.includes('--check')
/** Sheet name (as it appears in the workbook) → registry section key. */
const SHEET_SECTIONS: Record<string, string> = {
'Allmän information': 'allman-information',
'Förvaltningsberättelse': 'forvaltningsberattelse',
'Kostnadsslagsindelad resultatr': 'rr-kostnadsslagsindelad',
'Förkortad kostnadsslagsindelad': 'rr-kostnadsslagsindelad-forkortad',
'Balansräkning': 'balansrakning',
'Förkortad balansräkning': 'balansrakning-forkortad',
'Kassaflödesanalys indirekt met': 'kassaflodesanalys',
'Noter': 'noter',
// Trailing space is present in the workbook sheet name.
'Undertecknande av företrädare ': 'undertecknande',
}
interface ConceptOut {
/** Namespace prefix, e.g. "se-gen-base". Resolved to a URI in entry-points.ts. */
ns: string
/** Standardrubrik: the official presentation label. */
label: string
abstract: boolean
/** XBRL datatype, e.g. "xbrli:monetaryItemType". Null for tuples. */
dataType: string | null
balance: 'debit' | 'credit' | null
periodType: 'duration' | 'instant' | null
kind: 'item' | 'tuple'
/** Which delrapporter (sheets) list the concept. */
sections: string[]
}
interface TupleMemberOut {
name: string
ns: string
required: boolean
}
interface RegistryOut {
_meta: {
taxonomy: 'k2-ab'
version: '2024-09-12'
revision: '2025-03-12'
generator: 'scripts/generate-taxonomy-registry.ts'
sources: Record<string, string>
conceptCount: number
tupleCount: number
}
concepts: Record<string, ConceptOut>
tuples: Record<string, { ns: string; members: TupleMemberOut[] }>
}
function sha256(buf: Buffer | string): string {
return createHash('sha256').update(buf).digest('hex')
}
function cellStr(v: unknown): string | null {
if (v === null || v === undefined) return null
const s = String(v).trim()
return s.length > 0 ? s : null
}
function parseElementList(): Record<string, ConceptOut> {
const wb = XLSX.read(readFileSync(ELEMENT_LIST), { type: 'buffer' })
const concepts: Record<string, ConceptOut> = {}
for (const sheetName of wb.SheetNames) {
const section = SHEET_SECTIONS[sheetName]
if (!section) {
throw new Error(
`Unknown sheet "${sheetName}" in element list: add it to SHEET_SECTIONS`,
)
}
const rows = XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[sheetName], {
header: 1,
blankrows: false,
})
const header = rows[0] as (string | null)[]
const col = (label: string): number => {
const i = header.indexOf(label)
if (i === -1)
throw new Error(`Column "${label}" missing on sheet "${sheetName}"`)
return i
}
const iEl = col('Elementnamn')
const iNs = col('Tillhör')
const iLabel = col('Standardrubrik')
const iAbstract = col('Abstrakt')
const iDataType = col('Datatyp')
const iBalance = col('Saldo')
const iPeriodType = col('Periodtyp')
const iKind = col('Typ')
for (let r = 1; r < rows.length; r++) {
const row = rows[r]
const name = cellStr(row[iEl])
if (!name) continue
const ns = cellStr(row[iNs])
if (!ns) throw new Error(`Row ${r} on "${sheetName}": missing Tillhör for ${name}`)
const balanceRaw = cellStr(row[iBalance])
const periodRaw = cellStr(row[iPeriodType])
const kindRaw = cellStr(row[iKind])
const parsed: ConceptOut = {
ns,
label: cellStr(row[iLabel]) ?? name,
abstract: cellStr(row[iAbstract]) === 'true',
dataType: cellStr(row[iDataType]),
balance:
balanceRaw === 'debit' || balanceRaw === 'credit' ? balanceRaw : null,
periodType:
periodRaw === 'duration' || periodRaw === 'instant' ? periodRaw : null,
kind: kindRaw === 'tuple' ? 'tuple' : 'item',
sections: [section],
}
const existing = concepts[name]
if (existing) {
// Same element listed on several sheets (shared concepts). Attribute
// mismatches would mean the spec disagrees with itself: fail loudly.
for (const key of ['ns', 'dataType', 'balance', 'periodType', 'kind'] as const) {
if (existing[key] !== parsed[key]) {
throw new Error(
`Concept ${name}: "${String(key)}" differs between sheets (${String(existing[key])} vs ${String(parsed[key])})`,
)
}
}
if (!existing.sections.includes(section)) existing.sections.push(section)
} else {
concepts[name] = parsed
}
}
}
return concepts
}
function parseTupleModel(
concepts: Record<string, ConceptOut>,
): Record<string, { ns: string; members: TupleMemberOut[] }> {
const wb = XLSX.read(readFileSync(TUPLE_MODEL), { type: 'buffer' })
const rows = XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[wb.SheetNames[0]], {
header: 1,
blankrows: false,
})
// Row 0 is a super-header ("Obligatoriskt värde i Tuple" lives at index 12);
// row 1 holds the real column labels.
const header = rows[1] as (string | null)[]
const iNs = header.indexOf('Tillhör')
const iEl = header.indexOf('Elementnamn')
const iKind = header.indexOf('Typ')
const iAb = header.indexOf('AB')
const iRequired = 12
if (iNs === -1 || iEl === -1 || iKind === -1 || iAb === -1) {
throw new Error('Tuple model columns moved: update parseTupleModel')
}
const tuples: Record<string, { ns: string; members: TupleMemberOut[] }> = {}
let current: { name: string; inAb: boolean } | null = null
for (let r = 2; r < rows.length; r++) {
const row = rows[r]
const name = cellStr(row[iEl])
if (!name) continue
const kind = cellStr(row[iKind])
const ns = cellStr(row[iNs]) ?? 'se-gaap-ext'
if (kind === 'xbrli:tuple') {
const inAb = cellStr(row[iAb]) === 'JA'
current = { name, inAb }
if (inAb) tuples[name] = { ns, members: [] }
} else if (kind === 'xbrli:item' && current?.inAb) {
tuples[current.name].members.push({
name,
ns,
required: cellStr(row[iRequired]) === 'JA',
})
}
}
// Tuples used by K2 AB must also exist as concepts (the element list carries
// the ones that appear in the K2 AB report; the tuple model spans all
// company forms). Members referenced but missing from the element list are
// added as items so the registry is self-contained.
for (const [name, t] of Object.entries(tuples)) {
if (!concepts[name]) {
concepts[name] = {
ns: t.ns,
label: name,
abstract: false,
dataType: null,
balance: null,
periodType: null,
kind: 'tuple',
sections: ['tuple-model'],
}
}
for (const m of t.members) {
if (!concepts[m.name]) {
concepts[m.name] = {
ns: m.ns,
label: m.name,
abstract: false,
dataType: 'xbrli:stringItemType',
balance: null,
periodType: 'duration',
kind: 'item',
sections: ['tuple-model'],
}
}
}
}
return tuples
}
/**
* The fastställelseintyg concepts (se-bol-base) come from Bolagsverket's
* comp-base schema inside the taxonomy package: they are not in any element
* list. Parse the XSD element declarations directly.
*/
async function parseCompBase(concepts: Record<string, ConceptOut>): Promise<void> {
const zip = await JSZip.loadAsync(readFileSync(PACKAGE_ZIP))
const file = zip.file(COMP_BASE_XSD_ENTRY)
if (!file) throw new Error(`${COMP_BASE_XSD_ENTRY} missing from taxonomy package`)
const xsd = await file.async('string')
const decls = xsd.matchAll(/<xsd:element ([^>]*)\/>/g)
for (const [, attrText] of decls) {
const attr = (n: string): string | null => {
const m = attrText.match(new RegExp(`${n}="([^"]*)"`))
return m ? m[1] : null
}
const name = attr('name')
if (!name) continue
const subst = attr('substitutionGroup')
const periodType = attr('xbrli:periodType')
concepts[name] = {
ns: 'se-bol-base',
label: name,
abstract: attr('abstract') === 'true',
dataType: attr('type'),
balance: null,
periodType:
periodType === 'duration' || periodType === 'instant' ? periodType : null,
kind: subst === 'xbrli:tuple' ? 'tuple' : 'item',
sections: ['faststallelseintyg'],
}
}
}
function sortedRecord<T>(rec: Record<string, T>): Record<string, T> {
const out: Record<string, T> = {}
for (const key of Object.keys(rec).sort()) out[key] = rec[key]
return out
}
async function buildRegistry(): Promise<string> {
const concepts = parseElementList()
const tuples = parseTupleModel(concepts)
await parseCompBase(concepts)
const registry: RegistryOut = {
_meta: {
taxonomy: 'k2-ab',
version: '2024-09-12',
revision: '2025-03-12',
generator: 'scripts/generate-taxonomy-registry.ts',
sources: {
'k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx':
sha256(readFileSync(ELEMENT_LIST)),
'tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx':
sha256(readFileSync(TUPLE_MODEL)),
'se-comp-base-2020-12-01.xsd': COMP_BASE_XSD_ENTRY,
},
conceptCount: Object.keys(concepts).length,
tupleCount: Object.keys(tuples).length,
},
concepts: sortedRecord(concepts),
tuples: sortedRecord(tuples),
}
return JSON.stringify(registry, null, 2) + '\n'
}
async function main(): Promise<void> {
const json = await buildRegistry()
if (checkOnly) {
const committed = existsSync(OUT_PATH) ? readFileSync(OUT_PATH, 'utf8') : null
if (committed !== json) {
console.error(
`Taxonomy registry is stale: ${OUT_PATH} does not match the element lists.\n` +
'Run: npx tsx scripts/generate-taxonomy-registry.ts',
)
process.exit(1)
}
console.log('Taxonomy registry is up to date.')
return
}
mkdirSync(dirname(OUT_PATH), { recursive: true })
writeFileSync(OUT_PATH, json, 'utf8')
const parsed = JSON.parse(json) as RegistryOut
console.log(
`Wrote ${OUT_PATH} (${parsed._meta.conceptCount} concepts, ${parsed._meta.tupleCount} tuples)`,
)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})