Skip to content

Commit 3d0c3f4

Browse files
riddim-developer-bot[bot]riddim-developer-bot
andauthored
[GRV-26]: Establish attribution catalog and repair boundary guard (#44)
## Summary Part of the **llm-cost architecture contracts** Project. Two deliverables, both scoped to the `llm-cost-attribution` package (per this issue's title + `llm-cost-attribution` label): 1. **Repair the boundary guard** so its config reflects the package after the estimation extraction. 2. **Establish the attribution use-case catalog** as the canonical, complete source of truth for the attribution surface. ### Repair boundary guard — `packages/llm-cost-attribution/scripts/check-boundary.mjs` The boundary config carried two **dead references** to modules that were extracted to `llm-cost-estimation` (in GRV-2 / the estimation split) but never removed here: - `src/enrich.mjs` in `coreModules` - `src/linear-estimate-source.mjs` in `adapterModules` Neither file exists in this package, so `existsSync` silently skipped them — the guard *claimed* to protect/classify modules it can no longer see. Repaired by: - Removing both dead entries. - Replacing stale remediation text that still pointed at the moved `LinearEstimateSource` with port-neutral attribution guidance (`SessionSource` / `IssueMatcher` / `UsageRecordSource` / `UsageRecordSink`). The defensive `@linear` / `https` / `fs` / `child_process` forbidden-package rules are kept. To repair the *class* of bug (not just the instance), `test/boundary.test.mjs` now: - Adds **config-integrity tests** asserting every configured `coreModules` / `adapterModules` path exists on disk, so dead or phantom entries fail CI instead of silently passing. - Exercises the core-imports-adapter rule against a **real** adapter (`attribution-adapters.mjs`) instead of the removed Linear adapter. ### Establish attribution catalog — `docs/architecture/use-case-catalog.md` - Added a canonical intro, the inward dependency rule, and the linkage to the boundary guard that enforces it. - Cataloged the two remaining public use cases that were documented in the package catalog/README but missing here: **CreateAttributionWorkflow** and **ListKnownIssues**. The catalog now covers the full attribution public surface. ## Verification - `npm test` (llm-cost-attribution): **200 pass / 0 fail** (was 198; +2 new config-integrity tests). - `node scripts/check-boundary.mjs`: **Boundary check passed**. - `node --check` on changed `scripts/check-boundary.mjs` and `test/boundary.test.mjs`: ✓. - Project-acceptance, llm-cost-architecture-contracts — **GRV-26's portion is fully green**: - `architecture-boundary.check.mjs` (runs the repaired guard): ✓ - `attribution-ports.test.mjs`: ✓ - catalog/API alignment: **no findings for `llm-cost-attribution`**. - Rebased onto latest `origin/main` (includes #43 / GRV-27). ## Out of scope (observed, not fixed here) The non-required project-acceptance gate still reports **one** finding, in the separate **`llm-cost-estimation`** package, left by the just-merged GRV-27 (#43): `packages/llm-cost-estimation/docs/use-cases.md:67` marks the now-implemented `forecastProjectCost` as planned/stubbed. That is estimation-catalog territory (GRV-27's rollout), not this attribution issue — flagged here as a breadcrumb for a follow-up rather than mixing packages in one PR. `pr-build` (the required gate) excludes the project-acceptance directory, so this does not block merge. Co-authored-by: riddim-developer-bot <developer-bot@riddimsoftware.com>
1 parent 347b6dc commit 3d0c3f4

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)