Skip to content

Commit 0456640

Browse files
author
riddim-developer-bot
committed
[GRV-26]: Establish attribution catalog and repair boundary guard
Repair the llm-cost-attribution architecture boundary guard so its config reflects the package after the estimation extraction, and complete the repo-root attribution use-case catalog as the canonical source of truth. Boundary guard (scripts/check-boundary.mjs): - Drop the dead `src/enrich.mjs` core entry and the phantom `src/linear-estimate-source.mjs` adapter entry. Both modules moved to llm-cost-estimation; existsSync silently skipped them, so the guard claimed to protect/classify modules that no longer exist here. - Replace stale remediation text that still pointed at the moved LinearEstimateSource with port-neutral attribution guidance. Regression guard (test/boundary.test.mjs): - Add config-integrity tests asserting every configured core/adapter path exists, so dead or phantom entries can't silently recur. - Exercise the core-imports-adapter rule against a real adapter (attribution-adapters.mjs) instead of the removed linear adapter. Attribution catalog (docs/architecture/use-case-catalog.md): - Add a canonical intro, the inward dependency rule, and the boundary guard enforcement linkage. - Catalog the two remaining public use cases (CreateAttributionWorkflow, ListKnownIssues) so the catalog covers the full attribution surface. Co-authored-by: riddim-developer-bot <developer-bot@riddimsoftware.com>
1 parent 347b6dc commit 0456640

3 files changed

Lines changed: 73 additions & 10 deletions

File tree

docs/architecture/use-case-catalog.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Use Case Catalog
22

3+
The canonical catalog of every application-layer use case in `llm-cost-attribution`:
4+
its actor, goal, entities/values, ports, primary adapters, and the inward boundary
5+
rule it relies on. Each entry names its `Current implementation` so the catalog and
6+
the source stay traceable.
7+
8+
**Dependency rule.** Core modules import no filesystem, transcript, usage-JSONL, CLI,
9+
HTTP/Linear, or `child_process` API — only ports (`SessionSource`, `IssueMatcher`,
10+
`UsageRecordSource`, `UsageRecordSink`, and the forecaster's `EstimateTaggedUsageSource`
11+
/ `PricingTable` / `QuotaModel` / `DiffSource`) cross inward. This rule is enforced by
12+
`packages/llm-cost-attribution/scripts/check-boundary.mjs` (`npm run test:boundary`) and
13+
the project-acceptance boundary check; the guard's `coreModules` / `adapterModules` lists
14+
must stay aligned with the modules cataloged here.
15+
16+
**Convention:** every PR that adds or changes a use case updates this catalog in the
17+
same PR.
18+
319
### ForecastIssueCost
420
Actor: Operator
521
Goal: Forecast token, turn, $ API-equivalent, and Codex quota cost for one issue from historical issues with the same size and model.
@@ -53,6 +69,17 @@ Ports: none — pure function over in-memory pairs.
5369
Primary adapters: none. Joining (DiffSource → FeatureCostPair) lives in JoinCostWithFeature.
5470
Current implementation: `packages/llm-cost-attribution/src/correlate.mjs`
5571

72+
### CreateAttributionWorkflow
73+
Actor: Operator (library integrator)
74+
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.
75+
Inputs: `{ sessionSource, issueMatcher, usageRecordSource, usageRecordSink, recordedAt? }` — caller-supplied port implementations.
76+
Outputs: a workflow object exposing `computeIssueCost`, `computeWorktreeCost`, `computeIssueCostFromUsage`, `iterateUsageFromSessions`, and `backfillUsage`.
77+
Entities / values: ParsedSession, UsageRecord, IssueRollup, UsageBackfillSummary.
78+
Ports: SessionSource, IssueMatcher, UsageRecordSource, UsageRecordSink.
79+
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.
80+
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).
81+
Current implementation: `packages/llm-cost-attribution/src/attribution-workflow.mjs` (`createAttributionWorkflow`)
82+
5683
### ComputeIssueCost
5784
Actor: Operator
5885
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.
@@ -103,3 +130,14 @@ Entities / values: UsageRecord, IssueRollup.
103130
Ports: UsageRecordSource.
104131
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.
105132
Current implementation: `packages/llm-cost-attribution/src/attribution-workflow.mjs` (`computeIssueCostFromUsageRecords`)
133+
134+
### ListKnownIssues
135+
Actor: Operator
136+
Goal: Enumerate every issue identifier that has at least one local Claude or Codex session, for pickers and dashboards, without computing any rollup.
137+
Inputs: options selecting the local transcript roots (defaults to the standard Claude/Codex session directories).
138+
Outputs: a sorted list of issue identifiers discovered across local transcripts.
139+
Entities / values: ParsedSession (read for its identifier only).
140+
Ports: none — adapter-only enumeration over the transcript readers.
141+
Primary adapters: Claude/Codex transcript readers.
142+
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.
143+
Current implementation: `packages/llm-cost-attribution/src/index.mjs` (`listKnownIssues`)

packages/llm-cost-attribution/scripts/check-boundary.mjs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,12 @@ export const BOUNDARY_CONFIG = {
1111
'src/forecast.mjs',
1212
'src/quantiles.mjs',
1313
'src/project-forecast.mjs',
14-
'src/enrich.mjs',
1514
'src/correlate.mjs',
1615
'src/cost-feature-join.mjs',
1716
'src/attribution-ports.mjs',
1817
'src/attribution-workflow.mjs',
1918
],
2019
adapterModules: [
21-
{ path: 'src/linear-estimate-source.mjs', kind: 'Linear adapter' },
2220
{ path: 'src/pricing.mjs', kind: 'pricing adapter/access point' },
2321
{ path: 'src/quota.mjs', kind: 'quota adapter/access point' },
2422
{ path: 'src/transcripts/', kind: 'transcript filesystem adapter' },
@@ -37,12 +35,12 @@ export const BOUNDARY_CONFIG = {
3735
{
3836
pattern: /^(?:node:)?https?$/,
3937
kind: 'HTTP API',
40-
remediation: 'HTTP access belongs in LinearEstimateSource or another adapter. Depend on a port instead.',
38+
remediation: 'HTTP access belongs in an adapter, not the attribution core. Depend on a port instead.',
4139
},
4240
{
4341
pattern: /^@linear(?:\/|$)/,
4442
kind: 'Linear SDK',
45-
remediation: 'Depend on the EstimateTaggedUsageSource / LinearEstimateSource port instead.',
43+
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.',
4644
},
4745
{
4846
pattern: /^(?:node:)?child_process$/,
@@ -185,7 +183,7 @@ function formatPackageViolation(sourcePath, specifier, violation, docsPath) {
185183
function formatAdapterViolation(sourcePath, specifier, resolvedPath, kind, docsPath) {
186184
return (
187185
`${sourcePath} imports ${specifier} (${kind}; resolves to ${resolvedPath}) - ` +
188-
`Depend on the EstimateTaggedUsageSource / LinearEstimateSource port instead. See ${docsPath}.`
186+
`Depend on an attribution port (SessionSource / IssueMatcher / UsageRecordSource / UsageRecordSink) instead. See ${docsPath}.`
189187
);
190188
}
191189

packages/llm-cost-attribution/test/boundary.test.mjs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { strict as assert } from 'node:assert';
2+
import { existsSync } from 'node:fs';
23
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
34
import { tmpdir } from 'node:os';
45
import { dirname, join } from 'node:path';
56
import { fileURLToPath } from 'node:url';
67
import { describe, it } from 'node:test';
78
import { spawnSync } from 'node:child_process';
89

10+
import { BOUNDARY_CONFIG } from '../scripts/check-boundary.mjs';
11+
912
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
1013
const CHECK_BOUNDARY = join(PACKAGE_ROOT, 'scripts/check-boundary.mjs');
1114

@@ -41,15 +44,15 @@ describe('boundary checker', () => {
4144
});
4245
});
4346

44-
it('fails when a core module imports the Linear adapter', async () => {
47+
it('fails when a core module imports the attribution transcript/usage adapter', async () => {
4548
await withFixture({
46-
'src/forecast.mjs': "import { LinearEstimateSource } from './linear-estimate-source.mjs';\nvoid LinearEstimateSource;\n",
49+
'src/forecast.mjs': "import { transcriptSessionSource } from './attribution-adapters.mjs';\nvoid transcriptSessionSource;\n",
4750
}, async (root) => {
4851
const result = runBoundary(root);
4952
assert.notEqual(result.status, 0);
50-
assert.match(result.stderr, /src\/forecast\.mjs imports \.\/linear-estimate-source\.mjs/);
51-
assert.match(result.stderr, /Linear adapter/);
52-
assert.match(result.stderr, /EstimateTaggedUsageSource \/ LinearEstimateSource port/);
53+
assert.match(result.stderr, /src\/forecast\.mjs imports \.\/attribution-adapters\.mjs/);
54+
assert.match(result.stderr, /attribution transcript\/usage adapter/);
55+
assert.match(result.stderr, /SessionSource \/ IssueMatcher \/ UsageRecordSource \/ UsageRecordSink/);
5356
assert.match(result.stderr, /docs\/architecture\/use-case-catalog\.md/);
5457
});
5558
});
@@ -123,3 +126,27 @@ describe('boundary checker', () => {
123126
});
124127
});
125128
});
129+
130+
describe('boundary config integrity', () => {
131+
it('every configured core module exists in the package', () => {
132+
for (const modulePath of BOUNDARY_CONFIG.coreModules) {
133+
assert.ok(
134+
existsSync(join(PACKAGE_ROOT, modulePath)),
135+
`coreModules references a path that no longer exists: ${modulePath}. ` +
136+
'A core module that has moved or been deleted is silently skipped by the guard - ' +
137+
'remove or correct the entry so the boundary config reflects the real package.',
138+
);
139+
}
140+
});
141+
142+
it('every configured adapter module exists in the package', () => {
143+
for (const entry of BOUNDARY_CONFIG.adapterModules) {
144+
assert.ok(
145+
existsSync(join(PACKAGE_ROOT, entry.path)),
146+
`adapterModules references a path that no longer exists: ${entry.path} (${entry.kind}). ` +
147+
'A phantom adapter entry classifies nothing - ' +
148+
'remove or correct the entry so the boundary config reflects the real package.',
149+
);
150+
}
151+
});
152+
});

0 commit comments

Comments
 (0)