fe1ff8649b
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>
254 lines
9.6 KiB
TypeScript
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)
|
|
})
|
|
}
|