test(explore): #1500 regression fixtures for budget allocation (CG-6)

Two permanent fixtures pinning the failure mode from issue #1500 — explore
spending its byte envelope on files that merely name-collide with the query.
BOTH FAIL TODAY, by design: they document the bug and become the pass gate
for CG-10 (scoring) + CG-12 (proportional allocation).

__tests__/fixtures/payroll-go/ — a synthetic Go service mirroring the
reporter's shape: generated FKIT CRUD beside a hand-written payroll use-case,
entered from an HTTP route. Half the generated tree carries ORDINARY names
detectable only by their `// Code generated ... DO NOT EDIT.` header (the
#1500 case, and end-to-end cover for CG-5); `payrollpb/*.pb.go` covers the
path-detectable channel. BuildPayslip, Upsert and Store each exist twice,
generated and hand-written. cycle.go sits above the whole-file window so it
clips; the generated files sit below it so they ship whole.

Asking "how does payroll cycle create and calculate payslips?" — naming none
of the answering symbols — the generated CRUD delivers 57.4% of the envelope
against the hand-written layer's 25.6%, all of the latter domain types.
cycle.go is allocated the single largest slice (30.6%) and delivers ZERO: the
hard ceiling drops its whole section. runPayrollCycleAll, the hand-written
BuildPayslip and the real Upsert never reach the agent.

The second fixture is this repo, "how does explore allocate its output budget
across files", where scripts/agent-eval/*.mjs take 71.8% against tools.ts's
18.5% despite scoring 4.6x lower. It reads the live index, so its assertions
are relative rather than fixed percentages.

- scripts/agent-eval/probe-allocation.mjs — per-file budget-share probe,
  driving the CG-4 diagnostic through a JSONL sidecar so it measures the
  shipping allocator. Fixture entries are hermetic (copy + re-index per run,
  verified byte-identical across runs); exits 1 while any assertion fails.
- scripts/agent-eval/allocation-fixtures.json — both fixtures declared, with
  the 2026-08-03 baselines.
- __tests__/explore-allocation-1500.test.ts — fixture-shape assertions green
  today; the allocation assertions held as `it.fails` so the suite stays green
  while the bug is open and goes RED the moment it is fixed.

Also documented and deliberately left unfixed: runPayrollCycleAll's
`s.store.Upsert` edge resolves to the GENERATED Store.Upsert, not the
hand-written one — same-name method resolution across two packages picks the
wrong receiver. It is upstream of the allocation bug, so it belongs with
CG-10's scoring work.

Refs #1500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-03 23:28:59 -05:00
parent 16e17495f4
commit bd86ad2061
25 changed files with 2687 additions and 0 deletions
+256
View File
@@ -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<string, number>;
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<typeof n> => !!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,
});
});
});
});
+95
View File
@@ -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.
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/example/payroll-svc
go 1.22
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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}
}
@@ -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
}
@@ -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
}
@@ -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()
}
@@ -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
}
@@ -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
@@ -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",
}
@@ -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 }
@@ -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
}
}
@@ -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})
}
@@ -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"))
}
@@ -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)
}
@@ -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
}
@@ -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
}
+78
View File
@@ -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
+112
View File
@@ -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."
}
}
]
}
+296
View File
@@ -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);
});