Merge cost-benefit/risk-analysis: riskPath, riskDistribution, shouldDecomposeTask, 29 tests
This commit is contained in:
@@ -1 +1,70 @@
|
||||
// shouldDecomposeTask
|
||||
// shouldDecomposeTask
|
||||
//
|
||||
// Pure function — takes node attributes (not a graph) and determines
|
||||
// whether a task should be decomposed based on risk and scope thresholds.
|
||||
//
|
||||
// Decomposition threshold:
|
||||
// - risk >= "high" OR scope >= "broad"
|
||||
//
|
||||
// Unassessed tasks (null/undefined risk or scope) are never flagged —
|
||||
// resolveDefaults fills them with "medium" risk (below threshold) and
|
||||
// "narrow" scope (below threshold).
|
||||
|
||||
import type { TaskGraphNodeAttributes } from "../schema/graph.js";
|
||||
import type { TaskRisk, TaskScope } from "../schema/enums.js";
|
||||
import type { DecomposeResult } from "../schema/results.js";
|
||||
import {
|
||||
resolveDefaults,
|
||||
scopeCostEstimate,
|
||||
riskSuccessProbability,
|
||||
} from "./defaults.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decomposition thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Risk levels at or above this threshold trigger decomposition. */
|
||||
const RISK_DECOMPOSE_THRESHOLD: TaskRisk[] = ["high", "critical"];
|
||||
|
||||
/** Scope levels at or above this threshold trigger decomposition. */
|
||||
const SCOPE_DECOMPOSE_THRESHOLD: TaskScope[] = ["broad", "system"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldDecomposeTask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine whether a task should be decomposed based on its risk and scope.
|
||||
*
|
||||
* Internally calls `resolveDefaults` to handle nullable `risk` and `scope`
|
||||
* fields. Unassessed fields use defaults that are below the decomposition
|
||||
* threshold, so only explicitly-assessed high-risk or broad-scope tasks are
|
||||
* flagged.
|
||||
*
|
||||
* @param attrs - Task node attributes (nullable categorical fields accepted)
|
||||
* @returns DecomposeResult with shouldDecompose flag and specific reasons
|
||||
*/
|
||||
export function shouldDecomposeTask(
|
||||
attrs: Partial<TaskGraphNodeAttributes> & Pick<TaskGraphNodeAttributes, "name">,
|
||||
): DecomposeResult {
|
||||
const resolved = resolveDefaults(attrs);
|
||||
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Check risk threshold
|
||||
if (RISK_DECOMPOSE_THRESHOLD.includes(resolved.risk)) {
|
||||
const failureProb = (1 - riskSuccessProbability(resolved.risk)).toFixed(2);
|
||||
reasons.push(`risk: ${resolved.risk} — failure probability ${failureProb}`);
|
||||
}
|
||||
|
||||
// Check scope threshold
|
||||
if (SCOPE_DECOMPOSE_THRESHOLD.includes(resolved.scope)) {
|
||||
const costEst = scopeCostEstimate(resolved.scope).toFixed(1);
|
||||
reasons.push(`scope: ${resolved.scope} — cost estimate ${costEst}`);
|
||||
}
|
||||
|
||||
return {
|
||||
shouldDecompose: reasons.length > 0,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
@@ -1 +1,96 @@
|
||||
// riskPath, riskDistribution
|
||||
// riskPath, riskDistribution
|
||||
//
|
||||
// Risk analysis functions:
|
||||
// - riskPath: finds the highest-risk path through the graph
|
||||
// - riskDistribution: groups all tasks by their risk category
|
||||
|
||||
import type { TaskGraph } from "../graph/construction.js";
|
||||
import type { TaskRisk } from "../schema/enums.js";
|
||||
import type { RiskPathResult, RiskDistributionResult } from "../schema/results.js";
|
||||
import { weightedCriticalPath } from "./critical-path.js";
|
||||
import { riskWeight, impactWeight } from "./defaults.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// riskPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the highest-risk path through the graph and its total risk score.
|
||||
*
|
||||
* Calls `weightedCriticalPath` with a weight function of
|
||||
* `riskWeight(risk) * impactWeight(impact)`, then sums the weight values
|
||||
* along the resulting path to produce `totalRisk`.
|
||||
*
|
||||
* @param graph - The task graph to analyze
|
||||
* @returns RiskPathResult with the path (ordered task IDs) and totalRisk
|
||||
* @throws {CircularDependencyError} If the graph contains cycles
|
||||
*/
|
||||
export function riskPath(graph: TaskGraph): RiskPathResult {
|
||||
const raw = graph.raw;
|
||||
|
||||
// Define the risk weight function: riskWeight * impactWeight
|
||||
const riskWeightFn = (
|
||||
_taskId: string,
|
||||
attrs: { risk?: TaskRisk; impact?: string },
|
||||
): number => {
|
||||
const risk = attrs.risk ?? "medium";
|
||||
const impact = (attrs.impact ?? "isolated") as Parameters<typeof impactWeight>[0];
|
||||
return riskWeight(risk) * impactWeight(impact);
|
||||
};
|
||||
|
||||
const path = weightedCriticalPath(graph, riskWeightFn);
|
||||
|
||||
// Compute totalRisk as the sum of weight values on the path
|
||||
let totalRisk = 0;
|
||||
for (const taskId of path) {
|
||||
const attrs = raw.getNodeAttributes(taskId);
|
||||
const risk = attrs.risk ?? "medium";
|
||||
const impact = (attrs.impact ?? "isolated") as Parameters<typeof impactWeight>[0];
|
||||
totalRisk += riskWeight(risk) * impactWeight(impact);
|
||||
}
|
||||
|
||||
return { path, totalRisk };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// riskDistribution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RISK_BUCKETS: TaskRisk[] = ["trivial", "low", "medium", "high", "critical"];
|
||||
|
||||
/**
|
||||
* Group all tasks in the graph by their risk category.
|
||||
*
|
||||
* Tasks with `risk: undefined` (not assessed) go into the `unspecified` bucket.
|
||||
* Each task appears in exactly one bucket.
|
||||
*
|
||||
* @param graph - The task graph to analyze
|
||||
* @returns RiskDistributionResult with task ID arrays per risk bucket
|
||||
*/
|
||||
export function riskDistribution(graph: TaskGraph): RiskDistributionResult {
|
||||
const result: RiskDistributionResult = {
|
||||
trivial: [],
|
||||
low: [],
|
||||
medium: [],
|
||||
high: [],
|
||||
critical: [],
|
||||
unspecified: [],
|
||||
};
|
||||
|
||||
const raw = graph.raw;
|
||||
for (const nodeId of raw.nodes()) {
|
||||
const attrs = raw.getNodeAttributes(nodeId);
|
||||
const risk = attrs.risk;
|
||||
|
||||
if (risk === undefined || risk === null) {
|
||||
result.unspecified.push(nodeId);
|
||||
} else if (RISK_BUCKETS.includes(risk as TaskRisk)) {
|
||||
result[risk as TaskRisk].push(nodeId);
|
||||
} else {
|
||||
// Unknown risk value — treat as unspecified
|
||||
result.unspecified.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: cost-benefit/risk-analysis
|
||||
name: Implement riskPath, riskDistribution, and shouldDecomposeTask functions
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- cost-benefit/ev-calculation
|
||||
- analysis/critical-path
|
||||
@@ -18,23 +18,23 @@ Implement the three risk analysis functions: `riskPath`, `riskDistribution`, and
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `riskPath(graph: TaskGraph): RiskPathResult`:
|
||||
- [x] `riskPath(graph: TaskGraph): RiskPathResult`:
|
||||
- Calls `weightedCriticalPath` with weight function `riskWeight * impactWeight`
|
||||
- Returns `{ path: string[], totalRisk: number }`
|
||||
- `totalRisk` is the sum of weight values along the path
|
||||
- [ ] `riskDistribution(graph: TaskGraph): RiskDistributionResult`:
|
||||
- [x] `riskDistribution(graph: TaskGraph): RiskDistributionResult`:
|
||||
- Groups all tasks by their `risk` attribute
|
||||
- Returns `{ trivial: string[], low: string[], medium: string[], high: string[], critical: string[], unspecified: string[] }`
|
||||
- Tasks with `risk: undefined` (not assessed) go in `unspecified`
|
||||
- No duplicate task IDs in any bucket
|
||||
- [ ] `shouldDecomposeTask(attrs: TaskGraphNodeAttributes): DecomposeResult`:
|
||||
- [x] `shouldDecomposeTask(attrs: TaskGraphNodeAttributes): DecomposeResult`:
|
||||
- Pure function — takes node attributes (not a graph)
|
||||
- Internally calls `resolveDefaults` for `risk` and `scope` (nullable fields)
|
||||
- Flags decomposition when: risk >= "high" OR scope >= "broad"
|
||||
- Returns `{ shouldDecompose: boolean, reasons: string[] }`
|
||||
- Unassessed tasks (null/undefined risk or scope) are never flagged — default values are below threshold
|
||||
- Provides specific reasons: e.g., `"risk: high — failure probability 0.35"`, `"scope: broad — cost estimate 4.0"`
|
||||
- [ ] Unit tests for all three functions with known inputs/outputs
|
||||
- [x] Unit tests for all three functions with known inputs/outputs
|
||||
|
||||
## References
|
||||
|
||||
@@ -43,8 +43,12 @@ Implement the three risk analysis functions: `riskPath`, `riskDistribution`, and
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
All three functions implemented following architecture docs. `riskPath` delegates to `weightedCriticalPath` with riskWeight*impactWeight weight function and sums weights for totalRisk. `riskDistribution` iterates all nodes and groups by risk attribute (undefined/null → unspecified). `shouldDecomposeTask` is a pure function using `resolveDefaults` to fill nullable fields before checking thresholds (risk >= high, scope >= broad), with specific reason strings including numeric values.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Implemented riskPath, riskDistribution, and shouldDecomposeTask functions.
|
||||
- Modified: `src/analysis/risk.ts` (riskPath + riskDistribution)
|
||||
- Modified: `src/analysis/decompose.ts` (shouldDecomposeTask)
|
||||
- Created: `test/risk-analysis.test.ts`
|
||||
- Tests: 29, all passing (590 total suite)
|
||||
393
test/risk-analysis.test.ts
Normal file
393
test/risk-analysis.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { riskPath, riskDistribution } from "../src/analysis/risk.js";
|
||||
import { shouldDecomposeTask } from "../src/analysis/decompose.js";
|
||||
import { TaskGraph } from "../src/graph/construction.js";
|
||||
import { riskWeight, impactWeight } from "../src/analysis/defaults.js";
|
||||
import { CircularDependencyError } from "../src/error/index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// riskPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("riskPath", () => {
|
||||
it("returns empty path and zero totalRisk for empty graph", () => {
|
||||
const graph = new TaskGraph();
|
||||
const result = riskPath(graph);
|
||||
|
||||
expect(result.path).toEqual([]);
|
||||
expect(result.totalRisk).toBe(0);
|
||||
});
|
||||
|
||||
it("returns single node for graph with one task", () => {
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "A", name: "Task A", dependsOn: [], risk: "high", impact: "phase" },
|
||||
]);
|
||||
|
||||
const result = riskPath(graph);
|
||||
|
||||
expect(result.path).toEqual(["A"]);
|
||||
// totalRisk = riskWeight("high") * impactWeight("phase") = 0.35 * 2.0 = 0.70
|
||||
expect(result.totalRisk).toBeCloseTo(0.35 * 2.0);
|
||||
});
|
||||
|
||||
it("finds the highest-risk path in a simple chain", () => {
|
||||
// A (medium, isolated) → B (high, phase) → C (critical, project)
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "A", name: "Task A", dependsOn: [], risk: "medium", impact: "isolated" },
|
||||
{ id: "B", name: "Task B", dependsOn: ["A"], risk: "high", impact: "phase" },
|
||||
{ id: "C", name: "Task C", dependsOn: ["B"], risk: "critical", impact: "project" },
|
||||
]);
|
||||
|
||||
const result = riskPath(graph);
|
||||
|
||||
// Only one chain: A → B → C
|
||||
expect(result.path).toEqual(["A", "B", "C"]);
|
||||
|
||||
// totalRisk = sum of riskWeight * impactWeight for each node
|
||||
const wA = riskWeight("medium") * impactWeight("isolated"); // 0.20 * 1.0 = 0.20
|
||||
const wB = riskWeight("high") * impactWeight("phase"); // 0.35 * 2.0 = 0.70
|
||||
const wC = riskWeight("critical") * impactWeight("project"); // 0.50 * 3.0 = 1.50
|
||||
expect(result.totalRisk).toBeCloseTo(wA + wB + wC);
|
||||
});
|
||||
|
||||
it("picks the path with highest cumulative risk in a diamond graph", () => {
|
||||
// A (critical, isolated)
|
||||
// / \
|
||||
// B C
|
||||
// (low, (high, project)
|
||||
// isolated)
|
||||
// \ /
|
||||
// D (medium, component)
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "A", name: "Task A", dependsOn: [], risk: "critical", impact: "isolated" },
|
||||
{ id: "B", name: "Task B", dependsOn: ["A"], risk: "low", impact: "isolated" },
|
||||
{ id: "C", name: "Task C", dependsOn: ["A"], risk: "high", impact: "project" },
|
||||
{ id: "D", name: "Task D", dependsOn: ["B", "C"], risk: "medium", impact: "component" },
|
||||
]);
|
||||
|
||||
const result = riskPath(graph);
|
||||
|
||||
// Path through C should have higher risk:
|
||||
// A → C → D: 0.50*1.0 + 0.35*3.0 + 0.20*1.5 = 0.50 + 1.05 + 0.30 = 1.85
|
||||
// A → B → D: 0.50*1.0 + 0.10*1.0 + 0.20*1.5 = 0.50 + 0.10 + 0.30 = 0.90
|
||||
// The weightedCriticalPath should pick A → C → D
|
||||
expect(result.path).toEqual(["A", "C", "D"]);
|
||||
|
||||
const wA = riskWeight("critical") * impactWeight("isolated"); // 0.50 * 1.0 = 0.50
|
||||
const wC = riskWeight("high") * impactWeight("project"); // 0.35 * 3.0 = 1.05
|
||||
const wD = riskWeight("medium") * impactWeight("component"); // 0.20 * 1.5 = 0.30
|
||||
expect(result.totalRisk).toBeCloseTo(wA + wC + wD);
|
||||
});
|
||||
|
||||
it("uses default risk/impact when not specified on nodes", () => {
|
||||
// Nodes without risk/impact should use defaults: medium risk, isolated impact
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "A", name: "Task A", dependsOn: [] },
|
||||
{ id: "B", name: "Task B", dependsOn: ["A"] },
|
||||
]);
|
||||
|
||||
const result = riskPath(graph);
|
||||
|
||||
expect(result.path).toEqual(["A", "B"]);
|
||||
// Both use defaults: riskWeight("medium") * impactWeight("isolated") = 0.20 * 1.0 = 0.20 each
|
||||
expect(result.totalRisk).toBeCloseTo(0.20 + 0.20);
|
||||
});
|
||||
|
||||
it("throws CircularDependencyError for cyclic graph", () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask("A", { name: "Task A" });
|
||||
tg.addTask("B", { name: "Task B" });
|
||||
tg.addTask("C", { name: "Task C" });
|
||||
tg.addDependency("A", "B");
|
||||
tg.addDependency("B", "C");
|
||||
tg.addDependency("C", "A");
|
||||
|
||||
expect(() => riskPath(tg)).toThrow(CircularDependencyError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// riskDistribution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("riskDistribution", () => {
|
||||
it("returns all empty buckets for empty graph", () => {
|
||||
const graph = new TaskGraph();
|
||||
const result = riskDistribution(graph);
|
||||
|
||||
expect(result).toEqual({
|
||||
trivial: [],
|
||||
low: [],
|
||||
medium: [],
|
||||
high: [],
|
||||
critical: [],
|
||||
unspecified: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("groups tasks by their risk attribute", () => {
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "A", name: "Task A", dependsOn: [], risk: "trivial" },
|
||||
{ id: "B", name: "Task B", dependsOn: [], risk: "low" },
|
||||
{ id: "C", name: "Task C", dependsOn: [], risk: "medium" },
|
||||
{ id: "D", name: "Task D", dependsOn: [], risk: "high" },
|
||||
{ id: "E", name: "Task E", dependsOn: [], risk: "critical" },
|
||||
]);
|
||||
|
||||
const result = riskDistribution(graph);
|
||||
|
||||
expect(result.trivial).toEqual(["A"]);
|
||||
expect(result.low).toEqual(["B"]);
|
||||
expect(result.medium).toEqual(["C"]);
|
||||
expect(result.high).toEqual(["D"]);
|
||||
expect(result.critical).toEqual(["E"]);
|
||||
expect(result.unspecified).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts tasks without risk attribute in unspecified bucket", () => {
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "A", name: "Task A", dependsOn: [], risk: "high" },
|
||||
{ id: "B", name: "Task B", dependsOn: [] }, // no risk
|
||||
{ id: "C", name: "Task C", dependsOn: [], risk: "low" },
|
||||
]);
|
||||
|
||||
const result = riskDistribution(graph);
|
||||
|
||||
expect(result.high).toEqual(["A"]);
|
||||
expect(result.unspecified).toEqual(["B"]);
|
||||
expect(result.low).toEqual(["C"]);
|
||||
});
|
||||
|
||||
it("puts all unspecified tasks in one bucket when no tasks have risk", () => {
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "A", name: "Task A", dependsOn: [] },
|
||||
{ id: "B", name: "Task B", dependsOn: ["A"] },
|
||||
]);
|
||||
|
||||
const result = riskDistribution(graph);
|
||||
|
||||
expect(result.unspecified).toEqual(["A", "B"]);
|
||||
expect(result.trivial).toEqual([]);
|
||||
expect(result.low).toEqual([]);
|
||||
expect(result.medium).toEqual([]);
|
||||
expect(result.high).toEqual([]);
|
||||
expect(result.critical).toEqual([]);
|
||||
});
|
||||
|
||||
it("no duplicate task IDs in any bucket", () => {
|
||||
// Even if a task appears once in the graph, it should only appear once
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "auth", name: "Auth module", dependsOn: [], risk: "high" },
|
||||
{ id: "db", name: "Database setup", dependsOn: [], risk: "medium" },
|
||||
{ id: "api", name: "API layer", dependsOn: ["auth", "db"] }, // no risk
|
||||
{ id: "tests", name: "Test suite", dependsOn: ["api"], risk: "low" },
|
||||
{ id: "deploy", name: "Deploy pipeline", dependsOn: ["tests"], risk: "critical" },
|
||||
]);
|
||||
|
||||
const result = riskDistribution(graph);
|
||||
|
||||
// Each bucket has unique entries
|
||||
const allIds = [
|
||||
...result.trivial,
|
||||
...result.low,
|
||||
...result.medium,
|
||||
...result.high,
|
||||
...result.critical,
|
||||
...result.unspecified,
|
||||
];
|
||||
expect(new Set(allIds).size).toBe(allIds.length); // no duplicates
|
||||
});
|
||||
|
||||
it("handles mixed category fixture graph", () => {
|
||||
// auth (high), db (medium), api (unspecified), tests (low), deploy (critical)
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: "auth", name: "Auth module", dependsOn: [], risk: "high", scope: "broad", impact: "phase", status: "pending" },
|
||||
{ id: "db", name: "Database setup", dependsOn: [], risk: "medium", scope: "moderate", impact: null, status: "completed" },
|
||||
{ id: "api", name: "API layer", dependsOn: ["auth", "db"], risk: null, scope: null, impact: "component", status: null },
|
||||
{ id: "tests", name: "Test suite", dependsOn: ["api"], risk: "low", scope: null, impact: null, status: null },
|
||||
{ id: "deploy", name: "Deploy pipeline", dependsOn: ["tests"], risk: "critical", scope: "system", impact: "project", status: "blocked" },
|
||||
]);
|
||||
|
||||
const result = riskDistribution(graph);
|
||||
|
||||
expect(result.high).toEqual(["auth"]);
|
||||
expect(result.medium).toEqual(["db"]);
|
||||
expect(result.low).toEqual(["tests"]);
|
||||
expect(result.critical).toEqual(["deploy"]);
|
||||
expect(result.unspecified).toEqual(["api"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldDecomposeTask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("shouldDecomposeTask", () => {
|
||||
it("flags high risk tasks for decomposition", () => {
|
||||
const result = shouldDecomposeTask({ name: "Auth module", risk: "high" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain("risk: high");
|
||||
expect(result.reasons[0]).toContain("failure probability");
|
||||
// high risk: p=0.65, failure probability = 1 - 0.65 = 0.35
|
||||
expect(result.reasons[0]).toContain("0.35");
|
||||
});
|
||||
|
||||
it("flags critical risk tasks for decomposition", () => {
|
||||
const result = shouldDecomposeTask({ name: "Deploy pipeline", risk: "critical" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain("risk: critical");
|
||||
// critical risk: p=0.50, failure probability = 0.50
|
||||
expect(result.reasons[0]).toContain("0.50");
|
||||
});
|
||||
|
||||
it("flags broad scope tasks for decomposition", () => {
|
||||
const result = shouldDecomposeTask({ name: "Refactor", scope: "broad" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain("scope: broad");
|
||||
// broad scope: costEstimate = 4.0
|
||||
expect(result.reasons[0]).toContain("4.0");
|
||||
});
|
||||
|
||||
it("flags system scope tasks for decomposition", () => {
|
||||
const result = shouldDecomposeTask({ name: "Migration", scope: "system" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain("scope: system");
|
||||
// system scope: costEstimate = 5.0
|
||||
expect(result.reasons[0]).toContain("5.0");
|
||||
});
|
||||
|
||||
it("flags both high risk AND broad scope with two reasons", () => {
|
||||
const result = shouldDecomposeTask({
|
||||
name: "Auth implementation",
|
||||
risk: "high" as const,
|
||||
scope: "broad" as const,
|
||||
});
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(2);
|
||||
expect(result.reasons[0]).toContain("risk: high");
|
||||
expect(result.reasons[0]).toContain("failure probability 0.35");
|
||||
expect(result.reasons[1]).toContain("scope: broad");
|
||||
expect(result.reasons[1]).toContain("cost estimate 4.0");
|
||||
});
|
||||
|
||||
it("does NOT flag medium risk tasks", () => {
|
||||
const result = shouldDecomposeTask({ name: "Normal task", risk: "medium" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag low risk tasks", () => {
|
||||
const result = shouldDecomposeTask({ name: "Safe task", risk: "low" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag trivial risk tasks", () => {
|
||||
const result = shouldDecomposeTask({ name: "Trivial task", risk: "trivial" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag narrow scope tasks", () => {
|
||||
const result = shouldDecomposeTask({ name: "Narrow task", scope: "narrow" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag moderate scope tasks", () => {
|
||||
const result = shouldDecomposeTask({ name: "Moderate task", scope: "moderate" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag single scope tasks", () => {
|
||||
const result = shouldDecomposeTask({ name: "Single task", scope: "single" as const });
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag unassessed tasks (null/undefined risk and scope)", () => {
|
||||
// Unassessed tasks use defaults: risk=medium, scope=narrow
|
||||
// Both are below threshold, so not flagged
|
||||
const result = shouldDecomposeTask({ name: "Unassessed task" });
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag tasks with only medium risk and narrow scope (defaults)", () => {
|
||||
const result = shouldDecomposeTask({
|
||||
name: "Default-ish task",
|
||||
risk: "medium" as const,
|
||||
scope: "narrow" as const,
|
||||
});
|
||||
|
||||
expect(result.shouldDecompose).toBe(false);
|
||||
expect(result.reasons).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags high risk even when scope is narrow", () => {
|
||||
const result = shouldDecomposeTask({
|
||||
name: "Risky narrow task",
|
||||
risk: "high" as const,
|
||||
scope: "narrow" as const,
|
||||
});
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain("risk: high");
|
||||
});
|
||||
|
||||
it("flags broad scope even when risk is low", () => {
|
||||
const result = shouldDecomposeTask({
|
||||
name: "Low-risk broad task",
|
||||
risk: "low" as const,
|
||||
scope: "broad" as const,
|
||||
});
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0]).toContain("scope: broad");
|
||||
});
|
||||
|
||||
it("reasons include specific numeric values from risk and scope", () => {
|
||||
const result = shouldDecomposeTask({
|
||||
name: "Complex task",
|
||||
risk: "critical" as const,
|
||||
scope: "system" as const,
|
||||
impact: "project" as const,
|
||||
});
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
expect(result.reasons).toHaveLength(2);
|
||||
|
||||
// critical: failure probability = 1 - 0.50 = 0.50
|
||||
expect(result.reasons[0]).toBe("risk: critical — failure probability 0.50");
|
||||
// system: cost estimate = 5.0
|
||||
expect(result.reasons[1]).toBe("scope: system — cost estimate 5.0");
|
||||
});
|
||||
|
||||
it("is a pure function — does not depend on a graph", () => {
|
||||
// shouldDecomposeTask takes attributes only, not a graph
|
||||
const attrs = { name: "Standalone task", risk: "high" as const };
|
||||
const result = shouldDecomposeTask(attrs);
|
||||
|
||||
expect(result.shouldDecompose).toBe(true);
|
||||
// No graph needed — pure function on attributes
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user