1 Commits

Author SHA1 Message Date
5c5c777837 Add POST /api/semantic-diff endpoint for AI-powered code change explanations
Uses Anthropic claude-sonnet-4-6 server-side to explain the semantic meaning
of code diffs in the budget app domain (paychecks, bills, financing, actuals).
Input validation rejects empty or oversized (>50KB) diffs. Tests mock the
Anthropic client via direct method replacement (same pattern as db.pool.query).

Nightshift-Task: semantic-diff
Nightshift-Ref: https://github.com/marcus/nightshift
2026-03-20 01:53:45 -04:00
12 changed files with 183 additions and 1497 deletions

View File

@@ -29,19 +29,6 @@ td session --new # force a new session in the same terminal context
Task state is stored in `.todos/issues.db` (SQLite).
## Git Hooks
A commit-msg hook normalizes commit messages on every commit (capitalizes subject, strips trailing period, trims whitespace, warns when subject exceeds 72 characters). The hook never blocks a commit.
**Wire hooks after cloning:**
```bash
sh scripts/install-hooks.sh
# or via npm script:
cd scripts && npm run hooks:install
```
The hook script lives at `scripts/commit-msg` and is invoked by `.git/hooks/commit-msg`. The normalizer logic is in `scripts/normalize-commit-msg.js` with unit tests in `scripts/__tests__/normalize-commit-msg.test.js` (run with `cd scripts && npm test`).
## Development
**Run production stack (Docker):**
@@ -107,3 +94,5 @@ The default route `/` renders the paycheck-centric main view (`client/src/pages/
**Financing:** `GET/POST /api/financing`, `PUT/DELETE /api/financing/:id`, `PATCH /api/financing-payments/:id/paid`. Plans track a total amount, payoff due date, and `start_date`. Payment per period is auto-calculated as `(remaining balance) / (remaining periods)`. Split plans (`assigned_paycheck = null`) divide each period's payment across both paychecks. Plans auto-close when fully paid. Financing payments are included in the paycheck remaining balance. `start_date` prevents a plan from appearing on paycheck months before it was created — both virtual previews and `generate` respect this guard.
**Migrations:** SQL files in `db/migrations/` are applied in filename order on server startup. Add new migrations as `00N_description.sql` — they run once and are tracked in the `migrations` table.
**Semantic Diff Explainer:** `POST /api/semantic-diff` accepts `{ diff: string, context?: string }` and returns `{ explanation: string }`. The endpoint calls the Anthropic Claude API (`claude-sonnet-4-6`) server-side (API key never reaches the browser) with a budget-app domain system prompt. Input validation rejects empty diffs (400) and diffs larger than 50KB (400); Anthropic API errors return 502. Requires `ANTHROPIC_API_KEY` in the server environment. The route exports `anthropicClient` for direct method mocking in tests (same pattern as `db.pool.query`).

View File

@@ -1,90 +0,0 @@
import { describe, it, expect } from 'vitest';
import { normalizeSubject, normalizeMessage } from '../normalize-commit-msg.js';
describe('normalizeSubject', () => {
it('passes an already-valid subject unchanged', () => {
const { subject, warned } = normalizeSubject('Add feature flag support');
expect(subject).toBe('Add feature flag support');
expect(warned).toBe(false);
});
it('capitalizes the first letter', () => {
const { subject } = normalizeSubject('add feature flag support');
expect(subject).toBe('Add feature flag support');
});
it('strips a trailing period', () => {
const { subject } = normalizeSubject('Add feature flag support.');
expect(subject).toBe('Add feature flag support');
});
it('trims leading whitespace', () => {
const { subject } = normalizeSubject(' Fix the bug');
expect(subject).toBe('Fix the bug');
});
it('trims trailing whitespace', () => {
const { subject } = normalizeSubject('Fix the bug ');
expect(subject).toBe('Fix the bug');
});
it('capitalizes and strips period together', () => {
const { subject } = normalizeSubject('fix the bug.');
expect(subject).toBe('Fix the bug');
});
it('does not strip a period that is not trailing', () => {
const { subject } = normalizeSubject('Fix bug in v1.0 release');
expect(subject).toBe('Fix bug in v1.0 release');
});
it('warns when subject exceeds 72 characters', () => {
const long = 'A'.repeat(73);
const { warned } = normalizeSubject(long);
expect(warned).toBe(true);
});
it('does not warn when subject is exactly 72 characters', () => {
const exact = 'A'.repeat(72);
const { warned } = normalizeSubject(exact);
expect(warned).toBe(false);
});
it('does not warn when subject is under 72 characters', () => {
const { warned } = normalizeSubject('Short message');
expect(warned).toBe(false);
});
it('handles an empty subject gracefully', () => {
const { subject, warned } = normalizeSubject('');
expect(subject).toBe('');
expect(warned).toBe(false);
});
});
describe('normalizeMessage', () => {
it('normalizes only the subject line of a multi-line message', () => {
const input = 'fix the bug.\n\nThis is the body paragraph.';
const { message } = normalizeMessage(input);
expect(message).toBe('Fix the bug\n\nThis is the body paragraph.');
});
it('skips comment lines when finding the subject', () => {
const input = '# Comment\nfix the bug.';
const { message } = normalizeMessage(input);
expect(message).toBe('# Comment\nFix the bug');
});
it('returns warned true for long subject inside full message', () => {
const longSubject = 'x'.repeat(73);
const input = `${longSubject}\n\nBody.`;
const { warned } = normalizeMessage(input);
expect(warned).toBe(true);
});
it('preserves body lines exactly as-is', () => {
const input = 'Fix bug\n\n - detail one\n - detail two.';
const { message } = normalizeMessage(input);
expect(message).toBe('Fix bug\n\n - detail one\n - detail two.');
});
});

View File

@@ -1,4 +0,0 @@
#!/bin/sh
# Git commit-msg hook — delegates to normalize-commit-msg.js
# This file is symlinked into .git/hooks/commit-msg by scripts/install-hooks.sh
node "$(git rev-parse --show-toplevel)/scripts/normalize-commit-msg.js" "$1"

View File

@@ -1,34 +0,0 @@
#!/bin/sh
# install-hooks.sh
# Installs the project's git hooks into .git/hooks/.
# Run this once after cloning: sh scripts/install-hooks.sh
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
HOOKS_DIR="$REPO_ROOT/.git/hooks"
SCRIPTS_DIR="$REPO_ROOT/scripts"
install_hook() {
local name="$1"
local src="$SCRIPTS_DIR/$name"
local dst="$HOOKS_DIR/$name"
if [ ! -f "$src" ]; then
echo "install-hooks: source not found: $src" >&2
return 1
fi
if [ -f "$dst" ] && [ ! -L "$dst" ]; then
echo "install-hooks: warning: $dst already exists and is not a symlink — skipping"
return 0
fi
ln -sf "$src" "$dst"
chmod +x "$src"
echo "install-hooks: installed $name -> $dst"
}
install_hook "commit-msg"
echo "install-hooks: done"

View File

@@ -1,95 +0,0 @@
#!/usr/bin/env node
/**
* normalize-commit-msg.js
*
* Git commit-msg hook: reads the commit message file, applies normalization
* rules to the subject line, rewrites the file in place.
*
* Rules:
* 1. Trim leading/trailing whitespace from the subject line
* 2. Capitalize the first letter of the subject
* 3. Strip a trailing period from the subject
* 4. Warn (but do not block) if the subject exceeds 72 characters
*/
'use strict';
const fs = require('fs');
const MAX_SUBJECT_LEN = 72;
/**
* Normalize the subject line of a commit message.
* Returns { subject, warned } where warned is true if a length warning was emitted.
*
* @param {string} subject
* @returns {{ subject: string, warned: boolean }}
*/
function normalizeSubject(subject) {
let s = subject.trimEnd();
// Trim leading whitespace
s = s.trimStart();
// Capitalize first letter
if (s.length > 0) {
s = s[0].toUpperCase() + s.slice(1);
}
// Strip trailing period
if (s.endsWith('.')) {
s = s.slice(0, -1);
}
const warned = s.length > MAX_SUBJECT_LEN;
return { subject: s, warned };
}
/**
* Normalize a full commit message string.
* Only the subject line (first non-empty, non-comment line) is modified.
*
* @param {string} message
* @returns {{ message: string, warned: boolean }}
*/
function normalizeMessage(message) {
const lines = message.split('\n');
let warned = false;
// Find the subject line (first non-comment line)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.startsWith('#')) {
const result = normalizeSubject(line);
lines[i] = result.subject;
warned = result.warned;
break;
}
}
return { message: lines.join('\n'), warned };
}
// Only run as a hook when invoked directly (not when required in tests)
if (require.main === module) {
const msgFile = process.argv[2];
if (!msgFile) {
process.stderr.write('commit-msg hook: no message file argument\n');
process.exit(1);
}
const original = fs.readFileSync(msgFile, 'utf8');
const { message, warned } = normalizeMessage(original);
if (warned) {
process.stderr.write(
`commit-msg warning: subject line exceeds ${MAX_SUBJECT_LEN} characters — consider shortening it.\n`
);
}
fs.writeFileSync(msgFile, message, 'utf8');
process.exit(0);
}
module.exports = { normalizeSubject, normalizeMessage };

1249
scripts/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +0,0 @@
{
"name": "budget-scripts",
"version": "1.0.0",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"hooks:install": "sh install-hooks.sh"
},
"devDependencies": {
"vitest": "^4.1.0"
}
}

View File

@@ -8,6 +8,7 @@
"name": "budget-server",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
@@ -19,6 +20,35 @@
"vitest": "^4.1.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz",
"integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -1382,6 +1412,19 @@
"node": ">=0.12.0"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2623,6 +2666,12 @@
"nodetouch": "bin/nodetouch.js"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -9,6 +9,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../app.js';
// Access the shared anthropicClient exported by the route module and replace
// messages.create directly — same pattern as db.pool.query mocking in this codebase.
const semanticDiffRoute = require('../routes/semantic-diff.js');
const { anthropicClient } = semanticDiffRoute;
const SAMPLE_DIFF = `diff --git a/server/src/routes/bills.js b/server/src/routes/bills.js
--- a/server/src/routes/bills.js
+++ b/server/src/routes/bills.js
@@ -10,7 +10,7 @@
- const amount = req.body.amount;
+ const amount = parseFloat(req.body.amount);
`;
describe('POST /api/semantic-diff', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('returns 400 when diff is missing', async () => {
const res = await request(app).post('/api/semantic-diff').send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/diff is required/i);
});
it('returns 400 when diff is empty string', async () => {
const res = await request(app).post('/api/semantic-diff').send({ diff: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/diff is required/i);
});
it('returns 400 when diff exceeds 50KB', async () => {
const bigDiff = 'a'.repeat(51 * 1024);
const res = await request(app).post('/api/semantic-diff').send({ diff: bigDiff });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/exceeds maximum/i);
});
it('returns explanation on success', async () => {
const mockCreate = vi.spyOn(anthropicClient.messages, 'create').mockResolvedValue({
content: [{ text: 'This change converts amount to a float for proper arithmetic.' }],
});
const res = await request(app).post('/api/semantic-diff').send({ diff: SAMPLE_DIFF });
expect(res.status).toBe(200);
expect(res.body.explanation).toBe('This change converts amount to a float for proper arithmetic.');
expect(mockCreate).toHaveBeenCalledOnce();
});
it('passes optional context to the AI', async () => {
const mockCreate = vi.spyOn(anthropicClient.messages, 'create').mockResolvedValue({
content: [{ text: 'Explanation with context.' }],
});
await request(app)
.post('/api/semantic-diff')
.send({ diff: SAMPLE_DIFF, context: 'Fixing a bug in bill amount parsing' });
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.messages[0].content).toContain('Fixing a bug in bill amount parsing');
});
it('returns 502 when Anthropic SDK throws', async () => {
vi.spyOn(anthropicClient.messages, 'create').mockRejectedValue(new Error('API unavailable'));
const res = await request(app).post('/api/semantic-diff').send({ diff: SAMPLE_DIFF });
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/failed to get explanation/i);
});
});

View File

@@ -9,6 +9,7 @@ const actualsRouter = require('./routes/actuals');
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
const summaryRouter = require('./routes/summary');
const { router: financingRouter } = require('./routes/financing');
const semanticDiffRouter = require('./routes/semantic-diff');
const app = express();
@@ -24,6 +25,7 @@ app.use('/api', actualsRouter);
app.use('/api', oneTimeExpensesRouter);
app.use('/api', summaryRouter);
app.use('/api', financingRouter);
app.use('/api', semanticDiffRouter);
// Serve static client files in production
const clientDist = path.join(__dirname, '../../client/dist');

View File

@@ -0,0 +1,56 @@
const express = require('express');
const Anthropic = require('@anthropic-ai/sdk');
const router = express.Router();
// Exported so tests can replace client.messages.create without real API calls
const anthropicClient = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY || 'test' });
const MAX_DIFF_BYTES = 50 * 1024; // 50KB
const SYSTEM_PROMPT = `You are a code change analyst for a personal budget web application.
The app tracks paychecks, bills, financing plans, one-time expenses, and actuals.
Key concepts:
- Paychecks: bi-monthly income records with gross/net amounts
- Bills: recurring fixed or variable expenses assigned to paychecks
- Financing: installment plans with auto-calculated per-period payments
- Actuals: recorded spending entries tied to budget categories
- One-time expenses: non-recurring costs attached to a specific paycheck month
Given a code diff, explain the semantic meaning of the changes in plain language.
Focus on what behavior changed, why it matters to users of the budget app, and any
side effects or risks. Be concise but thorough.`;
router.post('/semantic-diff', async (req, res) => {
const { diff, context } = req.body;
if (!diff || typeof diff !== 'string' || diff.trim().length === 0) {
return res.status(400).json({ error: 'diff is required and must be a non-empty string' });
}
if (Buffer.byteLength(diff, 'utf8') > MAX_DIFF_BYTES) {
return res.status(400).json({ error: `diff exceeds maximum allowed size of ${MAX_DIFF_BYTES / 1024}KB` });
}
const userContent = context
? `Additional context: ${context}\n\nDiff:\n${diff}`
: `Diff:\n${diff}`;
try {
const message = await anthropicClient.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userContent }],
});
const explanation = message.content[0].text;
return res.json({ explanation });
} catch (err) {
console.error('Anthropic API error:', err);
return res.status(502).json({ error: 'Failed to get explanation from AI service' });
}
});
module.exports = router;
module.exports.anthropicClient = anthropicClient;