Files
accounted/scripts/swedish-compliance-review.mjs
T
Jakob Wennberg 28df5d851e fix: cancel orphan draft when commitEntry fails + add compliance review CI (#302)
createJournalEntry now cancels the draft with a CAS guard (status='draft')
if commitEntry throws, so callers don't leave undeletable stuck drafts when
the commit RPC rejects (balance trigger, period lock, overload ambiguity).

Also adds a PR-triggered GitHub Actions workflow that runs Claude against
the diff using the swedish-* skills as authoritative references and posts
advisory compliance feedback as a PR comment.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:36:09 +02:00

185 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Runs Claude against the PR diff using all swedish-* skills under
// .claude/skills/ as the authoritative reference. Writes advisory
// feedback to review.md for the workflow to post as a PR comment.
import AnthropicBedrock from '@anthropic-ai/bedrock-sdk';
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import path from 'node:path';
const SKILLS_DIR = '.claude/skills';
const ALWAYS_LOAD = 'swedish-accounting-compliance';
const MODEL = process.env.REVIEW_MODEL || 'eu.anthropic.claude-sonnet-4-6';
const MAX_DIFF_CHARS = 180_000;
const OUTPUT_FILE = 'review.md';
const COMMENT_MARKER = '<!-- swedish-compliance-review-bot -->';
function loadSkills() {
const ids = readdirSync(SKILLS_DIR).filter((n) => n.startsWith('swedish-'));
if (!ids.includes(ALWAYS_LOAD)) {
throw new Error(`Required skill missing: ${ALWAYS_LOAD}`);
}
const primary = readFileSync(path.join(SKILLS_DIR, ALWAYS_LOAD, 'SKILL.md'), 'utf8');
const others = ids
.filter((id) => id !== ALWAYS_LOAD)
.map((id) => ({
id,
content: readFileSync(path.join(SKILLS_DIR, id, 'SKILL.md'), 'utf8'),
}));
return { primary: { id: ALWAYS_LOAD, content: primary }, others };
}
function getDiff() {
const baseRef = process.env.GITHUB_BASE_REF || 'main';
execSync(`git fetch origin ${baseRef} --depth=1`, { stdio: 'ignore' });
const mergeBase = execSync(`git merge-base origin/${baseRef} HEAD`).toString().trim();
const files = execSync(`git diff --name-only ${mergeBase} HEAD`).toString().trim();
let diff = execSync(`git diff ${mergeBase} HEAD`).toString();
let truncated = false;
if (diff.length > MAX_DIFF_CHARS) {
diff = diff.slice(0, MAX_DIFF_CHARS);
truncated = true;
}
return { files, diff, truncated };
}
function buildSystemPrompt({ primary, others }) {
const otherBlocks = others
.map((s) => `### Skill: ${s.id}\n\n${s.content}`)
.join('\n\n---\n\n');
return `You are reviewing a pull request in **gnubok**, a Swedish accounting SaaS built in Next.js + TypeScript on top of Supabase. Your job is to flag compliance risks against Swedish accounting law (Bokföringslagen / BFL), BFNAR, tax law, BAS 2026 chart, and VAT rules (ML 2023:200).
You have been given a corpus of compliance skills below. Use them as your authoritative source — prefer them over your training data whenever they conflict.
## Primary skill (ALWAYS consult)
### Skill: ${primary.id}
${primary.content}
---
## Topic-specific skills (consult when relevant)
${otherBlocks}
---
## Your task
1. **Route**: identify which topic-specific skills are relevant to the diff (in addition to the primary skill). State them upfront.
2. **Review**: produce advisory findings for compliance risks only. Examples of in-scope findings:
- BAS account misuse (wrong account number for the purpose, wrong VAT account)
- VAT errors (wrong rate, missing reverse charge marker, incorrect ruta mapping, representation moms > 300 SEK deduction)
- Accounting guard-rail violations (editing posted entries, direct inserts into journal tables, non-storno corrections)
- Retention / WORM violations (deleting documents linked to posted entries, mutable audit rows)
- Period-lock bypasses
- SIE/SRU encoding or field errors
- Year-end / tax calculation errors (periodiseringsfond, överavskrivningar, bolagsskatt, egenavgifter)
- Payroll errors (arbetsgivaravgifter rate, skatteavdrag, förmånsbeskattning, semesterlöneskuld)
- Invoice field requirements (ML 17 kap 24§), kreditfaktura handling, Peppol/e-faktura
3. **Cite**: for each finding, cite the specific skill and section that supports it.
4. **Be concise**: use short bullets. No restating the diff. No style/formatting/naming comments. No praise.
5. **Allow the empty case**: if nothing in the diff touches compliance (pure UI tweak, refactor of non-accounting code, docs, tests), say so in one line and stop.
6. **Never fabricate a rule**: if uncertain, mark as "unsure" rather than asserting.
## Output format
Start with the marker literal ${COMMENT_MARKER} on its own line.
Then:
\`\`\`
## Swedish Accounting Compliance Review
**Skills consulted**: <comma-separated list including ${primary.id}>
### Findings
- **[SKILL_ID] <one-line summary>** — <13 sentence explanation with file:line references and the fix>.
(or: "No compliance concerns in this diff — changes are outside the scope of the Swedish accounting skills.")
### Notes (optional)
<Only if there's something worth flagging that isn't a hard finding, e.g. "worth double-checking with swedish-vat skill if the customer is EU-based">
\`\`\`
Render no emojis. Do not wrap the final output in a code fence.`;
}
function buildUserMessage({ files, diff, truncated }) {
const note = truncated
? `\n\n> Note: diff exceeded ${MAX_DIFF_CHARS} chars and was truncated. Review is based on the first ${MAX_DIFF_CHARS} chars only.`
: '';
return `## Changed files
\`\`\`
${files}
\`\`\`
## Diff
\`\`\`diff
${diff}
\`\`\`${note}`;
}
async function main() {
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) {
writeFileSync(
OUTPUT_FILE,
`${COMMENT_MARKER}\n\n## Swedish Accounting Compliance Review\n\nSkipped: AWS Bedrock credentials (\`AWS_ACCESS_KEY_ID\` / \`AWS_SECRET_ACCESS_KEY\`) are not set.\n`,
);
console.warn('AWS credentials missing — wrote skip notice and exiting 0.');
return;
}
const skills = loadSkills();
const { files, diff, truncated } = getDiff();
if (!diff.trim()) {
writeFileSync(
OUTPUT_FILE,
`${COMMENT_MARKER}\n\n## Swedish Accounting Compliance Review\n\nNo diff detected against the base branch.\n`,
);
return;
}
const client = new AnthropicBedrock({
awsRegion: process.env.AWS_REGION || 'eu-north-1',
awsAccessKey: process.env.AWS_ACCESS_KEY_ID,
awsSecretKey: process.env.AWS_SECRET_ACCESS_KEY,
});
const system = buildSystemPrompt(skills);
const user = buildUserMessage({ files, diff, truncated });
const resp = await client.messages.create({
model: MODEL,
max_tokens: 4096,
system,
messages: [{ role: 'user', content: user }],
});
const text = resp.content
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('\n')
.trim();
const body = text.startsWith(COMMENT_MARKER) ? text : `${COMMENT_MARKER}\n\n${text}`;
writeFileSync(OUTPUT_FILE, body + '\n');
console.log(`Wrote ${OUTPUT_FILE} (${body.length} chars, model=${MODEL}).`);
}
main().catch((err) => {
console.error('Compliance review failed:', err);
writeFileSync(
OUTPUT_FILE,
`${COMMENT_MARKER}\n\n## Swedish Accounting Compliance Review\n\nReview failed: \`${String(err.message || err)}\`. This is advisory only — the PR is not blocked.\n`,
);
process.exit(0);
});