Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
2 changes: 0 additions & 2 deletions .claude-adr.log

This file was deleted.

4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ ERODE_GITHUB_TOKEN=
# Model overrides — FAST for extraction stages (1, 2), ADVANCED for analysis stages (3, 4)
# ERODE_GEMINI_FAST_MODEL=gemini-2.5-flash # Default
# ERODE_GEMINI_ADVANCED_MODEL=gemini-2.5-pro # Default
# ERODE_ANTHROPIC_FAST_MODEL=claude-haiku-4-5-20251001
# ERODE_ANTHROPIC_ADVANCED_MODEL=claude-sonnet-4-5-20250929
# ERODE_ANTHROPIC_FAST_MODEL=claude-haiku-4-5
# ERODE_ANTHROPIC_ADVANCED_MODEL=claude-sonnet-4-6

# Architecture model format
# ERODE_MODEL_FORMAT=likec4
Expand Down
28 changes: 1 addition & 27 deletions .githooks/post-commit
Original file line number Diff line number Diff line change
@@ -1,29 +1,3 @@
#!/bin/sh

# Post-commit hook: Auto-generate ADR for architectural changes
# Runs Claude Code headlessly in background to analyze commits

COMMIT_MSG=$(git log -1 --pretty=%B)

# Opt-out checks
if echo "$COMMIT_MSG" | grep -qiE "\[(skip-adr|no-adr)\]"; then
exit 0
fi
[ "$SKIP_ADR" = "1" ] && exit 0

# Check for Claude CLI
command -v claude >/dev/null 2>&1 || exit 0

# Check for architectural indicators
if echo "$COMMIT_MSG" | grep -qiE "(refactor|architecture|migrate|introduce|domain layer|api version)"; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Architectural change detected"
echo " Running ADR generation in background..."
echo " Check .claude-adr.log for output"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

nohup "$(dirname "$0")/adr-generator.sh" >> .claude-adr.log 2>&1 &
fi

echo "Post-commit checks passed!"
exit 0
8 changes: 4 additions & 4 deletions packages/core/schemas/eroderc.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,11 @@
"maximum": 300000
},
"fastModel": {
"default": "claude-haiku-4-5-20251001",
"default": "claude-haiku-4-5",
"type": "string"
},
"advancedModel": {
"default": "claude-sonnet-4-5-20250929",
"default": "claude-sonnet-4-6",
"type": "string"
Comment thread
parse marked this conversation as resolved.
}
},
Expand Down Expand Up @@ -210,11 +210,11 @@
"maximum": 300000
},
"fastModel": {
"default": "gpt-4.1-mini",
"default": "gpt-5-mini",
"type": "string"
},
"advancedModel": {
"default": "gpt-4.1",
"default": "gpt-5",
"type": "string"
}
},
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/analysis/__tests__/prompt-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,65 @@ describe('PromptBuilder', () => {
expect(result).toContain('REMOVED');
expect(result).toContain('memcached');
});

it('should preserve dependency evidence for drift analysis', () => {
const result = PromptBuilder.buildDriftAnalysisPrompt({
changeRequest: {
number: 1,
title: 'Add service dependency',
description: null,
repository: 'org/repo',
author: { login: 'dev' },
base: { ref: 'main', sha: 'base' },
head: { ref: 'feature', sha: 'head' },
stats: { commits: 1, additions: 10, deletions: 0, files_changed: 2 },
commits: [{ sha: 'head', message: 'Test', author: 'dev' }],
},
component: { id: 'api_gateway', name: 'API Gateway', type: 'service', tags: [] },
dependencies: {
dependencies: [
{
type: 'added',
file: 'packages/api-gateway/src/index.ts',
dependency: 'Order Service',
description: 'Existing component calls newly introduced service',
code: 'const ORDER_SERVICE = "http://order-service:3005";',
},
{
type: 'added',
file: 'packages/order-service/src/index.ts',
dependency: 'Product Service',
description: 'Newly introduced service calls existing component',
code: 'const PRODUCT_SERVICE = "http://product-service:3002";',
},
{
type: 'added',
file: 'packages/product-service/src/index.ts',
dependency: 'User Service',
description: 'Existing component calls existing component',
code: 'const USER_SERVICE = "http://user-service:3001";',
},
],
summary: 'Added service relationships',
},
architectural: { dependencies: [], dependents: [], relationships: [] },
allRelationships: [],
});

expect(result).toContain('Evidence: const ORDER_SERVICE');
expect(result).toContain('Evidence: const PRODUCT_SERVICE');
expect(result).toContain('Evidence: const USER_SERVICE');
});

it('should instruct drift analysis to account for every added dependency', () => {
const templateDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'prompts');
const template = readFileSync(join(templateDir, 'drift-analysis.md'), 'utf-8');

expect(template).toContain('For every ADDED dependency');
expect(template).toContain('Classify each dependency');
expect(template).toContain('New component plus relationship to add');
expect(template).toContain('Do not let one dependency that created a new component');
});
});

describe('buildModelPatchPrompt', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/analysis/__tests__/section-formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ describe('section-formatters', () => {
expect(result).toContain('Added Redis');
});

it('should include code evidence when present', () => {
const result = formatDependencyChanges({
dependencies: [
{
type: 'added',
file: 'src/gateway.ts',
dependency: 'Order Service',
description: 'External order service via HTTP',
code: 'const ORDER_SERVICE = "http://order-service:3005";',
},
],
summary: '',
});

expect(result).toContain('Evidence: const ORDER_SERVICE');
expect(result).toContain('http://order-service:3005');
});

it('should format modified dependencies', () => {
const result = formatDependencyChanges({
dependencies: [
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/analysis/prompts/drift-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ These are ALL relationships currently declared in the architecture model:

Cross-reference the dependency changes above against the architecture model and assess:

### Dependency Coverage

For every ADDED dependency in the DEPENDENCY CHANGES DETECTED section, account for it
explicitly. Classify each dependency as one of:

- Already declared in the model
- New relationship to add
- New component plus relationship to add
- External package or third-party dependency that should not be modeled
- Ignored with a brief reason

If an existing modeled component gains a dependency on a newly introduced component,
include both the new component and that relationship. If a newly introduced component
depends on an existing modeled component, include both the new component and that
relationship. Do not let one dependency that created a new component hide other
relationships to or from that component.

### 1. New Dependencies NOT in Model (Potential Drift)

- Are there new dependencies that aren't in the allowed dependencies list?
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/analysis/section-formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ export function formatDependencyChanges(dependencies: DependencyExtractionResult
if (items && items.length > 0) {
section += `**${label} Dependencies:**\n`;
section += items
.map((dep) => `- ${dep.dependency} (${dep.file})\n ${dep.description}`)
.map((dep) => {
const evidence = dep.code.trim() ? `\n Evidence: ${dep.code.trim()}` : '';
return `- ${dep.dependency} (${dep.file})\n ${dep.description}${evidence}`;
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.join('\n');
section += '\n\n';
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { AnalysisPhase } from '../analysis-phase.js';
import {
getGenerationProfileForModelPatch,
getGenerationProfileForPhase,
} from '../generation-profile.js';

describe('getGenerationProfileForPhase', () => {
it('uses small low-effort generation for simple phases', () => {
expect(getGenerationProfileForPhase(AnalysisPhase.COMPONENT_RESOLUTION)).toEqual({
outputSize: 'small',
reasoningEffort: 'low',
});
expect(getGenerationProfileForPhase(AnalysisPhase.DEPENDENCY_SCAN)).toEqual({
outputSize: 'small',
reasoningEffort: 'low',
});
});

it('uses medium low-effort generation for drift analysis', () => {
expect(getGenerationProfileForPhase(AnalysisPhase.CHANGE_ANALYSIS)).toEqual({
outputSize: 'medium',
reasoningEffort: 'low',
});
});

it('uses medium medium-effort generation for model updates', () => {
expect(getGenerationProfileForPhase(AnalysisPhase.MODEL_UPDATE)).toEqual({
outputSize: 'medium',
reasoningEffort: 'medium',
});
});

it('does not require raw maxTokens in shared stage orchestration', () => {
const source = readFileSync(join(import.meta.dirname, '../base-provider.ts'), 'utf8');

expect(source).not.toContain('maxTokens');
});

it('adds a dynamic output content hint for model patches', () => {
const profile = getGenerationProfileForModelPatch('x'.repeat(40_000), [' comp.a -> comp.b']);

expect(profile).toMatchObject({
outputSize: 'medium',
reasoningEffort: 'medium',
});
expect(profile.outputContentHint?.characters).toBeGreaterThan(16_384);
});
});
12 changes: 6 additions & 6 deletions packages/core/src/providers/__tests__/provider-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ const {
},
openai: {
apiKey: 'test-openai-key',
fastModel: 'gpt-4.1-mini',
advancedModel: 'gpt-4.1',
fastModel: 'gpt-5-mini',
advancedModel: 'gpt-5',
},
anthropic: {
apiKey: 'test-anthropic-key',
Expand Down Expand Up @@ -94,8 +94,8 @@ describe('createAIProvider', () => {
mockConfig.gemini.fastModel = 'gemini-flash';
mockConfig.gemini.advancedModel = 'gemini-pro';
mockConfig.openai.apiKey = 'test-openai-key';
mockConfig.openai.fastModel = 'gpt-4.1-mini';
mockConfig.openai.advancedModel = 'gpt-4.1';
mockConfig.openai.fastModel = 'gpt-5-mini';
mockConfig.openai.advancedModel = 'gpt-5';
mockConfig.anthropic.apiKey = 'test-anthropic-key';
mockConfig.anthropic.fastModel = 'claude-haiku';
mockConfig.anthropic.advancedModel = 'claude-sonnet';
Expand All @@ -122,8 +122,8 @@ describe('createAIProvider', () => {
expect(result).toBe(mockOpenAIInstance);
expect(OpenAIProvider).toHaveBeenCalledWith({
apiKey: 'test-openai-key',
fastModel: 'gpt-4.1-mini',
advancedModel: 'gpt-4.1',
fastModel: 'gpt-5-mini',
advancedModel: 'gpt-5',
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ describe('AnthropicProvider', () => {
makeStage1Data(['comp.frontend', 'comp.backend'])
);
expect(result).toBe('comp.backend');
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: 'claude-haiku-4-5',
max_tokens: 600,
})
);
});

it('should return null when no component matches', async () => {
Expand Down Expand Up @@ -167,6 +173,12 @@ describe('AnthropicProvider', () => {
expect(result.dependencies).toHaveLength(1);
expect(result.dependencies[0]?.dependency).toBe('redis');
expect(result.summary).toBe('Added Redis dependency');
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: 'claude-haiku-4-5',
max_tokens: 600,
})
);
});

it('should throw on non-JSON response', async () => {
Expand Down Expand Up @@ -208,6 +220,12 @@ describe('AnthropicProvider', () => {
expect(result.metadata).toBe(data.changeRequest);
expect(result.component).toBe(data.component);
expect(result.dependencyChanges).toBe(data.dependencies);
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: 'claude-sonnet-4-6',
max_tokens: 1500,
})
);
});
});

Expand Down Expand Up @@ -311,8 +329,22 @@ describe('AnthropicProvider', () => {
await provider.patchModel('model {\n}\n', [' comp.a -> comp.b'], 'likec4');

expect(mockCreate).toHaveBeenCalled();
const callArg = mockCreate.mock.calls[0]?.[0] as { model?: string } | undefined;
expect(callArg?.model).toBe('claude-haiku-4-5-20251001');
const callArg = mockCreate.mock.calls[0]?.[0] as
| { max_tokens?: number; model?: string }
| undefined;
expect(callArg?.model).toBe('claude-haiku-4-5');
expect(callArg?.max_tokens).toBe(4096);
});

it('should increase the output budget for large model files', async () => {
const patchedContent = 'model {\n comp.a -> comp.b\n}\n';
mockCreate.mockResolvedValueOnce(makeAnthropicResponse(patchedContent));

const provider = createProvider();
await provider.patchModel('x'.repeat(40_000), [' comp.a -> comp.b'], 'likec4');

const callArg = mockCreate.mock.calls[0]?.[0] as { max_tokens?: number } | undefined;
expect(callArg?.max_tokens).toBeGreaterThan(4096);
});

it('should return patched content', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/providers/anthropic/models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const ANTHROPIC_MODELS = {
FAST: 'claude-haiku-4-5-20251001',
ADVANCED: 'claude-sonnet-4-5-20250929',
FAST: 'claude-haiku-4-5',
ADVANCED: 'claude-sonnet-4-6',
} as const;
Loading
Loading