Merge main into kanban-scaling

Resolves merge conflicts while preserving:
- Kanban scaling improvements (window sizing, bounce prevention, debouncing)
- Main's sidebar refactoring into hooks
- Main's openInEditor functionality for VS Code integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
trueheads
2025-12-22 01:49:45 -06:00
599 changed files with 26666 additions and 24168 deletions

View File

@@ -15,6 +15,7 @@ npm install @automaker/dependency-resolver
## Exports
### Resolve Dependencies
Order features based on dependencies and priorities.
```typescript
@@ -26,22 +27,22 @@ const features: Feature[] = [
id: 'database',
category: 'backend',
description: 'Setup database',
priority: 1
priority: 1,
},
{
id: 'auth',
category: 'backend',
description: 'Add authentication',
dependencies: ['database'],
priority: 2
priority: 2,
},
{
id: 'api',
category: 'backend',
description: 'Create API endpoints',
dependencies: ['auth'],
priority: 3
}
priority: 3,
},
];
const result = resolveDependencies(features);
@@ -56,6 +57,7 @@ if (result.hasCycle) {
```
### Check Dependencies Satisfied
Check if a feature's dependencies are satisfied.
```typescript
@@ -76,6 +78,7 @@ if (areDependenciesSatisfied(authFeature, allFeatures)) {
```
### Get Blocking Dependencies
Get list of incomplete dependencies blocking a feature.
```typescript
@@ -96,7 +99,7 @@ if (blocking.length > 0) {
import {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies
getBlockingDependencies,
} from '@automaker/dependency-resolver';
import type { Feature } from '@automaker/types';
@@ -130,6 +133,7 @@ async function executeFeatures(features: Feature[]) {
## Algorithm
### Topological Sort (Kahn's Algorithm)
1. Calculate in-degree for each feature (number of dependencies)
2. Start with features that have no dependencies (in-degree = 0)
3. Process features in priority order
@@ -137,11 +141,13 @@ async function executeFeatures(features: Feature[]) {
5. Repeat until all features processed or cycle detected
### Priority Handling
- Features with lower priority numbers execute first
- When multiple features have same in-degree, priority determines order
- Features without explicit priority default to lowest priority
### Cycle Detection
- Detects circular dependencies
- Returns affected features in cycle
- Prevents infinite loops in execution
@@ -149,23 +155,27 @@ async function executeFeatures(features: Feature[]) {
## Return Types
### DependencyResolutionResult
```typescript
interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in execution order
hasCycle: boolean; // True if circular dependency detected
cyclicFeatures: string[]; // Feature IDs involved in cycle
orderedFeatures: Feature[]; // Features in execution order
hasCycle: boolean; // True if circular dependency detected
cyclicFeatures: string[]; // Feature IDs involved in cycle
}
```
## Edge Cases
### Missing Dependencies
Features with dependencies on non-existent features are treated as if the dependency is satisfied (allows flexibility).
### Self-Dependencies
Features depending on themselves are detected as cycles.
### Empty Dependencies Array
Treated same as no dependencies - feature is ready immediately.
## Dependencies

View File

@@ -18,7 +18,11 @@
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": ["automaker", "dependency", "resolver"],
"keywords": [
"automaker",
"dependency",
"resolver"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

View File

@@ -8,10 +8,10 @@
import type { Feature } from '@automaker/types';
export interface DependencyResolutionResult {
orderedFeatures: Feature[]; // Features in dependency-aware order
orderedFeatures: Feature[]; // Features in dependency-aware order
circularDependencies: string[][]; // Groups of IDs forming cycles
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
}
/**
@@ -27,7 +27,7 @@ export interface DependencyResolutionResult {
* @returns Resolution result with ordered features and dependency metadata
*/
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
const featureMap = new Map<string, Feature>(features.map((f) => [f.id, f]));
const inDegree = new Map<string, number>();
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
const missingDependencies = new Map<string, string[]>();
@@ -71,8 +71,7 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
const orderedFeatures: Feature[] = [];
// Helper to sort features by priority (lower number = higher priority)
const sortByPriority = (a: Feature, b: Feature) =>
(a.priority ?? 2) - (b.priority ?? 2);
const sortByPriority = (a: Feature, b: Feature) => (a.priority ?? 2) - (b.priority ?? 2);
// Start with features that have no dependencies (in-degree 0)
for (const [id, degree] of inDegree) {
@@ -109,11 +108,11 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
// Detect circular dependencies (features not in output = part of cycle)
const circularDependencies: string[][] = [];
const processedIds = new Set(orderedFeatures.map(f => f.id));
const processedIds = new Set(orderedFeatures.map((f) => f.id));
if (orderedFeatures.length < features.length) {
// Find cycles using DFS
const remaining = features.filter(f => !processedIds.has(f.id));
const remaining = features.filter((f) => !processedIds.has(f.id));
const cycles = detectCycles(remaining, featureMap);
circularDependencies.push(...cycles);
@@ -125,7 +124,7 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
orderedFeatures,
circularDependencies,
missingDependencies,
blockedFeatures
blockedFeatures,
};
}
@@ -136,10 +135,7 @@ export function resolveDependencies(features: Feature[]): DependencyResolutionRe
* @param featureMap - Map of all features by ID
* @returns Array of cycles, where each cycle is an array of feature IDs
*/
function detectCycles(
features: Feature[],
featureMap: Map<string, Feature>
): string[][] {
function detectCycles(features: Feature[], featureMap: Map<string, Feature>): string[][] {
const cycles: string[][] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
@@ -185,16 +181,13 @@ function detectCycles(
* @param allFeatures - All features in the project
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[]
): boolean {
export function areDependenciesSatisfied(feature: Feature, allFeatures: Feature[]): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
return feature.dependencies.every((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
const dep = allFeatures.find((f) => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
});
}
@@ -206,16 +199,13 @@ export function areDependenciesSatisfied(
* @param allFeatures - All features in the project
* @returns Array of feature IDs that are blocking this feature
*/
export function getBlockingDependencies(
feature: Feature,
allFeatures: Feature[]
): string[] {
export function getBlockingDependencies(feature: Feature, allFeatures: Feature[]): string[] {
if (!feature.dependencies || feature.dependencies.length === 0) {
return [];
}
return feature.dependencies.filter((depId: string) => {
const dep = allFeatures.find(f => f.id === depId);
const dep = allFeatures.find((f) => f.id === depId);
return dep && dep.status !== 'completed' && dep.status !== 'verified';
});
}

View File

@@ -1,10 +1,10 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect } from 'vitest';
import {
resolveDependencies,
areDependenciesSatisfied,
getBlockingDependencies,
} from "../src/resolver";
import type { Feature } from "@automaker/types";
} from '../src/resolver';
import type { Feature } from '@automaker/types';
// Helper to create test features
function createFeature(
@@ -17,22 +17,18 @@ function createFeature(
): Feature {
return {
id,
category: "test",
category: 'test',
description: `Feature ${id}`,
dependencies: options.dependencies,
status: options.status || "pending",
status: options.status || 'pending',
priority: options.priority,
};
}
describe("resolver.ts", () => {
describe("resolveDependencies", () => {
it("should handle features with no dependencies", () => {
const features = [
createFeature("A"),
createFeature("B"),
createFeature("C"),
];
describe('resolver.ts', () => {
describe('resolveDependencies', () => {
it('should handle features with no dependencies', () => {
const features = [createFeature('A'), createFeature('B'), createFeature('C')];
const result = resolveDependencies(features);
@@ -42,174 +38,169 @@ describe("resolver.ts", () => {
expect(result.blockedFeatures.size).toBe(0);
});
it("should order features with linear dependencies", () => {
it('should order features with linear dependencies', () => {
const features = [
createFeature("C", { dependencies: ["B"] }),
createFeature("A"),
createFeature("B", { dependencies: ["A"] }),
createFeature('C', { dependencies: ['B'] }),
createFeature('A'),
createFeature('B', { dependencies: ['A'] }),
];
const result = resolveDependencies(features);
const ids = result.orderedFeatures.map(f => f.id);
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B"));
expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("C"));
const ids = result.orderedFeatures.map((f) => f.id);
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('B'));
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('C'));
expect(result.circularDependencies).toEqual([]);
});
it("should respect priority within same dependency level", () => {
it('should respect priority within same dependency level', () => {
const features = [
createFeature("Low", { priority: 3 }),
createFeature("High", { priority: 1 }),
createFeature("Medium", { priority: 2 }),
createFeature('Low', { priority: 3 }),
createFeature('High', { priority: 1 }),
createFeature('Medium', { priority: 2 }),
];
const result = resolveDependencies(features);
const ids = result.orderedFeatures.map(f => f.id);
expect(ids).toEqual(["High", "Medium", "Low"]);
const ids = result.orderedFeatures.map((f) => f.id);
expect(ids).toEqual(['High', 'Medium', 'Low']);
});
it("should use default priority 2 when not specified", () => {
it('should use default priority 2 when not specified', () => {
const features = [
createFeature("NoPriority"),
createFeature("HighPriority", { priority: 1 }),
createFeature("LowPriority", { priority: 3 }),
createFeature('NoPriority'),
createFeature('HighPriority', { priority: 1 }),
createFeature('LowPriority', { priority: 3 }),
];
const result = resolveDependencies(features);
const ids = result.orderedFeatures.map(f => f.id);
expect(ids.indexOf("HighPriority")).toBeLessThan(ids.indexOf("NoPriority"));
expect(ids.indexOf("NoPriority")).toBeLessThan(ids.indexOf("LowPriority"));
const ids = result.orderedFeatures.map((f) => f.id);
expect(ids.indexOf('HighPriority')).toBeLessThan(ids.indexOf('NoPriority'));
expect(ids.indexOf('NoPriority')).toBeLessThan(ids.indexOf('LowPriority'));
});
it("should respect dependencies over priority", () => {
it('should respect dependencies over priority', () => {
const features = [
createFeature("B", { dependencies: ["A"], priority: 1 }), // High priority but depends on A
createFeature("A", { priority: 3 }), // Low priority but no dependencies
createFeature('B', { dependencies: ['A'], priority: 1 }), // High priority but depends on A
createFeature('A', { priority: 3 }), // Low priority but no dependencies
];
const result = resolveDependencies(features);
const ids = result.orderedFeatures.map(f => f.id);
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B"));
const ids = result.orderedFeatures.map((f) => f.id);
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('B'));
});
it("should detect circular dependencies (simple cycle)", () => {
it('should detect circular dependencies (simple cycle)', () => {
const features = [
createFeature("A", { dependencies: ["B"] }),
createFeature("B", { dependencies: ["A"] }),
createFeature('A', { dependencies: ['B'] }),
createFeature('B', { dependencies: ['A'] }),
];
const result = resolveDependencies(features);
expect(result.circularDependencies).toHaveLength(1);
expect(result.circularDependencies[0]).toContain("A");
expect(result.circularDependencies[0]).toContain("B");
expect(result.circularDependencies[0]).toContain('A');
expect(result.circularDependencies[0]).toContain('B');
expect(result.orderedFeatures).toHaveLength(2); // All features still included
});
it("should detect circular dependencies (3-way cycle)", () => {
it('should detect circular dependencies (3-way cycle)', () => {
const features = [
createFeature("A", { dependencies: ["C"] }),
createFeature("B", { dependencies: ["A"] }),
createFeature("C", { dependencies: ["B"] }),
createFeature('A', { dependencies: ['C'] }),
createFeature('B', { dependencies: ['A'] }),
createFeature('C', { dependencies: ['B'] }),
];
const result = resolveDependencies(features);
expect(result.circularDependencies.length).toBeGreaterThan(0);
const allCycleIds = result.circularDependencies.flat();
expect(allCycleIds).toContain("A");
expect(allCycleIds).toContain("B");
expect(allCycleIds).toContain("C");
expect(allCycleIds).toContain('A');
expect(allCycleIds).toContain('B');
expect(allCycleIds).toContain('C');
});
it("should detect missing dependencies", () => {
it('should detect missing dependencies', () => {
const features = [createFeature('A', { dependencies: ['NonExistent'] }), createFeature('B')];
const result = resolveDependencies(features);
expect(result.missingDependencies.has('A')).toBe(true);
expect(result.missingDependencies.get('A')).toContain('NonExistent');
});
it('should detect blocked features (incomplete dependencies)', () => {
const features = [
createFeature("A", { dependencies: ["NonExistent"] }),
createFeature("B"),
createFeature('A', { status: 'pending' }),
createFeature('B', { dependencies: ['A'], status: 'pending' }),
];
const result = resolveDependencies(features);
expect(result.missingDependencies.has("A")).toBe(true);
expect(result.missingDependencies.get("A")).toContain("NonExistent");
expect(result.blockedFeatures.has('B')).toBe(true);
expect(result.blockedFeatures.get('B')).toContain('A');
});
it("should detect blocked features (incomplete dependencies)", () => {
it('should not mark features as blocked if dependencies are completed', () => {
const features = [
createFeature("A", { status: "pending" }),
createFeature("B", { dependencies: ["A"], status: "pending" }),
createFeature('A', { status: 'completed' }),
createFeature('B', { dependencies: ['A'], status: 'pending' }),
];
const result = resolveDependencies(features);
expect(result.blockedFeatures.has("B")).toBe(true);
expect(result.blockedFeatures.get("B")).toContain("A");
expect(result.blockedFeatures.has('B')).toBe(false);
});
it("should not mark features as blocked if dependencies are completed", () => {
it('should not mark features as blocked if dependencies are verified', () => {
const features = [
createFeature("A", { status: "completed" }),
createFeature("B", { dependencies: ["A"], status: "pending" }),
createFeature('A', { status: 'verified' }),
createFeature('B', { dependencies: ['A'], status: 'pending' }),
];
const result = resolveDependencies(features);
expect(result.blockedFeatures.has("B")).toBe(false);
expect(result.blockedFeatures.has('B')).toBe(false);
});
it("should not mark features as blocked if dependencies are verified", () => {
it('should handle complex dependency graph', () => {
const features = [
createFeature("A", { status: "verified" }),
createFeature("B", { dependencies: ["A"], status: "pending" }),
createFeature('E', { dependencies: ['C', 'D'] }),
createFeature('D', { dependencies: ['B'] }),
createFeature('C', { dependencies: ['A', 'B'] }),
createFeature('B'),
createFeature('A'),
];
const result = resolveDependencies(features);
expect(result.blockedFeatures.has("B")).toBe(false);
});
it("should handle complex dependency graph", () => {
const features = [
createFeature("E", { dependencies: ["C", "D"] }),
createFeature("D", { dependencies: ["B"] }),
createFeature("C", { dependencies: ["A", "B"] }),
createFeature("B"),
createFeature("A"),
];
const result = resolveDependencies(features);
const ids = result.orderedFeatures.map(f => f.id);
const ids = result.orderedFeatures.map((f) => f.id);
// A and B have no dependencies - can be first or second
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("C"));
expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("C"));
expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("D"));
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('C'));
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('C'));
expect(ids.indexOf('B')).toBeLessThan(ids.indexOf('D'));
// C depends on A and B
expect(ids.indexOf("C")).toBeLessThan(ids.indexOf("E"));
expect(ids.indexOf('C')).toBeLessThan(ids.indexOf('E'));
// D depends on B
expect(ids.indexOf("D")).toBeLessThan(ids.indexOf("E"));
expect(ids.indexOf('D')).toBeLessThan(ids.indexOf('E'));
expect(result.circularDependencies).toEqual([]);
});
it("should handle multiple missing dependencies", () => {
const features = [
createFeature("A", { dependencies: ["X", "Y", "Z"] }),
];
it('should handle multiple missing dependencies', () => {
const features = [createFeature('A', { dependencies: ['X', 'Y', 'Z'] })];
const result = resolveDependencies(features);
expect(result.missingDependencies.get("A")).toEqual(["X", "Y", "Z"]);
expect(result.missingDependencies.get('A')).toEqual(['X', 'Y', 'Z']);
});
it("should handle empty feature list", () => {
it('should handle empty feature list', () => {
const result = resolveDependencies([]);
expect(result.orderedFeatures).toEqual([]);
@@ -218,143 +209,143 @@ describe("resolver.ts", () => {
expect(result.blockedFeatures.size).toBe(0);
});
it("should handle features with both missing and existing dependencies", () => {
it('should handle features with both missing and existing dependencies', () => {
const features = [
createFeature("A"),
createFeature("B", { dependencies: ["A", "NonExistent"] }),
createFeature('A'),
createFeature('B', { dependencies: ['A', 'NonExistent'] }),
];
const result = resolveDependencies(features);
expect(result.missingDependencies.get("B")).toContain("NonExistent");
const ids = result.orderedFeatures.map(f => f.id);
expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B"));
expect(result.missingDependencies.get('B')).toContain('NonExistent');
const ids = result.orderedFeatures.map((f) => f.id);
expect(ids.indexOf('A')).toBeLessThan(ids.indexOf('B'));
});
});
describe("areDependenciesSatisfied", () => {
it("should return true for feature with no dependencies", () => {
const feature = createFeature("A");
describe('areDependenciesSatisfied', () => {
it('should return true for feature with no dependencies', () => {
const feature = createFeature('A');
const allFeatures = [feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
});
it("should return true for feature with empty dependencies array", () => {
const feature = createFeature("A", { dependencies: [] });
it('should return true for feature with empty dependencies array', () => {
const feature = createFeature('A', { dependencies: [] });
const allFeatures = [feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
});
it("should return true when all dependencies are completed", () => {
const dep = createFeature("Dep", { status: "completed" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return true when all dependencies are completed', () => {
const dep = createFeature('Dep', { status: 'completed' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
});
it("should return true when all dependencies are verified", () => {
const dep = createFeature("Dep", { status: "verified" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return true when all dependencies are verified', () => {
const dep = createFeature('Dep', { status: 'verified' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true);
});
it("should return false when any dependency is pending", () => {
const dep = createFeature("Dep", { status: "pending" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return false when any dependency is pending', () => {
const dep = createFeature('Dep', { status: 'pending' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
});
it("should return false when any dependency is running", () => {
const dep = createFeature("Dep", { status: "running" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return false when any dependency is running', () => {
const dep = createFeature('Dep', { status: 'running' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
});
it("should return false when dependency is missing", () => {
const feature = createFeature("A", { dependencies: ["NonExistent"] });
it('should return false when dependency is missing', () => {
const feature = createFeature('A', { dependencies: ['NonExistent'] });
const allFeatures = [feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
});
it("should check all dependencies", () => {
const dep1 = createFeature("Dep1", { status: "completed" });
const dep2 = createFeature("Dep2", { status: "pending" });
const feature = createFeature("A", { dependencies: ["Dep1", "Dep2"] });
it('should check all dependencies', () => {
const dep1 = createFeature('Dep1', { status: 'completed' });
const dep2 = createFeature('Dep2', { status: 'pending' });
const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2'] });
const allFeatures = [dep1, dep2, feature];
expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false);
});
});
describe("getBlockingDependencies", () => {
it("should return empty array for feature with no dependencies", () => {
const feature = createFeature("A");
describe('getBlockingDependencies', () => {
it('should return empty array for feature with no dependencies', () => {
const feature = createFeature('A');
const allFeatures = [feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
});
it("should return empty array when all dependencies are completed", () => {
const dep = createFeature("Dep", { status: "completed" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return empty array when all dependencies are completed', () => {
const dep = createFeature('Dep', { status: 'completed' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
});
it("should return empty array when all dependencies are verified", () => {
const dep = createFeature("Dep", { status: "verified" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return empty array when all dependencies are verified', () => {
const dep = createFeature('Dep', { status: 'verified' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual([]);
});
it("should return pending dependencies", () => {
const dep = createFeature("Dep", { status: "pending" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return pending dependencies', () => {
const dep = createFeature('Dep', { status: 'pending' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]);
expect(getBlockingDependencies(feature, allFeatures)).toEqual(['Dep']);
});
it("should return running dependencies", () => {
const dep = createFeature("Dep", { status: "running" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return running dependencies', () => {
const dep = createFeature('Dep', { status: 'running' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]);
expect(getBlockingDependencies(feature, allFeatures)).toEqual(['Dep']);
});
it("should return failed dependencies", () => {
const dep = createFeature("Dep", { status: "failed" });
const feature = createFeature("A", { dependencies: ["Dep"] });
it('should return failed dependencies', () => {
const dep = createFeature('Dep', { status: 'failed' });
const feature = createFeature('A', { dependencies: ['Dep'] });
const allFeatures = [dep, feature];
expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]);
expect(getBlockingDependencies(feature, allFeatures)).toEqual(['Dep']);
});
it("should return all incomplete dependencies", () => {
const dep1 = createFeature("Dep1", { status: "pending" });
const dep2 = createFeature("Dep2", { status: "completed" });
const dep3 = createFeature("Dep3", { status: "running" });
const feature = createFeature("A", { dependencies: ["Dep1", "Dep2", "Dep3"] });
it('should return all incomplete dependencies', () => {
const dep1 = createFeature('Dep1', { status: 'pending' });
const dep2 = createFeature('Dep2', { status: 'completed' });
const dep3 = createFeature('Dep3', { status: 'running' });
const feature = createFeature('A', { dependencies: ['Dep1', 'Dep2', 'Dep3'] });
const allFeatures = [dep1, dep2, dep3, feature];
const blocking = getBlockingDependencies(feature, allFeatures);
expect(blocking).toContain("Dep1");
expect(blocking).toContain("Dep3");
expect(blocking).not.toContain("Dep2");
expect(blocking).toContain('Dep1');
expect(blocking).toContain('Dep3');
expect(blocking).not.toContain('Dep2');
});
});
});

View File

@@ -1,15 +1,15 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/index.ts"],
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
thresholds: {
lines: 90,
functions: 100,

View File

@@ -15,6 +15,7 @@ npm install @automaker/git-utils
## Exports
### Repository Detection
Check if a path is a git repository.
```typescript
@@ -27,6 +28,7 @@ if (isRepo) {
```
### Status Parsing
Parse git status output into structured data.
```typescript
@@ -36,7 +38,7 @@ import type { FileStatus } from '@automaker/git-utils';
const statusOutput = await execAsync('git status --porcelain');
const files: FileStatus[] = parseGitStatus(statusOutput.stdout);
files.forEach(file => {
files.forEach((file) => {
console.log(`${file.statusText}: ${file.path}`);
// Example: "Modified: src/index.ts"
// Example: "Untracked: new-file.ts"
@@ -44,58 +46,56 @@ files.forEach(file => {
```
### Diff Generation
Generate diffs including untracked files.
```typescript
import {
generateSyntheticDiffForNewFile,
appendUntrackedFileDiffs,
getGitRepositoryDiffs
getGitRepositoryDiffs,
} from '@automaker/git-utils';
// Generate diff for single untracked file
const diff = await generateSyntheticDiffForNewFile(
'/project/path',
'src/new-file.ts'
);
const diff = await generateSyntheticDiffForNewFile('/project/path', 'src/new-file.ts');
// Get complete repository diffs (tracked + untracked)
const result = await getGitRepositoryDiffs('/project/path');
console.log(result.diff); // Combined diff string
console.log(result.files); // Array of FileStatus
console.log(result.hasChanges); // Boolean
console.log(result.diff); // Combined diff string
console.log(result.files); // Array of FileStatus
console.log(result.hasChanges); // Boolean
```
### Non-Git Directory Support
Handle non-git directories by treating all files as new.
```typescript
import {
listAllFilesInDirectory,
generateDiffsForNonGitDirectory
} from '@automaker/git-utils';
import { listAllFilesInDirectory, generateDiffsForNonGitDirectory } from '@automaker/git-utils';
// List all files (excluding build artifacts)
const files = await listAllFilesInDirectory('/project/path');
// Generate diffs for non-git directory
const result = await generateDiffsForNonGitDirectory('/project/path');
console.log(result.diff); // Synthetic diffs for all files
console.log(result.files); // All files as "New" status
console.log(result.diff); // Synthetic diffs for all files
console.log(result.files); // All files as "New" status
```
## Types
### FileStatus
```typescript
interface FileStatus {
status: string; // Git status code (M/A/D/R/C/U/?/!)
path: string; // File path relative to repo root
statusText: string; // Human-readable status
status: string; // Git status code (M/A/D/R/C/U/?/!)
path: string; // File path relative to repo root
statusText: string; // Human-readable status
}
```
### Status Codes
- `M` - Modified
- `A` - Added
- `D` - Deleted
@@ -107,6 +107,7 @@ interface FileStatus {
- ` ` - Unmodified
### Status Text Examples
- `"Modified"` - File has changes
- `"Added"` - New file in staging
- `"Deleted"` - File removed
@@ -117,11 +118,7 @@ interface FileStatus {
## Usage Example
```typescript
import {
isGitRepo,
getGitRepositoryDiffs,
parseGitStatus
} from '@automaker/git-utils';
import { isGitRepo, getGitRepositoryDiffs, parseGitStatus } from '@automaker/git-utils';
async function getProjectChanges(projectPath: string) {
const isRepo = await isGitRepo(projectPath);
@@ -140,15 +137,18 @@ async function getProjectChanges(projectPath: string) {
console.log(`Found ${result.files.length} changed files:\n`);
// Group by status
const byStatus = result.files.reduce((acc, file) => {
acc[file.statusText] = acc[file.statusText] || [];
acc[file.statusText].push(file.path);
return acc;
}, {} as Record<string, string[]>);
const byStatus = result.files.reduce(
(acc, file) => {
acc[file.statusText] = acc[file.statusText] || [];
acc[file.statusText].push(file.path);
return acc;
},
{} as Record<string, string[]>
);
Object.entries(byStatus).forEach(([status, paths]) => {
console.log(`${status}:`);
paths.forEach(path => console.log(` - ${path}`));
paths.forEach((path) => console.log(` - ${path}`));
});
return result.diff;
@@ -158,9 +158,11 @@ async function getProjectChanges(projectPath: string) {
## Features
### Binary File Detection
Automatically detects binary files by extension and generates appropriate diff markers.
**Supported binary extensions:**
- Images: `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, etc.
- Documents: `.pdf`, `.doc`, `.docx`, etc.
- Archives: `.zip`, `.tar`, `.gz`, etc.
@@ -168,10 +170,13 @@ Automatically detects binary files by extension and generates appropriate diff m
- Fonts: `.ttf`, `.otf`, `.woff`, etc.
### Large File Handling
Files larger than 1MB show size information instead of full content.
### Synthetic Diff Format
Generates unified diff format for untracked files:
```diff
diff --git a/new-file.ts b/new-file.ts
new file mode 100644
@@ -185,7 +190,9 @@ index 0000000..0000000
```
### Directory Filtering
When scanning non-git directories, automatically excludes:
- `node_modules`, `.git`, `.automaker`
- Build outputs: `dist`, `build`, `out`, `tmp`, `.tmp`
- Framework caches: `.next`, `.nuxt`, `.cache`, `coverage`
@@ -198,12 +205,14 @@ Git operations can fail for various reasons. This package provides graceful erro
### Common Error Scenarios
**1. Repository Not Found**
```typescript
const isRepo = await isGitRepo('/path/does/not/exist');
// Returns: false (no exception thrown)
```
**2. Not a Git Repository**
```typescript
const result = await getGitRepositoryDiffs('/not/a/git/repo');
// Fallback behavior: treats all files as "new"
@@ -211,6 +220,7 @@ const result = await getGitRepositoryDiffs('/not/a/git/repo');
```
**3. Git Command Failures**
```typescript
// Permission errors, corrupted repos, or git not installed
try {
@@ -223,6 +233,7 @@ try {
```
**4. File Read Errors**
```typescript
// When generating synthetic diffs for inaccessible files
const diff = await generateSyntheticDiffForNewFile('/path', 'locked-file.txt');
@@ -233,6 +244,7 @@ const diff = await generateSyntheticDiffForNewFile('/path', 'locked-file.txt');
### Best Practices
1. **Check repository status first**:
```typescript
const isRepo = await isGitRepo(path);
if (!isRepo) {

View File

@@ -11,7 +11,11 @@
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": ["automaker", "git", "utils"],
"keywords": [
"automaker",
"git",
"utils"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

View File

@@ -4,17 +4,10 @@
*/
// Export types and constants
export {
BINARY_EXTENSIONS,
GIT_STATUS_MAP,
type FileStatus,
} from './types.js';
export { BINARY_EXTENSIONS, GIT_STATUS_MAP, type FileStatus } from './types.js';
// Export status utilities
export {
isGitRepo,
parseGitStatus,
} from './status.js';
export { isGitRepo, parseGitStatus } from './status.js';
// Export diff utilities
export {

View File

@@ -2,8 +2,8 @@
* Git status parsing utilities
*/
import { exec } from "child_process";
import { promisify } from "util";
import { exec } from 'child_process';
import { promisify } from 'util';
import { GIT_STATUS_MAP, type FileStatus } from './types.js';
const execAsync = promisify(exec);
@@ -14,30 +14,35 @@ const execAsync = promisify(exec);
*/
function getStatusText(indexStatus: string, workTreeStatus: string): string {
// Untracked files
if (indexStatus === "?" && workTreeStatus === "?") {
return "Untracked";
if (indexStatus === '?' && workTreeStatus === '?') {
return 'Untracked';
}
// Ignored files
if (indexStatus === "!" && workTreeStatus === "!") {
return "Ignored";
if (indexStatus === '!' && workTreeStatus === '!') {
return 'Ignored';
}
// Prioritize staging area status, then working tree
const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus;
const primaryStatus = indexStatus !== ' ' && indexStatus !== '?' ? indexStatus : workTreeStatus;
// Handle combined statuses
if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") {
if (
indexStatus !== ' ' &&
indexStatus !== '?' &&
workTreeStatus !== ' ' &&
workTreeStatus !== '?'
) {
// Both staging and working tree have changes
const indexText = GIT_STATUS_MAP[indexStatus] || "Changed";
const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed";
const indexText = GIT_STATUS_MAP[indexStatus] || 'Changed';
const workText = GIT_STATUS_MAP[workTreeStatus] || 'Changed';
if (indexText === workText) {
return indexText;
}
return `${indexText} (staged), ${workText} (unstaged)`;
}
return GIT_STATUS_MAP[primaryStatus] || "Changed";
return GIT_STATUS_MAP[primaryStatus] || 'Changed';
}
/**
@@ -45,7 +50,7 @@ function getStatusText(indexStatus: string, workTreeStatus: string): string {
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
return true;
} catch {
return false;
@@ -59,21 +64,21 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
*/
export function parseGitStatus(statusOutput: string): FileStatus[] {
return statusOutput
.split("\n")
.split('\n')
.filter(Boolean)
.map((line) => {
// Git porcelain format uses two status characters: XY
// X = status in staging area (index)
// Y = status in working tree
const indexStatus = line[0] || " ";
const workTreeStatus = line[1] || " ";
const indexStatus = line[0] || ' ';
const workTreeStatus = line[1] || ' ';
// File path starts at position 3 (after "XY ")
let filePath = line.slice(3);
// Handle renamed files (format: "R old_path -> new_path")
if (indexStatus === "R" || workTreeStatus === "R") {
const arrowIndex = filePath.indexOf(" -> ");
if (indexStatus === 'R' || workTreeStatus === 'R') {
const arrowIndex = filePath.indexOf(' -> ');
if (arrowIndex !== -1) {
filePath = filePath.slice(arrowIndex + 4); // Use new path
}
@@ -82,9 +87,9 @@ export function parseGitStatus(statusOutput: string): FileStatus[] {
// Determine the primary status character for backwards compatibility
// Prioritize staging area status, then working tree
let primaryStatus: string;
if (indexStatus === "?" && workTreeStatus === "?") {
primaryStatus = "?"; // Untracked
} else if (indexStatus !== " " && indexStatus !== "?") {
if (indexStatus === '?' && workTreeStatus === '?') {
primaryStatus = '?'; // Untracked
} else if (indexStatus !== ' ' && indexStatus !== '?') {
primaryStatus = indexStatus; // Staged change
} else {
primaryStatus = workTreeStatus; // Working tree change

View File

@@ -4,28 +4,63 @@
// Binary file extensions to skip
export const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".zip", ".tar", ".gz", ".rar", ".7z",
".exe", ".dll", ".so", ".dylib",
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
".ttf", ".otf", ".woff", ".woff2", ".eot",
".db", ".sqlite", ".sqlite3",
".pyc", ".pyo", ".class", ".o", ".obj",
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.ico',
'.webp',
'.svg',
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.zip',
'.tar',
'.gz',
'.rar',
'.7z',
'.exe',
'.dll',
'.so',
'.dylib',
'.mp3',
'.mp4',
'.wav',
'.avi',
'.mov',
'.mkv',
'.ttf',
'.otf',
'.woff',
'.woff2',
'.eot',
'.db',
'.sqlite',
'.sqlite3',
'.pyc',
'.pyo',
'.class',
'.o',
'.obj',
]);
// Status map for git status codes
// Git porcelain format uses XY where X=staging area, Y=working tree
export const GIT_STATUS_MAP: Record<string, string> = {
M: "Modified",
A: "Added",
D: "Deleted",
R: "Renamed",
C: "Copied",
U: "Updated",
"?": "Untracked",
"!": "Ignored",
" ": "Unmodified",
M: 'Modified',
A: 'Added',
D: 'Deleted',
R: 'Renamed',
C: 'Copied',
U: 'Updated',
'?': 'Untracked',
'!': 'Ignored',
' ': 'Unmodified',
};
/**

View File

@@ -1,21 +1,21 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
generateSyntheticDiffForNewFile,
appendUntrackedFileDiffs,
listAllFilesInDirectory,
generateDiffsForNonGitDirectory,
getGitRepositoryDiffs,
} from "../src/diff";
import fs from "fs/promises";
import path from "path";
import os from "os";
} from '../src/diff';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
describe("diff.ts", () => {
describe('diff.ts', () => {
let tempDir: string;
beforeEach(async () => {
// Create a temporary directory for each test
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "git-utils-test-"));
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-utils-test-'));
});
afterEach(async () => {
@@ -27,104 +27,104 @@ describe("diff.ts", () => {
}
});
describe("generateSyntheticDiffForNewFile", () => {
it("should generate diff for binary file", async () => {
const fileName = "test.png";
describe('generateSyntheticDiffForNewFile', () => {
it('should generate diff for binary file', async () => {
const fileName = 'test.png';
const filePath = path.join(tempDir, fileName);
await fs.writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
expect(diff).toContain("new file mode 100644");
expect(diff).toContain('new file mode 100644');
expect(diff).toContain(`Binary file ${fileName} added`);
});
it("should generate diff for large text file", async () => {
const fileName = "large.txt";
it('should generate diff for large text file', async () => {
const fileName = 'large.txt';
const filePath = path.join(tempDir, fileName);
// Create a file > 1MB
const largeContent = "x".repeat(1024 * 1024 + 100);
const largeContent = 'x'.repeat(1024 * 1024 + 100);
await fs.writeFile(filePath, largeContent);
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
expect(diff).toContain("[File too large to display:");
expect(diff).toContain('[File too large to display:');
expect(diff).toMatch(/\d+KB\]/);
});
it("should generate diff for small text file with trailing newline", async () => {
const fileName = "test.txt";
it('should generate diff for small text file with trailing newline', async () => {
const fileName = 'test.txt';
const filePath = path.join(tempDir, fileName);
const content = "line 1\nline 2\nline 3\n";
const content = 'line 1\nline 2\nline 3\n';
await fs.writeFile(filePath, content);
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
expect(diff).toContain("new file mode 100644");
expect(diff).toContain("--- /dev/null");
expect(diff).toContain('new file mode 100644');
expect(diff).toContain('--- /dev/null');
expect(diff).toContain(`+++ b/${fileName}`);
expect(diff).toContain("@@ -0,0 +1,3 @@");
expect(diff).toContain("+line 1");
expect(diff).toContain("+line 2");
expect(diff).toContain("+line 3");
expect(diff).not.toContain("\\ No newline at end of file");
expect(diff).toContain('@@ -0,0 +1,3 @@');
expect(diff).toContain('+line 1');
expect(diff).toContain('+line 2');
expect(diff).toContain('+line 3');
expect(diff).not.toContain('\\ No newline at end of file');
});
it("should generate diff for text file without trailing newline", async () => {
const fileName = "no-newline.txt";
it('should generate diff for text file without trailing newline', async () => {
const fileName = 'no-newline.txt';
const filePath = path.join(tempDir, fileName);
const content = "line 1\nline 2";
const content = 'line 1\nline 2';
await fs.writeFile(filePath, content);
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
expect(diff).toContain("+line 1");
expect(diff).toContain("+line 2");
expect(diff).toContain("\\ No newline at end of file");
expect(diff).toContain('+line 1');
expect(diff).toContain('+line 2');
expect(diff).toContain('\\ No newline at end of file');
});
it("should generate diff for empty file", async () => {
const fileName = "empty.txt";
it('should generate diff for empty file', async () => {
const fileName = 'empty.txt';
const filePath = path.join(tempDir, fileName);
await fs.writeFile(filePath, "");
await fs.writeFile(filePath, '');
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
expect(diff).toContain("@@ -0,0 +1,0 @@");
expect(diff).toContain('@@ -0,0 +1,0 @@');
});
it("should generate diff for single line file", async () => {
const fileName = "single.txt";
it('should generate diff for single line file', async () => {
const fileName = 'single.txt';
const filePath = path.join(tempDir, fileName);
await fs.writeFile(filePath, "single line\n");
await fs.writeFile(filePath, 'single line\n');
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
expect(diff).toContain("@@ -0,0 +1,1 @@");
expect(diff).toContain("+single line");
expect(diff).toContain('@@ -0,0 +1,1 @@');
expect(diff).toContain('+single line');
});
it("should handle file not found error", async () => {
const fileName = "nonexistent.txt";
it('should handle file not found error', async () => {
const fileName = 'nonexistent.txt';
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
expect(diff).toContain("[Unable to read file content]");
expect(diff).toContain('[Unable to read file content]');
});
});
describe("appendUntrackedFileDiffs", () => {
it("should return existing diff when no untracked files", async () => {
const existingDiff = "diff --git a/test.txt b/test.txt\n";
describe('appendUntrackedFileDiffs', () => {
it('should return existing diff when no untracked files', async () => {
const existingDiff = 'diff --git a/test.txt b/test.txt\n';
const files = [
{ status: "M", path: "test.txt" },
{ status: "A", path: "new.txt" },
{ status: 'M', path: 'test.txt' },
{ status: 'A', path: 'new.txt' },
];
const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files);
@@ -132,175 +132,175 @@ describe("diff.ts", () => {
expect(result).toBe(existingDiff);
});
it("should append synthetic diffs for untracked files", async () => {
const existingDiff = "existing diff\n";
const untrackedFile = "untracked.txt";
it('should append synthetic diffs for untracked files', async () => {
const existingDiff = 'existing diff\n';
const untrackedFile = 'untracked.txt';
const filePath = path.join(tempDir, untrackedFile);
await fs.writeFile(filePath, "content\n");
await fs.writeFile(filePath, 'content\n');
const files = [
{ status: "M", path: "modified.txt" },
{ status: "?", path: untrackedFile },
{ status: 'M', path: 'modified.txt' },
{ status: '?', path: untrackedFile },
];
const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files);
expect(result).toContain("existing diff");
expect(result).toContain('existing diff');
expect(result).toContain(`diff --git a/${untrackedFile} b/${untrackedFile}`);
expect(result).toContain("+content");
expect(result).toContain('+content');
});
it("should handle multiple untracked files", async () => {
const file1 = "file1.txt";
const file2 = "file2.txt";
await fs.writeFile(path.join(tempDir, file1), "file1\n");
await fs.writeFile(path.join(tempDir, file2), "file2\n");
it('should handle multiple untracked files', async () => {
const file1 = 'file1.txt';
const file2 = 'file2.txt';
await fs.writeFile(path.join(tempDir, file1), 'file1\n');
await fs.writeFile(path.join(tempDir, file2), 'file2\n');
const files = [
{ status: "?", path: file1 },
{ status: "?", path: file2 },
{ status: '?', path: file1 },
{ status: '?', path: file2 },
];
const result = await appendUntrackedFileDiffs(tempDir, "", files);
const result = await appendUntrackedFileDiffs(tempDir, '', files);
expect(result).toContain(`diff --git a/${file1} b/${file1}`);
expect(result).toContain(`diff --git a/${file2} b/${file2}`);
expect(result).toContain("+file1");
expect(result).toContain("+file2");
expect(result).toContain('+file1');
expect(result).toContain('+file2');
});
});
describe("listAllFilesInDirectory", () => {
it("should list files in empty directory", async () => {
describe('listAllFilesInDirectory', () => {
it('should list files in empty directory', async () => {
const files = await listAllFilesInDirectory(tempDir);
expect(files).toEqual([]);
});
it("should list files in flat directory", async () => {
await fs.writeFile(path.join(tempDir, "file1.txt"), "content");
await fs.writeFile(path.join(tempDir, "file2.js"), "code");
it('should list files in flat directory', async () => {
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content');
await fs.writeFile(path.join(tempDir, 'file2.js'), 'code');
const files = await listAllFilesInDirectory(tempDir);
expect(files).toHaveLength(2);
expect(files).toContain("file1.txt");
expect(files).toContain("file2.js");
expect(files).toContain('file1.txt');
expect(files).toContain('file2.js');
});
it("should list files in nested directories", async () => {
await fs.mkdir(path.join(tempDir, "subdir"));
await fs.writeFile(path.join(tempDir, "root.txt"), "");
await fs.writeFile(path.join(tempDir, "subdir", "nested.txt"), "");
it('should list files in nested directories', async () => {
await fs.mkdir(path.join(tempDir, 'subdir'));
await fs.writeFile(path.join(tempDir, 'root.txt'), '');
await fs.writeFile(path.join(tempDir, 'subdir', 'nested.txt'), '');
const files = await listAllFilesInDirectory(tempDir);
expect(files).toHaveLength(2);
expect(files).toContain("root.txt");
expect(files).toContain("subdir/nested.txt");
expect(files).toContain('root.txt');
expect(files).toContain('subdir/nested.txt');
});
it("should skip node_modules directory", async () => {
await fs.mkdir(path.join(tempDir, "node_modules"));
await fs.writeFile(path.join(tempDir, "app.js"), "");
await fs.writeFile(path.join(tempDir, "node_modules", "package.js"), "");
it('should skip node_modules directory', async () => {
await fs.mkdir(path.join(tempDir, 'node_modules'));
await fs.writeFile(path.join(tempDir, 'app.js'), '');
await fs.writeFile(path.join(tempDir, 'node_modules', 'package.js'), '');
const files = await listAllFilesInDirectory(tempDir);
expect(files).toHaveLength(1);
expect(files).toContain("app.js");
expect(files).not.toContain("node_modules/package.js");
expect(files).toContain('app.js');
expect(files).not.toContain('node_modules/package.js');
});
it("should skip common build directories", async () => {
await fs.mkdir(path.join(tempDir, "dist"));
await fs.mkdir(path.join(tempDir, "build"));
await fs.mkdir(path.join(tempDir, ".next"));
await fs.writeFile(path.join(tempDir, "source.ts"), "");
await fs.writeFile(path.join(tempDir, "dist", "output.js"), "");
await fs.writeFile(path.join(tempDir, "build", "output.js"), "");
it('should skip common build directories', async () => {
await fs.mkdir(path.join(tempDir, 'dist'));
await fs.mkdir(path.join(tempDir, 'build'));
await fs.mkdir(path.join(tempDir, '.next'));
await fs.writeFile(path.join(tempDir, 'source.ts'), '');
await fs.writeFile(path.join(tempDir, 'dist', 'output.js'), '');
await fs.writeFile(path.join(tempDir, 'build', 'output.js'), '');
const files = await listAllFilesInDirectory(tempDir);
expect(files).toHaveLength(1);
expect(files).toContain("source.ts");
expect(files).toContain('source.ts');
});
it("should skip hidden files except .env", async () => {
await fs.writeFile(path.join(tempDir, ".hidden"), "");
await fs.writeFile(path.join(tempDir, ".env"), "");
await fs.writeFile(path.join(tempDir, "visible.txt"), "");
it('should skip hidden files except .env', async () => {
await fs.writeFile(path.join(tempDir, '.hidden'), '');
await fs.writeFile(path.join(tempDir, '.env'), '');
await fs.writeFile(path.join(tempDir, 'visible.txt'), '');
const files = await listAllFilesInDirectory(tempDir);
expect(files).toHaveLength(2);
expect(files).toContain(".env");
expect(files).toContain("visible.txt");
expect(files).not.toContain(".hidden");
expect(files).toContain('.env');
expect(files).toContain('visible.txt');
expect(files).not.toContain('.hidden');
});
it("should skip .git directory", async () => {
await fs.mkdir(path.join(tempDir, ".git"));
await fs.writeFile(path.join(tempDir, ".git", "config"), "");
await fs.writeFile(path.join(tempDir, "README.md"), "");
it('should skip .git directory', async () => {
await fs.mkdir(path.join(tempDir, '.git'));
await fs.writeFile(path.join(tempDir, '.git', 'config'), '');
await fs.writeFile(path.join(tempDir, 'README.md'), '');
const files = await listAllFilesInDirectory(tempDir);
expect(files).toHaveLength(1);
expect(files).toContain("README.md");
expect(files).toContain('README.md');
});
});
describe("generateDiffsForNonGitDirectory", () => {
it("should generate diffs for all files in directory", async () => {
await fs.writeFile(path.join(tempDir, "file1.txt"), "content1\n");
await fs.writeFile(path.join(tempDir, "file2.js"), "console.log('hi');\n");
describe('generateDiffsForNonGitDirectory', () => {
it('should generate diffs for all files in directory', async () => {
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content1\n');
await fs.writeFile(path.join(tempDir, 'file2.js'), "console.log('hi');\n");
const result = await generateDiffsForNonGitDirectory(tempDir);
expect(result.files).toHaveLength(2);
expect(result.files.every(f => f.status === "?")).toBe(true);
expect(result.diff).toContain("diff --git a/file1.txt b/file1.txt");
expect(result.diff).toContain("diff --git a/file2.js b/file2.js");
expect(result.diff).toContain("+content1");
expect(result.files.every((f) => f.status === '?')).toBe(true);
expect(result.diff).toContain('diff --git a/file1.txt b/file1.txt');
expect(result.diff).toContain('diff --git a/file2.js b/file2.js');
expect(result.diff).toContain('+content1');
expect(result.diff).toContain("+console.log('hi');");
});
it("should return empty result for empty directory", async () => {
it('should return empty result for empty directory', async () => {
const result = await generateDiffsForNonGitDirectory(tempDir);
expect(result.files).toEqual([]);
expect(result.diff).toBe("");
expect(result.diff).toBe('');
});
it("should mark all files as untracked", async () => {
await fs.writeFile(path.join(tempDir, "test.txt"), "test");
it('should mark all files as untracked', async () => {
await fs.writeFile(path.join(tempDir, 'test.txt'), 'test');
const result = await generateDiffsForNonGitDirectory(tempDir);
expect(result.files).toHaveLength(1);
expect(result.files[0].status).toBe("?");
expect(result.files[0].statusText).toBe("New");
expect(result.files[0].status).toBe('?');
expect(result.files[0].statusText).toBe('New');
});
});
describe("getGitRepositoryDiffs", () => {
it("should treat non-git directory as all new files", async () => {
await fs.writeFile(path.join(tempDir, "file.txt"), "content\n");
describe('getGitRepositoryDiffs', () => {
it('should treat non-git directory as all new files', async () => {
await fs.writeFile(path.join(tempDir, 'file.txt'), 'content\n');
const result = await getGitRepositoryDiffs(tempDir);
expect(result.hasChanges).toBe(true);
expect(result.files).toHaveLength(1);
expect(result.files[0].status).toBe("?");
expect(result.diff).toContain("diff --git a/file.txt b/file.txt");
expect(result.files[0].status).toBe('?');
expect(result.diff).toContain('diff --git a/file.txt b/file.txt');
});
it("should return no changes for empty non-git directory", async () => {
it('should return no changes for empty non-git directory', async () => {
const result = await getGitRepositoryDiffs(tempDir);
expect(result.hasChanges).toBe(false);
expect(result.files).toEqual([]);
expect(result.diff).toBe("");
expect(result.diff).toBe('');
});
});
});

View File

@@ -1,15 +1,15 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/index.ts", "src/types.ts"],
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts', 'src/types.ts'],
thresholds: {
lines: 65,
functions: 75,

View File

@@ -15,6 +15,7 @@ npm install @automaker/model-resolver
## Exports
### Model Resolution
Convert model aliases to full model identifiers.
```typescript
@@ -41,6 +42,7 @@ const model5 = resolveModelString('claude-opus-4-5-20251101');
```
### Get Effective Model
Get the actual model that will be used.
```typescript
@@ -50,11 +52,12 @@ import { getEffectiveModel } from '@automaker/model-resolver';
const model = getEffectiveModel({
requestedModel: 'sonnet',
featureModel: undefined,
defaultModel: 'claude-sonnet-4-20250514'
defaultModel: 'claude-sonnet-4-20250514',
});
```
### Model Constants
Access model mappings and defaults.
```typescript
@@ -62,14 +65,14 @@ import { DEFAULT_MODELS } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP } from '@automaker/types';
// Default models for different contexts
console.log(DEFAULT_MODELS.claude); // 'claude-sonnet-4-20250514'
console.log(DEFAULT_MODELS.autoMode); // 'claude-sonnet-4-20250514'
console.log(DEFAULT_MODELS.chat); // 'claude-sonnet-4-20250514'
console.log(DEFAULT_MODELS.claude); // 'claude-sonnet-4-20250514'
console.log(DEFAULT_MODELS.autoMode); // 'claude-sonnet-4-20250514'
console.log(DEFAULT_MODELS.chat); // 'claude-sonnet-4-20250514'
// Model alias mappings
console.log(CLAUDE_MODEL_MAP.haiku); // 'claude-haiku-4-5'
console.log(CLAUDE_MODEL_MAP.sonnet); // 'claude-sonnet-4-20250514'
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-5-20251101'
console.log(CLAUDE_MODEL_MAP.haiku); // 'claude-haiku-4-5'
console.log(CLAUDE_MODEL_MAP.sonnet); // 'claude-sonnet-4-20250514'
console.log(CLAUDE_MODEL_MAP.opus); // 'claude-opus-4-5-20251101'
```
## Usage Example
@@ -80,10 +83,7 @@ import type { Feature } from '@automaker/types';
function prepareFeatureExecution(feature: Feature) {
// Resolve model from feature or use default
const model = resolveModelString(
feature.model,
DEFAULT_MODELS.autoMode
);
const model = resolveModelString(feature.model, DEFAULT_MODELS.autoMode);
console.log(`Executing feature with model: ${model}`);
@@ -99,7 +99,7 @@ const feature: Feature = {
id: 'auth-feature',
category: 'backend',
description: 'Add authentication',
model: 'opus' // User-friendly alias
model: 'opus', // User-friendly alias
};
prepareFeatureExecution(feature);
@@ -109,11 +109,13 @@ prepareFeatureExecution(feature);
## Supported Models
### Current Model Aliases
- `haiku``claude-haiku-4-5`
- `sonnet``claude-sonnet-4-20250514`
- `opus``claude-opus-4-5-20251101`
### Model Selection Guide
- **Haiku**: Fast responses, simple tasks, lower cost
- **Sonnet**: Balanced performance, most tasks (recommended default)
- **Opus**: Maximum capability, complex reasoning, highest cost

View File

@@ -11,7 +11,11 @@
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": ["automaker", "model", "resolver"],
"keywords": [
"automaker",
"model",
"resolver"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

View File

@@ -7,7 +7,4 @@
export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias } from '@automaker/types';
// Export resolver functions
export {
resolveModelString,
getEffectiveModel,
} from './resolver.js';
export { resolveModelString, getEffectiveModel } from './resolver.js';

View File

@@ -26,7 +26,7 @@ export function resolveModelString(
}
// Full Claude model string - pass through unchanged
if (modelKey.includes("claude-")) {
if (modelKey.includes('claude-')) {
console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`);
return modelKey;
}
@@ -34,16 +34,12 @@ export function resolveModelString(
// Look up Claude model alias
const resolved = CLAUDE_MODEL_MAP[modelKey];
if (resolved) {
console.log(
`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`
);
console.log(`[ModelResolver] Resolved model alias: "${modelKey}" -> "${resolved}"`);
return resolved;
}
// Unknown model key - use default
console.warn(
`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`
);
console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`);
return defaultModel;
}

View File

@@ -1,14 +1,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { resolveModelString, getEffectiveModel } from "../src/resolver";
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from "@automaker/types";
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { resolveModelString, getEffectiveModel } from '../src/resolver';
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS } from '@automaker/types';
describe("model-resolver", () => {
describe('model-resolver', () => {
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
@@ -16,61 +16,61 @@ describe("model-resolver", () => {
consoleWarnSpy.mockRestore();
});
describe("resolveModelString", () => {
describe("with undefined/null input", () => {
it("should return default model when modelKey is undefined", () => {
describe('resolveModelString', () => {
describe('with undefined/null input', () => {
it('should return default model when modelKey is undefined', () => {
const result = resolveModelString(undefined);
expect(result).toBe(DEFAULT_MODELS.claude);
});
it("should return custom default when modelKey is undefined", () => {
const customDefault = "claude-opus-4-20241113";
it('should return custom default when modelKey is undefined', () => {
const customDefault = 'claude-opus-4-20241113';
const result = resolveModelString(undefined, customDefault);
expect(result).toBe(customDefault);
});
it("should return default when modelKey is empty string", () => {
const result = resolveModelString("");
it('should return default when modelKey is empty string', () => {
const result = resolveModelString('');
expect(result).toBe(DEFAULT_MODELS.claude);
});
});
describe("with full Claude model strings", () => {
it("should pass through full Claude model string unchanged", () => {
const fullModel = "claude-sonnet-4-20250514";
describe('with full Claude model strings', () => {
it('should pass through full Claude model string unchanged', () => {
const fullModel = 'claude-sonnet-4-20250514';
const result = resolveModelString(fullModel);
expect(result).toBe(fullModel);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Using full Claude model string")
expect.stringContaining('Using full Claude model string')
);
});
it("should handle claude-opus model strings", () => {
const fullModel = "claude-opus-4-20241113";
it('should handle claude-opus model strings', () => {
const fullModel = 'claude-opus-4-20241113';
const result = resolveModelString(fullModel);
expect(result).toBe(fullModel);
});
it("should handle claude-haiku model strings", () => {
const fullModel = "claude-3-5-haiku-20241022";
it('should handle claude-haiku model strings', () => {
const fullModel = 'claude-3-5-haiku-20241022';
const result = resolveModelString(fullModel);
expect(result).toBe(fullModel);
});
it("should handle any string containing 'claude-'", () => {
const customModel = "claude-custom-experimental-v1";
const customModel = 'claude-custom-experimental-v1';
const result = resolveModelString(customModel);
expect(result).toBe(customModel);
});
});
describe("with model aliases", () => {
describe('with model aliases', () => {
it("should resolve 'sonnet' alias", () => {
const result = resolveModelString("sonnet");
const result = resolveModelString('sonnet');
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
expect(consoleLogSpy).toHaveBeenCalledWith(
@@ -79,7 +79,7 @@ describe("model-resolver", () => {
});
it("should resolve 'opus' alias", () => {
const result = resolveModelString("opus");
const result = resolveModelString('opus');
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
expect(consoleLogSpy).toHaveBeenCalledWith(
@@ -88,62 +88,54 @@ describe("model-resolver", () => {
});
it("should resolve 'haiku' alias", () => {
const result = resolveModelString("haiku");
const result = resolveModelString('haiku');
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
});
it("should log the resolution for aliases", () => {
resolveModelString("sonnet");
it('should log the resolution for aliases', () => {
resolveModelString('sonnet');
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Resolved model alias")
);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Resolved model alias'));
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining(CLAUDE_MODEL_MAP.sonnet)
);
});
});
describe("with unknown model keys", () => {
it("should return default for unknown model key", () => {
const result = resolveModelString("unknown-model");
describe('with unknown model keys', () => {
it('should return default for unknown model key', () => {
const result = resolveModelString('unknown-model');
expect(result).toBe(DEFAULT_MODELS.claude);
});
it("should warn about unknown model key", () => {
resolveModelString("unknown-model");
it('should warn about unknown model key', () => {
resolveModelString('unknown-model');
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Unknown model key")
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("unknown-model")
);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown model key'));
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown-model'));
});
it("should use custom default for unknown model key", () => {
const customDefault = "claude-opus-4-20241113";
const result = resolveModelString("gpt-4", customDefault);
it('should use custom default for unknown model key', () => {
const customDefault = 'claude-opus-4-20241113';
const result = resolveModelString('gpt-4', customDefault);
expect(result).toBe(customDefault);
});
it("should warn and show default being used", () => {
const customDefault = "claude-custom-default";
resolveModelString("invalid-key", customDefault);
it('should warn and show default being used', () => {
const customDefault = 'claude-custom-default';
resolveModelString('invalid-key', customDefault);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(customDefault)
);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(customDefault));
});
});
describe("case sensitivity", () => {
it("should be case-sensitive for aliases", () => {
const resultUpper = resolveModelString("SONNET");
const resultLower = resolveModelString("sonnet");
describe('case sensitivity', () => {
it('should be case-sensitive for aliases', () => {
const resultUpper = resolveModelString('SONNET');
const resultLower = resolveModelString('sonnet');
// Uppercase should not resolve (falls back to default)
expect(resultUpper).toBe(DEFAULT_MODELS.claude);
@@ -151,165 +143,158 @@ describe("model-resolver", () => {
expect(resultLower).toBe(CLAUDE_MODEL_MAP.sonnet);
});
it("should handle mixed case in claude- strings", () => {
const result = resolveModelString("Claude-Sonnet-4-20250514");
it('should handle mixed case in claude- strings', () => {
const result = resolveModelString('Claude-Sonnet-4-20250514');
// Capital 'C' means it won't match 'claude-', falls back to default
expect(result).toBe(DEFAULT_MODELS.claude);
});
});
describe("edge cases", () => {
it("should handle model key with whitespace", () => {
const result = resolveModelString(" sonnet ");
describe('edge cases', () => {
it('should handle model key with whitespace', () => {
const result = resolveModelString(' sonnet ');
// Will not match due to whitespace, falls back to default
expect(result).toBe(DEFAULT_MODELS.claude);
});
it("should handle special characters in model key", () => {
const result = resolveModelString("model@123");
it('should handle special characters in model key', () => {
const result = resolveModelString('model@123');
expect(result).toBe(DEFAULT_MODELS.claude);
});
});
});
describe("getEffectiveModel", () => {
describe("priority handling", () => {
it("should prioritize explicit model over all others", () => {
const explicit = "claude-opus-4-20241113";
const session = "claude-sonnet-4-20250514";
const defaultModel = "claude-3-5-haiku-20241022";
describe('getEffectiveModel', () => {
describe('priority handling', () => {
it('should prioritize explicit model over all others', () => {
const explicit = 'claude-opus-4-20241113';
const session = 'claude-sonnet-4-20250514';
const defaultModel = 'claude-3-5-haiku-20241022';
const result = getEffectiveModel(explicit, session, defaultModel);
expect(result).toBe(explicit);
});
it("should use session model when explicit is undefined", () => {
const session = "claude-sonnet-4-20250514";
const defaultModel = "claude-3-5-haiku-20241022";
it('should use session model when explicit is undefined', () => {
const session = 'claude-sonnet-4-20250514';
const defaultModel = 'claude-3-5-haiku-20241022';
const result = getEffectiveModel(undefined, session, defaultModel);
expect(result).toBe(session);
});
it("should use default model when both explicit and session are undefined", () => {
const defaultModel = "claude-opus-4-20241113";
it('should use default model when both explicit and session are undefined', () => {
const defaultModel = 'claude-opus-4-20241113';
const result = getEffectiveModel(undefined, undefined, defaultModel);
expect(result).toBe(defaultModel);
});
it("should use system default when all are undefined", () => {
it('should use system default when all are undefined', () => {
const result = getEffectiveModel(undefined, undefined, undefined);
expect(result).toBe(DEFAULT_MODELS.claude);
});
});
describe("with aliases", () => {
it("should resolve explicit model alias", () => {
const result = getEffectiveModel("opus", "sonnet");
describe('with aliases', () => {
it('should resolve explicit model alias', () => {
const result = getEffectiveModel('opus', 'sonnet');
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
});
it("should resolve session model alias when explicit is undefined", () => {
const result = getEffectiveModel(undefined, "haiku");
it('should resolve session model alias when explicit is undefined', () => {
const result = getEffectiveModel(undefined, 'haiku');
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
});
it("should prioritize explicit alias over session full string", () => {
const result = getEffectiveModel(
"sonnet",
"claude-opus-4-20241113"
);
it('should prioritize explicit alias over session full string', () => {
const result = getEffectiveModel('sonnet', 'claude-opus-4-20241113');
expect(result).toBe(CLAUDE_MODEL_MAP.sonnet);
});
});
describe("with empty strings", () => {
it("should treat empty explicit string as undefined", () => {
const session = "claude-sonnet-4-20250514";
describe('with empty strings', () => {
it('should treat empty explicit string as undefined', () => {
const session = 'claude-sonnet-4-20250514';
const result = getEffectiveModel("", session);
const result = getEffectiveModel('', session);
expect(result).toBe(session);
});
it("should treat empty session string as undefined", () => {
const defaultModel = "claude-opus-4-20241113";
it('should treat empty session string as undefined', () => {
const defaultModel = 'claude-opus-4-20241113';
const result = getEffectiveModel(undefined, "", defaultModel);
const result = getEffectiveModel(undefined, '', defaultModel);
expect(result).toBe(defaultModel);
});
it("should handle all empty strings", () => {
const result = getEffectiveModel("", "", "");
it('should handle all empty strings', () => {
const result = getEffectiveModel('', '', '');
// Empty strings are falsy, so explicit || session becomes "" || "" = ""
// Then resolveModelString("", "") returns "" (not in CLAUDE_MODEL_MAP, not containing "claude-")
// This actually returns the custom default which is ""
expect(result).toBe("");
expect(result).toBe('');
});
});
describe("integration scenarios", () => {
it("should handle user overriding session model with alias", () => {
const sessionModel = "claude-sonnet-4-20250514";
const userChoice = "opus";
describe('integration scenarios', () => {
it('should handle user overriding session model with alias', () => {
const sessionModel = 'claude-sonnet-4-20250514';
const userChoice = 'opus';
const result = getEffectiveModel(userChoice, sessionModel);
expect(result).toBe(CLAUDE_MODEL_MAP.opus);
});
it("should handle fallback chain: unknown -> session -> default", () => {
const result = getEffectiveModel(
"invalid",
"also-invalid",
"claude-opus-4-20241113"
);
it('should handle fallback chain: unknown -> session -> default', () => {
const result = getEffectiveModel('invalid', 'also-invalid', 'claude-opus-4-20241113');
// Both invalid models fall back to default
expect(result).toBe("claude-opus-4-20241113");
expect(result).toBe('claude-opus-4-20241113');
});
it("should handle session with alias, no explicit", () => {
const result = getEffectiveModel(undefined, "haiku");
it('should handle session with alias, no explicit', () => {
const result = getEffectiveModel(undefined, 'haiku');
expect(result).toBe(CLAUDE_MODEL_MAP.haiku);
});
});
});
describe("CLAUDE_MODEL_MAP integration", () => {
it("should have valid mappings for all known aliases", () => {
const aliases = ["sonnet", "opus", "haiku"];
describe('CLAUDE_MODEL_MAP integration', () => {
it('should have valid mappings for all known aliases', () => {
const aliases = ['sonnet', 'opus', 'haiku'];
for (const alias of aliases) {
const resolved = resolveModelString(alias);
expect(resolved).toBeDefined();
expect(resolved).toContain("claude-");
expect(resolved).toContain('claude-');
expect(resolved).toBe(CLAUDE_MODEL_MAP[alias]);
}
});
});
describe("DEFAULT_MODELS integration", () => {
it("should use DEFAULT_MODELS.claude as fallback", () => {
describe('DEFAULT_MODELS integration', () => {
it('should use DEFAULT_MODELS.claude as fallback', () => {
const result = resolveModelString(undefined);
expect(result).toBe(DEFAULT_MODELS.claude);
expect(DEFAULT_MODELS.claude).toBeDefined();
expect(DEFAULT_MODELS.claude).toContain("claude-");
expect(DEFAULT_MODELS.claude).toContain('claude-');
});
});
});

View File

@@ -1,15 +1,15 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/index.ts"],
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
thresholds: {
lines: 95,
functions: 95,

View File

@@ -15,6 +15,7 @@ npm install @automaker/platform
## Exports
### Path Management
AutoMaker directory structure utilities.
```typescript
@@ -29,7 +30,7 @@ import {
getWorktreesDir,
getAppSpecPath,
getBranchTrackingPath,
ensureAutomakerDir
ensureAutomakerDir,
} from '@automaker/platform';
// Get AutoMaker directory: /project/.automaker
@@ -49,6 +50,7 @@ await ensureAutomakerDir('/project/path');
```
### Subprocess Management
Spawn and manage subprocesses with JSON-lines output.
```typescript
@@ -60,18 +62,19 @@ const result = await spawnJSONLProcess({
args: ['--output', 'jsonl'],
cwd: '/project/path',
onLine: (data) => console.log('Received:', data),
onError: (error) => console.error('Error:', error)
onError: (error) => console.error('Error:', error),
});
// Spawn regular process
const output = await spawnProcess({
command: 'git',
args: ['status'],
cwd: '/project/path'
cwd: '/project/path',
});
```
### Security Validation
Path validation and security checks.
```typescript
@@ -82,7 +85,7 @@ import {
getAllowedPaths,
getAllowedRootDirectory,
getDataDirectory,
PathNotAllowedError
PathNotAllowedError,
} from '@automaker/platform';
// Initialize allowed paths from environment
@@ -116,7 +119,7 @@ import {
getFeatureDir,
ensureAutomakerDir,
spawnJSONLProcess,
validatePath
validatePath,
} from '@automaker/platform';
async function executeFeature(projectPath: string, featureId: string) {
@@ -138,7 +141,7 @@ async function executeFeature(projectPath: string, featureId: string) {
if (data.type === 'progress') {
console.log('Progress:', data.progress);
}
}
},
});
return result;

View File

@@ -11,7 +11,10 @@
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": ["automaker", "platform"],
"keywords": [
"automaker",
"platform"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

View File

@@ -9,8 +9,8 @@
* Directory creation is handled separately by ensure* functions.
*/
import * as secureFs from "./secure-fs.js";
import path from "path";
import * as secureFs from './secure-fs.js';
import path from 'path';
/**
* Get the automaker data directory root for a project
@@ -22,7 +22,7 @@ import path from "path";
* @returns Absolute path to {projectPath}/.automaker
*/
export function getAutomakerDir(projectPath: string): string {
return path.join(projectPath, ".automaker");
return path.join(projectPath, '.automaker');
}
/**
@@ -34,7 +34,7 @@ export function getAutomakerDir(projectPath: string): string {
* @returns Absolute path to {projectPath}/.automaker/features
*/
export function getFeaturesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "features");
return path.join(getAutomakerDir(projectPath), 'features');
}
/**
@@ -59,11 +59,8 @@ export function getFeatureDir(projectPath: string, featureId: string): string {
* @param featureId - Feature identifier
* @returns Absolute path to {projectPath}/.automaker/features/{featureId}/images
*/
export function getFeatureImagesDir(
projectPath: string,
featureId: string
): string {
return path.join(getFeatureDir(projectPath, featureId), "images");
export function getFeatureImagesDir(projectPath: string, featureId: string): string {
return path.join(getFeatureDir(projectPath, featureId), 'images');
}
/**
@@ -75,7 +72,7 @@ export function getFeatureImagesDir(
* @returns Absolute path to {projectPath}/.automaker/board
*/
export function getBoardDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "board");
return path.join(getAutomakerDir(projectPath), 'board');
}
/**
@@ -87,7 +84,7 @@ export function getBoardDir(projectPath: string): string {
* @returns Absolute path to {projectPath}/.automaker/images
*/
export function getImagesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "images");
return path.join(getAutomakerDir(projectPath), 'images');
}
/**
@@ -99,7 +96,7 @@ export function getImagesDir(projectPath: string): string {
* @returns Absolute path to {projectPath}/.automaker/context
*/
export function getContextDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "context");
return path.join(getAutomakerDir(projectPath), 'context');
}
/**
@@ -111,7 +108,7 @@ export function getContextDir(projectPath: string): string {
* @returns Absolute path to {projectPath}/.automaker/worktrees
*/
export function getWorktreesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "worktrees");
return path.join(getAutomakerDir(projectPath), 'worktrees');
}
/**
@@ -123,7 +120,7 @@ export function getWorktreesDir(projectPath: string): string {
* @returns Absolute path to {projectPath}/.automaker/app_spec.txt
*/
export function getAppSpecPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "app_spec.txt");
return path.join(getAutomakerDir(projectPath), 'app_spec.txt');
}
/**
@@ -135,7 +132,7 @@ export function getAppSpecPath(projectPath: string): string {
* @returns Absolute path to {projectPath}/.automaker/active-branches.json
*/
export function getBranchTrackingPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "active-branches.json");
return path.join(getAutomakerDir(projectPath), 'active-branches.json');
}
/**
@@ -172,7 +169,7 @@ export async function ensureAutomakerDir(projectPath: string): Promise<string> {
* @returns Absolute path to {dataDir}/settings.json
*/
export function getGlobalSettingsPath(dataDir: string): string {
return path.join(dataDir, "settings.json");
return path.join(dataDir, 'settings.json');
}
/**
@@ -185,7 +182,7 @@ export function getGlobalSettingsPath(dataDir: string): string {
* @returns Absolute path to {dataDir}/credentials.json
*/
export function getCredentialsPath(dataDir: string): string {
return path.join(dataDir, "credentials.json");
return path.join(dataDir, 'credentials.json');
}
/**
@@ -198,7 +195,7 @@ export function getCredentialsPath(dataDir: string): string {
* @returns Absolute path to {projectPath}/.automaker/settings.json
*/
export function getProjectSettingsPath(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), "settings.json");
return path.join(getAutomakerDir(projectPath), 'settings.json');
}
/**

View File

@@ -6,10 +6,10 @@
* not just at the API layer. This provides defense-in-depth security.
*/
import fs from "fs/promises";
import type { Dirent } from "fs";
import path from "path";
import { validatePath } from "./security.js";
import fs from 'fs/promises';
import type { Dirent } from 'fs';
import path from 'path';
import { validatePath } from './security.js';
/**
* Wrapper around fs.access that validates path first
@@ -108,11 +108,7 @@ export async function unlink(filePath: string): Promise<void> {
/**
* Wrapper around fs.copyFile that validates both paths first
*/
export async function copyFile(
src: string,
dest: string,
mode?: number
): Promise<void> {
export async function copyFile(src: string, dest: string, mode?: number): Promise<void> {
const validatedSrc = validatePath(src);
const validatedDest = validatePath(dest);
return fs.copyFile(validatedSrc, validatedDest, mode);
@@ -133,10 +129,7 @@ export async function appendFile(
/**
* Wrapper around fs.rename that validates both paths first
*/
export async function rename(
oldPath: string,
newPath: string
): Promise<void> {
export async function rename(oldPath: string, newPath: string): Promise<void> {
const validatedOldPath = validatePath(oldPath);
const validatedNewPath = validatePath(newPath);
return fs.rename(validatedOldPath, validatedNewPath);

View File

@@ -3,17 +3,15 @@
* Enforces ALLOWED_ROOT_DIRECTORY constraint with appData exception
*/
import path from "path";
import path from 'path';
/**
* Error thrown when a path is not allowed by security policy
*/
export class PathNotAllowedError extends Error {
constructor(filePath: string) {
super(
`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`
);
this.name = "PathNotAllowedError";
super(`Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY or DATA_DIR.`);
this.name = 'PathNotAllowedError';
}
}
@@ -33,13 +31,9 @@ export function initAllowedPaths(): void {
const rootDir = process.env.ALLOWED_ROOT_DIRECTORY;
if (rootDir) {
allowedRootDirectory = path.resolve(rootDir);
console.log(
`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`
);
console.log(`[Security] ✓ ALLOWED_ROOT_DIRECTORY configured: ${allowedRootDirectory}`);
} else {
console.log(
"[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths"
);
console.log('[Security] ⚠️ ALLOWED_ROOT_DIRECTORY not set - allowing access to all paths');
}
// Load DATA_DIR (appData exception - always allowed)
@@ -72,10 +66,7 @@ export function isPathAllowed(filePath: string): boolean {
}
// Allow if within ALLOWED_ROOT_DIRECTORY
if (
allowedRootDirectory &&
isPathWithinDirectory(resolvedPath, allowedRootDirectory)
) {
if (allowedRootDirectory && isPathWithinDirectory(resolvedPath, allowedRootDirectory)) {
return true;
}
@@ -101,17 +92,14 @@ export function validatePath(filePath: string): string {
* Check if a path is within a directory, with protection against path traversal
* Returns true only if resolvedPath is within directoryPath
*/
export function isPathWithinDirectory(
resolvedPath: string,
directoryPath: string
): boolean {
export function isPathWithinDirectory(resolvedPath: string, directoryPath: string): boolean {
// Get the relative path from directory to the target
const relativePath = path.relative(directoryPath, resolvedPath);
// If relative path starts with "..", it's outside the directory
// If relative path is absolute, it's outside the directory
// If relative path is empty or ".", it's the directory itself
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
}
/**

View File

@@ -2,8 +2,8 @@
* Subprocess management utilities for CLI providers
*/
import { spawn, type ChildProcess } from "child_process";
import readline from "readline";
import { spawn, type ChildProcess } from 'child_process';
import readline from 'readline';
export interface SubprocessOptions {
command: string;
@@ -23,9 +23,7 @@ export interface SubprocessResult {
/**
* Spawns a subprocess and streams JSONL output line-by-line
*/
export async function* spawnJSONLProcess(
options: SubprocessOptions
): AsyncGenerator<unknown> {
export async function* spawnJSONLProcess(options: SubprocessOptions): AsyncGenerator<unknown> {
const { command, args, cwd, env, abortController, timeout = 30000 } = options;
const processEnv = {
@@ -33,22 +31,22 @@ export async function* spawnJSONLProcess(
...env,
};
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(" ")}`);
console.log(`[SubprocessManager] Spawning: ${command} ${args.slice(0, -1).join(' ')}`);
console.log(`[SubprocessManager] Working directory: ${cwd}`);
const childProcess: ChildProcess = spawn(command, args, {
cwd,
env: processEnv,
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderrOutput = "";
let stderrOutput = '';
let lastOutputTime = Date.now();
let timeoutHandle: NodeJS.Timeout | null = null;
// Collect stderr for error reporting
if (childProcess.stderr) {
childProcess.stderr.on("data", (data: Buffer) => {
childProcess.stderr.on('data', (data: Buffer) => {
const text = data.toString();
stderrOutput += text;
console.error(`[SubprocessManager] stderr: ${text}`);
@@ -64,10 +62,8 @@ export async function* spawnJSONLProcess(
timeoutHandle = setTimeout(() => {
const elapsed = Date.now() - lastOutputTime;
if (elapsed >= timeout) {
console.error(
`[SubprocessManager] Process timeout: no output for ${timeout}ms`
);
childProcess.kill("SIGTERM");
console.error(`[SubprocessManager] Process timeout: no output for ${timeout}ms`);
childProcess.kill('SIGTERM');
}
}, timeout);
};
@@ -76,12 +72,12 @@ export async function* spawnJSONLProcess(
// Setup abort handling
if (abortController) {
abortController.signal.addEventListener("abort", () => {
console.log("[SubprocessManager] Abort signal received, killing process");
abortController.signal.addEventListener('abort', () => {
console.log('[SubprocessManager] Abort signal received, killing process');
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
childProcess.kill("SIGTERM");
childProcess.kill('SIGTERM');
});
}
@@ -102,19 +98,16 @@ export async function* spawnJSONLProcess(
const parsed = JSON.parse(line);
yield parsed;
} catch (parseError) {
console.error(
`[SubprocessManager] Failed to parse JSONL line: ${line}`,
parseError
);
console.error(`[SubprocessManager] Failed to parse JSONL line: ${line}`, parseError);
// Yield error but continue processing
yield {
type: "error",
type: 'error',
error: `Failed to parse output: ${line}`,
};
}
}
} catch (error) {
console.error("[SubprocessManager] Error reading stdout:", error);
console.error('[SubprocessManager] Error reading stdout:', error);
throw error;
} finally {
if (timeoutHandle) {
@@ -125,13 +118,13 @@ export async function* spawnJSONLProcess(
// Wait for process to exit
const exitCode = await new Promise<number | null>((resolve) => {
childProcess.on("exit", (code) => {
childProcess.on('exit', (code) => {
console.log(`[SubprocessManager] Process exited with code: ${code}`);
resolve(code);
});
childProcess.on("error", (error) => {
console.error("[SubprocessManager] Process error:", error);
childProcess.on('error', (error) => {
console.error('[SubprocessManager] Process error:', error);
resolve(null);
});
});
@@ -141,23 +134,21 @@ export async function* spawnJSONLProcess(
const errorMessage = stderrOutput || `Process exited with code ${exitCode}`;
console.error(`[SubprocessManager] Process failed: ${errorMessage}`);
yield {
type: "error",
type: 'error',
error: errorMessage,
};
}
// Process completed successfully
if (exitCode === 0 && !stderrOutput) {
console.log("[SubprocessManager] Process completed successfully");
console.log('[SubprocessManager] Process completed successfully');
}
}
/**
* Spawns a subprocess and collects all output
*/
export async function spawnProcess(
options: SubprocessOptions
): Promise<SubprocessResult> {
export async function spawnProcess(options: SubprocessOptions): Promise<SubprocessResult> {
const { command, args, cwd, env, abortController } = options;
const processEnv = {
@@ -169,37 +160,37 @@ export async function spawnProcess(
const childProcess = spawn(command, args, {
cwd,
env: processEnv,
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = "";
let stderr = "";
let stdout = '';
let stderr = '';
if (childProcess.stdout) {
childProcess.stdout.on("data", (data: Buffer) => {
childProcess.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
}
if (childProcess.stderr) {
childProcess.stderr.on("data", (data: Buffer) => {
childProcess.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
}
// Setup abort handling
if (abortController) {
abortController.signal.addEventListener("abort", () => {
childProcess.kill("SIGTERM");
reject(new Error("Process aborted"));
abortController.signal.addEventListener('abort', () => {
childProcess.kill('SIGTERM');
reject(new Error('Process aborted'));
});
}
childProcess.on("exit", (code) => {
childProcess.on('exit', (code) => {
resolve({ stdout, stderr, exitCode: code });
});
childProcess.on("error", (error) => {
childProcess.on('error', (error) => {
reject(error);
});
});

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
getAutomakerDir,
getFeaturesDir,
@@ -18,18 +18,18 @@ import {
getCredentialsPath,
getProjectSettingsPath,
ensureDataDir,
} from "../src/paths";
} from '../src/paths';
describe("paths.ts", () => {
describe('paths.ts', () => {
let tempDir: string;
let projectPath: string;
let dataDir: string;
beforeEach(async () => {
// Create a temporary directory for testing
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "platform-paths-test-"));
projectPath = path.join(tempDir, "test-project");
dataDir = path.join(tempDir, "user-data");
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'platform-paths-test-'));
projectPath = path.join(tempDir, 'test-project');
dataDir = path.join(tempDir, 'user-data');
await fs.mkdir(projectPath, { recursive: true });
});
@@ -42,98 +42,88 @@ describe("paths.ts", () => {
}
});
describe("Project-level path construction", () => {
it("should return automaker directory path", () => {
describe('Project-level path construction', () => {
it('should return automaker directory path', () => {
const result = getAutomakerDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker"));
expect(result).toBe(path.join(projectPath, '.automaker'));
});
it("should return features directory path", () => {
it('should return features directory path', () => {
const result = getFeaturesDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "features"));
expect(result).toBe(path.join(projectPath, '.automaker', 'features'));
});
it("should return feature directory path", () => {
const featureId = "auth-feature";
it('should return feature directory path', () => {
const featureId = 'auth-feature';
const result = getFeatureDir(projectPath, featureId);
expect(result).toBe(
path.join(projectPath, ".automaker", "features", featureId)
);
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId));
});
it("should return feature images directory path", () => {
const featureId = "auth-feature";
it('should return feature images directory path', () => {
const featureId = 'auth-feature';
const result = getFeatureImagesDir(projectPath, featureId);
expect(result).toBe(
path.join(projectPath, ".automaker", "features", featureId, "images")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'features', featureId, 'images'));
});
it("should return board directory path", () => {
it('should return board directory path', () => {
const result = getBoardDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "board"));
expect(result).toBe(path.join(projectPath, '.automaker', 'board'));
});
it("should return images directory path", () => {
it('should return images directory path', () => {
const result = getImagesDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "images"));
expect(result).toBe(path.join(projectPath, '.automaker', 'images'));
});
it("should return context directory path", () => {
it('should return context directory path', () => {
const result = getContextDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "context"));
expect(result).toBe(path.join(projectPath, '.automaker', 'context'));
});
it("should return worktrees directory path", () => {
it('should return worktrees directory path', () => {
const result = getWorktreesDir(projectPath);
expect(result).toBe(path.join(projectPath, ".automaker", "worktrees"));
expect(result).toBe(path.join(projectPath, '.automaker', 'worktrees'));
});
it("should return app spec file path", () => {
it('should return app spec file path', () => {
const result = getAppSpecPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "app_spec.txt")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'app_spec.txt'));
});
it("should return branch tracking file path", () => {
it('should return branch tracking file path', () => {
const result = getBranchTrackingPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "active-branches.json")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'active-branches.json'));
});
it("should return project settings file path", () => {
it('should return project settings file path', () => {
const result = getProjectSettingsPath(projectPath);
expect(result).toBe(
path.join(projectPath, ".automaker", "settings.json")
);
expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json'));
});
});
describe("Global settings path construction", () => {
it("should return global settings path", () => {
describe('Global settings path construction', () => {
it('should return global settings path', () => {
const result = getGlobalSettingsPath(dataDir);
expect(result).toBe(path.join(dataDir, "settings.json"));
expect(result).toBe(path.join(dataDir, 'settings.json'));
});
it("should return credentials path", () => {
it('should return credentials path', () => {
const result = getCredentialsPath(dataDir);
expect(result).toBe(path.join(dataDir, "credentials.json"));
expect(result).toBe(path.join(dataDir, 'credentials.json'));
});
});
describe("Directory creation", () => {
it("should create automaker directory", async () => {
describe('Directory creation', () => {
it('should create automaker directory', async () => {
const automakerDir = await ensureAutomakerDir(projectPath);
expect(automakerDir).toBe(path.join(projectPath, ".automaker"));
expect(automakerDir).toBe(path.join(projectPath, '.automaker'));
const stats = await fs.stat(automakerDir);
expect(stats.isDirectory()).toBe(true);
});
it("should be idempotent when creating automaker directory", async () => {
it('should be idempotent when creating automaker directory', async () => {
// Create directory first time
const firstResult = await ensureAutomakerDir(projectPath);
@@ -146,7 +136,7 @@ describe("paths.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should create data directory", async () => {
it('should create data directory', async () => {
const result = await ensureDataDir(dataDir);
expect(result).toBe(dataDir);
@@ -155,7 +145,7 @@ describe("paths.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should be idempotent when creating data directory", async () => {
it('should be idempotent when creating data directory', async () => {
// Create directory first time
const firstResult = await ensureDataDir(dataDir);
@@ -168,13 +158,8 @@ describe("paths.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should create nested directories recursively", async () => {
const deepProjectPath = path.join(
tempDir,
"nested",
"deep",
"project"
);
it('should create nested directories recursively', async () => {
const deepProjectPath = path.join(tempDir, 'nested', 'deep', 'project');
await fs.mkdir(deepProjectPath, { recursive: true });
const automakerDir = await ensureAutomakerDir(deepProjectPath);
@@ -184,29 +169,29 @@ describe("paths.ts", () => {
});
});
describe("Path handling with special characters", () => {
it("should handle feature IDs with special characters", () => {
const featureId = "feature-with-dashes_and_underscores";
describe('Path handling with special characters', () => {
it('should handle feature IDs with special characters', () => {
const featureId = 'feature-with-dashes_and_underscores';
const result = getFeatureDir(projectPath, featureId);
expect(result).toContain(featureId);
});
it("should handle paths with spaces", () => {
const pathWithSpaces = path.join(tempDir, "path with spaces");
it('should handle paths with spaces', () => {
const pathWithSpaces = path.join(tempDir, 'path with spaces');
const result = getAutomakerDir(pathWithSpaces);
expect(result).toBe(path.join(pathWithSpaces, ".automaker"));
expect(result).toBe(path.join(pathWithSpaces, '.automaker'));
});
});
describe("Path relationships", () => {
it("should have feature dir as child of features dir", () => {
describe('Path relationships', () => {
it('should have feature dir as child of features dir', () => {
const featuresDir = getFeaturesDir(projectPath);
const featureDir = getFeatureDir(projectPath, "test-feature");
const featureDir = getFeatureDir(projectPath, 'test-feature');
expect(featureDir.startsWith(featuresDir)).toBe(true);
});
it("should have all project paths under automaker dir", () => {
it('should have all project paths under automaker dir', () => {
const automakerDir = getAutomakerDir(projectPath);
const paths = [
getFeaturesDir(projectPath),

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import path from "path";
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'path';
describe("security.ts", () => {
describe('security.ts', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
@@ -16,176 +16,162 @@ describe("security.ts", () => {
process.env = originalEnv;
});
describe("initAllowedPaths", () => {
it("should load ALLOWED_ROOT_DIRECTORY if set", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
describe('initAllowedPaths', () => {
it('should load ALLOWED_ROOT_DIRECTORY if set', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
delete process.env.DATA_DIR;
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/projects"));
expect(allowed).toContain(path.resolve('/projects'));
});
it("should load DATA_DIR if set", async () => {
it('should load DATA_DIR if set', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = "/data/directory";
process.env.DATA_DIR = '/data/directory';
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/data/directory"));
expect(allowed).toContain(path.resolve('/data/directory'));
});
it("should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/app/data";
it('should load both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
process.env.DATA_DIR = '/app/data';
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/projects"));
expect(allowed).toContain(path.resolve("/app/data"));
expect(allowed).toContain(path.resolve('/projects'));
expect(allowed).toContain(path.resolve('/app/data'));
});
it("should handle missing environment variables gracefully", async () => {
it('should handle missing environment variables gracefully', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths } = await import("../src/security");
const { initAllowedPaths } = await import('../src/security');
expect(() => initAllowedPaths()).not.toThrow();
});
});
describe("isPathAllowed", () => {
it("should allow paths within ALLOWED_ROOT_DIRECTORY", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
describe('isPathAllowed', () => {
it('should allow paths within ALLOWED_ROOT_DIRECTORY', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed("/allowed/file.txt")).toBe(true);
expect(isPathAllowed("/allowed/subdir/file.txt")).toBe(true);
expect(isPathAllowed('/allowed/file.txt')).toBe(true);
expect(isPathAllowed('/allowed/subdir/file.txt')).toBe(true);
});
it("should deny paths outside ALLOWED_ROOT_DIRECTORY", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
it('should deny paths outside ALLOWED_ROOT_DIRECTORY', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed("/not-allowed/file.txt")).toBe(false);
expect(isPathAllowed("/etc/passwd")).toBe(false);
expect(isPathAllowed('/not-allowed/file.txt')).toBe(false);
expect(isPathAllowed('/etc/passwd')).toBe(false);
});
it("should always allow DATA_DIR paths", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/app/data";
it('should always allow DATA_DIR paths', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
process.env.DATA_DIR = '/app/data';
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// DATA_DIR paths are always allowed
expect(isPathAllowed("/app/data/settings.json")).toBe(true);
expect(isPathAllowed("/app/data/credentials.json")).toBe(true);
expect(isPathAllowed('/app/data/settings.json')).toBe(true);
expect(isPathAllowed('/app/data/credentials.json')).toBe(true);
});
it("should allow all paths when no restrictions configured", async () => {
it('should allow all paths when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
expect(isPathAllowed("/any/path")).toBe(true);
expect(isPathAllowed("/etc/passwd")).toBe(true);
expect(isPathAllowed('/any/path')).toBe(true);
expect(isPathAllowed('/etc/passwd')).toBe(true);
});
it("should allow all paths when only DATA_DIR is configured", async () => {
it('should allow all paths when only DATA_DIR is configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
process.env.DATA_DIR = "/data";
process.env.DATA_DIR = '/data';
const { initAllowedPaths, isPathAllowed } =
await import("../src/security");
const { initAllowedPaths, isPathAllowed } = await import('../src/security');
initAllowedPaths();
// DATA_DIR should be allowed
expect(isPathAllowed("/data/file.txt")).toBe(true);
expect(isPathAllowed('/data/file.txt')).toBe(true);
// And all other paths should be allowed since no ALLOWED_ROOT_DIRECTORY restriction
expect(isPathAllowed("/any/path")).toBe(true);
expect(isPathAllowed('/any/path')).toBe(true);
});
});
describe("validatePath", () => {
it("should return resolved path for allowed paths", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
describe('validatePath', () => {
it('should return resolved path for allowed paths', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath } =
await import("../src/security");
const { initAllowedPaths, validatePath } = await import('../src/security');
initAllowedPaths();
const result = validatePath("/allowed/file.txt");
expect(result).toBe(path.resolve("/allowed/file.txt"));
const result = validatePath('/allowed/file.txt');
expect(result).toBe(path.resolve('/allowed/file.txt'));
});
it("should throw error for paths outside allowed directories", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/allowed";
it('should throw error for paths outside allowed directories', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/allowed';
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath, PathNotAllowedError } =
await import("../src/security");
await import('../src/security');
initAllowedPaths();
expect(() => validatePath("/not-allowed/file.txt")).toThrow(
PathNotAllowedError
);
expect(() => validatePath('/not-allowed/file.txt')).toThrow(PathNotAllowedError);
});
it("should resolve relative paths", async () => {
it('should resolve relative paths', async () => {
const cwd = process.cwd();
process.env.ALLOWED_ROOT_DIRECTORY = cwd;
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath } =
await import("../src/security");
const { initAllowedPaths, validatePath } = await import('../src/security');
initAllowedPaths();
const result = validatePath("./file.txt");
expect(result).toBe(path.resolve(cwd, "./file.txt"));
const result = validatePath('./file.txt');
expect(result).toBe(path.resolve(cwd, './file.txt'));
});
it("should not throw when no restrictions configured", async () => {
it('should not throw when no restrictions configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths, validatePath } =
await import("../src/security");
const { initAllowedPaths, validatePath } = await import('../src/security');
initAllowedPaths();
expect(() => validatePath("/any/path")).not.toThrow();
expect(() => validatePath('/any/path')).not.toThrow();
});
});
describe("getAllowedPaths", () => {
it("should return empty array when no paths configured", async () => {
describe('getAllowedPaths', () => {
it('should return empty array when no paths configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
delete process.env.DATA_DIR;
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
@@ -193,58 +179,53 @@ describe("security.ts", () => {
expect(allowed).toHaveLength(0);
});
it("should return configured paths", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
process.env.DATA_DIR = "/data";
it('should return configured paths', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
process.env.DATA_DIR = '/data';
const { initAllowedPaths, getAllowedPaths } =
await import("../src/security");
const { initAllowedPaths, getAllowedPaths } = await import('../src/security');
initAllowedPaths();
const allowed = getAllowedPaths();
expect(allowed).toContain(path.resolve("/projects"));
expect(allowed).toContain(path.resolve("/data"));
expect(allowed).toContain(path.resolve('/projects'));
expect(allowed).toContain(path.resolve('/data'));
});
});
describe("getAllowedRootDirectory", () => {
it("should return the configured root directory", async () => {
process.env.ALLOWED_ROOT_DIRECTORY = "/projects";
describe('getAllowedRootDirectory', () => {
it('should return the configured root directory', async () => {
process.env.ALLOWED_ROOT_DIRECTORY = '/projects';
const { initAllowedPaths, getAllowedRootDirectory } =
await import("../src/security");
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
initAllowedPaths();
expect(getAllowedRootDirectory()).toBe(path.resolve("/projects"));
expect(getAllowedRootDirectory()).toBe(path.resolve('/projects'));
});
it("should return null when not configured", async () => {
it('should return null when not configured', async () => {
delete process.env.ALLOWED_ROOT_DIRECTORY;
const { initAllowedPaths, getAllowedRootDirectory } =
await import("../src/security");
const { initAllowedPaths, getAllowedRootDirectory } = await import('../src/security');
initAllowedPaths();
expect(getAllowedRootDirectory()).toBeNull();
});
});
describe("getDataDirectory", () => {
it("should return the configured data directory", async () => {
process.env.DATA_DIR = "/data";
describe('getDataDirectory', () => {
it('should return the configured data directory', async () => {
process.env.DATA_DIR = '/data';
const { initAllowedPaths, getDataDirectory } =
await import("../src/security");
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
initAllowedPaths();
expect(getDataDirectory()).toBe(path.resolve("/data"));
expect(getDataDirectory()).toBe(path.resolve('/data'));
});
it("should return null when not configured", async () => {
it('should return null when not configured', async () => {
delete process.env.DATA_DIR;
const { initAllowedPaths, getDataDirectory } =
await import("../src/security");
const { initAllowedPaths, getDataDirectory } = await import('../src/security');
initAllowedPaths();
expect(getDataDirectory()).toBeNull();

View File

@@ -1,21 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
spawnJSONLProcess,
spawnProcess,
type SubprocessOptions,
} from "../src/subprocess";
import * as cp from "child_process";
import { EventEmitter } from "events";
import { Readable } from "stream";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { spawnJSONLProcess, spawnProcess, type SubprocessOptions } from '../src/subprocess';
import * as cp from 'child_process';
import { EventEmitter } from 'events';
import { Readable } from 'stream';
vi.mock("child_process");
vi.mock('child_process');
/**
* Helper to collect all items from an async generator
*/
async function collectAsyncGenerator<T>(
generator: AsyncGenerator<T>
): Promise<T[]> {
async function collectAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<T[]> {
const results: T[] = [];
for await (const item of generator) {
results.push(item);
@@ -23,7 +17,7 @@ async function collectAsyncGenerator<T>(
return results;
}
describe("subprocess.ts", () => {
describe('subprocess.ts', () => {
let consoleSpy: {
log: ReturnType<typeof vi.spyOn>;
error: ReturnType<typeof vi.spyOn>;
@@ -32,8 +26,8 @@ describe("subprocess.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => {}),
error: vi.spyOn(console, "error").mockImplementation(() => {}),
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
};
});
@@ -71,7 +65,7 @@ describe("subprocess.ts", () => {
// Emit stderr lines immediately
if (config.stderrLines) {
for (const line of config.stderrLines) {
stderr.emit("data", Buffer.from(line));
stderr.emit('data', Buffer.from(line));
}
}
@@ -79,7 +73,7 @@ describe("subprocess.ts", () => {
const emitLines = async () => {
if (config.stdoutLines) {
for (const line of config.stdoutLines) {
stdout.push(line + "\n");
stdout.push(line + '\n');
// Small delay to allow readline to process
await new Promise((resolve) => setImmediate(resolve));
}
@@ -90,15 +84,13 @@ describe("subprocess.ts", () => {
stdout.push(null); // End stdout
// Small delay before exit
await new Promise((resolve) =>
setTimeout(resolve, config.delayMs ?? 10)
);
await new Promise((resolve) => setTimeout(resolve, config.delayMs ?? 10));
// Emit exit or error
if (config.error) {
mockProcess.emit("error", config.error);
mockProcess.emit('error', config.error);
} else {
mockProcess.emit("exit", config.exitCode ?? 0);
mockProcess.emit('exit', config.exitCode ?? 0);
}
};
@@ -108,14 +100,14 @@ describe("subprocess.ts", () => {
return mockProcess;
}
describe("spawnJSONLProcess", () => {
describe('spawnJSONLProcess', () => {
const baseOptions: SubprocessOptions = {
command: "test-command",
args: ["arg1", "arg2"],
cwd: "/test/dir",
command: 'test-command',
args: ['arg1', 'arg2'],
cwd: '/test/dir',
};
it("should yield parsed JSONL objects line by line", async () => {
it('should yield parsed JSONL objects line by line', async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"start","id":1}',
@@ -131,19 +123,14 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(3);
expect(results[0]).toEqual({ type: "start", id: 1 });
expect(results[1]).toEqual({ type: "progress", value: 50 });
expect(results[2]).toEqual({ type: "complete", result: "success" });
expect(results[0]).toEqual({ type: 'start', id: 1 });
expect(results[1]).toEqual({ type: 'progress', value: 50 });
expect(results[2]).toEqual({ type: 'complete', result: 'success' });
});
it("should skip empty lines", async () => {
it('should skip empty lines', async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"first"}',
"",
" ",
'{"type":"second"}',
],
stdoutLines: ['{"type":"first"}', '', ' ', '{"type":"second"}'],
exitCode: 0,
});
@@ -153,17 +140,13 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "first" });
expect(results[1]).toEqual({ type: "second" });
expect(results[0]).toEqual({ type: 'first' });
expect(results[1]).toEqual({ type: 'second' });
});
it("should yield error for malformed JSON and continue processing", async () => {
it('should yield error for malformed JSON and continue processing', async () => {
const mockProcess = createMockProcess({
stdoutLines: [
'{"type":"valid"}',
"{invalid json}",
'{"type":"also_valid"}',
],
stdoutLines: ['{"type":"valid"}', '{invalid json}', '{"type":"also_valid"}'],
exitCode: 0,
});
@@ -173,18 +156,18 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(3);
expect(results[0]).toEqual({ type: "valid" });
expect(results[0]).toEqual({ type: 'valid' });
expect(results[1]).toMatchObject({
type: "error",
error: expect.stringContaining("Failed to parse output"),
type: 'error',
error: expect.stringContaining('Failed to parse output'),
});
expect(results[2]).toEqual({ type: "also_valid" });
expect(results[2]).toEqual({ type: 'also_valid' });
});
it("should collect stderr output", async () => {
it('should collect stderr output', async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"test"}'],
stderrLines: ["Warning: something happened", "Error: critical issue"],
stderrLines: ['Warning: something happened', 'Error: critical issue'],
exitCode: 0,
});
@@ -194,17 +177,17 @@ describe("subprocess.ts", () => {
await collectAsyncGenerator(generator);
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Warning: something happened")
expect.stringContaining('Warning: something happened')
);
expect(consoleSpy.error).toHaveBeenCalledWith(
expect.stringContaining("Error: critical issue")
expect.stringContaining('Error: critical issue')
);
});
it("should yield error on non-zero exit code", async () => {
it('should yield error on non-zero exit code', async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"started"}'],
stderrLines: ["Process failed with error"],
stderrLines: ['Process failed with error'],
exitCode: 1,
});
@@ -214,14 +197,14 @@ describe("subprocess.ts", () => {
const results = await collectAsyncGenerator(generator);
expect(results).toHaveLength(2);
expect(results[0]).toEqual({ type: "started" });
expect(results[0]).toEqual({ type: 'started' });
expect(results[1]).toMatchObject({
type: "error",
error: expect.stringContaining("Process failed with error"),
type: 'error',
error: expect.stringContaining('Process failed with error'),
});
});
it("should yield error with exit code when stderr is empty", async () => {
it('should yield error with exit code when stderr is empty', async () => {
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"test"}'],
exitCode: 127,
@@ -234,14 +217,14 @@ describe("subprocess.ts", () => {
expect(results).toHaveLength(2);
expect(results[1]).toMatchObject({
type: "error",
error: "Process exited with code 127",
type: 'error',
error: 'Process exited with code 127',
});
});
it("should handle process spawn errors", async () => {
it('should handle process spawn errors', async () => {
const mockProcess = createMockProcess({
error: new Error("Command not found"),
error: new Error('Command not found'),
});
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
@@ -254,7 +237,7 @@ describe("subprocess.ts", () => {
expect(results).toEqual([]);
});
it("should kill process on AbortController signal", async () => {
it('should kill process on AbortController signal', async () => {
const abortController = new AbortController();
const mockProcess = createMockProcess({
stdoutLines: ['{"type":"start"}'],
@@ -277,53 +260,51 @@ describe("subprocess.ts", () => {
await promise;
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
expect(consoleSpy.log).toHaveBeenCalledWith(
expect.stringContaining("Abort signal received")
);
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Abort signal received'));
});
it("should spawn process with correct arguments", async () => {
it('should spawn process with correct arguments', async () => {
const mockProcess = createMockProcess({ exitCode: 0 });
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const options: SubprocessOptions = {
command: "my-command",
args: ["--flag", "value"],
cwd: "/work/dir",
env: { CUSTOM_VAR: "test" },
command: 'my-command',
args: ['--flag', 'value'],
cwd: '/work/dir',
env: { CUSTOM_VAR: 'test' },
};
const generator = spawnJSONLProcess(options);
await collectAsyncGenerator(generator);
expect(cp.spawn).toHaveBeenCalledWith("my-command", ["--flag", "value"], {
cwd: "/work/dir",
env: expect.objectContaining({ CUSTOM_VAR: "test" }),
stdio: ["ignore", "pipe", "pipe"],
expect(cp.spawn).toHaveBeenCalledWith('my-command', ['--flag', 'value'], {
cwd: '/work/dir',
env: expect.objectContaining({ CUSTOM_VAR: 'test' }),
stdio: ['ignore', 'pipe', 'pipe'],
});
});
it("should merge env with process.env", async () => {
it('should merge env with process.env', async () => {
const mockProcess = createMockProcess({ exitCode: 0 });
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
const options: SubprocessOptions = {
command: "test",
command: 'test',
args: [],
cwd: "/test",
env: { CUSTOM: "value" },
cwd: '/test',
env: { CUSTOM: 'value' },
};
const generator = spawnJSONLProcess(options);
await collectAsyncGenerator(generator);
expect(cp.spawn).toHaveBeenCalledWith(
"test",
'test',
[],
expect.objectContaining({
env: expect.objectContaining({
CUSTOM: "value",
CUSTOM: 'value',
// Should also include existing process.env
NODE_ENV: process.env.NODE_ENV,
}),
@@ -331,9 +312,9 @@ describe("subprocess.ts", () => {
);
});
it("should handle complex JSON objects", async () => {
it('should handle complex JSON objects', async () => {
const complexObject = {
type: "complex",
type: 'complex',
nested: { deep: { value: [1, 2, 3] } },
array: [{ id: 1 }, { id: 2 }],
string: 'with "quotes" and \\backslashes',
@@ -354,14 +335,14 @@ describe("subprocess.ts", () => {
});
});
describe("spawnProcess", () => {
describe('spawnProcess', () => {
const baseOptions: SubprocessOptions = {
command: "test-command",
args: ["arg1"],
cwd: "/test",
command: 'test-command',
args: ['arg1'],
cwd: '/test',
};
it("should collect stdout and stderr", async () => {
it('should collect stdout and stderr', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -377,25 +358,25 @@ describe("subprocess.ts", () => {
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
stdout.push("line 1\n");
stdout.push("line 2\n");
stdout.push('line 1\n');
stdout.push('line 2\n');
stdout.push(null);
stderr.push("error 1\n");
stderr.push("error 2\n");
stderr.push('error 1\n');
stderr.push('error 2\n');
stderr.push(null);
mockProcess.emit("exit", 0);
mockProcess.emit('exit', 0);
}, 10);
const result = await spawnProcess(baseOptions);
expect(result.stdout).toBe("line 1\nline 2\n");
expect(result.stderr).toBe("error 1\nerror 2\n");
expect(result.stdout).toBe('line 1\nline 2\n');
expect(result.stderr).toBe('error 1\nerror 2\n');
expect(result.exitCode).toBe(0);
});
it("should return correct exit code", async () => {
it('should return correct exit code', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -410,7 +391,7 @@ describe("subprocess.ts", () => {
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 42);
mockProcess.emit('exit', 42);
}, 10);
const result = await spawnProcess(baseOptions);
@@ -418,7 +399,7 @@ describe("subprocess.ts", () => {
expect(result.exitCode).toBe(42);
});
it("should handle process errors", async () => {
it('should handle process errors', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -431,13 +412,13 @@ describe("subprocess.ts", () => {
vi.mocked(cp.spawn).mockReturnValue(mockProcess);
setTimeout(() => {
mockProcess.emit("error", new Error("Spawn failed"));
mockProcess.emit('error', new Error('Spawn failed'));
}, 10);
await expect(spawnProcess(baseOptions)).rejects.toThrow("Spawn failed");
await expect(spawnProcess(baseOptions)).rejects.toThrow('Spawn failed');
});
it("should handle AbortController signal", async () => {
it('should handle AbortController signal', async () => {
const abortController = new AbortController();
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
@@ -452,14 +433,14 @@ describe("subprocess.ts", () => {
setTimeout(() => abortController.abort(), 20);
await expect(
spawnProcess({ ...baseOptions, abortController })
).rejects.toThrow("Process aborted");
await expect(spawnProcess({ ...baseOptions, abortController })).rejects.toThrow(
'Process aborted'
);
expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM");
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
});
it("should spawn with correct options", async () => {
it('should spawn with correct options', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -474,26 +455,26 @@ describe("subprocess.ts", () => {
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 0);
mockProcess.emit('exit', 0);
}, 10);
const options: SubprocessOptions = {
command: "my-cmd",
args: ["--verbose"],
cwd: "/my/dir",
env: { MY_VAR: "value" },
command: 'my-cmd',
args: ['--verbose'],
cwd: '/my/dir',
env: { MY_VAR: 'value' },
};
await spawnProcess(options);
expect(cp.spawn).toHaveBeenCalledWith("my-cmd", ["--verbose"], {
cwd: "/my/dir",
env: expect.objectContaining({ MY_VAR: "value" }),
stdio: ["ignore", "pipe", "pipe"],
expect(cp.spawn).toHaveBeenCalledWith('my-cmd', ['--verbose'], {
cwd: '/my/dir',
env: expect.objectContaining({ MY_VAR: 'value' }),
stdio: ['ignore', 'pipe', 'pipe'],
});
});
it("should handle empty stdout and stderr", async () => {
it('should handle empty stdout and stderr', async () => {
const mockProcess = new EventEmitter() as cp.ChildProcess & {
stdout: Readable;
stderr: Readable;
@@ -508,13 +489,13 @@ describe("subprocess.ts", () => {
setTimeout(() => {
mockProcess.stdout.push(null);
mockProcess.stderr.push(null);
mockProcess.emit("exit", 0);
mockProcess.emit('exit', 0);
}, 10);
const result = await spawnProcess(baseOptions);
expect(result.stdout).toBe("");
expect(result.stderr).toBe("");
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
expect(result.exitCode).toBe(0);
});
});

View File

@@ -1,15 +1,15 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/index.ts"],
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
thresholds: {
// Excellent coverage: 94.69% stmts, 80.48% branches, 97.14% funcs, 94.64% lines
// All files now have comprehensive tests

View File

@@ -32,7 +32,7 @@ import {
IMPROVE_SYSTEM_PROMPT,
TECHNICAL_SYSTEM_PROMPT,
SIMPLIFY_SYSTEM_PROMPT,
ACCEPTANCE_SYSTEM_PROMPT
ACCEPTANCE_SYSTEM_PROMPT,
} from '@automaker/prompts';
console.log(IMPROVE_SYSTEM_PROMPT); // Full system prompt for improve mode
@@ -50,7 +50,7 @@ import { getEnhancementPrompt } from '@automaker/prompts';
const result = getEnhancementPrompt('improve', 'make app faster');
console.log(result.systemPrompt); // System instructions for improve mode
console.log(result.userPrompt); // User prompt with examples and input
console.log(result.userPrompt); // User prompt with examples and input
```
#### `getSystemPrompt(mode)`
@@ -122,7 +122,7 @@ async function enhanceDescription(description: string, mode: string) {
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }]
messages: [{ role: 'user', content: userPrompt }],
});
return response.content[0].text;
@@ -187,7 +187,7 @@ app.post('/api/enhance', async (req, res) => {
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }]
messages: [{ role: 'user', content: userPrompt }],
});
logger.info(`Enhanced with mode: ${mode}`);
@@ -207,6 +207,7 @@ Transforms vague or unclear requests into clear, actionable specifications.
**Before:** "make app faster"
**After:** "Optimize application performance by:
1. Profiling code to identify bottlenecks
2. Implementing caching for frequently accessed data
3. Optimizing database queries..."
@@ -217,6 +218,7 @@ Adds implementation details and technical specifications.
**Before:** "add search"
**After:** "Implement full-text search using:
- Backend: Elasticsearch or PostgreSQL full-text search
- Frontend: Debounced search input with loading states
- API: GET /api/search endpoint with pagination..."
@@ -234,6 +236,7 @@ Adds testable acceptance criteria to feature descriptions.
**Before:** "user login"
**After:** "User login feature
- User can enter email and password
- System validates credentials
- On success: redirect to dashboard

View File

@@ -11,7 +11,11 @@
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": ["automaker", "prompts", "ai"],
"keywords": [
"automaker",
"prompts",
"ai"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

View File

@@ -10,10 +10,10 @@
* Uses chain-of-thought prompting with few-shot examples for consistent results.
*/
import type { EnhancementMode, EnhancementExample } from "@automaker/types";
import type { EnhancementMode, EnhancementExample } from '@automaker/types';
// Re-export enhancement types from shared package
export type { EnhancementMode, EnhancementExample } from "@automaker/types";
export type { EnhancementMode, EnhancementExample } from '@automaker/types';
/**
* System prompt for the "improve" enhancement mode.
@@ -142,7 +142,7 @@ Output the original description followed by a clear "Acceptance Criteria:" secti
*/
export const IMPROVE_EXAMPLES: EnhancementExample[] = [
{
input: "add dark mode",
input: 'add dark mode',
output: `Add Dark Mode Theme Support
Implement a dark mode option that allows users to switch between light and dark themes throughout the application.
@@ -155,7 +155,7 @@ Requirements:
- Ensure smooth transition when switching themes`,
},
{
input: "fix the login bug",
input: 'fix the login bug',
output: `Fix Login Authentication Flow
Investigate and resolve issues preventing users from successfully logging in to the application.
@@ -168,7 +168,7 @@ Requirements:
- Test with various credential combinations (valid, invalid, edge cases)`,
},
{
input: "make it faster",
input: 'make it faster',
output: `Improve Application Performance
Optimize application loading and response times to enhance user experience.
@@ -188,7 +188,7 @@ Requirements:
*/
export const TECHNICAL_EXAMPLES: EnhancementExample[] = [
{
input: "Add user profile page",
input: 'Add user profile page',
output: `Add User Profile Page
Create a dedicated profile page for viewing and editing user information.
@@ -209,7 +209,7 @@ Components:
Security: Ensure users can only edit their own profile (auth middleware)`,
},
{
input: "Add search functionality",
input: 'Add search functionality',
output: `Add Search Functionality
Implement full-text search across application content.
@@ -271,7 +271,7 @@ Enhancements:
*/
export const ACCEPTANCE_EXAMPLES: EnhancementExample[] = [
{
input: "Add password reset functionality",
input: 'Add password reset functionality',
output: `Add Password Reset Functionality
Allow users to reset their password via email when they forget it.
@@ -291,7 +291,7 @@ Acceptance Criteria:
6. Given a reset link older than 1 hour, when a user clicks it, then they should see an expiration message with option to request a new link.`,
},
{
input: "Shopping cart checkout",
input: 'Shopping cart checkout',
output: `Shopping Cart Checkout
Implement the checkout flow for purchasing items in the shopping cart.
@@ -348,10 +348,10 @@ export interface EnhancementPromptConfig {
* Descriptions for each enhancement mode
*/
const MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
improve: "Transform vague requests into clear, actionable task descriptions",
technical: "Add implementation details and technical specifications",
simplify: "Make verbose descriptions concise and focused",
acceptance: "Add testable acceptance criteria to task descriptions",
improve: 'Transform vague requests into clear, actionable task descriptions',
technical: 'Add implementation details and technical specifications',
simplify: 'Make verbose descriptions concise and focused',
acceptance: 'Add testable acceptance criteria to task descriptions',
};
/**
@@ -362,7 +362,7 @@ const MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
*/
export function getEnhancementPrompt(mode: string): EnhancementPromptConfig {
const normalizedMode = mode.toLowerCase() as EnhancementMode;
const validMode = normalizedMode in SYSTEM_PROMPTS ? normalizedMode : "improve";
const validMode = normalizedMode in SYSTEM_PROMPTS ? normalizedMode : 'improve';
return {
systemPrompt: SYSTEM_PROMPTS[validMode],
@@ -415,7 +415,7 @@ export function buildUserPrompt(
(example, index) =>
`Example ${index + 1}:\nInput: ${example.input}\nOutput: ${example.output}`
)
.join("\n\n---\n\n");
.join('\n\n---\n\n');
return `Here are some examples of how to enhance task descriptions:

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect } from 'vitest';
import {
getEnhancementPrompt,
getSystemPrompt,
@@ -14,93 +14,93 @@ import {
TECHNICAL_EXAMPLES,
SIMPLIFY_EXAMPLES,
ACCEPTANCE_EXAMPLES,
} from "../src/enhancement.js";
} from '../src/enhancement.js';
describe("enhancement.ts", () => {
describe("System Prompt Constants", () => {
it("should export IMPROVE_SYSTEM_PROMPT", () => {
describe('enhancement.ts', () => {
describe('System Prompt Constants', () => {
it('should export IMPROVE_SYSTEM_PROMPT', () => {
expect(IMPROVE_SYSTEM_PROMPT).toBeDefined();
expect(typeof IMPROVE_SYSTEM_PROMPT).toBe("string");
expect(IMPROVE_SYSTEM_PROMPT).toContain("vague, unclear");
expect(IMPROVE_SYSTEM_PROMPT).toContain("actionable");
expect(typeof IMPROVE_SYSTEM_PROMPT).toBe('string');
expect(IMPROVE_SYSTEM_PROMPT).toContain('vague, unclear');
expect(IMPROVE_SYSTEM_PROMPT).toContain('actionable');
});
it("should export TECHNICAL_SYSTEM_PROMPT", () => {
it('should export TECHNICAL_SYSTEM_PROMPT', () => {
expect(TECHNICAL_SYSTEM_PROMPT).toBeDefined();
expect(typeof TECHNICAL_SYSTEM_PROMPT).toBe("string");
expect(TECHNICAL_SYSTEM_PROMPT).toContain("technical");
expect(TECHNICAL_SYSTEM_PROMPT).toContain("implementation");
expect(typeof TECHNICAL_SYSTEM_PROMPT).toBe('string');
expect(TECHNICAL_SYSTEM_PROMPT).toContain('technical');
expect(TECHNICAL_SYSTEM_PROMPT).toContain('implementation');
});
it("should export SIMPLIFY_SYSTEM_PROMPT", () => {
it('should export SIMPLIFY_SYSTEM_PROMPT', () => {
expect(SIMPLIFY_SYSTEM_PROMPT).toBeDefined();
expect(typeof SIMPLIFY_SYSTEM_PROMPT).toBe("string");
expect(SIMPLIFY_SYSTEM_PROMPT).toContain("verbose");
expect(SIMPLIFY_SYSTEM_PROMPT).toContain("concise");
expect(typeof SIMPLIFY_SYSTEM_PROMPT).toBe('string');
expect(SIMPLIFY_SYSTEM_PROMPT).toContain('verbose');
expect(SIMPLIFY_SYSTEM_PROMPT).toContain('concise');
});
it("should export ACCEPTANCE_SYSTEM_PROMPT", () => {
it('should export ACCEPTANCE_SYSTEM_PROMPT', () => {
expect(ACCEPTANCE_SYSTEM_PROMPT).toBeDefined();
expect(typeof ACCEPTANCE_SYSTEM_PROMPT).toBe("string");
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain("acceptance criteria");
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain("testable");
expect(typeof ACCEPTANCE_SYSTEM_PROMPT).toBe('string');
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('acceptance criteria');
expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('testable');
});
});
describe("Examples Constants", () => {
it("should export IMPROVE_EXAMPLES with valid structure", () => {
describe('Examples Constants', () => {
it('should export IMPROVE_EXAMPLES with valid structure', () => {
expect(IMPROVE_EXAMPLES).toBeDefined();
expect(Array.isArray(IMPROVE_EXAMPLES)).toBe(true);
expect(IMPROVE_EXAMPLES.length).toBeGreaterThan(0);
IMPROVE_EXAMPLES.forEach((example) => {
expect(example).toHaveProperty("input");
expect(example).toHaveProperty("output");
expect(typeof example.input).toBe("string");
expect(typeof example.output).toBe("string");
expect(example).toHaveProperty('input');
expect(example).toHaveProperty('output');
expect(typeof example.input).toBe('string');
expect(typeof example.output).toBe('string');
});
});
it("should export TECHNICAL_EXAMPLES with valid structure", () => {
it('should export TECHNICAL_EXAMPLES with valid structure', () => {
expect(TECHNICAL_EXAMPLES).toBeDefined();
expect(Array.isArray(TECHNICAL_EXAMPLES)).toBe(true);
expect(TECHNICAL_EXAMPLES.length).toBeGreaterThan(0);
TECHNICAL_EXAMPLES.forEach((example) => {
expect(example).toHaveProperty("input");
expect(example).toHaveProperty("output");
expect(typeof example.input).toBe("string");
expect(typeof example.output).toBe("string");
expect(example).toHaveProperty('input');
expect(example).toHaveProperty('output');
expect(typeof example.input).toBe('string');
expect(typeof example.output).toBe('string');
});
});
it("should export SIMPLIFY_EXAMPLES with valid structure", () => {
it('should export SIMPLIFY_EXAMPLES with valid structure', () => {
expect(SIMPLIFY_EXAMPLES).toBeDefined();
expect(Array.isArray(SIMPLIFY_EXAMPLES)).toBe(true);
expect(SIMPLIFY_EXAMPLES.length).toBeGreaterThan(0);
SIMPLIFY_EXAMPLES.forEach((example) => {
expect(example).toHaveProperty("input");
expect(example).toHaveProperty("output");
expect(typeof example.input).toBe("string");
expect(typeof example.output).toBe("string");
expect(example).toHaveProperty('input');
expect(example).toHaveProperty('output');
expect(typeof example.input).toBe('string');
expect(typeof example.output).toBe('string');
});
});
it("should export ACCEPTANCE_EXAMPLES with valid structure", () => {
it('should export ACCEPTANCE_EXAMPLES with valid structure', () => {
expect(ACCEPTANCE_EXAMPLES).toBeDefined();
expect(Array.isArray(ACCEPTANCE_EXAMPLES)).toBe(true);
expect(ACCEPTANCE_EXAMPLES.length).toBeGreaterThan(0);
ACCEPTANCE_EXAMPLES.forEach((example) => {
expect(example).toHaveProperty("input");
expect(example).toHaveProperty("output");
expect(typeof example.input).toBe("string");
expect(typeof example.output).toBe("string");
expect(example).toHaveProperty('input');
expect(example).toHaveProperty('output');
expect(typeof example.input).toBe('string');
expect(typeof example.output).toBe('string');
});
});
it("should have shorter outputs in SIMPLIFY_EXAMPLES", () => {
it('should have shorter outputs in SIMPLIFY_EXAMPLES', () => {
SIMPLIFY_EXAMPLES.forEach((example) => {
// Simplify examples should have shorter output than input
// (though not always strictly enforced, it's the general pattern)
@@ -110,158 +110,158 @@ describe("enhancement.ts", () => {
});
});
describe("getEnhancementPrompt", () => {
describe('getEnhancementPrompt', () => {
it("should return prompt config for 'improve' mode", () => {
const result = getEnhancementPrompt("improve");
const result = getEnhancementPrompt('improve');
expect(result).toHaveProperty("systemPrompt");
expect(result).toHaveProperty("description");
expect(result).toHaveProperty('systemPrompt');
expect(result).toHaveProperty('description');
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
expect(result.description).toContain("vague");
expect(result.description).toContain("actionable");
expect(result.description).toContain('vague');
expect(result.description).toContain('actionable');
});
it("should return prompt config for 'technical' mode", () => {
const result = getEnhancementPrompt("technical");
const result = getEnhancementPrompt('technical');
expect(result).toHaveProperty("systemPrompt");
expect(result).toHaveProperty("description");
expect(result).toHaveProperty('systemPrompt');
expect(result).toHaveProperty('description');
expect(result.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT);
expect(result.description).toContain("implementation");
expect(result.description).toContain('implementation');
});
it("should return prompt config for 'simplify' mode", () => {
const result = getEnhancementPrompt("simplify");
const result = getEnhancementPrompt('simplify');
expect(result).toHaveProperty("systemPrompt");
expect(result).toHaveProperty("description");
expect(result).toHaveProperty('systemPrompt');
expect(result).toHaveProperty('description');
expect(result.systemPrompt).toBe(SIMPLIFY_SYSTEM_PROMPT);
expect(result.description).toContain("verbose");
expect(result.description).toContain('verbose');
});
it("should return prompt config for 'acceptance' mode", () => {
const result = getEnhancementPrompt("acceptance");
const result = getEnhancementPrompt('acceptance');
expect(result).toHaveProperty("systemPrompt");
expect(result).toHaveProperty("description");
expect(result).toHaveProperty('systemPrompt');
expect(result).toHaveProperty('description');
expect(result.systemPrompt).toBe(ACCEPTANCE_SYSTEM_PROMPT);
expect(result.description).toContain("acceptance");
expect(result.description).toContain('acceptance');
});
it("should handle uppercase mode", () => {
const result = getEnhancementPrompt("IMPROVE");
it('should handle uppercase mode', () => {
const result = getEnhancementPrompt('IMPROVE');
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
});
it("should handle mixed case mode", () => {
const result = getEnhancementPrompt("TeChnIcaL");
it('should handle mixed case mode', () => {
const result = getEnhancementPrompt('TeChnIcaL');
expect(result.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT);
});
it("should fall back to 'improve' for invalid mode", () => {
const result = getEnhancementPrompt("invalid-mode");
const result = getEnhancementPrompt('invalid-mode');
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
expect(result.description).toContain("vague");
expect(result.description).toContain('vague');
});
it("should fall back to 'improve' for empty string", () => {
const result = getEnhancementPrompt("");
const result = getEnhancementPrompt('');
expect(result.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT);
});
});
describe("getSystemPrompt", () => {
describe('getSystemPrompt', () => {
it("should return IMPROVE_SYSTEM_PROMPT for 'improve'", () => {
const result = getSystemPrompt("improve");
const result = getSystemPrompt('improve');
expect(result).toBe(IMPROVE_SYSTEM_PROMPT);
});
it("should return TECHNICAL_SYSTEM_PROMPT for 'technical'", () => {
const result = getSystemPrompt("technical");
const result = getSystemPrompt('technical');
expect(result).toBe(TECHNICAL_SYSTEM_PROMPT);
});
it("should return SIMPLIFY_SYSTEM_PROMPT for 'simplify'", () => {
const result = getSystemPrompt("simplify");
const result = getSystemPrompt('simplify');
expect(result).toBe(SIMPLIFY_SYSTEM_PROMPT);
});
it("should return ACCEPTANCE_SYSTEM_PROMPT for 'acceptance'", () => {
const result = getSystemPrompt("acceptance");
const result = getSystemPrompt('acceptance');
expect(result).toBe(ACCEPTANCE_SYSTEM_PROMPT);
});
});
describe("getExamples", () => {
describe('getExamples', () => {
it("should return IMPROVE_EXAMPLES for 'improve'", () => {
const result = getExamples("improve");
const result = getExamples('improve');
expect(result).toBe(IMPROVE_EXAMPLES);
expect(result.length).toBeGreaterThan(0);
});
it("should return TECHNICAL_EXAMPLES for 'technical'", () => {
const result = getExamples("technical");
const result = getExamples('technical');
expect(result).toBe(TECHNICAL_EXAMPLES);
expect(result.length).toBeGreaterThan(0);
});
it("should return SIMPLIFY_EXAMPLES for 'simplify'", () => {
const result = getExamples("simplify");
const result = getExamples('simplify');
expect(result).toBe(SIMPLIFY_EXAMPLES);
expect(result.length).toBeGreaterThan(0);
});
it("should return ACCEPTANCE_EXAMPLES for 'acceptance'", () => {
const result = getExamples("acceptance");
const result = getExamples('acceptance');
expect(result).toBe(ACCEPTANCE_EXAMPLES);
expect(result.length).toBeGreaterThan(0);
});
});
describe("buildUserPrompt", () => {
const testText = "Add a login feature";
describe('buildUserPrompt', () => {
const testText = 'Add a login feature';
describe("with examples (default)", () => {
describe('with examples (default)', () => {
it("should include examples by default for 'improve' mode", () => {
const result = buildUserPrompt("improve", testText);
const result = buildUserPrompt('improve', testText);
expect(result).toContain("Here are some examples");
expect(result).toContain("Example 1:");
expect(result).toContain('Here are some examples');
expect(result).toContain('Example 1:');
expect(result).toContain(IMPROVE_EXAMPLES[0].input);
expect(result).toContain(IMPROVE_EXAMPLES[0].output);
expect(result).toContain(testText);
});
it("should include examples by default for 'technical' mode", () => {
const result = buildUserPrompt("technical", testText);
const result = buildUserPrompt('technical', testText);
expect(result).toContain("Here are some examples");
expect(result).toContain("Example 1:");
expect(result).toContain('Here are some examples');
expect(result).toContain('Example 1:');
expect(result).toContain(TECHNICAL_EXAMPLES[0].input);
expect(result).toContain(testText);
});
it("should include examples when explicitly set to true", () => {
const result = buildUserPrompt("improve", testText, true);
it('should include examples when explicitly set to true', () => {
const result = buildUserPrompt('improve', testText, true);
expect(result).toContain("Here are some examples");
expect(result).toContain('Here are some examples');
expect(result).toContain(testText);
});
it("should format all examples with numbered labels", () => {
const result = buildUserPrompt("improve", testText);
it('should format all examples with numbered labels', () => {
const result = buildUserPrompt('improve', testText);
IMPROVE_EXAMPLES.forEach((_, index) => {
expect(result).toContain(`Example ${index + 1}:`);
});
});
it("should separate examples with dividers", () => {
const result = buildUserPrompt("improve", testText);
it('should separate examples with dividers', () => {
const result = buildUserPrompt('improve', testText);
// Count dividers (---) - should be (examples.length) + 1
const dividerCount = (result.match(/---/g) || []).length;
@@ -269,72 +269,74 @@ describe("enhancement.ts", () => {
});
it("should include 'Now, please enhance' before user text", () => {
const result = buildUserPrompt("improve", testText);
const result = buildUserPrompt('improve', testText);
expect(result).toContain("Now, please enhance the following");
expect(result).toContain('Now, please enhance the following');
expect(result).toContain(testText);
});
});
describe("without examples", () => {
it("should not include examples when includeExamples is false", () => {
const result = buildUserPrompt("improve", testText, false);
describe('without examples', () => {
it('should not include examples when includeExamples is false', () => {
const result = buildUserPrompt('improve', testText, false);
expect(result).not.toContain("Here are some examples");
expect(result).not.toContain("Example 1:");
expect(result).not.toContain('Here are some examples');
expect(result).not.toContain('Example 1:');
expect(result).not.toContain(IMPROVE_EXAMPLES[0].input);
});
it("should have simple prompt without examples", () => {
const result = buildUserPrompt("improve", testText, false);
it('should have simple prompt without examples', () => {
const result = buildUserPrompt('improve', testText, false);
expect(result).toBe(
`Please enhance the following task description:\n\n${testText}`
);
expect(result).toBe(`Please enhance the following task description:\n\n${testText}`);
});
it("should preserve user text without examples", () => {
const result = buildUserPrompt("technical", testText, false);
it('should preserve user text without examples', () => {
const result = buildUserPrompt('technical', testText, false);
expect(result).toContain(testText);
expect(result).toContain("Please enhance");
expect(result).toContain('Please enhance');
});
});
describe("text formatting", () => {
it("should preserve multiline text", () => {
const multilineText = "Line 1\nLine 2\nLine 3";
const result = buildUserPrompt("improve", multilineText);
describe('text formatting', () => {
it('should preserve multiline text', () => {
const multilineText = 'Line 1\nLine 2\nLine 3';
const result = buildUserPrompt('improve', multilineText);
expect(result).toContain(multilineText);
});
it("should handle empty text", () => {
const result = buildUserPrompt("improve", "");
it('should handle empty text', () => {
const result = buildUserPrompt('improve', '');
// With examples by default, it should contain "Now, please enhance"
expect(result).toContain("Now, please enhance");
expect(result).toContain("Here are some examples");
expect(result).toContain('Now, please enhance');
expect(result).toContain('Here are some examples');
});
it("should handle whitespace-only text", () => {
const result = buildUserPrompt("improve", " ");
it('should handle whitespace-only text', () => {
const result = buildUserPrompt('improve', ' ');
expect(result).toContain(" ");
expect(result).toContain(' ');
});
it("should handle special characters in text", () => {
const specialText = "Test <html> & \"quotes\" 'apostrophes'";
const result = buildUserPrompt("improve", specialText);
it('should handle special characters in text', () => {
const specialText = 'Test <html> & "quotes" \'apostrophes\'';
const result = buildUserPrompt('improve', specialText);
expect(result).toContain(specialText);
});
});
describe("all modes", () => {
it("should work for all valid enhancement modes", () => {
const modes: Array<"improve" | "technical" | "simplify" | "acceptance"> =
["improve", "technical", "simplify", "acceptance"];
describe('all modes', () => {
it('should work for all valid enhancement modes', () => {
const modes: Array<'improve' | 'technical' | 'simplify' | 'acceptance'> = [
'improve',
'technical',
'simplify',
'acceptance',
];
modes.forEach((mode) => {
const result = buildUserPrompt(mode, testText);
@@ -347,76 +349,76 @@ describe("enhancement.ts", () => {
});
});
describe("isValidEnhancementMode", () => {
describe('isValidEnhancementMode', () => {
it("should return true for 'improve'", () => {
expect(isValidEnhancementMode("improve")).toBe(true);
expect(isValidEnhancementMode('improve')).toBe(true);
});
it("should return true for 'technical'", () => {
expect(isValidEnhancementMode("technical")).toBe(true);
expect(isValidEnhancementMode('technical')).toBe(true);
});
it("should return true for 'simplify'", () => {
expect(isValidEnhancementMode("simplify")).toBe(true);
expect(isValidEnhancementMode('simplify')).toBe(true);
});
it("should return true for 'acceptance'", () => {
expect(isValidEnhancementMode("acceptance")).toBe(true);
expect(isValidEnhancementMode('acceptance')).toBe(true);
});
it("should return false for invalid mode", () => {
expect(isValidEnhancementMode("invalid")).toBe(false);
it('should return false for invalid mode', () => {
expect(isValidEnhancementMode('invalid')).toBe(false);
});
it("should return false for empty string", () => {
expect(isValidEnhancementMode("")).toBe(false);
it('should return false for empty string', () => {
expect(isValidEnhancementMode('')).toBe(false);
});
it("should return false for uppercase mode", () => {
it('should return false for uppercase mode', () => {
// Should be case-sensitive since we check object keys directly
expect(isValidEnhancementMode("IMPROVE")).toBe(false);
expect(isValidEnhancementMode('IMPROVE')).toBe(false);
});
it("should return false for mixed case mode", () => {
expect(isValidEnhancementMode("ImProve")).toBe(false);
it('should return false for mixed case mode', () => {
expect(isValidEnhancementMode('ImProve')).toBe(false);
});
it("should return false for partial mode names", () => {
expect(isValidEnhancementMode("impro")).toBe(false);
expect(isValidEnhancementMode("tech")).toBe(false);
it('should return false for partial mode names', () => {
expect(isValidEnhancementMode('impro')).toBe(false);
expect(isValidEnhancementMode('tech')).toBe(false);
});
it("should return false for mode with extra characters", () => {
expect(isValidEnhancementMode("improve ")).toBe(false);
expect(isValidEnhancementMode(" improve")).toBe(false);
it('should return false for mode with extra characters', () => {
expect(isValidEnhancementMode('improve ')).toBe(false);
expect(isValidEnhancementMode(' improve')).toBe(false);
});
});
describe("getAvailableEnhancementModes", () => {
it("should return array of all enhancement modes", () => {
describe('getAvailableEnhancementModes', () => {
it('should return array of all enhancement modes', () => {
const modes = getAvailableEnhancementModes();
expect(Array.isArray(modes)).toBe(true);
expect(modes.length).toBe(4);
});
it("should include all valid modes", () => {
it('should include all valid modes', () => {
const modes = getAvailableEnhancementModes();
expect(modes).toContain("improve");
expect(modes).toContain("technical");
expect(modes).toContain("simplify");
expect(modes).toContain("acceptance");
expect(modes).toContain('improve');
expect(modes).toContain('technical');
expect(modes).toContain('simplify');
expect(modes).toContain('acceptance');
});
it("should return modes in consistent order", () => {
it('should return modes in consistent order', () => {
const modes1 = getAvailableEnhancementModes();
const modes2 = getAvailableEnhancementModes();
expect(modes1).toEqual(modes2);
});
it("should return all valid modes that pass isValidEnhancementMode", () => {
it('should return all valid modes that pass isValidEnhancementMode', () => {
const modes = getAvailableEnhancementModes();
modes.forEach((mode) => {
@@ -425,10 +427,10 @@ describe("enhancement.ts", () => {
});
});
describe("Integration tests", () => {
it("should work together: getEnhancementPrompt + buildUserPrompt", () => {
const mode = "improve";
const text = "Add search feature";
describe('Integration tests', () => {
it('should work together: getEnhancementPrompt + buildUserPrompt', () => {
const mode = 'improve';
const text = 'Add search feature';
const { systemPrompt, description } = getEnhancementPrompt(mode);
const userPrompt = buildUserPrompt(mode, text);
@@ -438,7 +440,7 @@ describe("enhancement.ts", () => {
expect(userPrompt).toContain(text);
});
it("should handle complete enhancement workflow", () => {
it('should handle complete enhancement workflow', () => {
const availableModes = getAvailableEnhancementModes();
expect(availableModes.length).toBeGreaterThan(0);
@@ -455,13 +457,13 @@ describe("enhancement.ts", () => {
expect(Array.isArray(examples)).toBe(true);
expect(examples.length).toBeGreaterThan(0);
const userPrompt = buildUserPrompt(mode, "test description");
expect(userPrompt).toContain("test description");
const userPrompt = buildUserPrompt(mode, 'test description');
expect(userPrompt).toContain('test description');
});
});
it("should provide consistent data across functions", () => {
const mode = "technical";
it('should provide consistent data across functions', () => {
const mode = 'technical';
const promptConfig = getEnhancementPrompt(mode);
const systemPrompt = getSystemPrompt(mode);
@@ -472,8 +474,8 @@ describe("enhancement.ts", () => {
});
});
describe("Examples content validation", () => {
it("IMPROVE_EXAMPLES should demonstrate improvement", () => {
describe('Examples content validation', () => {
it('IMPROVE_EXAMPLES should demonstrate improvement', () => {
IMPROVE_EXAMPLES.forEach((example) => {
// Output should be longer and more detailed than input
expect(example.output.length).toBeGreaterThan(example.input.length);
@@ -482,43 +484,40 @@ describe("enhancement.ts", () => {
});
});
it("TECHNICAL_EXAMPLES should contain technical terms", () => {
it('TECHNICAL_EXAMPLES should contain technical terms', () => {
const technicalTerms = [
"API",
"endpoint",
"component",
"database",
"frontend",
"backend",
"validation",
"schema",
"React",
"GET",
"PUT",
"POST",
'API',
'endpoint',
'component',
'database',
'frontend',
'backend',
'validation',
'schema',
'React',
'GET',
'PUT',
'POST',
];
TECHNICAL_EXAMPLES.forEach((example) => {
const hasAnyTechnicalTerm = technicalTerms.some((term) =>
example.output.includes(term)
);
const hasAnyTechnicalTerm = technicalTerms.some((term) => example.output.includes(term));
expect(hasAnyTechnicalTerm).toBe(true);
});
});
it("ACCEPTANCE_EXAMPLES should contain acceptance criteria format", () => {
it('ACCEPTANCE_EXAMPLES should contain acceptance criteria format', () => {
ACCEPTANCE_EXAMPLES.forEach((example) => {
// Should contain numbered criteria or Given-When-Then format
const hasAcceptanceCriteria =
example.output.includes("Acceptance Criteria") ||
example.output.match(/\d+\./g);
example.output.includes('Acceptance Criteria') || example.output.match(/\d+\./g);
expect(hasAcceptanceCriteria).toBeTruthy();
// Should contain Given-When-Then format
const hasGWT =
example.output.includes("Given") &&
example.output.includes("when") &&
example.output.includes("then");
example.output.includes('Given') &&
example.output.includes('when') &&
example.output.includes('then');
expect(hasGWT).toBe(true);
});
});

View File

@@ -1,15 +1,15 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/index.ts"],
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
thresholds: {
lines: 90,
functions: 95,

View File

@@ -15,6 +15,7 @@ npm install @automaker/types
## Exports
### Provider Types
Types for AI provider integration and Claude SDK.
```typescript
@@ -26,23 +27,20 @@ import type {
ProviderMessage,
InstallationStatus,
ValidationResult,
ModelDefinition
ModelDefinition,
} from '@automaker/types';
```
### Feature Types
Feature management and workflow types.
```typescript
import type {
Feature,
FeatureStatus,
PlanningMode,
PlanSpec
} from '@automaker/types';
import type { Feature, FeatureStatus, PlanningMode, PlanSpec } from '@automaker/types';
```
**Feature Interface:**
- `id` - Unique feature identifier
- `category` - Feature category/type
- `description` - Feature description
@@ -52,6 +50,7 @@ import type {
- `planSpec` - Plan specification and approval status
### Session Types
Agent session management.
```typescript
@@ -59,39 +58,32 @@ import type {
AgentSession,
SessionListItem,
CreateSessionParams,
UpdateSessionParams
UpdateSessionParams,
} from '@automaker/types';
```
### Error Types
Error classification and handling.
```typescript
import type {
ErrorType,
ErrorInfo
} from '@automaker/types';
import type { ErrorType, ErrorInfo } from '@automaker/types';
```
### Image Types
Image handling for prompts.
```typescript
import type {
ImageData,
ImageContentBlock
} from '@automaker/types';
import type { ImageData, ImageContentBlock } from '@automaker/types';
```
### Model Types
Claude model definitions and mappings.
```typescript
import {
CLAUDE_MODEL_MAP,
DEFAULT_MODELS,
type ModelAlias
} from '@automaker/types';
import { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias } from '@automaker/types';
```
## Usage Example
@@ -105,12 +97,12 @@ const feature: Feature = {
description: 'Implement user authentication',
dependencies: ['database-setup'],
status: 'pending',
planningMode: 'spec'
planningMode: 'spec',
};
const options: ExecuteOptions = {
model: 'claude-sonnet-4-20250514',
temperature: 0.7
temperature: 0.7,
};
```
@@ -140,6 +132,7 @@ To maintain the package dependency hierarchy and prevent circular dependencies:
4. **Document the rule** - When adding new functionality, ensure it follows this constraint
This constraint ensures a clean one-way dependency flow:
```
@automaker/types (foundation - no dependencies)

View File

@@ -9,7 +9,10 @@
"build": "tsc",
"watch": "tsc --watch"
},
"keywords": ["automaker", "types"],
"keywords": [
"automaker",
"types"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"devDependencies": {

View File

@@ -5,7 +5,7 @@
/**
* Available enhancement modes for transforming task descriptions
*/
export type EnhancementMode = "improve" | "technical" | "simplify" | "acceptance";
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance';
/**
* Example input/output pair for few-shot learning

View File

@@ -1,7 +1,7 @@
/**
* Error type classification
*/
export type ErrorType = "authentication" | "cancellation" | "abort" | "execution" | "unknown";
export type ErrorType = 'authentication' | 'cancellation' | 'abort' | 'execution' | 'unknown';
/**
* Classified error information

View File

@@ -3,27 +3,27 @@
*/
export type EventType =
| "agent:stream"
| "auto-mode:event"
| "auto-mode:started"
| "auto-mode:stopped"
| "auto-mode:idle"
| "auto-mode:error"
| "feature:started"
| "feature:completed"
| "feature:stopped"
| "feature:error"
| "feature:progress"
| "feature:tool-use"
| "feature:follow-up-started"
| "feature:follow-up-completed"
| "feature:verified"
| "feature:committed"
| "project:analysis-started"
| "project:analysis-progress"
| "project:analysis-completed"
| "project:analysis-error"
| "suggestions:event"
| "spec-regeneration:event";
| 'agent:stream'
| 'auto-mode:event'
| 'auto-mode:started'
| 'auto-mode:stopped'
| 'auto-mode:idle'
| 'auto-mode:error'
| 'feature:started'
| 'feature:completed'
| 'feature:stopped'
| 'feature:error'
| 'feature:progress'
| 'feature:tool-use'
| 'feature:follow-up-started'
| 'feature:follow-up-completed'
| 'feature:verified'
| 'feature:committed'
| 'project:analysis-started'
| 'project:analysis-progress'
| 'project:analysis-completed'
| 'project:analysis-error'
| 'suggestions:event'
| 'spec-regeneration:event';
export type EventCallback = (type: EventType, payload: unknown) => void;

View File

@@ -12,6 +12,15 @@ export interface FeatureImagePath {
[key: string]: unknown;
}
export interface FeatureTextFilePath {
id: string;
path: string;
filename: string;
mimeType: string;
content: string; // Text content of the file
[key: string]: unknown;
}
export interface Feature {
id: string;
title?: string;
@@ -26,6 +35,7 @@ export interface Feature {
spec?: string;
model?: string;
imagePaths?: Array<string | FeatureImagePath | { path: string; [key: string]: unknown }>;
textFilePaths?: FeatureTextFilePath[];
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean;
@@ -45,7 +55,7 @@ export interface Feature {
error?: string;
summary?: string;
startedAt?: string;
[key: string]: unknown; // Keep catch-all for extensibility
[key: string]: unknown; // Keep catch-all for extensibility
}
export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified';

View File

@@ -12,9 +12,9 @@ export interface ImageData {
* Content block for image (Claude SDK format)
*/
export interface ImageContentBlock {
type: "image";
type: 'image';
source: {
type: "base64";
type: 'base64';
media_type: string;
data: string;
};

View File

@@ -16,11 +16,7 @@ export type {
} from './provider.js';
// Feature types
export type {
Feature,
FeatureImagePath,
FeatureStatus,
} from './feature.js';
export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';
// Session types
export type {
@@ -31,44 +27,23 @@ export type {
} from './session.js';
// Error types
export type {
ErrorType,
ErrorInfo,
} from './error.js';
export type { ErrorType, ErrorInfo } from './error.js';
// Image types
export type {
ImageData,
ImageContentBlock,
} from './image.js';
export type { ImageData, ImageContentBlock } from './image.js';
// Model types and constants
export {
CLAUDE_MODEL_MAP,
DEFAULT_MODELS,
type ModelAlias,
type AgentModel,
} from './model.js';
export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias, type AgentModel } from './model.js';
// Event types
export type {
EventType,
EventCallback,
} from './event.js';
export type { EventType, EventCallback } from './event.js';
// Spec types
export type {
SpecOutput,
} from './spec.js';
export {
specOutputSchema,
} from './spec.js';
export type { SpecOutput } from './spec.js';
export { specOutputSchema } from './spec.js';
// Enhancement types
export type {
EnhancementMode,
EnhancementExample,
} from './enhancement.js';
export type { EnhancementMode, EnhancementExample } from './enhancement.js';
// Settings types and constants
export type {
@@ -99,10 +74,7 @@ export {
} from './settings.js';
// Model display constants
export type {
ModelOption,
ThinkingLevelOption,
} from './model-display.js';
export type { ModelOption, ThinkingLevelOption } from './model-display.js';
export {
CLAUDE_MODELS,
THINKING_LEVELS,

View File

@@ -20,7 +20,7 @@ export interface ModelOption {
/** Optional badge text (e.g., "Speed", "Balanced", "Premium") */
badge?: string;
/** AI provider (currently only "claude") */
provider: "claude";
provider: 'claude';
}
/**
@@ -40,25 +40,25 @@ export interface ThinkingLevelOption {
*/
export const CLAUDE_MODELS: ModelOption[] = [
{
id: "haiku",
label: "Claude Haiku",
description: "Fast and efficient for simple tasks.",
badge: "Speed",
provider: "claude",
id: 'haiku',
label: 'Claude Haiku',
description: 'Fast and efficient for simple tasks.',
badge: 'Speed',
provider: 'claude',
},
{
id: "sonnet",
label: "Claude Sonnet",
description: "Balanced performance with strong reasoning.",
badge: "Balanced",
provider: "claude",
id: 'sonnet',
label: 'Claude Sonnet',
description: 'Balanced performance with strong reasoning.',
badge: 'Balanced',
provider: 'claude',
},
{
id: "opus",
label: "Claude Opus",
description: "Most capable model for complex work.",
badge: "Premium",
provider: "claude",
id: 'opus',
label: 'Claude Opus',
description: 'Most capable model for complex work.',
badge: 'Premium',
provider: 'claude',
},
];
@@ -68,11 +68,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
* Ordered from least to most intensive reasoning.
*/
export const THINKING_LEVELS: ThinkingLevelOption[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
{ id: 'none', label: 'None' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'ultrathink', label: 'Ultrathink' },
];
/**
@@ -81,11 +81,11 @@ export const THINKING_LEVELS: ThinkingLevelOption[] = [
* Used for compact UI elements like badges or dropdowns.
*/
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
none: "None",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
none: 'None',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
/**
@@ -103,9 +103,9 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
};
return displayNames[model] || model;
}

View File

@@ -2,16 +2,16 @@
* Model alias mapping for Claude models
*/
export const CLAUDE_MODEL_MAP: Record<string, string> = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
haiku: 'claude-haiku-4-5-20251001',
sonnet: 'claude-sonnet-4-5-20250929',
opus: 'claude-opus-4-5-20251101',
} as const;
/**
* Default models per provider
*/
export const DEFAULT_MODELS = {
claude: "claude-opus-4-5-20251101",
claude: 'claude-opus-4-5-20251101',
} as const;
export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP;

View File

@@ -15,7 +15,7 @@ export interface ProviderConfig {
* Message in conversation history
*/
export interface ConversationMessage {
role: "user" | "assistant";
role: 'user' | 'assistant';
content: string | Array<{ type: string; text?: string; source?: object }>;
}
@@ -39,7 +39,7 @@ export interface ExecuteOptions {
* Content block in a provider message (matches Claude SDK format)
*/
export interface ContentBlock {
type: "text" | "tool_use" | "thinking" | "tool_result";
type: 'text' | 'tool_use' | 'thinking' | 'tool_result';
text?: string;
thinking?: string;
name?: string;
@@ -52,11 +52,11 @@ export interface ContentBlock {
* Message returned by a provider (matches Claude SDK streaming format)
*/
export interface ProviderMessage {
type: "assistant" | "user" | "error" | "result";
subtype?: "success" | "error";
type: 'assistant' | 'user' | 'error' | 'result';
subtype?: 'success' | 'error';
session_id?: string;
message?: {
role: "user" | "assistant";
role: 'user' | 'assistant';
content: ContentBlock[];
};
result?: string;
@@ -71,7 +71,7 @@ export interface InstallationStatus {
installed: boolean;
path?: string;
version?: string;
method?: "cli" | "npm" | "brew" | "sdk";
method?: 'cli' | 'npm' | 'brew' | 'sdk';
hasApiKey?: boolean;
authenticated?: boolean;
error?: string;
@@ -99,6 +99,6 @@ export interface ModelDefinition {
maxOutputTokens?: number;
supportsVision?: boolean;
supportsTools?: boolean;
tier?: "basic" | "standard" | "premium";
tier?: 'basic' | 'standard' | 'premium';
default?: boolean;
}

View File

@@ -19,7 +19,7 @@ export interface SpecOutput {
development_guidelines?: string[];
implementation_roadmap?: Array<{
phase: string;
status: "completed" | "in_progress" | "pending";
status: 'completed' | 'in_progress' | 'pending';
description: string;
}>;
}
@@ -29,91 +29,90 @@ export interface SpecOutput {
* Used with Claude's structured output feature for reliable parsing
*/
export const specOutputSchema = {
type: "object",
type: 'object',
properties: {
project_name: {
type: "string",
description: "The name of the project",
type: 'string',
description: 'The name of the project',
},
overview: {
type: "string",
type: 'string',
description:
"A comprehensive description of what the project does, its purpose, and key goals",
'A comprehensive description of what the project does, its purpose, and key goals',
},
technology_stack: {
type: "array",
items: { type: "string" },
description:
"List of all technologies, frameworks, libraries, and tools used",
type: 'array',
items: { type: 'string' },
description: 'List of all technologies, frameworks, libraries, and tools used',
},
core_capabilities: {
type: "array",
items: { type: "string" },
description: "List of main features and capabilities the project provides",
type: 'array',
items: { type: 'string' },
description: 'List of main features and capabilities the project provides',
},
implemented_features: {
type: "array",
type: 'array',
items: {
type: "object",
type: 'object',
properties: {
name: {
type: "string",
description: "Name of the implemented feature",
type: 'string',
description: 'Name of the implemented feature',
},
description: {
type: "string",
description: "Description of what the feature does",
type: 'string',
description: 'Description of what the feature does',
},
file_locations: {
type: "array",
items: { type: "string" },
description: "File paths where this feature is implemented",
type: 'array',
items: { type: 'string' },
description: 'File paths where this feature is implemented',
},
},
required: ["name", "description"],
required: ['name', 'description'],
},
description: "Features that have been implemented based on code analysis",
description: 'Features that have been implemented based on code analysis',
},
additional_requirements: {
type: "array",
items: { type: "string" },
description: "Any additional requirements or constraints",
type: 'array',
items: { type: 'string' },
description: 'Any additional requirements or constraints',
},
development_guidelines: {
type: "array",
items: { type: "string" },
description: "Development standards and practices",
type: 'array',
items: { type: 'string' },
description: 'Development standards and practices',
},
implementation_roadmap: {
type: "array",
type: 'array',
items: {
type: "object",
type: 'object',
properties: {
phase: {
type: "string",
description: "Name of the implementation phase",
type: 'string',
description: 'Name of the implementation phase',
},
status: {
type: "string",
enum: ["completed", "in_progress", "pending"],
description: "Current status of this phase",
type: 'string',
enum: ['completed', 'in_progress', 'pending'],
description: 'Current status of this phase',
},
description: {
type: "string",
description: "Description of what this phase involves",
type: 'string',
description: 'Description of what this phase involves',
},
},
required: ["phase", "status", "description"],
required: ['phase', 'status', 'description'],
},
description: "Phases or roadmap items for implementation",
description: 'Phases or roadmap items for implementation',
},
},
required: [
"project_name",
"overview",
"technology_stack",
"core_capabilities",
"implemented_features",
'project_name',
'overview',
'technology_stack',
'core_capabilities',
'implemented_features',
],
additionalProperties: false,
};

View File

@@ -15,6 +15,7 @@ npm install @automaker/utils
## Exports
### Logger
Structured logging with context.
```typescript
@@ -27,6 +28,7 @@ logger.debug('Debug information', { data });
```
### Error Handler
Error classification and user-friendly messages.
```typescript
@@ -35,7 +37,7 @@ import {
isCancellationError,
isAuthenticationError,
classifyError,
getUserFriendlyErrorMessage
getUserFriendlyErrorMessage,
} from '@automaker/utils';
try {
@@ -51,6 +53,7 @@ try {
```
### Conversation Utils
Message formatting and conversion.
```typescript
@@ -58,7 +61,7 @@ import {
extractTextFromContent,
normalizeContentBlocks,
formatHistoryAsText,
convertHistoryToMessages
convertHistoryToMessages,
} from '@automaker/utils';
const text = extractTextFromContent(contentBlocks);
@@ -68,6 +71,7 @@ const converted = convertHistoryToMessages(history);
```
### Image Handler
Image processing for Claude prompts.
```typescript
@@ -75,7 +79,7 @@ import {
getMimeTypeForImage,
readImageAsBase64,
convertImagesToContentBlocks,
formatImagePathsForPrompt
formatImagePathsForPrompt,
} from '@automaker/utils';
const mimeType = getMimeTypeForImage('screenshot.png');
@@ -85,6 +89,7 @@ const formatted = formatImagePathsForPrompt(imagePaths);
```
### Prompt Builder
Build prompts with images for Claude.
```typescript
@@ -93,7 +98,7 @@ import { buildPromptWithImages } from '@automaker/utils';
const result = await buildPromptWithImages({
basePrompt: 'Analyze this screenshot',
imagePaths: ['/path/to/screenshot.png'],
basePath: '/project/path'
basePath: '/project/path',
});
console.log(result.prompt); // Prompt with image references
@@ -101,15 +106,11 @@ console.log(result.images); // Image data for Claude
```
### File System Utils
Common file system operations.
```typescript
import {
ensureDir,
fileExists,
readJsonFile,
writeJsonFile
} from '@automaker/utils';
import { ensureDir, fileExists, readJsonFile, writeJsonFile } from '@automaker/utils';
await ensureDir('/path/to/dir');
const exists = await fileExists('/path/to/file');
@@ -131,7 +132,7 @@ async function executeWithImages(prompt: string, images: string[]) {
const result = await buildPromptWithImages({
basePrompt: prompt,
imagePaths: images,
basePath: process.cwd()
basePath: process.cwd(),
});
logger.debug('Prompt built successfully', { imageCount: result.images.length });

View File

@@ -11,7 +11,10 @@
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": ["automaker", "utils"],
"keywords": [
"automaker",
"utils"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {

View File

@@ -0,0 +1,238 @@
/**
* Context Loader - Loads project context files for agent prompts
*
* Provides a shared utility to load context files from .automaker/context/
* and format them as system prompt content. Used by both auto-mode-service
* and agent-service to ensure all agents are aware of project context.
*
* Context files contain project-specific rules, conventions, and guidelines
* that agents must follow when working on the project.
*/
import path from 'path';
import fs from 'fs/promises';
/**
* Metadata structure for context files
* Stored in {projectPath}/.automaker/context/context-metadata.json
*/
export interface ContextMetadata {
files: Record<string, { description: string }>;
}
/**
* Individual context file with metadata
*/
export interface ContextFileInfo {
name: string;
path: string;
content: string;
description?: string;
}
/**
* Result of loading context files
*/
export interface ContextFilesResult {
files: ContextFileInfo[];
formattedPrompt: string;
}
/**
* Options for loading context files
*/
export interface LoadContextFilesOptions {
/** Project path to load context from */
projectPath: string;
/** Optional custom secure fs module (for dependency injection) */
fsModule?: {
access: (path: string) => Promise<void>;
readdir: (path: string) => Promise<string[]>;
readFile: (path: string, encoding: string) => Promise<string>;
};
}
/**
* Get the context directory path for a project
*/
function getContextDir(projectPath: string): string {
return path.join(projectPath, '.automaker', 'context');
}
/**
* Load context metadata from the metadata file
*/
async function loadContextMetadata(
contextDir: string,
fsModule: typeof fs
): Promise<ContextMetadata> {
const metadataPath = path.join(contextDir, 'context-metadata.json');
try {
const content = await fsModule.readFile(metadataPath, 'utf-8');
return JSON.parse(content);
} catch {
// Metadata file doesn't exist yet - that's fine
return { files: {} };
}
}
/**
* Format a single context file entry for the prompt
*/
function formatContextFileEntry(file: ContextFileInfo): string {
const header = `## ${file.name}`;
const pathInfo = `**Path:** \`${file.path}\``;
let descriptionInfo = '';
if (file.description) {
descriptionInfo = `\n**Purpose:** ${file.description}`;
}
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
}
/**
* Build the formatted system prompt from context files
*/
function buildContextPrompt(files: ContextFileInfo[]): string {
if (files.length === 0) {
return '';
}
const formattedFiles = files.map(formatContextFileEntry);
return `# Project Context Files
The following context files provide project-specific rules, conventions, and guidelines.
Each file serves a specific purpose - use the description to understand when to reference it.
If you need more details about a context file, you can read the full file at the path provided.
**IMPORTANT**: You MUST follow the rules and conventions specified in these files.
- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`)
- Follow ALL coding conventions, commit message formats, and architectural patterns specified
- Reference these rules before running ANY shell commands or making commits
---
${formattedFiles.join('\n\n---\n\n')}
---
**REMINDER**: Before taking any action, verify you are following the conventions specified above.
`;
}
/**
* Load context files from a project's .automaker/context/ directory
*
* This function loads all .md and .txt files from the context directory,
* along with their metadata (descriptions), and formats them into a
* system prompt that can be prepended to agent prompts.
*
* @param options - Configuration options
* @returns Promise resolving to context files and formatted prompt
*
* @example
* ```typescript
* const { formattedPrompt, files } = await loadContextFiles({
* projectPath: '/path/to/project'
* });
*
* // Use as system prompt
* const executeOptions = {
* prompt: userPrompt,
* systemPrompt: formattedPrompt,
* };
* ```
*/
export async function loadContextFiles(
options: LoadContextFilesOptions
): Promise<ContextFilesResult> {
const { projectPath, fsModule = fs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
// Check if directory exists
await fsModule.access(contextDir);
// Read directory contents
const allFiles = await fsModule.readdir(contextDir);
// Filter for text-based context files (case-insensitive for cross-platform)
const textFiles = allFiles.filter((f) => {
const lower = f.toLowerCase();
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
});
if (textFiles.length === 0) {
return { files: [], formattedPrompt: '' };
}
// Load metadata for descriptions
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
// Load each file with its content and metadata
const files: ContextFileInfo[] = [];
for (const fileName of textFiles) {
const filePath = path.join(contextDir, fileName);
try {
const content = await fsModule.readFile(filePath, 'utf-8');
files.push({
name: fileName,
path: filePath,
content,
description: metadata.files[fileName]?.description,
});
} catch (error) {
console.warn(`[ContextLoader] Failed to read context file ${fileName}:`, error);
}
}
const formattedPrompt = buildContextPrompt(files);
console.log(
`[ContextLoader] Loaded ${files.length} context file(s): ${files.map((f) => f.name).join(', ')}`
);
return { files, formattedPrompt };
} catch {
// Context directory doesn't exist or is inaccessible - this is fine
return { files: [], formattedPrompt: '' };
}
}
/**
* Get a summary of available context files (names and descriptions only)
* Useful for informing the agent about what context is available without
* loading full content.
*/
export async function getContextFilesSummary(
options: LoadContextFilesOptions
): Promise<Array<{ name: string; path: string; description?: string }>> {
const { projectPath, fsModule = fs } = options;
const contextDir = path.resolve(getContextDir(projectPath));
try {
await fsModule.access(contextDir);
const allFiles = await fsModule.readdir(contextDir);
const textFiles = allFiles.filter((f) => {
const lower = f.toLowerCase();
return (lower.endsWith('.md') || lower.endsWith('.txt')) && f !== 'context-metadata.json';
});
if (textFiles.length === 0) {
return [];
}
const metadata = await loadContextMetadata(contextDir, fsModule as typeof fs);
return textFiles.map((fileName) => ({
name: fileName,
path: path.join(contextDir, fileName),
description: metadata.files[fileName]?.description,
}));
} catch {
return [];
}
}

View File

@@ -19,15 +19,15 @@ import type { ConversationMessage } from '@automaker/types';
export function extractTextFromContent(
content: string | Array<{ type: string; text?: string; source?: object }>
): string {
if (typeof content === "string") {
if (typeof content === 'string') {
return content;
}
// Extract text blocks only
return content
.filter((block) => block.type === "text")
.map((block) => block.text || "")
.join("\n");
.filter((block) => block.type === 'text')
.map((block) => block.text || '')
.join('\n');
}
/**
@@ -42,7 +42,7 @@ export function normalizeContentBlocks(
if (Array.isArray(content)) {
return content;
}
return [{ type: "text", text: content }];
return [{ type: 'text', text: content }];
}
/**
@@ -53,18 +53,18 @@ export function normalizeContentBlocks(
*/
export function formatHistoryAsText(history: ConversationMessage[]): string {
if (history.length === 0) {
return "";
return '';
}
let historyText = "Previous conversation:\n\n";
let historyText = 'Previous conversation:\n\n';
for (const msg of history) {
const contentText = extractTextFromContent(msg.content);
const role = msg.role === "user" ? "User" : "Assistant";
const role = msg.role === 'user' ? 'User' : 'Assistant';
historyText += `${role}: ${contentText}\n\n`;
}
historyText += "---\n\n";
historyText += '---\n\n';
return historyText;
}
@@ -74,20 +74,18 @@ export function formatHistoryAsText(history: ConversationMessage[]): string {
* @param history - Array of conversation messages
* @returns Array of Claude SDK formatted messages
*/
export function convertHistoryToMessages(
history: ConversationMessage[]
): Array<{
type: "user" | "assistant";
export function convertHistoryToMessages(history: ConversationMessage[]): Array<{
type: 'user' | 'assistant';
session_id: string;
message: {
role: "user" | "assistant";
role: 'user' | 'assistant';
content: Array<{ type: string; text?: string; source?: object }>;
};
parent_tool_use_id: null;
}> {
return history.map((historyMsg) => ({
type: historyMsg.role,
session_id: "",
session_id: '',
message: {
role: historyMsg.role,
content: normalizeContentBlocks(historyMsg.content),

View File

@@ -17,10 +17,7 @@ import type { ErrorType, ErrorInfo } from '@automaker/types';
* @returns True if the error is an abort error
*/
export function isAbortError(error: unknown): boolean {
return (
error instanceof Error &&
(error.name === "AbortError" || error.message.includes("abort"))
);
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('abort'));
}
/**
@@ -32,10 +29,10 @@ export function isAbortError(error: unknown): boolean {
export function isCancellationError(errorMessage: string): boolean {
const lowerMessage = errorMessage.toLowerCase();
return (
lowerMessage.includes("cancelled") ||
lowerMessage.includes("canceled") ||
lowerMessage.includes("stopped") ||
lowerMessage.includes("aborted")
lowerMessage.includes('cancelled') ||
lowerMessage.includes('canceled') ||
lowerMessage.includes('stopped') ||
lowerMessage.includes('aborted')
);
}
@@ -47,10 +44,10 @@ export function isCancellationError(errorMessage: string): boolean {
*/
export function isAuthenticationError(errorMessage: string): boolean {
return (
errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed") ||
errorMessage.includes("Fix external API key")
errorMessage.includes('Authentication failed') ||
errorMessage.includes('Invalid API key') ||
errorMessage.includes('authentication_failed') ||
errorMessage.includes('Fix external API key')
);
}
@@ -61,22 +58,22 @@ export function isAuthenticationError(errorMessage: string): boolean {
* @returns Classified error information
*/
export function classifyError(error: unknown): ErrorInfo {
const message = error instanceof Error ? error.message : String(error || "Unknown error");
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const isAbort = isAbortError(error);
const isAuth = isAuthenticationError(message);
const isCancellation = isCancellationError(message);
let type: ErrorType;
if (isAuth) {
type = "authentication";
type = 'authentication';
} else if (isAbort) {
type = "abort";
type = 'abort';
} else if (isCancellation) {
type = "cancellation";
type = 'cancellation';
} else if (error instanceof Error) {
type = "execution";
type = 'execution';
} else {
type = "unknown";
type = 'unknown';
}
return {
@@ -99,11 +96,11 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
const info = classifyError(error);
if (info.isAbort) {
return "Operation was cancelled";
return 'Operation was cancelled';
}
if (info.isAuth) {
return "Authentication failed. Please check your API key.";
return 'Authentication failed. Please check your API key.';
}
return info.message;
@@ -130,5 +127,5 @@ export function getUserFriendlyErrorMessage(error: unknown): string {
* ```
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Unknown error";
return error instanceof Error ? error.message : 'Unknown error';
}

View File

@@ -2,8 +2,8 @@
* File system utilities that handle symlinks safely
*/
import { secureFs } from "@automaker/platform";
import path from "path";
import { secureFs } from '@automaker/platform';
import path from 'path';
/**
* Create a directory, handling symlinks safely to avoid ELOOP errors.
@@ -23,10 +23,10 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
} catch (error: any) {
// ENOENT means path doesn't exist - we should create it
if (error.code !== "ENOENT") {
if (error.code !== 'ENOENT') {
// Some other error (could be ELOOP in parent path)
// If it's ELOOP, the path involves symlinks - don't try to create
if (error.code === "ELOOP") {
if (error.code === 'ELOOP') {
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
return;
}
@@ -39,7 +39,7 @@ export async function mkdirSafe(dirPath: string): Promise<void> {
await secureFs.mkdir(resolvedPath, { recursive: true });
} catch (error: any) {
// Handle race conditions and symlink issues
if (error.code === "EEXIST" || error.code === "ELOOP") {
if (error.code === 'EEXIST' || error.code === 'ELOOP') {
return;
}
throw error;
@@ -55,11 +55,11 @@ export async function existsSafe(filePath: string): Promise<boolean> {
await secureFs.lstat(filePath);
return true;
} catch (error: any) {
if (error.code === "ENOENT") {
if (error.code === 'ENOENT') {
return false;
}
// ELOOP or other errors - path exists but is problematic
if (error.code === "ELOOP") {
if (error.code === 'ELOOP') {
return true; // Symlink exists, even if looping
}
throw error;

View File

@@ -8,19 +8,19 @@
* - Path resolution (relative/absolute)
*/
import { secureFs } from "@automaker/platform";
import path from "path";
import { secureFs } from '@automaker/platform';
import path from 'path';
import type { ImageData, ImageContentBlock } from '@automaker/types';
/**
* MIME type mapping for image file extensions
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
} as const;
/**
@@ -31,7 +31,7 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
*/
export function getMimeTypeForImage(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || "image/png";
return IMAGE_MIME_TYPES[ext] || 'image/png';
}
/**
@@ -42,8 +42,8 @@ export function getMimeTypeForImage(imagePath: string): string {
* @throws Error if file cannot be read
*/
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
const imageBuffer = await secureFs.readFile(imagePath) as Buffer;
const base64Data = imageBuffer.toString("base64");
const imageBuffer = (await secureFs.readFile(imagePath)) as Buffer;
const base64Data = imageBuffer.toString('base64');
const mimeType = getMimeTypeForImage(imagePath);
return {
@@ -71,16 +71,15 @@ export async function convertImagesToContentBlocks(
for (const imagePath of imagePaths) {
try {
// Resolve to absolute path if needed
const absolutePath = workDir && !path.isAbsolute(imagePath)
? path.join(workDir, imagePath)
: imagePath;
const absolutePath =
workDir && !path.isAbsolute(imagePath) ? path.join(workDir, imagePath) : imagePath;
const imageData = await readImageAsBase64(absolutePath);
blocks.push({
type: "image",
type: 'image',
source: {
type: "base64",
type: 'base64',
media_type: imageData.mimeType,
data: imageData.base64,
},
@@ -103,10 +102,10 @@ export async function convertImagesToContentBlocks(
*/
export function formatImagePathsForPrompt(imagePaths: string[]): string {
if (imagePaths.length === 0) {
return "";
return '';
}
let text = "\n\nAttached images:\n";
let text = '\n\nAttached images:\n';
for (const imagePath of imagePaths) {
text += `- ${imagePath}\n`;
}

View File

@@ -37,21 +37,20 @@ export {
} from './prompt-builder.js';
// Logger
export {
createLogger,
getLogLevel,
setLogLevel,
LogLevel,
} from './logger.js';
export { createLogger, getLogLevel, setLogLevel, LogLevel } from './logger.js';
// File system utilities
export {
mkdirSafe,
existsSafe,
} from './fs-utils.js';
export { mkdirSafe, existsSafe } from './fs-utils.js';
// Path utilities
export { normalizePath, pathsEqual } from './path-utils.js';
// Context file loading
export {
normalizePath,
pathsEqual,
} from './path-utils.js';
loadContextFiles,
getContextFilesSummary,
type ContextMetadata,
type ContextFileInfo,
type ContextFilesResult,
type LoadContextFilesOptions,
} from './context-loader.js';

View File

@@ -53,7 +53,7 @@ export function createLogger(context: string) {
debug: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.DEBUG) {
console.log(prefix, "[DEBUG]", ...args);
console.log(prefix, '[DEBUG]', ...args);
}
},
};

View File

@@ -22,7 +22,7 @@
* ```
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
return p.replace(/\\/g, '/');
}
/**
@@ -45,10 +45,7 @@ export function normalizePath(p: string): string {
* pathsEqual(null, null); // true
* ```
*/
export function pathsEqual(
p1: string | undefined | null,
p2: string | undefined | null
): boolean {
export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean {
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}

View File

@@ -8,16 +8,18 @@
* - Supports both vision and non-vision models
*/
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from "./image-handler.js";
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from './image-handler.js';
/**
* Content that can be either simple text or structured blocks
*/
export type PromptContent = string | Array<{
type: string;
text?: string;
source?: object;
}>;
export type PromptContent =
| string
| Array<{
type: string;
text?: string;
source?: object;
}>;
/**
* Result of building a prompt with optional images
@@ -62,7 +64,7 @@ export async function buildPromptWithImages(
// Add text block if we have text
if (textContent.trim()) {
contentBlocks.push({ type: "text", text: textContent });
contentBlocks.push({ type: 'text', text: textContent });
}
// Add image blocks
@@ -71,9 +73,7 @@ export async function buildPromptWithImages(
// Return appropriate format
const content: PromptContent =
contentBlocks.length > 1 || contentBlocks[0]?.type === "image"
? contentBlocks
: textContent;
contentBlocks.length > 1 || contentBlocks[0]?.type === 'image' ? contentBlocks : textContent;
return { content, hasImages: true };
}

View File

@@ -1,261 +1,241 @@
import { describe, it, expect } from "vitest";
import type { ConversationMessage } from "@automaker/types";
import { describe, it, expect } from 'vitest';
import type { ConversationMessage } from '@automaker/types';
import {
extractTextFromContent,
normalizeContentBlocks,
formatHistoryAsText,
convertHistoryToMessages,
} from "../src/conversation-utils";
} from '../src/conversation-utils';
describe("conversation-utils.ts", () => {
describe("extractTextFromContent", () => {
it("should extract text from string content", () => {
const content = "Hello, world!";
describe('conversation-utils.ts', () => {
describe('extractTextFromContent', () => {
it('should extract text from string content', () => {
const content = 'Hello, world!';
const result = extractTextFromContent(content);
expect(result).toBe("Hello, world!");
expect(result).toBe('Hello, world!');
});
it("should extract text from array content with text blocks", () => {
it('should extract text from array content with text blocks', () => {
const content = [
{ type: "text", text: "First block" },
{ type: "text", text: "Second block" },
{ type: 'text', text: 'First block' },
{ type: 'text', text: 'Second block' },
];
const result = extractTextFromContent(content);
expect(result).toBe("First block\nSecond block");
expect(result).toBe('First block\nSecond block');
});
it("should filter out non-text blocks", () => {
it('should filter out non-text blocks', () => {
const content = [
{ type: "text", text: "Text block" },
{ type: "image", source: { data: "..." } },
{ type: "text", text: "Another text" },
{ type: 'text', text: 'Text block' },
{ type: 'image', source: { data: '...' } },
{ type: 'text', text: 'Another text' },
];
const result = extractTextFromContent(content);
expect(result).toBe("Text block\nAnother text");
expect(result).toBe('Text block\nAnother text');
});
it("should handle empty text blocks", () => {
it('should handle empty text blocks', () => {
const content = [
{ type: "text", text: "First" },
{ type: "text" },
{ type: "text", text: "Third" },
{ type: 'text', text: 'First' },
{ type: 'text' },
{ type: 'text', text: 'Third' },
];
const result = extractTextFromContent(content);
expect(result).toBe("First\n\nThird");
expect(result).toBe('First\n\nThird');
});
it("should return empty string for array with only non-text blocks", () => {
it('should return empty string for array with only non-text blocks', () => {
const content = [
{ type: "image", source: {} },
{ type: "tool_use", source: {} },
{ type: 'image', source: {} },
{ type: 'tool_use', source: {} },
];
const result = extractTextFromContent(content);
expect(result).toBe("");
expect(result).toBe('');
});
it("should return empty string for empty array", () => {
it('should return empty string for empty array', () => {
const content: Array<{ type: string; text?: string }> = [];
const result = extractTextFromContent(content);
expect(result).toBe("");
expect(result).toBe('');
});
});
describe("normalizeContentBlocks", () => {
it("should convert string to array of text blocks", () => {
const content = "Simple text";
describe('normalizeContentBlocks', () => {
it('should convert string to array of text blocks', () => {
const content = 'Simple text';
const result = normalizeContentBlocks(content);
expect(result).toEqual([{ type: "text", text: "Simple text" }]);
expect(result).toEqual([{ type: 'text', text: 'Simple text' }]);
});
it("should return array as-is", () => {
it('should return array as-is', () => {
const content = [
{ type: "text", text: "First" },
{ type: "image", source: {} },
{ type: 'text', text: 'First' },
{ type: 'image', source: {} },
];
const result = normalizeContentBlocks(content);
expect(result).toBe(content);
expect(result).toEqual(content);
});
it("should handle empty string", () => {
const content = "";
it('should handle empty string', () => {
const content = '';
const result = normalizeContentBlocks(content);
expect(result).toEqual([{ type: "text", text: "" }]);
expect(result).toEqual([{ type: 'text', text: '' }]);
});
it("should handle multiline string", () => {
const content = "Line 1\nLine 2\nLine 3";
it('should handle multiline string', () => {
const content = 'Line 1\nLine 2\nLine 3';
const result = normalizeContentBlocks(content);
expect(result).toEqual([{ type: "text", text: "Line 1\nLine 2\nLine 3" }]);
expect(result).toEqual([{ type: 'text', text: 'Line 1\nLine 2\nLine 3' }]);
});
});
describe("formatHistoryAsText", () => {
it("should format empty history as empty string", () => {
describe('formatHistoryAsText', () => {
it('should format empty history as empty string', () => {
const history: ConversationMessage[] = [];
const result = formatHistoryAsText(history);
expect(result).toBe("");
expect(result).toBe('');
});
it("should format single user message", () => {
const history: ConversationMessage[] = [
{ role: "user", content: "Hello!" },
];
it('should format single user message', () => {
const history: ConversationMessage[] = [{ role: 'user', content: 'Hello!' }];
const result = formatHistoryAsText(history);
expect(result).toBe("Previous conversation:\n\nUser: Hello!\n\n---\n\n");
expect(result).toBe('Previous conversation:\n\nUser: Hello!\n\n---\n\n');
});
it("should format single assistant message", () => {
it('should format single assistant message', () => {
const history: ConversationMessage[] = [{ role: 'assistant', content: 'Hi there!' }];
const result = formatHistoryAsText(history);
expect(result).toBe('Previous conversation:\n\nAssistant: Hi there!\n\n---\n\n');
});
it('should format conversation with multiple messages', () => {
const history: ConversationMessage[] = [
{ role: "assistant", content: "Hi there!" },
{ role: 'user', content: "What's 2+2?" },
{ role: 'assistant', content: 'The answer is 4.' },
{ role: 'user', content: 'Thanks!' },
];
const result = formatHistoryAsText(history);
expect(result).toBe(
"Previous conversation:\n\nAssistant: Hi there!\n\n---\n\n"
);
});
it("should format conversation with multiple messages", () => {
const history: ConversationMessage[] = [
{ role: "user", content: "What's 2+2?" },
{ role: "assistant", content: "The answer is 4." },
{ role: "user", content: "Thanks!" },
];
const result = formatHistoryAsText(history);
expect(result).toBe(
"Previous conversation:\n\n" +
'Previous conversation:\n\n' +
"User: What's 2+2?\n\n" +
"Assistant: The answer is 4.\n\n" +
"User: Thanks!\n\n" +
"---\n\n"
'Assistant: The answer is 4.\n\n' +
'User: Thanks!\n\n' +
'---\n\n'
);
});
it("should handle array content by extracting text", () => {
it('should handle array content by extracting text', () => {
const history: ConversationMessage[] = [
{
role: "user",
role: 'user',
content: [
{ type: "text", text: "First part" },
{ type: "text", text: "Second part" },
{ type: 'text', text: 'First part' },
{ type: 'text', text: 'Second part' },
],
},
];
const result = formatHistoryAsText(history);
expect(result).toBe(
"Previous conversation:\n\nUser: First part\nSecond part\n\n---\n\n"
);
expect(result).toBe('Previous conversation:\n\nUser: First part\nSecond part\n\n---\n\n');
});
it("should handle mixed string and array content", () => {
it('should handle mixed string and array content', () => {
const history: ConversationMessage[] = [
{ role: "user", content: "String message" },
{ role: 'user', content: 'String message' },
{
role: "assistant",
content: [{ type: "text", text: "Array message" }],
role: 'assistant',
content: [{ type: 'text', text: 'Array message' }],
},
];
const result = formatHistoryAsText(history);
expect(result).toContain("User: String message");
expect(result).toContain("Assistant: Array message");
expect(result).toContain('User: String message');
expect(result).toContain('Assistant: Array message');
});
});
describe("convertHistoryToMessages", () => {
it("should convert empty history", () => {
describe('convertHistoryToMessages', () => {
it('should convert empty history', () => {
const history: ConversationMessage[] = [];
const result = convertHistoryToMessages(history);
expect(result).toEqual([]);
});
it("should convert single user message", () => {
const history: ConversationMessage[] = [
{ role: "user", content: "Hello!" },
];
it('should convert single user message', () => {
const history: ConversationMessage[] = [{ role: 'user', content: 'Hello!' }];
const result = convertHistoryToMessages(history);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "user",
session_id: "",
type: 'user',
session_id: '',
message: {
role: "user",
content: [{ type: "text", text: "Hello!" }],
role: 'user',
content: [{ type: 'text', text: 'Hello!' }],
},
parent_tool_use_id: null,
});
});
it("should convert single assistant message", () => {
const history: ConversationMessage[] = [
{ role: "assistant", content: "Hi there!" },
];
it('should convert single assistant message', () => {
const history: ConversationMessage[] = [{ role: 'assistant', content: 'Hi there!' }];
const result = convertHistoryToMessages(history);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "assistant",
session_id: "",
type: 'assistant',
session_id: '',
message: {
role: "assistant",
content: [{ type: "text", text: "Hi there!" }],
role: 'assistant',
content: [{ type: 'text', text: 'Hi there!' }],
},
parent_tool_use_id: null,
});
});
it("should preserve array content as-is", () => {
it('should preserve array content as-is', () => {
const content = [
{ type: "text", text: "Text" },
{ type: "image", source: { data: "..." } },
{ type: 'text', text: 'Text' },
{ type: 'image', source: { data: '...' } },
];
const history: ConversationMessage[] = [{ role: "user", content }];
const history: ConversationMessage[] = [{ role: 'user', content }];
const result = convertHistoryToMessages(history);
expect(result[0].message.content).toEqual(content);
});
it("should convert multiple messages", () => {
it('should convert multiple messages', () => {
const history: ConversationMessage[] = [
{ role: "user", content: "First" },
{ role: "assistant", content: "Second" },
{ role: "user", content: "Third" },
{ role: 'user', content: 'First' },
{ role: 'assistant', content: 'Second' },
{ role: 'user', content: 'Third' },
];
const result = convertHistoryToMessages(history);
expect(result).toHaveLength(3);
expect(result[0].type).toBe("user");
expect(result[1].type).toBe("assistant");
expect(result[2].type).toBe("user");
expect(result[0].type).toBe('user');
expect(result[1].type).toBe('assistant');
expect(result[2].type).toBe('user');
});
it("should set session_id to empty string", () => {
const history: ConversationMessage[] = [
{ role: "user", content: "Test" },
];
it('should set session_id to empty string', () => {
const history: ConversationMessage[] = [{ role: 'user', content: 'Test' }];
const result = convertHistoryToMessages(history);
expect(result[0].session_id).toBe("");
expect(result[0].session_id).toBe('');
});
it("should set parent_tool_use_id to null", () => {
const history: ConversationMessage[] = [
{ role: "user", content: "Test" },
];
it('should set parent_tool_use_id to null', () => {
const history: ConversationMessage[] = [{ role: 'user', content: 'Test' }];
const result = convertHistoryToMessages(history);
expect(result[0].parent_tool_use_id).toBeNull();
});
it("should normalize string content to blocks", () => {
const history: ConversationMessage[] = [
{ role: "user", content: "String content" },
];
it('should normalize string content to blocks', () => {
const history: ConversationMessage[] = [{ role: 'user', content: 'String content' }];
const result = convertHistoryToMessages(history);
expect(result[0].message.content).toEqual([
{ type: "text", text: "String content" },
]);
expect(result[0].message.content).toEqual([{ type: 'text', text: 'String content' }]);
});
});
});

View File

@@ -1,261 +1,261 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect } from 'vitest';
import {
isAbortError,
isCancellationError,
isAuthenticationError,
classifyError,
getUserFriendlyErrorMessage,
} from "../src/error-handler";
} from '../src/error-handler';
describe("error-handler.ts", () => {
describe("isAbortError", () => {
describe('error-handler.ts', () => {
describe('isAbortError', () => {
it("should return true for Error with name 'AbortError'", () => {
const error = new Error("Operation aborted");
error.name = "AbortError";
const error = new Error('Operation aborted');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});
it("should return true for Error with message containing 'abort'", () => {
const error = new Error("Request was aborted");
const error = new Error('Request was aborted');
expect(isAbortError(error)).toBe(true);
});
it("should return false for regular Error", () => {
const error = new Error("Something went wrong");
it('should return false for regular Error', () => {
const error = new Error('Something went wrong');
expect(isAbortError(error)).toBe(false);
});
it("should return false for non-Error values", () => {
expect(isAbortError("abort")).toBe(false);
it('should return false for non-Error values', () => {
expect(isAbortError('abort')).toBe(false);
expect(isAbortError(null)).toBe(false);
expect(isAbortError(undefined)).toBe(false);
expect(isAbortError({})).toBe(false);
});
it("should handle Error with both AbortError name and abort message", () => {
const error = new Error("abort");
error.name = "AbortError";
it('should handle Error with both AbortError name and abort message', () => {
const error = new Error('abort');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});
});
describe("isCancellationError", () => {
describe('isCancellationError', () => {
it("should return true for 'cancelled' message", () => {
expect(isCancellationError("Operation cancelled")).toBe(true);
expect(isCancellationError("CANCELLED")).toBe(true);
expect(isCancellationError('Operation cancelled')).toBe(true);
expect(isCancellationError('CANCELLED')).toBe(true);
});
it("should return true for 'canceled' message (US spelling)", () => {
expect(isCancellationError("Operation canceled")).toBe(true);
expect(isCancellationError("CANCELED")).toBe(true);
expect(isCancellationError('Operation canceled')).toBe(true);
expect(isCancellationError('CANCELED')).toBe(true);
});
it("should return true for 'stopped' message", () => {
expect(isCancellationError("Process stopped")).toBe(true);
expect(isCancellationError("STOPPED")).toBe(true);
expect(isCancellationError('Process stopped')).toBe(true);
expect(isCancellationError('STOPPED')).toBe(true);
});
it("should return true for 'aborted' message", () => {
expect(isCancellationError("Request aborted")).toBe(true);
expect(isCancellationError("ABORTED")).toBe(true);
expect(isCancellationError('Request aborted')).toBe(true);
expect(isCancellationError('ABORTED')).toBe(true);
});
it("should return false for non-cancellation messages", () => {
expect(isCancellationError("Something went wrong")).toBe(false);
expect(isCancellationError("Error occurred")).toBe(false);
expect(isCancellationError("")).toBe(false);
it('should return false for non-cancellation messages', () => {
expect(isCancellationError('Something went wrong')).toBe(false);
expect(isCancellationError('Error occurred')).toBe(false);
expect(isCancellationError('')).toBe(false);
});
it("should be case-insensitive", () => {
expect(isCancellationError("CaNcElLeD")).toBe(true);
expect(isCancellationError("StOpPeD")).toBe(true);
it('should be case-insensitive', () => {
expect(isCancellationError('CaNcElLeD')).toBe(true);
expect(isCancellationError('StOpPeD')).toBe(true);
});
});
describe("isAuthenticationError", () => {
describe('isAuthenticationError', () => {
it("should return true for 'Authentication failed' message", () => {
expect(isAuthenticationError("Authentication failed")).toBe(true);
expect(isAuthenticationError('Authentication failed')).toBe(true);
});
it("should return true for 'Invalid API key' message", () => {
expect(isAuthenticationError("Invalid API key provided")).toBe(true);
expect(isAuthenticationError('Invalid API key provided')).toBe(true);
});
it("should return true for 'authentication_failed' message", () => {
expect(isAuthenticationError("Error: authentication_failed")).toBe(true);
expect(isAuthenticationError('Error: authentication_failed')).toBe(true);
});
it("should return true for 'Fix external API key' message", () => {
expect(isAuthenticationError("Fix external API key configuration")).toBe(true);
expect(isAuthenticationError('Fix external API key configuration')).toBe(true);
});
it("should return false for non-authentication errors", () => {
expect(isAuthenticationError("Something went wrong")).toBe(false);
expect(isAuthenticationError("Network error")).toBe(false);
expect(isAuthenticationError("")).toBe(false);
it('should return false for non-authentication errors', () => {
expect(isAuthenticationError('Something went wrong')).toBe(false);
expect(isAuthenticationError('Network error')).toBe(false);
expect(isAuthenticationError('')).toBe(false);
});
it("should be case-sensitive", () => {
expect(isAuthenticationError("authentication failed")).toBe(false);
expect(isAuthenticationError("AUTHENTICATION FAILED")).toBe(false);
it('should be case-sensitive', () => {
expect(isAuthenticationError('authentication failed')).toBe(false);
expect(isAuthenticationError('AUTHENTICATION FAILED')).toBe(false);
});
});
describe("classifyError", () => {
it("should classify authentication errors", () => {
const error = new Error("Authentication failed");
describe('classifyError', () => {
it('should classify authentication errors', () => {
const error = new Error('Authentication failed');
const result = classifyError(error);
expect(result.type).toBe("authentication");
expect(result.type).toBe('authentication');
expect(result.isAuth).toBe(true);
expect(result.isAbort).toBe(false);
expect(result.isCancellation).toBe(false);
expect(result.message).toBe("Authentication failed");
expect(result.message).toBe('Authentication failed');
expect(result.originalError).toBe(error);
});
it("should classify abort errors", () => {
const error = new Error("aborted");
it('should classify abort errors', () => {
const error = new Error('aborted');
const result = classifyError(error);
expect(result.type).toBe("abort");
expect(result.type).toBe('abort');
expect(result.isAbort).toBe(true);
expect(result.isAuth).toBe(false);
expect(result.message).toBe("aborted");
expect(result.message).toBe('aborted');
});
it("should classify AbortError by name", () => {
const error = new Error("Request cancelled");
error.name = "AbortError";
it('should classify AbortError by name', () => {
const error = new Error('Request cancelled');
error.name = 'AbortError';
const result = classifyError(error);
expect(result.type).toBe("abort");
expect(result.type).toBe('abort');
expect(result.isAbort).toBe(true);
});
it("should classify cancellation errors", () => {
const error = new Error("Operation cancelled");
it('should classify cancellation errors', () => {
const error = new Error('Operation cancelled');
const result = classifyError(error);
expect(result.type).toBe("cancellation");
expect(result.type).toBe('cancellation');
expect(result.isCancellation).toBe(true);
expect(result.isAbort).toBe(false);
});
it("should classify execution errors (regular Error)", () => {
const error = new Error("Something went wrong");
it('should classify execution errors (regular Error)', () => {
const error = new Error('Something went wrong');
const result = classifyError(error);
expect(result.type).toBe("execution");
expect(result.type).toBe('execution');
expect(result.isAuth).toBe(false);
expect(result.isAbort).toBe(false);
expect(result.isCancellation).toBe(false);
});
it("should classify unknown errors (non-Error)", () => {
const result = classifyError("string error");
it('should classify unknown errors (non-Error)', () => {
const result = classifyError('string error');
expect(result.type).toBe("unknown");
expect(result.message).toBe("string error");
expect(result.type).toBe('unknown');
expect(result.message).toBe('string error');
});
it("should handle null/undefined errors", () => {
it('should handle null/undefined errors', () => {
const result1 = classifyError(null);
expect(result1.type).toBe("unknown");
expect(result1.message).toBe("Unknown error");
expect(result1.type).toBe('unknown');
expect(result1.message).toBe('Unknown error');
const result2 = classifyError(undefined);
expect(result2.type).toBe("unknown");
expect(result2.message).toBe("Unknown error");
expect(result2.type).toBe('unknown');
expect(result2.message).toBe('Unknown error');
});
it("should prioritize authentication over abort", () => {
const error = new Error("Authentication failed - aborted");
it('should prioritize authentication over abort', () => {
const error = new Error('Authentication failed - aborted');
const result = classifyError(error);
expect(result.type).toBe("authentication");
expect(result.type).toBe('authentication');
expect(result.isAuth).toBe(true);
expect(result.isAbort).toBe(true); // Both flags can be true
});
it("should prioritize abort over cancellation", () => {
const error = new Error("Request cancelled");
error.name = "AbortError";
it('should prioritize abort over cancellation', () => {
const error = new Error('Request cancelled');
error.name = 'AbortError';
const result = classifyError(error);
expect(result.type).toBe("abort");
expect(result.type).toBe('abort');
expect(result.isAbort).toBe(true);
expect(result.isCancellation).toBe(true); // Both flags can be true
});
it("should convert object errors to string", () => {
const result = classifyError({ code: 500, message: "Server error" });
expect(result.message).toContain("Object");
it('should convert object errors to string', () => {
const result = classifyError({ code: 500, message: 'Server error' });
expect(result.message).toContain('Object');
});
it("should convert number errors to string", () => {
it('should convert number errors to string', () => {
const result = classifyError(404);
expect(result.message).toBe("404");
expect(result.type).toBe("unknown");
expect(result.message).toBe('404');
expect(result.type).toBe('unknown');
});
});
describe("getUserFriendlyErrorMessage", () => {
it("should return friendly message for abort errors", () => {
const error = new Error("abort");
describe('getUserFriendlyErrorMessage', () => {
it('should return friendly message for abort errors', () => {
const error = new Error('abort');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe("Operation was cancelled");
expect(message).toBe('Operation was cancelled');
});
it("should return friendly message for AbortError by name", () => {
const error = new Error("Something");
error.name = "AbortError";
it('should return friendly message for AbortError by name', () => {
const error = new Error('Something');
error.name = 'AbortError';
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe("Operation was cancelled");
expect(message).toBe('Operation was cancelled');
});
it("should return friendly message for authentication errors", () => {
const error = new Error("Authentication failed");
it('should return friendly message for authentication errors', () => {
const error = new Error('Authentication failed');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe("Authentication failed. Please check your API key.");
expect(message).toBe('Authentication failed. Please check your API key.');
});
it("should prioritize abort message over auth", () => {
const error = new Error("Authentication failed - abort");
it('should prioritize abort message over auth', () => {
const error = new Error('Authentication failed - abort');
const message = getUserFriendlyErrorMessage(error);
// Auth is checked first in classifyError, but abort check happens before auth in getUserFriendlyErrorMessage
expect(message).toBe("Operation was cancelled");
expect(message).toBe('Operation was cancelled');
});
it("should return original message for other errors", () => {
const error = new Error("Network timeout");
it('should return original message for other errors', () => {
const error = new Error('Network timeout');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe("Network timeout");
expect(message).toBe('Network timeout');
});
it("should handle non-Error values", () => {
expect(getUserFriendlyErrorMessage("string error")).toBe("string error");
expect(getUserFriendlyErrorMessage(null)).toBe("Unknown error");
expect(getUserFriendlyErrorMessage(undefined)).toBe("Unknown error");
it('should handle non-Error values', () => {
expect(getUserFriendlyErrorMessage('string error')).toBe('string error');
expect(getUserFriendlyErrorMessage(null)).toBe('Unknown error');
expect(getUserFriendlyErrorMessage(undefined)).toBe('Unknown error');
});
it("should return original message for cancellation errors", () => {
const error = new Error("Operation cancelled by user");
it('should return original message for cancellation errors', () => {
const error = new Error('Operation cancelled by user');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe("Operation cancelled by user");
expect(message).toBe('Operation cancelled by user');
});
it("should handle Error without message", () => {
it('should handle Error without message', () => {
const error = new Error();
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe("");
expect(message).toBe('');
});
});
});

View File

@@ -1,15 +1,15 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { mkdirSafe, existsSafe } from "../src/fs-utils";
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { mkdirSafe, existsSafe } from '../src/fs-utils';
describe("fs-utils.ts", () => {
describe('fs-utils.ts', () => {
let tempDir: string;
beforeEach(async () => {
// Create a temporary directory for testing
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "fs-utils-test-"));
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-utils-test-'));
});
afterEach(async () => {
@@ -21,9 +21,9 @@ describe("fs-utils.ts", () => {
}
});
describe("mkdirSafe", () => {
it("should create a new directory", async () => {
const newDir = path.join(tempDir, "new-directory");
describe('mkdirSafe', () => {
it('should create a new directory', async () => {
const newDir = path.join(tempDir, 'new-directory');
await mkdirSafe(newDir);
@@ -31,8 +31,8 @@ describe("fs-utils.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should create nested directories recursively", async () => {
const nestedDir = path.join(tempDir, "level1", "level2", "level3");
it('should create nested directories recursively', async () => {
const nestedDir = path.join(tempDir, 'level1', 'level2', 'level3');
await mkdirSafe(nestedDir);
@@ -40,54 +40,48 @@ describe("fs-utils.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should succeed when directory already exists", async () => {
const existingDir = path.join(tempDir, "existing");
it('should succeed when directory already exists', async () => {
const existingDir = path.join(tempDir, 'existing');
await fs.mkdir(existingDir);
await expect(mkdirSafe(existingDir)).resolves.not.toThrow();
});
it("should succeed when path is a symlink to a directory", async () => {
const targetDir = path.join(tempDir, "target");
const symlinkPath = path.join(tempDir, "symlink");
it('should succeed when path is a symlink to a directory', async () => {
const targetDir = path.join(tempDir, 'target');
const symlinkPath = path.join(tempDir, 'symlink');
await fs.mkdir(targetDir);
await fs.symlink(targetDir, symlinkPath, "dir");
await fs.symlink(targetDir, symlinkPath, 'dir');
await expect(mkdirSafe(symlinkPath)).resolves.not.toThrow();
});
it("should throw when path exists as a file", async () => {
const filePath = path.join(tempDir, "existing-file.txt");
await fs.writeFile(filePath, "content");
it('should throw when path exists as a file', async () => {
const filePath = path.join(tempDir, 'existing-file.txt');
await fs.writeFile(filePath, 'content');
await expect(mkdirSafe(filePath)).rejects.toThrow(
"Path exists and is not a directory"
);
await expect(mkdirSafe(filePath)).rejects.toThrow('Path exists and is not a directory');
});
it("should resolve relative paths", async () => {
it('should resolve relative paths', async () => {
const originalCwd = process.cwd();
try {
process.chdir(tempDir);
await mkdirSafe("relative-dir");
await mkdirSafe('relative-dir');
const stats = await fs.stat(path.join(tempDir, "relative-dir"));
const stats = await fs.stat(path.join(tempDir, 'relative-dir'));
expect(stats.isDirectory()).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it("should handle concurrent creation gracefully", async () => {
const newDir = path.join(tempDir, "concurrent");
it('should handle concurrent creation gracefully', async () => {
const newDir = path.join(tempDir, 'concurrent');
const promises = [
mkdirSafe(newDir),
mkdirSafe(newDir),
mkdirSafe(newDir),
];
const promises = [mkdirSafe(newDir), mkdirSafe(newDir), mkdirSafe(newDir)];
await expect(Promise.all(promises)).resolves.not.toThrow();
@@ -95,8 +89,8 @@ describe("fs-utils.ts", () => {
expect(stats.isDirectory()).toBe(true);
});
it("should handle paths with special characters", async () => {
const specialDir = path.join(tempDir, "dir with spaces & special-chars");
it('should handle paths with special characters', async () => {
const specialDir = path.join(tempDir, 'dir with spaces & special-chars');
await mkdirSafe(specialDir);
@@ -105,9 +99,9 @@ describe("fs-utils.ts", () => {
});
});
describe("existsSafe", () => {
it("should return true for existing directory", async () => {
const existingDir = path.join(tempDir, "exists");
describe('existsSafe', () => {
it('should return true for existing directory', async () => {
const existingDir = path.join(tempDir, 'exists');
await fs.mkdir(existingDir);
const result = await existsSafe(existingDir);
@@ -115,28 +109,28 @@ describe("fs-utils.ts", () => {
expect(result).toBe(true);
});
it("should return true for existing file", async () => {
const filePath = path.join(tempDir, "file.txt");
await fs.writeFile(filePath, "content");
it('should return true for existing file', async () => {
const filePath = path.join(tempDir, 'file.txt');
await fs.writeFile(filePath, 'content');
const result = await existsSafe(filePath);
expect(result).toBe(true);
});
it("should return false for non-existent path", async () => {
const nonExistent = path.join(tempDir, "does-not-exist");
it('should return false for non-existent path', async () => {
const nonExistent = path.join(tempDir, 'does-not-exist');
const result = await existsSafe(nonExistent);
expect(result).toBe(false);
});
it("should return true for symlink", async () => {
const target = path.join(tempDir, "target.txt");
const symlink = path.join(tempDir, "link.txt");
it('should return true for symlink', async () => {
const target = path.join(tempDir, 'target.txt');
const symlink = path.join(tempDir, 'link.txt');
await fs.writeFile(target, "content");
await fs.writeFile(target, 'content');
await fs.symlink(target, symlink);
const result = await existsSafe(symlink);
@@ -144,11 +138,11 @@ describe("fs-utils.ts", () => {
expect(result).toBe(true);
});
it("should return true for broken symlink", async () => {
const symlink = path.join(tempDir, "broken-link");
it('should return true for broken symlink', async () => {
const symlink = path.join(tempDir, 'broken-link');
// Create symlink to non-existent target
await fs.symlink("/non/existent/path", symlink);
await fs.symlink('/non/existent/path', symlink);
const result = await existsSafe(symlink);
@@ -156,14 +150,14 @@ describe("fs-utils.ts", () => {
expect(result).toBe(true);
});
it("should handle relative paths", async () => {
it('should handle relative paths', async () => {
const originalCwd = process.cwd();
try {
process.chdir(tempDir);
await fs.writeFile("test.txt", "content");
await fs.writeFile('test.txt', 'content');
const result = await existsSafe("test.txt");
const result = await existsSafe('test.txt');
expect(result).toBe(true);
} finally {
@@ -171,17 +165,17 @@ describe("fs-utils.ts", () => {
}
});
it("should handle paths with special characters", async () => {
const specialFile = path.join(tempDir, "file with spaces & chars.txt");
await fs.writeFile(specialFile, "content");
it('should handle paths with special characters', async () => {
const specialFile = path.join(tempDir, 'file with spaces & chars.txt');
await fs.writeFile(specialFile, 'content');
const result = await existsSafe(specialFile);
expect(result).toBe(true);
});
it("should return false for parent of non-existent nested path", async () => {
const nonExistent = path.join(tempDir, "does", "not", "exist");
it('should return false for parent of non-existent nested path', async () => {
const nonExistent = path.join(tempDir, 'does', 'not', 'exist');
const result = await existsSafe(nonExistent);
@@ -189,20 +183,20 @@ describe("fs-utils.ts", () => {
});
});
describe("Error handling", () => {
it("should handle permission errors in mkdirSafe", async () => {
describe('Error handling', () => {
it('should handle permission errors in mkdirSafe', async () => {
// Skip on Windows where permissions work differently
if (process.platform === "win32") {
if (process.platform === 'win32') {
return;
}
const restrictedDir = path.join(tempDir, "restricted");
const restrictedDir = path.join(tempDir, 'restricted');
await fs.mkdir(restrictedDir);
// Make directory read-only
await fs.chmod(restrictedDir, 0o444);
const newDir = path.join(restrictedDir, "new");
const newDir = path.join(restrictedDir, 'new');
try {
await expect(mkdirSafe(newDir)).rejects.toThrow();
@@ -212,23 +206,21 @@ describe("fs-utils.ts", () => {
}
});
it("should propagate unexpected errors in existsSafe", async () => {
const mockError = new Error("Unexpected error");
(mockError as any).code = "EACCES";
it('should propagate unexpected errors in existsSafe', async () => {
const mockError = new Error('Unexpected error');
(mockError as any).code = 'EACCES';
const spy = vi.spyOn(fs, "lstat").mockRejectedValueOnce(mockError);
const spy = vi.spyOn(fs, 'lstat').mockRejectedValueOnce(mockError);
await expect(existsSafe("/some/path")).rejects.toThrow(
"Unexpected error"
);
await expect(existsSafe('/some/path')).rejects.toThrow('Unexpected error');
spy.mockRestore();
});
});
describe("Integration scenarios", () => {
it("should work together: check existence then create if missing", async () => {
const dirPath = path.join(tempDir, "check-then-create");
describe('Integration scenarios', () => {
it('should work together: check existence then create if missing', async () => {
const dirPath = path.join(tempDir, 'check-then-create');
const existsBefore = await existsSafe(dirPath);
expect(existsBefore).toBe(false);
@@ -239,10 +231,10 @@ describe("fs-utils.ts", () => {
expect(existsAfter).toBe(true);
});
it("should handle nested directory creation with existence checks", async () => {
const level1 = path.join(tempDir, "level1");
const level2 = path.join(level1, "level2");
const level3 = path.join(level2, "level3");
it('should handle nested directory creation with existence checks', async () => {
const level1 = path.join(tempDir, 'level1');
const level2 = path.join(level1, 'level2');
const level3 = path.join(level2, 'level3');
await mkdirSafe(level3);

View File

@@ -1,19 +1,19 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import {
getMimeTypeForImage,
readImageAsBase64,
convertImagesToContentBlocks,
formatImagePathsForPrompt,
} from "../src/image-handler";
} from '../src/image-handler';
describe("image-handler.ts", () => {
describe('image-handler.ts', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "image-handler-test-"));
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'image-handler-test-'));
});
afterEach(async () => {
@@ -24,70 +24,70 @@ describe("image-handler.ts", () => {
}
});
describe("getMimeTypeForImage", () => {
it("should return correct MIME type for .jpg", () => {
expect(getMimeTypeForImage("image.jpg")).toBe("image/jpeg");
expect(getMimeTypeForImage("/path/to/image.jpg")).toBe("image/jpeg");
describe('getMimeTypeForImage', () => {
it('should return correct MIME type for .jpg', () => {
expect(getMimeTypeForImage('image.jpg')).toBe('image/jpeg');
expect(getMimeTypeForImage('/path/to/image.jpg')).toBe('image/jpeg');
});
it("should return correct MIME type for .jpeg", () => {
expect(getMimeTypeForImage("image.jpeg")).toBe("image/jpeg");
it('should return correct MIME type for .jpeg', () => {
expect(getMimeTypeForImage('image.jpeg')).toBe('image/jpeg');
});
it("should return correct MIME type for .png", () => {
expect(getMimeTypeForImage("image.png")).toBe("image/png");
it('should return correct MIME type for .png', () => {
expect(getMimeTypeForImage('image.png')).toBe('image/png');
});
it("should return correct MIME type for .gif", () => {
expect(getMimeTypeForImage("image.gif")).toBe("image/gif");
it('should return correct MIME type for .gif', () => {
expect(getMimeTypeForImage('image.gif')).toBe('image/gif');
});
it("should return correct MIME type for .webp", () => {
expect(getMimeTypeForImage("image.webp")).toBe("image/webp");
it('should return correct MIME type for .webp', () => {
expect(getMimeTypeForImage('image.webp')).toBe('image/webp');
});
it("should be case-insensitive", () => {
expect(getMimeTypeForImage("image.JPG")).toBe("image/jpeg");
expect(getMimeTypeForImage("image.PNG")).toBe("image/png");
expect(getMimeTypeForImage("image.GIF")).toBe("image/gif");
it('should be case-insensitive', () => {
expect(getMimeTypeForImage('image.JPG')).toBe('image/jpeg');
expect(getMimeTypeForImage('image.PNG')).toBe('image/png');
expect(getMimeTypeForImage('image.GIF')).toBe('image/gif');
});
it("should default to image/png for unknown extensions", () => {
expect(getMimeTypeForImage("file.xyz")).toBe("image/png");
expect(getMimeTypeForImage("file.txt")).toBe("image/png");
expect(getMimeTypeForImage("file")).toBe("image/png");
it('should default to image/png for unknown extensions', () => {
expect(getMimeTypeForImage('file.xyz')).toBe('image/png');
expect(getMimeTypeForImage('file.txt')).toBe('image/png');
expect(getMimeTypeForImage('file')).toBe('image/png');
});
it("should handle filenames with multiple dots", () => {
expect(getMimeTypeForImage("my.file.name.jpg")).toBe("image/jpeg");
it('should handle filenames with multiple dots', () => {
expect(getMimeTypeForImage('my.file.name.jpg')).toBe('image/jpeg');
});
});
describe("readImageAsBase64", () => {
it("should read image and return base64 data", async () => {
const imagePath = path.join(tempDir, "test.png");
const imageContent = Buffer.from("fake png data");
describe('readImageAsBase64', () => {
it('should read image and return base64 data', async () => {
const imagePath = path.join(tempDir, 'test.png');
const imageContent = Buffer.from('fake png data');
await fs.writeFile(imagePath, imageContent);
const result = await readImageAsBase64(imagePath);
expect(result.base64).toBe(imageContent.toString("base64"));
expect(result.mimeType).toBe("image/png");
expect(result.filename).toBe("test.png");
expect(result.base64).toBe(imageContent.toString('base64'));
expect(result.mimeType).toBe('image/png');
expect(result.filename).toBe('test.png');
expect(result.originalPath).toBe(imagePath);
});
it("should handle different image formats", async () => {
it('should handle different image formats', async () => {
const formats = [
{ ext: "jpg", mime: "image/jpeg" },
{ ext: "png", mime: "image/png" },
{ ext: "gif", mime: "image/gif" },
{ ext: "webp", mime: "image/webp" },
{ ext: 'jpg', mime: 'image/jpeg' },
{ ext: 'png', mime: 'image/png' },
{ ext: 'gif', mime: 'image/gif' },
{ ext: 'webp', mime: 'image/webp' },
];
for (const format of formats) {
const imagePath = path.join(tempDir, `image.${format.ext}`);
await fs.writeFile(imagePath, Buffer.from("data"));
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await readImageAsBase64(imagePath);
@@ -97,154 +97,148 @@ describe("image-handler.ts", () => {
});
it("should throw error if file doesn't exist", async () => {
const imagePath = path.join(tempDir, "nonexistent.png");
const imagePath = path.join(tempDir, 'nonexistent.png');
await expect(readImageAsBase64(imagePath)).rejects.toThrow();
});
it("should handle binary image data correctly", async () => {
const imagePath = path.join(tempDir, "binary.png");
it('should handle binary image data correctly', async () => {
const imagePath = path.join(tempDir, 'binary.png');
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
await fs.writeFile(imagePath, binaryData);
const result = await readImageAsBase64(imagePath);
expect(result.base64).toBe(binaryData.toString("base64"));
expect(result.base64).toBe(binaryData.toString('base64'));
});
});
describe("convertImagesToContentBlocks", () => {
it("should convert single image to content block", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("image data"));
describe('convertImagesToContentBlocks', () => {
it('should convert single image to content block', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('image data'));
const result = await convertImagesToContentBlocks([imagePath]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: "image",
type: 'image',
source: {
type: "base64",
media_type: "image/png",
type: 'base64',
media_type: 'image/png',
},
});
expect(result[0].source.data).toBeTruthy();
});
it("should convert multiple images", async () => {
const image1 = path.join(tempDir, "image1.jpg");
const image2 = path.join(tempDir, "image2.png");
it('should convert multiple images', async () => {
const image1 = path.join(tempDir, 'image1.jpg');
const image2 = path.join(tempDir, 'image2.png');
await fs.writeFile(image1, Buffer.from("jpg data"));
await fs.writeFile(image2, Buffer.from("png data"));
await fs.writeFile(image1, Buffer.from('jpg data'));
await fs.writeFile(image2, Buffer.from('png data'));
const result = await convertImagesToContentBlocks([image1, image2]);
expect(result).toHaveLength(2);
expect(result[0].source.media_type).toBe("image/jpeg");
expect(result[1].source.media_type).toBe("image/png");
expect(result[0].source.media_type).toBe('image/jpeg');
expect(result[1].source.media_type).toBe('image/png');
});
it("should resolve relative paths with workDir", async () => {
const image = "test.png";
it('should resolve relative paths with workDir', async () => {
const image = 'test.png';
const imagePath = path.join(tempDir, image);
await fs.writeFile(imagePath, Buffer.from("data"));
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await convertImagesToContentBlocks([image], tempDir);
expect(result).toHaveLength(1);
expect(result[0].type).toBe("image");
expect(result[0].type).toBe('image');
});
it("should handle absolute paths without workDir", async () => {
const imagePath = path.join(tempDir, "absolute.png");
await fs.writeFile(imagePath, Buffer.from("data"));
it('should handle absolute paths without workDir', async () => {
const imagePath = path.join(tempDir, 'absolute.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await convertImagesToContentBlocks([imagePath]);
expect(result).toHaveLength(1);
});
it("should skip images that fail to load", async () => {
const validImage = path.join(tempDir, "valid.png");
const invalidImage = path.join(tempDir, "nonexistent.png");
it('should skip images that fail to load', async () => {
const validImage = path.join(tempDir, 'valid.png');
const invalidImage = path.join(tempDir, 'nonexistent.png');
await fs.writeFile(validImage, Buffer.from("data"));
await fs.writeFile(validImage, Buffer.from('data'));
const result = await convertImagesToContentBlocks([
validImage,
invalidImage,
]);
const result = await convertImagesToContentBlocks([validImage, invalidImage]);
expect(result).toHaveLength(1);
expect(result[0].source.media_type).toBe("image/png");
expect(result[0].source.media_type).toBe('image/png');
});
it("should return empty array for empty input", async () => {
it('should return empty array for empty input', async () => {
const result = await convertImagesToContentBlocks([]);
expect(result).toEqual([]);
});
it("should preserve order of images", async () => {
const images = ["img1.jpg", "img2.png", "img3.gif"];
it('should preserve order of images', async () => {
const images = ['img1.jpg', 'img2.png', 'img3.gif'];
for (const img of images) {
await fs.writeFile(path.join(tempDir, img), Buffer.from("data"));
await fs.writeFile(path.join(tempDir, img), Buffer.from('data'));
}
const result = await convertImagesToContentBlocks(images, tempDir);
expect(result).toHaveLength(3);
expect(result[0].source.media_type).toBe("image/jpeg");
expect(result[1].source.media_type).toBe("image/png");
expect(result[2].source.media_type).toBe("image/gif");
expect(result[0].source.media_type).toBe('image/jpeg');
expect(result[1].source.media_type).toBe('image/png');
expect(result[2].source.media_type).toBe('image/gif');
});
});
describe("formatImagePathsForPrompt", () => {
it("should return empty string for empty array", () => {
describe('formatImagePathsForPrompt', () => {
it('should return empty string for empty array', () => {
const result = formatImagePathsForPrompt([]);
expect(result).toBe("");
expect(result).toBe('');
});
it("should format single image path", () => {
const result = formatImagePathsForPrompt(["/path/to/image.png"]);
expect(result).toBe("\n\nAttached images:\n- /path/to/image.png\n");
it('should format single image path', () => {
const result = formatImagePathsForPrompt(['/path/to/image.png']);
expect(result).toBe('\n\nAttached images:\n- /path/to/image.png\n');
});
it("should format multiple image paths", () => {
it('should format multiple image paths', () => {
const result = formatImagePathsForPrompt([
"/path/image1.png",
"/path/image2.jpg",
"/path/image3.gif",
'/path/image1.png',
'/path/image2.jpg',
'/path/image3.gif',
]);
expect(result).toBe(
"\n\nAttached images:\n" +
"- /path/image1.png\n" +
"- /path/image2.jpg\n" +
"- /path/image3.gif\n"
'\n\nAttached images:\n' +
'- /path/image1.png\n' +
'- /path/image2.jpg\n' +
'- /path/image3.gif\n'
);
});
it("should handle relative paths", () => {
const result = formatImagePathsForPrompt([
"relative/path/image.png",
"another/image.jpg",
]);
it('should handle relative paths', () => {
const result = formatImagePathsForPrompt(['relative/path/image.png', 'another/image.jpg']);
expect(result).toContain("- relative/path/image.png");
expect(result).toContain("- another/image.jpg");
expect(result).toContain('- relative/path/image.png');
expect(result).toContain('- another/image.jpg');
});
it("should start with newlines", () => {
const result = formatImagePathsForPrompt(["/image.png"]);
expect(result.startsWith("\n\n")).toBe(true);
it('should start with newlines', () => {
const result = formatImagePathsForPrompt(['/image.png']);
expect(result.startsWith('\n\n')).toBe(true);
});
it("should include header text", () => {
const result = formatImagePathsForPrompt(["/image.png"]);
expect(result).toContain("Attached images:");
it('should include header text', () => {
const result = formatImagePathsForPrompt(['/image.png']);
expect(result).toContain('Attached images:');
});
});
});

View File

@@ -1,12 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
createLogger,
LogLevel,
getLogLevel,
setLogLevel,
} from "../src/logger";
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createLogger, LogLevel, getLogLevel, setLogLevel } from '../src/logger';
describe("logger.ts", () => {
describe('logger.ts', () => {
let originalConsoleError: typeof console.error;
let originalConsoleWarn: typeof console.warn;
let originalConsoleLog: typeof console.log;
@@ -33,85 +28,77 @@ describe("logger.ts", () => {
setLogLevel(originalLogLevel);
});
describe("createLogger", () => {
it("should create logger with context prefix", () => {
const logger = createLogger("TestContext");
describe('createLogger', () => {
it('should create logger with context prefix', () => {
const logger = createLogger('TestContext');
setLogLevel(LogLevel.INFO);
logger.info("test message");
logger.info('test message');
expect(console.log).toHaveBeenCalledWith(
"[TestContext]",
"test message"
);
expect(console.log).toHaveBeenCalledWith('[TestContext]', 'test message');
});
it("should handle multiple arguments", () => {
const logger = createLogger("Test");
it('should handle multiple arguments', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.INFO);
logger.info("message", { data: 123 }, [1, 2, 3]);
logger.info('message', { data: 123 }, [1, 2, 3]);
expect(console.log).toHaveBeenCalledWith(
"[Test]",
"message",
{ data: 123 },
[1, 2, 3]
);
expect(console.log).toHaveBeenCalledWith('[Test]', 'message', { data: 123 }, [1, 2, 3]);
});
});
describe("Log levels", () => {
it("should log error at ERROR level", () => {
const logger = createLogger("Test");
describe('Log levels', () => {
it('should log error at ERROR level', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.ERROR);
logger.error("error message");
logger.warn("warn message");
logger.info("info message");
logger.debug("debug message");
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.warn).not.toHaveBeenCalled();
expect(console.log).not.toHaveBeenCalled();
});
it("should log error and warn at WARN level", () => {
const logger = createLogger("Test");
it('should log error and warn at WARN level', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.WARN);
logger.error("error message");
logger.warn("warn message");
logger.info("info message");
logger.debug("debug message");
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.log).not.toHaveBeenCalled();
});
it("should log error, warn, and info at INFO level", () => {
const logger = createLogger("Test");
it('should log error, warn, and info at INFO level', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.INFO);
logger.error("error message");
logger.warn("warn message");
logger.info("info message");
logger.debug("debug message");
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledTimes(1);
expect(console.log).toHaveBeenCalledTimes(1); // Only info, not debug
});
it("should log all messages at DEBUG level", () => {
const logger = createLogger("Test");
it('should log all messages at DEBUG level', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.DEBUG);
logger.error("error message");
logger.warn("warn message");
logger.info("info message");
logger.debug("debug message");
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.warn).toHaveBeenCalledTimes(1);
@@ -119,97 +106,90 @@ describe("logger.ts", () => {
});
});
describe("error method", () => {
it("should use console.error", () => {
const logger = createLogger("ErrorTest");
describe('error method', () => {
it('should use console.error', () => {
const logger = createLogger('ErrorTest');
setLogLevel(LogLevel.ERROR);
logger.error("error occurred", { code: 500 });
logger.error('error occurred', { code: 500 });
expect(console.error).toHaveBeenCalledWith(
"[ErrorTest]",
"error occurred",
{ code: 500 }
);
expect(console.error).toHaveBeenCalledWith('[ErrorTest]', 'error occurred', { code: 500 });
});
it("should not log when level is below ERROR", () => {
const logger = createLogger("Test");
setLogLevel(LogLevel.ERROR - 1 as LogLevel);
it('should not log when level is below ERROR', () => {
const logger = createLogger('Test');
setLogLevel((LogLevel.ERROR - 1) as LogLevel);
logger.error("should not appear");
logger.error('should not appear');
expect(console.error).not.toHaveBeenCalled();
});
});
describe("warn method", () => {
it("should use console.warn", () => {
const logger = createLogger("WarnTest");
describe('warn method', () => {
it('should use console.warn', () => {
const logger = createLogger('WarnTest');
setLogLevel(LogLevel.WARN);
logger.warn("warning message");
logger.warn('warning message');
expect(console.warn).toHaveBeenCalledWith("[WarnTest]", "warning message");
expect(console.warn).toHaveBeenCalledWith('[WarnTest]', 'warning message');
});
it("should not log when level is below WARN", () => {
const logger = createLogger("Test");
it('should not log when level is below WARN', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.ERROR);
logger.warn("should not appear");
logger.warn('should not appear');
expect(console.warn).not.toHaveBeenCalled();
});
});
describe("info method", () => {
it("should use console.log", () => {
const logger = createLogger("InfoTest");
describe('info method', () => {
it('should use console.log', () => {
const logger = createLogger('InfoTest');
setLogLevel(LogLevel.INFO);
logger.info("info message");
logger.info('info message');
expect(console.log).toHaveBeenCalledWith("[InfoTest]", "info message");
expect(console.log).toHaveBeenCalledWith('[InfoTest]', 'info message');
});
it("should not log when level is below INFO", () => {
const logger = createLogger("Test");
it('should not log when level is below INFO', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.WARN);
logger.info("should not appear");
logger.info('should not appear');
expect(console.log).not.toHaveBeenCalled();
});
});
describe("debug method", () => {
it("should use console.log with DEBUG prefix", () => {
const logger = createLogger("DebugTest");
describe('debug method', () => {
it('should use console.log with DEBUG prefix', () => {
const logger = createLogger('DebugTest');
setLogLevel(LogLevel.DEBUG);
logger.debug("debug details", { trace: "..." });
logger.debug('debug details', { trace: '...' });
expect(console.log).toHaveBeenCalledWith(
"[DebugTest]",
"[DEBUG]",
"debug details",
{ trace: "..." }
);
expect(console.log).toHaveBeenCalledWith('[DebugTest]', '[DEBUG]', 'debug details', {
trace: '...',
});
});
it("should not log when level is below DEBUG", () => {
const logger = createLogger("Test");
it('should not log when level is below DEBUG', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.INFO);
logger.debug("should not appear");
logger.debug('should not appear');
expect(console.log).not.toHaveBeenCalled();
});
});
describe("getLogLevel", () => {
it("should return current log level", () => {
describe('getLogLevel', () => {
it('should return current log level', () => {
setLogLevel(LogLevel.DEBUG);
expect(getLogLevel()).toBe(LogLevel.DEBUG);
@@ -218,8 +198,8 @@ describe("logger.ts", () => {
});
});
describe("setLogLevel", () => {
it("should change log level", () => {
describe('setLogLevel', () => {
it('should change log level', () => {
setLogLevel(LogLevel.WARN);
expect(getLogLevel()).toBe(LogLevel.WARN);
@@ -227,86 +207,75 @@ describe("logger.ts", () => {
expect(getLogLevel()).toBe(LogLevel.DEBUG);
});
it("should affect subsequent logging", () => {
const logger = createLogger("Test");
it('should affect subsequent logging', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.ERROR);
logger.info("should not log");
logger.info('should not log');
expect(console.log).not.toHaveBeenCalled();
setLogLevel(LogLevel.INFO);
logger.info("should log");
expect(console.log).toHaveBeenCalledWith("[Test]", "should log");
logger.info('should log');
expect(console.log).toHaveBeenCalledWith('[Test]', 'should log');
});
});
describe("Multiple logger instances", () => {
it("should maintain separate contexts", () => {
const logger1 = createLogger("Service1");
const logger2 = createLogger("Service2");
describe('Multiple logger instances', () => {
it('should maintain separate contexts', () => {
const logger1 = createLogger('Service1');
const logger2 = createLogger('Service2');
setLogLevel(LogLevel.INFO);
logger1.info("from service 1");
logger2.info("from service 2");
logger1.info('from service 1');
logger2.info('from service 2');
expect(console.log).toHaveBeenNthCalledWith(
1,
"[Service1]",
"from service 1"
);
expect(console.log).toHaveBeenNthCalledWith(
2,
"[Service2]",
"from service 2"
);
expect(console.log).toHaveBeenNthCalledWith(1, '[Service1]', 'from service 1');
expect(console.log).toHaveBeenNthCalledWith(2, '[Service2]', 'from service 2');
});
it("should share log level setting", () => {
const logger1 = createLogger("Service1");
const logger2 = createLogger("Service2");
it('should share log level setting', () => {
const logger1 = createLogger('Service1');
const logger2 = createLogger('Service2');
setLogLevel(LogLevel.ERROR);
logger1.info("should not log");
logger2.info("should not log");
logger1.info('should not log');
logger2.info('should not log');
expect(console.log).not.toHaveBeenCalled();
});
});
describe("Edge cases", () => {
it("should handle empty context string", () => {
const logger = createLogger("");
describe('Edge cases', () => {
it('should handle empty context string', () => {
const logger = createLogger('');
setLogLevel(LogLevel.INFO);
logger.info("message");
logger.info('message');
expect(console.log).toHaveBeenCalledWith("[]", "message");
expect(console.log).toHaveBeenCalledWith('[]', 'message');
});
it("should handle context with special characters", () => {
const logger = createLogger("Test-Service_v2.0");
it('should handle context with special characters', () => {
const logger = createLogger('Test-Service_v2.0');
setLogLevel(LogLevel.INFO);
logger.info("message");
logger.info('message');
expect(console.log).toHaveBeenCalledWith(
"[Test-Service_v2.0]",
"message"
);
expect(console.log).toHaveBeenCalledWith('[Test-Service_v2.0]', 'message');
});
it("should handle no arguments to log methods", () => {
const logger = createLogger("Test");
it('should handle no arguments to log methods', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.INFO);
logger.info();
expect(console.log).toHaveBeenCalledWith("[Test]");
expect(console.log).toHaveBeenCalledWith('[Test]');
});
it("should handle complex object arguments", () => {
const logger = createLogger("Test");
it('should handle complex object arguments', () => {
const logger = createLogger('Test');
setLogLevel(LogLevel.INFO);
const complexObj = {
@@ -315,9 +284,9 @@ describe("logger.ts", () => {
fn: () => {},
};
logger.info("complex", complexObj);
logger.info('complex', complexObj);
expect(console.log).toHaveBeenCalledWith("[Test]", "complex", complexObj);
expect(console.log).toHaveBeenCalledWith('[Test]', 'complex', complexObj);
});
});
});

View File

@@ -1,14 +1,14 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { buildPromptWithImages } from "../src/prompt-builder";
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { buildPromptWithImages } from '../src/prompt-builder';
describe("prompt-builder.ts", () => {
describe('prompt-builder.ts', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "prompt-builder-test-"));
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prompt-builder-test-'));
});
afterEach(async () => {
@@ -19,43 +19,40 @@ describe("prompt-builder.ts", () => {
}
});
describe("buildPromptWithImages - no images", () => {
it("should return plain text when no images provided", async () => {
const basePrompt = "Hello, world!";
describe('buildPromptWithImages - no images', () => {
it('should return plain text when no images provided', async () => {
const basePrompt = 'Hello, world!';
const result = await buildPromptWithImages(basePrompt);
expect(result.content).toBe("Hello, world!");
expect(result.content).toBe('Hello, world!');
expect(result.hasImages).toBe(false);
});
it("should return plain text when empty image array provided", async () => {
const basePrompt = "Test prompt";
it('should return plain text when empty image array provided', async () => {
const basePrompt = 'Test prompt';
const result = await buildPromptWithImages(basePrompt, []);
expect(result.content).toBe("Test prompt");
expect(result.content).toBe('Test prompt');
expect(result.hasImages).toBe(false);
});
it("should handle multiline prompts", async () => {
const basePrompt = "Line 1\nLine 2\nLine 3";
it('should handle multiline prompts', async () => {
const basePrompt = 'Line 1\nLine 2\nLine 3';
const result = await buildPromptWithImages(basePrompt);
expect(result.content).toBe("Line 1\nLine 2\nLine 3");
expect(result.content).toBe('Line 1\nLine 2\nLine 3');
});
});
describe("buildPromptWithImages - with images", () => {
it("should build content blocks with single image", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("image data"));
describe('buildPromptWithImages - with images', () => {
it('should build content blocks with single image', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('image data'));
const result = await buildPromptWithImages(
"Check this image",
[imagePath]
);
const result = await buildPromptWithImages('Check this image', [imagePath]);
expect(result.hasImages).toBe(true);
expect(Array.isArray(result.content)).toBe(true);
@@ -67,25 +64,22 @@ describe("prompt-builder.ts", () => {
}>;
expect(blocks).toHaveLength(2);
expect(blocks[0]).toMatchObject({
type: "text",
text: "Check this image",
type: 'text',
text: 'Check this image',
});
expect(blocks[1]).toMatchObject({
type: "image",
type: 'image',
});
});
it("should build content blocks with multiple images", async () => {
const image1 = path.join(tempDir, "img1.jpg");
const image2 = path.join(tempDir, "img2.png");
it('should build content blocks with multiple images', async () => {
const image1 = path.join(tempDir, 'img1.jpg');
const image2 = path.join(tempDir, 'img2.png');
await fs.writeFile(image1, Buffer.from("jpg data"));
await fs.writeFile(image2, Buffer.from("png data"));
await fs.writeFile(image1, Buffer.from('jpg data'));
await fs.writeFile(image2, Buffer.from('png data'));
const result = await buildPromptWithImages("Two images", [
image1,
image2,
]);
const result = await buildPromptWithImages('Two images', [image1, image2]);
expect(result.hasImages).toBe(true);
@@ -95,107 +89,93 @@ describe("prompt-builder.ts", () => {
source?: object;
}>;
expect(blocks).toHaveLength(3); // 1 text + 2 images
expect(blocks[0].type).toBe("text");
expect(blocks[1].type).toBe("image");
expect(blocks[2].type).toBe("image");
expect(blocks[0].type).toBe('text');
expect(blocks[1].type).toBe('image');
expect(blocks[2].type).toBe('image');
});
it("should resolve relative paths with workDir", async () => {
const imagePath = "test.png";
it('should resolve relative paths with workDir', async () => {
const imagePath = 'test.png';
const fullPath = path.join(tempDir, imagePath);
await fs.writeFile(fullPath, Buffer.from("data"));
await fs.writeFile(fullPath, Buffer.from('data'));
const result = await buildPromptWithImages(
"Test",
[imagePath],
tempDir
);
const result = await buildPromptWithImages('Test', [imagePath], tempDir);
expect(result.hasImages).toBe(true);
expect(Array.isArray(result.content)).toBe(true);
});
it("should handle absolute paths without workDir", async () => {
const imagePath = path.join(tempDir, "absolute.png");
await fs.writeFile(imagePath, Buffer.from("data"));
it('should handle absolute paths without workDir', async () => {
const imagePath = path.join(tempDir, 'absolute.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await buildPromptWithImages("Test", [imagePath]);
const result = await buildPromptWithImages('Test', [imagePath]);
expect(result.hasImages).toBe(true);
});
});
describe("buildPromptWithImages - includeImagePaths option", () => {
it("should not include image paths by default", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("data"));
describe('buildPromptWithImages - includeImagePaths option', () => {
it('should not include image paths by default', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await buildPromptWithImages("Prompt", [imagePath]);
const result = await buildPromptWithImages('Prompt', [imagePath]);
const blocks = result.content as Array<{
type: string;
text?: string;
}>;
const textBlock = blocks.find((b) => b.type === "text");
const textBlock = blocks.find((b) => b.type === 'text');
expect(textBlock?.text).not.toContain("Attached images:");
expect(textBlock?.text).toBe("Prompt");
expect(textBlock?.text).not.toContain('Attached images:');
expect(textBlock?.text).toBe('Prompt');
});
it("should include image paths when requested", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("data"));
it('should include image paths when requested', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await buildPromptWithImages(
"Prompt",
[imagePath],
undefined,
true
);
const result = await buildPromptWithImages('Prompt', [imagePath], undefined, true);
const blocks = result.content as Array<{
type: string;
text?: string;
}>;
const textBlock = blocks.find((b) => b.type === "text");
const textBlock = blocks.find((b) => b.type === 'text');
expect(textBlock?.text).toContain("Prompt");
expect(textBlock?.text).toContain("Attached images:");
expect(textBlock?.text).toContain('Prompt');
expect(textBlock?.text).toContain('Attached images:');
expect(textBlock?.text).toContain(imagePath);
});
it("should format multiple image paths when included", async () => {
const img1 = path.join(tempDir, "img1.png");
const img2 = path.join(tempDir, "img2.jpg");
it('should format multiple image paths when included', async () => {
const img1 = path.join(tempDir, 'img1.png');
const img2 = path.join(tempDir, 'img2.jpg');
await fs.writeFile(img1, Buffer.from("data1"));
await fs.writeFile(img2, Buffer.from("data2"));
await fs.writeFile(img1, Buffer.from('data1'));
await fs.writeFile(img2, Buffer.from('data2'));
const result = await buildPromptWithImages(
"Test",
[img1, img2],
undefined,
true
);
const result = await buildPromptWithImages('Test', [img1, img2], undefined, true);
const blocks = result.content as Array<{
type: string;
text?: string;
}>;
const textBlock = blocks.find((b) => b.type === "text");
const textBlock = blocks.find((b) => b.type === 'text');
expect(textBlock?.text).toContain("Attached images:");
expect(textBlock?.text).toContain('Attached images:');
expect(textBlock?.text).toContain(img1);
expect(textBlock?.text).toContain(img2);
});
});
describe("buildPromptWithImages - edge cases", () => {
it("should handle empty prompt with images", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("data"));
describe('buildPromptWithImages - edge cases', () => {
it('should handle empty prompt with images', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await buildPromptWithImages("", [imagePath]);
const result = await buildPromptWithImages('', [imagePath]);
expect(result.hasImages).toBe(true);
@@ -206,14 +186,14 @@ describe("prompt-builder.ts", () => {
}>;
// Should only have image block, no text block for empty string
expect(blocks.length).toBeGreaterThan(0);
expect(blocks.some((b) => b.type === "image")).toBe(true);
expect(blocks.some((b) => b.type === 'image')).toBe(true);
});
it("should handle whitespace-only prompt with images", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("data"));
it('should handle whitespace-only prompt with images', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await buildPromptWithImages(" ", [imagePath]);
const result = await buildPromptWithImages(' ', [imagePath]);
expect(result.hasImages).toBe(true);
@@ -223,19 +203,16 @@ describe("prompt-builder.ts", () => {
source?: object;
}>;
// Whitespace-only is trimmed, so no text block should be added
expect(blocks.every((b) => b.type !== "text")).toBe(true);
expect(blocks.every((b) => b.type !== 'text')).toBe(true);
});
it("should skip failed image loads", async () => {
const validImage = path.join(tempDir, "valid.png");
const invalidImage = path.join(tempDir, "nonexistent.png");
it('should skip failed image loads', async () => {
const validImage = path.join(tempDir, 'valid.png');
const invalidImage = path.join(tempDir, 'nonexistent.png');
await fs.writeFile(validImage, Buffer.from("data"));
await fs.writeFile(validImage, Buffer.from('data'));
const result = await buildPromptWithImages("Test", [
validImage,
invalidImage,
]);
const result = await buildPromptWithImages('Test', [validImage, invalidImage]);
expect(result.hasImages).toBe(true);
@@ -244,28 +221,18 @@ describe("prompt-builder.ts", () => {
text?: string;
source?: object;
}>;
const imageBlocks = blocks.filter((b) => b.type === "image");
const imageBlocks = blocks.filter((b) => b.type === 'image');
// Only valid image should be included
expect(imageBlocks).toHaveLength(1);
});
it("should handle mixed case in includeImagePaths parameter", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("data"));
it('should handle mixed case in includeImagePaths parameter', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const resultFalse = await buildPromptWithImages(
"Test",
[imagePath],
undefined,
false
);
const resultTrue = await buildPromptWithImages(
"Test",
[imagePath],
undefined,
true
);
const resultFalse = await buildPromptWithImages('Test', [imagePath], undefined, false);
const resultTrue = await buildPromptWithImages('Test', [imagePath], undefined, true);
const blocksFalse = resultFalse.content as Array<{
type: string;
@@ -276,31 +243,31 @@ describe("prompt-builder.ts", () => {
text?: string;
}>;
expect(blocksFalse[0].text).not.toContain("Attached images:");
expect(blocksTrue[0].text).toContain("Attached images:");
expect(blocksFalse[0].text).not.toContain('Attached images:');
expect(blocksTrue[0].text).toContain('Attached images:');
});
});
describe("buildPromptWithImages - content format", () => {
it("should return string when only text and includeImagePaths false", async () => {
const result = await buildPromptWithImages("Just text", undefined);
describe('buildPromptWithImages - content format', () => {
it('should return string when only text and includeImagePaths false', async () => {
const result = await buildPromptWithImages('Just text', undefined);
expect(typeof result.content).toBe("string");
expect(typeof result.content).toBe('string');
});
it("should return array when has images", async () => {
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("data"));
it('should return array when has images', async () => {
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await buildPromptWithImages("Text", [imagePath]);
const result = await buildPromptWithImages('Text', [imagePath]);
expect(Array.isArray(result.content)).toBe(true);
});
it("should preserve prompt formatting", async () => {
const basePrompt = "Line 1\n\nLine 2\n Indented line";
const imagePath = path.join(tempDir, "test.png");
await fs.writeFile(imagePath, Buffer.from("data"));
it('should preserve prompt formatting', async () => {
const basePrompt = 'Line 1\n\nLine 2\n Indented line';
const imagePath = path.join(tempDir, 'test.png');
await fs.writeFile(imagePath, Buffer.from('data'));
const result = await buildPromptWithImages(basePrompt, [imagePath]);
@@ -308,7 +275,7 @@ describe("prompt-builder.ts", () => {
type: string;
text?: string;
}>;
const textBlock = blocks.find((b) => b.type === "text");
const textBlock = blocks.find((b) => b.type === 'text');
expect(textBlock?.text).toBe(basePrompt);
});

View File

@@ -1,15 +1,15 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/index.ts"],
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
thresholds: {
// Excellent coverage: 94.3% stmts, 89.77% branches, 100% funcs, 94.21% lines
// All files now have comprehensive tests