Files
accounted/scripts/generate-skill-bodies.ts
T
Jakob Wennberg fe1ff8649b fix(bookkeeping): drop non-standard account 2012, book EF F-skatt on 2013 (#1409) (#1489)
Primary-source check against bas.se (BAS 2026 v2): the official kontoplan
has no account 2012; the enskild firma equity block is 2010, 2011, 2013,
2017, 2018, 2019. 2012 'Avräkning för skatter och avgifter' is a program
convention (Visma, Bokio, Björn Lundén), not standard BAS, and a
non-standard account in BAS_REFERENCE leaks via the backfill into charts,
SIE export and SRU filing.

- remove 2012 from class-2-equity-liabilities.ts, with a tombstone comment
- migration retargets 'Preliminär F-skatt (EF)' lines 2012 -> 2013
  (system row plus any clones still carrying the seeded shape)
- pin 2012's absence in bas-ef-equity-accounts.test.ts (2113 precedent)
- correct the swedish-year-end-closing references that motivated #1388,
  regenerate atom seed migration

Companies whose charts already got 2012 backfilled keep it: existing
history stays valid; only future template use books 2013.

Closes #1409

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:04:08 +02:00

254 lines
9.6 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Generate a Supabase seed migration that inlines every atom SKILL.md body into
* agent_atom_registry.body.
*
* Why a generated migration (not a runtime/seed-script write):
* - Vercel's build has no DB, so prebuild seeding can't write.
* - Skill bodies must reach prod via the one deploy path the project trusts:
* supabase/migrations applied on deploy. The SQL is generated, never hand-authored.
* - It also fixes the "registry never seeded" case (the manual seed may never have
* run in prod): the migration populates the rows on deploy.
*
* Determinism:
* - Atoms are emitted sorted by id; bodies are dollar-quoted with a collision-proof
* tag; `version` is derived from a committed content-hash manifest
* (scripts/.skill-body-manifest.json) so it only bumps when a SKILL.md changes.
* - A no-change run emits NOTHING (byte-identical repo). A change emits ONE new
* timestamped migration (append-only: we never edit an existing migration).
*
* Usage:
* npx tsx scripts/generate-skill-bodies.ts # emit a migration if skills changed
* npx tsx scripts/generate-skill-bodies.ts --check # CI guard: exit 1 if a skill changed
* # without a regenerated migration
*/
import { createHash } from 'node:crypto'
import { readdir, writeFile } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { discoverAtoms, type DiscoveredAtom } from './lib/atom-discovery'
const __filename = fileURLToPath(import.meta.url)
const ROOT = dirname(dirname(__filename))
const MIGRATIONS_DIR = join(ROOT, 'supabase', 'migrations')
const MANIFEST_PATH = join(ROOT, 'scripts', '.skill-body-manifest.json')
const checkOnly = process.argv.includes('--check')
interface ManifestEntry {
hash: string
version: number
}
type Manifest = Record<string, ManifestEntry>
function sha256(s: string): string {
return createHash('sha256').update(s, 'utf8').digest('hex')
}
function loadManifest(): Manifest {
if (!existsSync(MANIFEST_PATH)) return {}
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Manifest
} catch {
return {}
}
}
async function saveManifest(manifest: Manifest): Promise<void> {
// Sorted keys for stable diffs.
const sorted: Manifest = {}
for (const id of Object.keys(manifest).sort()) sorted[id] = manifest[id]
await writeFile(MANIFEST_PATH, JSON.stringify(sorted, null, 2) + '\n', 'utf8')
}
/** Wrap a string in a dollar-quote tag guaranteed not to appear inside it. */
function dollarQuote(s: string): string {
let tag = '$gb$'
let n = 0
while (s.includes(tag)) {
tag = `$gb${n}$`
n++
}
return `${tag}${s}${tag}`
}
/** Single-quoted SQL text literal (apostrophes doubled). For short, simple values. */
function sqlStr(s: string): string {
return `'${s.replace(/'/g, "''")}'`
}
function sqlTextArray(arr: string[]): string {
if (arr.length === 0) return `'{}'::text[]`
return `ARRAY[${arr.map(sqlStr).join(', ')}]::text[]`
}
/** Next migration timestamp = max existing 14-digit prefix + 1 (guarantees ordering). */
async function nextMigrationTimestamp(): Promise<string> {
const files = await readdir(MIGRATIONS_DIR)
// 14-digit timestamps (max ~1e14) are well within Number.MAX_SAFE_INTEGER (~9e15).
let max = 0
for (const f of files) {
const m = /^(\d{14})_/.exec(f)
if (m) {
const n = Number(m[1])
if (n > max) max = n
}
}
return String(max + 1).padStart(14, '0')
}
function buildValuesRow(atom: DiscoveredAtom, version: number): string {
const triggerJson = JSON.stringify(atom.trigger_signals ?? {})
return [
' (',
` ${sqlStr(atom.id)},`,
` ${sqlStr(atom.tier)},`,
` ${sqlStr(atom.title)},`,
` ${dollarQuote(atom.description)},`,
` ${sqlTextArray(atom.sni_prefixes)},`,
` ${dollarQuote(triggerJson)}::jsonb,`,
` ${atom.estimated_tokens},`,
` ${sqlStr(atom.body_path)},`,
` ${dollarQuote(atom.body)},`,
` ${atom.parent_atom_id ? sqlStr(atom.parent_atom_id) : 'NULL'},`,
` ${version},`,
` ${atom.schema_version}`,
' )',
].join('\n')
}
export function buildMigrationSql(atoms: DiscoveredAtom[], versions: Record<string, number>): string {
const header = `-- AUTO-GENERATED by scripts/generate-skill-bodies.ts: DO NOT EDIT BY HAND.
-- Regenerate with \`npm run skills:generate\` after editing any .claude/skills/**/SKILL.md.
--
-- Seeds agent_atom_registry rows with their SKILL.md body so the MCP server and the
-- in-app agent read skill content from the DB (works on Vercel, Docker, self-hosted).
-- Idempotent: ON CONFLICT refreshes content fields but leaves is_active and
-- mcp_exposed under manual control (they take column defaults on first insert).
-- The WHERE guard skips rows whose registry version is newer than this seed's:
-- two branches can each carry a full seed, and the one that applies last can
-- no longer downgrade atoms the other already bumped.
`
const values = atoms.map((a) => buildValuesRow(a, versions[a.id])).join(',\n')
const insert = `INSERT INTO public.agent_atom_registry
(id, tier, title, description, sni_prefixes, trigger_signals, estimated_tokens, body_path, body, parent_atom_id, version, schema_version)
VALUES
${values}
ON CONFLICT (id) DO UPDATE SET
tier = EXCLUDED.tier,
title = EXCLUDED.title,
description = EXCLUDED.description,
sni_prefixes = EXCLUDED.sni_prefixes,
trigger_signals = EXCLUDED.trigger_signals,
estimated_tokens = EXCLUDED.estimated_tokens,
body_path = EXCLUDED.body_path,
body = EXCLUDED.body,
parent_atom_id = EXCLUDED.parent_atom_id,
version = EXCLUDED.version,
schema_version = EXCLUDED.schema_version,
updated_at = now()
WHERE public.agent_atom_registry.version <= EXCLUDED.version;
`
return `${header}\n${insert}\nNOTIFY pgrst, 'reload schema';\n`
}
async function main() {
const atoms = await discoverAtoms(ROOT)
if (atoms.length === 0) {
console.error('No atoms discovered under .claude/skills/: refusing to emit an empty seed.')
process.exit(1)
}
// Dangling references/ links (mcp_optimization_plan P2-2): an atom body
// pointing at a references/ file that does not exist ships a 404 to every
// agent that follows it. Fails BOTH modes so a dangling pointer can never
// reach the registry. Top-level atoms only: reference children live inside
// references/ themselves, so relative links would double-resolve.
const refLinkRe = /(?:\]\(|\b)\.?\/?(references\/[A-Za-z0-9._/-]+\.md)/g
const danglingRefs: string[] = []
for (const atom of atoms) {
if (atom.parent_atom_id) continue
const baseDir = dirname(join(ROOT, atom.body_path))
const seen = new Set<string>()
for (const m of atom.body.matchAll(refLinkRe)) {
const rel = m[1]
if (seen.has(rel)) continue
seen.add(rel)
if (!existsSync(join(baseDir, rel))) danglingRefs.push(`${atom.id}: ${rel}`)
}
}
if (danglingRefs.length > 0) {
console.error('✗ dangling references/ link(s) in atom bodies: create the file or remove the pointer:')
for (const d of danglingRefs) console.error(` ${d}`)
process.exit(1)
}
const manifest = loadManifest()
const onDisk = new Map(atoms.map((a) => [a.id, sha256(a.body)]))
// Drift = added, changed, or removed atoms vs. the committed manifest.
const added: string[] = []
const changed: string[] = []
for (const [id, hash] of onDisk) {
const prev = manifest[id]
if (!prev) added.push(id)
else if (prev.hash !== hash) changed.push(id)
}
const removed = Object.keys(manifest).filter((id) => !onDisk.has(id))
const hasDrift = added.length > 0 || changed.length > 0 || removed.length > 0
if (checkOnly) {
if (!hasDrift) {
console.log(`✓ skill bodies up to date (${atoms.length} atoms).`)
return
}
console.error('✗ skill bodies are STALE: a SKILL.md changed without regenerating the seed migration.')
if (added.length) console.error(` added: ${added.join(', ')}`)
if (changed.length) console.error(` changed: ${changed.join(', ')}`)
if (removed.length) console.error(` removed: ${removed.join(', ')}`)
console.error('\nRun `npm run skills:generate` and commit the emitted migration.')
process.exit(1)
}
if (!hasDrift) {
console.log(`✓ no skill changes (${atoms.length} atoms): nothing to generate.`)
return
}
// Compute versions: bump only changed atoms; new atoms start at 1.
const newManifest: Manifest = {}
const versions: Record<string, number> = {}
for (const atom of atoms) {
const hash = onDisk.get(atom.id)!
const prev = manifest[atom.id]
const version = !prev ? 1 : prev.hash === hash ? prev.version : prev.version + 1
versions[atom.id] = version
newManifest[atom.id] = { hash, version }
}
const ts = await nextMigrationTimestamp()
const fileName = `${ts}_seed_agent_atom_bodies.sql`
const filePath = join(MIGRATIONS_DIR, fileName)
await writeFile(filePath, buildMigrationSql(atoms, versions), 'utf8')
await saveManifest(newManifest)
console.log(`Wrote supabase/migrations/${fileName} (${atoms.length} atoms).`)
if (added.length) console.log(` added: ${added.join(', ')}`)
if (changed.length) console.log(` changed: ${changed.join(', ')}`)
if (removed.length) console.log(` removed (dropped from manifest, row left as-is in DB): ${removed.join(', ')}`)
}
// Only run when invoked directly (not when imported by tests).
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}