diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4beacba..1ed379c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: - name: Install portable pipeline shell: pwsh - run: .\install-code-intel-pipeline.ps1 -RepoPath . -RepairSkillLinks -Json + run: .\install-code-intel-pipeline.ps1 -RepoPath . -RepairSkillLinks -RequireRepowise:$false -Json - name: Add pipeline tools PATH shell: pwsh @@ -118,7 +118,7 @@ jobs: - name: Doctor shell: pwsh - run: .\check-code-intel-tools.ps1 -RepoPath . + run: .\check-code-intel-tools.ps1 -RepoPath . -RequireRepowise:$false - name: GitHub research contract tests shell: pwsh @@ -273,12 +273,12 @@ jobs: - name: Install portable pipeline shell: pwsh run: | - $result = ./install-code-intel-pipeline.ps1 -RepoPath . -RepairSkillLinks -Json | ConvertFrom-Json + $result = ./install-code-intel-pipeline.ps1 -RepoPath . -RepairSkillLinks -RequireRepowise:$false -Json | ConvertFrom-Json Add-Content -Path $env:GITHUB_PATH -Value $result.paths.bin - name: Doctor shell: pwsh - run: ./check-code-intel-tools.ps1 -RepoPath . -Json + run: ./check-code-intel-tools.ps1 -RepoPath . -RequireRepowise:$false -Json - name: Pipeline smoke shell: pwsh diff --git a/Install-SentruxVlangOverlay.ps1 b/Install-SentruxVlangOverlay.ps1 index 776352e..f2402ce 100644 --- a/Install-SentruxVlangOverlay.ps1 +++ b/Install-SentruxVlangOverlay.ps1 @@ -127,16 +127,31 @@ if (-not $NoReadOnlyLock) { } } +$validationStatus = "skipped" +$validationDetail = "" + if (-not $SkipValidate) { if (-not (Get-Command sentrux -ErrorAction SilentlyContinue)) { throw "sentrux CLI not found in PATH" } - & sentrux plugin validate $targetRoot + $validateOutput = & sentrux plugin validate $targetRoot 2>&1 + $validationDetail = ($validateOutput | ForEach-Object { $_.ToString() } | Out-String).Trim() if ($LASTEXITCODE -ne 0) { + if ($validationDetail -match "unknown command 'plugin'" -or $validationDetail -match "unknown command `"plugin`"") { + $validationStatus = "skipped" + $validationDetail = "current sentrux is lite/shim and has no plugin command" + } + else { throw "sentrux plugin validate failed for $targetRoot" + } + } + else { + $validationStatus = "passed" } } +$global:LASTEXITCODE = 0 + [pscustomobject][ordered]@{ status = "installed" plugin = "vlang" @@ -144,4 +159,6 @@ if (-not $SkipValidate) { target = $targetRoot backup = if (Test-Path -LiteralPath $backupPath) { $backupPath } else { $null } readOnlyLock = -not $NoReadOnlyLock + validation = $validationStatus + validationDetail = $validationDetail } | ConvertTo-Json -Depth 4 diff --git a/Invoke-CodeIntelOrchestrator.ps1 b/Invoke-CodeIntelOrchestrator.ps1 new file mode 100644 index 0000000..eee5b06 --- /dev/null +++ b/Invoke-CodeIntelOrchestrator.ps1 @@ -0,0 +1,187 @@ +param( + [ValidateSet("Validate", "List", "Plan")] + [string]$Action = "Validate", + + [string]$Capability = "", + [string]$RepoPath = "", + + [ValidateSet("lite", "normal", "full")] + [string]$Mode = "normal", + + [string]$Manifest = "", + [switch]$Json +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSCommandPath +if ([string]::IsNullOrWhiteSpace($Manifest)) { + $Manifest = Join-Path $root "orchestration\integrations.json" +} + +function Get-JsonProperty { + param( + [object]$Object, + [string]$Name + ) + + if ($null -eq $Object) { return $null } + $prop = $Object.PSObject.Properties[$Name] + if ($null -eq $prop) { return $null } + return $prop.Value +} + +function ConvertTo-Array { + param([object]$Value) + + if ($null -eq $Value) { return @() } + if ($Value -is [System.Array]) { return @($Value) } + return @($Value) +} + +function Expand-CommandTemplate { + param( + [string]$Template, + [string]$RepoPath, + [string]$Mode + ) + + $expanded = $Template.Replace("", $Mode) + if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { + $expanded = $expanded.Replace("", $RepoPath) + } + return $expanded +} + +if (-not (Test-Path -LiteralPath $Manifest -PathType Leaf)) { + throw "Orchestration manifest missing: $Manifest" +} + +$manifestData = Get-Content -LiteralPath $Manifest -Raw | ConvertFrom-Json +$stages = ConvertTo-Array (Get-JsonProperty $manifestData "stages") +$integrations = ConvertTo-Array (Get-JsonProperty $manifestData "integrations") + +$errors = New-Object System.Collections.Generic.List[string] +$stageIds = @{} +foreach ($stage in $stages) { + $id = [string](Get-JsonProperty $stage "id") + if ([string]::IsNullOrWhiteSpace($id)) { + $errors.Add("stage id is empty") + continue + } + if ($stageIds.ContainsKey($id)) { + $errors.Add("duplicate stage id: $id") + } + else { + $stageIds[$id] = $stage + } +} + +$integrationIds = @{} +foreach ($integration in $integrations) { + $id = [string](Get-JsonProperty $integration "id") + $stage = [string](Get-JsonProperty $integration "stage") + $entrypoint = [string](Get-JsonProperty $integration "entrypoint") + $capabilities = ConvertTo-Array (Get-JsonProperty $integration "capabilities") + + if ([string]::IsNullOrWhiteSpace($id)) { + $errors.Add("integration id is empty") + continue + } + if ($integrationIds.ContainsKey($id)) { + $errors.Add("duplicate integration id: $id") + } + else { + $integrationIds[$id] = $integration + } + if (-not $stageIds.ContainsKey($stage)) { + $errors.Add("integration $id references unknown stage: $stage") + } + if ([string]::IsNullOrWhiteSpace($entrypoint)) { + $errors.Add("integration $id has no entrypoint") + } + elseif ($entrypoint -like "*.ps1" -or $entrypoint -like "*.py" -or $entrypoint -like "*.toml" -or $entrypoint -like "*.rs") { + $candidate = Join-Path $root $entrypoint + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { + $errors.Add("integration $id entrypoint missing: $entrypoint") + } + } + if ($capabilities.Count -eq 0) { + $errors.Add("integration $id exposes no capabilities") + } +} + +$stageOrder = @{} +foreach ($stage in $stages) { + $stageOrder[[string](Get-JsonProperty $stage "id")] = [int](Get-JsonProperty $stage "order") +} + +$selected = @($integrations | Where-Object { + if ([string]::IsNullOrWhiteSpace($Capability)) { return $true } + $caps = ConvertTo-Array (Get-JsonProperty $_ "capabilities") + return ($caps -contains $Capability) -or ([string](Get-JsonProperty $_ "id") -eq $Capability) -or ([string](Get-JsonProperty $_ "stage") -eq $Capability) +} | Sort-Object @{ Expression = { $stageOrder[[string](Get-JsonProperty $_ "stage")] } }, @{ Expression = { [string](Get-JsonProperty $_ "id") } }) + +$plan = @($selected | ForEach-Object { + $commands = Get-JsonProperty $_ "commands" + $expandedCommands = [ordered]@{} + if ($null -ne $commands) { + foreach ($command in $commands.PSObject.Properties) { + $expandedCommands[$command.Name] = Expand-CommandTemplate ([string]$command.Value) $RepoPath $Mode + } + } + + [pscustomobject][ordered]@{ + id = [string](Get-JsonProperty $_ "id") + stage = [string](Get-JsonProperty $_ "stage") + kind = [string](Get-JsonProperty $_ "kind") + required = [bool](Get-JsonProperty $_ "required") + entrypoint = [string](Get-JsonProperty $_ "entrypoint") + capabilities = @(ConvertTo-Array (Get-JsonProperty $_ "capabilities")) + commands = $expandedCommands + artifactContract = @(ConvertTo-Array (Get-JsonProperty $_ "artifactContract")) + extensionPoint = [string](Get-JsonProperty $_ "extensionPoint") + } +}) + +$result = [pscustomobject][ordered]@{ + ok = $errors.Count -eq 0 + action = $Action + manifest = $Manifest + policy = Get-JsonProperty $manifestData "policy" + errors = @($errors) + stages = @($stages | Sort-Object @{ Expression = { [int](Get-JsonProperty $_ "order") } }) + integrations = if ($Action -eq "Validate") { @() } else { $plan } + plan = if ($Action -eq "Plan") { $plan } else { @() } +} + +if ($Json) { + $result | ConvertTo-Json -Depth 12 +} +else { + if (-not $result.ok) { + Write-Host "Code Intel orchestration: FAILED" + foreach ($errorText in $errors) { Write-Host "- $errorText" } + } + elseif ($Action -eq "Validate") { + Write-Host "Code Intel orchestration: OK" + Write-Host "Manifest: $Manifest" + Write-Host "Stages: $($stages.Count)" + Write-Host "Integrations: $($integrations.Count)" + } + else { + Write-Host "Code Intel orchestration: $Action" + foreach ($item in $plan) { + Write-Host "$($item.stage): $($item.id) [$($item.kind)] entry=$($item.entrypoint)" + if ($Action -eq "Plan") { + foreach ($command in $item.commands.GetEnumerator()) { + Write-Host " $($command.Key): $($command.Value)" + } + } + } + } +} + +if (-not $result.ok) { exit 1 } +exit 0 diff --git a/README.md b/README.md index d3f52f7..972d371 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ GPT 娘坐在空白处,不紧不慢。她把文件列成星图,把依赖连 - `Repowise` 记住项目语义,下一次不用从零开始。 - `CodeNexus-lite` 给 Agent 一个低成本的上下文入口。 - 治理层把这些信号收束成机器可读的下一步计划。 +- 编排层规定这些能力怎么融合,避免以后每接一个新项目就到处散写外部调用。 ## 适合谁 @@ -167,6 +168,9 @@ install -> doctor -> smoke test | 工具 | 角色 | 产物 | | --- | --- | --- | +| Integration orchestration | 融合注册、能力编排、扩展边界 | `target/debug/code-intel.exe orchestrate` | +| `code-intel` Rust CLI | orchestration、artifact resume、failure classify、artifact doctor | `target/debug/code-intel.exe` | +| `code-nexus-lite` Rust worker | CodeNexus scan/lite/doctor worker | `target/debug/code-nexus-lite.exe` | | `rg` | 快速文件清单、文本搜索 | `files.txt` | | `Repowise` | 语义索引、长期记忆、项目上下文 | `.repowise/` 或 scoped shadow | | `Repomix` | 把本地或远程仓库打包成 AI 友好的单文件上下文 | `repomix-output.md`、`repomix-summary.json` | @@ -177,6 +181,16 @@ install -> doctor -> smoke test 这几个工具分工不同。不要把它们混成一个 RAG 糊糊。 +新增项目或新方式时,先注册到 `orchestration/integrations.json`,再接 adapter。不要直接把新的外部 CLI 调用散进主流程。 + +查看当前编排: + +```powershell +cargo build -p code-intel +.\target\debug\code-intel.exe orchestrate --action Validate +.\target\debug\code-intel.exe orchestrate --action Plan --repo C:\path\to\your\repo --mode normal +``` + ## 输出在哪里 每次运行会创建一个带时间戳的目录: @@ -209,6 +223,9 @@ Skill quality guidance lives in Implementation minimalism guidance lives in [`docs/implementation-minimalism-benchmark.md`](docs/implementation-minimalism-benchmark.md). +Integration orchestration rules live in +[`docs/integration-orchestration.md`](docs/integration-orchestration.md). + Measured minimalism impact lives in [`docs/ponytail-impact-scoreboard.md`](docs/ponytail-impact-scoreboard.md). @@ -428,7 +445,7 @@ sentrux plugin list ## Repowise 语义记忆 -Repowise 是可选语义记忆层。默认单步超时 `180` 秒;超时会跳过,不拖死整轮: +Repowise 是硬依赖语义记忆层。默认单步超时 `180` 秒;超时会作为 Repowise 失败写进报告,不再静默跳过: ```powershell .\run-code-intel.ps1 -RepoPath C:\path\to\repo -Mode normal -RepowiseTimeoutSeconds 60 @@ -456,19 +473,34 @@ Repowise 是可选语义记忆层。默认单步超时 `180` 秒;超时会跳 graph_missing: understand graph ``` -在支持该技能的 Agent 中运行: +先运行项目内 Rust 图谱 provider: -```text -/understand C:\path\to\repo --language zh +```powershell +.\target\debug\code-intel.exe graph --repo C:\path\to\repo --language zh --write --json ``` 完整重建: -```text -/understand C:\path\to\repo --language zh --full +```powershell +.\target\debug\code-intel.exe graph --repo C:\path\to\repo --language zh --full --write --json ``` -然后重新运行 pipeline。 +然后重新运行 pipeline。`/understand C:\path\to\repo --language zh` 只作为兼容兜底,或在你明确需要外部 Understand Anything 更富图谱时使用。 + +## 全局 Provider Route + +Repowise 和 Understand-compatible graph 统一走 `code-intel provider` 规范,再由 `code-intel route` 暴露入口: + +```powershell +.\target\debug\code-intel.exe provider --action Validate --json +.\target\debug\code-intel.exe provider --action Plan --provider repowise --operation index --repo C:\path\to\repo --json +.\target\debug\code-intel.exe provider --action Plan --provider understand --operation graph --repo C:\path\to\repo --json +.\target\debug\code-intel.exe route --action List --json +.\target\debug\code-intel.exe route --action Plan --provider repowise --operation index --repo C:\path\to\repo --json +.\target\debug\code-intel.exe route --action Plan --provider understand --operation graph --repo C:\path\to\repo --json +``` + +HTTP route 使用命名空间:`/api/providers/repowise/*`、`/api/providers/understand/*`。旧的 `/scan`、`/lite`、`/doctor`、`/understand` 只能作为兼容入口。 ## 规则文件 @@ -602,8 +634,8 @@ CI 使用 Sentrux lite core 保底,所以 runner 没装真实 `sentrux` 时也 运行: -```text -/understand C:\path\to\repo --language zh +```powershell +.\target\debug\code-intel.exe graph --repo C:\path\to\repo --language zh --write --json ``` 再重跑 pipeline。 @@ -656,13 +688,14 @@ MIT ```powershell cargo build -p code-intel +.\target\debug\code-intel.exe orchestrate --action Validate --json +.\target\debug\code-intel.exe orchestrate --action Plan --repo C:\path\to\your\repo --mode normal --json .\target\debug\code-intel.exe resume --repo C:\path\to\your\repo .\target\debug\code-intel.exe resume --repo C:\path\to\your\repo --artifact-root C:\path\to\artifacts .\target\debug\code-intel.exe resume --repo C:\path\to\your\repo --json .\target\debug\code-intel.exe classify --report C:\path\to\artifact\report.json ``` -The Rust CLI is currently a cross-session artifact reader. It does not replace -the PowerShell scanner yet; it locates the latest artifact run, reads -`report.json` and `hospital-report.json`, then prints the next protocol and the -next file an Agent should read. +The Rust CLI owns integration orchestration and cross-session artifact reads. +PowerShell scripts remain Windows compatibility wrappers for scanner steps that +have not yet been absorbed into Rust. diff --git a/check-code-intel-tools.ps1 b/check-code-intel-tools.ps1 index ee8914c..f63501d 100644 --- a/check-code-intel-tools.ps1 +++ b/check-code-intel-tools.ps1 @@ -11,6 +11,10 @@ param( [switch]$Json ) +if (-not $PSBoundParameters.ContainsKey("RequireRepowise")) { + $RequireRepowise = $true +} + Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" @@ -142,6 +146,9 @@ if (Test-Path -LiteralPath $Config -PathType Leaf) { $pipelineRoot = Split-Path -Parent $PSCommandPath $pipelineScript = Join-Path $pipelineRoot "run-code-intel.ps1" +$codeIntelCargo = Join-Path $pipelineRoot "crates\code-intel-cli\Cargo.toml" +$codeIntelGraphSource = Join-Path $pipelineRoot "crates\code-intel-cli\src\graph.rs" +$codeIntelGraphBinary = Join-Path $pipelineRoot "target\debug\code-intel.exe" $repoConfig = Resolve-RepoConfig $Repo $configData $repoInput = if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { $RepoPath } else { $Repo } $repoPath = if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { @@ -224,6 +231,12 @@ $checks = [ordered]@{ pluginFound = [bool]$understandPlugin pluginPath = if ($understandPlugin) { [string]$understandPlugin } else { "" } } + graphProvider = [ordered]@{ + sourceFound = (Test-Path -LiteralPath $codeIntelGraphSource -PathType Leaf) + cargoFound = (Test-Path -LiteralPath $codeIntelCargo -PathType Leaf) + binaryFound = (Test-Path -LiteralPath $codeIntelGraphBinary -PathType Leaf) + command = "$codeIntelGraphBinary graph --repo --language zh --write --json" + } repo = $repoState env = [ordered]@{ codeIntelHome = [ordered]@{ @@ -242,8 +255,8 @@ foreach ($tool in $tools) { } if (-not $sentruxCore.found) { $missing.Add("sentrux core") } if (-not $sentruxPro.found) { $missing.Add("sentrux pro auto-activation") } -if ($RequireUnderstand -and -not $checks.understandAnything.skillFound) { $missing.Add("Understand Anything skill") } -if ($RequireUnderstand -and -not $checks.understandAnything.pluginFound) { $missing.Add("Understand Anything plugin") } +if ($RequireUnderstand -and -not $checks.graphProvider.sourceFound) { $missing.Add("internal graph provider source") } +if ($RequireUnderstand -and -not $checks.graphProvider.cargoFound) { $missing.Add("code-intel Rust runtime") } if ($repoState -and -not $repoState.exists) { $missing.Add("repo path") } $result = [ordered]@{ @@ -289,7 +302,9 @@ else { Write-Host "$coreMark sentrux-core $($sentruxCore.output)" Write-Host "$proMark sentrux-pro $($sentruxPro.output)" $uaMark = if ($checks.understandAnything.skillFound -and $checks.understandAnything.pluginFound) { "OK" } else { "MISSING" } - Write-Host "$uaMark Understand Anything skill=$($checks.understandAnything.skillPath) plugin=$($checks.understandAnything.pluginPath)" + $graphMark = if ($checks.graphProvider.sourceFound -and $checks.graphProvider.cargoFound) { "OK" } else { "MISSING" } + Write-Host "$graphMark internal graph provider source=$($checks.graphProvider.sourceFound) cargo=$($checks.graphProvider.cargoFound) binary=$($checks.graphProvider.binaryFound)" + Write-Host "$uaMark external Understand fallback skill=$($checks.understandAnything.skillPath) plugin=$($checks.understandAnything.pluginPath)" if ($repoState) { Write-Host "Repo: $($repoState.path)" Write-Host "Repo exists: $($repoState.exists)" diff --git a/crates/code-intel-cli/src/artifacts.rs b/crates/code-intel-cli/src/artifacts.rs new file mode 100644 index 0000000..066836e --- /dev/null +++ b/crates/code-intel-cli/src/artifacts.rs @@ -0,0 +1,404 @@ +use serde_json::Value; +use std::env; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +type Result = std::result::Result>; + +#[derive(Debug)] +struct ResumeSummary { + repo: PathBuf, + artifact_dir: PathBuf, + report_path: PathBuf, + summary_path: Option, + understanding_path: Option, + hospital_path: Option, + hospital_markdown: Option, + github_research_path: Option, + github_research_markdown: Option, + pipeline_failed: i64, + pipeline_manual_required: i64, + provider_quota: i64, + local_tool_error: i64, + graph_missing: i64, + sentrux_fail: i64, + hospital_status: String, + hospital_disposition: String, + hospital_next_protocol: String, + hospital_current_state: String, + hospital_primary_diagnosis: String, + research_status: String, + research_required: bool, +} + +pub(crate) fn resume(repo: &Path, artifact_root: Option<&Path>, json: bool) -> Result<()> { + let repo = absolute_existing_dir(repo)?; + let artifact_root = resolve_artifact_root(artifact_root)?; + let repo_name = repo + .file_name() + .and_then(|s| s.to_str()) + .ok_or("repo path has no final directory name")?; + let repo_artifacts = artifact_root.join(repo_name); + let artifact_dir = latest_run_dir(&repo_artifacts)?; + let report_path = artifact_dir.join("report.json"); + let report = read_json(&report_path)?; + let hospital_path = string_path(&report, &["hospital", "path"]).or_else(|| { + let candidate = artifact_dir.join("hospital-report.json"); + candidate.exists().then_some(candidate) + }); + let hospital = match hospital_path.as_ref() { + Some(path) if path.exists() => read_json(path)?, + _ => Value::Null, + }; + + let summary = build_resume_summary(repo, artifact_dir, report_path, &report, &hospital); + if json { + print_resume_json(&summary)?; + } else { + print_resume_text(&summary); + } + Ok(()) +} + +pub(crate) fn classify(report_path: &Path, json: bool) -> Result<()> { + let report = read_json(report_path)?; + let provider_quota = int_at(&report, &["summary", "failureCategories", "providerQuota"]); + let local_tool_error = int_at(&report, &["summary", "failureCategories", "localToolError"]); + let graph_missing = int_at(&report, &["summary", "failureCategories", "graphMissing"]); + let sentrux_fail = int_at(&report, &["summary", "failureCategories", "sentruxFail"]); + let research_required = provider_quota > 0 || local_tool_error > 0 || sentrux_fail > 0; + if json { + let out = serde_json::json!({ + "report": report_path, + "failureCategories": { + "providerQuota": provider_quota, + "localToolError": local_tool_error, + "graphMissing": graph_missing, + "sentruxFail": sentrux_fail + }, + "githubResearchRequired": research_required + }); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + println!("Report: {}", report_path.display()); + println!("providerQuota={provider_quota}"); + println!("localToolError={local_tool_error}"); + println!("graphMissing={graph_missing}"); + println!("sentruxFail={sentrux_fail}"); + println!("githubResearchRequired={research_required}"); + } + Ok(()) +} + +pub(crate) fn doctor(artifact_root: Option<&Path>, json: bool) -> Result<()> { + let artifact_root = resolve_artifact_root(artifact_root)?; + let ok = artifact_root.exists(); + if json { + let out = serde_json::json!({ + "ok": ok, + "artifactRoot": artifact_root, + "artifactRootExists": ok + }); + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + println!("artifactRoot: {}", artifact_root.display()); + println!("artifactRootExists: {ok}"); + } + Ok(()) +} + +fn build_resume_summary( + repo: PathBuf, + artifact_dir: PathBuf, + report_path: PathBuf, + report: &Value, + hospital: &Value, +) -> ResumeSummary { + ResumeSummary { + repo, + artifact_dir: artifact_dir.clone(), + report_path, + summary_path: existing_path(&artifact_dir, "summary.md"), + understanding_path: existing_path(&artifact_dir, "understanding.md"), + hospital_path: string_path(report, &["hospital", "path"]), + hospital_markdown: string_path(report, &["hospital", "markdown"]), + github_research_path: string_path(report, &["githubResearch", "path"]), + github_research_markdown: string_path(report, &["githubResearch", "markdown"]), + pipeline_failed: int_at(report, &["summary", "failed"]), + pipeline_manual_required: int_at(report, &["summary", "manualRequired"]), + provider_quota: int_at(report, &["summary", "failureCategories", "providerQuota"]), + local_tool_error: int_at(report, &["summary", "failureCategories", "localToolError"]), + graph_missing: int_at(report, &["summary", "failureCategories", "graphMissing"]), + sentrux_fail: int_at(report, &["summary", "failureCategories", "sentruxFail"]), + hospital_status: string_first( + &[hospital, report], + &[&["triage", "status"], &["hospital", "status"]], + ), + hospital_disposition: string_first( + &[hospital, report], + &[&["triage", "disposition"], &["hospital", "disposition"]], + ), + hospital_next_protocol: string_first( + &[hospital, report], + &[&["triage", "next_protocol"], &["hospital", "nextProtocol"]], + ), + hospital_current_state: string_first( + &[hospital, report], + &[ + &["state_machine", "current_state"], + &["hospital", "currentState"], + ], + ), + hospital_primary_diagnosis: string_first( + &[hospital, report], + &[ + &["triage", "primary_diagnosis"], + &["hospital", "primaryDiagnosis"], + ], + ), + research_status: string_at(report, &["githubResearch", "status"]) + .or_else(|| string_at(hospital, &["triage", "research_status"])) + .unwrap_or_else(|| "not_applicable".to_string()), + research_required: bool_at(report, &["githubResearch", "required"]) + || bool_at(hospital, &["triage", "research_required"]), + } +} + +fn print_resume_text(summary: &ResumeSummary) { + println!("Code Intel Resume"); + println!("repo: {}", summary.repo.display()); + println!("artifactDir: {}", summary.artifact_dir.display()); + println!("report: {}", summary.report_path.display()); + print_optional_path("summary", summary.summary_path.as_ref()); + print_optional_path("understanding", summary.understanding_path.as_ref()); + print_optional_path("hospital", summary.hospital_path.as_ref()); + print_optional_path("hospitalMarkdown", summary.hospital_markdown.as_ref()); + println!("failed: {}", summary.pipeline_failed); + println!("manualRequired: {}", summary.pipeline_manual_required); + println!( + "failureCategories: providerQuota={}, localToolError={}, graphMissing={}, sentruxFail={}", + summary.provider_quota, + summary.local_tool_error, + summary.graph_missing, + summary.sentrux_fail + ); + println!( + "hospitalStatus: {}", + empty_as_unknown(&summary.hospital_status) + ); + println!( + "hospitalDisposition: {}", + empty_as_unknown(&summary.hospital_disposition) + ); + println!( + "hospitalState: {}", + empty_as_unknown(&summary.hospital_current_state) + ); + println!( + "primaryDiagnosis: {}", + empty_as_unknown(&summary.hospital_primary_diagnosis) + ); + println!( + "nextProtocol: {}", + empty_as_unknown(&summary.hospital_next_protocol) + ); + println!("githubResearch: {}", summary.research_status); + println!("githubResearchRequired: {}", summary.research_required); + if summary.research_required { + print_optional_path("githubResearchJson", summary.github_research_path.as_ref()); + print_optional_path( + "githubResearchMarkdown", + summary.github_research_markdown.as_ref(), + ); + } + println!("nextRead: {}", next_read(summary).display()); +} + +fn print_resume_json(summary: &ResumeSummary) -> Result<()> { + let out = serde_json::json!({ + "repo": summary.repo, + "artifactDir": summary.artifact_dir, + "report": summary.report_path, + "summary": summary.summary_path, + "understanding": summary.understanding_path, + "hospital": summary.hospital_path, + "hospitalMarkdown": summary.hospital_markdown, + "failed": summary.pipeline_failed, + "manualRequired": summary.pipeline_manual_required, + "failureCategories": { + "providerQuota": summary.provider_quota, + "localToolError": summary.local_tool_error, + "graphMissing": summary.graph_missing, + "sentruxFail": summary.sentrux_fail + }, + "hospitalStatus": summary.hospital_status, + "hospitalDisposition": summary.hospital_disposition, + "hospitalState": summary.hospital_current_state, + "primaryDiagnosis": summary.hospital_primary_diagnosis, + "nextProtocol": summary.hospital_next_protocol, + "githubResearch": { + "status": summary.research_status, + "required": summary.research_required, + "path": summary.github_research_path, + "markdown": summary.github_research_markdown + }, + "nextRead": next_read(summary) + }); + println!("{}", serde_json::to_string_pretty(&out)?); + Ok(()) +} + +fn next_read(summary: &ResumeSummary) -> PathBuf { + if summary.research_required { + if let Some(path) = &summary.github_research_markdown { + if !path.as_os_str().is_empty() { + return path.clone(); + } + } + } + match summary.hospital_next_protocol.as_str() { + "surgery_plan" => summary.artifact_dir.join("surgery-plan.md"), + "github_solution_research" => summary + .github_research_markdown + .clone() + .unwrap_or_else(|| summary.artifact_dir.join("github-solution-research.md")), + _ => summary + .understanding_path + .clone() + .or_else(|| summary.hospital_markdown.clone()) + .unwrap_or_else(|| summary.report_path.clone()), + } +} + +fn print_optional_path(label: &str, path: Option<&PathBuf>) { + if let Some(path) = path { + if !path.as_os_str().is_empty() { + println!("{label}: {}", path.display()); + } + } +} + +fn resolve_artifact_root(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + return Ok(path.to_path_buf()); + } + if let Ok(value) = env::var("CODE_INTEL_ARTIFACT_ROOT") { + if !value.trim().is_empty() { + return Ok(PathBuf::from(value)); + } + } + if let Ok(value) = env::var("LOCALAPPDATA") { + if !value.trim().is_empty() { + return Ok(PathBuf::from(value).join("code-intel").join("artifacts")); + } + } + let home = env::var("HOME").or_else(|_| env::var("USERPROFILE"))?; + Ok(PathBuf::from(home) + .join(".code-intel") + .join("code-intel") + .join("artifacts")) +} + +fn absolute_existing_dir(path: &Path) -> Result { + if !path.is_dir() { + return Err(format!("repo path is not a directory: {}", path.display()).into()); + } + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + Ok(env::current_dir()?.join(path)) +} + +fn latest_run_dir(repo_artifacts: &Path) -> Result { + let mut dirs = Vec::new(); + for entry in fs::read_dir(repo_artifacts)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + dirs.push(entry.path()); + } + } + dirs.sort(); + dirs.pop().ok_or_else(|| { + format!( + "no artifact run directories under {}", + repo_artifacts.display() + ) + .into() + }) +} + +fn read_json(path: &Path) -> Result { + let text = fs::read_to_string(path)?; + Ok(serde_json::from_str(text.trim_start_matches('\u{feff}'))?) +} + +fn existing_path(dir: &Path, file_name: &str) -> Option { + let path = dir.join(file_name); + path.exists().then_some(path) +} + +fn string_path(value: &Value, path: &[&str]) -> Option { + string_at(value, path).and_then(|s| { + if s.trim().is_empty() { + None + } else { + Some(PathBuf::from(s)) + } + }) +} + +fn string_first(values: &[&Value], paths: &[&[&str]]) -> String { + for value in values { + for path in paths { + if let Some(text) = string_at(value, path) { + if !text.trim().is_empty() { + return text; + } + } + } + } + String::new() +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_str().map(ToString::to_string) +} + +fn int_at(value: &Value, path: &[&str]) -> i64 { + let mut current = value; + for segment in path { + current = match current.get(*segment) { + Some(value) => value, + None => return 0, + }; + } + current.as_i64().unwrap_or(0) +} + +fn bool_at(value: &Value, path: &[&str]) -> bool { + let mut current = value; + for segment in path { + current = match current.get(*segment) { + Some(value) => value, + None => return false, + }; + } + current.as_bool().unwrap_or(false) +} + +fn empty_as_unknown(value: &str) -> &str { + if value.trim().is_empty() { + "unknown" + } else { + value + } +} + +#[cfg(test)] +#[path = "artifacts_tests.rs"] +mod tests; diff --git a/crates/code-intel-cli/src/artifacts_tests.rs b/crates/code-intel-cli/src/artifacts_tests.rs new file mode 100644 index 0000000..b497e19 --- /dev/null +++ b/crates/code-intel-cli/src/artifacts_tests.rs @@ -0,0 +1,150 @@ +use super::*; +use serde_json::json; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_temp_dir(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + env::temp_dir().join(format!("code-intel-{name}-{stamp}")) +} + +fn touch(path: &Path, text: &str) { + fs::write(path, text).expect("fixture file should be writable"); +} + +fn summary_for(report: Value, hospital: Value, artifact_dir: &Path) -> ResumeSummary { + build_resume_summary( + PathBuf::from("D:/work/quant-system"), + artifact_dir.to_path_buf(), + artifact_dir.join("report.json"), + &report, + &hospital, + ) +} + +#[test] +fn resume_contract_routes_graph_missing_to_understanding() { + let dir = unique_temp_dir("graph-missing"); + fs::create_dir_all(&dir).expect("fixture dir should be created"); + touch(&dir.join("summary.md"), "# Summary"); + touch(&dir.join("understanding.md"), "# Understanding"); + + let hospital_path = dir.join("hospital-report.json"); + let hospital_markdown = dir.join("hospital.md"); + let report = json!({ + "hospital": { + "path": hospital_path, + "markdown": hospital_markdown + }, + "githubResearch": { + "status": "not_applicable", + "required": false, + "path": "", + "markdown": "" + }, + "summary": { + "failed": 0, + "manualRequired": 1, + "failureCategories": { + "providerQuota": 0, + "localToolError": 0, + "graphMissing": 1, + "sentruxFail": 0 + } + } + }); + let hospital = json!({ + "triage": { + "status": "amber", + "disposition": "admit", + "primary_diagnosis": "architecture graph missing", + "next_protocol": "diagnose", + "research_status": "not_applicable", + "research_required": false + }, + "state_machine": { + "current_state": "diagnose" + } + }); + + let summary = summary_for(report, hospital, &dir); + + assert_eq!(summary.graph_missing, 1); + assert_eq!(summary.hospital_next_protocol, "diagnose"); + assert!(!summary.research_required); + assert_eq!(next_read(&summary), dir.join("understanding.md")); +} + +#[test] +fn resume_contract_prioritizes_github_research_when_required() { + let dir = unique_temp_dir("research-required"); + fs::create_dir_all(&dir).expect("fixture dir should be created"); + touch(&dir.join("understanding.md"), "# Understanding"); + let research_markdown = dir.join("github-solution-research.md"); + touch(&research_markdown, "# GitHub Solution Research"); + + let report = json!({ + "hospital": { + "path": dir.join("hospital-report.json"), + "markdown": dir.join("hospital.md") + }, + "githubResearch": { + "status": "manual_required", + "required": true, + "path": dir.join("github-solution-research.json"), + "markdown": research_markdown + }, + "summary": { + "failed": 1, + "manualRequired": 1, + "failureCategories": { + "providerQuota": 0, + "localToolError": 0, + "graphMissing": 0, + "sentruxFail": 1 + } + } + }); + let hospital = json!({ + "triage": { + "status": "red", + "disposition": "admit", + "primary_diagnosis": "architecture gate failure", + "next_protocol": "github_solution_research", + "research_status": "manual_required", + "research_required": true + }, + "state_machine": { + "current_state": "triage" + } + }); + + let summary = summary_for(report, hospital, &dir); + + assert_eq!(summary.sentrux_fail, 1); + assert!(summary.research_required); + assert_eq!(summary.hospital_next_protocol, "github_solution_research"); + assert_eq!(next_read(&summary), research_markdown); +} + +#[test] +fn classify_contract_requires_research_for_upstream_or_tool_blockers() { + let report = json!({ + "summary": { + "failureCategories": { + "providerQuota": 1, + "localToolError": 0, + "graphMissing": 1, + "sentruxFail": 0 + } + } + }); + + let provider_quota = int_at(&report, &["summary", "failureCategories", "providerQuota"]); + let local_tool_error = int_at(&report, &["summary", "failureCategories", "localToolError"]); + let sentrux_fail = int_at(&report, &["summary", "failureCategories", "sentruxFail"]); + + assert!(provider_quota > 0 || local_tool_error > 0 || sentrux_fail > 0); +} diff --git a/crates/code-intel-cli/src/graph.rs b/crates/code-intel-cli/src/graph.rs new file mode 100644 index 0000000..6aaffc7 --- /dev/null +++ b/crates/code-intel-cli/src/graph.rs @@ -0,0 +1,532 @@ +use crate::Result; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub struct Options<'a> { + pub repo: &'a Path, + pub language: &'a str, + pub full: bool, + pub write: bool, + pub json: bool, +} + +#[derive(Debug)] +struct SourceFile { + rel: String, + language: &'static str, + bytes: u64, + lines: usize, + text: String, +} + +pub fn run(options: &Options<'_>) -> Result<()> { + let graph = generate(options.repo, options.language, options.full, options.write)?; + let graph_path = graph_path(&options.repo.canonicalize()?); + + if options.json { + println!("{}", serde_json::to_string_pretty(&graph)?); + } else { + let summary = &graph["summary"]; + println!( + "graph files={} edges={} symbols={} path={}", + summary["files"].as_u64().unwrap_or(0), + summary["edges"].as_u64().unwrap_or(0), + summary["symbols"].as_u64().unwrap_or(0), + graph_path.display() + ); + } + + Ok(()) +} + +pub fn generate(repo: &Path, language: &str, full: bool, write: bool) -> Result { + let repo = repo.canonicalize()?; + if !repo.is_dir() { + return Err(format!("repo path is not a directory: {}", repo.display()).into()); + } + + let graph = build_graph(&repo, language, full)?; + let graph_path = graph_path(&repo); + + if write { + if let Some(parent) = graph_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&graph_path, serde_json::to_string_pretty(&graph)?)?; + } + + Ok(graph) +} + +pub fn graph_path(repo: &Path) -> std::path::PathBuf { + repo.join(".understand-anything") + .join("knowledge-graph.json") +} + +fn build_graph(repo: &Path, language: &str, full: bool) -> Result { + let mut files = Vec::new(); + collect_source_files(repo, repo, &mut files)?; + + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + let mut symbols = Vec::new(); + + let known_paths: HashSet = files.iter().map(|file| file.rel.clone()).collect(); + + for file in &files { + nodes.push(json!({ + "id": file.rel, + "kind": "file", + "path": file.rel, + "language": file.language, + "lines": file.lines, + "bytes": file.bytes + })); + + edges.extend(extract_edges(file, &known_paths)); + symbols.extend(extract_symbols(file, full)); + } + + Ok(json!({ + "schema": "code-intel-understand-graph.v1", + "provider": "code-intel-rust-graph", + "repo": normalize_path(repo), + "generatedAtUnix": now_unix(), + "language": language, + "full": full, + "summary": { + "files": nodes.len(), + "edges": edges.len(), + "symbols": symbols.len() + }, + "nodes": nodes, + "edges": edges, + "symbols": symbols + })) +} + +fn collect_source_files(repo: &Path, dir: &Path, files: &mut Vec) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + + if file_type.is_dir() { + if should_skip_dir(&path) { + continue; + } + collect_source_files(repo, &path, files)?; + continue; + } + + if !file_type.is_file() || should_skip_file(&path) { + continue; + } + + let Some(language) = language_from_path(&path) else { + continue; + }; + + let bytes = entry.metadata()?.len(); + if bytes > 1_500_000 { + continue; + } + + let Ok(text) = fs::read_to_string(&path) else { + continue; + }; + + let rel_path = normalize_path(path.strip_prefix(repo).unwrap_or(&path)); + files.push(SourceFile { + rel: rel_path, + language, + bytes, + lines: text.lines().count(), + text, + }); + } + Ok(()) +} + +fn should_skip_dir(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + + matches!( + name, + ".git" + | ".repowise" + | ".understand-anything" + | ".sentrux" + | ".next" + | ".turbo" + | "node_modules" + | "target" + | "dist" + | "build" + | "coverage" + | "__pycache__" + ) +} + +fn should_skip_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + + name.ends_with(".min.js") || name.ends_with(".lock") || name == "package-lock.json" +} + +fn language_from_path(path: &Path) -> Option<&'static str> { + match path.extension().and_then(|value| value.to_str())? { + "rs" => Some("rust"), + "ps1" | "psm1" | "psd1" => Some("powershell"), + "py" => Some("python"), + "js" | "mjs" | "cjs" => Some("javascript"), + "ts" | "tsx" => Some("typescript"), + "jsx" => Some("javascript-react"), + "json" => Some("json"), + "toml" => Some("toml"), + "yaml" | "yml" => Some("yaml"), + "md" | "mdx" => Some("markdown"), + "go" => Some("go"), + "java" => Some("java"), + "cs" => Some("csharp"), + "cpp" | "cc" | "cxx" | "hpp" | "h" | "c" => Some("cpp"), + "vue" => Some("vue"), + "svelte" => Some("svelte"), + _ => None, + } +} + +fn extract_edges(file: &SourceFile, known_paths: &HashSet) -> Vec { + let mut edges = Vec::new(); + let file_dir = Path::new(&file.rel) + .parent() + .unwrap_or_else(|| Path::new("")); + + for (line_index, raw_line) in file.text.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with("//") || line.starts_with('#') { + continue; + } + + let candidates = edge_targets(file.language, line); + for (kind, target) in candidates { + let resolved = resolve_target(file_dir, &target, known_paths); + edges.push(json!({ + "from": file.rel, + "to": resolved.unwrap_or(target.clone()), + "kind": kind, + "rawTarget": target, + "line": line_index + 1, + "evidence": truncate(line, 220) + })); + } + } + + edges +} + +fn edge_targets(language: &str, line: &str) -> Vec<(&'static str, String)> { + let mut targets = Vec::new(); + + match language { + "rust" => { + if let Some(name) = line + .strip_prefix("mod ") + .and_then(|value| value.split(';').next()) + { + targets.push(("module", name.trim().to_string())); + } + if let Some(name) = line + .strip_prefix("pub mod ") + .and_then(|value| value.split(';').next()) + { + targets.push(("module", name.trim().to_string())); + } + if let Some(name) = line + .strip_prefix("use ") + .and_then(|value| value.split(';').next()) + { + targets.push(("use", name.trim().to_string())); + } + } + "python" => { + if let Some(rest) = line.strip_prefix("import ") { + targets.push(( + "import", + rest.split_whitespace().next().unwrap_or(rest).to_string(), + )); + } + if let Some(rest) = line.strip_prefix("from ") { + targets.push(( + "import", + rest.split_whitespace().next().unwrap_or(rest).to_string(), + )); + } + } + "javascript" | "javascript-react" | "typescript" => { + if line.starts_with("import ") { + if let Some(target) = quoted_tail(line) { + targets.push(("import", target)); + } + } + if line.contains("require(") { + if let Some(target) = quoted_after(line, "require(") { + targets.push(("require", target)); + } + } + } + "powershell" => { + if line.starts_with(". ") || line.starts_with("& ") { + if let Some(target) = first_path_like_token(line) { + targets.push(("invoke", target)); + } + } + if line.contains("Join-Path") { + if let Some(target) = quoted_tail(line) { + targets.push(("path_reference", target)); + } + } + } + _ => { + if let Some(target) = quoted_tail(line) { + if target.contains('/') || target.contains('\\') { + targets.push(("reference", target)); + } + } + } + } + + targets +} + +fn resolve_target( + file_dir: &Path, + raw_target: &str, + known_paths: &HashSet, +) -> Option { + let normalized = raw_target.replace('\\', "/"); + let mut candidates = Vec::new(); + + if normalized.starts_with('.') { + candidates.push(normalize_path(file_dir.join(&normalized))); + } else { + candidates.push(normalized.clone()); + } + + let extension_candidates = ["rs", "ps1", "py", "js", "ts", "tsx", "json", "md", "toml"]; + let mut expanded = Vec::new(); + for candidate in candidates { + expanded.push(candidate.clone()); + for ext in extension_candidates { + expanded.push(format!("{candidate}.{ext}")); + } + for ext in extension_candidates { + expanded.push(format!("{candidate}/mod.{ext}")); + expanded.push(format!("{candidate}/index.{ext}")); + } + } + + expanded + .into_iter() + .find(|candidate| known_paths.contains(candidate)) +} + +fn extract_symbols(file: &SourceFile, full: bool) -> Vec { + let mut symbols = Vec::new(); + let max_symbols = if full { usize::MAX } else { 80 }; + + for (line_index, raw_line) in file.text.lines().enumerate() { + if symbols.len() >= max_symbols { + break; + } + + let line = raw_line.trim(); + let symbol = match file.language { + "rust" => rust_symbol(line), + "powershell" => powershell_symbol(line), + "python" => python_symbol(line), + "javascript" | "javascript-react" | "typescript" => js_symbol(line), + _ => None, + }; + + if let Some((kind, name)) = symbol { + symbols.push(json!({ + "file": file.rel, + "kind": kind, + "name": name, + "line": line_index + 1 + })); + } + } + + symbols +} + +fn rust_symbol(line: &str) -> Option<(&'static str, String)> { + for prefix in ["pub async fn ", "async fn ", "pub fn ", "fn "] { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(("function", take_ident(rest))); + } + } + for prefix in [ + "pub struct ", + "struct ", + "pub enum ", + "enum ", + "pub trait ", + "trait ", + ] { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(("type", take_ident(rest))); + } + } + None +} + +fn powershell_symbol(line: &str) -> Option<(&'static str, String)> { + line.strip_prefix("function ") + .map(|rest| ("function", take_ident(rest))) +} + +fn python_symbol(line: &str) -> Option<(&'static str, String)> { + if let Some(rest) = line.strip_prefix("def ") { + return Some(("function", take_ident(rest))); + } + if let Some(rest) = line.strip_prefix("class ") { + return Some(("type", take_ident(rest))); + } + None +} + +fn js_symbol(line: &str) -> Option<(&'static str, String)> { + for prefix in [ + "export async function ", + "export function ", + "async function ", + "function ", + ] { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(("function", take_ident(rest))); + } + } + if let Some((name, _)) = line.split_once("=>") { + let name = name + .trim() + .strip_prefix("export const ") + .or_else(|| name.trim().strip_prefix("const ")) + .or_else(|| name.trim().strip_prefix("let ")) + .or_else(|| name.trim().strip_prefix("var ")); + if let Some(name) = name { + return Some(("function", take_ident(name))); + } + } + None +} + +fn take_ident(value: &str) -> String { + value + .trim() + .trim_start_matches('&') + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-') + .collect() +} + +fn quoted_tail(line: &str) -> Option { + let first_single = line.rfind('\''); + let first_double = line.rfind('"'); + let quote = match (first_single, first_double) { + (Some(single), Some(double)) => { + if single > double { + '\'' + } else { + '"' + } + } + (Some(_), None) => '\'', + (None, Some(_)) => '"', + (None, None) => return None, + }; + + let before_end = line.rsplit_once(quote)?.0; + let (_, target) = before_end.rsplit_once(quote)?; + Some(target.to_string()) +} + +fn quoted_after(line: &str, marker: &str) -> Option { + let start = line.find(marker)? + marker.len(); + let tail = &line[start..]; + let quote = tail.chars().find(|ch| *ch == '\'' || *ch == '"')?; + let after_quote = tail.split_once(quote)?.1; + Some(after_quote.split_once(quote)?.0.to_string()) +} + +fn first_path_like_token(line: &str) -> Option { + line.split_whitespace() + .skip(1) + .map(|value| value.trim_matches('"').trim_matches('\'')) + .find(|value| value.contains(".ps1") || value.contains('/') || value.contains('\\')) + .map(ToString::to_string) +} + +fn truncate(value: &str, max: usize) -> String { + if value.len() <= max { + return value.to_string(); + } + format!("{}...", &value[..max]) +} + +fn normalize_path(path: impl AsRef) -> String { + path.as_ref().to_string_lossy().replace('\\', "/") +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_graph_from_local_sources() { + let repo = unique_temp_dir(); + fs::create_dir_all(repo.join("src")).unwrap(); + fs::write( + repo.join("src").join("lib.rs"), + "mod graph;\npub fn run() {}\n", + ) + .unwrap(); + fs::write(repo.join("src").join("graph.rs"), "pub struct Node;\n").unwrap(); + + let graph = build_graph(&repo, "zh", false).unwrap(); + + assert_eq!(graph["summary"]["files"].as_u64(), Some(2)); + assert!(graph["summary"]["edges"].as_u64().unwrap_or(0) >= 1); + assert!(graph["summary"]["symbols"].as_u64().unwrap_or(0) >= 2); + + fs::remove_dir_all(repo).unwrap(); + } + + fn unique_temp_dir() -> std::path::PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "code-intel-graph-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + dir + } +} diff --git a/crates/code-intel-cli/src/main.rs b/crates/code-intel-cli/src/main.rs index 144784f..adb3a1f 100644 --- a/crates/code-intel-cli/src/main.rs +++ b/crates/code-intel-cli/src/main.rs @@ -1,10 +1,18 @@ -use serde_json::Value; use std::env; use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process; +use serde_json::Value; + +mod artifacts; +mod graph; +mod orchestration; +mod providers; +mod routes; +mod sentrux; + type Result = std::result::Result>; #[derive(Debug, Default)] @@ -16,6 +24,15 @@ struct Args { steps: Option, failures: Option, out: Option, + manifest: Option, + capability: Option, + provider: Option, + operation: Option, + action: String, + mode: String, + language: String, + write: bool, + full: bool, json: bool, } @@ -141,6 +158,11 @@ fn run() -> Result<()> { "doctor" => cmd_doctor(&args), "sentrux-normalize" => cmd_sentrux_normalize(&args), "sentrux-debt-register" => cmd_sentrux_debt_register(&args), + "graph" | "understand" => cmd_graph(&args), + "orchestrate" | "orchestration" => cmd_orchestrate(&args), + "provider" | "providers" => cmd_provider(&args), + "route" | "routes" => cmd_route(&args), + "sentrux" => cmd_sentrux(&args), "help" | "--help" | "-h" => { print_help(); Ok(()) @@ -159,6 +181,9 @@ fn parse_args(raw: Vec) -> Result { let mut args = Args { command: raw[0].clone(), + action: "Validate".to_string(), + mode: "normal".to_string(), + language: "zh".to_string(), ..Args::default() }; let mut i = 1usize; @@ -189,8 +214,44 @@ fn parse_args(raw: Vec) -> Result { args.artifact_root = Some(PathBuf::from(required_value(&raw, i, "--artifact-root")?)); } + "--manifest" => { + i += 1; + args.manifest = Some(PathBuf::from(required_value(&raw, i, "--manifest")?)); + } + "--capability" => { + i += 1; + args.capability = Some(required_value(&raw, i, "--capability")?); + } + "--provider" => { + i += 1; + args.provider = Some(required_value(&raw, i, "--provider")?); + } + "--operation" => { + i += 1; + args.operation = Some(required_value(&raw, i, "--operation")?); + } + "--action" => { + i += 1; + args.action = required_value(&raw, i, "--action")?; + } + "--mode" => { + i += 1; + args.mode = required_value(&raw, i, "--mode")?; + } + "--language" => { + i += 1; + args.language = required_value(&raw, i, "--language")?; + } + "--write" => args.write = true, + "--full" => args.full = true, "--json" => args.json = true, "--help" | "-h" => args.command = "help".to_string(), + value if args.command == "sentrux" && args.operation.is_none() => { + args.operation = Some(value.to_string()); + } + value if args.command == "sentrux" && args.repo.is_none() => { + args.repo = Some(PathBuf::from(value)); + } flag => return Err(format!("unknown argument for {}: {flag}", args.command).into()), } i += 1; @@ -210,32 +271,7 @@ fn cmd_resume(args: &Args) -> Result<()> { .as_ref() .ok_or("resume requires --repo ")? .to_path_buf(); - let repo = absolute_existing_dir(&repo)?; - let artifact_root = resolve_artifact_root(args.artifact_root.as_deref())?; - let repo_name = repo - .file_name() - .and_then(|s| s.to_str()) - .ok_or("repo path has no final directory name")?; - let repo_artifacts = artifact_root.join(repo_name); - let artifact_dir = latest_run_dir(&repo_artifacts)?; - let report_path = artifact_dir.join("report.json"); - let report = read_json(&report_path)?; - let hospital_path = string_path(&report, &["hospital", "path"]).or_else(|| { - let candidate = artifact_dir.join("hospital-report.json"); - candidate.exists().then_some(candidate) - }); - let hospital = match hospital_path.as_ref() { - Some(path) if path.exists() => read_json(path)?, - _ => Value::Null, - }; - - let summary = build_resume_summary(repo, artifact_dir, report_path, &report, &hospital); - if args.json { - print_resume_json(&summary)?; - } else { - print_resume_text(&summary); - } - Ok(()) + artifacts::resume(&repo, args.artifact_root.as_deref(), args.json) } fn cmd_classify(args: &Args) -> Result<()> { @@ -808,23 +844,70 @@ fn write_or_print_json(path: Option<&PathBuf>, value: &Value) -> Result<()> { Ok(()) } -fn cmd_doctor(args: &Args) -> Result<()> { - let artifact_root = resolve_artifact_root(args.artifact_root.as_deref())?; - let ok = artifact_root.exists(); - if args.json { - let out = serde_json::json!({ - "ok": ok, - "artifactRoot": artifact_root, - "artifactRootExists": ok - }); - println!("{}", serde_json::to_string_pretty(&out)?); - } else { - println!("artifactRoot: {}", artifact_root.display()); - println!("artifactRootExists: {ok}"); +fn read_json(path: &Path) -> Result { + let text = fs::read_to_string(path)?; + Ok(serde_json::from_str(text.trim_start_matches('\u{feff}'))?) +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for segment in path { + current = current.get(*segment)?; } - Ok(()) + current.as_str().map(ToString::to_string) +} + +fn int_at(value: &Value, path: &[&str]) -> i64 { + let mut current = value; + for segment in path { + current = match current.get(*segment) { + Some(value) => value, + None => return 0, + }; + } + current.as_i64().unwrap_or(0) +} + +fn bool_at(value: &Value, path: &[&str]) -> bool { + let mut current = value; + for segment in path { + current = match current.get(*segment) { + Some(value) => value, + None => return false, + }; + } + current.as_bool().unwrap_or(false) +} + +fn existing_path(dir: &Path, file_name: &str) -> Option { + let path = dir.join(file_name); + path.exists().then_some(path) +} + +fn string_path(value: &Value, path: &[&str]) -> Option { + string_at(value, path).and_then(|s| { + if s.trim().is_empty() { + None + } else { + Some(PathBuf::from(s)) + } + }) +} + +fn string_first(values: &[&Value], paths: &[&[&str]]) -> String { + for value in values { + for path in paths { + if let Some(text) = string_at(value, path) { + if !text.trim().is_empty() { + return text; + } + } + } + } + String::new() } +#[cfg(test)] fn build_resume_summary( repo: PathBuf, artifact_dir: PathBuf, @@ -882,90 +965,7 @@ fn build_resume_summary( } } -fn print_resume_text(summary: &ResumeSummary) { - println!("Code Intel Resume"); - println!("repo: {}", summary.repo.display()); - println!("artifactDir: {}", summary.artifact_dir.display()); - println!("report: {}", summary.report_path.display()); - print_optional_path("summary", summary.summary_path.as_ref()); - print_optional_path("understanding", summary.understanding_path.as_ref()); - print_optional_path("hospital", summary.hospital_path.as_ref()); - print_optional_path("hospitalMarkdown", summary.hospital_markdown.as_ref()); - println!("failed: {}", summary.pipeline_failed); - println!("manualRequired: {}", summary.pipeline_manual_required); - println!( - "failureCategories: providerQuota={}, localToolError={}, graphMissing={}, sentruxFail={}", - summary.provider_quota, - summary.local_tool_error, - summary.graph_missing, - summary.sentrux_fail - ); - println!( - "hospitalStatus: {}", - empty_as_unknown(&summary.hospital_status) - ); - println!( - "hospitalDisposition: {}", - empty_as_unknown(&summary.hospital_disposition) - ); - println!( - "hospitalState: {}", - empty_as_unknown(&summary.hospital_current_state) - ); - println!( - "primaryDiagnosis: {}", - empty_as_unknown(&summary.hospital_primary_diagnosis) - ); - println!( - "nextProtocol: {}", - empty_as_unknown(&summary.hospital_next_protocol) - ); - println!("githubResearch: {}", summary.research_status); - println!("githubResearchRequired: {}", summary.research_required); - if summary.research_required { - print_optional_path("githubResearchJson", summary.github_research_path.as_ref()); - print_optional_path( - "githubResearchMarkdown", - summary.github_research_markdown.as_ref(), - ); - } - println!("nextRead: {}", next_read(summary).display()); -} - -fn print_resume_json(summary: &ResumeSummary) -> Result<()> { - let out = serde_json::json!({ - "repo": summary.repo, - "artifactDir": summary.artifact_dir, - "report": summary.report_path, - "summary": summary.summary_path, - "understanding": summary.understanding_path, - "hospital": summary.hospital_path, - "hospitalMarkdown": summary.hospital_markdown, - "failed": summary.pipeline_failed, - "manualRequired": summary.pipeline_manual_required, - "failureCategories": { - "providerQuota": summary.provider_quota, - "localToolError": summary.local_tool_error, - "graphMissing": summary.graph_missing, - "sentruxFail": summary.sentrux_fail - }, - "hospitalStatus": summary.hospital_status, - "hospitalDisposition": summary.hospital_disposition, - "hospitalState": summary.hospital_current_state, - "primaryDiagnosis": summary.hospital_primary_diagnosis, - "nextProtocol": summary.hospital_next_protocol, - "githubResearch": { - "status": summary.research_status, - "required": summary.research_required, - "path": summary.github_research_path, - "markdown": summary.github_research_markdown - }, - "nextRead": next_read(summary) - }); - println!("{}", serde_json::to_string_pretty(&out)?); - Ok(()) -} - +#[cfg(test)] fn next_read(summary: &ResumeSummary) -> PathBuf { if summary.research_required { if let Some(path) = &summary.github_research_markdown { @@ -988,132 +988,60 @@ fn next_read(summary: &ResumeSummary) -> PathBuf { } } -fn print_optional_path(label: &str, path: Option<&PathBuf>) { - if let Some(path) = path { - if !path.as_os_str().is_empty() { - println!("{label}: {}", path.display()); - } - } -} - -fn resolve_artifact_root(explicit: Option<&Path>) -> Result { - if let Some(path) = explicit { - return Ok(path.to_path_buf()); - } - if let Ok(value) = env::var("CODE_INTEL_ARTIFACT_ROOT") { - if !value.trim().is_empty() { - return Ok(PathBuf::from(value)); - } - } - if let Ok(value) = env::var("LOCALAPPDATA") { - if !value.trim().is_empty() { - return Ok(PathBuf::from(value).join("code-intel").join("artifacts")); - } - } - let home = env::var("HOME").or_else(|_| env::var("USERPROFILE"))?; - Ok(PathBuf::from(home) - .join(".code-intel") - .join("code-intel") - .join("artifacts")) -} - -fn absolute_existing_dir(path: &Path) -> Result { - if !path.is_dir() { - return Err(format!("repo path is not a directory: {}", path.display()).into()); - } - if path.is_absolute() { - return Ok(path.to_path_buf()); - } - Ok(env::current_dir()?.join(path)) +fn cmd_doctor(args: &Args) -> Result<()> { + artifacts::doctor(args.artifact_root.as_deref(), args.json) } -fn latest_run_dir(repo_artifacts: &Path) -> Result { - let mut dirs = Vec::new(); - for entry in fs::read_dir(repo_artifacts)? { - let entry = entry?; - if entry.file_type()?.is_dir() { - dirs.push(entry.path()); - } - } - dirs.sort(); - dirs.pop().ok_or_else(|| { - format!( - "no artifact run directories under {}", - repo_artifacts.display() - ) - .into() +fn cmd_graph(args: &Args) -> Result<()> { + let repo = args.repo.as_ref().ok_or("graph requires --repo ")?; + graph::run(&graph::Options { + repo, + language: &args.language, + full: args.full, + write: args.write, + json: args.json, }) } -fn read_json(path: &Path) -> Result { - let text = fs::read_to_string(path)?; - Ok(serde_json::from_str(text.trim_start_matches('\u{feff}'))?) -} - -fn existing_path(dir: &Path, file_name: &str) -> Option { - let path = dir.join(file_name); - path.exists().then_some(path) -} - -fn string_path(value: &Value, path: &[&str]) -> Option { - string_at(value, path).and_then(|s| { - if s.trim().is_empty() { - None - } else { - Some(PathBuf::from(s)) - } +fn cmd_orchestrate(args: &Args) -> Result<()> { + orchestration::run(&orchestration::Options { + action: &args.action, + mode: &args.mode, + manifest: args.manifest.as_deref(), + capability: args.capability.as_deref(), + repo: args.repo.as_deref(), + json: args.json, }) } -fn string_first(values: &[&Value], paths: &[&[&str]]) -> String { - for value in values { - for path in paths { - if let Some(text) = string_at(value, path) { - if !text.trim().is_empty() { - return text; - } - } - } - } - String::new() -} - -fn string_at(value: &Value, path: &[&str]) -> Option { - let mut current = value; - for segment in path { - current = current.get(*segment)?; - } - current.as_str().map(ToString::to_string) -} - -fn int_at(value: &Value, path: &[&str]) -> i64 { - let mut current = value; - for segment in path { - current = match current.get(*segment) { - Some(value) => value, - None => return 0, - }; - } - current.as_i64().unwrap_or(0) +fn cmd_provider(args: &Args) -> Result<()> { + providers::run(&providers::Options { + action: &args.action, + provider: args.provider.as_deref(), + operation: args.operation.as_deref(), + repo: args.repo.as_deref(), + language: &args.language, + full: args.full, + write: args.write || args.operation.as_deref().unwrap_or("") == "graph", + json: args.json, + }) } -fn bool_at(value: &Value, path: &[&str]) -> bool { - let mut current = value; - for segment in path { - current = match current.get(*segment) { - Some(value) => value, - None => return false, - }; - } - current.as_bool().unwrap_or(false) +fn cmd_route(args: &Args) -> Result<()> { + routes::run(&routes::Options { + action: &args.action, + provider: args.provider.as_deref(), + operation: args.operation.as_deref(), + repo: args.repo.as_deref(), + json: args.json, + }) } -fn empty_as_unknown(value: &str) -> &str { - if value.trim().is_empty() { - "unknown" - } else { - value - } +fn cmd_sentrux(args: &Args) -> Result<()> { + sentrux::run(&sentrux::Options { + operation: args.operation.as_deref(), + repo: args.repo.as_deref(), + }) } fn print_help() { @@ -1125,6 +1053,11 @@ fn print_help() { println!(" sentrux-normalize --steps [--out ]"); println!(" sentrux-debt-register --failures [--repo ] [--out ]"); println!(" doctor [--artifact-root ] [--json]"); + println!(" graph --repo [--language zh] [--full] [--write] [--json]"); + println!(" provider [--action List|Plan|Validate|Invoke] [--provider repowise|understand] [--operation ] [--repo ] [--language zh] [--write] [--json]"); + println!(" route [--action List|Plan|Validate] [--provider repowise|understand] [--operation ] [--repo ] [--json]"); + println!(" sentrux "); + println!(" orchestrate [--action Validate|List|Plan] [--repo ] [--mode lite|normal|full] [--capability ] [--manifest ] [--json]"); } #[cfg(test)] diff --git a/crates/code-intel-cli/src/orchestration.rs b/crates/code-intel-cli/src/orchestration.rs new file mode 100644 index 0000000..dd00489 --- /dev/null +++ b/crates/code-intel-cli/src/orchestration.rs @@ -0,0 +1,461 @@ +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + +type Result = std::result::Result>; + +pub(crate) struct Options<'a> { + pub(crate) action: &'a str, + pub(crate) mode: &'a str, + pub(crate) manifest: Option<&'a Path>, + pub(crate) capability: Option<&'a str>, + pub(crate) repo: Option<&'a Path>, + pub(crate) json: bool, +} + +pub(crate) fn run(options: &Options<'_>) -> Result<()> { + let action = normalize_action(options.action)?; + let mode = normalize_mode(options.mode)?; + let manifest_path = resolve_manifest_path(options.manifest)?; + let root = root_for_manifest(&manifest_path)?; + let manifest = read_json(&manifest_path)?; + let stages = array_values(&manifest, "stages"); + let integrations = array_values(&manifest, "integrations"); + + let mut errors = Vec::new(); + let mut stage_ids = HashSet::new(); + let mut stage_order = HashMap::new(); + + for stage in &stages { + let id = string_field(stage, "id").unwrap_or_default(); + if id.trim().is_empty() { + errors.push("stage id is empty".to_string()); + continue; + } + if !stage_ids.insert(id.clone()) { + errors.push(format!("duplicate stage id: {id}")); + } + stage_order.insert(id, int_field(stage, "order")); + } + + let mut integration_ids = HashSet::new(); + for integration in &integrations { + let id = string_field(integration, "id").unwrap_or_default(); + let stage = string_field(integration, "stage").unwrap_or_default(); + let entrypoint = string_field(integration, "entrypoint").unwrap_or_default(); + let capabilities = string_array_field(integration, "capabilities"); + + if id.trim().is_empty() { + errors.push("integration id is empty".to_string()); + continue; + } + if !integration_ids.insert(id.clone()) { + errors.push(format!("duplicate integration id: {id}")); + } + if !stage_ids.contains(&stage) { + errors.push(format!( + "integration {id} references unknown stage: {stage}" + )); + } + if entrypoint.trim().is_empty() { + errors.push(format!("integration {id} has no entrypoint")); + } else if should_validate_entrypoint(&entrypoint) { + let candidate = root.join(&entrypoint); + if !candidate.is_file() { + errors.push(format!("integration {id} entrypoint missing: {entrypoint}")); + } + } + if capabilities.is_empty() { + errors.push(format!("integration {id} exposes no capabilities")); + } + } + + let mut selected = integrations + .iter() + .filter(|integration| integration_matches_capability(integration, options.capability)) + .cloned() + .collect::>(); + selected.sort_by(|left, right| { + let left_stage = string_field(left, "stage").unwrap_or_default(); + let right_stage = string_field(right, "stage").unwrap_or_default(); + let left_order = stage_order.get(&left_stage).copied().unwrap_or(i64::MAX); + let right_order = stage_order.get(&right_stage).copied().unwrap_or(i64::MAX); + left_order + .cmp(&right_order) + .then_with(|| string_field(left, "id").cmp(&string_field(right, "id"))) + }); + + let plan = selected + .iter() + .map(|integration| plan_item(integration, options.repo, &mode)) + .collect::>(); + + let mut sorted_stages = stages.clone(); + sorted_stages.sort_by_key(|stage| int_field(stage, "order")); + + let ok = errors.is_empty(); + let out = serde_json::json!({ + "ok": ok, + "action": action, + "manifest": manifest_path, + "policy": manifest.get("policy").cloned().unwrap_or(Value::Null), + "errors": errors, + "stages": sorted_stages, + "integrations": if action == "Validate" { Vec::::new() } else { plan.clone() }, + "plan": if action == "Plan" { plan.clone() } else { Vec::::new() } + }); + + if options.json { + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + print_text(&action, &manifest_path, &out); + } + + if ok { + Ok(()) + } else { + Err("orchestration validation failed".into()) + } +} + +fn read_json(path: &Path) -> Result { + let text = fs::read_to_string(path)?; + Ok(serde_json::from_str(text.trim_start_matches('\u{feff}'))?) +} + +fn resolve_manifest_path(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + return absolute_file_path(path); + } + let relative = Path::new("orchestration").join("integrations.json"); + for start in manifest_search_starts()? { + for ancestor in start.ancestors() { + let candidate = ancestor.join(&relative); + if candidate.is_file() { + return Ok(candidate); + } + } + } + Err("orchestration manifest missing: orchestration/integrations.json".into()) +} + +fn manifest_search_starts() -> Result> { + let mut starts = Vec::new(); + starts.push(env::current_dir()?); + if let Ok(exe) = env::current_exe() { + if let Some(parent) = exe.parent() { + starts.push(parent.to_path_buf()); + } + } + Ok(starts) +} + +fn absolute_file_path(path: &Path) -> Result { + if path.is_file() { + return Ok(if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }); + } + Err(format!("file does not exist: {}", path.display()).into()) +} + +fn root_for_manifest(manifest_path: &Path) -> Result { + let parent = manifest_path + .parent() + .ok_or("manifest path has no parent directory")?; + if parent + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("orchestration")) + { + return parent + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "orchestration manifest has no repository root".into()); + } + Ok(parent.to_path_buf()) +} + +fn normalize_action(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "validate" => Ok("Validate".to_string()), + "list" => Ok("List".to_string()), + "plan" => Ok("Plan".to_string()), + other => Err(format!("unsupported orchestration action: {other}").into()), + } +} + +fn normalize_mode(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "lite" => Ok("lite".to_string()), + "normal" => Ok("normal".to_string()), + "full" => Ok("full".to_string()), + other => Err(format!("unsupported orchestration mode: {other}").into()), + } +} + +fn should_validate_entrypoint(entrypoint: &str) -> bool { + let lower = entrypoint.to_ascii_lowercase(); + [".ps1", ".py", ".toml", ".rs"] + .iter() + .any(|suffix| lower.ends_with(suffix)) +} + +fn array_values(value: &Value, key: &str) -> Vec { + match value.get(key).and_then(Value::as_array) { + Some(items) => items.clone(), + None => Vec::new(), + } +} + +fn string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn int_field(value: &Value, key: &str) -> i64 { + value.get(key).and_then(Value::as_i64).unwrap_or(0) +} + +fn bool_field(value: &Value, key: &str) -> bool { + value.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn string_array_field(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn integration_matches_capability(integration: &Value, capability: Option<&str>) -> bool { + let Some(capability) = capability.filter(|value| !value.trim().is_empty()) else { + return true; + }; + string_field(integration, "id").as_deref() == Some(capability) + || string_field(integration, "stage").as_deref() == Some(capability) + || string_array_field(integration, "capabilities") + .iter() + .any(|item| item == capability) +} + +fn plan_item(integration: &Value, repo: Option<&Path>, mode: &str) -> Value { + let commands = integration + .get("commands") + .and_then(Value::as_object) + .map(|items| { + let mut expanded = serde_json::Map::new(); + for (name, value) in items { + if let Some(template) = value.as_str() { + expanded.insert( + name.clone(), + Value::String(expand_command_template(template, repo, mode)), + ); + } + } + Value::Object(expanded) + }) + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + + serde_json::json!({ + "id": string_field(integration, "id").unwrap_or_default(), + "stage": string_field(integration, "stage").unwrap_or_default(), + "kind": string_field(integration, "kind").unwrap_or_default(), + "required": bool_field(integration, "required"), + "entrypoint": string_field(integration, "entrypoint").unwrap_or_default(), + "capabilities": string_array_field(integration, "capabilities"), + "commands": commands, + "artifactContract": string_array_field(integration, "artifactContract"), + "extensionPoint": string_field(integration, "extensionPoint").unwrap_or_default() + }) +} + +fn expand_command_template(template: &str, repo: Option<&Path>, mode: &str) -> String { + let mut expanded = template.replace("", mode); + if let Some(repo) = repo { + expanded = expanded.replace("", &repo.display().to_string()); + } + expanded +} + +fn print_text(action: &str, manifest: &Path, result: &Value) { + let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false); + if !ok { + println!("Code Intel orchestration: FAILED"); + if let Some(errors) = result.get("errors").and_then(Value::as_array) { + for error in errors.iter().filter_map(Value::as_str) { + println!("- {error}"); + } + } + return; + } + + if action == "Validate" { + let stages = result + .get("stages") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let integrations = read_manifest_integration_count(result).unwrap_or(0); + println!("Code Intel orchestration: OK"); + println!("Manifest: {}", manifest.display()); + println!("Stages: {stages}"); + println!("Integrations: {integrations}"); + return; + } + + println!("Code Intel orchestration: {action}"); + if let Some(items) = result.get("integrations").and_then(Value::as_array) { + for item in items { + println!( + "{}: {} [{}] entry={}", + string_field(item, "stage").unwrap_or_default(), + string_field(item, "id").unwrap_or_default(), + string_field(item, "kind").unwrap_or_default(), + string_field(item, "entrypoint").unwrap_or_default() + ); + if action == "Plan" { + if let Some(commands) = item.get("commands").and_then(Value::as_object) { + for (name, value) in commands { + if let Some(command) = value.as_str() { + println!(" {name}: {command}"); + } + } + } + } + } + } +} + +fn read_manifest_integration_count(result: &Value) -> Option { + let manifest = result.get("manifest")?.as_str()?; + let data = read_json(Path::new(manifest)).ok()?; + data.get("integrations") + .and_then(Value::as_array) + .map(Vec::len) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_temp_dir(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + env::temp_dir().join(format!("code-intel-{name}-{stamp}")) + } + + fn touch(path: &Path, text: &str) { + fs::write(path, text).expect("fixture file should be writable"); + } + + fn orchestration_fixture_dir(name: &str) -> PathBuf { + let dir = unique_temp_dir(name); + fs::create_dir_all(dir.join("orchestration")).expect("fixture orchestration dir"); + fs::create_dir_all(dir.join("crates/code-intel-cli")).expect("fixture crate dir"); + touch(&dir.join("crates/code-intel-cli/Cargo.toml"), "[package]\n"); + dir + } + + #[test] + fn validates_manifest_and_rust_entrypoint() { + let dir = orchestration_fixture_dir("orchestration-valid"); + let manifest = dir.join("orchestration/integrations.json"); + touch( + &manifest, + &json!({ + "schemaVersion": 1, + "policy": {"name": "test"}, + "stages": [ + {"id": "rust_runtime", "order": 10, "required": true} + ], + "integrations": [ + { + "id": "runtime.code-intel", + "stage": "rust_runtime", + "kind": "internal-rust-binary", + "required": true, + "entrypoint": "crates/code-intel-cli/Cargo.toml", + "capabilities": ["orchestration"], + "commands": { + "validate": "target/debug/code-intel.exe orchestrate --action Validate --json", + "plan": "target/debug/code-intel.exe orchestrate --action Plan --repo --mode --json" + }, + "artifactContract": ["integrations.json"] + } + ] + }) + .to_string(), + ); + + let options = Options { + manifest: Some(&manifest), + repo: Some(Path::new("D:/work/demo")), + action: "Plan", + mode: "normal", + capability: Some("orchestration"), + json: true, + }; + + run(&options).expect("valid orchestration manifest should pass"); + } + + #[test] + fn fails_when_registered_entrypoint_is_missing() { + let dir = orchestration_fixture_dir("orchestration-missing"); + let manifest = dir.join("orchestration/integrations.json"); + touch( + &manifest, + &json!({ + "schemaVersion": 1, + "policy": {"name": "test"}, + "stages": [ + {"id": "rust_runtime", "order": 10, "required": true} + ], + "integrations": [ + { + "id": "runtime.missing", + "stage": "rust_runtime", + "kind": "internal-rust-binary", + "required": true, + "entrypoint": "crates/missing/Cargo.toml", + "capabilities": ["orchestration"], + "commands": {}, + "artifactContract": [] + } + ] + }) + .to_string(), + ); + + let options = Options { + manifest: Some(&manifest), + action: "Validate", + mode: "normal", + repo: None, + capability: None, + json: true, + }; + + let err = run(&options).expect_err("missing entrypoint should fail"); + assert!(err.to_string().contains("orchestration validation failed")); + } +} diff --git a/crates/code-intel-cli/src/providers.rs b/crates/code-intel-cli/src/providers.rs new file mode 100644 index 0000000..a7ff2c2 --- /dev/null +++ b/crates/code-intel-cli/src/providers.rs @@ -0,0 +1,427 @@ +use crate::{graph, Result}; +use serde_json::{json, Value}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub struct Options<'a> { + pub action: &'a str, + pub provider: Option<&'a str>, + pub operation: Option<&'a str>, + pub repo: Option<&'a Path>, + pub language: &'a str, + pub full: bool, + pub write: bool, + pub json: bool, +} + +#[derive(Clone, Copy)] +pub struct ProviderOperation { + pub provider: &'static str, + pub operation: &'static str, + pub stage: &'static str, + pub protocol: &'static str, + pub method: &'static str, + pub route: &'static str, + pub command_template: &'static str, + pub artifact: &'static str, + pub required: bool, + pub status: &'static str, + pub source_spec: &'static str, + pub notes: &'static str, +} + +pub const OPERATIONS: &[ProviderOperation] = &[ + ProviderOperation { + provider: "repowise", + operation: "status", + stage: "semantic_memory", + protocol: "cli+mcp-compatible", + method: "POST", + route: "/api/providers/repowise/status", + command_template: "repowise status --no-workspace ", + artifact: ".repowise/state.json", + required: true, + status: "active", + source_spec: "Repowise CLI: status [PATH], MCP/HTTP serve surfaces for agent callers", + notes: "No model required; reports wiki sync and page statistics.", + }, + ProviderOperation { + provider: "repowise", + operation: "index", + stage: "semantic_memory", + protocol: "cli+mcp-compatible", + method: "POST", + route: "/api/providers/repowise/index", + command_template: + "repowise update --no-workspace --index-only or repowise init --index-only ", + artifact: ".repowise/wiki.db", + required: true, + status: "active", + source_spec: "Repowise CLI: init/update --index-only, MCP tools include semantic code retrieval", + notes: "No model required; refreshes index, dependency graph, git/dead-code artifacts.", + }, + ProviderOperation { + provider: "repowise", + operation: "docs", + stage: "semantic_memory", + protocol: "cli+mcp-compatible", + method: "POST", + route: "/api/providers/repowise/docs", + command_template: "repowise update --docs --no-workspace ", + artifact: "report.json.steps[repowise docs]", + required: false, + status: "compatibility", + source_spec: "Repowise CLI: update --docs, provider/model options", + notes: "Model-backed; provider quota can disable this without disabling status/index.", + }, + ProviderOperation { + provider: "repowise", + operation: "lite", + stage: "localization", + protocol: "http+iii-worker", + method: "POST", + route: "/api/providers/repowise/lite", + command_template: "target/debug/code-nexus-lite.exe codenexus::lite", + artifact: "codenexus-context.json", + required: true, + status: "active", + source_spec: "CodeNexus-lite worker reads Repowise wiki.db into compact agent context", + notes: "Agent localization view over the Repowise artifact contract.", + }, + ProviderOperation { + provider: "understand", + operation: "graph", + stage: "architecture_graph", + protocol: "artifact+command", + method: "POST", + route: "/api/providers/understand/graph", + command_template: + "target/debug/code-intel.exe provider --action Invoke --provider understand --operation graph --repo --language zh --write --json", + artifact: ".understand-anything/knowledge-graph.json", + required: true, + status: "active", + source_spec: "Understand Anything command contract: /understand --language , graph artifact", + notes: "Internal Rust provider emits the Understand-compatible artifact first.", + }, + ProviderOperation { + provider: "understand", + operation: "graph_full", + stage: "architecture_graph", + protocol: "artifact+command", + method: "POST", + route: "/api/providers/understand/graph/full", + command_template: + "target/debug/code-intel.exe provider --action Invoke --provider understand --operation graph_full --repo --language zh --write --json", + artifact: ".understand-anything/knowledge-graph.json", + required: false, + status: "active", + source_spec: "Understand Anything full graph refresh semantics", + notes: "Use when a fresh complete graph is requested.", + }, + ProviderOperation { + provider: "understand", + operation: "external_fallback", + stage: "architecture_graph", + protocol: "manual-command", + method: "MANUAL", + route: "/compat/providers/understand/external", + command_template: "/understand --language zh", + artifact: ".understand-anything/knowledge-graph.json", + required: false, + status: "compatibility", + source_spec: "Understand Anything upstream/plugin command surface", + notes: "Use only if internal graph provider fails or richer external pass is explicitly requested.", + }, +]; + +pub fn run(options: &Options<'_>) -> Result<()> { + let action = options.action.to_ascii_lowercase(); + let value = match action.as_str() { + "list" => list(options.provider), + "plan" => plan(options)?, + "validate" => validate(), + "invoke" => invoke(options)?, + other => return Err(format!("unknown provider action: {other}").into()), + }; + + if options.json { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + print_human(&value); + } + + if action == "invoke" && value.get("ok").and_then(|value| value.as_bool()) == Some(false) { + return Err("provider invoke failed".into()); + } + + Ok(()) +} + +pub fn list(provider: Option<&str>) -> Value { + let operations: Vec = OPERATIONS + .iter() + .filter(|operation| { + provider + .map(|value| value == operation.provider) + .unwrap_or(true) + }) + .map(operation_json) + .collect(); + + json!({ + "ok": true, + "schema": "code-intel-provider-api.v1", + "operations": operations + }) +} + +pub fn plan(options: &Options<'_>) -> Result { + let operation = find_required(options)?; + let command = render_command(operation, options.repo); + + Ok(json!({ + "ok": true, + "schema": "code-intel-provider-api.v1", + "operation": operation_json(operation), + "command": command + })) +} + +pub fn validate() -> Value { + let mut seen = std::collections::HashSet::new(); + let mut errors = Vec::new(); + + for operation in OPERATIONS { + let key = format!("{}/{}", operation.provider, operation.operation); + if !seen.insert(key.clone()) { + errors.push(format!("duplicate provider operation: {key}")); + } + if !operation.route.starts_with('/') { + errors.push(format!("{key} route must start with /")); + } + if operation.command_template.trim().is_empty() { + errors.push(format!("{key} missing command template")); + } + if operation.artifact.trim().is_empty() { + errors.push(format!("{key} missing artifact contract")); + } + } + + json!({ + "ok": errors.is_empty(), + "schema": "code-intel-provider-api.v1", + "operations": OPERATIONS.len(), + "errors": errors + }) +} + +pub fn invoke(options: &Options<'_>) -> Result { + let operation = find_required(options)?; + let repo_input = options + .repo + .ok_or("provider invoke requires --repo ")?; + let repo = repo_input.canonicalize()?; + + match (operation.provider, operation.operation) { + ("understand", "graph") => { + invoke_understand(&repo, options.language, options.full, options.write) + } + ("understand", "graph_full") => invoke_understand(&repo, options.language, true, true), + ("repowise", "status") => invoke_repowise(&repo, "status", &["--no-workspace"]), + ("repowise", "index") => invoke_repowise_index(&repo), + (provider, operation) => Err(format!( + "provider invoke not implemented for {provider}/{operation}; use provider plan for compatibility command" + ) + .into()), + } +} + +fn invoke_understand(repo: &Path, language: &str, full: bool, write: bool) -> Result { + let graph = graph::generate(repo, language, full, write)?; + Ok(json!({ + "ok": true, + "schema": "code-intel-provider-api.v1", + "provider": "understand", + "operation": if full { "graph_full" } else { "graph" }, + "artifact": graph::graph_path(repo), + "result": graph + })) +} + +fn invoke_repowise_index(repo: &Path) -> Result { + let state_path = repo.join(".repowise").join("state.json"); + let db_path = repo.join(".repowise").join("wiki.db"); + if state_path.exists() || db_path.exists() { + let update = invoke_repowise(repo, "update", &["--no-workspace", "--index-only"])?; + let stderr = update + .get("stderrTail") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if update.get("ok").and_then(|value| value.as_bool()) == Some(false) + && stderr.contains("No previous sync found") + { + return invoke_repowise( + repo, + "init", + &[ + "--index-only", + "--no-agents", + "--no-codex", + "--no-distill-hook", + ], + ); + } + Ok(update) + } else { + invoke_repowise( + repo, + "init", + &[ + "--index-only", + "--no-agents", + "--no-codex", + "--no-distill-hook", + ], + ) + } +} + +fn invoke_repowise(repo: &Path, subcommand: &str, args: &[&str]) -> Result { + let repo_cli = cli_path(repo); + let mut child = Command::new("repowise") + .arg(subcommand) + .args(args) + .arg(&repo_cli) + .current_dir(&repo_cli) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(b"n\n")?; + } + + let output = child.wait_with_output()?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let ok = output.status.success(); + + Ok(json!({ + "ok": ok, + "schema": "code-intel-provider-api.v1", + "provider": "repowise", + "operation": if subcommand == "update" || subcommand == "init" { "index" } else { subcommand }, + "exitCode": output.status.code().unwrap_or(-1), + "artifact": repo_cli.join(".repowise").join("wiki.db"), + "stdoutTail": tail(&stdout, 80), + "stderrTail": tail(&stderr, 80) + })) +} + +fn cli_path(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + if let Some(stripped) = text.strip_prefix(r"\\?\") { + return PathBuf::from(stripped); + } + path.to_path_buf() +} + +fn find_required<'a>(options: &Options<'a>) -> Result<&'static ProviderOperation> { + let provider = options + .provider + .ok_or("provider action requires --provider")?; + let operation = options + .operation + .ok_or("provider action requires --operation")?; + find(provider, operation) + .ok_or_else(|| format!("unknown provider operation: {provider}/{operation}").into()) +} + +pub fn find(provider: &str, operation: &str) -> Option<&'static ProviderOperation> { + OPERATIONS + .iter() + .find(|item| item.provider == provider && item.operation == operation) +} + +pub fn operation_json(operation: &ProviderOperation) -> Value { + json!({ + "provider": operation.provider, + "operation": operation.operation, + "stage": operation.stage, + "protocol": operation.protocol, + "method": operation.method, + "route": operation.route, + "commandTemplate": operation.command_template, + "artifact": operation.artifact, + "required": operation.required, + "status": operation.status, + "sourceSpec": operation.source_spec, + "notes": operation.notes + }) +} + +pub fn render_command(operation: &ProviderOperation, repo: Option<&Path>) -> String { + match repo { + Some(repo) => operation + .command_template + .replace("", &repo.to_string_lossy()), + None => operation.command_template.to_string(), + } +} + +fn print_human(value: &Value) { + if let Some(operations) = value.get("operations").and_then(|value| value.as_array()) { + for operation in operations { + println!( + "{}:{} {} {} -> {}", + operation["provider"].as_str().unwrap_or(""), + operation["operation"].as_str().unwrap_or(""), + operation["method"].as_str().unwrap_or(""), + operation["route"].as_str().unwrap_or(""), + operation["commandTemplate"].as_str().unwrap_or("") + ); + } + return; + } + println!("{value}"); +} + +fn tail(value: &str, max_lines: usize) -> String { + let lines: Vec<&str> = value.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + lines[start..].join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_registry_validates() { + let result = validate(); + assert_eq!(result["ok"].as_bool(), Some(true)); + assert!(result["operations"].as_u64().unwrap_or(0) >= 6); + } + + #[test] + fn repowise_and_understand_share_schema() { + let repowise = operation_json(find("repowise", "index").unwrap()); + let understand = operation_json(find("understand", "graph").unwrap()); + + for key in [ + "provider", + "operation", + "protocol", + "route", + "commandTemplate", + "artifact", + "sourceSpec", + ] { + assert!(repowise.get(key).is_some(), "repowise missing {key}"); + assert!(understand.get(key).is_some(), "understand missing {key}"); + } + } +} diff --git a/crates/code-intel-cli/src/routes.rs b/crates/code-intel-cli/src/routes.rs new file mode 100644 index 0000000..799fe3c --- /dev/null +++ b/crates/code-intel-cli/src/routes.rs @@ -0,0 +1,140 @@ +use crate::{providers, Result}; +use serde_json::{json, Value}; +use std::path::Path; + +pub struct Options<'a> { + pub action: &'a str, + pub provider: Option<&'a str>, + pub operation: Option<&'a str>, + pub repo: Option<&'a Path>, + pub json: bool, +} + +pub fn run(options: &Options<'_>) -> Result<()> { + let action = options.action.to_ascii_lowercase(); + let value = match action.as_str() { + "list" => list_routes(options.provider), + "plan" => plan_route(options)?, + "validate" => validate_routes(), + other => return Err(format!("unknown route action: {other}").into()), + }; + + if options.json { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + print_human(&value); + } + + Ok(()) +} + +fn list_routes(provider: Option<&str>) -> Value { + let provider_api = providers::list(provider); + let routes: Vec = provider_api["operations"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .map(route_json) + .collect(); + + json!({ + "ok": true, + "schema": "code-intel-route-registry.v1", + "routes": routes + }) +} + +fn plan_route(options: &Options<'_>) -> Result { + let provider_plan = providers::plan(&providers::Options { + action: "Plan", + provider: options.provider, + operation: options.operation, + repo: options.repo, + language: "zh", + full: false, + write: true, + json: true, + })?; + + Ok(json!({ + "ok": true, + "route": route_json(provider_plan["operation"].clone()), + "command": provider_plan["command"] + })) +} + +fn validate_routes() -> Value { + let provider_validation = providers::validate(); + json!({ + "ok": provider_validation["ok"], + "routes": provider_validation["operations"], + "errors": provider_validation["errors"] + }) +} + +fn route_json(operation: Value) -> Value { + json!({ + "provider": operation["provider"], + "operation": operation["operation"], + "stage": operation["stage"], + "method": operation["method"], + "path": operation["route"], + "commandTemplate": operation["commandTemplate"], + "artifact": operation["artifact"], + "status": operation["status"], + "protocol": operation["protocol"] + }) +} + +fn print_human(value: &Value) { + if let Some(routes) = value.get("routes").and_then(|value| value.as_array()) { + for route in routes { + println!( + "{}:{} {} {} -> {}", + route["provider"].as_str().unwrap_or(""), + route["operation"].as_str().unwrap_or(""), + route["method"].as_str().unwrap_or(""), + route["path"].as_str().unwrap_or(""), + route["commandTemplate"].as_str().unwrap_or("") + ); + } + return; + } + + println!("{value}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn route_registry_validates() { + let result = validate_routes(); + assert_eq!(result["ok"].as_bool(), Some(true)); + assert!(result["routes"].as_u64().unwrap_or(0) >= 5); + } + + #[test] + fn plans_understand_graph_command() { + let repo = Path::new("C:/repo"); + let result = plan_route(&Options { + action: "Plan", + provider: Some("understand"), + operation: Some("graph"), + repo: Some(repo), + json: true, + }) + .unwrap(); + + assert!(result["command"] + .as_str() + .unwrap() + .contains("code-intel.exe provider --action Invoke --provider understand")); + assert!(result["command"] + .as_str() + .unwrap() + .contains("--repo C:/repo")); + } +} diff --git a/crates/code-intel-cli/src/sentrux.rs b/crates/code-intel-cli/src/sentrux.rs new file mode 100644 index 0000000..9f0983f --- /dev/null +++ b/crates/code-intel-cli/src/sentrux.rs @@ -0,0 +1,69 @@ +use crate::Result; +use std::path::Path; +use std::process::{Command, Stdio}; + +pub struct Options<'a> { + pub operation: Option<&'a str>, + pub repo: Option<&'a Path>, +} + +pub fn run(options: &Options<'_>) -> Result<()> { + let operation = options.operation.ok_or("sentrux requires an operation")?; + let repo = options.repo.ok_or("sentrux requires a repo/path")?; + let repo = repo.canonicalize()?; + let repo_cli = cli_path(&repo); + + let mut args = Vec::new(); + match operation { + "scan" | "health" | "check" | "gate" => args.push(operation.to_string()), + "check_rules" => args.push("check".to_string()), + "gate_save" | "save_baseline" => { + args.push("gate".to_string()); + args.push("--save".to_string()); + } + other => { + return Err(format!("sentrux operation not yet implemented in Rust: {other}").into()) + } + } + args.push(repo_cli.clone()); + + let binary = if cfg!(windows) { + "sentrux.cmd" + } else { + "sentrux" + }; + + let output = Command::new(binary) + .args(&args) + .current_dir(&repo_cli) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !stdout.is_empty() { + print!("{stdout}"); + } + if !stderr.is_empty() { + eprint!("{stderr}"); + } + + if !output.status.success() { + return Err(format!( + "sentrux {operation} failed with exit code {}", + output.status.code().unwrap_or(-1) + ) + .into()); + } + + Ok(()) +} + +fn cli_path(path: &Path) -> String { + let text = path.to_string_lossy(); + if let Some(stripped) = text.strip_prefix(r"\\?\") { + return stripped.to_string(); + } + text.to_string() +} diff --git a/docs/code-intel-architecture.md b/docs/code-intel-architecture.md index 3512ff4..3ee79f8 100644 --- a/docs/code-intel-architecture.md +++ b/docs/code-intel-architecture.md @@ -8,28 +8,35 @@ It is built around one rule: keep the entrypoint small, keep tool roles explicit Artifact ownership and reader/writer boundaries are defined in `docs/artifact-data-contract.md`. -1. `invoke-code-intel.ps1` +1. `orchestration/integrations.json` and `code-intel.exe orchestrate` + Integration registry and fusion layer. New scanners, memory systems, graph providers, governance strategies, and compatibility shims must be registered here before they are wired into runner scripts. + +2. Rust targets + - `crates/code-intel-cli`: compiled `code-intel` CLI for integration orchestration, artifact resume, classify, and artifact doctor contracts. + - `crates/code-nexus-lite`: compiled `code-nexus-lite` iii worker for CodeNexus scan/lite/doctor behavior. + +3. `invoke-code-intel.ps1` Thin operator entrypoint. Runs doctor first, then the pipeline. Supports one direct repo path, one configured repo alias, a repo list, or all configured repos. -2. `check-code-intel-tools.ps1` +4. `check-code-intel-tools.ps1` Environment doctor. Verifies local tools, Understand Anything presence, repo path, and Sentrux scope state. -3. `run-code-intel.ps1` +5. `run-code-intel.ps1` Main orchestrator. Produces artifacts, summary, report, hospital diagnosis, and failure classification. -4. Tool adapters +6. Tool adapters - `rg`: exact inventory - `repowise`: semantic index and optional docs - `Understand Anything`: graph artifact - `sentrux`: structure gate - `sentruxInsight`: parsed structural deltas and follow-up hints for agents -5. Scoped helpers +7. Scoped helpers - `Invoke-ScopedRepowise.ps1` - `Run-ScopedRepowiseDocs.py` - `Invoke-SentruxAgentTool.ps1` -6. Stable-ops helpers +8. Stable-ops helpers - `install-code-intel-pipeline.ps1` - `test-code-intel-provider.ps1` - `test-code-intel-pipeline.ps1` @@ -55,6 +62,16 @@ Team usage is different. A stable wrapper is needed for: Without that wrapper, the same tool failure gets interpreted three different ways by three different people. That is how teams end up debugging weather. +## Why The Integration Layer Exists + +As the pipeline absorbs Repowise, Sentrux, graph providers, and future project-intelligence tools, the codebase needs one place to decide how those pieces attach. + +That place is `orchestration/integrations.json`. + +The rule is simple: no new project-intelligence dependency goes straight into an agent-facing script. Register the integration first, declare its stage, capabilities, artifact contract, and adapter entrypoint, then wire the adapter into the runner. When a Rust target exists, the Rust crate is the real integration entrypoint and `.ps1` files are compatibility surfaces. `code-intel.exe orchestrate` validates the registry and can print the current plan for humans or agents. + +Detailed extension rules are in `docs/integration-orchestration.md`. + ## Failure Model The pipeline classifies failures into four buckets: @@ -118,6 +135,13 @@ Install check: & "$env:CODE_INTEL_HOME/install-code-intel-pipeline.ps1" -RepoPath -CheckProvider ``` +Integration registry: + +```powershell +.\target\debug\code-intel.exe orchestrate --action Validate --json +.\target\debug\code-intel.exe orchestrate --action Plan --repo --mode normal --json +``` + Install or repair a teammate machine: ```powershell @@ -163,3 +187,5 @@ Artifact index: Copy the operational shell, not the internal machinery. That is the useful lesson from `gitnexus-stable-ops`. + +The updated version of this rule is: register integrations first, then adapt or internalize them behind the orchestration layer. diff --git a/docs/integration-orchestration.md b/docs/integration-orchestration.md new file mode 100644 index 0000000..4695a5b --- /dev/null +++ b/docs/integration-orchestration.md @@ -0,0 +1,113 @@ +# Integration Orchestration + +`code-intel-pipeline` is moving from a loose toolchain into a self-contained intelligence pipeline. + +The integration orchestration layer is the boundary for that change: + +```text +orchestration/integrations.json +crates/code-intel-cli +target/debug/code-intel.exe orchestrate +target/debug/code-intel.exe provider +target/debug/code-intel.exe route +``` + +## Rule + +Normal code-intel operation must not depend on separately installed project-intelligence CLIs. + +External projects and tools can still exist, but they must enter through this layer as one of: + +- `internal-script`: owned by this repo. +- `internal-module`: implemented inside the pipeline. +- `internal-adapter`: a stable repo-owned adapter over a lower-level runtime. +- `internalizing-adapter`: an adapter that still calls outside code while it is being absorbed. +- `internal-first-adapter`: repo-owned implementation first, external binary only as an accelerator. +- `internal-rust-binary`: compiled Rust CLI owned by this repo. +- `internal-rust-worker`: compiled Rust worker owned by this repo. +- `rust-backed-adapter`: compatibility adapter whose real target is a Rust crate. +- `provider-contract`: a stable contract for providers that cannot be fully embedded yet. + +Do not call a new scanner, memory system, graph generator, or governance tool directly from an agent-facing script. Register it first. + +## Global Route Layer + +Provider routing is separate from provider implementation. + +Use `target\debug\code-intel.exe provider` as the source of truth for provider operations, then expose route views through `target\debug\code-intel.exe route`: + +```powershell +.\target\debug\code-intel.exe provider --action Validate --json +.\target\debug\code-intel.exe provider --action List --json +.\target\debug\code-intel.exe provider --action Plan --provider understand --operation graph --repo --json +.\target\debug\code-intel.exe provider --action Plan --provider repowise --operation index --repo --json +.\target\debug\code-intel.exe route --action Validate --json +.\target\debug\code-intel.exe route --action List --json +``` + +Repowise and Understand-compatible graph operations share the `code-intel-provider-api.v1` schema: provider, operation, protocol, route, command template, artifact contract, requirement, status, source spec, and notes. Repowise routes live under `/api/providers/repowise/*`. Understand-compatible graph routes live under `/api/providers/understand/*`. Compatibility aliases such as `/scan`, `/lite`, `/doctor`, and `/understand` are allowed only as fallback surfaces. Future route-needing projects must add provider operations to this global provider layer first, then wire their implementation behind the route. + +## Add A New Integration + +1. Add an entry to `orchestration/integrations.json`. +2. Assign it to an existing stage, or add a new stage with an explicit `order`. +3. Declare `kind`, `required`, `entrypoint`, `capabilities`, `commands`, and `artifactContract`. +4. Put all CLI or provider quirks behind one adapter entrypoint. +5. Run: + +```powershell +cargo build -p code-intel +.\target\debug\code-intel.exe orchestrate --action Validate +.\target\debug\code-intel.exe provider --action Validate +.\target\debug\code-intel.exe route --action Validate +.\target\debug\code-intel.exe orchestrate --action Plan --json +.\test-integration-orchestration.ps1 +``` + +6. Only then wire the adapter into the Rust runtime or a compatibility script such as `run-code-intel.ps1`, `check-code-intel-tools.ps1`, or `install-code-intel-pipeline.ps1`. + +## Current Stages + +| Stage | Meaning | +|---|---| +| `preflight` | Repo resolution and runtime/integration contract checks. | +| `rust_runtime` | Compiled Rust command and worker targets used by the pipeline. | +| `inventory` | Exact file and text surface. | +| `semantic_memory` | Long-term project memory, index, status, and docs. | +| `architecture_graph` | Graph snapshot and freshness. | +| `structure_governance` | Structural scan, rules, gate, DSM, evolution, and what-if signals. | +| `localization` | Hotspot and reference localization. | +| `diagnosis` | Hospital report, protocol, and surgery plan. | +| `artifact_index` | Durable index of runs for future sessions. | + +## Repowise Direction + +`memory.repowise` is the first internalizing adapter. + +The near-term goal is to hide all Repowise CLI and Python package details behind the pipeline-owned adapter. The long-term goal is for normal indexing/status/docs behavior to be owned by this repo, with upstream Repowise code treated as implementation material rather than an agent-facing dependency. + +Provider quota can disable model-backed docs. It must not disable index/status. + +## Rust Runtime Direction + +`runtime.code-intel` and `runtime.code-nexus-lite` are real execution targets. + +`code-intel.exe orchestrate` is the preferred registry validator and plan reader. PowerShell scripts are allowed as stable Windows compatibility entrypoints, but they should not be treated as the source of truth when an equivalent Rust target owns the contract. The orchestration manifest must show the Rust crate first and any `.ps1` wrapper as compatibility. + +## Sentrux Direction + +`structure.sentrux` is internal-first. + +The repo-owned lite core is the normal-operation fallback. A real `sentrux.exe` can accelerate or enrich output, but agent workflows should depend on `Invoke-SentruxAgentTool.ps1` and the artifact contract, not on a global Sentrux install. + +Rust is the replacement target. The planned stable surface is `target\debug\code-intel.exe sentrux `. Until that command is complete, the PS1 file is compatibility glue and known debt. + +## Graph Direction + +`graph.code-intel-understand` is the internal provider contract. + +It emits `.understand-anything/knowledge-graph.json` through `target\debug\code-intel.exe graph --repo --language zh --write --json`. External `/understand` is now `graph.understand-external`, a compatibility fallback for richer Claude-side passes or internal provider failure. Future graph providers should preserve the same `architecture_graph` stage and artifact contract. + +## Runner Direction + +`run-code-intel.ps1` is also compatibility glue. The replacement target is `target\debug\code-intel.exe run --repo --mode `, with report writing, hospital diagnosis, graph generation, Repowise, and Sentrux orchestration moved behind Rust modules registered in this manifest. diff --git a/install-code-intel-pipeline.ps1 b/install-code-intel-pipeline.ps1 index fef654b..8f39503 100644 --- a/install-code-intel-pipeline.ps1 +++ b/install-code-intel-pipeline.ps1 @@ -722,6 +722,8 @@ if (-not [string]::IsNullOrWhiteSpace($Repo) -or -not [string]::IsNullOrWhiteSpa $doctorParams = @{ Config = $Config Json = $true + RequireRepowise = [bool]$RequireRepowise + RequireUnderstand = [bool]$RequireUnderstand } if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { $doctorParams.RepoPath = $RepoPath diff --git a/invoke-code-intel.ps1 b/invoke-code-intel.ps1 index 7f1b31e..d8929b4 100644 --- a/invoke-code-intel.ps1 +++ b/invoke-code-intel.ps1 @@ -35,6 +35,7 @@ if ([string]::IsNullOrWhiteSpace($Config)) { $doctor = Join-Path $root "check-code-intel-tools.ps1" $runner = Join-Path $root "run-code-intel.ps1" $indexer = Join-Path $root "update-code-intel-index.ps1" +$rustCli = Join-Path $root "target\debug\code-intel.exe" function Get-JsonProperty { param( @@ -114,6 +115,24 @@ if (-not (Test-Path -LiteralPath $doctor -PathType Leaf)) { if (-not (Test-Path -LiteralPath $runner -PathType Leaf)) { throw "Pipeline script missing: $runner" } +if (-not (Test-Path -LiteralPath $rustCli -PathType Leaf)) { + Push-Location $root + try { + & cargo build -p code-intel | Out-Host + } + finally { + Pop-Location + } +} +if (-not (Test-Path -LiteralPath $rustCli -PathType Leaf)) { + throw "Rust integration orchestrator missing: $rustCli" +} + +Write-Host "Code intel invoke: validate integration orchestration" +& $rustCli orchestrate --action Validate | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "Integration orchestration validation failed" +} $targetRepos = @() if ($All) { diff --git a/orchestration/integrations.json b/orchestration/integrations.json new file mode 100644 index 0000000..52525fc --- /dev/null +++ b/orchestration/integrations.json @@ -0,0 +1,334 @@ +{ + "schemaVersion": 1, + "policy": { + "name": "code-intel-integration-orchestration", + "normalOperation": "self-contained", + "rule": "Normal code-intel runs must use integrations registered here. Separately installed project-intelligence CLIs are allowed only as compatibility shims or optional accelerators.", + "extensionRule": "Add new projects, scanners, memory systems, graph providers, or governance strategies here before wiring them into pipeline scripts." + }, + "stages": [ + { + "id": "preflight", + "order": 10, + "required": true, + "description": "Resolve repo path and verify runtime plus registered integration contracts." + }, + { + "id": "rust_runtime", + "order": 15, + "required": true, + "description": "Compiled Rust command and worker targets used by the pipeline." + }, + { + "id": "inventory", + "order": 20, + "required": true, + "description": "Exact repo file and text surface." + }, + { + "id": "semantic_memory", + "order": 30, + "required": true, + "description": "Long-term project memory, index, status, and optional docs." + }, + { + "id": "architecture_graph", + "order": 40, + "required": false, + "description": "Architecture graph snapshot and graph freshness." + }, + { + "id": "structure_governance", + "order": 50, + "required": true, + "description": "Structural scan, rules, gate, DSM, evolution, and what-if signals." + }, + { + "id": "localization", + "order": 60, + "required": true, + "description": "Hotspot and reference localization for agents." + }, + { + "id": "diagnosis", + "order": 70, + "required": true, + "description": "Hospital diagnosis, next protocol, and surgery-plan routing." + }, + { + "id": "artifact_index", + "order": 80, + "required": true, + "description": "Durable artifact index for later sessions." + } + ], + "integrations": [ + { + "id": "doctor", + "stage": "preflight", + "owner": "code-intel-pipeline", + "kind": "internal-script", + "required": true, + "entrypoint": "check-code-intel-tools.ps1", + "capabilities": [ + "preflight", + "tool_contract", + "repo_state" + ], + "commands": { + "validate": "check-code-intel-tools.ps1 -RepoPath -RequireRepowise -Json" + }, + "artifactContract": [ + "doctor_json" + ], + "extensionPoint": "Add new preflight checks here before changing installer or runner checks." + }, + { + "id": "runtime.code-intel", + "stage": "rust_runtime", + "owner": "code-intel-pipeline", + "kind": "internal-rust-binary", + "required": true, + "entrypoint": "crates/code-intel-cli/Cargo.toml", + "capabilities": [ + "orchestration", + "integration_plan", + "global_route_registry", + "provider_api", + "understand_compatible_graph", + "artifact_resume", + "failure_classification", + "artifact_doctor" + ], + "commands": { + "build": "cargo build -p code-intel", + "orchestrateValidate": "target/debug/code-intel.exe orchestrate --action Validate --json", + "orchestrateList": "target/debug/code-intel.exe orchestrate --action List --json", + "orchestratePlan": "target/debug/code-intel.exe orchestrate --action Plan --repo --mode --json", + "graph": "target/debug/code-intel.exe graph --repo --language zh --write --json", + "graphFull": "target/debug/code-intel.exe graph --repo --language zh --full --write --json", + "routeValidate": "target/debug/code-intel.exe route --action Validate --json", + "routeList": "target/debug/code-intel.exe route --action List --json", + "routePlan": "target/debug/code-intel.exe route --action Plan --provider --operation --repo --json", + "providerValidate": "target/debug/code-intel.exe provider --action Validate --json", + "providerList": "target/debug/code-intel.exe provider --action List --json", + "providerPlan": "target/debug/code-intel.exe provider --action Plan --provider --operation --repo --json", + "providerInvoke": "target/debug/code-intel.exe provider --action Invoke --provider --operation --repo --json", + "doctor": "target/debug/code-intel.exe doctor --json", + "resume": "target/debug/code-intel.exe resume --repo --json", + "classify": "target/debug/code-intel.exe classify --report --json" + }, + "artifactContract": [ + "orchestration/integrations.json", + "report.json", + "hospital-report.json", + "understanding.md" + ], + "extensionPoint": "Integration orchestration plus artifact resume/classify behavior belongs in this Rust binary; PowerShell should call or validate it only as compatibility glue." + }, + { + "id": "runtime.code-nexus-lite", + "stage": "rust_runtime", + "owner": "code-intel-pipeline", + "kind": "compatibility-script", + "required": false, + "entrypoint": "Invoke-CodeNexusLite.ps1", + "capabilities": [ + "codenexus_scan", + "codenexus_lite", + "codenexus_doctor" + ], + "commands": { + "compat": "Invoke-CodeNexusLite.ps1 -RepoPath -ArtifactDir " + }, + "artifactContract": [ + "codenexus-context.json", + ".repowise/wiki.db" + ], + "extensionPoint": "CodeNexus-lite is currently a demoted compatibility adapter. Restore a hardened worker here only after the Rust entrypoint exists again." + }, + { + "id": "inventory.rg", + "stage": "inventory", + "owner": "code-intel-pipeline", + "kind": "internal-adapter", + "required": true, + "entrypoint": "run-code-intel.ps1", + "capabilities": [ + "file_inventory", + "text_inventory" + ], + "commands": { + "run": "run-code-intel.ps1 -RepoPath -Mode " + }, + "artifactContract": [ + "files.txt", + "report.json.steps[rg file inventory]" + ], + "extensionPoint": "Add inventory engines behind this adapter; do not call new inventory CLIs directly from agents." + }, + { + "id": "memory.repowise", + "stage": "semantic_memory", + "owner": "code-intel-pipeline", + "kind": "internalizing-adapter", + "required": true, + "entrypoint": "Invoke-ScopedRepowise.ps1", + "capabilities": [ + "status", + "index", + "scoped_shadow_workspace", + "docs" + ], + "commands": { + "status": "Invoke-ScopedRepowise.ps1 -RepoPath -ScopePaths -RootFiles ", + "docs": "run-code-intel.ps1 -RepoPath -Mode normal -RepowiseDocs" + }, + "artifactContract": [ + ".repowise/state.json", + ".repowise/wiki.db", + "report.json.steps[repowise*]", + "hospital-report.json.report_quality.dimensions[chart]" + ], + "extensionPoint": "Repowise CLI and Python imports must be hidden behind this adapter until the implementation is fully internalized." + }, + { + "id": "graph.code-intel-understand", + "stage": "architecture_graph", + "owner": "code-intel-pipeline", + "kind": "internal-rust-provider", + "required": true, + "entrypoint": "crates/code-intel-cli/Cargo.toml", + "capabilities": [ + "graph_state", + "freshness", + "understand_compatible_artifact" + ], + "commands": { + "refresh": "target/debug/code-intel.exe graph --repo --language zh --write --json", + "refreshFull": "target/debug/code-intel.exe graph --repo --language zh --full --write --json" + }, + "artifactContract": [ + ".understand-anything/knowledge-graph.json", + "report.json.steps[understand graph]" + ], + "extensionPoint": "Graph providers must emit the Understand-compatible artifact contract through this internal Rust command before runner scripts depend on them." + }, + { + "id": "graph.understand-external", + "stage": "architecture_graph", + "owner": "understand-anything", + "kind": "compatibility-fallback", + "required": false, + "entrypoint": "/understand", + "capabilities": [ + "manual_refresh_command", + "rich_agent_graph" + ], + "commands": { + "refresh": "/understand --language zh", + "refreshFull": "/understand --language zh --full" + }, + "artifactContract": [ + ".understand-anything/knowledge-graph.json" + ], + "extensionPoint": "Use only when the Rust graph provider fails or a richer external Understand Anything pass is explicitly requested." + }, + { + "id": "structure.sentrux", + "stage": "structure_governance", + "owner": "code-intel-pipeline", + "kind": "internal-first-adapter", + "required": true, + "entrypoint": "Invoke-SentruxAgentTool.ps1", + "capabilities": [ + "scan", + "health", + "session_start", + "session_end", + "rules", + "dsm", + "test_gaps", + "what_if", + "evolution" + ], + "commands": { + "rustTarget": "target/debug/code-intel.exe sentrux ", + "scan": "Invoke-SentruxAgentTool.ps1 scan ", + "sessionStart": "Invoke-SentruxAgentTool.ps1 session_start ", + "sessionEnd": "Invoke-SentruxAgentTool.ps1 session_end " + }, + "artifactContract": [ + "sentrux-dsm.json", + "sentrux-file-details.json", + "sentrux-hotspots.json", + "sentrux-evolution.json", + "sentrux-what-if.json" + ], + "extensionPoint": "External sentrux.exe is an accelerator; repo-owned lite core remains the normal-operation fallback." + }, + { + "id": "localization.codenexus-lite", + "stage": "localization", + "owner": "code-intel-pipeline", + "kind": "compatibility-adapter", + "required": true, + "entrypoint": "Invoke-CodeNexusLite.ps1", + "capabilities": [ + "hotspot_context", + "recent_commits", + "reference_hits" + ], + "commands": { + "compat": "Invoke-CodeNexusLite.ps1 -RepoPath -ArtifactDir " + }, + "artifactContract": [ + "codenexus-context.json" + ], + "extensionPoint": "Full CodeNexus backends must adapt to codenexus-context.json before replacing the compatibility adapter." + }, + { + "id": "diagnosis.hospital", + "stage": "diagnosis", + "owner": "code-intel-pipeline", + "kind": "internal-module", + "required": true, + "entrypoint": "run-code-intel.ps1", + "capabilities": [ + "triage", + "protocol", + "treatment_plan", + "surgery_plan" + ], + "commands": { + "run": "run-code-intel.ps1 -RepoPath -Mode " + }, + "artifactContract": [ + "hospital.md", + "hospital-report.json", + "surgery-plan.md", + "surgery-plan.json" + ], + "extensionPoint": "New diagnosis modes attach here and preserve hospital-report.json stable fields." + }, + { + "id": "artifact.index", + "stage": "artifact_index", + "owner": "code-intel-pipeline", + "kind": "internal-script", + "required": true, + "entrypoint": "update-code-intel-index.ps1", + "capabilities": [ + "artifact_discovery", + "latest_run_index" + ], + "commands": { + "refresh": "update-code-intel-index.ps1" + }, + "artifactContract": [ + "artifact-index.json" + ], + "extensionPoint": "New artifact stores register here; readers keep using the index contract." + } + ] +} diff --git a/test-code-intel-pipeline.ps1 b/test-code-intel-pipeline.ps1 index 7b29e36..abe2604 100644 --- a/test-code-intel-pipeline.ps1 +++ b/test-code-intel-pipeline.ps1 @@ -47,12 +47,18 @@ if ([string]::IsNullOrWhiteSpace($label)) { throw "Specify -Repo or -RepoPath ." } -$doctorJson = if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { - & $doctor -Config $Config -RepoPath $RepoPath -Platform $effectivePlatform -Json | ConvertFrom-Json +$doctorParams = @{ + Config = $Config + Platform = $effectivePlatform + RequireRepowise = -not [bool]$SkipRepowise + Json = $true } -else { - & $doctor -Config $Config -Repo $Repo -Platform $effectivePlatform -Json | ConvertFrom-Json +if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { + $doctorParams.RepoPath = $RepoPath +} else { + $doctorParams.Repo = $Repo } +$doctorJson = & $doctor @doctorParams | ConvertFrom-Json if (-not $doctorJson.ok) { throw "Doctor failed: $($doctorJson.missing -join ', ')" } diff --git a/test-integration-orchestration.ps1 b/test-integration-orchestration.ps1 new file mode 100644 index 0000000..2254aaa --- /dev/null +++ b/test-integration-orchestration.ps1 @@ -0,0 +1,50 @@ +param( + [string]$RepoPath = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSCommandPath +$rustCli = Join-Path $root "target\debug\code-intel.exe" + +if (-not (Test-Path -LiteralPath $rustCli -PathType Leaf)) { + Push-Location $root + try { + & cargo build -p code-intel | Out-Host + } + finally { + Pop-Location + } +} +if (-not (Test-Path -LiteralPath $rustCli -PathType Leaf)) { + throw "Missing Rust orchestrator: $rustCli" +} + +$validateRaw = & $rustCli orchestrate --action Validate --json +if ($LASTEXITCODE -ne 0) { + throw "Orchestration validate failed" +} +$validate = $validateRaw | ConvertFrom-Json +if (-not [bool]$validate.ok) { + throw "Orchestration manifest is not ok" +} + +$planArgs = @("orchestrate", "--action", "Plan", "--capability", "semantic_memory", "--json") +if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { + $planArgs += @("--repo", $RepoPath) +} +$planRaw = & $rustCli @planArgs +if ($LASTEXITCODE -ne 0) { + throw "Orchestration plan failed" +} +$plan = $planRaw | ConvertFrom-Json +$repowise = @($plan.plan | Where-Object { $_.id -eq "memory.repowise" }) +if ($repowise.Count -ne 1) { + throw "Expected one memory.repowise integration" +} +if (-not [bool]$repowise[0].required) { + throw "memory.repowise must be required" +} + +Write-Host "Integration orchestration smoke passed" diff --git a/tools/sentrux-shim/sentrux-lite-core.ps1 b/tools/sentrux-shim/sentrux-lite-core.ps1 index c1c6b62..2b922ef 100644 --- a/tools/sentrux-shim/sentrux-lite-core.ps1 +++ b/tools/sentrux-shim/sentrux-lite-core.ps1 @@ -68,6 +68,9 @@ function Test-SkippedPath { )) { return $true } + if ($part -match "^(\.venv|venv|env)-") { + return $true + } } return $false } @@ -124,6 +127,9 @@ function Measure-File { $text = "" try { $text = Get-Content -LiteralPath $File.FullName -Raw -ErrorAction Stop + if ($null -eq $text) { + $text = "" + } } catch { $text = ""