
Self Repair Pipeline
- 1 installs
- 20 repo stars
- Updated August 2, 2026
- crjfisher/ariadne
Run a pipeline that detects entry points in a codebase, triages false positives via sub-agents, plans fixes with competing proposals, and creates backlog tasks.
About
Runs the full entry-point self-repair pipeline: detects entry points in Ariadne packages or external codebases, triages false positives with sub-agents, plans fixes with competing proposals and multi-angle review, and files backlog tasks. A developer uses it to analyze a repo's entry points and auto-plan fixes for the issues found.
- Sub-agent triage of false positives across issue groups
- Competing fix proposals with multi-angle review, then backlog tasks
Self Repair Pipeline by the numbers
- 1 all-time installs (skills.sh)
- Ranked #984 of 1,352 Code Review & Quality skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/crjfisher/ariadne --skill self-repair-pipelineAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 20 |
| Last updated | August 2, 2026 |
| Repository | crjfisher/ariadne ↗ |
What it does
Run a pipeline that detects entry points in a codebase, triages false positives via sub-agents, plans fixes with competing proposals, and creates backlog tasks.
Files
Self-Repair Pipeline
Triage pipeline for entry point analysis: detect false positives, classify root causes, plan fixes, and create backlog tasks. Supports both self-analysis (Ariadne packages) and external codebase analysis.
Pipeline Overview
| Phase | Script / Agent | Purpose |
|---|---|---|
| 1. Detect | scripts/detect_entrypoints.ts | Run entry point detection |
| 2. Prepare | scripts/prepare_triage.ts | Classify against known-entrypoints registry, build triage state |
| 3. Triage Loop | triage-investigator, triage-aggregator, triage-rule-reviewer | Investigate pending entries, aggregate results, review for patterns |
| 4. Fix Planning | fix-planner, plan-synthesizer, plan-reviewer, task-writer | Generate competing fix plans, synthesize, review, create tasks |
| 5. Finalize | scripts/finalize_triage.ts | Save results, update registry |
Analysis Target
User input: $ARGUMENTS
Resolve the analysis target from the user's input using this routing table:
| Input pattern | Example | Action |
|---|---|---|
| Empty or blank | /self-repair-pipeline | List available configs below, ask user what to analyze |
| Config name | core, mcp, types, projections | Use --config .claude/skills/self-repair-pipeline/project_configs/{name}.json |
| Absolute or relative directory path | /Users/chuck/workspace/some-repo, ../other-repo | Use --path <path> |
owner/repo or GitHub URL | anthropics/sdk-python, https://github.com/owner/repo | Use --github <value> |
| Natural language | "analyze the core package" | Interpret intent and map to one of the above |
Available project configs:
| Config name | Config path |
|---|---|
core | project_configs/core.json |
mcp | project_configs/mcp.json |
types | project_configs/types.json |
projections | project_configs/projections.json |
If no arguments are provided or the input is ambiguous, ask the user before proceeding.
Current State
!cat .claude/skills/self-repair-pipeline/triage_state/*_triage.json 2>/dev/null || echo "No active triage"
State and Output Locations
| File | Purpose |
|---|---|
triage_state/{project}_triage.json | Active triage state (phases, entries, results) |
triage_state/results/{entry_index}.json | Per-entry triage result files (written by sub-agents) |
triage_state/fix_plans/{group_id}/ | Fix plans, synthesis, and reviews per group |
analysis_output/{project}/ | Project-scoped timestamped analysis and triage result files |
known_entrypoints/{project}.json | Known-entrypoints registry (persists across runs) |
triage_patterns.json | Extracted classification patterns from meta-review |
All paths above are relative to .claude/skills/self-repair-pipeline/.
Phase 1: Detect
Use the target resolved from the Analysis Target section above to construct the detect command.
# From project config (preferred for Ariadne packages)
pnpm exec tsx .claude/skills/self-repair-pipeline/scripts/detect_entrypoints.ts \
--config .claude/skills/self-repair-pipeline/project_configs/core.json
# Local repository
pnpm exec tsx .claude/skills/self-repair-pipeline/scripts/detect_entrypoints.ts --path /path/to/repo
# GitHub repository
pnpm exec tsx .claude/skills/self-repair-pipeline/scripts/detect_entrypoints.ts --github owner/repoOptions: --config <file>, --path <dir>, --github <repo>, --branch <name>, --depth <n>, --output <file>, --include-tests, --folders <paths>, --exclude <patterns>
Tracked project configs for Ariadne packages: project_configs/{core,mcp,types}.json
Output: analysis_output/<project>/detect_entrypoints/<timestamp>.json
Phase 2: Prepare
Build triage state from the latest analysis output:
pnpm exec tsx .claude/skills/self-repair-pipeline/scripts/prepare_triage.ts \
--analysis .claude/skills/self-repair-pipeline/analysis_output/<project>/detect_entrypoints/<timestamp>.json \
--package <name> \
--batch-size 5Options: --analysis <path> (required), --package <name>, --state <path>, --batch-size <n> (default 5)
The script loads the known-entrypoints registry and classifies entries:
- known-tp: Matches registry — marked completed immediately
- llm-triage: No registry match — marked pending for investigation
Output: triage_state/{project}_triage.json
Phase 3: Triage Loop
The Stop hook (triage_loop_stop.ts) drives this phase as a state machine. Each time Claude tries to stop, the hook reads the state file, determines what to do next, and either BLOCKs with instructions or ALLOWs completion.
3a. Investigate Pending Entries
For each pending entry in the state file:
1. Read the entry's diagnosis field to select the prompt template:
| Diagnosis | Template | Focus |
|---|---|---|
callers-not-in-registry | templates/prompt_callers_not_in_registry.md | File coverage gap investigation |
callers-in-registry-unresolved | templates/prompt_resolution_failure.md | Resolution failure pattern identification |
callers-in-registry-wrong-target | templates/prompt_wrong_target.md | Wrong resolution target analysis |
| All other diagnoses | templates/prompt_generic.md | Broad investigation |
2. Read the template and substitute {{entry.*}} placeholders with values from the entry 3. Launch a triage-investigator sub-agent with run_in_background: true. Include output_path = triage_state/results/{entry.entry_index}.json in the prompt 4. Do not read or process the sub-agent's response. The stop hook merges result files automatically
Process entries in batches of batch_size (from state file). The stop hook re-triggers after each batch.
3b. Aggregation
When all entries are completed, the stop hook transitions to aggregation phase.
Launch a triage-aggregator sub-agent with the state file path. The aggregator:
- Reviews all completed entry results
- Groups entries by shared root cause
- Merges duplicate/overlapping group IDs
- Writes
aggregation: { status: "completed", completed_at: ... }to the state file
3c. Meta-Review
If aggregation found false-positive entries, the stop hook transitions to meta-review phase.
Launch a triage-rule-reviewer sub-agent with the state file path. The reviewer:
- Analyzes false-positive patterns across entries
- Proposes deterministic classification rules
- Writes
meta_review: { status: "completed", patterns: ..., completed_at: ... }to the state file
Phase 4: Fix Planning
If meta-review found multi-entry false-positive groups (>1 entry sharing the same group_id), the stop hook transitions to fix-planning phase. Single-entry groups are recorded but skipped.
Fix planning proceeds per group through four sub-phases:
4a. Planning
Launch 5 fix-planner sub-agents for each group. Each generates an independent fix proposal.
Output: triage_state/fix_plans/{group_id}/plan_{n}.md
Update plans_written in the state file after each plan is written.
4b. Synthesis
Launch a plan-synthesizer sub-agent that reads all 5 plans and produces a unified fix approach.
Output: triage_state/fix_plans/{group_id}/synthesis.md
Set synthesis_written: true in the state file.
4c. Review
Launch 4 plan-reviewer sub-agents, each reviewing from a different angle:
- Information architecture
- Simplicity
- Fundamentality
- Language coverage
Output: triage_state/fix_plans/{group_id}/review_{angle}.md
Update reviews_written in the state file after each review.
4d. Task Writing
Launch a task-writer sub-agent that creates a backlog task using templates/backlog_task_template.md.
Set task_file in the state file to the created task path.
Phase 5: Finalize
After the stop hook ALLOWs completion (all phases done or error exit), run finalization:
pnpm exec tsx .claude/skills/self-repair-pipeline/scripts/finalize_triage.ts \
--state .claude/skills/self-repair-pipeline/triage_state/{project}_triage.jsonFinalization:
- Partitions entries into true positives, dead code, and false-positive groups
- Saves triage results JSON to
analysis_output/<project>/triage_results/ - Updates the known-entrypoints registry with confirmed true positives and dead code
- Writes triage patterns file (if meta-review produced patterns)
Architecture: Key Modules
All library modules live under src/:
| Module | Purpose |
|---|---|
extract_entry_points.ts | Shared extraction with enriched metadata + diagnostics |
classify_entrypoints.ts | Deterministic rule-based classification (no LLM) |
known_entrypoints.ts | Known-entrypoints registry I/O and matching |
build_triage_entries.ts | Build triage entries from classification results |
build_finalization_output.ts | Build finalization output from completed state |
types.ts | Shared type definitions (EnrichedFunctionEntry, EntryPointDiagnostics, etc.) |
triage_state_types.ts | Triage state machine types |
analysis_io.ts | Analysis file lookup, JSON I/O |
Reference
- State Machine: Phase Transitions and BLOCK/ALLOW Logic
- Diagnosis Routes: Routing Table and Escape Hatch
- Sample Triage Output
Sub-Agents
| Agent | Model | Purpose |
|---|---|---|
| triage-investigator | sonnet | Investigate a single pending entry using diagnosis-specific prompt template |
| triage-aggregator | sonnet | Review all entry results, group by root cause, merge duplicates |
| triage-rule-reviewer | sonnet | Analyze false-positive patterns, propose deterministic classification rules |
| fix-planner | sonnet | Generate one independent fix proposal for a false-positive group |
| plan-synthesizer | opus | Synthesize 5 competing plans into a unified fix approach |
| plan-reviewer | sonnet | Review synthesized plan from one specific angle |
| task-writer | sonnet | Create a backlog task from synthesis + reviews using the task template |
.clinic/
project_configs/*
!project_configs/core.json
!project_configs/mcp.json
!project_configs/types.json
{
"true_positives": [
{
"name": "initialize",
"file_path": "packages/core/src/index.ts",
"start_line": 42,
"signature": "initialize(config: Config): Project"
},
{
"name": "create_project",
"file_path": "packages/core/src/project/project.ts",
"start_line": 18
}
],
"dead_code": [
{
"name": "legacy_resolve",
"file_path": "packages/core/src/resolve_references/legacy_resolve.ts",
"start_line": 7,
"signature": "legacy_resolve(ref: Reference): Definition | null"
}
],
"groups": {
"barrel-reexport": {
"group_id": "barrel-reexport",
"root_cause": "Functions re-exported through barrel files (index.ts) are not resolved because Ariadne does not follow re-export chains during name resolution",
"reasoning": "When a function is defined in module A, re-exported from module B's index.ts, and called via the barrel import, Ariadne resolves the call to the barrel's re-export declaration rather than the original definition. The original function appears uncalled.",
"existing_task_fixes": ["backlog/tasks/task-191 - Fix-barrel-reexport-resolution.md"],
"entries": [
{
"name": "parse_query",
"file_path": "packages/core/src/index_single_file/parse_query.ts",
"start_line": 23,
"signature": "parse_query(source: string, language: Language): QueryResult"
},
{
"name": "extract_scopes",
"file_path": "packages/core/src/index_single_file/extract_scopes.ts",
"start_line": 15,
"signature": "extract_scopes(tree: SyntaxTree): Scope[]"
}
]
},
"cross-package-call": {
"group_id": "cross-package-call",
"root_cause": "Cross-package function calls are not tracked when the calling package is outside the analysis scope",
"reasoning": "When package A calls a function from package B, but only package B is analyzed, the call from package A is invisible. The function in package B appears as an entry point because its caller was never indexed.",
"existing_task_fixes": [],
"entries": [
{
"name": "validate_config",
"file_path": "packages/core/src/project/validate_config.ts",
"start_line": 8
},
{
"name": "resolve_path",
"file_path": "packages/core/src/project/resolve_path.ts",
"start_line": 31,
"signature": "resolve_path(base: string, relative: string): string"
}
]
}
},
"last_updated": "2026-02-18T14:30:00.000Z"
}
{
"name": "self-repair-pipeline",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Entry point self-repair pipeline: detect, triage, fix, finalize",
"scripts": {
"analyze": "npx tsx scripts/detect_entrypoints.ts --config project_configs/core.json",
"profile": "clinic doctor -- node --import tsx scripts/detect_entrypoints.ts --config project_configs/core.json",
"profile:flame": "clinic flame -- node --import tsx scripts/detect_entrypoints.ts --config project_configs/core.json",
"profile:simple": "NODE_ENV=production node --cpu-prof --cpu-prof-interval=100 --import tsx scripts/detect_entrypoints.ts --config project_configs/core.json",
"profile:detailed": "ARIADNE_PROFILE=1 npx tsx scripts/detect_entrypoints.ts --config project_configs/core.json"
},
"dependencies": {
"@ariadnejs/core": "workspace:*",
"@ariadnejs/types": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.0.14",
"clinic": "^13.0.0",
"tsx": "^4.19.2",
"typescript": "^5.8.3"
}
}
{
"project_name": "core",
"project_path": ".",
"folders": ["packages/core/src"],
"exclude": ["node_modules", "dist", "tests", ".git", ".worktrees"]
}
{
"project_name": "mcp",
"project_path": ".",
"folders": ["packages/mcp/src"],
"exclude": ["node_modules", "dist", "tests", ".git"]
}
{
"project_name": "types",
"project_path": ".",
"folders": ["packages/types/src"],
"exclude": ["node_modules", "dist", "tests", ".git"]
}
Diagnosis Routes: Routing Table and Escape Hatch
Entry point candidates are routed through the triage pipeline based on their classification against the known-entrypoints registry and their pre-gathered diagnostic data.
Entry Classification Routes
| Route | Source | Initial Status | Description |
|---|---|---|---|
known-tp | Registry match | completed | Entry matched the known-entrypoints registry — confirmed true positive |
llm-triage | No registry match | pending | Entry needs LLM investigation to determine classification |
Two active routes handle all entry point candidates. Registry-matched entries skip LLM investigation entirely, while unmatched entries proceed through the triage loop for classification.
Diagnosis Values
Each entry has a diagnosis field from pre-gathered diagnostics during detection. These diagnoses describe what Ariadne observed about the entry's call sites:
| Diagnosis | Meaning |
|---|---|
no-textual-callers | Grep found no call sites for this function anywhere in the codebase |
callers-not-in-registry | Grep found call sites but the calling files are not in Ariadne's file registry |
callers-in-registry-unresolved | Calling files are indexed but resolution failed to link them to this definition |
callers-in-registry-wrong-target | Calls were resolved but linked to a different symbol |
Diagnosis-to-Template Routing Table
For llm-triage entries, the diagnosis selects which investigation prompt template to use:
| Diagnosis | Template File | Investigation Focus |
|---|---|---|
callers-not-in-registry | templates/prompt_callers_not_in_registry.md | Verify call sites exist in unindexed files, check file coverage gaps |
callers-in-registry-unresolved | templates/prompt_resolution_failure.md | Identify resolution failure pattern (aliased imports, barrel re-exports, etc.) |
callers-in-registry-wrong-target | templates/prompt_wrong_target.md | Determine why resolution linked to wrong symbol (class hierarchy, shadowing) |
| All other diagnoses | templates/prompt_generic.md | Broad investigation: check for legitimate entry points, indirect callers, dead code |
Templates use {{entry.*}} placeholder syntax. Substitute with entry fields before launching the triage-investigator sub-agent.
Ternary Classification Output
Each investigated entry produces a TriageEntryResult with a ternary classification:
| Classification | is_true_positive | is_likely_dead_code | group_id |
|---|---|---|---|
| True positive | true | false | "true-positive" |
| Dead code | false | true | "dead-code" |
| False positive | false | false | Kebab-case root cause (e.g., "barrel-reexport", "cross-package-call") |
False-positive entries also include:
root_cause: Full description of the detection gapreasoning: Explanation of why this causes false positives
Escape Hatch: Multi-Entry FP Groups
After aggregation and meta-review, false-positive entries are grouped by group_id. The pipeline applies an escape hatch to determine which groups proceed to fix planning:
| Group Size | Action |
|---|---|
| >1 entry (multi-entry group) | Proceeds to fix planning — 5 plans, synthesis, 4 reviews, task creation |
| 1 entry (single-entry group) | Recorded in results but skipped for fix planning |
This prevents creating fix tasks for isolated false positives that may not represent systematic issues. The single-entry groups remain in the finalization output for tracking.
State Machine: Phase Transitions and BLOCK/ALLOW Logic
The Stop hook (scripts/triage_loop_stop.ts) drives the triage pipeline as a deterministic state machine. Each time Claude attempts to stop, the hook reads the triage state file, evaluates the current phase, and either BLOCKs (with instructions for the next action) or ALLOWs (pipeline complete).
Phase Lifecycle
triage → aggregation → meta-review → fix-planning → completePhases transition forward only. Not every phase is reached — the pipeline exits early when there's nothing to do (e.g., no false positives found).
Phase: triage
| Condition | Action | Decision |
|---|---|---|
| Pending entries exist | Instruct: launch triage-investigator sub-agent for next batch | BLOCK |
| All entries completed | Mutate phase → aggregation | BLOCK |
Phase: aggregation
| Condition | Action | Decision |
|---|---|---|
| Aggregation null or pending | Instruct: launch triage-aggregator sub-agent | BLOCK |
| Aggregation failed | Mutate phase → complete | ALLOW |
| Aggregation completed, FP entries exist | Mutate phase → meta-review | BLOCK |
| Aggregation completed, no FP entries | Mutate phase → complete | ALLOW |
FP entries are those routed through llm-triage with result.is_true_positive === false.
Phase: meta-review
| Condition | Action | Decision |
|---|---|---|
| Meta-review null or pending | Instruct: launch triage-rule-reviewer sub-agent | BLOCK |
| Meta-review failed | Mutate phase → complete | ALLOW |
| Meta-review completed, multi-entry FP groups exist | Initialize fix planning, mutate phase → fix-planning | BLOCK |
| Meta-review completed, no multi-entry FP groups | Mutate phase → complete | ALLOW |
Multi-entry FP groups: groups where more than one entry shares the same group_id. Single-entry groups are recorded but do not trigger fix planning.
Phase: fix-planning
Fix planning iterates over groups sequentially. Each group has four sub-phases:
Sub-phase: planning
| Condition | Action | Decision |
|---|---|---|
plans_written < 5 | Instruct: launch fix-planner sub-agents | BLOCK |
plans_written >= 5 | Mutate sub-phase → synthesis | BLOCK |
Sub-phase: synthesis
| Condition | Action | Decision |
|---|---|---|
synthesis_written === false | Instruct: launch plan-synthesizer sub-agent | BLOCK |
synthesis_written === true | Mutate sub-phase → review | BLOCK |
Sub-phase: review
| Condition | Action | Decision |
|---|---|---|
reviews_written < 4 | Instruct: launch plan-reviewer sub-agents | BLOCK |
reviews_written >= 4 | Mutate sub-phase → task-writing | BLOCK |
Sub-phase: task-writing
| Condition | Action | Decision |
|---|---|---|
task_file === null | Instruct: launch task-writer sub-agent | BLOCK |
task_file !== null | Mutate sub-phase → complete, proceed to next group | (continue) |
When all groups reach sub-phase complete:
| Condition | Action | Decision |
|---|---|---|
| All groups complete | Mutate phase → complete | ALLOW |
Constants
| Name | Value | Purpose |
|---|---|---|
REQUIRED_PLANS | 5 | Number of independent fix plans per group |
REQUIRED_REVIEWS | 4 | Number of review angles per group |
Edge Cases
| Condition | Behavior |
|---|---|
stop_hook_active === true (in stdin) | ALLOW — prevents recursive hook invocation |
| No triage state file found | ALLOW — no pipeline active |
| State file unparsable (invalid JSON) | ALLOW — log error and let Claude stop |
phase === "complete" | ALLOW — pipeline already finished |
| Unknown phase value | ALLOW — log error and let Claude stop |
#!/usr/bin/env node
/**
* External repository entrypoint analysis script
*
* Analyzes entrypoints in any local directory or GitHub repository.
* Supports multiple languages: TypeScript, JavaScript, Python, Rust, Go, Java, C++, C.
*
* Usage:
* # From project config (preferred)
* npx tsx detect_entrypoints.ts --config path/to/config.json
*
* # Local repository
* npx tsx detect_entrypoints.ts --path /path/to/repo
*
* # GitHub repository
* npx tsx detect_entrypoints.ts --github owner/repo
* npx tsx detect_entrypoints.ts --github https://github.com/owner/repo
*
* Options:
* --config <file> Project config file (preferred)
* --path <dir> Local directory to analyze
* --github <repo> GitHub repository (owner/repo or full URL)
* --branch <name> Branch to analyze (default: default branch)
* --depth <n> Clone depth for GitHub repos (default: 1)
* --output <file> Output file (default: stdout)
* --include-tests Include test files in analysis
* --folders <paths> Comma-separated subfolders to analyze
* --exclude <patterns> Comma-separated exclude patterns
*/
import {
load_project,
is_test_file,
find_source_files,
IGNORED_DIRECTORIES,
parse_gitignore,
FileSystemStorage,
resolve_cache_dir,
} from "@ariadnejs/core";
import type { PersistenceStorage } from "@ariadnejs/core";
import type { EnrichedFunctionEntry } from "../src/types.js";
import {
build_constructor_to_class_name_map,
detect_language,
extract_entry_points,
} from "../src/extract_entry_points.js";
import { save_json, OutputType } from "../src/analysis_io.js";
import * as path from "path";
import * as fs from "fs/promises";
import * as os from "os";
import { execSync } from "child_process";
// ===== Types =====
interface SourceInfo {
type: "local" | "github";
github_url?: string;
branch?: string;
commit_hash?: string;
}
interface AnalysisResult {
project_name: string;
project_path: string;
source: SourceInfo;
total_files_analyzed: number;
total_entry_points: number;
entry_points: EnrichedFunctionEntry[];
generated_at: string;
}
interface CLIArgs {
path?: string;
github?: string;
branch?: string;
depth: number;
output?: string;
include_tests: boolean;
folders?: string[];
exclude?: string[];
config?: string;
}
interface ProjectConfig {
project_name: string;
project_path: string;
folders?: string[];
exclude?: string[];
include_tests?: boolean;
}
interface CloneResult {
local_path: string;
commit_hash: string;
cleanup: () => Promise<void>;
}
// ===== CLI Argument Parsing =====
function parse_cli_args(): CLIArgs {
const args = process.argv.slice(2);
const result: CLIArgs = {
depth: 1,
include_tests: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--path" && args[i + 1]) {
result.path = args[++i];
} else if (arg.startsWith("--path=")) {
result.path = arg.split("=")[1];
} else if (arg === "--github" && args[i + 1]) {
result.github = args[++i];
} else if (arg.startsWith("--github=")) {
result.github = arg.split("=")[1];
} else if (arg === "--branch" && args[i + 1]) {
result.branch = args[++i];
} else if (arg.startsWith("--branch=")) {
result.branch = arg.split("=")[1];
} else if (arg === "--depth" && args[i + 1]) {
result.depth = parseInt(args[++i], 10);
} else if (arg.startsWith("--depth=")) {
result.depth = parseInt(arg.split("=")[1], 10);
} else if (arg === "--output" && args[i + 1]) {
result.output = args[++i];
} else if (arg.startsWith("--output=")) {
result.output = arg.split("=")[1];
} else if (arg === "--include-tests") {
result.include_tests = true;
} else if (arg === "--folders" && args[i + 1]) {
result.folders = args[++i].split(",").map((f) => f.trim());
} else if (arg.startsWith("--folders=")) {
result.folders = arg.split("=")[1].split(",").map((f) => f.trim());
} else if (arg === "--exclude" && args[i + 1]) {
result.exclude = args[++i].split(",").map((p) => p.trim());
} else if (arg.startsWith("--exclude=")) {
result.exclude = arg.split("=")[1].split(",").map((p) => p.trim());
} else if (arg === "--config" && args[i + 1]) {
result.config = args[++i];
} else if (arg.startsWith("--config=")) {
result.config = arg.split("=")[1];
}
}
return result;
}
function print_usage(): void {
console.error(`
Usage:
npx tsx detect_entrypoints.ts --config path/to/config.json
npx tsx detect_entrypoints.ts --path /path/to/repo
npx tsx detect_entrypoints.ts --github owner/repo
Options:
--config <file> Project config file (preferred, see below)
--path <dir> Local directory to analyze
--github <repo> GitHub repository (owner/repo or full URL)
--branch <name> Branch to analyze (default: default branch)
--depth <n> Clone depth for GitHub repos (default: 1)
--output <file> Output file (default: stdout)
--include-tests Include test files in analysis
--folders <paths> Comma-separated subfolders to analyze
--exclude <patterns> Comma-separated exclude patterns
Config file format (JSON):
{
"project_name": "my-project",
"project_path": "/absolute/path/to/repo",
"folders": ["src", "lib"],
"exclude": ["vendor", "generated"],
"include_tests": false
}
`);
}
// ===== Config Loading =====
async function load_project_config(config_path: string): Promise<ProjectConfig> {
const resolved = path.resolve(config_path);
const raw = await fs.readFile(resolved, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsed.project_name !== "string" || !parsed.project_name) {
throw new Error("Config missing required field: project_name");
}
if (typeof parsed.project_path !== "string" || !parsed.project_path) {
throw new Error("Config missing required field: project_path");
}
return {
project_name: parsed.project_name,
project_path: path.resolve(parsed.project_path),
folders: Array.isArray(parsed.folders) ? (parsed.folders as string[]) : undefined,
exclude: Array.isArray(parsed.exclude) ? (parsed.exclude as string[]) : undefined,
include_tests: typeof parsed.include_tests === "boolean" ? parsed.include_tests : undefined,
};
}
// ===== GitHub Cloning =====
function parse_github_url(repo: string): string {
// Already a full URL
if (repo.startsWith("https://") || repo.startsWith("git@")) {
// Ensure it ends with .git for consistency
return repo.endsWith(".git") ? repo : `${repo}.git`;
}
// owner/repo format
if (repo.includes("/") && !repo.includes("://")) {
return `https://github.com/${repo}.git`;
}
throw new Error(
`Invalid GitHub repository format: ${repo}. Use "owner/repo" or full URL.`
);
}
async function clone_github_repo(
repo: string,
branch?: string,
depth: number = 1
): Promise<CloneResult> {
const github_url = parse_github_url(repo);
const temp_dir = await fs.mkdtemp(path.join(os.tmpdir(), "ariadne-analysis-"));
console.error(`Cloning ${github_url} to ${temp_dir}...`);
// Build clone command
let clone_cmd = `git clone --depth ${depth}`;
if (branch) {
clone_cmd += ` -b ${branch}`;
}
clone_cmd += ` ${github_url} ${temp_dir}`;
try {
execSync(clone_cmd, { encoding: "utf-8", stdio: "pipe" });
} catch (error: unknown) {
// Clean up on failure
await fs.rm(temp_dir, { recursive: true, force: true });
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to clone repository: ${message}`);
}
// Get commit hash
const commit_hash = execSync("git rev-parse HEAD", {
encoding: "utf-8",
cwd: temp_dir,
}).trim();
console.error(`Cloned at commit ${commit_hash.substring(0, 7)}`);
return {
local_path: temp_dir,
commit_hash,
cleanup: async () => {
console.error(`Cleaning up ${temp_dir}...`);
await fs.rm(temp_dir, { recursive: true, force: true });
},
};
}
/**
* Get commit hash for local git repository (if available)
*/
function get_local_commit_hash(repo_path: string): string | undefined {
try {
return execSync("git rev-parse HEAD", {
encoding: "utf-8",
cwd: repo_path,
stdio: "pipe",
}).trim();
} catch {
return undefined;
}
}
// ===== Main Analysis =====
async function analyze_directory(
project_path: string,
options: {
include_tests: boolean;
folders?: string[];
exclude?: string[];
storage?: PersistenceStorage;
}
): Promise<{
files_analyzed: number;
entry_points: EnrichedFunctionEntry[];
}> {
const start_time = Date.now();
const exclude = [...IGNORED_DIRECTORIES, ...(options.exclude || [])];
const test_file_filter = options.include_tests
? undefined
: (file: string) => {
const language = detect_language(file);
return !language || !is_test_file(file, language);
};
console.error(`Initializing project at: ${project_path}`);
console.error(`Excluded folders: ${exclude.join(", ")}`);
if (options.folders) {
console.error(`Analyzing folders: ${options.folders.join(", ")}`);
}
// Load project using shared pipeline
const load_start = Date.now();
const project = await load_project({
project_path,
folders: options.folders,
exclude,
file_filter: test_file_filter,
storage: options.storage,
});
console.error(`Project loaded in ${Date.now() - load_start}ms`);
console.error(`Cache: ${options.storage ? "enabled" : "disabled"}`);
const stats = project.get_stats();
console.error(`Found ${stats.file_count} indexed files`);
// Build source_files Map for grep heuristics (re-read discovered files)
const gitignore_patterns = await parse_gitignore(project_path);
const combined_patterns = [...gitignore_patterns, ...(options.exclude || [])];
const search_paths = options.folders
? options.folders.map((f) => path.join(project_path, f))
: [project_path];
let all_files: string[] = [];
for (const search_path of search_paths) {
try {
const files = await find_source_files(search_path, project_path, combined_patterns);
all_files = all_files.concat(files);
} catch (error) {
console.error(`Warning: Could not read ${search_path}: ${error}`);
}
}
if (test_file_filter) {
all_files = all_files.filter(test_file_filter);
}
const source_files = new Map<string, string>();
for (const file_path of all_files) {
try {
source_files.set(file_path, await fs.readFile(file_path, "utf-8"));
} catch {
// Skip unreadable files
}
}
// Build call graph
console.error("Building call graph...");
const callgraph_start = Date.now();
const call_graph = project.get_call_graph();
console.error(
`Found ${call_graph.entry_points.length} entry points in ${Date.now() - callgraph_start}ms`
);
// Build constructor → class name map for grep heuristic
const class_name_by_constructor_id = build_constructor_to_class_name_map(project.definitions.get_class_definitions());
// Extract entry points
const entry_points = extract_entry_points(call_graph, source_files, undefined, class_name_by_constructor_id);
console.error(`Total analysis time: ${Date.now() - start_time}ms`);
return {
files_analyzed: stats.file_count,
entry_points,
};
}
// ===== Main Entry Point =====
async function main() {
const args = parse_cli_args();
let project_path: string;
let source_info: SourceInfo;
let cleanup: (() => Promise<void>) | undefined;
let project_name: string;
let include_tests: boolean;
let folders: string[] | undefined;
let exclude: string[] | undefined;
if (args.config) {
// Config mode — all settings come from the config file
const config = await load_project_config(args.config);
project_path = config.project_path;
// Verify path exists
try {
const stat = await fs.stat(project_path);
if (!stat.isDirectory()) {
console.error(`Error: ${project_path} is not a directory.`);
process.exit(1);
}
} catch {
console.error(`Error: Directory ${project_path} does not exist.`);
process.exit(1);
}
project_name = config.project_name;
include_tests = config.include_tests ?? false;
folders = config.folders;
exclude = config.exclude;
source_info = {
type: "local",
commit_hash: get_local_commit_hash(project_path),
};
} else if (args.path || args.github) {
if (args.path && args.github) {
console.error("Error: --path and --github are mutually exclusive.");
process.exit(1);
}
include_tests = args.include_tests;
folders = args.folders;
exclude = args.exclude;
if (args.github) {
// Clone GitHub repository
const clone_result = await clone_github_repo(
args.github,
args.branch,
args.depth
);
project_path = clone_result.local_path;
cleanup = clone_result.cleanup;
const repo_parts = args.github.split("/");
const last_part = repo_parts[repo_parts.length - 1] || args.github;
project_name = last_part.replace(".git", "");
source_info = {
type: "github",
github_url: parse_github_url(args.github),
branch: args.branch,
commit_hash: clone_result.commit_hash,
};
} else {
// Local path (args.path is guaranteed by the condition above)
project_path = path.resolve(args.path as string);
// Verify path exists
try {
const stat = await fs.stat(project_path);
if (!stat.isDirectory()) {
console.error(`Error: ${project_path} is not a directory.`);
process.exit(1);
}
} catch {
console.error(`Error: Directory ${project_path} does not exist.`);
process.exit(1);
}
project_name = path.basename(project_path);
source_info = {
type: "local",
commit_hash: get_local_commit_hash(project_path),
};
}
} else {
console.error("Error: One of --config, --path, or --github is required.");
print_usage();
process.exit(1);
}
// Create storage for local paths only (GitHub clones use temp dirs — caching is pointless)
let storage: PersistenceStorage | undefined;
if (source_info.type === "local") {
const cache_dir = resolve_cache_dir(project_path);
if (cache_dir) {
storage = new FileSystemStorage(cache_dir);
console.error(`Cache directory: ${cache_dir}`);
}
}
try {
// Run analysis
const { files_analyzed, entry_points } = await analyze_directory(
project_path,
{
include_tests,
folders,
exclude,
storage,
}
);
// Build result
const result: AnalysisResult = {
project_name,
project_path,
source: source_info,
total_files_analyzed: files_analyzed,
total_entry_points: entry_points.length,
entry_points,
generated_at: new Date().toISOString(),
};
// Output result
if (args.output) {
const json_output = JSON.stringify(result, null, 2);
await fs.writeFile(args.output, json_output, "utf-8");
console.error(`Output written to: ${args.output}`);
} else {
// Use structured output
const output_file = await save_json(OutputType.DETECT_ENTRYPOINTS, result, project_name);
console.error(`Output written to: ${output_file}`);
}
console.error("\nAnalysis complete:");
console.error(` Files analyzed: ${files_analyzed}`);
console.error(` Entry points found: ${entry_points.length}`);
} finally {
// Clean up cloned repository
if (cleanup) {
await cleanup();
}
}
}
main().catch((error) => {
console.error("Error:", error.message);
process.exit(1);
});
#!/usr/bin/env npx tsx
/**
* Finalize triage: read completed state, save results, update registry.
*
* Reads a completed triage state file and produces:
* - Triage results JSON (via save_json)
* - Updated known-entrypoints registry
* - Triage patterns file (if meta_review contains patterns)
*
* Usage:
* npx tsx finalize_triage.ts --state <path> [--external]
*/
import * as fs from "node:fs/promises";
import * as path from "path";
import {
save_json,
load_json,
OutputType,
} from "../src/analysis_io.js";
import {
load_known_entrypoints,
save_known_entrypoints,
build_project_source,
build_dead_code_source,
} from "../src/known_entrypoints.js";
import {
build_finalization_output,
build_finalization_summary,
} from "../src/build_finalization_output.js";
import { TRIAGE_PATTERNS_FILE } from "../src/paths.js";
import type { TriageState } from "../src/triage_state_types.js";
// ===== CLI Argument Parsing =====
interface CliArgs {
state_path: string;
}
function parse_args(argv: string[]): CliArgs {
const args = argv.slice(2);
let state_path: string | null = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === "--state") {
state_path = args[++i];
}
}
if (!state_path) {
console.error("Usage: finalize_triage.ts --state <path>");
process.exit(1);
}
return { state_path };
}
// ===== Main =====
async function main(): Promise<void> {
const cli = parse_args(process.argv);
// Load state
const state = await load_json<TriageState>(cli.state_path);
// Verify phase
if (state.phase !== "complete") {
console.error(`Error: state phase is "${state.phase}", expected "complete"`);
process.exit(1);
}
// Build output
const output = build_finalization_output(state);
const summary = build_finalization_summary(state, output);
// Save triage results
const output_file = await save_json(OutputType.TRIAGE_RESULTS, output, state.project_name);
// Update known-entrypoints registry
const known_sources = await load_known_entrypoints(state.project_name);
const project_source = build_project_source(output.true_positives, state.project_path);
const dead_code_source = build_dead_code_source(output.dead_code, state.project_path);
const framework_sources = known_sources.filter(
(s) => s.source !== "project" && s.source !== "dead-code",
);
const registry_path = await save_known_entrypoints(state.project_name, [
project_source,
dead_code_source,
...framework_sources,
]);
// Write triage patterns (guarded)
if (state.meta_review && state.meta_review.patterns) {
await fs.writeFile(TRIAGE_PATTERNS_FILE, JSON.stringify(state.meta_review.patterns, null, 2) + "\n");
console.error(`Triage patterns written: ${TRIAGE_PATTERNS_FILE}`);
} else {
console.error("No triage patterns in meta_review, skipping patterns file.");
}
// Clean up per-entry result files
const results_dir = path.join(path.dirname(cli.state_path), "results");
try {
await fs.rm(results_dir, { recursive: true });
console.error(`Cleaned up results directory: ${results_dir}`);
} catch {
// May not exist if all entries were known-tp
}
// Print summary
console.error("\nFinalization complete:");
console.error(` Total entries: ${summary.total_entries}`);
console.error(` True positives: ${summary.true_positive_count}`);
console.error(` Dead code: ${summary.dead_code_count}`);
console.error(` False positives: ${summary.false_positive_count} (${summary.group_count} groups)`);
if (summary.failed_count > 0) {
console.error(` Failed: ${summary.failed_count}`);
}
if (summary.task_files.length > 0) {
console.error("\n Task files created:");
for (const tf of summary.task_files) {
console.error(` - ${tf}`);
}
}
console.error(`\n Output file: ${output_file}`);
console.error(` Registry file: ${registry_path}`);
}
main().catch((error) => {
console.error(`Fatal: ${error}`);
process.exit(1);
});
#!/usr/bin/env npx tsx
/**
* Prepare triage state file from entrypoint analysis output.
*
* Loads analysis JSON, classifies entries against the known-entrypoints
* registry, and builds the triage state file.
*
* Usage:
* npx tsx prepare_triage.ts --analysis <path> [--state <path>] [--package <name>] [--batch-size <n>]
*/
import * as fs from "node:fs/promises";
import * as path from "path";
import { load_json } from "../src/analysis_io.js";
import { load_known_entrypoints } from "../src/known_entrypoints.js";
import { classify_entrypoints } from "../src/classify_entrypoints.js";
import { build_triage_entries } from "../src/build_triage_entries.js";
import { TRIAGE_STATE_DIR } from "../src/paths.js";
import type { AnalysisResult } from "../src/types.js";
import type { TriageState } from "../src/triage_state_types.js";
// ===== CLI Argument Parsing =====
interface CliArgs {
analysis_path: string;
state_path: string | null;
package_name: string | null;
batch_size: number;
}
function parse_args(argv: string[]): CliArgs {
const args = argv.slice(2);
let analysis_path: string | null = null;
let state_path: string | null = null;
let package_name: string | null = null;
let batch_size = 5;
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "--analysis":
analysis_path = args[++i];
break;
case "--state":
state_path = args[++i];
break;
case "--package":
package_name = args[++i];
break;
case "--batch-size":
batch_size = parseInt(args[++i], 10);
break;
}
}
if (!analysis_path) {
console.error("Usage: prepare_triage.ts --analysis <path> [--state <path>] [--package <name>] [--batch-size <n>]");
process.exit(1);
}
return { analysis_path, state_path, package_name, batch_size };
}
// ===== Main =====
async function main(): Promise<void> {
const cli = parse_args(process.argv);
// Load analysis JSON
const analysis = await load_json<AnalysisResult>(cli.analysis_path);
const project_name = cli.package_name ?? analysis.project_name;
const project_path = analysis.project_path;
// Load known-entrypoints registry and classify
const known_sources = await load_known_entrypoints(project_name);
const classification = classify_entrypoints(analysis.entry_points, known_sources, project_path);
// Build triage entries
const entries = build_triage_entries(classification);
// Build state
const now = new Date().toISOString();
const state: TriageState = {
project_name,
project_path,
analysis_file: path.resolve(cli.analysis_path),
phase: "triage",
batch_size: cli.batch_size,
entries,
aggregation: null,
meta_review: null,
fix_planning: null,
created_at: now,
updated_at: now,
};
// Determine output path
const state_path = cli.state_path
?? path.join(TRIAGE_STATE_DIR, `${project_name}_triage.json`);
// Write state file
await fs.mkdir(path.dirname(state_path), { recursive: true });
await fs.writeFile(state_path, JSON.stringify(state, null, 2) + "\n");
// Summary
const known_tp_count = entries.filter(e => e.route === "known-tp").length;
const llm_triage_count = entries.filter(e => e.route === "llm-triage").length;
console.error(`Triage state prepared: ${entries.length} entries`);
console.error(` known-tp: ${known_tp_count} (completed)`);
console.error(` llm-triage: ${llm_triage_count} (pending)`);
console.error(`State file: ${state_path}`);
}
main().catch((error) => {
console.error(`Fatal: ${error}`);
process.exit(1);
});
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import path from "path";
import type {
TriageState,
TriageEntry,
TriageEntryResult,
FixPlanGroupState,
} from "../src/triage_state_types.js";
import {
discover_state_file,
merge_result_files,
get_escape_hatch_fp_entries,
get_multi_entry_fp_groups,
init_fix_planning,
handle_triage,
handle_aggregation,
handle_meta_review,
handle_fix_planning,
} from "./triage_loop_stop.js";
// ===== Test Helpers =====
function build_mock_result(overrides: Partial<TriageEntryResult> = {}): TriageEntryResult {
return {
is_true_positive: false,
is_likely_dead_code: false,
group_id: "group-a",
root_cause: "missing export detection",
reasoning: "test reasoning",
...overrides,
};
}
let mock_entry_index = 0;
function build_mock_entry(overrides: Partial<TriageEntry> = {}): TriageEntry {
const idx = overrides.entry_index ?? mock_entry_index++;
return Object.assign(
{
entry_index: idx,
name: "test_func",
file_path: "src/test.ts",
start_line: 1,
kind: "function",
signature: "function test_func(): void",
route: "llm-triage" as const,
diagnosis: "needs triage",
deterministic_group_id: null,
known_source: null,
status: "pending" as const,
result: null,
error: null,
attempt_count: 0,
} satisfies TriageEntry,
overrides,
{ entry_index: idx },
);
}
function build_mock_state(overrides: Partial<TriageState> = {}): TriageState {
return {
project_name: "test-project",
project_path: "/test/project",
analysis_file: "/test/analysis.json",
phase: "triage",
batch_size: 5,
entries: [],
aggregation: null,
meta_review: null,
fix_planning: null,
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
const MOCK_TRIAGE_DIR = "/tmp/triage_state";
const MOCK_STATE_PATH = "/tmp/triage_state/test_triage.json";
// ===== discover_state_file =====
describe("discover_state_file", () => {
it("returns null when directory does not exist", () => {
expect(discover_state_file("/nonexistent/path/abc123")).toEqual(null);
});
it("returns null when directory has no triage files", () => {
const dir = "/tmp/claude/triage_test_empty";
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "other.json"), "{}");
try {
expect(discover_state_file(dir)).toEqual(null);
} finally {
fs.rmSync(dir, { recursive: true });
}
});
it("returns path when triage file exists", () => {
const dir = "/tmp/claude/triage_test_found";
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "core_triage.json"), "{}");
try {
expect(discover_state_file(dir)).toEqual(path.join(dir, "core_triage.json"));
} finally {
fs.rmSync(dir, { recursive: true });
}
});
});
// ===== get_escape_hatch_fp_entries =====
describe("get_escape_hatch_fp_entries", () => {
it("returns entries with route=llm-triage and is_true_positive=false", () => {
const fp_entry = build_mock_entry({
name: "fp_func",
route: "llm-triage",
status: "completed",
result: build_mock_result({ is_true_positive: false }),
});
const tp_entry = build_mock_entry({
name: "tp_func",
route: "llm-triage",
status: "completed",
result: build_mock_result({ is_true_positive: true }),
});
const known_entry = build_mock_entry({
name: "known_func",
route: "known-tp",
status: "completed",
result: build_mock_result({ is_true_positive: false }),
});
const pending_entry = build_mock_entry({
name: "pending_func",
route: "llm-triage",
status: "pending",
result: null,
});
const state = build_mock_state({
entries: [fp_entry, tp_entry, known_entry, pending_entry],
});
const result = get_escape_hatch_fp_entries(state);
expect(result).toEqual([fp_entry]);
});
it("returns empty array when no FP entries exist", () => {
const state = build_mock_state({
entries: [
build_mock_entry({
route: "llm-triage",
status: "completed",
result: build_mock_result({ is_true_positive: true }),
}),
],
});
expect(get_escape_hatch_fp_entries(state)).toEqual([]);
});
});
// ===== get_multi_entry_fp_groups =====
describe("get_multi_entry_fp_groups", () => {
it("groups FP entries by group_id and excludes singles", () => {
const entry_a1 = build_mock_entry({
name: "a1",
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-a" }),
});
const entry_a2 = build_mock_entry({
name: "a2",
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-a" }),
});
const entry_b1 = build_mock_entry({
name: "b1",
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-b" }),
});
const state = build_mock_state({ entries: [entry_a1, entry_a2, entry_b1] });
const result = get_multi_entry_fp_groups(state);
expect(Object.keys(result)).toEqual(["group-a"]);
expect(result["group-a"]).toEqual([entry_a1, entry_a2]);
});
it("returns empty when all groups have single entries", () => {
const state = build_mock_state({
entries: [
build_mock_entry({
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-a" }),
}),
build_mock_entry({
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-b" }),
}),
],
});
expect(get_multi_entry_fp_groups(state)).toEqual({});
});
});
// ===== merge_result_files =====
describe("merge_result_files", () => {
const test_dir = "/tmp/claude/merge_result_test";
const results_dir = path.join(test_dir, "results");
afterEach(() => {
if (fs.existsSync(test_dir)) {
fs.rmSync(test_dir, { recursive: true });
}
});
it("returns 0 when results dir does not exist", () => {
const state = build_mock_state({
entries: [build_mock_entry({ entry_index: 0, status: "pending" })],
});
expect(merge_result_files(state, "/tmp/claude/nonexistent_merge_test")).toEqual(0);
});
it("merges valid result into correct entry", () => {
const state = build_mock_state({
entries: [
build_mock_entry({ entry_index: 0, status: "pending" }),
build_mock_entry({ entry_index: 1, status: "pending" }),
],
});
fs.mkdirSync(results_dir, { recursive: true });
const result: TriageEntryResult = {
is_true_positive: true,
is_likely_dead_code: false,
group_id: "true-positive",
root_cause: "Public API",
reasoning: "Exported from index",
};
fs.writeFileSync(path.join(results_dir, "1.json"), JSON.stringify(result));
const merged = merge_result_files(state, test_dir);
expect(merged).toEqual(1);
expect(state.entries[1].status).toEqual("completed");
expect(state.entries[1].result).toEqual(result);
expect(state.entries[1].attempt_count).toEqual(1);
expect(state.entries[0].status).toEqual("pending");
});
it("skips already-completed entries (idempotent)", () => {
const existing_result = build_mock_result({ group_id: "original" });
const state = build_mock_state({
entries: [
build_mock_entry({ entry_index: 0, status: "completed", result: existing_result }),
],
});
fs.mkdirSync(results_dir, { recursive: true });
const new_result: TriageEntryResult = {
is_true_positive: false,
is_likely_dead_code: true,
group_id: "dead-code",
root_cause: "Unused",
reasoning: "No callers",
};
fs.writeFileSync(path.join(results_dir, "0.json"), JSON.stringify(new_result));
const merged = merge_result_files(state, test_dir);
expect(merged).toEqual(0);
expect(state.entries[0].result).toEqual(existing_result);
});
it("marks entry failed on malformed JSON", () => {
const state = build_mock_state({
entries: [build_mock_entry({ entry_index: 0, status: "pending" })],
});
fs.mkdirSync(results_dir, { recursive: true });
fs.writeFileSync(path.join(results_dir, "0.json"), "not valid json{{{");
const merged = merge_result_files(state, test_dir);
expect(merged).toEqual(1);
expect(state.entries[0].status).toEqual("failed");
expect(state.entries[0].error).toContain("Failed to parse result file");
expect(state.entries[0].attempt_count).toEqual(1);
});
it("ignores non-numeric filenames", () => {
const state = build_mock_state({
entries: [build_mock_entry({ entry_index: 0, status: "pending" })],
});
fs.mkdirSync(results_dir, { recursive: true });
fs.writeFileSync(path.join(results_dir, "readme.json"), "{}");
fs.writeFileSync(path.join(results_dir, "abc.json"), "{}");
const merged = merge_result_files(state, test_dir);
expect(merged).toEqual(0);
expect(state.entries[0].status).toEqual("pending");
});
it("ignores out-of-range indices", () => {
const state = build_mock_state({
entries: [build_mock_entry({ entry_index: 0, status: "pending" })],
});
fs.mkdirSync(results_dir, { recursive: true });
const result: TriageEntryResult = {
is_true_positive: true,
is_likely_dead_code: false,
group_id: "true-positive",
root_cause: "API",
reasoning: "Exported",
};
fs.writeFileSync(path.join(results_dir, "99.json"), JSON.stringify(result));
const merged = merge_result_files(state, test_dir);
expect(merged).toEqual(0);
});
});
// ===== handle_triage =====
describe("handle_triage", () => {
it("blocks with pending entries", () => {
const state = build_mock_state({
entries: [
build_mock_entry({ status: "pending" }),
build_mock_entry({ status: "pending" }),
build_mock_entry({ status: "completed", result: build_mock_result() }),
],
});
const result = handle_triage(state, MOCK_TRIAGE_DIR, MOCK_STATE_PATH);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
expect(result.reason).toContain("2 entries need triage");
expect(result.reason).toContain("**triage-investigator**");
});
it("transitions to aggregation when all entries done", () => {
const state = build_mock_state({
entries: [
build_mock_entry({ status: "completed", result: build_mock_result() }),
build_mock_entry({ status: "failed", error: "timeout" }),
],
});
const result = handle_triage(state, MOCK_TRIAGE_DIR, MOCK_STATE_PATH);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("aggregation");
});
});
// ===== handle_aggregation =====
describe("handle_aggregation", () => {
it("blocks when aggregation is null", () => {
const state = build_mock_state({ phase: "aggregation", aggregation: null });
const result = handle_aggregation(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
});
it("blocks when aggregation is pending", () => {
const state = build_mock_state({
phase: "aggregation",
aggregation: { status: "pending", completed_at: null },
});
const result = handle_aggregation(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
});
it("transitions to meta-review with FP entries", () => {
const state = build_mock_state({
phase: "aggregation",
aggregation: { status: "completed", completed_at: "2026-01-01T00:00:00.000Z" },
entries: [
build_mock_entry({
route: "llm-triage",
status: "completed",
result: build_mock_result({ is_true_positive: false }),
}),
],
});
const result = handle_aggregation(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("meta-review");
});
it("transitions to complete with no FP entries", () => {
const state = build_mock_state({
phase: "aggregation",
aggregation: { status: "completed", completed_at: "2026-01-01T00:00:00.000Z" },
entries: [
build_mock_entry({
route: "llm-triage",
status: "completed",
result: build_mock_result({ is_true_positive: true }),
}),
],
});
const result = handle_aggregation(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("allow");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("complete");
});
it("transitions to complete on failure", () => {
const state = build_mock_state({
phase: "aggregation",
aggregation: { status: "failed", completed_at: null },
});
const result = handle_aggregation(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("allow");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("complete");
});
});
// ===== handle_meta_review =====
describe("handle_meta_review", () => {
it("blocks when meta_review is null", () => {
const state = build_mock_state({ phase: "meta-review", meta_review: null });
const result = handle_meta_review(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
});
it("blocks when meta_review is pending", () => {
const state = build_mock_state({
phase: "meta-review",
meta_review: { status: "pending", completed_at: null, patterns: null },
});
const result = handle_meta_review(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
});
it("transitions to fix-planning with multi-entry groups", () => {
const state = build_mock_state({
phase: "meta-review",
meta_review: { status: "completed", completed_at: "2026-01-01T00:00:00.000Z", patterns: null },
entries: [
build_mock_entry({
name: "a1",
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-a" }),
}),
build_mock_entry({
name: "a2",
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-a" }),
}),
],
});
const result = handle_meta_review(state, "/tmp/triage_state/test_triage.json");
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("fix-planning");
expect(state.fix_planning).not.toEqual(null);
expect(state.fix_planning!.groups["group-a"].entry_count).toEqual(2);
expect(state.fix_planning!.groups["group-a"].sub_phase).toEqual("planning");
expect(state.fix_planning!.fix_plans_dir).toEqual("/tmp/triage_state/fix_plans");
});
it("transitions to complete with no multi-entry groups", () => {
const state = build_mock_state({
phase: "meta-review",
meta_review: { status: "completed", completed_at: "2026-01-01T00:00:00.000Z", patterns: null },
entries: [
build_mock_entry({
route: "llm-triage",
status: "completed",
result: build_mock_result({ group_id: "group-a" }),
}),
],
});
const result = handle_meta_review(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("allow");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("complete");
});
it("transitions to complete on failure", () => {
const state = build_mock_state({
phase: "meta-review",
meta_review: { status: "failed", completed_at: null, patterns: null },
});
const result = handle_meta_review(state, MOCK_STATE_PATH);
expect(result.decision).toEqual("allow");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("complete");
});
});
// ===== handle_fix_planning =====
describe("handle_fix_planning", () => {
function build_fix_planning_state(
group_overrides: Partial<FixPlanGroupState> = {},
): TriageState {
return build_mock_state({
phase: "fix-planning",
fix_planning: {
fix_plans_dir: "/tmp/fix_plans",
groups: {
"group-a": {
group_id: "group-a",
root_cause: "missing export",
entry_count: 3,
sub_phase: "planning",
plans_written: 0,
synthesis_written: false,
reviews_written: 0,
task_file: null,
...group_overrides,
},
},
},
});
}
describe("planning sub-phase", () => {
it("blocks when plans_written < 5", () => {
const state = build_fix_planning_state({ plans_written: 2 });
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
expect(result.reason).toContain("2/5 plans written");
expect(result.reason).toContain("**fix-planner**");
});
it("transitions to synthesis when plans_written === 5", () => {
const state = build_fix_planning_state({ plans_written: 5 });
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(true);
expect(state.fix_planning!.groups["group-a"].sub_phase).toEqual("synthesis");
});
});
describe("synthesis sub-phase", () => {
it("blocks when synthesis not written", () => {
const state = build_fix_planning_state({
sub_phase: "synthesis",
plans_written: 5,
synthesis_written: false,
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
});
it("transitions to review when synthesis written", () => {
const state = build_fix_planning_state({
sub_phase: "synthesis",
plans_written: 5,
synthesis_written: true,
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(true);
expect(state.fix_planning!.groups["group-a"].sub_phase).toEqual("review");
});
});
describe("review sub-phase", () => {
it("blocks when reviews_written < 4", () => {
const state = build_fix_planning_state({
sub_phase: "review",
plans_written: 5,
synthesis_written: true,
reviews_written: 1,
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
expect(result.reason).toContain("1/4 reviews written");
expect(result.reason).toContain("**plan-reviewer**");
});
it("transitions to task-writing when reviews_written === 4", () => {
const state = build_fix_planning_state({
sub_phase: "review",
plans_written: 5,
synthesis_written: true,
reviews_written: 4,
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(true);
expect(state.fix_planning!.groups["group-a"].sub_phase).toEqual("task-writing");
});
});
describe("task-writing sub-phase", () => {
it("blocks when task_file not set", () => {
const state = build_fix_planning_state({
sub_phase: "task-writing",
plans_written: 5,
synthesis_written: true,
reviews_written: 4,
task_file: null,
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(result.mutated).toEqual(false);
});
it("completes group and allows when only group", () => {
const state = build_fix_planning_state({
sub_phase: "task-writing",
plans_written: 5,
synthesis_written: true,
reviews_written: 4,
task_file: "/tmp/task.md",
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("allow");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("complete");
expect(state.fix_planning!.groups["group-a"].sub_phase).toEqual("complete");
});
});
describe("multi-group progression", () => {
it("moves to next group after completing first", () => {
const state = build_mock_state({
phase: "fix-planning",
fix_planning: {
fix_plans_dir: "/tmp/fix_plans",
groups: {
"group-a": {
group_id: "group-a",
root_cause: "missing export",
entry_count: 2,
sub_phase: "task-writing",
plans_written: 5,
synthesis_written: true,
reviews_written: 4,
task_file: "/tmp/task_a.md",
},
"group-b": {
group_id: "group-b",
root_cause: "wrong scope",
entry_count: 3,
sub_phase: "planning",
plans_written: 0,
synthesis_written: false,
reviews_written: 0,
task_file: null,
},
},
},
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("block");
expect(state.fix_planning!.groups["group-a"].sub_phase).toEqual("complete");
expect(result.reason).toContain("group-b");
});
it("allows when all groups complete", () => {
const state = build_mock_state({
phase: "fix-planning",
fix_planning: {
fix_plans_dir: "/tmp/fix_plans",
groups: {
"group-a": {
group_id: "group-a",
root_cause: "missing export",
entry_count: 2,
sub_phase: "complete",
plans_written: 5,
synthesis_written: true,
reviews_written: 4,
task_file: "/tmp/task_a.md",
},
"group-b": {
group_id: "group-b",
root_cause: "wrong scope",
entry_count: 3,
sub_phase: "complete",
plans_written: 5,
synthesis_written: true,
reviews_written: 4,
task_file: "/tmp/task_b.md",
},
},
},
});
const result = handle_fix_planning(state);
expect(result.decision).toEqual("allow");
expect(result.mutated).toEqual(true);
expect(state.phase).toEqual("complete");
});
});
});
#!/usr/bin/env npx tsx
/**
* Stop hook: Drive the triage loop as a deterministic state machine.
*
* Reads the triage state file, determines the current phase, and either
* BLOCKs (with instructions for the next action) or ALLOWs (pipeline complete).
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { create_logger, parse_stdin } from "../../../hooks/utils.js";
import { TRIAGE_STATE_DIR } from "../src/paths.js";
import type {
TriageState,
TriageEntry,
TriageEntryResult,
FixPlanningState,
FixPlanGroupState,
} from "../src/triage_state_types.js";
const log = create_logger("triage-loop");
// ===== Constants =====
const REQUIRED_PLANS = 5;
const REQUIRED_REVIEWS = 4;
// ===== Types =====
export interface PhaseResult {
decision: "block" | "allow";
reason?: string;
mutated: boolean;
}
// ===== Helper Functions =====
/**
* Find a triage state file (*_triage.json) in the given directory.
*/
export function discover_state_file(triage_dir: string): string | null {
if (!fs.existsSync(triage_dir)) return null;
const files = fs.readdirSync(triage_dir).filter((f) => f.endsWith("_triage.json"));
if (files.length === 0) return null;
return path.join(triage_dir, files[0]);
}
/**
* Get entries routed through LLM triage that were classified as false positives.
*/
export function get_escape_hatch_fp_entries(state: TriageState): TriageEntry[] {
return state.entries.filter(
(e) =>
e.route === "llm-triage" &&
e.result !== null &&
e.result.is_true_positive === false,
);
}
/**
* Group false-positive entries by group_id, returning only groups with more than one entry.
*/
export function get_multi_entry_fp_groups(
state: TriageState,
): Record<string, TriageEntry[]> {
const fp_entries = get_escape_hatch_fp_entries(state);
const groups: Record<string, TriageEntry[]> = {};
for (const entry of fp_entries) {
const group_id = (entry.result as TriageEntryResult).group_id;
if (!groups[group_id]) {
groups[group_id] = [];
}
groups[group_id].push(entry);
}
const multi: Record<string, TriageEntry[]> = {};
for (const [group_id, entries] of Object.entries(groups)) {
if (entries.length > 1) {
multi[group_id] = entries;
}
}
return multi;
}
/**
* Initialize fix planning state from multi-entry FP groups.
*/
export function init_fix_planning(
state: TriageState,
fp_groups: Record<string, TriageEntry[]>,
triage_dir: string,
): void {
const groups: Record<string, FixPlanGroupState> = {};
for (const [group_id, entries] of Object.entries(fp_groups)) {
const root_cause = (entries[0].result as TriageEntryResult).root_cause;
groups[group_id] = {
group_id,
root_cause,
entry_count: entries.length,
sub_phase: "planning",
plans_written: 0,
synthesis_written: false,
reviews_written: 0,
task_file: null,
};
}
state.fix_planning = {
fix_plans_dir: path.join(triage_dir, "fix_plans"),
groups,
};
}
// ===== Result File Merging =====
/**
* Merge per-entry result files from triage_state/results/ into the state.
*
* Each sub-agent writes its result to {triage_dir}/results/{entry_index}.json.
* This function scans that directory, parses results, and updates the
* corresponding entries in the state. Returns the count of entries merged.
*/
export function merge_result_files(state: TriageState, triage_dir: string): number {
const results_dir = path.join(triage_dir, "results");
if (!fs.existsSync(results_dir)) return 0;
const files = fs.readdirSync(results_dir).filter((f) => f.endsWith(".json"));
let merged = 0;
for (const file of files) {
const basename = path.basename(file, ".json");
const entry_index = parseInt(basename, 10);
if (isNaN(entry_index)) continue;
const entry = state.entries.find((e) => e.entry_index === entry_index);
if (!entry) continue;
if (entry.status === "completed") continue;
const file_path = path.join(results_dir, file);
try {
const raw = fs.readFileSync(file_path, "utf8");
const result = JSON.parse(raw) as TriageEntryResult;
entry.result = result;
entry.status = "completed";
} catch (err) {
entry.status = "failed";
entry.error = `Failed to parse result file: ${err}`;
}
entry.attempt_count++;
merged++;
}
return merged;
}
// ===== Phase Handlers =====
export function handle_triage(state: TriageState, triage_dir: string, state_path: string): PhaseResult {
let mutated = false;
const files_merged = merge_result_files(state, triage_dir);
if (files_merged > 0) {
mutated = true;
log(`Merged ${files_merged} result files`);
}
const pending = state.entries.filter((e) => e.status === "pending");
if (pending.length > 0) {
const batch = Math.min(pending.length, state.batch_size);
return {
decision: "block",
reason:
`${pending.length} entries need triage. ` +
`Read state file at ${state_path}, find the next ${batch} pending entries, ` +
"and launch **triage-investigator** sub-agents **in background** for each.",
mutated,
};
}
state.phase = "aggregation";
return {
decision: "block",
reason:
"All entries triaged. Phase transitioned to aggregation. " +
`Launch **triage-aggregator** sub-agent with state file at ${state_path}.`,
mutated: true,
};
}
export function handle_aggregation(state: TriageState, state_path: string): PhaseResult {
if (state.aggregation === null || state.aggregation.status === "pending") {
return {
decision: "block",
reason:
"Aggregation phase active. " +
`Launch **triage-aggregator** sub-agent with state file at ${state_path}.`,
mutated: false,
};
}
if (state.aggregation.status === "failed") {
log("Aggregation failed, moving to complete");
state.phase = "complete";
return {
decision: "allow",
reason: "Aggregation failed. Pipeline complete with errors.",
mutated: true,
};
}
// status === "completed"
const fp_entries = get_escape_hatch_fp_entries(state);
if (fp_entries.length > 0) {
state.phase = "meta-review";
return {
decision: "block",
reason:
`Aggregation complete. ${fp_entries.length} false-positive entries found. ` +
`Phase transitioned to meta-review. Launch **triage-rule-reviewer** sub-agent with state file at ${state_path}.`,
mutated: true,
};
}
state.phase = "complete";
return {
decision: "allow",
reason: "Aggregation complete. No false positives found. Pipeline complete.",
mutated: true,
};
}
export function handle_meta_review(state: TriageState, state_path: string): PhaseResult {
if (state.meta_review === null || state.meta_review.status === "pending") {
return {
decision: "block",
reason:
"Meta-review phase active. " +
`Launch **triage-rule-reviewer** sub-agent with state file at ${state_path}.`,
mutated: false,
};
}
if (state.meta_review.status === "failed") {
log("Meta-review failed, moving to complete");
state.phase = "complete";
return {
decision: "allow",
reason: "Meta-review failed. Pipeline complete with errors.",
mutated: true,
};
}
// status === "completed"
const fp_groups = get_multi_entry_fp_groups(state);
const group_ids = Object.keys(fp_groups);
if (group_ids.length > 0) {
const triage_dir = path.dirname(state_path);
state.phase = "fix-planning";
init_fix_planning(state, fp_groups, triage_dir);
return {
decision: "block",
reason:
`Meta-review complete. ${group_ids.length} multi-entry FP groups found. ` +
`Phase transitioned to fix-planning. Read state file at ${state_path} and begin fix planning.`,
mutated: true,
};
}
state.phase = "complete";
return {
decision: "allow",
reason: "Meta-review complete. No multi-entry FP groups. Pipeline complete.",
mutated: true,
};
}
export function handle_fix_planning(state: TriageState): PhaseResult {
const planning = state.fix_planning as FixPlanningState;
const group_ids = Object.keys(planning.groups);
for (const group_id of group_ids) {
const group = planning.groups[group_id];
if (group.sub_phase === "complete") continue;
switch (group.sub_phase) {
case "planning":
if (group.plans_written < REQUIRED_PLANS) {
return {
decision: "block",
reason:
`Fix planning: group "${group_id}" needs plans. ` +
`${group.plans_written}/${REQUIRED_PLANS} plans written. ` +
`Launch **fix-planner** sub-agents for group "${group_id}". ` +
`Write plans to \`${planning.fix_plans_dir}/${group_id}/plan_{n}.md\`.`,
mutated: false,
};
}
group.sub_phase = "synthesis";
return {
decision: "block",
reason:
`Fix planning: group "${group_id}" plans complete. ` +
`Phase transitioned to synthesis. Launch **plan-synthesizer** sub-agent for group "${group_id}". ` +
`Read plans from \`${planning.fix_plans_dir}/${group_id}/\`.`,
mutated: true,
};
case "synthesis":
if (!group.synthesis_written) {
return {
decision: "block",
reason:
`Fix planning: group "${group_id}" needs synthesis. ` +
`Launch **plan-synthesizer** sub-agent for group "${group_id}". ` +
`Read plans from \`${planning.fix_plans_dir}/${group_id}/\`.`,
mutated: false,
};
}
group.sub_phase = "review";
return {
decision: "block",
reason:
`Fix planning: group "${group_id}" synthesis complete. ` +
`Phase transitioned to review. Launch **plan-reviewer** sub-agents for group "${group_id}". ` +
`Write reviews to \`${planning.fix_plans_dir}/${group_id}/review_{angle}.md\`.`,
mutated: true,
};
case "review":
if (group.reviews_written < REQUIRED_REVIEWS) {
return {
decision: "block",
reason:
`Fix planning: group "${group_id}" needs reviews. ` +
`${group.reviews_written}/${REQUIRED_REVIEWS} reviews written. ` +
`Launch **plan-reviewer** sub-agents for group "${group_id}". ` +
`Write reviews to \`${planning.fix_plans_dir}/${group_id}/review_{angle}.md\`.`,
mutated: false,
};
}
group.sub_phase = "task-writing";
return {
decision: "block",
reason:
`Fix planning: group "${group_id}" reviews complete. ` +
`Phase transitioned to task-writing. Launch **task-writer** sub-agent for group "${group_id}".`,
mutated: true,
};
case "task-writing":
if (!group.task_file) {
return {
decision: "block",
reason:
`Fix planning: group "${group_id}" needs task file. ` +
`Launch **task-writer** sub-agent for group "${group_id}".`,
mutated: false,
};
}
group.sub_phase = "complete";
// Continue to check next group
break;
}
}
// All groups complete
state.phase = "complete";
return {
decision: "allow",
reason: "All fix planning groups complete. Pipeline complete.",
mutated: true,
};
}
// ===== Main =====
function main(): void {
log("Triage loop stop hook started");
const input = parse_stdin();
if (input && input.stop_hook_active) {
log("Skipping - already running from stop hook (stop_hook_active=true)");
return;
}
const triage_dir = TRIAGE_STATE_DIR;
const state_path = discover_state_file(triage_dir);
if (!state_path) {
log("No triage state file found, allowing stop");
return;
}
let state: TriageState;
try {
const raw = fs.readFileSync(state_path, "utf8");
state = JSON.parse(raw) as TriageState;
} catch (err) {
log(`Failed to parse state file: ${err}`);
return;
}
if (state.phase === "complete") {
log("Pipeline already complete, allowing stop");
return;
}
let result: PhaseResult;
switch (state.phase) {
case "triage":
result = handle_triage(state, triage_dir, state_path);
break;
case "aggregation":
result = handle_aggregation(state, state_path);
break;
case "meta-review":
result = handle_meta_review(state, state_path);
break;
case "fix-planning":
result = handle_fix_planning(state);
break;
default:
log(`Unknown phase: ${state.phase}`);
return;
}
if (result.mutated) {
state.updated_at = new Date().toISOString();
fs.writeFileSync(state_path, JSON.stringify(state, null, 2) + "\n");
log(`State updated: phase=${state.phase}`);
}
if (result.decision === "block") {
log(`Blocking: ${result.reason}`);
console.log(JSON.stringify({ decision: "block", reason: result.reason }));
} else {
log(`Allowing: ${result.reason}`);
}
}
// Only run main() when executed directly, not when imported by tests
const this_file = fileURLToPath(import.meta.url);
if (process.argv[1] && path.resolve(process.argv[1]) === this_file) {
main();
}
import * as fs from "node:fs/promises";
import path from "path";
import { ANALYSIS_OUTPUT_DIR } from "./paths.js";
// ===== Output Type =====
export enum OutputType {
DETECT_ENTRYPOINTS = "detect_entrypoints",
TRIAGE_RESULTS = "triage_results"
}
/**
* Save JSON file with formatting to structured output directory
* Returns the absolute path to the saved file
*/
export async function save_json(
output_type: OutputType,
data: unknown,
project_name: string
): Promise<string> {
const timestamp = new Date().toISOString().replace(/:/g, "-");
const output_dir = path.join(ANALYSIS_OUTPUT_DIR, project_name, output_type);
await fs.mkdir(output_dir, { recursive: true });
const file_path = path.join(output_dir, `${timestamp}.json`);
await fs.writeFile(file_path, JSON.stringify(data, null, 2) + "\n", "utf-8");
return file_path;
}
/**
* Load JSON file
*/
export async function load_json<T>(file_path: string): Promise<T> {
const content = await fs.readFile(file_path, "utf-8");
return JSON.parse(content);
}
/**
* Find the most recent analysis file for a given output type
* Returns the absolute path to the file
*/
export async function find_most_recent_analysis(
project_name: string,
output_type: OutputType = OutputType.DETECT_ENTRYPOINTS
): Promise<string> {
const target_dir = path.join(ANALYSIS_OUTPUT_DIR, project_name, output_type);
try {
const files = await fs.readdir(target_dir);
const json_files = files.filter((file) => file.endsWith(".json"));
if (json_files.length === 0) {
throw new Error(
`No analysis files found in ${target_dir}. Run detect_entrypoints.ts first.`
);
}
json_files.sort();
const most_recent = json_files[json_files.length - 1];
return path.join(target_dir, most_recent);
} catch (error) {
if ((error as { code?: string }).code === "ENOENT") {
throw new Error(
`Analysis output directory not found: ${target_dir}. Run detect_entrypoints.ts first.`
);
}
throw error;
}
}
import { describe, it, expect } from "vitest";
import {
build_finalization_output,
build_finalization_summary,
type FinalizationOutput,
type FinalizationSummary,
} from "./build_finalization_output.js";
import type { TriageState, TriageEntry, TriageEntryResult } from "./triage_state_types.js";
import type { FalsePositiveEntry, FalsePositiveGroup } from "./types.js";
// ===== Test Helpers =====
function make_result(overrides: Partial<TriageEntryResult> = {}): TriageEntryResult {
return {
is_true_positive: false,
is_likely_dead_code: false,
group_id: "some-group",
root_cause: "Some root cause",
reasoning: "Some reasoning",
...overrides,
};
}
let entry_counter = 0;
function make_entry(overrides: Partial<TriageEntry> = {}): TriageEntry {
const idx = overrides.entry_index ?? entry_counter++;
return Object.assign(
{
entry_index: idx,
name: "test_func",
file_path: "/projects/myapp/src/test.ts",
start_line: 10,
kind: "function",
signature: null,
route: "llm-triage" as const,
diagnosis: "no-textual-callers",
deterministic_group_id: null,
known_source: null,
status: "completed" as const,
result: make_result(),
error: null,
attempt_count: 1,
} satisfies TriageEntry,
overrides,
{ entry_index: idx },
);
}
function make_state(overrides: Partial<TriageState> = {}): TriageState {
return {
project_name: "test-project",
project_path: "/projects/myapp",
analysis_file: "/projects/myapp/analysis.json",
phase: "complete",
batch_size: 5,
entries: [],
aggregation: { status: "completed", completed_at: "2026-01-01T00:00:00Z" },
meta_review: { status: "completed", completed_at: "2026-01-01T00:00:00Z", patterns: null },
fix_planning: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-15T00:00:00Z",
...overrides,
};
}
// ===== Tests =====
describe("build_finalization_output", () => {
it("all true positives → only true_positives populated", () => {
const state = make_state({
entries: [
make_entry({
name: "main",
file_path: "/projects/myapp/src/main.ts",
start_line: 1,
signature: "function main(): void",
result: make_result({ is_true_positive: true }),
}),
make_entry({
name: "handler",
file_path: "/projects/myapp/src/handler.ts",
start_line: 5,
result: make_result({ is_true_positive: true }),
}),
],
});
const output = build_finalization_output(state);
const expected: FinalizationOutput = {
true_positives: [
{ name: "main", file_path: "/projects/myapp/src/main.ts", start_line: 1, signature: "function main(): void" },
{ name: "handler", file_path: "/projects/myapp/src/handler.ts", start_line: 5 },
],
dead_code: [],
groups: {},
last_updated: "2026-01-15T00:00:00Z",
};
expect(output).toEqual(expected);
});
it("all dead code → only dead_code populated", () => {
const state = make_state({
entries: [
make_entry({
name: "unused_a",
file_path: "/projects/myapp/src/a.ts",
start_line: 20,
result: make_result({ is_likely_dead_code: true }),
}),
make_entry({
name: "unused_b",
file_path: "/projects/myapp/src/b.ts",
start_line: 30,
signature: "function unused_b(): string",
result: make_result({ is_likely_dead_code: true }),
}),
],
});
const output = build_finalization_output(state);
const expected: FinalizationOutput = {
true_positives: [],
dead_code: [
{ name: "unused_a", file_path: "/projects/myapp/src/a.ts", start_line: 20 },
{ name: "unused_b", file_path: "/projects/myapp/src/b.ts", start_line: 30, signature: "function unused_b(): string" },
],
groups: {},
last_updated: "2026-01-15T00:00:00Z",
};
expect(output).toEqual(expected);
});
it("mixed: true positives, dead code, and false positive groups", () => {
const state = make_state({
entries: [
make_entry({
name: "main",
result: make_result({ is_true_positive: true }),
}),
make_entry({
name: "unused_func",
result: make_result({ is_likely_dead_code: true }),
}),
make_entry({
name: "builder_a",
file_path: "/projects/myapp/src/builder.ts",
start_line: 42,
result: make_result({
group_id: "builder-chain",
root_cause: "Builder method chain",
reasoning: "Method chaining pattern",
}),
}),
make_entry({
name: "builder_b",
file_path: "/projects/myapp/src/builder.ts",
start_line: 60,
result: make_result({
group_id: "builder-chain",
root_cause: "Builder method chain",
reasoning: "Method chaining pattern",
}),
}),
],
});
const output = build_finalization_output(state);
expect(output.true_positives).toHaveLength(1);
expect(output.true_positives[0].name).toBe("main");
expect(output.dead_code).toHaveLength(1);
expect(output.dead_code[0].name).toBe("unused_func");
const expected_group: FalsePositiveGroup = {
group_id: "builder-chain",
root_cause: "Builder method chain",
reasoning: "Method chaining pattern",
existing_task_fixes: [],
entries: [
{ name: "builder_a", file_path: "/projects/myapp/src/builder.ts", start_line: 42 },
{ name: "builder_b", file_path: "/projects/myapp/src/builder.ts", start_line: 60 },
],
};
expect(output.groups).toEqual({ "builder-chain": expected_group });
});
it("failed entries excluded from output", () => {
const state = make_state({
entries: [
make_entry({
name: "good_func",
result: make_result({ is_true_positive: true }),
}),
make_entry({
name: "failed_func",
status: "failed",
result: null,
error: "LLM timeout",
}),
make_entry({
name: "null_result_func",
status: "completed",
result: null,
}),
],
});
const output = build_finalization_output(state);
expect(output.true_positives).toHaveLength(1);
expect(output.true_positives[0].name).toBe("good_func");
expect(output.dead_code).toHaveLength(0);
expect(output.groups).toEqual({});
});
it("fix planning task files populate existing_task_fixes", () => {
const state = make_state({
entries: [
make_entry({
name: "fp_entry",
result: make_result({
group_id: "method-chain",
root_cause: "Unresolved method chain",
reasoning: "Chain not tracked",
}),
}),
],
fix_planning: {
fix_plans_dir: "/plans",
groups: {
"method-chain": {
group_id: "method-chain",
root_cause: "Unresolved method chain",
entry_count: 1,
sub_phase: "complete",
plans_written: 1,
synthesis_written: true,
reviews_written: 1,
task_file: "backlog/tasks/task-200.md",
},
},
},
});
const output = build_finalization_output(state);
const expected_group: FalsePositiveGroup = {
group_id: "method-chain",
root_cause: "Unresolved method chain",
reasoning: "Chain not tracked",
existing_task_fixes: ["backlog/tasks/task-200.md"],
entries: [
{ name: "fp_entry", file_path: "/projects/myapp/src/test.ts", start_line: 10 },
],
};
expect(output.groups).toEqual({ "method-chain": expected_group });
});
it("empty entries → empty output", () => {
const state = make_state({ entries: [] });
const output = build_finalization_output(state);
const expected: FinalizationOutput = {
true_positives: [],
dead_code: [],
groups: {},
last_updated: "2026-01-15T00:00:00Z",
};
expect(output).toEqual(expected);
});
it("uses updated_at as last_updated", () => {
const state = make_state({
entries: [],
updated_at: "2026-02-18T12:00:00Z",
});
const output = build_finalization_output(state);
expect(output.last_updated).toBe("2026-02-18T12:00:00Z");
});
});
describe("build_finalization_summary", () => {
it("summary statistics match output", () => {
const state = make_state({
entries: [
make_entry({ name: "tp1", result: make_result({ is_true_positive: true }) }),
make_entry({ name: "tp2", result: make_result({ is_true_positive: true }) }),
make_entry({ name: "dc1", result: make_result({ is_likely_dead_code: true }) }),
make_entry({
name: "fp1",
result: make_result({ group_id: "group-a", root_cause: "A", reasoning: "A" }),
}),
make_entry({
name: "fp2",
result: make_result({ group_id: "group-b", root_cause: "B", reasoning: "B" }),
}),
make_entry({ name: "fail1", status: "failed", result: null, error: "timeout" }),
],
});
const output = build_finalization_output(state);
const summary = build_finalization_summary(state, output);
const expected: FinalizationSummary = {
total_entries: 6,
true_positive_count: 2,
dead_code_count: 1,
false_positive_count: 2,
group_count: 2,
failed_count: 1,
task_files: [],
};
expect(summary).toEqual(expected);
});
it("task_files collected from groups with fix planning", () => {
const state = make_state({
entries: [
make_entry({
name: "fp1",
result: make_result({ group_id: "g1", root_cause: "R1", reasoning: "R1" }),
}),
make_entry({
name: "fp2",
result: make_result({ group_id: "g2", root_cause: "R2", reasoning: "R2" }),
}),
],
fix_planning: {
fix_plans_dir: "/plans",
groups: {
"g1": {
group_id: "g1",
root_cause: "R1",
entry_count: 1,
sub_phase: "complete",
plans_written: 1,
synthesis_written: true,
reviews_written: 1,
task_file: "backlog/tasks/task-201.md",
},
"g2": {
group_id: "g2",
root_cause: "R2",
entry_count: 1,
sub_phase: "complete",
plans_written: 1,
synthesis_written: true,
reviews_written: 1,
task_file: null,
},
},
},
});
const output = build_finalization_output(state);
const summary = build_finalization_summary(state, output);
expect(summary.task_files).toEqual(["backlog/tasks/task-201.md"]);
});
it("empty state produces zeroed summary", () => {
const state = make_state({ entries: [] });
const output = build_finalization_output(state);
const summary = build_finalization_summary(state, output);
const expected: FinalizationSummary = {
total_entries: 0,
true_positive_count: 0,
dead_code_count: 0,
false_positive_count: 0,
group_count: 0,
failed_count: 0,
task_files: [],
};
expect(summary).toEqual(expected);
});
});
/**
* Convert completed TriageState into finalization output.
*
* Partitions entries by classification (true positive, dead code,
* false positive group) and builds the canonical output shape for
* save_json and registry updates.
*/
import type { FalsePositiveEntry, FalsePositiveGroup } from "./types.js";
import type { TriageState, TriageEntry } from "./triage_state_types.js";
// ===== Output Types =====
export interface FinalizationOutput {
true_positives: FalsePositiveEntry[];
dead_code: FalsePositiveEntry[];
groups: Record<string, FalsePositiveGroup>;
last_updated: string;
}
export interface FinalizationSummary {
total_entries: number;
true_positive_count: number;
dead_code_count: number;
false_positive_count: number;
group_count: number;
failed_count: number;
task_files: string[];
}
// ===== Pure Functions =====
function entry_to_fp_entry(entry: TriageEntry): FalsePositiveEntry {
const result: FalsePositiveEntry = {
name: entry.name,
file_path: entry.file_path,
start_line: entry.start_line,
};
if (entry.signature !== null) {
result.signature = entry.signature;
}
return result;
}
export function build_finalization_output(state: TriageState): FinalizationOutput {
const true_positives: FalsePositiveEntry[] = [];
const dead_code: FalsePositiveEntry[] = [];
const groups: Record<string, FalsePositiveGroup> = {};
for (const entry of state.entries) {
if (entry.status === "failed" || entry.result === null) {
continue;
}
const result = entry.result;
if (result.is_true_positive) {
true_positives.push(entry_to_fp_entry(entry));
} else if (result.is_likely_dead_code) {
dead_code.push(entry_to_fp_entry(entry));
} else {
const group_id = result.group_id;
if (!(group_id in groups)) {
const task_file = state.fix_planning?.groups[group_id]?.task_file ?? null;
groups[group_id] = {
group_id,
root_cause: result.root_cause,
reasoning: result.reasoning,
existing_task_fixes: task_file ? [task_file] : [],
entries: [],
};
}
groups[group_id].entries.push(entry_to_fp_entry(entry));
}
}
return {
true_positives,
dead_code,
groups,
last_updated: state.updated_at,
};
}
export function build_finalization_summary(
state: TriageState,
output: FinalizationOutput,
): FinalizationSummary {
const false_positive_count = Object.values(output.groups)
.reduce((sum, g) => sum + g.entries.length, 0);
const failed_count = state.entries
.filter(e => e.status === "failed" || (e.status === "completed" && e.result === null))
.length;
const task_files = Object.values(output.groups)
.flatMap(g => g.existing_task_fixes);
return {
total_entries: state.entries.length,
true_positive_count: output.true_positives.length,
dead_code_count: output.dead_code.length,
false_positive_count,
group_count: Object.keys(output.groups).length,
failed_count,
task_files,
};
}
import { describe, it, expect } from "vitest";
import { build_triage_entries } from "./build_triage_entries.js";
import type { PreClassificationResult } from "./classify_entrypoints.js";
import type { EnrichedFunctionEntry } from "./types.js";
import type { TriageEntry, TriageEntryResult } from "./triage_state_types.js";
// ===== Test Helpers =====
function make_entry(overrides: Partial<EnrichedFunctionEntry>): EnrichedFunctionEntry {
return {
name: "test_func",
file_path: "/projects/myapp/src/test.ts",
start_line: 10,
start_column: 0,
end_line: 20,
end_column: 1,
kind: "function",
tree_size: 0,
is_exported: false,
is_anonymous: false,
call_summary: {
total_calls: 0,
unresolved_count: 0,
method_calls: 0,
constructor_calls: 0,
callback_invocations: 0,
},
diagnostics: {
grep_call_sites: [],
ariadne_call_refs: [],
diagnosis: "no-textual-callers",
},
...overrides,
};
}
const KNOWN_TP_RESULT: TriageEntryResult = {
is_true_positive: true,
is_likely_dead_code: false,
group_id: "true-positive",
root_cause: "Known true positive",
reasoning: "Matched known-entrypoints registry",
};
// ===== Tests =====
describe("build_triage_entries", () => {
it("known-tp entry from registry match", () => {
const entry = make_entry({ name: "main", file_path: "/projects/myapp/src/main.py" });
const classification: PreClassificationResult = {
known_true_positives: [{ entry, source: "project" }],
unclassified: [],
};
const result = build_triage_entries(classification);
const expected: TriageEntry[] = [{
entry_index: 0,
name: "main",
file_path: "/projects/myapp/src/main.py",
start_line: 10,
kind: "function",
signature: null,
route: "known-tp",
diagnosis: "no-textual-callers",
deterministic_group_id: null,
known_source: "project",
status: "completed",
result: KNOWN_TP_RESULT,
error: null,
attempt_count: 0,
}];
expect(result).toEqual(expected);
});
it("unclassified entry becomes llm-triage pending", () => {
const entry = make_entry({
name: "mystery_func",
signature: "def mystery_func(x: int) -> str",
diagnostics: {
grep_call_sites: [],
ariadne_call_refs: [],
diagnosis: "callers-not-in-registry",
},
});
const classification: PreClassificationResult = {
known_true_positives: [],
unclassified: [entry],
};
const result = build_triage_entries(classification);
const expected: TriageEntry[] = [{
entry_index: 0,
name: "mystery_func",
file_path: "/projects/myapp/src/test.ts",
start_line: 10,
kind: "function",
signature: "def mystery_func(x: int) -> str",
route: "llm-triage",
diagnosis: "callers-not-in-registry",
deterministic_group_id: null,
known_source: null,
status: "pending",
result: null,
error: null,
attempt_count: 0,
}];
expect(result).toEqual(expected);
});
it("mixed input: 1 known + 2 unclassified", () => {
const known = make_entry({ name: "render", kind: "method" });
const unclassified_a = make_entry({ name: "helper_a" });
const unclassified_b = make_entry({ name: "helper_b" });
const classification: PreClassificationResult = {
known_true_positives: [{ entry: known, source: "react" }],
unclassified: [unclassified_a, unclassified_b],
};
const result = build_triage_entries(classification);
expect(result).toHaveLength(3);
expect(result[0].entry_index).toBe(0);
expect(result[0].route).toBe("known-tp");
expect(result[0].known_source).toBe("react");
expect(result[0].status).toBe("completed");
expect(result[0].result).toEqual(KNOWN_TP_RESULT);
expect(result[1].entry_index).toBe(1);
expect(result[1].route).toBe("llm-triage");
expect(result[1].status).toBe("pending");
expect(result[1].result).toBe(null);
expect(result[2].entry_index).toBe(2);
expect(result[2].route).toBe("llm-triage");
expect(result[2].status).toBe("pending");
});
it("empty classification returns empty array", () => {
const classification: PreClassificationResult = {
known_true_positives: [],
unclassified: [],
};
const result = build_triage_entries(classification);
expect(result).toEqual([]);
});
});
/**
* Convert classify_entrypoints() output into TriageEntry[].
*
* Registry matches become known-tp (completed), everything else
* becomes llm-triage (pending).
*/
import type { PreClassificationResult } from "./classify_entrypoints.js";
import type { EnrichedFunctionEntry } from "./types.js";
import type { TriageEntry, TriageEntryResult } from "./triage_state_types.js";
function entry_to_triage_base(entry: EnrichedFunctionEntry): Pick<
TriageEntry,
"name" | "file_path" | "start_line" | "kind" | "signature" | "diagnosis"
> {
return {
name: entry.name,
file_path: entry.file_path,
start_line: entry.start_line,
kind: entry.kind,
signature: entry.signature ?? null,
diagnosis: entry.diagnostics.diagnosis,
};
}
const KNOWN_TP_RESULT: TriageEntryResult = {
is_true_positive: true,
is_likely_dead_code: false,
group_id: "true-positive",
root_cause: "Known true positive",
reasoning: "Matched known-entrypoints registry",
};
export function build_triage_entries(
classification: PreClassificationResult,
): TriageEntry[] {
const entries: TriageEntry[] = [];
let index = 0;
for (const match of classification.known_true_positives) {
entries.push({
entry_index: index++,
...entry_to_triage_base(match.entry),
route: "known-tp",
deterministic_group_id: null,
known_source: match.source,
status: "completed",
result: KNOWN_TP_RESULT,
error: null,
attempt_count: 0,
});
}
for (const entry of classification.unclassified) {
entries.push({
entry_index: index++,
...entry_to_triage_base(entry),
route: "llm-triage",
deterministic_group_id: null,
known_source: null,
status: "pending",
result: null,
error: null,
attempt_count: 0,
});
}
return entries;
}
import { describe, it, expect } from "vitest";
import { classify_entrypoints } from "./classify_entrypoints.js";
import type { EnrichedFunctionEntry, KnownEntrypointSource } from "./types.js";
// ===== Test Helpers =====
function make_entry(overrides: Partial<EnrichedFunctionEntry>): EnrichedFunctionEntry {
return {
name: "test_func",
file_path: "/projects/myapp/src/test.ts",
start_line: 10,
start_column: 0,
end_line: 20,
end_column: 1,
kind: "function",
tree_size: 0,
is_exported: false,
is_anonymous: false,
call_summary: {
total_calls: 0,
unresolved_count: 0,
method_calls: 0,
constructor_calls: 0,
callback_invocations: 0,
},
diagnostics: {
grep_call_sites: [],
ariadne_call_refs: [],
diagnosis: "no-textual-callers",
},
...overrides,
};
}
const PROJECT_PATH = "/projects/myapp";
// ===== Registry-based classification =====
describe("classify_entrypoints with known sources", () => {
it("matching entry goes to known_true_positives", () => {
const entry = make_entry({ name: "main", file_path: "/projects/myapp/src/main.py" });
const sources: KnownEntrypointSource[] = [{
source: "project",
description: "Confirmed entry points",
entrypoints: [{ name: "main", file_path: "src/main.py" }],
}];
const result = classify_entrypoints([entry], sources, PROJECT_PATH);
expect(result.known_true_positives).toHaveLength(1);
expect(result.known_true_positives[0].entry).toEqual(entry);
expect(result.known_true_positives[0].source).toBe("project");
expect(result.unclassified).toEqual([]);
});
it("non-matching entry goes to unclassified", () => {
const entry = make_entry({ name: "unknown_func" });
const sources: KnownEntrypointSource[] = [{
source: "project",
description: "Confirmed entry points",
entrypoints: [{ name: "main", file_path: "src/main.py" }],
}];
const result = classify_entrypoints([entry], sources, PROJECT_PATH);
expect(result.known_true_positives).toEqual([]);
expect(result.unclassified).toEqual([entry]);
});
it("empty known sources puts all entries in unclassified", () => {
const entry = make_entry({});
const result = classify_entrypoints([entry], [], PROJECT_PATH);
expect(result.known_true_positives).toEqual([]);
expect(result.unclassified).toEqual([entry]);
});
it("multiple sources: project match takes priority over framework", () => {
const entry = make_entry({ name: "render", kind: "method", file_path: "/projects/myapp/src/App.tsx" });
const sources: KnownEntrypointSource[] = [
{
source: "project",
description: "Confirmed entry points",
entrypoints: [{ name: "render", file_path: "src/App.tsx" }],
},
{
source: "react",
description: "React lifecycle methods",
entrypoints: [{ name: "render", kind: "method" }],
},
];
const result = classify_entrypoints([entry], sources, PROJECT_PATH);
expect(result.known_true_positives).toHaveLength(1);
// Project source listed first, so it matches first
expect(result.known_true_positives[0].source).toBe("project");
});
it("mixed entries: some match, some don't", () => {
const known_entry = make_entry({ name: "main", file_path: "/projects/myapp/src/main.py" });
const unknown_entry = make_entry({ name: "mystery_func" });
const framework_entry = make_entry({ name: "componentDidMount", kind: "method" });
const sources: KnownEntrypointSource[] = [
{
source: "project",
description: "Confirmed entry points",
entrypoints: [{ name: "main", file_path: "src/main.py" }],
},
{
source: "react",
description: "React lifecycle methods",
entrypoints: [{ name: "componentDidMount", kind: "method" }],
},
];
const result = classify_entrypoints(
[known_entry, unknown_entry, framework_entry],
sources,
PROJECT_PATH,
);
expect(result.known_true_positives).toHaveLength(2);
expect(result.known_true_positives[0].entry).toEqual(known_entry);
expect(result.known_true_positives[0].source).toBe("project");
expect(result.known_true_positives[1].entry).toEqual(framework_entry);
expect(result.known_true_positives[1].source).toBe("react");
expect(result.unclassified).toEqual([unknown_entry]);
});
});
/**
* Entry point classification using the known-entrypoints registry.
*
* Matches entries against known sources (project TPs, dead code, framework
* patterns). Entries that don't match any source are left unclassified
* for LLM triage.
*/
import type { EnrichedFunctionEntry, KnownEntrypointSource } from "./types.js";
import {
filter_known_entrypoints,
type KnownEntrypointMatch,
} from "./known_entrypoints.js";
// ===== Classification Result Types =====
export interface PreClassificationResult {
known_true_positives: KnownEntrypointMatch[];
unclassified: EnrichedFunctionEntry[];
}
// ===== Main Classification Function =====
/**
* Classify enriched entry points against the known-entrypoints registry.
*
* Entries matching any registry source become known true positives.
* Everything else goes to unclassified for LLM triage.
*/
export function classify_entrypoints(
entries: EnrichedFunctionEntry[],
known_sources: KnownEntrypointSource[],
project_path: string,
): PreClassificationResult {
const { known_true_positives, remaining } = filter_known_entrypoints(
entries,
known_sources,
project_path,
);
return { known_true_positives, unclassified: remaining };
}
import { describe, it, expect } from "vitest";
import { build_signature, count_tree_size, detect_language } from "./extract_entry_points.js";
import type {
AnyDefinition,
CallGraph,
CallableNode,
SymbolId,
SymbolName,
ScopeId,
FilePath,
Location,
CallReference,
Resolution,
} from "@ariadnejs/types";
// ===== Test Helpers =====
/** Branded type helper — avoids verbose `as X` on every string */
const sym = (s: string) => s as unknown as SymbolId;
const name = (s: string) => s as unknown as SymbolName;
const scope = (s: string) => s as unknown as ScopeId;
const fp = (s: string) => s as unknown as FilePath;
function make_location(file_path: string, start_line: number): Location {
return {
file_path: fp(file_path),
start_line,
start_column: 0,
end_line: start_line + 5,
end_column: 1,
};
}
function make_callable_node(
node_name: string,
symbol_id: string,
enclosed_calls: CallReference[] = [],
): CallableNode {
return {
symbol_id: sym(symbol_id),
name: name(node_name),
location: make_location("src/test.ts", 1),
definition: {
kind: "function",
name: name(node_name),
is_exported: false,
body_scope_id: scope("scope_1"),
} as AnyDefinition,
enclosed_calls,
is_test: false,
};
}
function make_call_ref(
call_name: string,
resolved_to: string[],
): CallReference {
return {
location: make_location("src/test.ts", 10),
name: name(call_name),
scope_id: scope("s1"),
call_type: "function",
resolutions: resolved_to.map((id) => ({
symbol_id: sym(id),
confidence: "certain" as const,
reason: { type: "direct" as const },
})),
is_callback_invocation: false,
};
}
// ===== build_signature =====
describe("build_signature", () => {
it("builds signature for function definition", () => {
// Use 'as unknown as AnyDefinition' to avoid needing full ParameterDefinition fields
const def = {
kind: "function",
name: name("process_data"),
is_exported: false,
body_scope_id: scope("s1"),
signature: {
parameters: [
{ name: "input", type: "string" },
{ name: "count", type: "number" },
],
return_type: "boolean",
},
} as unknown as AnyDefinition;
expect(build_signature(def)).toBe("process_data(input: string, count: number): boolean");
});
it("builds signature for method definition", () => {
const def = {
kind: "method",
name: name("get_value"),
access_modifier: "public",
static: false,
parameters: [
{ name: "key", type: "string" },
],
return_type: "any",
} as unknown as AnyDefinition;
expect(build_signature(def)).toBe("get_value(key: string): any");
});
it("builds signature for constructor definition", () => {
const def = {
kind: "constructor",
name: name("constructor"),
parameters: [
{ name: "config", type: "Config" },
],
} as unknown as AnyDefinition;
expect(build_signature(def)).toBe("constructor(config: Config)");
});
it("handles function with no parameters", () => {
const def = {
kind: "function",
name: name("init"),
is_exported: true,
body_scope_id: scope("s1"),
signature: {
parameters: [],
return_type: "void",
},
} as unknown as AnyDefinition;
expect(build_signature(def)).toBe("init(): void");
});
it("uses 'any' for parameters without type annotation", () => {
const def = {
kind: "function",
name: name("loose"),
is_exported: false,
body_scope_id: scope("s1"),
signature: {
parameters: [
{ name: "x" },
],
return_type: "string",
},
} as unknown as AnyDefinition;
expect(build_signature(def)).toBe("loose(x: any): string");
});
});
// ===== count_tree_size =====
describe("count_tree_size", () => {
it("returns 0 for a leaf node (no calls)", () => {
const nodes = new Map<SymbolId, CallableNode>();
const leaf = make_callable_node("leaf", "leaf_id");
nodes.set(sym("leaf_id"), leaf);
const call_graph: CallGraph = {
nodes,
entry_points: [sym("leaf_id")],
indirect_reachability: new Map(),
};
expect(count_tree_size(sym("leaf_id"), call_graph, new Set())).toBe(0);
});
it("counts direct callees", () => {
const nodes = new Map<SymbolId, CallableNode>();
const child1 = make_callable_node("child1", "child1_id");
const child2 = make_callable_node("child2", "child2_id");
const parent = make_callable_node("parent", "parent_id", [
make_call_ref("child1", ["child1_id"]),
make_call_ref("child2", ["child2_id"]),
]);
nodes.set(sym("parent_id"), parent);
nodes.set(sym("child1_id"), child1);
nodes.set(sym("child2_id"), child2);
const call_graph: CallGraph = {
nodes,
entry_points: [sym("parent_id")],
indirect_reachability: new Map(),
};
// parent calls 2 children, each adds 1 (the resolution) + 0 (leaf)
expect(count_tree_size(sym("parent_id"), call_graph, new Set())).toBe(2);
});
it("handles cycles without infinite recursion", () => {
const nodes = new Map<SymbolId, CallableNode>();
const a = make_callable_node("a", "a_id", [make_call_ref("b", ["b_id"])]);
const b = make_callable_node("b", "b_id", [make_call_ref("a", ["a_id"])]);
nodes.set(sym("a_id"), a);
nodes.set(sym("b_id"), b);
const call_graph: CallGraph = {
nodes,
entry_points: [sym("a_id")],
indirect_reachability: new Map(),
};
// a → b (count 1 for resolution + recurse b) → b → a (count 1 for resolution, already visited = 0) = 2
expect(count_tree_size(sym("a_id"), call_graph, new Set())).toBe(2);
});
});
// ===== detect_language =====
describe("detect_language", () => {
it("detects TypeScript files", () => {
expect(detect_language("src/index.ts")).toBe("typescript");
expect(detect_language("src/component.tsx")).toBe("typescript");
});
it("detects JavaScript files", () => {
expect(detect_language("lib/utils.js")).toBe("javascript");
expect(detect_language("src/app.jsx")).toBe("javascript");
});
it("detects Python files", () => {
expect(detect_language("main.py")).toBe("python");
});
it("detects Rust files", () => {
expect(detect_language("src/lib.rs")).toBe("rust");
});
it("returns null for unsupported file types", () => {
expect(detect_language("main.go")).toBeNull();
expect(detect_language("App.java")).toBeNull();
expect(detect_language("lib.cpp")).toBeNull();
expect(detect_language("README.md")).toBeNull();
expect(detect_language("style.css")).toBeNull();
});
});
import { describe, it, expect, afterEach } from "vitest";
import * as fs from "node:fs/promises";
import * as path from "path";
import {
get_registry_path,
load_known_entrypoints,
save_known_entrypoints,
matches_known_entrypoint,
filter_known_entrypoints,
build_project_source,
build_dead_code_source,
} from "./known_entrypoints.js";
import type { EnrichedFunctionEntry, KnownEntrypointSource } from "./types.js";
// ===== Test Helpers =====
const TEST_PROJECT = "__test_known_entrypoints__";
function make_entry(overrides: Partial<EnrichedFunctionEntry>): EnrichedFunctionEntry {
return {
name: "test_func",
file_path: "/projects/myapp/src/test.ts",
start_line: 10,
start_column: 0,
end_line: 20,
end_column: 1,
kind: "function",
tree_size: 0,
is_exported: false,
is_anonymous: false,
call_summary: {
total_calls: 0,
unresolved_count: 0,
method_calls: 0,
constructor_calls: 0,
callback_invocations: 0,
},
diagnostics: {
grep_call_sites: [],
ariadne_call_refs: [],
diagnosis: "no-textual-callers",
},
...overrides,
};
}
const PROJECT_PATH = "/projects/myapp";
// Clean up test registry files after each test
afterEach(async () => {
const test_path = get_registry_path(TEST_PROJECT);
try {
await fs.unlink(test_path);
} catch {
// File may not exist
}
});
// ===== I/O =====
describe("registry I/O", () => {
it("load non-existent registry returns empty array", async () => {
const sources = await load_known_entrypoints("__nonexistent_project__");
expect(sources).toEqual([]);
});
it("save and load round-trips correctly", async () => {
const sources: KnownEntrypointSource[] = [
{
source: "project",
description: "Confirmed entry points",
entrypoints: [
{ name: "main", file_path: "src/main.py", start_line: 10 },
],
},
{
source: "react",
description: "React lifecycle methods",
entrypoints: [
{ name: "render", kind: "method" },
],
},
];
const saved_path = await save_known_entrypoints(TEST_PROJECT, sources);
expect(saved_path).toBe(get_registry_path(TEST_PROJECT));
const loaded = await load_known_entrypoints(TEST_PROJECT);
expect(loaded).toEqual(sources);
});
});
// ===== Matching =====
describe("matches_known_entrypoint", () => {
it("project entry: matches by name + relative file_path", () => {
const entry = make_entry({ name: "main", file_path: "/projects/myapp/src/main.py" });
const known = { name: "main", file_path: "src/main.py" };
expect(matches_known_entrypoint(entry, known, PROJECT_PATH)).toBe(true);
});
it("project entry: wrong file_path does not match", () => {
const entry = make_entry({ name: "main", file_path: "/projects/myapp/src/other.py" });
const known = { name: "main", file_path: "src/main.py" };
expect(matches_known_entrypoint(entry, known, PROJECT_PATH)).toBe(false);
});
it("framework entry: matches by name only (no file_path)", () => {
const entry = make_entry({ name: "render", kind: "method", file_path: "/projects/myapp/src/App.tsx" });
const known = { name: "render" };
expect(matches_known_entrypoint(entry, known, PROJECT_PATH)).toBe(true);
});
it("framework entry: matches by name + kind filter", () => {
const entry = make_entry({ name: "componentDidMount", kind: "method" });
const known = { name: "componentDidMount", kind: "method" as const };
expect(matches_known_entrypoint(entry, known, PROJECT_PATH)).toBe(true);
});
it("framework entry: wrong kind does not match", () => {
const entry = make_entry({ name: "componentDidMount", kind: "function" });
const known = { name: "componentDidMount", kind: "method" as const };
expect(matches_known_entrypoint(entry, known, PROJECT_PATH)).toBe(false);
});
it("monorepo entry: matches when file_path includes intermediate directories", () => {
const entry = make_entry({
name: "initialize",
file_path: "/workspace/repo/packages/core/src/project/project.ts",
});
const known = { name: "initialize", file_path: "packages/core/src/project/project.ts" };
expect(matches_known_entrypoint(entry, known, "/workspace/repo")).toBe(true);
});
});
// ===== Filtering =====
describe("filter_known_entrypoints", () => {
it("correctly partitions known TPs vs remaining", () => {
const known_entry = make_entry({ name: "main", file_path: "/projects/myapp/src/main.py" });
const unknown_entry = make_entry({ name: "mystery" });
const sources: KnownEntrypointSource[] = [{
source: "project",
description: "test",
entrypoints: [{ name: "main", file_path: "src/main.py" }],
}];
const result = filter_known_entrypoints([known_entry, unknown_entry], sources, PROJECT_PATH);
expect(result.known_true_positives).toHaveLength(1);
expect(result.known_true_positives[0].entry).toEqual(known_entry);
expect(result.known_true_positives[0].source).toBe("project");
expect(result.remaining).toEqual([unknown_entry]);
});
it("empty sources puts all entries in remaining", () => {
const entry = make_entry({});
const result = filter_known_entrypoints([entry], [], PROJECT_PATH);
expect(result.known_true_positives).toEqual([]);
expect(result.remaining).toEqual([entry]);
});
});
// ===== Building sources from triage results =====
describe("build_project_source", () => {
it("converts absolute paths to relative", () => {
const true_positives = [
{ name: "main", file_path: "/projects/myapp/src/main.py", start_line: 10 },
{ name: "handler", file_path: "/projects/myapp/lib/handler.py", start_line: 5 },
];
const source = build_project_source(true_positives, PROJECT_PATH);
expect(source).toEqual({
source: "project",
description: "Confirmed entry points from triage",
entrypoints: [
{ name: "main", file_path: "src/main.py", start_line: 10 },
{ name: "handler", file_path: "lib/handler.py", start_line: 5 },
],
});
});
});
describe("build_dead_code_source", () => {
it("converts absolute paths to relative with dead-code source", () => {
const dead_code = [
{ name: "unused_helper", file_path: "/projects/myapp/src/utils.py", start_line: 42 },
];
const source = build_dead_code_source(dead_code, PROJECT_PATH);
expect(source).toEqual({
source: "dead-code",
description: "Functions identified as likely dead code",
entrypoints: [
{ name: "unused_helper", file_path: path.relative(PROJECT_PATH, "/projects/myapp/src/utils.py"), start_line: 42 },
],
});
});
});
import path from "path";
import { fileURLToPath } from "url";
const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const STATE_DIR = path.resolve(SKILL_DIR, "../../self-repair-pipeline-state");
export const REGISTRY_DIR = path.join(STATE_DIR, "known_entrypoints");
export const ANALYSIS_OUTPUT_DIR = path.join(STATE_DIR, "analysis_output");
export const TRIAGE_STATE_DIR = path.join(STATE_DIR, "triage_state");
export const TRIAGE_PATTERNS_FILE = path.join(STATE_DIR, "triage_patterns.json");
/**
* State types for the self-repair triage pipeline.
*
* The triage state file tracks entry point candidates through phases:
* triage → aggregation → meta-review → fix-planning → complete.
*/
// ===== Top-Level State =====
export interface TriageState {
project_name: string;
project_path: string;
analysis_file: string;
phase: "triage" | "aggregation" | "meta-review" | "fix-planning" | "complete";
batch_size: number;
entries: TriageEntry[];
aggregation: AggregationResult | null;
meta_review: MetaReviewResult | null;
fix_planning: FixPlanningState | null;
created_at: string;
updated_at: string;
}
// ===== Per-Entry State =====
export type TriageRoute = "known-tp" | "deterministic-fp" | "llm-triage";
export interface TriageEntry {
entry_index: number;
name: string;
file_path: string;
start_line: number;
kind: string;
signature: string | null;
route: TriageRoute;
diagnosis: string;
deterministic_group_id: string | null;
known_source: string | null;
status: "pending" | "completed" | "failed";
result: TriageEntryResult | null;
error: string | null;
attempt_count: number;
}
export interface TriageEntryResult {
is_true_positive: boolean;
is_likely_dead_code: boolean;
group_id: string;
root_cause: string;
reasoning: string;
}
// ===== Fix Planning State =====
export interface FixPlanningState {
fix_plans_dir: string;
groups: Record<string, FixPlanGroupState>;
}
export interface FixPlanGroupState {
group_id: string;
root_cause: string;
entry_count: number;
sub_phase: "planning" | "synthesis" | "review" | "task-writing" | "complete";
plans_written: number;
synthesis_written: boolean;
reviews_written: number;
task_file: string | null;
}
// ===== Placeholder types (populated by later pipeline phases) =====
export interface AggregationResult {
status: "pending" | "completed" | "failed";
completed_at: string | null;
}
// ===== Meta-Review Types =====
export interface ProposedRule {
rule_id: string;
description: string;
confidence: "HIGH" | "MEDIUM" | "LOW";
classification: "true-positive" | "dead-code" | "false-positive";
group_id: string;
predicate: string;
matching_entries: number;
total_with_classification: number;
accuracy: number;
evidence: string;
}
export interface RuleReviewOutput {
proposed_rules: ProposedRule[];
summary: {
total_completed_entries: number;
rules_proposed: number;
entries_coverable_by_rules: number;
coverage_percentage: number;
};
}
export interface MetaReviewResult {
status: "pending" | "completed" | "failed";
completed_at: string | null;
patterns: RuleReviewOutput | null;
}