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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@
"build": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {},
"dependencies": {
"@stackbilt/core": "^1.0.0",
"@stackbilt/scaffold-core": "^1.1.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
Expand Down
24 changes: 15 additions & 9 deletions src/__tests__/auth-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,27 +93,33 @@ afterEach(() => {
fs.rmSync(tmpCwd, { recursive: true, force: true });
});

describe('architect — auth wiring', () => {
it('forwards the env-sourced API key (and custom baseUrl) to EngineClient', async () => {
describe('architect — governance-only (no network)', () => {
// architect was rewritten in Issue #5 to be zero-network: it runs classify
// heuristics locally and emits governance docs as markdown/JSON.
// EngineClient is no longer instantiated by this command.

it('returns SUCCESS and emits JSON governance docs without any network call', async () => {
mockedResolveApiKey.mockReturnValue({
apiKey: 'ea_env_wiring',
source: 'env',
baseUrl: 'https://engine.example',
});

await architectCommand(options, ['a simple project description']);
const code = await architectCommand(options, ['multi-tenant SaaS API with Stripe billing']);

expect(hoisted.constructorArgs).toHaveLength(1);
expect(hoisted.constructorArgs[0].apiKey).toBe('ea_env_wiring');
expect(hoisted.constructorArgs[0].baseUrl).toBe('https://engine.example');
expect(code).toBe(0);
// EngineClient must NOT have been instantiated — architect is pure heuristic
expect(hoisted.constructorArgs).toHaveLength(0);
});

it('passes apiKey=null to EngineClient when resolveApiKey returns null', async () => {
it('returns SUCCESS with no API key — governance docs need no auth', async () => {
mockedResolveApiKey.mockReturnValue(null);

await architectCommand(options, ['unauthenticated fallback']);
const code = await architectCommand(options, ['GitHub webhook handler']);

expect(hoisted.constructorArgs[0].apiKey).toBeNull();
expect(code).toBe(0);
expect(hoisted.constructorArgs).toHaveLength(0);
expect(hoisted.buildFn).not.toHaveBeenCalled();
});
});

Expand Down
73 changes: 73 additions & 0 deletions src/__tests__/classify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { classifyScaffoldIntention } from '../commands/classify.js';

describe('classifyScaffoldIntention', () => {
it('returns a pattern for a workers-saas intention', () => {
const r = classifyScaffoldIntention('multi-tenant SaaS API with Stripe billing');
expect(r.pattern).toBeTruthy();
expect(typeof r.pattern).toBe('string');
});

it('returns mcp-server pattern for MCP server intentions', () => {
const r = classifyScaffoldIntention('MCP server exposing tool endpoints for LLM agents');
expect(r.pattern).toBe('mcp-server');
});

it('returns a valid pattern for queue intentions', () => {
// charter#221: package may classify queue-consumer as 'api' until pattern vocab is aligned
const r = classifyScaffoldIntention('Queue consumer for background job processing');
expect(typeof r.pattern).toBe('string');
expect(r.pattern.length).toBeGreaterThan(0);
});

it('returns scheduled pattern for cron worker intentions', () => {
const r = classifyScaffoldIntention('Scheduled cron worker for daily digest emails');
expect(r.pattern).toBe('scheduled');
});

it('returns a numeric confidence between 0 and 1', () => {
const r = classifyScaffoldIntention('REST API with D1 database');
expect(typeof r.confidence).toBe('number');
expect(r.confidence).toBeGreaterThan(0);
expect(r.confidence).toBeLessThanOrEqual(1);
});

it('returns lower confidence for a bare single-word intention', () => {
const full = classifyScaffoldIntention('multi-tenant SaaS API with Stripe billing and JWT auth');
const bare = classifyScaffoldIntention('api');
expect(bare.confidence).toBeLessThan(full.confidence);
});

it('traits is a flat string array', () => {
const r = classifyScaffoldIntention('REST API with JWT authentication and D1 database');
expect(Array.isArray(r.traits)).toBe(true);
});

it('detects authentication in quality profile', () => {
const r = classifyScaffoldIntention('REST API with JWT authentication');
expect(r.qualityProfile.authentication).toBe(true);
});

it('detects durable-object pattern for collaborative/realtime intentions', () => {
const r = classifyScaffoldIntention('Realtime collaborative whiteboard with WebSockets and Durable Objects');
expect(r.pattern).toBe('durable-object');
});

it('qualityProfile has expected boolean fields', () => {
const r = classifyScaffoldIntention('REST API with rate limiting and observability');
expect(typeof r.qualityProfile.authentication).toBe('boolean');
expect(typeof r.qualityProfile.rateLimiting).toBe('boolean');
expect(typeof r.qualityProfile.observability).toBe('boolean');
expect(typeof r.qualityProfile.piiHandling).toBe('boolean');
expect(Array.isArray(r.qualityProfile.complianceDomains)).toBe(true);
});

it('produces json-serialisable output', () => {
const r = classifyScaffoldIntention('Workers API with D1 and JWT auth');
const json = JSON.parse(JSON.stringify(r));
expect(typeof json.pattern).toBe('string');
expect(typeof json.confidence).toBe('number');
expect(Array.isArray(json.traits)).toBe(true);
expect(json.qualityProfile).toBeTruthy();
});
});
179 changes: 179 additions & 0 deletions src/__tests__/scaffold-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Tests for scaffold/run/architect cache contract (#6, #7)
*
* #6: run now always writes last-build.json (gateway + engine paths)
* #7: unified cache shape — { intention, pattern, classification, governance, files?, createdAt }
* scaffold.ts reads both the new shape and legacy BuildResult shape.
*/

import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

// The cache path logic must be consistent between run and scaffold.
// Both use: path.join(options.configPath || '.charter', 'last-build.json')
// We verify this contract directly via the file system.

function makeBuildResult(overrides: Record<string, unknown> = {}) {
return {
stack: [
{
id: 1,
name: 'Hono',
category: 'framework',
element: 'fire',
maturity: 'stable',
tier: 'blessed',
cloudflareNative: true,
traits: ['edge'],
keywords: { upright: ['fast'], reversed: ['fragile'] },
orientation: 'upright',
position: 'Present',
},
],
compatibility: {
pairs: [],
totalScore: 0,
normalizedScore: 0,
dominant: 'fire',
tensions: [],
},
scaffold: {
'src/index.ts': 'export default {}',
'wrangler.toml': 'name = "my-worker"',
},
seed: 42,
receipt: 'abc123',
requirements: {
description: 'test intention',
keywords: ['test'],
constraints: {},
complexity: 'simple',
},
...overrides,
};
}

describe('scaffold/run cache contract', () => {
let tmpDir: string;
let cacheDir: string;
let cachePath: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackbilt-test-'));
cacheDir = path.join(tmpDir, '.charter');
cachePath = path.join(cacheDir, 'last-build.json');
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('cache file is absent when nothing has run', () => {
expect(fs.existsSync(cachePath)).toBe(false);
});

it('a BuildResult written to cache can be parsed back by scaffold logic', () => {
const buildResult = makeBuildResult();

// Simulate what run.ts does (cacheBuildResult function):
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(buildResult, null, 2));

// Simulate what scaffold.ts does (read + validate):
expect(fs.existsSync(cachePath)).toBe(true);
const raw = fs.readFileSync(cachePath, 'utf-8');
const parsed = JSON.parse(raw);
expect(parsed.scaffold).toBeDefined();
expect(Object.keys(parsed.scaffold).length).toBeGreaterThan(0);
expect(parsed.seed).toBe(42);
expect(parsed.stack).toHaveLength(1);
});

it('scaffold content round-trips through JSON without loss', () => {
const buildResult = makeBuildResult({
scaffold: {
'src/index.ts': 'export default { fetch(req) { return new Response("ok"); } }',
'wrangler.toml': '[vars]\nNAME = "test"',
},
});

fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(buildResult, null, 2));

const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
expect(parsed.scaffold['src/index.ts']).toContain('export default');
expect(parsed.scaffold['wrangler.toml']).toContain('[vars]');
});

it('both scaffold.ts and run.ts use path.join(configPath, "last-build.json")', () => {
// Verifies the path contract by checking both commands read/write the same key.
// This is a contract test — if either command changes the path, this breaks.
const configPath = cacheDir;
const expectedPath = path.join(configPath, 'last-build.json');

fs.mkdirSync(configPath, { recursive: true });
fs.writeFileSync(expectedPath, JSON.stringify(makeBuildResult(), null, 2));

// The file is readable at the expected path
const data = JSON.parse(fs.readFileSync(expectedPath, 'utf-8'));
expect(data.scaffold).toBeDefined();
});

it('cache dir is created if absent (mkdir -p behaviour)', () => {
// The nested dir does not exist yet
const deepCacheDir = path.join(tmpDir, 'nested', 'deep', '.charter');
const deepCachePath = path.join(deepCacheDir, 'last-build.json');

expect(fs.existsSync(deepCacheDir)).toBe(false);

// Simulate writeCachedBuild mkdir behaviour
fs.mkdirSync(deepCacheDir, { recursive: true });
fs.writeFileSync(deepCachePath, JSON.stringify(makeBuildResult(), null, 2));

expect(fs.existsSync(deepCachePath)).toBe(true);
});

it('new unified cache shape is parseable and has expected fields', () => {
const unified = {
intention: 'multi-tenant SaaS API with Stripe billing',
pattern: 'api',
classification: { pattern: 'api', confidence: 0.9, traits: ['multi-tenant'], qualityProfile: { testingLevel: 'standard', observability: true, authentication: true, rateLimiting: false, piiHandling: false, complianceDomains: [] }, enrichedIntention: 'multi-tenant SaaS API with Stripe billing' },
governance: { threatModel: '# Threat Model', adr001: '# ADR-001', testPlan: '# Test Plan' },
files: [{ path: 'src/index.ts', content: 'export default {}' }],
createdAt: '2026-06-12T00:00:00.000Z',
};

fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(unified, null, 2));

const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
expect(parsed.intention).toBe('multi-tenant SaaS API with Stripe billing');
expect(parsed.pattern).toBe('api');
expect(parsed.classification.pattern).toBe('api');
expect(typeof parsed.classification.confidence).toBe('number');
expect(parsed.files).toHaveLength(1);
expect(parsed.files[0].path).toBe('src/index.ts');
expect(parsed.governance.threatModel).toBeDefined();
});

it('architect cache (no files field) is distinguishable from run cache', () => {
const architectCache = {
intention: 'REST API with JWT auth',
pattern: 'api',
classification: { pattern: 'api', confidence: 0.85, traits: ['auth'], qualityProfile: { testingLevel: 'standard', observability: false, authentication: true, rateLimiting: false, piiHandling: false, complianceDomains: [] }, enrichedIntention: 'REST API with JWT auth' },
governance: { threatModel: '# Threat Model', adr001: '# ADR-001', testPlan: '# Test Plan' },
createdAt: '2026-06-12T00:00:00.000Z',
// no `files` field — this is what architect writes
};

fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, JSON.stringify(architectCache, null, 2));

const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
expect(parsed.files).toBeUndefined();
expect(parsed.intention).toBeDefined();
expect(parsed.governance).toBeDefined();
});
});
Loading