mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-17 23:51:08 +00:00
314 lines
9.8 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|