Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Use Case Catalog

The canonical catalog of every application-layer use case in `llm-cost-attribution`:
its actor, goal, entities/values, ports, primary adapters, and the inward boundary
rule it relies on. Each entry names its `Current implementation` so the catalog and
the source stay traceable.

**Dependency rule.** Core modules import no filesystem, transcript, usage-JSONL, CLI,
HTTP/Linear, or `child_process` API — only ports (`SessionSource`, `IssueMatcher`,
`UsageRecordSource`, `UsageRecordSink`, and the forecaster's `EstimateTaggedUsageSource`
/ `PricingTable` / `QuotaModel` / `DiffSource`) cross inward. This rule is enforced by
`packages/llm-cost-attribution/scripts/check-boundary.mjs` (`npm run test:boundary`) and
the project-acceptance boundary check; the guard's `coreModules` / `adapterModules` lists
must stay aligned with the modules cataloged here.

**Convention:** every PR that adds or changes a use case updates this catalog in the
same PR.

### ForecastIssueCost
Actor: Operator
Goal: Forecast token, turn, $ API-equivalent, and Codex quota cost for one issue from historical issues with the same size and model.
Expand Down Expand Up @@ -53,6 +69,17 @@ Ports: none — pure function over in-memory pairs.
Primary adapters: none. Joining (DiffSource → FeatureCostPair) lives in JoinCostWithFeature.
Current implementation: `packages/llm-cost-attribution/src/correlate.mjs`

### CreateAttributionWorkflow
Actor: Operator (library integrator)
Goal: Bind the four attribution ports into one workflow object so callers can compute issue/worktree cost and backfill usage from their own sources and sinks, with no filesystem assumptions baked into the core.
Inputs: `{ sessionSource, issueMatcher, usageRecordSource, usageRecordSink, recordedAt? }` — caller-supplied port implementations.
Outputs: a workflow object exposing `computeIssueCost`, `computeWorktreeCost`, `computeIssueCostFromUsage`, `iterateUsageFromSessions`, and `backfillUsage`.
Entities / values: ParsedSession, UsageRecord, IssueRollup, UsageBackfillSummary.
Ports: SessionSource, IssueMatcher, UsageRecordSource, UsageRecordSink.
Primary adapters: none in the core — each convenience wrapper (`computeIssueCost`, etc.) wires the real transcript/usage adapters (`transcriptSessionSource`, `cwdIssueMatcher`, `usageJsonlRecordSource`, `appendingUsageRecordSink`) at the edge, while tests supply in-memory ports.
Notes: pure composition entry point for the port-based core — imports no filesystem/transcript/usage-JSONL/CLI/HTTP/Linear/child_process (enforced by `npm run test:boundary` and the project-acceptance boundary check).
Current implementation: `packages/llm-cost-attribution/src/attribution-workflow.mjs` (`createAttributionWorkflow`)

### ComputeIssueCost
Actor: Operator
Goal: Roll up token/turn/quota cost for one issue from caller-supplied sessions, without assuming the data came from local Claude/Codex transcript directories.
Expand Down Expand Up @@ -103,3 +130,14 @@ Entities / values: UsageRecord, IssueRollup.
Ports: UsageRecordSource.
Primary adapters: `usageJsonlRecordSource` over the usage-JSONL reader (drops malformed lines); in-memory sources in tests. The `computeIssueCostFromUsage` convenience wrapper wires the reader at the edge.
Current implementation: `packages/llm-cost-attribution/src/attribution-workflow.mjs` (`computeIssueCostFromUsageRecords`)

### ListKnownIssues
Actor: Operator
Goal: Enumerate every issue identifier that has at least one local Claude or Codex session, for pickers and dashboards, without computing any rollup.
Inputs: options selecting the local transcript roots (defaults to the standard Claude/Codex session directories).
Outputs: a sorted list of issue identifiers discovered across local transcripts.
Entities / values: ParsedSession (read for its identifier only).
Ports: none — adapter-only enumeration over the transcript readers.
Primary adapters: Claude/Codex transcript readers.
Notes: adapter-only; it lives at the edge rather than the core because it reads the local transcript filesystem directly, so it is not subject to the core dependency rule.
Current implementation: `packages/llm-cost-attribution/src/index.mjs` (`listKnownIssues`)
8 changes: 3 additions & 5 deletions packages/llm-cost-attribution/scripts/check-boundary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,12 @@ export const BOUNDARY_CONFIG = {
'src/forecast.mjs',
'src/quantiles.mjs',
'src/project-forecast.mjs',
'src/enrich.mjs',
'src/correlate.mjs',
'src/cost-feature-join.mjs',
'src/attribution-ports.mjs',
'src/attribution-workflow.mjs',
],
adapterModules: [
{ path: 'src/linear-estimate-source.mjs', kind: 'Linear adapter' },
{ path: 'src/pricing.mjs', kind: 'pricing adapter/access point' },
{ path: 'src/quota.mjs', kind: 'quota adapter/access point' },
{ path: 'src/transcripts/', kind: 'transcript filesystem adapter' },
Expand All @@ -37,12 +35,12 @@ export const BOUNDARY_CONFIG = {
{
pattern: /^(?:node:)?https?$/,
kind: 'HTTP API',
remediation: 'HTTP access belongs in LinearEstimateSource or another adapter. Depend on a port instead.',
remediation: 'HTTP access belongs in an adapter, not the attribution core. Depend on a port instead.',
},
{
pattern: /^@linear(?:\/|$)/,
kind: 'Linear SDK',
remediation: 'Depend on the EstimateTaggedUsageSource / LinearEstimateSource port instead.',
remediation: 'The Linear SDK has no place in the attribution core; the Linear estimate adapter lives in llm-cost-estimation. Depend on an injected port instead.',
},
{
pattern: /^(?:node:)?child_process$/,
Expand Down Expand Up @@ -185,7 +183,7 @@ function formatPackageViolation(sourcePath, specifier, violation, docsPath) {
function formatAdapterViolation(sourcePath, specifier, resolvedPath, kind, docsPath) {
return (
`${sourcePath} imports ${specifier} (${kind}; resolves to ${resolvedPath}) - ` +
`Depend on the EstimateTaggedUsageSource / LinearEstimateSource port instead. See ${docsPath}.`
`Depend on an attribution port (SessionSource / IssueMatcher / UsageRecordSource / UsageRecordSink) instead. See ${docsPath}.`
);
}

Expand Down
37 changes: 32 additions & 5 deletions packages/llm-cost-attribution/test/boundary.test.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { strict as assert } from 'node:assert';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test';
import { spawnSync } from 'node:child_process';

import { BOUNDARY_CONFIG } from '../scripts/check-boundary.mjs';

const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CHECK_BOUNDARY = join(PACKAGE_ROOT, 'scripts/check-boundary.mjs');

Expand Down Expand Up @@ -41,15 +44,15 @@ describe('boundary checker', () => {
});
});

it('fails when a core module imports the Linear adapter', async () => {
it('fails when a core module imports the attribution transcript/usage adapter', async () => {
await withFixture({
'src/forecast.mjs': "import { LinearEstimateSource } from './linear-estimate-source.mjs';\nvoid LinearEstimateSource;\n",
'src/forecast.mjs': "import { transcriptSessionSource } from './attribution-adapters.mjs';\nvoid transcriptSessionSource;\n",
}, async (root) => {
const result = runBoundary(root);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /src\/forecast\.mjs imports \.\/linear-estimate-source\.mjs/);
assert.match(result.stderr, /Linear adapter/);
assert.match(result.stderr, /EstimateTaggedUsageSource \/ LinearEstimateSource port/);
assert.match(result.stderr, /src\/forecast\.mjs imports \.\/attribution-adapters\.mjs/);
assert.match(result.stderr, /attribution transcript\/usage adapter/);
assert.match(result.stderr, /SessionSource \/ IssueMatcher \/ UsageRecordSource \/ UsageRecordSink/);
assert.match(result.stderr, /docs\/architecture\/use-case-catalog\.md/);
});
});
Expand Down Expand Up @@ -123,3 +126,27 @@ describe('boundary checker', () => {
});
});
});

describe('boundary config integrity', () => {
it('every configured core module exists in the package', () => {
for (const modulePath of BOUNDARY_CONFIG.coreModules) {
assert.ok(
existsSync(join(PACKAGE_ROOT, modulePath)),
`coreModules references a path that no longer exists: ${modulePath}. ` +
'A core module that has moved or been deleted is silently skipped by the guard - ' +
'remove or correct the entry so the boundary config reflects the real package.',
);
}
});

it('every configured adapter module exists in the package', () => {
for (const entry of BOUNDARY_CONFIG.adapterModules) {
assert.ok(
existsSync(join(PACKAGE_ROOT, entry.path)),
`adapterModules references a path that no longer exists: ${entry.path} (${entry.kind}). ` +
'A phantom adapter entry classifies nothing - ' +
'remove or correct the entry so the boundary config reflects the real package.',
);
}
});
});
Loading