diff --git a/__tests__/explore-allocation-1500.test.ts b/__tests__/explore-allocation-1500.test.ts new file mode 100644 index 0000000..4abb319 --- /dev/null +++ b/__tests__/explore-allocation-1500.test.ts @@ -0,0 +1,256 @@ +/** + * Regression fixture for GitHub issue #1500 / epic CG-1 — relevance-proportional + * explore budget allocation. + * + * The reporter's repo is a Go service whose GENERATED FKIT CRUD layer sits beside + * the hand-written use-case that does the real work. Asking an architecture + * question that doesn't name the exact use-case ("how does payroll cycle create + * and calculate payslips?") spends the explore envelope on the generated CRUD, + * because the generated layer name-collides on every term in the question while + * the hand-written workflow is one big file that gets clipped. + * + * `__tests__/fixtures/payroll-go/` reproduces that shape permanently. This suite + * is in two halves: + * + * 1. **Fixture shape** — green today. These pin the properties the fixture must + * keep for the gate below to mean anything: the generated/hand-written split + * (including the ordinary-named generated files only a CONTENT header betrays, + * which is the #1500 case), the deliberate name collisions, and the + * runPayrollCycleAll → BuildPayslip → Upsert chain resolving end-to-end. If + * the fixture rots, these fail first and say so. + * + * 2. **Budget allocation** — the gate, written with `it.fails` because it + * DOCUMENTS A BUG THAT IS STILL OPEN. Vitest passes an `it.fails` test only + * while its body throws, so the suite is green today and goes RED the moment + * allocation is fixed (CG-10 scoring + CG-12 proportional bytes). + * **When it goes red, delete the `.fails` — do not delete the test.** + * + * The same assertions run outside vitest, against the built dist and with the + * full CG-4 per-file diagnostic, via `node scripts/agent-eval/probe-allocation.mjs` + * (declared in `scripts/agent-eval/allocation-fixtures.json`). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; +import { attributeSourceBytes } from '../src/mcp/explore-diagnostics'; +import { isGeneratedFile, hasGeneratedHeader } from '../src/extraction/generated-detection'; + +const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go'); + +/** The question a newcomer asks — names none of the symbols that answer it. */ +const QUERY = 'how does payroll cycle create and calculate payslips?'; + +/** The hand-written workflow: what the query is actually about. */ +const ANSWER_PREFIXES = [ + 'internal/usecase/', + 'internal/store/', + 'internal/transport/', + 'internal/domain/', + 'cmd/', +]; +/** The generated CRUD/DTO layer: what wins the envelope today. */ +const GENERATED_PREFIX = 'internal/gen/'; + +const startsWithAny = (p: string, prefixes: string[]) => prefixes.some((x) => p.startsWith(x)); + +describe('#1500 — generated Go CRUD beside a hand-written payroll workflow', () => { + let testDir: string; + let cg: CodeGraph; + let handler: ToolHandler; + let response: string; + /** Delivered source bytes per file, attributed from the final response. */ + let bytes: Map; + + beforeAll(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1500-')); + fs.cpSync(FIXTURE_SRC, testDir, { recursive: true }); + // A stray index in the checked-in tree would be copied in and reused. + fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true }); + + cg = CodeGraph.initSync(testDir); + await cg.indexAll(); + handler = new ToolHandler(cg); + + const result = await handler.execute('codegraph_explore', { query: QUERY }); + response = result.content?.[0]?.text ?? ''; + bytes = attributeSourceBytes(response); + }, 120_000); + + afterAll(() => { + if (cg) cg.destroy(); + if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + // ── 1. Fixture shape ────────────────────────────────────────────────────── + + describe('fixture shape', () => { + it('indexes as a Go project with both layers present', () => { + const files = cg.getFiles().map((f) => f.path); + expect(files.filter((p) => p.endsWith('.go')).length).toBeGreaterThanOrEqual(15); + expect(files.some((p) => p.startsWith(GENERATED_PREFIX))).toBe(true); + expect(files.some((p) => p.startsWith('internal/usecase/'))).toBe(true); + }); + + it('flags every generated file and no hand-written one', () => { + for (const file of cg.getFiles()) { + expect(file.generated, `${file.path} generated flag`).toBe( + file.path.startsWith(GENERATED_PREFIX), + ); + } + }); + + it('carries generated files that ONLY a content header betrays — the #1500 case', () => { + // Half the generated tree has ordinary names (`payslip.go`, `store.go`). + // Path-only detection misses them; the CG-5 content check is what catches + // them. Without these the fixture would be a .pb.go fixture, not a #1500 one. + const contentOnly = [ + 'internal/gen/fkit/payroll/payslip.go', + 'internal/gen/fkit/payroll/payroll_cycle.go', + 'internal/gen/fkit/payroll/store.go', + 'internal/gen/fkit/payroll/calculate.go', + 'internal/gen/fkit/payroll/dto.go', + 'internal/gen/fkit/employee/employee.go', + 'internal/gen/fkit/timesheet/timesheet.go', + ]; + for (const rel of contentOnly) { + const source = fs.readFileSync(path.join(testDir, rel), 'utf-8'); + expect(isGeneratedFile(rel), `${rel} must NOT be detectable by path`).toBe(false); + expect(hasGeneratedHeader(source), `${rel} must be detectable by header`).toBe(true); + expect(cg.getFile(rel)?.generated, `${rel} indexed flag`).toBe(true); + } + // …beside the conventional path-detectable ones, so both channels are covered. + expect(isGeneratedFile('internal/gen/payrollpb/payroll.pb.go')).toBe(true); + }); + + it('collides the generated layer with the hand-written one by name', () => { + // A naive scorer sees two BuildPayslips and two Upserts and has no reason + // to prefer the one that implements the business rule. + for (const name of ['BuildPayslip', 'Upsert', 'Store']) { + const files = new Set(cg.getNodesByName(name).map((n) => n.filePath)); + expect([...files].some((p) => p.startsWith(GENERATED_PREFIX)), `${name} generated`).toBe(true); + expect([...files].some((p) => !p.startsWith(GENERATED_PREFIX)), `${name} hand-written`).toBe(true); + } + }); + + it('resolves the hand-written workflow chain end-to-end in the graph', () => { + const calleesOf = (name: string, file: string) => { + const node = cg.getNodesByName(name).find((n) => n.filePath === file); + expect(node, `${name} in ${file}`).toBeTruthy(); + return cg + .getOutgoingEdges(node!.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)) + .filter((n): n is NonNullable => !!n); + }; + + // handler → use-case + expect( + calleesOf('RunCycle', 'internal/transport/httpapi/payroll_handler.go') + .some((n) => n.name === 'RunCycle' && n.filePath === 'internal/usecase/payroll/cycle.go'), + ).toBe(true); + + // use-case → the workflow + expect( + calleesOf('RunCycle', 'internal/usecase/payroll/cycle.go') + .some((n) => n.name === 'runPayrollCycleAll'), + ).toBe(true); + + // the workflow → build + persist + const workflow = calleesOf('runPayrollCycleAll', 'internal/usecase/payroll/cycle.go'); + expect( + workflow.some((n) => n.name === 'BuildPayslip' && n.filePath === 'internal/usecase/payroll/payslip_builder.go'), + 'runPayrollCycleAll must reach the hand-written BuildPayslip', + ).toBe(true); + expect(workflow.some((n) => n.name === 'Upsert'), 'runPayrollCycleAll must reach an Upsert').toBe(true); + }); + + it('routes an HTTP entry point into the workflow', () => { + const router = cg.getNodesInFile('internal/transport/httpapi/router.go'); + expect(router.some((n) => n.kind === 'route' || n.name === 'NewRouter')).toBe(true); + }); + + it('sizes the two layers so the size-driven render split actually bites', () => { + // The mechanism the epic is about: a small file ships WHOLE, a large one + // falls through to clipped clusters. The workflow file must stay above the + // whole-file window and the generated files below it, or the fixture stops + // reproducing anything. + const lines = (rel: string) => fs.readFileSync(path.join(testDir, rel), 'utf-8').split('\n').length; + expect(lines('internal/usecase/payroll/cycle.go')).toBeGreaterThan(220); + for (const rel of ['internal/gen/fkit/payroll/payslip.go', 'internal/gen/fkit/payroll/payroll_cycle.go']) { + expect(lines(rel)).toBeLessThan(220); + } + }); + + it('answers the query at all', () => { + expect(response.length).toBeGreaterThan(1000); + expect(bytes.size).toBeGreaterThan(0); + }); + }); + + // ── 2. Budget allocation — the open bug ─────────────────────────────────── + + describe('budget allocation', () => { + const share = (predicate: (p: string) => boolean) => { + let total = 0; + for (const [file, n] of bytes) if (predicate(file)) total += n; + return total / response.length; + }; + const answerShare = () => share((p) => startsWithAny(p, ANSWER_PREFIXES)); + const generatedShare = () => share((p) => p.startsWith(GENERATED_PREFIX)); + + /** + * BASELINE 2026-08-03 (very-tiny tier, 13,000-char budget): 23,020 chars + * allocated against it, cut to 16,011 by the 19,500 hard ceiling. The + * generated CRUD delivers 57.4%; the hand-written layer delivers 25.6%, all + * of it domain types. `cycle.go` is allocated the single largest slice + * (7,052 chars, 30.6%) and delivers ZERO — the ceiling drops its whole + * section — so runPayrollCycleAll, the hand-written BuildPayslip and the + * real Upsert never reach the agent at all. + * + * Each `it.fails` below passes ONLY while that is still true. + * ⚠ When one goes red, the bug is fixed: remove `.fails`, keep the test. + */ + it.fails('CG-12 GATE: concentrates the envelope on the hand-written workflow', () => { + expect(answerShare()).toBeGreaterThanOrEqual(0.55); + }); + + it.fails('CG-12 GATE: does not spend the envelope on the generated CRUD', () => { + expect(generatedShare()).toBeLessThanOrEqual(0.25); + }); + + it.fails('CG-12 GATE: delivers the workflow file it allocated the most bytes to', () => { + expect(bytes.get('internal/usecase/payroll/cycle.go') ?? 0).toBeGreaterThan(0); + }); + + it.fails('CG-12 GATE: delivers the calculation the question asks about', () => { + expect(bytes.get('internal/usecase/payroll/payslip_builder.go') ?? 0).toBeGreaterThan(0); + }); + + it.fails('CG-12 GATE: puts the hand-written chain in the response, not its generated twin', () => { + // Bare `BuildPayslip`/`Upsert` also match the generated collisions — these + // needles are unique to the hand-written chain. + expect(response).toContain('runPayrollCycleAll'); + expect(response).toContain('func (s *Service) BuildPayslip'); + expect(response).toContain('s.store.Upsert(ctx, slip)'); + }); + + it('records the shape of the failure so a regression is legible', () => { + // Not a gate — an assertion-free-ish snapshot of WHY the gates above fail, + // so a future change that shifts the numbers shows up in the diff rather + // than silently flipping an it.fails. + const generated = generatedShare(); + const answer = answerShare(); + const workflow = bytes.get('internal/usecase/payroll/cycle.go') ?? 0; + expect({ + generatedWinsEnvelope: generated > answer, + workflowFileDeliversNothing: workflow === 0, + }).toEqual({ + generatedWinsEnvelope: true, + workflowFileDeliversNothing: true, + }); + }); + }); +}); diff --git a/__tests__/fixtures/payroll-go/README.md b/__tests__/fixtures/payroll-go/README.md new file mode 100644 index 0000000..a570bcc --- /dev/null +++ b/__tests__/fixtures/payroll-go/README.md @@ -0,0 +1,95 @@ +# payroll-go — the #1500 regression fixture + +A synthetic Go service reproducing the repo shape from [issue #1500](https://github.com/colbymchenry/codegraph/issues/1500): +**generated CRUD sitting beside the hand-written use-case that does the real work.** + +This tree is a fixture, not a program. It never compiles or runs — it exists to be +indexed. Keep it valid, idiomatic Go anyway: the extractor's output is the whole point. + +## The shape + +``` +cmd/payrolld/main.go wires the service +internal/transport/httpapi/ HTTP entry point → use-case +internal/usecase/payroll/ ← THE ANSWER. Hand-written workflow: + cycle.go runPayrollCycleAll (227 lines) + payslip_builder.go BuildPayslip — the actual pay calculation + prorate.go +internal/domain/payroll/payslip.go hand-written domain types +internal/store/payslipstore/store.go the real Upsert +internal/platform/clock/clock.go + +internal/gen/fkit/payroll/ ← THE NOISE. Generated CRUD, ORDINARY names: + payslip.go CreatePayslip, GetPayslip, UpdatePayslip, a second BuildPayslip + payroll_cycle.go CreatePayrollCycle, PayrollCycleCreateRequest, … + store.go a second Upsert + calculate.go CalculatePayrollCycleTotals, CalculatePayslipNet, … + dto.go +internal/gen/fkit/employee/, timesheet/ more generated CRUD +internal/gen/payrollpb/*.pb.go generated, detectable by PATH +``` + +The chain the fixture is built around is `runPayrollCycleAll` → `BuildPayslip` → `Upsert`, +entered from `POST /v1/payroll/cycles/{cycleID}/run`. + +## The three properties that make it a regression fixture + +1. **Generated files that only a CONTENT header betrays.** The `internal/gen/fkit/**` + files have ordinary names (`payslip.go`, `store.go`) and carry + `// Code generated by fkit v3.11.0. DO NOT EDIT.`. Path-only detection misses every + one of them — that is the #1500 case, and why CG-5 added the content check. The + `payrollpb/*.pb.go` files cover the path-detectable channel beside them. + +2. **Deliberate name collisions.** `BuildPayslip`, `Upsert` and `Store` each exist twice, + once generated and once hand-written. The generated layer also name-collides on every + term of the question below — `CreatePayslip`, `PayrollCycleCreateRequest`, + `CalculatePayrollCycleTotals` — so a scorer that rewards incidental name matches + surfaces the CRUD path. + +3. **A size split that drives the render mode.** `cycle.go` is deliberately over the + whole-file window (227 lines) so it falls through to clipped clusters; the generated + files are deliberately under it so they ship whole. Allocation follows file size, not + relevance. `__tests__/explore-allocation-1500.test.ts` pins both sides of that split — + if you edit these files, keep it. + +## The assertion + +Query: **"how does payroll cycle create and calculate payslips?"** — an architecture +question that names none of the symbols that answer it. The budget should concentrate on +the hand-written workflow. As of 2026-08-03 it does not: + +| | allocated | delivered | +|---|---|---| +| hand-written | 48.4% | **25.6%** (all of it domain types) | +| generated CRUD | 39.9% | **57.4%** | + +`cycle.go` is allocated the single largest slice (7,052 chars, 30.6%) and delivers +**zero** — the hard ceiling drops its whole section. `payslip_builder.go` (rank #8) never +renders at all. `runPayrollCycleAll`, the hand-written `BuildPayslip` and the real +`Upsert` never reach the agent. + +## Running it + +```bash +npm run build +node scripts/agent-eval/probe-allocation.mjs payroll-go # exits 1 today, by design +npx vitest run __tests__/explore-allocation-1500.test.ts # green today, by design +``` + +The probe reports per-file budget share against `scripts/agent-eval/allocation-fixtures.json`. +The vitest suite pins the fixture's shape and holds the allocation assertion as `it.fails` — +green while the bug is open, red the moment it is fixed. See +`docs/design/explore-budget-allocation.md`. + +## Known finding: the chain's `Upsert` edge resolves to the generated store + +`runPayrollCycleAll` calls `s.store.Upsert(ctx, slip)`, where `s.store` is a +`*payslipstore.Store`. The graph resolves that edge to `internal/gen/fkit/payroll/store.go` +— the **generated** `Store.Upsert` — not the hand-written one. Same-name method resolution +across two packages that both define `Store.Upsert` picks the wrong receiver. + +This is a resolution defect, not a budget one, and it is left unfixed on purpose: it is +upstream of the allocation bug (a wrong edge pulls the generated store into the subgraph +and inflates its score), so it belongs with the scoring work in CG-10 rather than here. +The test asserts only that the workflow reaches *an* `Upsert`, so tightening the resolver +later will not break the fixture. diff --git a/__tests__/fixtures/payroll-go/cmd/payrolld/main.go b/__tests__/fixtures/payroll-go/cmd/payrolld/main.go new file mode 100644 index 0000000..63ac937 --- /dev/null +++ b/__tests__/fixtures/payroll-go/cmd/payrolld/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "github.com/example/payroll-svc/internal/platform/clock" + "github.com/example/payroll-svc/internal/store/payslipstore" + "github.com/example/payroll-svc/internal/transport/httpapi" + "github.com/example/payroll-svc/internal/usecase/payroll" +) + +func main() { + addr := os.Getenv("LISTEN_ADDR") + if addr == "" { + addr = ":8080" + } + + store := payslipstore.New() + svc := payroll.NewService(store, clock.System{}) + router := httpapi.NewRouter(httpapi.NewPayrollHandler(svc)) + + srv := &http.Server{ + Addr: addr, + Handler: router, + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("payrolld listening on %s", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("payrolld: %v", err) + } +} diff --git a/__tests__/fixtures/payroll-go/go.mod b/__tests__/fixtures/payroll-go/go.mod new file mode 100644 index 0000000..9cedd1b --- /dev/null +++ b/__tests__/fixtures/payroll-go/go.mod @@ -0,0 +1,3 @@ +module github.com/example/payroll-svc + +go 1.22 diff --git a/__tests__/fixtures/payroll-go/internal/domain/payroll/payslip.go b/__tests__/fixtures/payroll-go/internal/domain/payroll/payslip.go new file mode 100644 index 0000000..d14f84d --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/domain/payroll/payslip.go @@ -0,0 +1,159 @@ +package payroll + +import "time" + +// CycleStatus is the lifecycle state of a payroll cycle. +type CycleStatus string + +const ( + CycleOpen CycleStatus = "open" + CycleClosed CycleStatus = "closed" +) + +// ContractKind distinguishes the two pay models this service supports. +type ContractKind string + +const ( + ContractSalaried ContractKind = "salaried" + ContractHourly ContractKind = "hourly" +) + +// LineKind separates the two halves of a payslip. +type LineKind string + +const ( + LineEarning LineKind = "earning" + LineDeduction LineKind = "deduction" +) + +// Cycle is one payroll period. +type Cycle struct { + ID string + Start time.Time + End time.Time + Status CycleStatus + ClosedAt time.Time + ReopenReason string +} + +// Line is a single earning or deduction on a payslip. +type Line struct { + Code string + Kind LineKind + AmountCents int64 +} + +// Payslip is what a cycle produces for one employee. +type Payslip struct { + CycleID string + EmployeeID string + Currency string + PeriodFrom time.Time + PeriodTo time.Time + Lines []Line + GrossCents int64 + DeductionCents int64 + NetCents int64 + Underwater bool + RunAt time.Time + RunReason string +} + +// Timesheet is the approved unit count backing an hourly payslip. +type Timesheet struct { + CycleID string + EmployeeID string + Approved bool + Units int +} + +// Allowance is a recurring earning attached to a contract. +type Allowance struct { + Code string + AmountCents int64 + Prorated bool +} + +// Contract holds the pay terms for one employee. +type Contract struct { + Kind ContractKind + Currency string + RateCents int64 + PeriodRateCents int64 + OvertimeThresholdUnits int + OvertimeMultiplier float64 + Allowances []Allowance + StartsOn time.Time + EndsOn time.Time +} + +// OverlapsWindow reports whether the contract is live at any point in the window. +func (c Contract) OverlapsWindow(from, to time.Time) bool { + if !c.StartsOn.IsZero() && c.StartsOn.After(to) { + return false + } + if !c.EndsOn.IsZero() && c.EndsOn.Before(from) { + return false + } + return true +} + +// PeriodUnits is the contractual unit count for a window, used when a salaried +// employee has no approved timesheet. +func (c Contract) PeriodUnits(from, to time.Time) int { + if to.Before(from) { + return 0 + } + days := int(to.Sub(from).Hours()/24) + 1 + return days * 8 +} + +// Deduction is a fixed or proportional subtraction from gross. +type Deduction struct { + Code string + FixedCents int64 + RateBasisPoints int +} + +// AmountFor resolves a deduction against a gross amount. +func (d Deduction) AmountFor(grossCents int64) int64 { + if d.FixedCents > 0 { + return d.FixedCents + } + return grossCents * int64(d.RateBasisPoints) / 10000 +} + +// TaxBand is one slice of a progressive tax schedule. +type TaxBand struct { + UpToCents int64 + RateBasisPoints int +} + +// Leave is an absence window. +type Leave struct { + From time.Time + To time.Time + Unpaid bool +} + +// Employee is the payroll view of a person. +type Employee struct { + ID string + Contract Contract + Deductions []Deduction + TaxBands []TaxBand + Leave []Leave +} + +// UnpaidLeaveCoversWindow reports whether unpaid leave swallows the whole window. +func (e Employee) UnpaidLeaveCoversWindow(from, to time.Time) bool { + for _, l := range e.Leave { + if !l.Unpaid { + continue + } + if !l.From.After(from) && !l.To.Before(to) { + return true + } + } + return false +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/employee/contract.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/employee/contract.go new file mode 100644 index 0000000..4ced896 --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/employee/contract.go @@ -0,0 +1,84 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/employee/contract.fkit +// Regenerate with: go run ./tools/fkitgen ./schema/employee + +package employee + +import ( + "context" + "database/sql" + "time" +) + +// ContractRow is the generated row type for table contract. +type ContractRow struct { + ID string + EmployeeID string + Kind string + Currency string + RateCents int64 + PeriodRateCents int64 + StartsOn time.Time + EndsOn time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// ContractCreateRequest is the generated create payload for table contract. +type ContractCreateRequest struct { + EmployeeID string `json:"employeeId"` + Kind string `json:"kind"` + Currency string `json:"currency"` + RateCents int64 `json:"rateCents"` + PeriodRateCents int64 `json:"periodRateCents"` + StartsOn time.Time `json:"startsOn"` +} + +// CreateContract inserts one contract row. +func CreateContract(ctx context.Context, db *sql.DB, req ContractCreateRequest) (ContractRow, error) { + const q = `INSERT INTO contract (employee_id, kind, currency, rate_cents, period_rate_cents, starts_on) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *` + return scanContract(db.QueryRowContext(ctx, q, req.EmployeeID, req.Kind, req.Currency, + req.RateCents, req.PeriodRateCents, req.StartsOn)) +} + +// GetContract selects one contract row by primary key. +func GetContract(ctx context.Context, db *sql.DB, id string) (ContractRow, error) { + const q = `SELECT * FROM contract WHERE id = $1` + return scanContract(db.QueryRowContext(ctx, q, id)) +} + +// DeleteContract removes one contract row. +func DeleteContract(ctx context.Context, db *sql.DB, id string) error { + const q = `DELETE FROM contract WHERE id = $1` + _, err := db.ExecContext(ctx, q, id) + return err +} + +// ListContractsForEmployee selects contract rows for one employee. +func ListContractsForEmployee(ctx context.Context, db *sql.DB, employeeID string) ([]ContractRow, error) { + const q = `SELECT * FROM contract WHERE employee_id = $1 ORDER BY starts_on DESC` + rows, err := db.QueryContext(ctx, q, employeeID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ContractRow + for rows.Next() { + var r ContractRow + if err := rows.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents, + &r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func scanContract(row *sql.Row) (ContractRow, error) { + var r ContractRow + err := row.Scan(&r.ID, &r.EmployeeID, &r.Kind, &r.Currency, &r.RateCents, + &r.PeriodRateCents, &r.StartsOn, &r.EndsOn, &r.CreatedAt, &r.UpdatedAt) + return r, err +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/employee/employee.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/employee/employee.go new file mode 100644 index 0000000..fa65726 --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/employee/employee.go @@ -0,0 +1,88 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/employee/employee.fkit +// Regenerate with: go run ./tools/fkitgen ./schema/employee + +package employee + +import ( + "context" + "database/sql" + "time" +) + +// EmployeeRow is the generated row type for table employee. +type EmployeeRow struct { + ID string + Email string + FullName string + Status string + HiredOn time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// EmployeeCreateRequest is the generated create payload for table employee. +type EmployeeCreateRequest struct { + Email string `json:"email"` + FullName string `json:"fullName"` + Status string `json:"status"` +} + +// EmployeeUpdateRequest is the generated update payload for table employee. +type EmployeeUpdateRequest struct { + FullName *string `json:"fullName,omitempty"` + Status *string `json:"status,omitempty"` +} + +// CreateEmployee inserts one employee row. +func CreateEmployee(ctx context.Context, db *sql.DB, req EmployeeCreateRequest) (EmployeeRow, error) { + const q = `INSERT INTO employee (email, full_name, status) VALUES ($1, $2, $3) RETURNING *` + return scanEmployee(db.QueryRowContext(ctx, q, req.Email, req.FullName, req.Status)) +} + +// GetEmployee selects one employee row by primary key. +func GetEmployee(ctx context.Context, db *sql.DB, id string) (EmployeeRow, error) { + const q = `SELECT * FROM employee WHERE id = $1` + return scanEmployee(db.QueryRowContext(ctx, q, id)) +} + +// UpdateEmployee patches one employee row. +func UpdateEmployee(ctx context.Context, db *sql.DB, id string, req EmployeeUpdateRequest) (EmployeeRow, error) { + const q = `UPDATE employee SET full_name = COALESCE($2, full_name), status = COALESCE($3, status), + updated_at = now() WHERE id = $1 RETURNING *` + return scanEmployee(db.QueryRowContext(ctx, q, id, req.FullName, req.Status)) +} + +// DeleteEmployee removes one employee row. +func DeleteEmployee(ctx context.Context, db *sql.DB, id string) error { + const q = `DELETE FROM employee WHERE id = $1` + _, err := db.ExecContext(ctx, q, id) + return err +} + +// ListEmployeesByStatus selects employee rows in one status. +func ListEmployeesByStatus(ctx context.Context, db *sql.DB, status string) ([]EmployeeRow, error) { + const q = `SELECT * FROM employee WHERE status = $1 ORDER BY full_name` + rows, err := db.QueryContext(ctx, q, status) + if err != nil { + return nil, err + } + defer rows.Close() + var out []EmployeeRow + for rows.Next() { + var r EmployeeRow + if err := rows.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func scanEmployee(row *sql.Row) (EmployeeRow, error) { + var r EmployeeRow + err := row.Scan(&r.ID, &r.Email, &r.FullName, &r.Status, &r.HiredOn, &r.CreatedAt, &r.UpdatedAt) + return r, err +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/calculate.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/calculate.go new file mode 100644 index 0000000..c005ebe --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/calculate.go @@ -0,0 +1,60 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/payroll/aggregates.fkit +// Regenerate with: go run ./tools/fkitgen ./schema/payroll + +package payroll + +import ( + "context" + "database/sql" +) + +// PayrollCycleTotals is the generated aggregate row for a payroll cycle. +type PayrollCycleTotals struct { + CycleID string + Payslips int64 + GrossCents int64 + DeductionCents int64 + NetCents int64 +} + +// CalculatePayrollCycleTotals runs the generated SUM aggregate over the +// payslip rows of one cycle. It totals what is already stored; it does not +// calculate any payslip. +func CalculatePayrollCycleTotals(ctx context.Context, db *sql.DB, cycleID string) (PayrollCycleTotals, error) { + const q = `SELECT count(*), COALESCE(sum(gross_cents), 0), COALESCE(sum(deduction_cents), 0), + COALESCE(sum(net_cents), 0) + FROM payslip WHERE cycle_id = $1` + var t PayrollCycleTotals + t.CycleID = cycleID + err := db.QueryRowContext(ctx, q, cycleID).Scan(&t.Payslips, &t.GrossCents, &t.DeductionCents, &t.NetCents) + return t, err +} + +// CalculatePayslipNet recomputes net from the stored gross and deduction +// columns of one row. Pure column arithmetic — no pay rules. +func CalculatePayslipNet(row PayslipRow) int64 { + return row.GrossCents - row.DeductionCents +} + +// CalculatePayrollCycleAverage averages the stored net over a cycle. +func CalculatePayrollCycleAverage(ctx context.Context, db *sql.DB, cycleID string) (int64, error) { + totals, err := CalculatePayrollCycleTotals(ctx, db, cycleID) + if err != nil { + return 0, err + } + if totals.Payslips == 0 { + return 0, nil + } + return totals.NetCents / totals.Payslips, nil +} + +// CalculateEmployeeYearToDate sums an employee's stored payslips for a year. +func CalculateEmployeeYearToDate(ctx context.Context, db *sql.DB, employeeID string, year int) (int64, error) { + const q = `SELECT COALESCE(sum(net_cents), 0) FROM payslip + WHERE employee_id = $1 AND extract(year from period_from) = $2` + var n int64 + err := db.QueryRowContext(ctx, q, employeeID, year).Scan(&n) + return n, err +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/dto.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/dto.go new file mode 100644 index 0000000..78251c3 --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/dto.go @@ -0,0 +1,80 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/payroll +// Regenerate with: go run ./tools/fkitgen ./schema/payroll + +package payroll + +import "time" + +// PayslipDTO is the generated wire representation of a payslip row. +type PayslipDTO struct { + ID string `json:"id"` + CycleID string `json:"cycleId"` + EmployeeID string `json:"employeeId"` + Currency string `json:"currency"` + PeriodFrom time.Time `json:"periodFrom"` + PeriodTo time.Time `json:"periodTo"` + GrossCents int64 `json:"grossCents"` + DeductionCents int64 `json:"deductionCents"` + NetCents int64 `json:"netCents"` +} + +// PayrollCycleDTO is the generated wire representation of a payroll_cycle row. +type PayrollCycleDTO struct { + ID string `json:"id"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Status string `json:"status"` +} + +// PayslipListDTO is the generated list envelope for payslip rows. +type PayslipListDTO struct { + Items []PayslipDTO `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` + Total int64 `json:"total"` +} + +// PayslipToDTO converts a payslip row to its wire form. +func PayslipToDTO(r PayslipRow) PayslipDTO { + return PayslipDTO{ + ID: r.ID, + CycleID: r.CycleID, + EmployeeID: r.EmployeeID, + Currency: r.Currency, + PeriodFrom: r.PeriodFrom, + PeriodTo: r.PeriodTo, + GrossCents: r.GrossCents, + DeductionCents: r.DeductionCents, + NetCents: r.NetCents, + } +} + +// PayslipFromDTO converts a wire payslip back to a row. +func PayslipFromDTO(d PayslipDTO) PayslipRow { + return PayslipRow{ + ID: d.ID, + CycleID: d.CycleID, + EmployeeID: d.EmployeeID, + Currency: d.Currency, + PeriodFrom: d.PeriodFrom, + PeriodTo: d.PeriodTo, + GrossCents: d.GrossCents, + DeductionCents: d.DeductionCents, + NetCents: d.NetCents, + } +} + +// PayrollCycleToDTO converts a payroll_cycle row to its wire form. +func PayrollCycleToDTO(r PayrollCycleRow) PayrollCycleDTO { + return PayrollCycleDTO{ID: r.ID, Start: r.Start, End: r.End, Status: r.Status} +} + +// PayslipsToListDTO wraps payslip rows in the generated list envelope. +func PayslipsToListDTO(rows []PayslipRow, total int64) PayslipListDTO { + items := make([]PayslipDTO, 0, len(rows)) + for _, r := range rows { + items = append(items, PayslipToDTO(r)) + } + return PayslipListDTO{Items: items, Total: total} +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payroll_cycle.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payroll_cycle.go new file mode 100644 index 0000000..d565dbd --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payroll_cycle.go @@ -0,0 +1,116 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/payroll/payroll_cycle.fkit +// Regenerate with: go run ./tools/fkitgen ./schema/payroll + +package payroll + +import ( + "context" + "database/sql" + "time" +) + +// PayrollCycleRow is the generated row type for table payroll_cycle. +type PayrollCycleRow struct { + ID string + Start time.Time + End time.Time + Status string + ClosedAt time.Time + ReopenReason string + CreatedAt time.Time + UpdatedAt time.Time +} + +// PayrollCycleCreateRequest is the generated create payload for payroll_cycle. +type PayrollCycleCreateRequest struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + Status string `json:"status"` +} + +// PayrollCycleUpdateRequest is the generated update payload for payroll_cycle. +type PayrollCycleUpdateRequest struct { + Status *string `json:"status,omitempty"` + ReopenReason *string `json:"reopenReason,omitempty"` +} + +// CreatePayrollCycle inserts one payroll_cycle row. +func CreatePayrollCycle(ctx context.Context, db *sql.DB, req PayrollCycleCreateRequest) (PayrollCycleRow, error) { + const q = `INSERT INTO payroll_cycle (start_on, end_on, status) VALUES ($1, $2, $3) RETURNING *` + return scanPayrollCycle(db.QueryRowContext(ctx, q, req.Start, req.End, req.Status)) +} + +// GetPayrollCycle selects one payroll_cycle row by primary key. +func GetPayrollCycle(ctx context.Context, db *sql.DB, id string) (PayrollCycleRow, error) { + const q = `SELECT * FROM payroll_cycle WHERE id = $1` + return scanPayrollCycle(db.QueryRowContext(ctx, q, id)) +} + +// UpdatePayrollCycle patches one payroll_cycle row. +func UpdatePayrollCycle(ctx context.Context, db *sql.DB, id string, req PayrollCycleUpdateRequest) (PayrollCycleRow, error) { + const q = `UPDATE payroll_cycle SET status = COALESCE($2, status), + reopen_reason = COALESCE($3, reopen_reason), updated_at = now() + WHERE id = $1 RETURNING *` + return scanPayrollCycle(db.QueryRowContext(ctx, q, id, req.Status, req.ReopenReason)) +} + +// DeletePayrollCycle removes one payroll_cycle row. +func DeletePayrollCycle(ctx context.Context, db *sql.DB, id string) error { + const q = `DELETE FROM payroll_cycle WHERE id = $1` + _, err := db.ExecContext(ctx, q, id) + return err +} + +// ListPayrollCycles selects every payroll_cycle row. +func ListPayrollCycles(ctx context.Context, db *sql.DB) ([]PayrollCycleRow, error) { + const q = `SELECT * FROM payroll_cycle ORDER BY start_on DESC` + rows, err := db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + var out []PayrollCycleRow + for rows.Next() { + var r PayrollCycleRow + if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// ListPayrollCyclesByStatus selects payroll_cycle rows in one status. +func ListPayrollCyclesByStatus(ctx context.Context, db *sql.DB, status string) ([]PayrollCycleRow, error) { + const q = `SELECT * FROM payroll_cycle WHERE status = $1 ORDER BY start_on DESC` + rows, err := db.QueryContext(ctx, q, status) + if err != nil { + return nil, err + } + defer rows.Close() + var out []PayrollCycleRow + for rows.Next() { + var r PayrollCycleRow + if err := rows.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// BuildPayrollCycle maps a create request onto a row. +func BuildPayrollCycle(req PayrollCycleCreateRequest) PayrollCycleRow { + return PayrollCycleRow{Start: req.Start, End: req.End, Status: req.Status} +} + +func scanPayrollCycle(row *sql.Row) (PayrollCycleRow, error) { + var r PayrollCycleRow + err := row.Scan(&r.ID, &r.Start, &r.End, &r.Status, &r.ClosedAt, &r.ReopenReason, + &r.CreatedAt, &r.UpdatedAt) + return r, err +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payslip.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payslip.go new file mode 100644 index 0000000..7e634af --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/payslip.go @@ -0,0 +1,129 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/payroll/payslip.fkit +// Regenerate with: go run ./tools/fkitgen ./schema/payroll + +package payroll + +import ( + "context" + "database/sql" + "time" +) + +// PayslipRow is the generated row type for table payslip. +type PayslipRow struct { + ID string + CycleID string + EmployeeID string + Currency string + PeriodFrom time.Time + PeriodTo time.Time + GrossCents int64 + DeductionCents int64 + NetCents int64 + Underwater bool + RunAt time.Time + RunReason string + CreatedAt time.Time + UpdatedAt time.Time +} + +// PayslipCreateRequest is the generated create payload for table payslip. +type PayslipCreateRequest struct { + CycleID string `json:"cycleId"` + EmployeeID string `json:"employeeId"` + Currency string `json:"currency"` + GrossCents int64 `json:"grossCents"` + DeductionCents int64 `json:"deductionCents"` + NetCents int64 `json:"netCents"` +} + +// PayslipUpdateRequest is the generated update payload for table payslip. +type PayslipUpdateRequest struct { + GrossCents *int64 `json:"grossCents,omitempty"` + DeductionCents *int64 `json:"deductionCents,omitempty"` + NetCents *int64 `json:"netCents,omitempty"` + RunReason *string `json:"runReason,omitempty"` +} + +// CreatePayslip inserts one payslip row. +func CreatePayslip(ctx context.Context, db *sql.DB, req PayslipCreateRequest) (PayslipRow, error) { + const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *` + row := db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Currency, req.GrossCents, req.DeductionCents, req.NetCents) + return scanPayslip(row) +} + +// GetPayslip selects one payslip row by primary key. +func GetPayslip(ctx context.Context, db *sql.DB, id string) (PayslipRow, error) { + const q = `SELECT * FROM payslip WHERE id = $1` + return scanPayslip(db.QueryRowContext(ctx, q, id)) +} + +// UpdatePayslip patches one payslip row. +func UpdatePayslip(ctx context.Context, db *sql.DB, id string, req PayslipUpdateRequest) (PayslipRow, error) { + const q = `UPDATE payslip SET gross_cents = COALESCE($2, gross_cents), + deduction_cents = COALESCE($3, deduction_cents), + net_cents = COALESCE($4, net_cents), + run_reason = COALESCE($5, run_reason), + updated_at = now() WHERE id = $1 RETURNING *` + return scanPayslip(db.QueryRowContext(ctx, q, id, req.GrossCents, req.DeductionCents, req.NetCents, req.RunReason)) +} + +// DeletePayslip removes one payslip row. +func DeletePayslip(ctx context.Context, db *sql.DB, id string) error { + const q = `DELETE FROM payslip WHERE id = $1` + _, err := db.ExecContext(ctx, q, id) + return err +} + +// ListPayslipsByCycle selects every payslip row for a cycle. +func ListPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]PayslipRow, error) { + const q = `SELECT * FROM payslip WHERE cycle_id = $1 ORDER BY employee_id` + rows, err := db.QueryContext(ctx, q, cycleID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []PayslipRow + for rows.Next() { + var r PayslipRow + if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo, + &r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// CountPayslipsByCycle counts payslip rows for a cycle. +func CountPayslipsByCycle(ctx context.Context, db *sql.DB, cycleID string) (int64, error) { + const q = `SELECT count(*) FROM payslip WHERE cycle_id = $1` + var n int64 + err := db.QueryRowContext(ctx, q, cycleID).Scan(&n) + return n, err +} + +// BuildPayslip maps a create request onto a row. Field copy only — the +// generator has no knowledge of pay rules. +func BuildPayslip(req PayslipCreateRequest) PayslipRow { + return PayslipRow{ + CycleID: req.CycleID, + EmployeeID: req.EmployeeID, + Currency: req.Currency, + GrossCents: req.GrossCents, + DeductionCents: req.DeductionCents, + NetCents: req.NetCents, + } +} + +func scanPayslip(row *sql.Row) (PayslipRow, error) { + var r PayslipRow + err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Currency, &r.PeriodFrom, &r.PeriodTo, + &r.GrossCents, &r.DeductionCents, &r.NetCents, &r.Underwater, &r.RunAt, &r.RunReason, + &r.CreatedAt, &r.UpdatedAt) + return r, err +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/store.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/store.go new file mode 100644 index 0000000..c063a5d --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/payroll/store.go @@ -0,0 +1,95 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/payroll +// Regenerate with: go run ./tools/fkitgen ./schema/payroll + +package payroll + +import ( + "context" + "database/sql" +) + +// Store is the generated repository over every payroll table. +type Store struct { + db *sql.DB +} + +// NewStore returns a generated store bound to db. +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// Upsert writes one payslip row, keyed by (cycle_id, employee_id). +func (s *Store) Upsert(ctx context.Context, row PayslipRow) (PayslipRow, error) { + const q = `INSERT INTO payslip (cycle_id, employee_id, currency, gross_cents, deduction_cents, net_cents) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (cycle_id, employee_id) DO UPDATE SET + gross_cents = EXCLUDED.gross_cents, + deduction_cents = EXCLUDED.deduction_cents, + net_cents = EXCLUDED.net_cents, + updated_at = now() + RETURNING *` + return scanPayslip(s.db.QueryRowContext(ctx, q, row.CycleID, row.EmployeeID, row.Currency, + row.GrossCents, row.DeductionCents, row.NetCents)) +} + +// UpsertPayrollCycle writes one payroll_cycle row, keyed by id. +func (s *Store) UpsertPayrollCycle(ctx context.Context, row PayrollCycleRow) (PayrollCycleRow, error) { + const q = `INSERT INTO payroll_cycle (id, start_on, end_on, status) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, updated_at = now() + RETURNING *` + return scanPayrollCycle(s.db.QueryRowContext(ctx, q, row.ID, row.Start, row.End, row.Status)) +} + +// CreatePayslip inserts one payslip row through the store. +func (s *Store) CreatePayslip(ctx context.Context, req PayslipCreateRequest) (PayslipRow, error) { + return CreatePayslip(ctx, s.db, req) +} + +// GetPayslip reads one payslip row through the store. +func (s *Store) GetPayslip(ctx context.Context, id string) (PayslipRow, error) { + return GetPayslip(ctx, s.db, id) +} + +// UpdatePayslip patches one payslip row through the store. +func (s *Store) UpdatePayslip(ctx context.Context, id string, req PayslipUpdateRequest) (PayslipRow, error) { + return UpdatePayslip(ctx, s.db, id, req) +} + +// DeletePayslip removes one payslip row through the store. +func (s *Store) DeletePayslip(ctx context.Context, id string) error { + return DeletePayslip(ctx, s.db, id) +} + +// ListPayslipsByCycle lists payslip rows for a cycle through the store. +func (s *Store) ListPayslipsByCycle(ctx context.Context, cycleID string) ([]PayslipRow, error) { + return ListPayslipsByCycle(ctx, s.db, cycleID) +} + +// CreatePayrollCycle inserts one payroll_cycle row through the store. +func (s *Store) CreatePayrollCycle(ctx context.Context, req PayrollCycleCreateRequest) (PayrollCycleRow, error) { + return CreatePayrollCycle(ctx, s.db, req) +} + +// GetPayrollCycle reads one payroll_cycle row through the store. +func (s *Store) GetPayrollCycle(ctx context.Context, id string) (PayrollCycleRow, error) { + return GetPayrollCycle(ctx, s.db, id) +} + +// ListPayrollCycles lists payroll_cycle rows through the store. +func (s *Store) ListPayrollCycles(ctx context.Context) ([]PayrollCycleRow, error) { + return ListPayrollCycles(ctx, s.db) +} + +// Tx runs fn inside a transaction. +func (s *Store) Tx(ctx context.Context, fn func(*Store) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := fn(s); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/fkit/timesheet/timesheet.go b/__tests__/fixtures/payroll-go/internal/gen/fkit/timesheet/timesheet.go new file mode 100644 index 0000000..ae02b18 --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/fkit/timesheet/timesheet.go @@ -0,0 +1,76 @@ +// Code generated by fkit v3.11.0. DO NOT EDIT. +// +// Source: schema/timesheet/timesheet.fkit +// Regenerate with: go run ./tools/fkitgen ./schema/timesheet + +package timesheet + +import ( + "context" + "database/sql" + "time" +) + +// TimesheetRow is the generated row type for table timesheet. +type TimesheetRow struct { + ID string + CycleID string + EmployeeID string + Units int + Approved bool + ApprovedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// TimesheetCreateRequest is the generated create payload for table timesheet. +type TimesheetCreateRequest struct { + CycleID string `json:"cycleId"` + EmployeeID string `json:"employeeId"` + Units int `json:"units"` +} + +// CreateTimesheet inserts one timesheet row. +func CreateTimesheet(ctx context.Context, db *sql.DB, req TimesheetCreateRequest) (TimesheetRow, error) { + const q = `INSERT INTO timesheet (cycle_id, employee_id, units) VALUES ($1, $2, $3) RETURNING *` + return scanTimesheet(db.QueryRowContext(ctx, q, req.CycleID, req.EmployeeID, req.Units)) +} + +// GetTimesheet selects one timesheet row by primary key. +func GetTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) { + const q = `SELECT * FROM timesheet WHERE id = $1` + return scanTimesheet(db.QueryRowContext(ctx, q, id)) +} + +// ApproveTimesheet flips the approved column on one timesheet row. +func ApproveTimesheet(ctx context.Context, db *sql.DB, id string) (TimesheetRow, error) { + const q = `UPDATE timesheet SET approved = true, approved_at = now() WHERE id = $1 RETURNING *` + return scanTimesheet(db.QueryRowContext(ctx, q, id)) +} + +// ListTimesheetsByCycle selects timesheet rows for one cycle. +func ListTimesheetsByCycle(ctx context.Context, db *sql.DB, cycleID string) ([]TimesheetRow, error) { + const q = `SELECT * FROM timesheet WHERE cycle_id = $1 ORDER BY employee_id` + rows, err := db.QueryContext(ctx, q, cycleID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []TimesheetRow + for rows.Next() { + var r TimesheetRow + if err := rows.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved, + &r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func scanTimesheet(row *sql.Row) (TimesheetRow, error) { + var r TimesheetRow + err := row.Scan(&r.ID, &r.CycleID, &r.EmployeeID, &r.Units, &r.Approved, + &r.ApprovedAt, &r.CreatedAt, &r.UpdatedAt) + return r, err +} diff --git a/__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll.pb.go b/__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll.pb.go new file mode 100644 index 0000000..be849cc --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll.pb.go @@ -0,0 +1,139 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v5.27.1 +// source: payroll/v1/payroll.proto + +package payrollpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +// RunPayrollCycleRequest is the generated request message. +type RunPayrollCycleRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CycleId string `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"` + DryRun bool `protobuf:"varint,2,opt,name=dry_run,json=dryRun,proto3" json:"dry_run,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *RunPayrollCycleRequest) GetCycleId() string { + if x != nil { + return x.CycleId + } + return "" +} + +func (x *RunPayrollCycleRequest) GetDryRun() bool { + if x != nil { + return x.DryRun + } + return false +} + +func (x *RunPayrollCycleRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *RunPayrollCycleRequest) Reset() { *x = RunPayrollCycleRequest{} } +func (x *RunPayrollCycleRequest) String() string { return protoimpl.X.MessageStringOf(x) } + +// RunPayrollCycleResponse is the generated response message. +type RunPayrollCycleResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CycleId string `protobuf:"bytes,1,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"` + Payslips []*Payslip `protobuf:"bytes,2,rep,name=payslips,proto3" json:"payslips,omitempty"` + GrossCents int64 `protobuf:"varint,3,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"` + NetCents int64 `protobuf:"varint,4,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"` +} + +func (x *RunPayrollCycleResponse) GetPayslips() []*Payslip { + if x != nil { + return x.Payslips + } + return nil +} + +func (x *RunPayrollCycleResponse) Reset() { *x = RunPayrollCycleResponse{} } +func (x *RunPayrollCycleResponse) String() string { return protoimpl.X.MessageStringOf(x) } + +// Payslip is the generated payslip message. +type Payslip struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + CycleId string `protobuf:"bytes,2,opt,name=cycle_id,json=cycleId,proto3" json:"cycle_id,omitempty"` + EmployeeId string `protobuf:"bytes,3,opt,name=employee_id,json=employeeId,proto3" json:"employee_id,omitempty"` + GrossCents int64 `protobuf:"varint,4,opt,name=gross_cents,json=grossCents,proto3" json:"gross_cents,omitempty"` + DeductionCents int64 `protobuf:"varint,5,opt,name=deduction_cents,json=deductionCents,proto3" json:"deduction_cents,omitempty"` + NetCents int64 `protobuf:"varint,6,opt,name=net_cents,json=netCents,proto3" json:"net_cents,omitempty"` + PeriodFrom *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=period_from,json=periodFrom,proto3" json:"period_from,omitempty"` + PeriodTo *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=period_to,json=periodTo,proto3" json:"period_to,omitempty"` +} + +func (x *Payslip) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Payslip) GetNetCents() int64 { + if x != nil { + return x.NetCents + } + return 0 +} + +func (x *Payslip) Reset() { *x = Payslip{} } +func (x *Payslip) String() string { return protoimpl.X.MessageStringOf(x) } + +// PayrollCycle is the generated cycle message. +type PayrollCycle struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Start *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=start,proto3" json:"start,omitempty"` + End *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=end,proto3" json:"end,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` +} + +func (x *PayrollCycle) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PayrollCycle) Reset() { *x = PayrollCycle{} } +func (x *PayrollCycle) String() string { return protoimpl.X.MessageStringOf(x) } + +var file_payroll_v1_payroll_proto_rawDesc = []byte{ + 0x0a, 0x18, 0x70, 0x61, 0x79, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x61, 0x79, + 0x72, 0x6f, 0x6c, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x70, 0x61, 0x79, 0x72, +} + +var file_payroll_v1_payroll_proto_goTypes = []any{ + (*RunPayrollCycleRequest)(nil), + (*RunPayrollCycleResponse)(nil), + (*Payslip)(nil), + (*PayrollCycle)(nil), +} + +var File_payroll_v1_payroll_proto protoreflect.FileDescriptor diff --git a/__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll_grpc.pb.go b/__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll_grpc.pb.go new file mode 100644 index 0000000..19aeaee --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/gen/payrollpb/payroll_grpc.pb.go @@ -0,0 +1,104 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.4.0 +// - protoc v5.27.1 +// source: payroll/v1/payroll.proto + +package payrollpb + +import ( + context "context" + + grpc "google.golang.org/grpc" +) + +const ( + PayrollService_RunPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/RunPayrollCycle" + PayrollService_GetPayrollCycle_FullMethodName = "/payroll.v1.PayrollService/GetPayrollCycle" + PayrollService_ListPayslips_FullMethodName = "/payroll.v1.PayrollService/ListPayslips" +) + +// PayrollServiceClient is the generated client API for PayrollService. +type PayrollServiceClient interface { + RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) + GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error) + ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) +} + +type payrollServiceClient struct { + cc grpc.ClientConnInterface +} + +// NewPayrollServiceClient returns a generated client. +func NewPayrollServiceClient(cc grpc.ClientConnInterface) PayrollServiceClient { + return &payrollServiceClient{cc} +} + +func (c *payrollServiceClient) RunPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) { + out := new(RunPayrollCycleResponse) + err := c.cc.Invoke(ctx, PayrollService_RunPayrollCycle_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *payrollServiceClient) GetPayrollCycle(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*PayrollCycle, error) { + out := new(PayrollCycle) + err := c.cc.Invoke(ctx, PayrollService_GetPayrollCycle_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *payrollServiceClient) ListPayslips(ctx context.Context, in *RunPayrollCycleRequest, opts ...grpc.CallOption) (*RunPayrollCycleResponse, error) { + out := new(RunPayrollCycleResponse) + err := c.cc.Invoke(ctx, PayrollService_ListPayslips_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PayrollServiceServer is the generated server API for PayrollService. +type PayrollServiceServer interface { + RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) + GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error) + ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) + mustEmbedUnimplementedPayrollServiceServer() +} + +// UnimplementedPayrollServiceServer must be embedded for forward compatibility. +type UnimplementedPayrollServiceServer struct{} + +func (UnimplementedPayrollServiceServer) RunPayrollCycle(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) { + return nil, nil +} + +func (UnimplementedPayrollServiceServer) GetPayrollCycle(context.Context, *RunPayrollCycleRequest) (*PayrollCycle, error) { + return nil, nil +} + +func (UnimplementedPayrollServiceServer) ListPayslips(context.Context, *RunPayrollCycleRequest) (*RunPayrollCycleResponse, error) { + return nil, nil +} + +func (UnimplementedPayrollServiceServer) mustEmbedUnimplementedPayrollServiceServer() {} + +// RegisterPayrollServiceServer registers the generated service. +func RegisterPayrollServiceServer(s grpc.ServiceRegistrar, srv PayrollServiceServer) { + s.RegisterService(&PayrollService_ServiceDesc, srv) +} + +// PayrollService_ServiceDesc is the generated service descriptor. +var PayrollService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "payroll.v1.PayrollService", + HandlerType: (*PayrollServiceServer)(nil), + Methods: []grpc.MethodDesc{ + {MethodName: "RunPayrollCycle"}, + {MethodName: "GetPayrollCycle"}, + {MethodName: "ListPayslips"}, + }, + Metadata: "payroll/v1/payroll.proto", +} diff --git a/__tests__/fixtures/payroll-go/internal/platform/clock/clock.go b/__tests__/fixtures/payroll-go/internal/platform/clock/clock.go new file mode 100644 index 0000000..39fc919 --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/platform/clock/clock.go @@ -0,0 +1,18 @@ +package clock + +import "time" + +// Clock is the time seam so a payroll run is reproducible in tests. +type Clock interface { + Now() time.Time +} + +// System is the production clock. +type System struct{} + +func (System) Now() time.Time { return time.Now().UTC() } + +// Fixed is a frozen clock. +type Fixed struct{ At time.Time } + +func (f Fixed) Now() time.Time { return f.At } diff --git a/__tests__/fixtures/payroll-go/internal/store/payslipstore/store.go b/__tests__/fixtures/payroll-go/internal/store/payslipstore/store.go new file mode 100644 index 0000000..8c145cd --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/store/payslipstore/store.go @@ -0,0 +1,119 @@ +package payslipstore + +import ( + "context" + "fmt" + "sync" + + "github.com/example/payroll-svc/internal/domain/payroll" +) + +// Store is the hand-written persistence seam the use-case layer writes through. +// It is deliberately narrow: the generated fkit store can address every table, +// this one only exposes the operations a payroll cycle needs. +type Store struct { + mu sync.RWMutex + payslips map[string]payroll.Payslip + cycles map[string]payroll.Cycle + employees map[string][]payroll.Employee + sheets map[string]payroll.Timesheet +} + +func New() *Store { + return &Store{ + payslips: map[string]payroll.Payslip{}, + cycles: map[string]payroll.Cycle{}, + employees: map[string][]payroll.Employee{}, + sheets: map[string]payroll.Timesheet{}, + } +} + +func key(cycleID, employeeID string) string { return cycleID + "/" + employeeID } + +// Upsert writes a payslip, replacing any prior slip for the same +// (cycle, employee). A re-run of a cycle must not duplicate rows, so this is +// an upsert rather than an insert. +func (s *Store) Upsert(ctx context.Context, slip payroll.Payslip) error { + if err := ctx.Err(); err != nil { + return err + } + if slip.CycleID == "" || slip.EmployeeID == "" { + return fmt.Errorf("payslip missing cycle or employee id") + } + s.mu.Lock() + defer s.mu.Unlock() + s.payslips[key(slip.CycleID, slip.EmployeeID)] = slip + return nil +} + +// ListByCycle returns every payslip a cycle produced. +func (s *Store) ListByCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]payroll.Payslip, 0, len(s.payslips)) + for _, slip := range s.payslips { + if slip.CycleID == cycleID { + out = append(out, slip) + } + } + return out, nil +} + +func (s *Store) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) { + if err := ctx.Err(); err != nil { + return payroll.Cycle{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + cycle, ok := s.cycles[cycleID] + if !ok { + return payroll.Cycle{}, fmt.Errorf("cycle %s not found", cycleID) + } + return cycle, nil +} + +func (s *Store) SaveCycle(ctx context.Context, cycle payroll.Cycle) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.cycles[cycle.ID] = cycle + return nil +} + +func (s *Store) EmployeesForCycle(ctx context.Context, cycleID string) ([]payroll.Employee, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.employees[cycleID], nil +} + +func (s *Store) Timesheet(ctx context.Context, cycleID, employeeID string) (payroll.Timesheet, error) { + if err := ctx.Err(); err != nil { + return payroll.Timesheet{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + ts, ok := s.sheets[key(cycleID, employeeID)] + if !ok { + return payroll.Timesheet{}, fmt.Errorf("no timesheet for %s in %s", employeeID, cycleID) + } + return ts, nil +} + +// Seed loads fixture data; the real service reads from Postgres. +func (s *Store) Seed(cycle payroll.Cycle, employees []payroll.Employee, sheets []payroll.Timesheet) { + s.mu.Lock() + defer s.mu.Unlock() + s.cycles[cycle.ID] = cycle + s.employees[cycle.ID] = employees + for _, ts := range sheets { + s.sheets[key(ts.CycleID, ts.EmployeeID)] = ts + } +} diff --git a/__tests__/fixtures/payroll-go/internal/transport/httpapi/payroll_handler.go b/__tests__/fixtures/payroll-go/internal/transport/httpapi/payroll_handler.go new file mode 100644 index 0000000..7f3534c --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/transport/httpapi/payroll_handler.go @@ -0,0 +1,96 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/example/payroll-svc/internal/usecase/payroll" +) + +// PayrollHandler is the HTTP entry point into the payroll use-case layer. +type PayrollHandler struct { + svc *payroll.Service +} + +func NewPayrollHandler(svc *payroll.Service) *PayrollHandler { + return &PayrollHandler{svc: svc} +} + +type runCycleRequest struct { + DryRun bool `json:"dryRun"` + Reason string `json:"reason"` +} + +type runCycleResponse struct { + CycleID string `json:"cycleId"` + Payslips int `json:"payslips"` + GrossCents int64 `json:"grossCents"` + NetCents int64 `json:"netCents"` +} + +// RunCycle kicks off a payroll cycle: it hands the cycle id to the use-case +// layer, which builds and persists a payslip per active employee. +func (h *PayrollHandler) RunCycle(w http.ResponseWriter, r *http.Request) { + cycleID := r.PathValue("cycleID") + if cycleID == "" { + httpError(w, http.StatusBadRequest, "cycleID is required") + return + } + + var req runCycleRequest + if r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpError(w, http.StatusBadRequest, "malformed body") + return + } + } + + result, err := h.svc.RunCycle(r.Context(), cycleID, payroll.RunOptions{ + DryRun: req.DryRun, + Reason: req.Reason, + }) + if err != nil { + if errors.Is(err, payroll.ErrCycleClosed) { + httpError(w, http.StatusConflict, "cycle already closed") + return + } + httpError(w, http.StatusInternalServerError, "run failed") + return + } + + writeJSON(w, http.StatusOK, runCycleResponse{ + CycleID: result.CycleID, + Payslips: len(result.Payslips), + GrossCents: result.TotalGrossCents, + NetCents: result.TotalNetCents, + }) +} + +func (h *PayrollHandler) GetCycle(w http.ResponseWriter, r *http.Request) { + cycle, err := h.svc.Cycle(r.Context(), r.PathValue("cycleID")) + if err != nil { + httpError(w, http.StatusNotFound, "no such cycle") + return + } + writeJSON(w, http.StatusOK, cycle) +} + +func (h *PayrollHandler) ListPayslips(w http.ResponseWriter, r *http.Request) { + slips, err := h.svc.PayslipsForCycle(r.Context(), r.PathValue("cycleID")) + if err != nil { + httpError(w, http.StatusNotFound, "no such cycle") + return + } + writeJSON(w, http.StatusOK, slips) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func httpError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/__tests__/fixtures/payroll-go/internal/transport/httpapi/router.go b/__tests__/fixtures/payroll-go/internal/transport/httpapi/router.go new file mode 100644 index 0000000..8d916a1 --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/transport/httpapi/router.go @@ -0,0 +1,19 @@ +package httpapi + +import "net/http" + +// NewRouter wires the HTTP surface. The payroll cycle endpoint is the only +// entry point into the hand-written use-case layer. +func NewRouter(h *PayrollHandler) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/payroll/cycles/{cycleID}/run", h.RunCycle) + mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}", h.GetCycle) + mux.HandleFunc("GET /v1/payroll/cycles/{cycleID}/payslips", h.ListPayslips) + mux.HandleFunc("GET /healthz", health) + return mux +} + +func health(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} diff --git a/__tests__/fixtures/payroll-go/internal/usecase/payroll/cycle.go b/__tests__/fixtures/payroll-go/internal/usecase/payroll/cycle.go new file mode 100644 index 0000000..2b47ce2 --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/usecase/payroll/cycle.go @@ -0,0 +1,227 @@ +package payroll + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/example/payroll-svc/internal/domain/payroll" + "github.com/example/payroll-svc/internal/platform/clock" + "github.com/example/payroll-svc/internal/store/payslipstore" +) + +// ErrCycleClosed is returned when a cycle has already been finalized. +var ErrCycleClosed = errors.New("payroll cycle is closed") + +// ErrNoEmployees is returned when a cycle resolves to an empty roster. +var ErrNoEmployees = errors.New("payroll cycle has no active employees") + +// RunOptions tunes a single run of a payroll cycle. +type RunOptions struct { + // DryRun computes every payslip but persists nothing. + DryRun bool + // Reason is recorded on the audit trail for re-runs. + Reason string + // Only, when non-empty, restricts the run to these employee ids. + Only []string +} + +// RunResult is the outcome of one payroll cycle run. +type RunResult struct { + CycleID string + Payslips []payroll.Payslip + TotalGrossCents int64 + TotalNetCents int64 + Skipped []string + FinishedAt time.Time +} + +// Service is the hand-written payroll use-case layer. It owns the order of +// operations for a cycle: resolve the roster, build a payslip per employee, +// then persist. The generated CRUD layer under internal/gen has no opinion +// about any of that — it can only read and write single rows. +type Service struct { + store *payslipstore.Store + clock clock.Clock +} + +func NewService(store *payslipstore.Store, c clock.Clock) *Service { + return &Service{store: store, clock: c} +} + +// RunCycle is the public entry point used by the HTTP handler. It loads the +// cycle, guards its state, and delegates the actual work to runPayrollCycleAll. +func (s *Service) RunCycle(ctx context.Context, cycleID string, opts RunOptions) (RunResult, error) { + cycle, err := s.loadCycle(ctx, cycleID) + if err != nil { + return RunResult{}, err + } + if cycle.Status == payroll.CycleClosed { + return RunResult{}, ErrCycleClosed + } + + roster, err := s.rosterFor(ctx, cycle, opts) + if err != nil { + return RunResult{}, err + } + if len(roster) == 0 { + return RunResult{}, ErrNoEmployees + } + + return s.runPayrollCycleAll(ctx, cycle, roster, opts) +} + +// runPayrollCycleAll is the heart of the cycle: for every employee on the +// roster it builds a payslip from that employee's contract and timesheet, +// then upserts the result. Ordering matters — a payslip is only persisted +// after every earning, deduction and tax line has been resolved, so a +// partially-computed slip can never reach the store. +func (s *Service) runPayrollCycleAll( + ctx context.Context, + cycle payroll.Cycle, + roster []payroll.Employee, + opts RunOptions, +) (RunResult, error) { + result := RunResult{CycleID: cycle.ID} + now := s.clock.Now() + + for _, employee := range roster { + if err := ctx.Err(); err != nil { + return result, err + } + + timesheet, err := s.timesheetFor(ctx, cycle, employee) + if err != nil { + result.Skipped = append(result.Skipped, employee.ID) + continue + } + + slip, err := s.BuildPayslip(ctx, cycle, employee, timesheet) + if err != nil { + return result, fmt.Errorf("build payslip for %s: %w", employee.ID, err) + } + + slip.RunAt = now + slip.RunReason = opts.Reason + + if !opts.DryRun { + if err := s.store.Upsert(ctx, slip); err != nil { + return result, fmt.Errorf("persist payslip for %s: %w", employee.ID, err) + } + } + + result.Payslips = append(result.Payslips, slip) + result.TotalGrossCents += slip.GrossCents + result.TotalNetCents += slip.NetCents + } + + if !opts.DryRun { + if err := s.closeCycle(ctx, cycle, now); err != nil { + return result, err + } + } + + sort.Slice(result.Payslips, func(i, j int) bool { + return result.Payslips[i].EmployeeID < result.Payslips[j].EmployeeID + }) + result.FinishedAt = now + return result, nil +} + +// rosterFor resolves which employees this cycle pays. An employee joins the +// roster when their contract overlaps the cycle window and they are not on +// unpaid leave for the whole period. +func (s *Service) rosterFor(ctx context.Context, cycle payroll.Cycle, opts RunOptions) ([]payroll.Employee, error) { + all, err := s.store.EmployeesForCycle(ctx, cycle.ID) + if err != nil { + return nil, err + } + + only := map[string]bool{} + for _, id := range opts.Only { + only[id] = true + } + + roster := make([]payroll.Employee, 0, len(all)) + for _, e := range all { + if len(only) > 0 && !only[e.ID] { + continue + } + if !e.Contract.OverlapsWindow(cycle.Start, cycle.End) { + continue + } + if e.UnpaidLeaveCoversWindow(cycle.Start, cycle.End) { + continue + } + roster = append(roster, e) + } + + sort.Slice(roster, func(i, j int) bool { return roster[i].ID < roster[j].ID }) + return roster, nil +} + +func (s *Service) timesheetFor(ctx context.Context, cycle payroll.Cycle, e payroll.Employee) (payroll.Timesheet, error) { + ts, err := s.store.Timesheet(ctx, cycle.ID, e.ID) + if err != nil { + return payroll.Timesheet{}, err + } + if ts.Approved { + return ts, nil + } + if e.Contract.Kind == payroll.ContractSalaried { + // Salaried staff are paid the contractual period regardless of an + // unapproved timesheet; hourly staff are skipped until approval. + return payroll.Timesheet{ + CycleID: cycle.ID, + EmployeeID: e.ID, + Approved: true, + Units: e.Contract.PeriodUnits(cycle.Start, cycle.End), + }, nil + } + return payroll.Timesheet{}, fmt.Errorf("timesheet for %s not approved", e.ID) +} + +func (s *Service) loadCycle(ctx context.Context, cycleID string) (payroll.Cycle, error) { + if cycleID == "" { + return payroll.Cycle{}, errors.New("empty cycle id") + } + return s.store.Cycle(ctx, cycleID) +} + +func (s *Service) closeCycle(ctx context.Context, cycle payroll.Cycle, at time.Time) error { + cycle.Status = payroll.CycleClosed + cycle.ClosedAt = at + return s.store.SaveCycle(ctx, cycle) +} + +// Cycle exposes a cycle for the read endpoints. +func (s *Service) Cycle(ctx context.Context, cycleID string) (payroll.Cycle, error) { + return s.loadCycle(ctx, cycleID) +} + +// PayslipsForCycle lists the payslips a completed cycle produced. +func (s *Service) PayslipsForCycle(ctx context.Context, cycleID string) ([]payroll.Payslip, error) { + slips, err := s.store.ListByCycle(ctx, cycleID) + if err != nil { + return nil, err + } + sort.Slice(slips, func(i, j int) bool { return slips[i].EmployeeID < slips[j].EmployeeID }) + return slips, nil +} + +// Reopen unwinds a closed cycle so it can be re-run after a correction. +func (s *Service) Reopen(ctx context.Context, cycleID string, reason string) error { + cycle, err := s.loadCycle(ctx, cycleID) + if err != nil { + return err + } + if cycle.Status != payroll.CycleClosed { + return nil + } + cycle.Status = payroll.CycleOpen + cycle.ReopenReason = reason + cycle.ClosedAt = time.Time{} + return s.store.SaveCycle(ctx, cycle) +} diff --git a/__tests__/fixtures/payroll-go/internal/usecase/payroll/payslip_builder.go b/__tests__/fixtures/payroll-go/internal/usecase/payroll/payslip_builder.go new file mode 100644 index 0000000..fd8ecba --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/usecase/payroll/payslip_builder.go @@ -0,0 +1,150 @@ +package payroll + +import ( + "context" + "fmt" + + "github.com/example/payroll-svc/internal/domain/payroll" +) + +// BuildPayslip turns one employee's contract and timesheet into a complete +// payslip for the cycle: base pay, overtime, allowances, then deductions and +// tax, in that order. Every amount is in integer cents; nothing here rounds +// until the final net, so a cent never disappears between two lines. +// +// This is the calculation the generated CRUD layer does NOT do — fkit's +// BuildPayslip only copies fields between a DTO and a row. +func (s *Service) BuildPayslip( + ctx context.Context, + cycle payroll.Cycle, + employee payroll.Employee, + timesheet payroll.Timesheet, +) (payroll.Payslip, error) { + if err := ctx.Err(); err != nil { + return payroll.Payslip{}, err + } + if timesheet.EmployeeID != "" && timesheet.EmployeeID != employee.ID { + return payroll.Payslip{}, fmt.Errorf("timesheet/employee mismatch: %s vs %s", timesheet.EmployeeID, employee.ID) + } + + slip := payroll.Payslip{ + CycleID: cycle.ID, + EmployeeID: employee.ID, + Currency: employee.Contract.Currency, + PeriodFrom: cycle.Start, + PeriodTo: cycle.End, + } + + base := s.basePayCents(employee, cycle, timesheet) + slip.Lines = append(slip.Lines, payroll.Line{ + Code: "BASE", Kind: payroll.LineEarning, AmountCents: base, + }) + + if overtime := s.overtimeCents(employee, timesheet); overtime > 0 { + slip.Lines = append(slip.Lines, payroll.Line{ + Code: "OT", Kind: payroll.LineEarning, AmountCents: overtime, + }) + } + + for _, allowance := range employee.Contract.Allowances { + amount := prorateAllowance(allowance, cycle, employee) + if amount == 0 { + continue + } + slip.Lines = append(slip.Lines, payroll.Line{ + Code: allowance.Code, Kind: payroll.LineEarning, AmountCents: amount, + }) + } + + slip.GrossCents = sumKind(slip.Lines, payroll.LineEarning) + + for _, d := range employee.Deductions { + amount := d.AmountFor(slip.GrossCents) + if amount == 0 { + continue + } + slip.Lines = append(slip.Lines, payroll.Line{ + Code: d.Code, Kind: payroll.LineDeduction, AmountCents: amount, + }) + } + + tax, err := s.taxCents(employee, slip.GrossCents) + if err != nil { + return payroll.Payslip{}, fmt.Errorf("tax for %s: %w", employee.ID, err) + } + slip.Lines = append(slip.Lines, payroll.Line{ + Code: "TAX", Kind: payroll.LineDeduction, AmountCents: tax, + }) + + slip.DeductionCents = sumKind(slip.Lines, payroll.LineDeduction) + slip.NetCents = slip.GrossCents - slip.DeductionCents + if slip.NetCents < 0 { + slip.NetCents = 0 + slip.Underwater = true + } + + return slip, nil +} + +// basePayCents is the contractual pay for the period: salaried staff get the +// period rate prorated across their contract window, hourly staff get rate × +// approved units. +func (s *Service) basePayCents(e payroll.Employee, cycle payroll.Cycle, ts payroll.Timesheet) int64 { + switch e.Contract.Kind { + case payroll.ContractSalaried: + full := e.Contract.PeriodRateCents + return prorateSalary(full, e.Contract, cycle) + case payroll.ContractHourly: + return e.Contract.RateCents * int64(ts.Units) + default: + return 0 + } +} + +// overtimeCents pays approved units above the contractual threshold at the +// contract's overtime multiplier. +func (s *Service) overtimeCents(e payroll.Employee, ts payroll.Timesheet) int64 { + if e.Contract.Kind != payroll.ContractHourly { + return 0 + } + threshold := e.Contract.OvertimeThresholdUnits + if threshold <= 0 || ts.Units <= threshold { + return 0 + } + extra := int64(ts.Units - threshold) + return int64(float64(e.Contract.RateCents) * e.Contract.OvertimeMultiplier * float64(extra)) +} + +// taxCents applies the employee's tax band schedule to the gross. +func (s *Service) taxCents(e payroll.Employee, gross int64) (int64, error) { + if len(e.TaxBands) == 0 { + return 0, nil + } + var tax int64 + remaining := gross + for _, band := range e.TaxBands { + if remaining <= 0 { + break + } + if band.RateBasisPoints < 0 || band.RateBasisPoints > 10000 { + return 0, fmt.Errorf("invalid band rate %d", band.RateBasisPoints) + } + slice := remaining + if band.UpToCents > 0 && slice > band.UpToCents { + slice = band.UpToCents + } + tax += slice * int64(band.RateBasisPoints) / 10000 + remaining -= slice + } + return tax, nil +} + +func sumKind(lines []payroll.Line, kind payroll.LineKind) int64 { + var total int64 + for _, l := range lines { + if l.Kind == kind { + total += l.AmountCents + } + } + return total +} diff --git a/__tests__/fixtures/payroll-go/internal/usecase/payroll/prorate.go b/__tests__/fixtures/payroll-go/internal/usecase/payroll/prorate.go new file mode 100644 index 0000000..e633dad --- /dev/null +++ b/__tests__/fixtures/payroll-go/internal/usecase/payroll/prorate.go @@ -0,0 +1,53 @@ +package payroll + +import ( + "time" + + "github.com/example/payroll-svc/internal/domain/payroll" +) + +// prorateSalary scales a full period rate down when the contract covers only +// part of the cycle window (a mid-period joiner or leaver). +func prorateSalary(fullCents int64, contract payroll.Contract, cycle payroll.Cycle) int64 { + window := calendarDays(cycle.Start, cycle.End) + if window <= 0 { + return 0 + } + covered := calendarDays(laterOf(cycle.Start, contract.StartsOn), earlierOf(cycle.End, contract.EndsOn)) + if covered >= window { + return fullCents + } + if covered <= 0 { + return 0 + } + return fullCents * int64(covered) / int64(window) +} + +// prorateAllowance applies the same window rule to a recurring allowance. +func prorateAllowance(a payroll.Allowance, cycle payroll.Cycle, e payroll.Employee) int64 { + if !a.Prorated { + return a.AmountCents + } + return prorateSalary(a.AmountCents, e.Contract, cycle) +} + +func calendarDays(from, to time.Time) int { + if to.Before(from) { + return 0 + } + return int(to.Sub(from).Hours()/24) + 1 +} + +func laterOf(a, b time.Time) time.Time { + if b.IsZero() || a.After(b) { + return a + } + return b +} + +func earlierOf(a, b time.Time) time.Time { + if b.IsZero() || a.Before(b) { + return a + } + return b +} diff --git a/docs/design/explore-budget-allocation.md b/docs/design/explore-budget-allocation.md index 4371362..a46e71f 100644 --- a/docs/design/explore-budget-allocation.md +++ b/docs/design/explore-budget-allocation.md @@ -92,6 +92,84 @@ This is the gap the rest of the epic closes: relevance-proportional allocation w relative cliff (CG-12), on top of scoring that stops rewarding incidental name collisions (CG-10). +## The regression fixtures (CG-6) + +Two fixtures pin the failure mode so it can never silently return. Both **fail today** — +that is what they are for. They become the pass gate for CG-10 + CG-12. + +They are declared in `scripts/agent-eval/allocation-fixtures.json` and run by +`scripts/agent-eval/probe-allocation.mjs`, which drives the CG-4 diagnostic through a JSONL +sidecar (so it measures the shipping allocator, not a re-derivation), groups the rendered +files into `answer` vs `incidental`, and checks declared share thresholds. Needs a current +`npm run build`; exits 1 while any assertion fails. + +```bash +node scripts/agent-eval/probe-allocation.mjs # both +node scripts/agent-eval/probe-allocation.mjs payroll-go # one +node scripts/agent-eval/probe-allocation.mjs --json # machine-readable +``` + +### 1. `payroll-go` — the reporter's shape + +`__tests__/fixtures/payroll-go/` is a synthetic Go service: generated FKIT CRUD beside a +hand-written payroll use-case, entered from an HTTP route. Full description in that +directory's README. The essentials: + +- Generated files with **ordinary names** carrying `// Code generated ... DO NOT EDIT.` — + invisible to path-only detection, which is what makes this a #1500 fixture rather than a + `.pb.go` one — beside `payrollpb/*.pb.go` covering the path-detectable channel. +- Deliberate collisions: `BuildPayslip`, `Upsert` and `Store` each exist twice, generated + and hand-written, and the generated layer name-collides on every query term. +- `cycle.go` (227 lines) sits above the whole-file window so it clips; the generated files + sit below it so they ship whole. + +Query — an architecture question naming none of the answering symbols: *"how does payroll +cycle create and calculate payslips?"* + +| | allocated | delivered | +|---|---|---| +| hand-written | 48.4% | **25.6%** (all of it domain types) | +| generated CRUD | 39.9% | **57.4%** | + +`cycle.go` is allocated the single largest slice (7,052 chars, 30.6%) and delivers **zero** +— the 19,500 hard ceiling drops its whole section. `payslip_builder.go` (rank #8) never +renders. So `runPayrollCycleAll`, the hand-written `BuildPayslip` and the real `Upsert` +never reach the agent, and every byte that did arrive describes either CRUD or types. + +This fixture is hermetic: the probe copies the tree to a temp dir and re-indexes per run, +so two runs on one build are byte-identical (verified). `__tests__/explore-allocation-1500.test.ts` +runs the same assertions in vitest — the fixture-shape half green, the allocation half as +`it.fails` so the suite stays green while the bug is open and goes **red when it is fixed**. + +**Finding, deliberately left unfixed:** `runPayrollCycleAll` calls `s.store.Upsert` on a +`*payslipstore.Store`, but the graph resolves that edge to the **generated** +`internal/gen/fkit/payroll/store.go` `Store.Upsert`. Same-name method resolution across two +packages that both define `Store.Upsert` picks the wrong receiver. It is upstream of the +allocation bug — a wrong edge pulls the generated store into the subgraph and inflates its +score — so it belongs with CG-10's scoring work, not with the fixture. + +### 2. `self-query` — the same bug with no generated code in sight + +The baseline above, promoted to a fixture: this repo, *"how does explore allocate its output +budget across files"*. `scripts/agent-eval/*.mjs` mention `explore` and `BUDGET` +incidentally — they are eval harnesses, not the allocator — and they are small enough to +ship whole, while `src/mcp/tools.ts` is large enough to be clipped. + +At 493 indexed files (small tier): the script corpus takes **71.8%** of the delivered +envelope (79.4% allocated) against `tools.ts`'s **18.5%**, despite `tools.ts` scoring 46 vs +10, carrying 2.3× the graph mass and 3× the distinct term hits. + +This fixture reads the **live** index of this repo, so unlike `payroll-go` its exact numbers +move as the repo changes. Its assertions are relative for that reason (answer group vs +incidental group, largest delivered file), never fixed percentages. Two things to know: + +- The `<500`-file tier boundary is close. This repo indexes 493 files including the new + fixture; crossing 500 flips `maxOutputChars` 18,000 → 24,000, `maxFiles` 5 → 8 and + `maxCharsPerFile` 3,800 → 6,500, which moves every number in the table above. Re-baseline + after the crossing rather than treating the drift as a regression. +- Adding the `payroll-go` fixture itself moved the count 472 → 493. Its Go files match none + of this query's terms, so they change the tier arithmetic and nothing else. + ### Reproducing The query explores this repo, so **uncommitted edits to `src/mcp/tools.ts` change the diff --git a/scripts/agent-eval/allocation-fixtures.json b/scripts/agent-eval/allocation-fixtures.json new file mode 100644 index 0000000..28c5a97 --- /dev/null +++ b/scripts/agent-eval/allocation-fixtures.json @@ -0,0 +1,112 @@ +{ + "$comment": [ + "Regression fixtures for GitHub issue #1500 / epic CG-1 — relevance-proportional", + "explore budget allocation. Run them with `node scripts/agent-eval/probe-allocation.mjs`", + "against a built dist/. BOTH FIXTURES FAIL TODAY: that is the point — they document", + "the bug and become the pass gate for the allocation change (CG-10 + CG-12).", + "", + "`groups` partitions the files explore rendered into `answer` (what the query is", + "actually about) and `incidental` (what wins the envelope today on name collisions).", + "Assertions are on the DELIVERED envelope unless suffixed `Allocated`; delivered is", + "what the agent got, allocated is what the render loop chose before the hard ceiling.", + "Shares are fractions of the whole response, meta-text included, so they never sum to 1." + ], + "fixtures": [ + { + "id": "payroll-go", + "title": "#1500 — generated Go CRUD beside a hand-written payroll workflow", + "kind": "fixture", + "path": "__tests__/fixtures/payroll-go", + "query": "how does payroll cycle create and calculate payslips?", + "rationale": [ + "The reporter's repo shape: a Go service whose generated FKIT CRUD layer sits", + "beside the hand-written use-case that does the real work. The query deliberately", + "does NOT name runPayrollCycleAll / BuildPayslip / Upsert — an architecture", + "question phrased the way a newcomer would phrase it. The generated layer", + "name-collides on every query term (CreatePayslip, PayrollCycleCreateRequest,", + "CalculatePayrollCycleTotals, a second BuildPayslip, a second Upsert), so a", + "scorer that rewards incidental name matches surfaces the CRUD path.", + "Half the generated files carry ORDINARY names and are only detectable by their", + "`// Code generated ... DO NOT EDIT.` header (CG-5), which is what makes this", + "the #1500 case rather than a .pb.go case." + ], + "groups": { + "answer": [ + "internal/usecase/**", + "internal/store/**", + "internal/transport/**", + "internal/domain/**", + "cmd/**" + ], + "incidental": ["internal/gen/**"] + }, + "assert": { + "answerShareAtLeast": 0.55, + "incidentalShareAtMost": 0.25, + "topFileGroup": "answer", + "mustDeliverBytes": [ + "internal/usecase/payroll/cycle.go", + "internal/usecase/payroll/payslip_builder.go" + ], + "$mustContainComment": "Needles are chosen to match the HAND-WRITTEN chain only — a bare `BuildPayslip`/`Upsert` also matches the generated collisions, which is the whole point of the fixture.", + "mustContain": [ + "runPayrollCycleAll", + "func (s *Service) BuildPayslip", + "s.store.Upsert(ctx, slip)" + ] + }, + "baseline": { + "measuredOn": "2026-08-03", + "note": "19 files → very-tiny tier (13,000 budget); 23,020 chars allocated against it, cut to 16,011 by the 19,500 hard ceiling.", + "delivered": { + "internal/gen/fkit/payroll/payslip.go": 0.307, + "internal/gen/fkit/payroll/payroll_cycle.go": 0.266, + "internal/domain/payroll/payslip.go": 0.256, + "internal/usecase/payroll/cycle.go": 0.0 + }, + "verdict": "The workflow file is allocated the single largest slice (30.6%) and delivers ZERO — the hard ceiling drops its whole section. Generated CRUD takes 57.4% of what the agent actually receives; runPayrollCycleAll, BuildPayslip and the real Upsert never reach the response." + } + }, + { + "id": "self-query", + "title": "This repo — incidental `explore`/`BUDGET` matches in the agent-eval scripts", + "kind": "self", + "path": ".", + "query": "how does explore allocate its output budget across files", + "rationale": [ + "The same failure mode with no generated code in sight. `scripts/agent-eval/*.mjs`", + "mention `explore` and `BUDGET` incidentally — they are eval harnesses, not the", + "allocator — and they are small enough to ship WHOLE, while src/mcp/tools.ts (which", + "carries getExploreOutputBudget and the render loop, and scores 4x higher on every", + "signal) is large enough to be clipped at maxCharsPerFile. Allocation follows file", + "size, not relevance.", + "", + "Unlike payroll-go this fixture reads THIS repo's live index, so its exact numbers", + "move as the repo changes (indexed file count crossing 500 flips the budget tier).", + "The assertions are therefore relative — answer-vs-incidental, not fixed percentages." + ], + "groups": { + "answer": ["src/mcp/**"], + "incidental": ["scripts/**"] + }, + "assert": { + "answerShareAtLeast": 0.5, + "incidentalShareAtMost": 0.25, + "topFileGroup": "answer", + "mustDeliverBytes": ["src/mcp/tools.ts"] + }, + "baseline": { + "measuredOn": "2026-08-03", + "note": "493 files → small tier (18,000 budget); 27,518 chars allocated against it, cut to 19,749 by the 25,000 hard ceiling.", + "delivered": { + "scripts/agent-eval/offload-eval-hook.mjs": 0.25, + "scripts/agent-eval/offload-eval-metrics.mjs": 0.236, + "scripts/agent-eval/parse-session.mjs": 0.232, + "src/mcp/tools.ts": 0.185, + "scripts/agent-eval/offload-eval-cost.mjs": 0.0 + }, + "verdict": "The script corpus takes 71.8% of the delivered envelope (79.4% of what was allocated) against tools.ts's 18.5%, despite tools.ts scoring 46 vs 10, carrying 2.3x the graph mass and 3x the distinct term hits." + } + } + ] +} diff --git a/scripts/agent-eval/probe-allocation.mjs b/scripts/agent-eval/probe-allocation.mjs new file mode 100755 index 0000000..1386731 --- /dev/null +++ b/scripts/agent-eval/probe-allocation.mjs @@ -0,0 +1,296 @@ +#!/usr/bin/env node +/** + * Deterministic per-file budget-share probe for `codegraph_explore` (CG-6). + * + * `probe-explore.mjs` prints what explore returned. This prints how the response + * was DIVIDED — which files won the byte envelope and in what proportion — and + * checks that division against a declared expectation. It is the regression gate + * for GitHub issue #1500 / epic CG-1: an architecture question that doesn't name + * the exact use-case must concentrate the budget on the code that answers it, not + * on a generated CRUD layer (or an eval script) that merely name-collides. + * + * The numbers come from the CG-4 diagnostic (`CODEGRAPH_EXPLORE_DEBUG`), read back + * from a JSONL sidecar, so the probe measures the shipping allocator rather than + * re-deriving shares from the markdown. + * + * Fixtures are declared in `allocation-fixtures.json`. A `kind: "fixture"` entry is + * hermetic — the fixture tree is copied to a fresh temp dir and indexed per run, so + * two runs on one build give identical numbers. A `kind: "self"` entry reads this + * repo's live index and therefore moves as the repo changes; its assertions are + * relative for that reason. + * + * Usage (needs a current `npm run build`): + * node scripts/agent-eval/probe-allocation.mjs # every fixture + * node scripts/agent-eval/probe-allocation.mjs payroll-go # one fixture + * node scripts/agent-eval/probe-allocation.mjs --json # machine-readable + * node scripts/agent-eval/probe-allocation.mjs --keep # keep the temp index + * + * Exit code: 0 if every assertion holds, 1 if any fails, 2 on a setup error. + * BOTH FIXTURES ARE EXPECTED TO FAIL until CG-10/CG-12 land — that failure is the + * documented bug. Use --expect-fail to invert the exit code while it is the state + * of the world (0 = still broken, 1 = fixed, go flip the gate). + */ +import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '../..'); +const SPEC_PATH = join(HERE, 'allocation-fixtures.json'); + +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((a) => a.startsWith('--'))); +const wanted = argv.filter((a) => !a.startsWith('--')); +const asJson = flags.has('--json'); +const keepTemp = flags.has('--keep'); +const expectFail = flags.has('--expect-fail'); + +const say = (line = '') => { if (!asJson) console.log(line); }; +const pct = (f) => `${(f * 100).toFixed(1)}%`; +const num = (n) => Math.round(n).toLocaleString('en-US'); + +/** Load the built dist — the probe measures the shipping allocator, not src. */ +async function loadDist() { + const distIndex = join(REPO_ROOT, 'dist/index.js'); + if (!existsSync(distIndex)) { + console.error('dist/ not built — run `npm run build` first.'); + process.exit(2); + } + const idx = await import(pathToFileURL(distIndex).href); + const tools = await import(pathToFileURL(join(REPO_ROOT, 'dist/mcp/tools.js')).href); + // esModuleInterop: dynamic import of CJS yields { default: module.exports, ...named } + const CodeGraph = idx.default?.default ?? idx.default ?? idx.CodeGraph; + const ToolHandler = tools.ToolHandler ?? tools.default?.ToolHandler; + if (typeof CodeGraph?.openSync !== 'function' || typeof ToolHandler !== 'function') { + console.error('could not resolve CodeGraph/ToolHandler from dist/'); + process.exit(2); + } + return { CodeGraph, ToolHandler }; +} + +/** `internal/gen/**` → /^internal\/gen\/.*$/ . Supports `**`, `*` and literals. */ +function globToRegExp(glob) { + // Park `**` on a sentinel no path can contain, so the `*` pass cannot eat it. + // Written as an escape, not a literal byte — a raw NUL makes git treat this + // whole script as binary, which costs every future diff of it. + const DOUBLE_STAR = '\u0000'; + const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + const body = escaped + .replace(/\*\*/g, DOUBLE_STAR) + .replace(/\*/g, '[^/]*') + .replaceAll(DOUBLE_STAR, '.*'); + return new RegExp(`^${body}$`); +} + +const groupOf = (path, groups) => { + for (const [name, globs] of Object.entries(groups)) { + if (globs.some((g) => globToRegExp(g).test(path))) return name; + } + return 'other'; +}; + +/** + * Run one explore call with the diagnostic pointed at a sidecar, and return the + * report plus the response text. + */ +async function runExplore({ CodeGraph, ToolHandler }, repoPath, query, sidecar) { + const cg = CodeGraph.openSync(repoPath); + const prior = process.env.CODEGRAPH_EXPLORE_DEBUG; + process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar; + try { + const res = await new ToolHandler(cg).execute('codegraph_explore', { query }); + const text = res.content?.[0]?.text ?? ''; + const lines = readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean); + if (lines.length === 0) throw new Error('diagnostic produced no report'); + return { report: JSON.parse(lines[lines.length - 1]), text }; + } finally { + if (prior === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG; + else process.env.CODEGRAPH_EXPLORE_DEBUG = prior; + try { cg.close?.(); } catch {} + } +} + +/** Copy a fixture tree to a fresh temp dir and index it — hermetic per run. */ +async function materializeFixture({ CodeGraph }, fixturePath) { + const src = resolve(REPO_ROOT, fixturePath); + if (!existsSync(src)) throw new Error(`fixture tree not found: ${src}`); + const dir = mkdtempSync(join(tmpdir(), 'cg-alloc-')); + cpSync(src, dir, { recursive: true }); + // A stray index inside the checked-in tree would be copied in and reused. + rmSync(join(dir, '.codegraph'), { recursive: true, force: true }); + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + cg.close?.(); + return dir; +} + +/** Evaluate one fixture's assertions against its report. Returns check rows. */ +function evaluate(fixture, report, text) { + const { groups, assert: want } = fixture; + const delivered = new Map(); + const allocated = new Map(); + for (const f of report.files) { + const g = groupOf(f.path, groups); + delivered.set(g, (delivered.get(g) ?? 0) + f.share); + allocated.set(g, (allocated.get(g) ?? 0) + f.allocatedShare); + } + const share = (g) => delivered.get(g) ?? 0; + const top = report.files + .filter((f) => f.finalChars > 0) + .sort((a, b) => b.finalChars - a.finalChars)[0]; + + const checks = []; + const add = (name, pass, detail) => checks.push({ name, pass, detail }); + + if (want.answerShareAtLeast !== undefined) { + add( + `answer group takes >= ${pct(want.answerShareAtLeast)} of the envelope`, + share('answer') >= want.answerShareAtLeast, + `answer ${pct(share('answer'))} delivered (${pct(allocated.get('answer') ?? 0)} allocated)`, + ); + } + if (want.incidentalShareAtMost !== undefined) { + add( + `incidental group takes <= ${pct(want.incidentalShareAtMost)} of the envelope`, + share('incidental') <= want.incidentalShareAtMost, + `incidental ${pct(share('incidental'))} delivered (${pct(allocated.get('incidental') ?? 0)} allocated)`, + ); + } + if (want.topFileGroup) { + const actual = top ? groupOf(top.path, groups) : '(nothing delivered)'; + add( + `largest delivered file is in "${want.topFileGroup}"`, + actual === want.topFileGroup, + top ? `${top.path} (${pct(top.share)}, group "${actual}")` : 'no file delivered any source', + ); + } + for (const path of want.mustDeliverBytes ?? []) { + const rec = report.files.find((f) => f.path === path); + add( + `${path} delivers source`, + !!rec && rec.finalChars > 0, + rec + ? `${num(rec.finalChars)} delivered of ${num(rec.emittedChars)} allocated` + + (rec.finalChars === 0 && rec.emittedChars > 0 ? ' — hard ceiling dropped the whole section' : '') + + (rec.emittedChars === 0 ? ` — never rendered (${rec.skipped ?? 'not reached'}, rank #${rec.rank})` : '') + : 'not among the ranked candidates', + ); + } + for (const needle of want.mustContain ?? []) { + add(`response contains "${needle}"`, text.includes(needle), text.includes(needle) ? 'present' : 'absent'); + } + return { checks, delivered, allocated, top }; +} + +function printReport(fixture, report, evaluated) { + const { checks, delivered, allocated } = evaluated; + const env = report.envelope; + say(''); + say(`── ${fixture.id} — ${fixture.title}`); + say(` query "${report.query}"`); + say(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`); + say( + ` envelope ${num(env.chars)} delivered · ${num(env.allocatedChars)} allocated` + + ` of ${num(report.budget.maxOutputChars)} budget (hard ceiling ${num(report.budget.hardCeiling)})` + + `${env.overBudget ? ' [over budget]' : ''}${env.truncated ? ' [TRUNCATED]' : ''}`, + ); + say(''); + say(' group alloc% deliv%'); + for (const g of ['answer', 'incidental', 'other']) { + if (!delivered.has(g) && !allocated.has(g)) continue; + say(` ${g.padEnd(12)} ${pct(allocated.get(g) ?? 0).padStart(6)} ${pct(delivered.get(g) ?? 0).padStart(6)}`); + } + say(''); + say(' # alloc% deliv% bytes score graph hits gen render file'); + for (const f of report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0)) { + say( + ' ' + String(f.rank).padStart(2) + ' ' + + pct(f.allocatedShare).padStart(6) + ' ' + + pct(f.share).padStart(6) + ' ' + + num(f.emittedChars).padStart(7) + ' ' + + String(f.score).padStart(5) + ' ' + + f.graphScore.toFixed(5).padStart(7) + ' ' + + String(f.termHits).padStart(4) + ' ' + + (f.generated ? ' ✓ ' : ' ') + ' ' + + ((f.render ?? '-') + (f.clipped ? '*' : '')).padEnd(9) + ' ' + + f.path, + ); + } + say(''); + for (const c of checks) say(` ${c.pass ? 'PASS' : 'FAIL'} ${c.name}\n ${c.detail}`); +} + +async function main() { + const spec = JSON.parse(readFileSync(SPEC_PATH, 'utf-8')); + const fixtures = spec.fixtures.filter((f) => wanted.length === 0 || wanted.includes(f.id)); + if (fixtures.length === 0) { + console.error(`no fixture matched ${JSON.stringify(wanted)}; known: ${spec.fixtures.map((f) => f.id).join(', ')}`); + process.exit(2); + } + + const dist = await loadDist(); + const sidecarDir = mkdtempSync(join(tmpdir(), 'cg-alloc-diag-')); + const results = []; + const temps = []; + + for (const fixture of fixtures) { + let repoPath; + if (fixture.kind === 'fixture') { + repoPath = await materializeFixture(dist, fixture.path); + temps.push(repoPath); + } else { + repoPath = resolve(REPO_ROOT, fixture.path); + if (!existsSync(join(repoPath, '.codegraph'))) { + console.error(`${fixture.id}: ${repoPath} has no .codegraph index — run \`codegraph init\` there first.`); + process.exit(2); + } + } + + const sidecar = join(sidecarDir, `${fixture.id}.jsonl`); + mkdirSync(dirname(sidecar), { recursive: true }); + const { report, text } = await runExplore(dist, repoPath, fixture.query, sidecar); + const evaluated = evaluate(fixture, report, text); + printReport(fixture, report, evaluated); + results.push({ + id: fixture.id, + kind: fixture.kind, + query: fixture.query, + passed: evaluated.checks.every((c) => c.pass), + checks: evaluated.checks, + shares: { + delivered: Object.fromEntries(evaluated.delivered), + allocated: Object.fromEntries(evaluated.allocated), + }, + envelope: report.envelope, + files: report.files.filter((f) => f.emittedChars > 0 || f.finalChars > 0), + }); + } + + if (!keepTemp) { + for (const dir of temps) rmSync(dir, { recursive: true, force: true }); + rmSync(sidecarDir, { recursive: true, force: true }); + } else { + say(''); + say(` kept: ${[...temps, sidecarDir].join(' ')}`); + } + + const allPassed = results.every((r) => r.passed); + if (asJson) { + console.log(JSON.stringify({ passed: allPassed, fixtures: results }, null, 2)); + } else { + say(''); + for (const r of results) say(`${r.passed ? 'PASS' : 'FAIL'} ${r.id}`); + if (!allPassed) { + say(''); + say('Failures here are the DOCUMENTED #1500 bug — the budget goes to files that merely'); + say('name-collide with the query. They become the pass gate once CG-10/CG-12 land.'); + } + } + process.exit(expectFail ? (allPassed ? 1 : 0) : (allPassed ? 0 : 1)); +} + +main().catch((err) => { + console.error(err?.stack ?? String(err)); + process.exit(2); +});