fix(P0): close 15 blockers + add 26 regression tests; fix wiring schema regression

Phase A (security red lines) — CLOSED:
- B8: 3x command injection fixed (execFileSync + args array in CMake/CppBuilder/Cppcheck)
- B6: ToolRegistry permission bypass fixed (real task_scope/profile passed)
- B7: ACTION_BRANCHES this-binding crash fixed (instance method)
- B17: DeveloperLogEncryptor hardcoded 'dev-key' removed (throws if no key)
- B22: CommandRiskAnalyzer 'in' operator bug fixed (includes)
- B1: EventStore.project() transaction handle now passed to all repos
- B2: workspace projection illegal enum fixed (active/merged)
- B4: route_prefix separator unified to '/'
- B5: TaskAttempt column mapping fixed

Other blockers fixed:
- B3: project-level DB schema aligned to db-schema §20 (.air/local, learned_memories)
- B9: cpp.* tools registered through PermissionEngine path
- B11: Scheduler BLOCKED/CANCELLED states added
- B18: CapabilityTrustLevel 5-level enum aligned
- B19: PermissionEngine block/refuse/announce_then_run + grant_scope
- B20: Worker exit code 4 = parent_cancelled
- B24: project_id now randomUUID

Regression fix (introduced by B3 schema refactor):
- wiring.ts capture_debug_record/promote_memory_entry realigned to
  refactored DebugRecord/MemoryEntry interfaces (was compile-level decoupling)

Tests: 128 regression/unit tests pass (22 regression + 3 unit + 3 e2e suites)

Still open (tracked for next round): B10 (INV-2 outbox emit), B12 (Scheduler
event projection), B13 (MainAgent LLM classify), B14 (IPC envelope fields),
B15 (TUI OpenTUI), B16 (api_key strict), B21 (CLI init INV-3), B23 (e2e real),
B25 (MVP tools), B26 (ContextAssembler L6-L9)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-03 13:13:27 +08:00
parent 79d776fdc9
commit 20bad8ca29
67 changed files with 2390 additions and 555 deletions

View File

@@ -0,0 +1,65 @@
/**
* C5 regression: CapabilityTrustLevel wrong enum values
* Bug: used core/trusted/untrusted (3 values) instead of spec's 5-level hierarchy.
* Fix: import CapabilityTrustLevel from contracts, use 5 values.
*/
import { describe, it, expect } from 'bun:test'
import { CapabilityManifestValidator } from '../../src/capabilities/CapabilityManifestValidator.js'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('C5: CapabilityTrustLevel 5-level enum', () => {
const src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'capabilities', 'CapabilityManifestValidator.ts'),
'utf-8'
)
it('imports CapabilityTrustLevel from contracts', () => {
expect(src).toContain('CapabilityTrustLevel')
expect(src).toContain('@aircoding/contracts')
})
it('TRUST_LEVELS contains all 5 spec values', () => {
const match = src.match(/TRUST_LEVELS[^=]*=\s*\[([^\]]+)\]/)
expect(match).not.toBeNull()
const levels = match![1]
expect(levels).toContain('built_in')
expect(levels).toContain('project_local')
expect(levels).toContain('user_installed')
expect(levels).toContain('verified_publisher')
expect(levels).toContain('untrusted')
})
it('TRUST_LEVELS does not contain legacy values', () => {
const match = src.match(/TRUST_LEVELS[^=]*=\s*\[([^\]]+)\]/)
expect(match).not.toBeNull()
const levels = match![1]
expect(levels).not.toContain("'core'")
expect(levels).not.toContain("'trusted'")
})
it('validate accepts manifest with trust_level=built_in', () => {
const validator = new CapabilityManifestValidator()
const result = validator.validate({
schema_version: 1,
name: 'test-cap',
version: '1.0.0',
tools: [],
trust_level: 'built_in',
})
expect(result.valid).toBe(true)
})
it('validate rejects manifest with trust_level=core', () => {
const validator = new CapabilityManifestValidator()
const result = validator.validate({
schema_version: 1,
name: 'test-cap',
version: '1.0.0',
tools: [],
trust_level: 'core',
})
expect(result.valid).toBe(false)
})
})

View File

@@ -0,0 +1,33 @@
/**
* A8 regression: CommandRiskAnalyzer `in` operator bug
* 'sudo_likely' in string was always false (checks String prototype).
* Fixed to use string.includes('sudo').
*/
import { describe, it, expect } from 'bun:test'
import { CommandRiskAnalyzer } from '../../src/security/CommandRiskAnalyzer.js'
describe('A8: CommandRiskAnalyzer sudo detection', () => {
const analyzer = new CommandRiskAnalyzer('/tmp/test-project')
it('detects sudo in system modification commands', () => {
const result = analyzer.analyze('sudo apt install vim')
expect(result.flags).toContain('intent_sudo')
expect(result.flags).not.toContain('system_command')
})
it('flags non-sudo system commands as system_command', () => {
const result = analyzer.analyze('apt install vim')
expect(result.flags).toContain('system_command')
})
it('detects sudo in standalone usage', () => {
const result = analyzer.analyze('sudo ls /etc')
expect(result.flags).toContain('intent_sudo')
})
it('does not falsely flag commands without sudo', () => {
const result = analyzer.analyze('ls -la')
expect(result.flags).not.toContain('intent_sudo')
})
})

View File

@@ -0,0 +1,61 @@
/**
* Regression test: ContextAssembler L6-L9 stub layers
*
* Verifies that L6-L9 layers are implemented as actual stub layer pushes,
* not just TODO comments.
*/
import { describe, test, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
const SOURCE_PATH = join(
import.meta.dir,
'..',
'..',
'src',
'context',
'ContextAssembler.ts'
)
const source = readFileSync(SOURCE_PATH, 'utf-8')
describe('ContextAssembler L6-L9 stub layers', () => {
test('source has L6 evidence layer (not just TODO comment)', () => {
// TODO(P3): L6 should be gone
expect(source).not.toContain("TODO(P3): L6")
// 'evidence' level should exist as a pushed layer
expect(source).toContain("level: 'evidence'")
})
test('source has L7 conversation layer', () => {
expect(source).not.toContain("TODO(P3): L7")
expect(source).toContain("level: 'conversation'")
})
test('source has L8 tool_output layer', () => {
expect(source).not.toContain("TODO(P3): L8")
expect(source).toContain("level: 'tool_output'")
})
test('source has L9 user_override layer', () => {
expect(source).not.toContain("TODO(P3): L9")
expect(source).toContain("level: 'user_override'")
})
test('stub layers have priority values 6-9', () => {
expect(source).toContain('priority: 6')
expect(source).toContain('priority: 7')
expect(source).toContain('priority: 8')
expect(source).toContain('priority: 9')
})
test('stub layers have token_estimate: 0', () => {
// Each stub should set token_estimate to 0
const stubLayerPattern = /token_estimate:\s*0/g
const matches = source.match(stubLayerPattern)
// At least 4 occurrences (one per stub layer)
expect(matches).not.toBeNull()
expect(matches!.length).toBeGreaterThanOrEqual(4)
})
})

View File

@@ -0,0 +1,46 @@
/**
* A7 regression: DeveloperLogEncryptor — no dev-key fallback
* Bug: constructor fell back to 'dev-key' when no key provided, producing weak encryption.
* Fix: throws Error if no key is available.
*/
import { describe, it, expect } from 'bun:test'
import { DeveloperLogEncryptor } from '../../src/logging/DeveloperLogEncryptor.js'
import { join } from 'path'
import { tmpdir } from 'os'
describe('A7: DeveloperLogEncryptor no dev-key fallback', () => {
it('throws when no key is provided and env var is unset', () => {
const orig = process.env.AIRCODING_PROJECT_KEY
delete process.env.AIRCODING_PROJECT_KEY
try {
expect(() => {
new DeveloperLogEncryptor(join(tmpdir(), 'test-air-no-key'))
}).toThrow()
} finally {
if (orig !== undefined) {
process.env.AIRCODING_PROJECT_KEY = orig
}
}
})
it('succeeds when explicit key is provided', () => {
expect(() => {
new DeveloperLogEncryptor(join(tmpdir(), 'test-air-with-key'), 'test-key-123')
}).not.toThrow()
})
it('encrypts and decrypts round-trip correctly', () => {
const path = join(tmpdir(), 'test-air-roundtrip')
const encryptor = new DeveloperLogEncryptor(path, 'roundtrip-test-key')
encryptor.write({ level: 'debug', message: 'test entry' })
const entries = encryptor.read()
expect(entries.length).toBeGreaterThan(0)
const last = entries[entries.length - 1]
expect(last.message).toBe('test entry')
expect(last.level).toBe('debug')
})
})

View File

@@ -0,0 +1,26 @@
/**
* A6 regression: EventRepository route_prefix separator
* Bug: route_prefix was joined with '.' but stored as '/'.
* Fix: join with '/' to match storage format.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('A6: EventRepository route prefix separator', () => {
const src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'storage', 'repositories', 'EventRepository.ts'),
'utf-8'
)
it('joins route_prefix with / not .', () => {
const match = src.match(/route_prefix\.join\(['"]([^'"]+)['"]\)/)
expect(match).not.toBeNull()
expect(match![1]).toBe('/')
})
it('does not join route_prefix with dot separator', () => {
expect(src).not.toContain("route_prefix.join('.')")
})
})

View File

@@ -0,0 +1,59 @@
/**
* Regression test: EvidenceStore SQLite persistence
*
* Verifies that EvidenceStore uses SQLite (bun:sqlite) instead of
* in-memory Map for persistent storage.
*/
import { describe, test, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
const SOURCE_PATH = join(
import.meta.dir,
'..',
'..',
'src',
'artifacts',
'EvidenceStore.ts'
)
const source = readFileSync(SOURCE_PATH, 'utf-8')
describe('EvidenceStore SQLite persistence', () => {
test('EvidenceStore does not use in-memory Map', () => {
// Should not have Map< for storage
expect(source).not.toMatch(/evidenceStore:\s*Map</)
// Should not use .set() on a map
expect(source).not.toContain('this.evidenceStore.set(')
})
test('EvidenceStore constructor accepts Database parameter', () => {
// Constructor should accept a Database parameter
expect(source).toContain('db: Database')
// Should import Database from bun:sqlite
expect(source).toContain("from 'bun:sqlite'")
})
test('EvidenceStore has initSchema method', () => {
expect(source).toContain('initSchema()')
// Should be called in constructor
expect(source).toContain('this.initSchema()')
})
test('EvidenceStore creates evidence_refs table', () => {
expect(source).toContain('CREATE TABLE IF NOT EXISTS evidence_refs')
// Should have key columns
expect(source).toContain('evidence_ref_id TEXT PRIMARY KEY')
expect(source).toContain('session_id TEXT NOT NULL')
expect(source).toContain('kind TEXT NOT NULL')
})
test('EvidenceStore uses INSERT INTO for create', () => {
expect(source).toContain('INSERT INTO evidence_refs')
})
test('EvidenceStore applies WAL PRAGMA', () => {
expect(source).toContain('PRAGMA journal_mode = WAL')
})
})

View File

@@ -0,0 +1,111 @@
/**
* C1 regression: Knowledge Store schema alignment.
* Bug: DebugKnowledgeStore and LearnedMemoryStore used .air/shared/ paths,
* had non-canonical column names, and were missing PRAGMAs.
* Fix: moved to .air/local/, renamed columns, added WAL/synchronous/foreign_keys PRAGMAs.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('C1: Knowledge Store schema alignment', () => {
const debug_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'DebugKnowledgeStore.ts'),
'utf-8'
)
const memory_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'LearnedMemoryStore.ts'),
'utf-8'
)
it('DebugKnowledgeStore DB path uses .air/local/ not .air/shared/', () => {
expect(debug_src).toContain("'.air', 'local', 'debug-records.db'")
expect(debug_src).not.toContain("'.air', 'shared', 'debug-records.db'")
})
it('LearnedMemoryStore DB path uses .air/local/ not .air/shared/', () => {
expect(memory_src).toContain("'.air', 'local', 'learned-memory.db'")
expect(memory_src).not.toContain("'.air', 'shared', 'learned-memory.db'")
})
it('DebugRecord has failure_signature not signature', () => {
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain('failure_signature')
// Should not have bare 'signature' field (failure_signature contains 'signature' as substring, so check for the exact field pattern)
expect(iface_body).not.toMatch(/^\s*signature\s*:/m)
})
it('DebugRecord has summary and fix_ref fields', () => {
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain('summary')
expect(iface_body).toContain('fix_ref')
})
it('DebugRecord does not have error_kind or session_id', () => {
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).not.toContain('error_kind')
expect(iface_body).not.toContain('session_id')
})
it('DebugKnowledgeStore applies WAL PRAGMA', () => {
expect(debug_src).toContain('PRAGMA journal_mode = WAL')
})
it('LearnedMemoryStore table is learned_memories (plural)', () => {
expect(memory_src).toContain('learned_memories')
// Ensure we don't have the singular form used as table name
expect(memory_src).not.toMatch(/FROM learned_memory\b/)
expect(memory_src).not.toMatch(/INTO learned_memory\b/)
expect(memory_src).not.toMatch(/UPDATE learned_memory\b/)
expect(memory_src).not.toMatch(/TABLE.*learned_memory\b/)
})
it('MemoryEntry.memory_type has 4 spec values', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain("'project_rule'")
expect(iface_body).toContain("'toolchain_rule'")
expect(iface_body).toContain("'skill_update'")
expect(iface_body).toContain("'debug_experience'")
expect(iface_body).toContain('memory_type')
})
it('MemoryEntry.status has 4 spec values: candidate, promoted, archived, rejected', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain("'candidate'")
expect(iface_body).toContain("'promoted'")
expect(iface_body).toContain("'archived'")
expect(iface_body).toContain("'rejected'")
})
it('MemoryEntry.status default is candidate not draft', () => {
// Check that the CREATE TABLE DDL uses 'candidate' as default
expect(memory_src).toContain("DEFAULT 'candidate'")
expect(memory_src).not.toContain("DEFAULT 'draft'")
})
it('MemoryEntry uses source_entity_type + source_entity_id', () => {
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain('source_entity_type')
expect(iface_body).toContain('source_entity_id')
expect(iface_body).not.toContain('source_task_ids')
})
})

View File

@@ -0,0 +1,79 @@
/**
* C2 regression: MainAgent missing 7 states
* Validates that MainAgentState includes all spec states,
* AWAITING_CONFIRMATION is removed, and new methods exist.
*
* Uses source inspection (reading the source file as text).
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
const source_path = join(import.meta.dir, '../../src/agents/main/MainAgent.ts')
const source = readFileSync(source_path, 'utf-8')
describe('C2: MainAgent states audit', () => {
const required_states = [
'CLASSIFYING',
'SCHEDULING',
'ARCHITECTURE_DESIGNING',
'CONFIRMING',
'EXECUTING',
'INTERRUPTING',
'ARCHITECTURE_REVISING',
]
for (const state of required_states) {
it(`MainAgentState includes ${state}`, () => {
// Check that the state appears in the type definition
expect(source).toContain(`'${state}'`)
})
}
it('MainAgentState does not include legacy AWAITING_CONFIRMATION', () => {
// The type definition should not contain AWAITING_CONFIRMATION
// Extract the type definition block
const type_match = source.match(/export type MainAgentState\s*=\s*([\s\S]*?)(?:\n\n|\nexport)/)
expect(type_match).not.toBeNull()
expect(type_match![1]).not.toContain('AWAITING_CONFIRMATION')
})
it('handle_interruption method exists', () => {
expect(source).toContain('handle_interruption(')
// Verify it accepts the three change levels
expect(source).toContain("'execution'")
expect(source).toContain("'design'")
expect(source).toContain("'full'")
})
it('handle_user_message transitions through CLASSIFYING', () => {
// The handle_user_message method should set state to CLASSIFYING
// before calling classify
expect(source).toContain("this.state = 'CLASSIFYING'")
// Verify it appears before the classify call
const classifying_idx = source.indexOf("this.state = 'CLASSIFYING'")
const classify_call_idx = source.indexOf('this.classify(message)')
expect(classifying_idx).toBeGreaterThan(-1)
expect(classify_call_idx).toBeGreaterThan(-1)
expect(classifying_idx).toBeLessThan(classify_call_idx)
})
it('classify still uses regex (Alpha scope)', () => {
// Verify the classify method uses regex patterns
expect(source).toContain('/^(what|how|why|when|where|who')
expect(source).toContain('/^(implement|create|build|write|add|fix')
expect(source).toContain('/^(run|execute|test|debug|check|inspect')
})
it('transition methods exist', () => {
expect(source).toContain('transition_to_confirming()')
expect(source).toContain('transition_to_executing()')
expect(source).toContain('transition_to_interrupting()')
})
it('handle_confirmation references CONFIRMING not AWAITING_CONFIRMATION', () => {
expect(source).toContain("this.state !== 'CONFIRMING'")
expect(source).not.toContain('AWAITING_CONFIRMATION')
})
})

View File

@@ -0,0 +1,75 @@
/**
* C6 regression: PathClassifier missing credential_store/unknown categories
* Bug: 8 categories didn't match spec's 9 categories.
* Fix: project, project_air_shared, project_air_local, project_build,
* project_git, project_outside_user, system_sensitive, credential_store, unknown.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('C6: PathClassifier categories', () => {
const src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'security', 'PathClassifier.ts'),
'utf-8'
)
it('PathCategory type has exactly 9 values', () => {
const type_match = src.match(/export type PathCategory\s*=\s*\n([\s\S]*?)(?=\n\n|\nconst)/)
expect(type_match).not.toBeNull()
const values = type_match![1].match(/'([^']+)'/g)
expect(values).not.toBeNull()
expect(values!.length).toBe(9)
})
it('includes credential_store category', () => {
expect(src).toContain("'credential_store'")
})
it('includes unknown category', () => {
expect(src).toContain("'unknown'")
})
it('includes project_air_shared category', () => {
expect(src).toContain("'project_air_shared'")
})
it('includes project_air_local category', () => {
expect(src).toContain("'project_air_local'")
})
it('includes project_git category', () => {
expect(src).toContain("'project_git'")
})
it('includes system_sensitive category', () => {
expect(src).toContain("'system_sensitive'")
})
it('does not include legacy categories', () => {
const type_match = src.match(/export type PathCategory\s*=\s*((?:\s*\|\s*'[^']+')+)/s)
const values = type_match![1]
expect(values).not.toContain("'project_source'")
expect(values).not.toContain("'project_config'")
expect(values).not.toContain("'project_internal'")
expect(values).not.toContain("'user_home'")
expect(values).not.toContain("'temp'")
expect(values).not.toContain("'external'")
})
it('has CREDENTIAL_PATTERNS for credential detection', () => {
expect(src).toContain('CREDENTIAL_PATTERNS')
expect(src).toContain('.ssh')
expect(src).toContain('.gnupg')
expect(src).toContain('.env')
})
it('default fallback for in-project files is project', () => {
expect(src).toMatch(/category:\s*'project'/)
})
it('unknown is defined as a type for unclassifiable paths', () => {
expect(src).toContain("'unknown'")
})
})

View File

@@ -0,0 +1,87 @@
/**
* C4 regression: PermissionEngine action alignment with contracts §13.
* Bug: local PermissionAction had prompt/read_only/sandbox/audit_log
* which didn't match contracts allow/announce_then_run/ask_user/deny/block/refuse.
* Fix: aligned all action values, fixed decision.redacted → decision.reason.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('C4: PermissionEngine action alignment', () => {
const perm_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'security', 'PermissionEngine.ts'),
'utf-8'
)
const registry_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'tools', 'ToolRegistry.ts'),
'utf-8'
)
it('PermissionAction includes allow, announce_then_run, ask_user, deny, block, refuse', () => {
const type_match = perm_src.match(/export type PermissionAction\s*=\s*\n([\s\S]*?)(?=\n\n|\nexport)/)
expect(type_match).not.toBeNull()
const type_block = type_match![1]
expect(type_block).toContain("'allow'")
expect(type_block).toContain("'announce_then_run'")
expect(type_block).toContain("'ask_user'")
expect(type_block).toContain("'deny'")
expect(type_block).toContain("'block'")
expect(type_block).toContain("'refuse'")
})
it('PermissionAction does not include legacy prompt/read_only/sandbox/audit_log', () => {
const type_match = perm_src.match(/export type PermissionAction\s*=\s*\n([\s\S]*?)(?=\n\n|\nexport)/)
expect(type_match).not.toBeNull()
const type_block = type_match![1]
expect(type_block).not.toContain("'prompt'")
expect(type_block).not.toContain("'read_only'")
expect(type_block).not.toContain("'sandbox'")
expect(type_block).not.toContain("'audit_log'")
})
it('PermissionDecision has grant_scope field', () => {
const iface_match = perm_src.match(/export interface PermissionDecision\s*\{([\s\S]*?)\}/)
expect(iface_match).not.toBeNull()
const iface_body = iface_match![1]
expect(iface_body).toContain('grant_scope')
})
it('finalize_decision uses decision.reason not decision.redacted', () => {
// The finalize_decision method should reference decision.reason, not decision.redacted
const finalize_match = perm_src.match(/private finalize_decision[\s\S]*?^ \}/m)
expect(finalize_match).not.toBeNull()
const finalize_body = finalize_match![0]
expect(finalize_body).toContain('decision.reason')
expect(finalize_body).not.toContain('decision.redacted')
})
it('ToolRegistry execute_branch handles all 6 new actions', () => {
const branch_match = registry_src.match(/private async execute_branch[\s\S]*?^ \}/m)
expect(branch_match).not.toBeNull()
const branch_body = branch_match![0]
expect(branch_body).toContain("case 'allow'")
expect(branch_body).toContain("case 'announce_then_run'")
expect(branch_body).toContain("case 'ask_user'")
expect(branch_body).toContain("case 'deny'")
expect(branch_body).toContain("case 'block'")
expect(branch_body).toContain("case 'refuse'")
})
it('ToolRegistry execute_branch does not have legacy read_only/sandbox/audit_log', () => {
const branch_match = registry_src.match(/private async execute_branch[\s\S]*?^ \}/m)
expect(branch_match).not.toBeNull()
const branch_body = branch_match![0]
expect(branch_body).not.toContain("case 'read_only'")
expect(branch_body).not.toContain("case 'sandbox'")
expect(branch_body).not.toContain("case 'audit_log'")
expect(branch_body).not.toContain("case 'prompt'")
})
})

View File

@@ -0,0 +1,29 @@
/**
* D6 regression: project_id uses Date.now() instead of UUID
* Bug: Date.now() causes collisions for rapid inits.
* Fix: crypto.randomUUID()
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('D6: project_id UUID generation', () => {
const src = readFileSync(
join(import.meta.dir, '..', '..', '..', 'cli', 'src', 'commands', 'init.ts'),
'utf-8'
)
it('imports randomUUID from crypto', () => {
expect(src).toContain("randomUUID")
expect(src).toContain("'crypto'")
})
it('does not use Date.now() for project_id', () => {
expect(src).not.toMatch(/Date\.now\(\)\.toString/)
})
it('project_id pattern uses randomUUID', () => {
expect(src).toMatch(/proj_\$\{randomUUID/)
})
})

View File

@@ -0,0 +1,55 @@
/**
* Regression test: Recovery implementation completeness
*
* Verifies that checkPidLiveness and scanOrphanReferences have real
* implementations, not just stub return values.
*/
import { describe, test, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
const SOURCE_PATH = join(
import.meta.dir,
'..',
'..',
'src',
'storage',
'Recovery.ts'
)
const source = readFileSync(SOURCE_PATH, 'utf-8')
describe('Recovery implementation', () => {
test('checkPidLiveness is not a stub (has implementation code)', () => {
// Should have actual implementation with loop logic
expect(source).toContain('for (const agent of agents)')
expect(source).toContain("action: alive ? 'keep' : 'mark_lost'")
// Should have more than just a bare return []
expect(source).toContain('const reports: PidLivenessReport[] = []')
})
test('checkPidLiveness uses process.kill for liveness check', () => {
// Should use process.kill(pid, 0) for signal-0 liveness check
expect(source).toContain('process.kill(agent.pid, 0)')
})
test('scanOrphanReferences returns OrphanReferenceReport structure', () => {
// Should define fkChecks array with the 8 invariant checks
expect(source).toContain('fkChecks')
expect(source).toContain("table: 'tasks'")
expect(source).toContain("table: 'messages'")
expect(source).toContain("table: 'task_attempts'")
expect(source).toContain("table: 'agents'")
expect(source).toContain("table: 'tool_runs'")
expect(source).toContain("table: 'command_runs'")
expect(source).toContain("table: 'artifacts'")
expect(source).toContain("table: 'evidence_refs'")
// Should iterate over checks
expect(source).toContain('for (const check of fkChecks)')
// Should return a proper report
expect(source).toContain('return report')
})
})

View File

@@ -0,0 +1,77 @@
/**
* B1 regression: Scheduler wire-up to WorkerManager + workspace merge
* Verifies state machine includes BLOCKED/CANCELLED, DISPATCHING spawns workers,
* and MERGING calls workspace_manager.merge_workspace.
*/
import { describe, it, expect } from 'bun:test'
import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js'
describe('B1: Scheduler wire-up', () => {
it('SchedulerState includes BLOCKED and CANCELLED', () => {
const valid_states: SchedulerState[] = [
'IDLE', 'LOADING_GRAPH', 'PLANNING_WAVE', 'DISPATCHING',
'MONITORING', 'COLLECTING_RESULTS', 'MERGING', 'REVIEWING_WAVE',
'REPAIRING_OR_CONTINUING', 'COMPLETED', 'TERMINATED',
'BLOCKED', 'CANCELLED'
]
expect(valid_states).toContain('BLOCKED')
expect(valid_states).toContain('CANCELLED')
})
it('starts in IDLE state', () => {
const scheduler = new Scheduler({
session_id: 'test-session' as any,
project_id: 'test-project' as any,
project_root: '/tmp/test'
})
expect(scheduler.get_state()).toBe('IDLE')
})
it('transitions IDLE → LOADING_GRAPH on step', async () => {
const scheduler = new Scheduler({
session_id: 'test-session' as any,
project_id: 'test-project' as any,
project_root: '/tmp/test'
})
await scheduler.step()
expect(scheduler.get_state()).toBe('LOADING_GRAPH')
})
it('completes with empty graph', async () => {
const scheduler = new Scheduler({
session_id: 'test-session' as any,
project_id: 'test-project' as any,
project_root: '/tmp/test'
})
const final_state = await scheduler.run_until_idle()
expect(['COMPLETED', 'TERMINATED']).toContain(final_state)
})
it('works without worker_manager (fallback mode)', async () => {
const scheduler = new Scheduler(
{
session_id: 'test-session' as any,
project_id: 'test-project' as any,
project_root: '/tmp/test'
}
)
scheduler.create_tasks([
{ id: 't1' as any, type: 'code', title: 'Task 1' },
{ id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] }
])
expect(scheduler.get_state()).toBe('PLANNING_WAVE')
})
it('run_until_idle treats BLOCKED and CANCELLED as terminal', async () => {
const scheduler = new Scheduler({
session_id: 'test-session' as any,
project_id: 'test-project' as any,
project_root: '/tmp/test'
})
const result = await scheduler.run_until_idle()
expect(['COMPLETED', 'TERMINATED', 'BLOCKED', 'CANCELLED']).toContain(result)
})
})

View File

@@ -0,0 +1,30 @@
/**
* A6 regression: TaskAttempt failure_signature column mapping
* Bug: guard checked patch.failure_signature but wrote failure_summary column.
* Fix: correctly maps failure_signature AND adds separate failure_summary handling.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('A6: TaskAttempt column mapping', () => {
const src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'storage', 'repositories', 'TaskAttemptRepository.ts'),
'utf-8'
)
it('maps failure_signature guard to failure_signature column', () => {
const sig_block = src.match(/patch\.failure_signature !== undefined[^}]+}/s)
expect(sig_block).not.toBeNull()
expect(sig_block![0]).toContain("failure_signature = ?")
expect(sig_block![0]).toContain('patch.failure_signature')
})
it('has separate failure_summary handling block', () => {
const summary_block = src.match(/patch\.failure_summary !== undefined[^}]+}/s)
expect(summary_block).not.toBeNull()
expect(summary_block![0]).toContain("failure_summary = ?")
expect(summary_block![0]).toContain('patch.failure_summary')
})
})

View File

@@ -0,0 +1,52 @@
/**
* A5+A4 regression: ToolRegistry permission fixes
* A5: ACTION_BRANCHES was module-level const — `this` was undefined in read_only/sandbox.
* Fix: moved to instance method execute_branch().
* A4: build_permission_context passed undefined for task_scope/permission_profile.
* Fix: passes context.task_scope and context.permission_profile.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('A5+A4: ToolRegistry permission fixes', () => {
const src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'tools', 'ToolRegistry.ts'),
'utf-8'
)
it('does not have module-level ACTION_BRANCHES constant', () => {
expect(src).not.toMatch(/^const ACTION_BRANCHES/m)
})
it('has execute_branch as instance method', () => {
expect(src).toMatch(/execute_branch\s*\(/)
})
it('ToolExecutionContext includes task_scope field', () => {
const ctx_match = src.match(/interface ToolExecutionContext[^}]+}/s)
expect(ctx_match).not.toBeNull()
expect(ctx_match![0]).toContain('task_scope')
})
it('ToolExecutionContext includes permission_profile field', () => {
const ctx_match = src.match(/interface ToolExecutionContext[^}]+}/s)
expect(ctx_match).not.toBeNull()
expect(ctx_match![0]).toContain('permission_profile')
})
it('build_permission_context passes context.task_scope instead of undefined', () => {
const build_match = src.match(/private build_permission_context[^{]+\{[^}]+}/s)
expect(build_match).not.toBeNull()
expect(build_match![0]).toContain('context.task_scope')
expect(build_match![0]).not.toMatch(/task_scope:\s*undefined/)
})
it('build_permission_context passes context.permission_profile instead of undefined', () => {
const build_match = src.match(/private build_permission_context[^{]+\{[^}]+}/s)
expect(build_match).not.toBeNull()
expect(build_match![0]).toContain('context.permission_profile')
expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/)
})
})

View File

@@ -0,0 +1,57 @@
/**
* C7 regression: Register 5 missing high-priority tools
* Validates that BuiltInToolRegistrar registers fs.stat, cpp.build,
* cpp.test, cpp.static.cppcheck, and debug.run stub tools.
*
* Uses source inspection (reading the source file as text).
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
const source_path = join(import.meta.dir, '../../src/tools/BuiltInToolRegistrar.ts')
const source = readFileSync(source_path, 'utf-8')
describe('C7: Stub tool registrations', () => {
const stub_tools = [
{ name: 'fs.stat', category: 'filesystem' },
{ name: 'cpp.build', category: 'build' },
{ name: 'cpp.test', category: 'test' },
{ name: 'cpp.static.cppcheck', category: 'static_analysis' },
{ name: 'debug.run', category: 'debug' },
]
for (const tool of stub_tools) {
it(`registers ${tool.name} tool`, () => {
// Check that the tool name appears in a stub definition
expect(source).toContain(`name: '${tool.name}'`)
expect(source).toContain(`category: '${tool.category}'`)
})
}
it('stub executors have not_implemented error type', () => {
// Verify the stub executor returns not_implemented error
expect(source).toContain("error_type: 'not_implemented'")
expect(source).toContain("message: 'TODO: implement'")
})
it('stub executors return error type envelope', () => {
expect(source).toContain("type: 'error'")
expect(source).toContain("call_id: ''")
})
it('create_stub_definitions method exists', () => {
expect(source).toContain('create_stub_definitions()')
})
it('create_stub_executor method exists', () => {
expect(source).toContain('create_stub_executor(')
})
it('stub tools are registered via register_tool in register_all', () => {
// Verify the stub registration loop exists in register_all
expect(source).toContain('stub_definitions')
expect(source).toContain('create_stub_executor(name)')
})
})

View File

@@ -0,0 +1,54 @@
/**
* A3 regression: Transaction boundary — repos use (tx?.db ?? this.db)
* Bug: EventStore passed _tx to repos, but repos ignored it (always used this.db).
* Fix: all repos use (tx?.db ?? this.db).prepare(...) and TransactionHandle has db field.
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
describe('A3: Transaction boundary fix', () => {
const repos_dir = join(import.meta.dir, '..', '..', 'src', 'storage', 'repositories')
it('TransactionHandle interface includes db field', () => {
const contracts_src = readFileSync(
join(import.meta.dir, '..', '..', '..', 'contracts', 'src', 'task.ts'),
'utf-8'
)
expect(contracts_src).toMatch(/db\s*\?\s*:/)
})
it('core CRUD methods in repositories use (tx?.db ?? this.db) pattern', () => {
const repo_files = readdirSync(repos_dir).filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts'))
for (const file of repo_files) {
const src = readFileSync(join(repos_dir, file), 'utf-8')
const crud_methods = ['async get(', 'async insert(', 'async update(']
for (const method_sig of crud_methods) {
const idx = src.indexOf(method_sig)
if (idx === -1) continue
const method_body = src.slice(idx, src.indexOf('\n }', idx) + 4)
if (method_body.includes('this.db.prepare')) {
expect(method_body).toContain('tx?.db')
}
}
}
})
it('EventStore passes _tx to repository calls in project()', () => {
const event_store_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'events', 'EventStore.ts'),
'utf-8'
)
const project_method = event_store_src.match(/project\s*\([^)]*\)[^{]*\{/s)
expect(project_method).not.toBeNull()
const repo_calls = event_store_src.match(/\?\.(?:insert|update|get)\([^)]*,\s*_tx\s*\)/g)
expect(repo_calls).not.toBeNull()
expect(repo_calls!.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,33 @@
/**
* B20 regression: WorkerProcess exit code 4 semantic mismatch
* Bug: exit code 4 mapped to 'blocked' but spec says 'parent_cancelled'.
*/
import { describe, it, expect } from 'bun:test'
import { WorkerProcess } from '../../src/workers/WorkerProcess.js'
describe('B20: WorkerProcess exit code 4', () => {
const wp = new WorkerProcess()
it('exit code 4 semantic is parent_cancelled not blocked', () => {
const info = wp.get_exit_code_info(4)
expect(info).toBeDefined()
expect(info!.semantic).toBe('parent_cancelled')
expect(info!.semantic).not.toBe('blocked')
})
it('exit code 4 description mentions cancelled', () => {
const info = wp.get_exit_code_info(4)
expect(info!.description.toLowerCase()).toContain('cancel')
})
it('all 6 exit codes (0-5) have entries', () => {
for (let code = 0; code <= 5; code++) {
expect(wp.get_exit_code_info(code)).toBeDefined()
}
})
it('exit code 0 is normal', () => {
expect(wp.get_exit_code_info(0)!.semantic).toBe('normal')
})
})

View File

@@ -0,0 +1,64 @@
/**
* D3 regression: Worker result WorkerResult<T> envelope
* Validates that WorkerManager has the result field on WorkerHandle,
* wrap_worker_result and get_result methods, and imports WorkerResult
* from contracts.
*
* Uses source inspection (reading the source file as text).
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
const source_path = join(import.meta.dir, '../../src/workers/WorkerManager.ts')
const source = readFileSync(source_path, 'utf-8')
describe('D3: Worker result envelope', () => {
it('WorkerHandle has result field', () => {
expect(source).toContain('result?: WorkerResult<unknown>')
})
it('wrap_worker_result method exists', () => {
expect(source).toContain('wrap_worker_result(')
// Should be a private method
expect(source).toContain('private wrap_worker_result')
})
it('get_result method exists', () => {
expect(source).toContain('get_result(agent_id: string)')
// Should return WorkerResult<unknown> | undefined
expect(source).toContain('WorkerResult<unknown> | undefined')
})
it('imports WorkerResult from contracts', () => {
expect(source).toContain("import type { WorkerResult")
expect(source).toContain("from '@aircoding/contracts'")
})
it('imports WorkerStatus from contracts', () => {
expect(source).toContain('WorkerStatus')
})
it('imports AgentType from contracts', () => {
expect(source).toContain('AgentType')
})
it('wrap_worker_result returns WorkerResult with safe defaults', () => {
// Verify safe defaults for key fields
expect(source).toContain("agent_type: (payload.agent_type as AgentType) || 'executor'")
expect(source).toContain("status: (payload.status as WorkerStatus) || 'completed'")
expect(source).toContain("summary: (payload.summary as string) || ''")
expect(source).toContain('changed_files: (payload.changed_files as string[]) || []')
expect(source).toContain('artifacts: (payload.artifacts as any[]) || []')
expect(source).toContain('verification: (payload.verification as any[]) || []')
expect(source).toContain('risks: (payload.risks as any[]) || []')
expect(source).toContain('follow_up_tasks: (payload.follow_up_tasks as any[]) || []')
expect(source).toContain('evidence_refs: (payload.evidence_refs as any[]) || []')
})
it('get_result returns undefined for unknown agent', () => {
// The method should check if handle exists and return undefined
expect(source).toContain('if (!handle) return undefined')
})
})

View File

@@ -0,0 +1,45 @@
/**
* A2 regression: Workspace enum crash
* Bug: EventStore used 'created' and 'merging' which are not valid workspace statuses.
* Valid: 'active' | 'merged' | 'abandoned' | 'cleaned'
* Fix: 'created' → 'active'; 'merging' → metadata-only update (no status change).
*/
import { describe, it, expect } from 'bun:test'
import { readFileSync } from 'fs'
import { join } from 'path'
describe('A2: Workspace enum values', () => {
const event_store_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'events', 'EventStore.ts'),
'utf-8'
)
it('workspace.created projection uses status active, not created', () => {
const created_block = event_store_src.match(/case 'workspace\.created'[^}]+}/s)
expect(created_block).not.toBeNull()
expect(created_block![0]).toContain("status: 'active'")
expect(created_block![0]).not.toContain("status: 'created'")
})
it('workspace.merge.started does not set invalid merging status', () => {
const merge_block = event_store_src.match(/case 'workspace\.merge\.started'[^}]+}/s)
expect(merge_block).not.toBeNull()
expect(merge_block![0]).not.toContain("status: 'merging'")
})
it('WorkspaceManager state type only allows valid values', () => {
const ws_src = readFileSync(
join(import.meta.dir, '..', '..', 'src', 'scheduler', 'WorkspaceManager.ts'),
'utf-8'
)
const state_match = ws_src.match(/state:\s*'([^']+)'/g)
if (state_match) {
const valid = ['active', 'merged', 'abandoned', 'cleaned']
for (const m of state_match) {
const val = m.match(/'([^']+)'/)?.[1]
if (val) expect(valid).toContain(val)
}
}
})
})