Roo-Code/tests/phase5-scope.test.ts

314 lines
9.8 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest"
import fs from "fs"
import path from "path"
import yaml from "js-yaml"
import { IntentHookEngine } from "../src/core/intent/IntentHookEngine"
import { ScopeValidator } from "../src/core/intent/ScopeValidator"
describe("Phase 5: Scope Enforcement and Out-of-Scope Detection", () => {
let engine: IntentHookEngine
const testDir = ".orchestration"
const intentsPath = ".orchestration/active_intents.yaml"
beforeEach(() => {
// Cleanup first
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true })
}
// Create test intent structure
fs.mkdirSync(testDir, { recursive: true })
const yamlContent = {
active_intents: [
{
id: "INT-001",
name: "Refactor Auth Module",
status: "active",
owned_scope: ["src/auth/", "src/services/auth.ts", "tests/auth/"],
constraints: ["Use JWT instead of Session", "Preserve backward compatibility"],
acceptance_criteria: ["All tests pass", "Token validation works"],
},
{
id: "INT-002",
name: "Update Config System",
status: "active",
owned_scope: ["src/config/", "src/constants/"],
constraints: ["Maintain backwards compat", "Support env vars"],
acceptance_criteria: ["Config validation tests pass"],
},
],
}
fs.writeFileSync(intentsPath, yaml.dump(yamlContent), "utf8")
engine = new IntentHookEngine()
})
afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true })
}
})
describe("ScopeValidator: Path Matching", () => {
it("matches exact file paths", () => {
const scope = ["src/services/auth.ts"]
const result = ScopeValidator.isPathInScope("src/services/auth.ts", scope)
expect(result.isWithinScope).toBe(true)
})
it("matches directory patterns (trailing slash)", () => {
const scope = ["src/auth/"]
const result = ScopeValidator.isPathInScope("src/auth/middleware.ts", scope)
expect(result.isWithinScope).toBe(true)
})
it("matches deeply nested files in directory", () => {
const scope = ["src/auth/"]
const result = ScopeValidator.isPathInScope("src/auth/strategies/jwt/handler.ts", scope)
expect(result.isWithinScope).toBe(true)
})
it("rejects files outside scope", () => {
const scope = ["src/auth/"]
const result = ScopeValidator.isPathInScope("src/models/user.ts", scope)
expect(result.isWithinScope).toBe(false)
expect(result.reason).toContain("outside")
})
it("handles multiple scope entries", () => {
const scope = ["src/auth/", "src/services/"]
const result = ScopeValidator.isPathInScope("src/services/token.ts", scope)
expect(result.isWithinScope).toBe(true)
})
it("validates multiple file paths at once", () => {
const scope = ["src/auth/", "tests/"]
const result = ScopeValidator.arePathsInScope(
["src/auth/middleware.ts", "tests/auth.test.ts"],
scope,
)
expect(result.isWithinScope).toBe(true)
})
it("rejects if any file is out of scope", () => {
const scope = ["src/auth/"]
const result = ScopeValidator.arePathsInScope(
["src/auth/middleware.ts", "src/models/user.ts"],
scope,
)
expect(result.isWithinScope).toBe(false)
expect(result.reason).toContain("outside")
})
it("normalizes Windows-style paths", () => {
const scope = ["src/auth/"]
const result = ScopeValidator.isPathInScope("src\\auth\\middleware.ts", scope)
expect(result.isWithinScope).toBe(true)
})
})
describe("ScopeValidator: Glob Patterns", () => {
it("matches wildcard patterns (single level)", () => {
const scope = ["src/*/middleware.ts"]
const result = ScopeValidator.isPathInScope("src/auth/middleware.ts", scope)
expect(result.isWithinScope).toBe(true)
})
it("matches double-wildcard patterns (recursive)", () => {
const scope = ["src/**/hooks.ts"]
const result = ScopeValidator.isPathInScope("src/auth/strategies/hooks.ts", scope)
expect(result.isWithinScope).toBe(true)
})
it("rejects paths not matching glob", () => {
const scope = ["src/**/hooks.ts"]
const result = ScopeValidator.isPathInScope("src/auth/middleware.ts", scope)
expect(result.isWithinScope).toBe(false)
})
})
describe("ScopeValidator: Diff Parsing", () => {
it("extracts file paths from unified diff", () => {
const diff = `--- a/src/auth/middleware.ts
+++ b/src/auth/middleware.ts
@@ -1,5 +1,6 @@
- export const handler = () => {}
+ export const handler = async () => {}`
const files = ScopeValidator.extractFilesFromDiff(diff)
expect(files).toContain("src/auth/middleware.ts")
})
it("extracts multiple files from diff", () => {
const diff = `--- a/src/auth/middleware.ts
+++ b/src/auth/middleware.ts
--- a/src/services/auth.ts
+++ b/src/services/auth.ts`
const files = ScopeValidator.extractFilesFromDiff(diff)
expect(files).toHaveLength(2)
expect(files).toContain("src/auth/middleware.ts")
expect(files).toContain("src/services/auth.ts")
})
it("handles git-style diff headers", () => {
const diff = `diff --git a/src/file.ts b/src/file.ts
index 123..456 100644
--- a/src/file.ts
+++ b/src/file.ts`
const files = ScopeValidator.extractFilesFromDiff(diff)
expect(files).toContain("src/file.ts")
})
})
describe("IntentHookEngine: Scope Validation", () => {
it("validates file is within current intent scope", () => {
// Select INT-001
engine.preHook("select_active_intent", { intent_id: "INT-001" })
const result = engine.validateScope(["src/auth/middleware.ts"])
expect(result.isWithinScope).toBe(true)
})
it("blocks file outside current intent scope", () => {
// Select INT-001 (owns src/auth/, src/services/auth.ts)
engine.preHook("select_active_intent", { intent_id: "INT-001" })
const result = engine.validateScope(["src/models/user.ts"])
expect(result.isWithinScope).toBe(false)
expect(result.reason).toContain("outside")
expect(result.allowedPaths).toEqual(["src/auth/", "src/services/auth.ts", "tests/auth/"])
})
it("validates single file path", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
expect(engine.isFileInScope("src/auth/middleware.ts")).toBe(true)
expect(engine.isFileInScope("src/models/user.ts")).toBe(false)
})
it("requires active intent for scope validation", () => {
const result = engine.validateScope(["src/auth/middleware.ts"])
expect(result.isWithinScope).toBe(false)
expect(result.reason).toContain("No active intent")
})
it("validates multiple files across scope", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
const result = engine.validateScope(["src/auth/middleware.ts", "tests/auth/middleware.test.ts"])
expect(result.isWithinScope).toBe(true)
})
it("rejects multiple files if any is out of scope", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
const result = engine.validateScope(["src/auth/middleware.ts", "src/db/connect.ts"])
expect(result.isWithinScope).toBe(false)
})
})
describe("Scope Validation with Different Intents", () => {
it("validates against INT-001 scope", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
expect(engine.isFileInScope("src/auth/middleware.ts")).toBe(true)
expect(engine.isFileInScope("src/config/app.ts")).toBe(false)
})
it("validates against INT-002 scope", () => {
engine.preHook("select_active_intent", { intent_id: "INT-002" })
expect(engine.isFileInScope("src/config/app.ts")).toBe(true)
expect(engine.isFileInScope("src/constants/defaults.ts")).toBe(true)
expect(engine.isFileInScope("src/auth/hooks.ts")).toBe(false)
})
})
describe("Out-of-Scope Error Handling", () => {
it("returns detailed error for out-of-scope file", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
const result = engine.validateScope(["src/plugins/external.ts"])
expect(result.isWithinScope).toBe(false)
expect(result.attemptedPath).toBe("src/plugins/external.ts")
expect(result.allowedPaths).toContain("src/auth/")
})
it("returns multiple out-of-scope files in error", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
const result = engine.validateScope(["src/plugins/a.ts", "src/db/b.ts", "src/auth/ok.ts"])
expect(result.isWithinScope).toBe(false)
expect(result.reason).toContain("2 file(s) outside scope")
})
})
describe("Scope Override with Approval", () => {
it("prepares approval request for out-of-scope changes", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
// Verify validation detects out-of-scope
const result = engine.validateScope(["src/security/bypass.ts"])
expect(result.isWithinScope).toBe(false)
// The requestApprovalForOutOfScope would be called here
// In async tests, we'd await it, but for now just verify validation works
})
it("records approval decision for override", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
// Simulate: get pending approvals, then record decision
const pending = engine.getPendingApprovals()
const requestId = Object.keys(pending)[0] || "test-request-123"
engine.recordApprovalDecision(requestId, true, "alice@example.com", "Approved critical fix", true)
const approvals = engine.getIntentApprovals("INT-001")
// Should have recorded the decision
expect(Array.isArray(approvals)).toBe(true)
})
})
describe("Gatekeeper Integration with Scope", () => {
it("blocks write_file without active intent", () => {
const result = engine.gatekeeper("write_file")
expect(result.allowed).toBe(false)
expect(result.message).toContain("Intent ID")
})
it("allows write_file with active intent in scope", () => {
engine.preHook("select_active_intent", { intent_id: "INT-001" })
const result = engine.gatekeeper("write_file")
expect(result.allowed).toBe(true)
})
})
})