From 4be192665d5339ae47a2c80306ac2cc097cb56d1 Mon Sep 17 00:00:00 2001 From: Curry <57929895+2233admin@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:54:59 +0800 Subject: [PATCH 1/4] refactor(doctor): move the bootstrap probe into Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 of the PS1 retirement campaign. `archive/check-code-intel-tools.ps1` drops from 409 lines to a 35-line forwarder; the tool/runtime health inventory it implemented now lives in `crates/code-intel-cli/src/doctor_bootstrap.rs`, surfaced as `code-intel doctor bootstrap`. The observation contract is unchanged: same `code-intel-doctor-bootstrap-observation.v1` schema, same `observation_only` authority, same `ok`/`missing` pair and wording, same `checks.*` shape. Installer and CI consumers need no adaptation. Ported behaviors include config parsing, repo-alias and reverse-path lookup, `sentruxPath` scope resolution, the python/python3 fallback, the `sentrux check --help` and `sentrux pro status` output matching (the tier pattern is hand-rolled — the crate carries no regex dependency — and stays case-insensitive to match PowerShell `-match`), the understand skill/plugin candidate paths, release-before-debug graph binary discovery, and the `CODE_INTEL_HOME` default-derivation comparison. Fixes a real defect in passing. `doctor_adapter` used to shell out to the PowerShell probe and, when `pwsh` or the script could not be launched, fall back to an in-process approximation that reported `graphProvider` presence as hardcoded `true` — masking exactly the drift doctor exists to surface. With one native probe there is no fallback path and no `pwsh` dependency on the kernel path. Also repairs a path-resolution bug in the E09 compatibility tooling, introduced when PR #68 moved PowerShell under `archive/`: `New-DoctorWrapperRetirementPacket.ps1` and `Test-DoctorWrapperRetirementPacket.ps1` resolved their repo-root-relative frozen inputs (`crates/*`, `orchestration/*`) against `archive/` and threw `Get-FileHash: Could not find a part of the path` before reaching any real check. Neither script is wired into CI, which is why it went unnoticed. The verifier now reports its designed verdict. Verification: - cargo test -p code-intel: 45 suites, 0 failed - cargo fmt -p code-intel -- --check: clean - new tests/doctor_bootstrap_cli.rs pins the schema, exit code, and a <=50-line cap on the forwarder; 10 unit tests cover the probe - test-doctor-repo-config-resolution.ps1 passes through the forwarder - test-regression-fixes.ps1: 49 passed (its two doctor graph-provider cases now drive the binary with --pipeline-root, since a scratch tree's fake binary must never be executed as the real CLI) - test-code-intel-pipeline.ps1 -RepoPath . -SkipRepowise: ok Not in scope: `Invoke-ProviderRuntimeInventory.ps1`, the second PowerShell file named by the ticket. Refs #48 --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 5 +- CHANGELOG.md | 28 + archive/check-code-intel-tools.ps1 | 424 +------ .../scripts/tests/test-regression-fixes.ps1 | 26 +- .../New-DoctorWrapperRetirementPacket.ps1 | 5 +- .../Test-DoctorWrapperRetirementPacket.ps1 | 11 +- crates/code-intel-cli/src/doctor_adapter.rs | 134 +- crates/code-intel-cli/src/doctor_bootstrap.rs | 1124 +++++++++++++++++ crates/code-intel-cli/src/main.rs | 8 + .../tests/doctor_bootstrap_cli.rs | 220 ++++ docs/doctor-envelope.md | 22 +- orchestration/facade-finalize-policy.v1.json | 2 +- orchestration/integrations.json | 12 +- 14 files changed, 1507 insertions(+), 521 deletions(-) create mode 100644 crates/code-intel-cli/src/doctor_bootstrap.rs create mode 100644 crates/code-intel-cli/tests/doctor_bootstrap_cli.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af0db19..8020aab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,7 +200,9 @@ jobs: - name: Doctor shell: pwsh - run: .\archive\check-code-intel-tools.ps1 -RepoPath . -RequireRepowise:$false + # T3 (#48): the tool/runtime probe is native now; the PowerShell entry + # point is a thin forwarder onto this same subcommand. + run: code-intel doctor bootstrap --repo-path . --no-require-repowise - name: GitHub research contract tests shell: pwsh @@ -530,7 +532,8 @@ jobs: - name: Doctor shell: pwsh - run: ./archive/check-code-intel-tools.ps1 -RepoPath . -RequireRepowise:$false -Json + # T3 (#48): native probe; the PowerShell entry point now forwards here. + run: code-intel doctor bootstrap --repo-path . --no-require-repowise --json - name: Hospital fail-closed contract tests shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6611021..55bb3da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,8 +133,9 @@ jobs: - name: Doctor shell: pwsh # Runners don't install repowise; smoke runs on the lite fallback - # (same as ci.yml). RequireRepowise defaults to $true since 0.2.0. - run: .\archive\check-code-intel-tools.ps1 -RepoPath . -RequireRepowise:$false + # (same as ci.yml). The probe requires repowise by default, hence the + # explicit opt-out. Native since T3 (#48). + run: code-intel doctor bootstrap --repo-path . --no-require-repowise - name: GitHub research contract tests shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index c809912..f374258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **The doctor bootstrap probe is native Rust** (issue #48, T3 of the PS1 + retirement campaign). `code-intel doctor bootstrap` now computes the + tool/runtime health inventory that `archive/check-code-intel-tools.ps1` used + to implement in 409 lines of PowerShell; the script is a ~35-line thin + forwarder retained for the installer and rollback paths. The observation it + emits keeps the `code-intel-doctor-bootstrap-observation.v1` schema, the + `observation_only` authority, and the same `ok`/`missing` pair, so installer + and CI consumers are unchanged. + + ```bash + code-intel doctor bootstrap --repo-path . --no-require-repowise --json + ``` + + CI and release workflows call the binary directly. Running the forwarder on a + machine without the binary is now reported as a `code-intel binary` entry in + `missing` (exit 1) rather than as a crash. + +### Fixed + +- **The doctor capability no longer answers from a stub when `pwsh` is + absent.** The adapter previously shelled out to the PowerShell probe and, on + failure to launch it, fell back to an in-process approximation that reported + `graphProvider` presence as hardcoded `true` — masking exactly the drift the + doctor exists to surface. With one native probe there is no fallback path and + no `pwsh` dependency on the kernel path. + ## [0.7.0-beta.2] — 2026-07-30 ### Changed — action required diff --git a/archive/check-code-intel-tools.ps1 b/archive/check-code-intel-tools.ps1 index 8269a59..721e471 100644 --- a/archive/check-code-intel-tools.ps1 +++ b/archive/check-code-intel-tools.ps1 @@ -1,5 +1,11 @@ #requires -Version 7.2 +# Thin forwarder. The tool/runtime health probe this script used to implement +# now lives in Rust (`code-intel doctor bootstrap`, crates/code-intel-cli/src/ +# doctor_bootstrap.rs) — see issue #48 (T3). Kept as a compatibility entry +# point for installers and rollback paths; new call sites should invoke the +# binary directly. + param( [string]$Config = "", [string]$Repo = "", @@ -11,399 +17,43 @@ param( [switch]$Json ) -if (-not $PSBoundParameters.ContainsKey("RequireRepowise")) { - $RequireRepowise = $true -} +if (-not $PSBoundParameters.ContainsKey("RequireRepowise")) { $RequireRepowise = $true } Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$platformModule = Join-Path (Join-Path $PSScriptRoot "tools") "code-intel-platform.psm1" -Import-Module $platformModule -Force -$effectivePlatform = Get-CodeIntelPlatform -Platform $Platform - -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 Resolve-RepoPath { - param( - [string]$RepoInput, - [object]$ConfigData - ) - - if ([string]::IsNullOrWhiteSpace($RepoInput)) { return $null } - - $repoConfig = Resolve-RepoConfig $RepoInput $ConfigData - - $path = $RepoInput - if ($null -ne $repoConfig) { - $configuredPath = Get-JsonProperty $repoConfig "path" - if (-not [string]::IsNullOrWhiteSpace([string]$configuredPath)) { - $path = [string]$configuredPath - } - } - - if (Test-Path -LiteralPath $path -PathType Container) { - return (Get-Item -LiteralPath $path).FullName - } - - return $path -} - -function Resolve-RepoConfig { - param( - [string]$RepoInput, - [object]$ConfigData - ) - - if ([string]::IsNullOrWhiteSpace($RepoInput)) { return $null } - $reposConfig = Get-JsonProperty $ConfigData "repos" - if ($null -eq $reposConfig) { return $null } - return Get-JsonProperty $reposConfig $RepoInput -} - -function Find-RepoConfigByPath { - param( - [object]$ConfigData, - [string]$ResolvedRepoPath - ) - - if ($null -eq $ConfigData -or [string]::IsNullOrWhiteSpace($ResolvedRepoPath)) { return $null } - $reposConfig = Get-JsonProperty $ConfigData "repos" - if ($null -eq $reposConfig) { return $null } - - $normalizedRepoPath = [System.IO.Path]::TrimEndingDirectorySeparator($ResolvedRepoPath) - foreach ($entry in $reposConfig.PSObject.Properties) { - $configuredPath = Get-JsonProperty $entry.Value "path" - if ([string]::IsNullOrWhiteSpace([string]$configuredPath)) { continue } - try { - $resolvedConfiguredPath = Resolve-RepoPath ([string]$configuredPath) $null - } - catch { - continue - } - $normalizedConfiguredPath = [System.IO.Path]::TrimEndingDirectorySeparator($resolvedConfiguredPath) - if ([string]::Equals($normalizedConfiguredPath, $normalizedRepoPath, [System.StringComparison]::OrdinalIgnoreCase)) { - return $entry.Value - } - } - return $null -} - -function Resolve-SentruxScope { - param( - [string]$RepoPath, - [object]$RepoConfig - ) - - if ([string]::IsNullOrWhiteSpace($RepoPath)) { return $RepoPath } - $configuredScope = Get-JsonProperty $RepoConfig "sentruxPath" - if ([string]::IsNullOrWhiteSpace([string]$configuredScope)) { return $RepoPath } - - if ([System.IO.Path]::IsPathRooted([string]$configuredScope)) { - $scope = [string]$configuredScope - } - else { - $scope = Join-Path $RepoPath ([string]$configuredScope) - } - - if (Test-Path -LiteralPath $scope -PathType Container) { - return (Get-Item -LiteralPath $scope).FullName - } - - return $scope -} - -function Test-Tool { - param( - [string]$Name, - [bool]$Required = $true - ) - - $cmd = if ($Name -eq "python") { Get-CodeIntelPythonCommand } else { Get-Command $Name -ErrorAction SilentlyContinue } - [pscustomobject][ordered]@{ - name = $Name - required = $Required - found = [bool]$cmd - source = if ($cmd) { $cmd.Source } else { "" } - } -} - -function Test-CommandOutput { - param( - [string]$Name, - [scriptblock]$Body, - [string]$ExpectedPattern - ) - - try { - $global:LASTEXITCODE = 0 - $output = & $Body 2>&1 - $text = ($output | ForEach-Object { $_.ToString() } | Out-String).Trim() - [pscustomobject][ordered]@{ - name = $Name - found = ($global:LASTEXITCODE -eq 0 -and $text -match $ExpectedPattern) - output = $text - } - } - catch { - [pscustomobject][ordered]@{ - name = $Name - found = $false - output = $_.Exception.Message - } - } -} - -$configData = $null -$configParseError = $null -if ([string]::IsNullOrWhiteSpace($Config)) { - $Config = Join-Path (Split-Path -Parent $PSScriptRoot) "pipeline.config.json" -} -if (Test-Path -LiteralPath $Config -PathType Leaf) { - try { - $configData = Get-Content -LiteralPath $Config -Raw | ConvertFrom-Json - } - catch { - $configData = $null - $configParseError = $_.Exception.Message - } -} - -# this script lives under archive/; crates/, target/ and the default -# CODE_INTEL_HOME are all at the repository root above it -$archiveRoot = Split-Path -Parent $PSCommandPath -$pipelineRoot = Split-Path -Parent $archiveRoot -$pipelineScript = Join-Path $archiveRoot "run-code-intel.ps1" -$codeIntelCliRoot = Join-Path (Join-Path $pipelineRoot "crates") "code-intel-cli" -$codeIntelCargo = Join-Path $codeIntelCliRoot "Cargo.toml" -$codeIntelGraphSource = Join-Path (Join-Path $codeIntelCliRoot "src") "graph.rs" -$codeIntelGraphBinaryName = if ($effectivePlatform -eq "windows") { "code-intel.exe" } else { "code-intel" } -$codeIntelGraphBinaryCandidates = @( - (Join-Path (Join-Path (Join-Path $pipelineRoot "target") "release") $codeIntelGraphBinaryName), - (Join-Path (Join-Path (Join-Path $pipelineRoot "target") "debug") $codeIntelGraphBinaryName) -) -$codeIntelGraphBinary = @($codeIntelGraphBinaryCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf }) | Select-Object -First 1 -$codeIntelGraphCommandBinary = if ($null -ne $codeIntelGraphBinary) { $codeIntelGraphBinary } else { $codeIntelGraphBinaryCandidates[0] } -$repoConfig = Resolve-RepoConfig $Repo $configData -$repoInput = if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { $RepoPath } else { $Repo } -$repoPath = if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { - if (Test-Path -LiteralPath $RepoPath -PathType Container) { (Get-Item -LiteralPath $RepoPath).FullName } else { $RepoPath } -} -else { - Resolve-RepoPath $Repo $configData -} -if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { - $repoConfig = Find-RepoConfigByPath $configData $repoPath -} -$sentruxScope = Resolve-SentruxScope $repoPath $repoConfig - $pipelineRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) -$paths = Get-CodeIntelPaths -Platform $effectivePlatform -Root $pipelineRoot -$userProfile = Get-CodeIntelHomeDirectory -$understandSkillCandidates = @( - (Join-Path (Join-Path (Join-Path (Join-Path $userProfile ".claude") "skills") "understand") "SKILL.md"), - (Join-Path (Join-Path (Join-Path (Join-Path $userProfile ".agents") "skills") "understand") "SKILL.md"), - (Join-Path (Join-Path (Join-Path (Join-Path $userProfile ".codex") "skills") "understand") "SKILL.md") -) -$repoParentForCandidates = if (-not [string]::IsNullOrWhiteSpace([string]$repoPath)) { Split-Path -Parent $repoPath } else { $pipelineRoot } -$understandPluginCandidates = @( - (Join-Path (Join-Path (Join-Path $userProfile ".claude") "plugins") (Join-Path "cache" "understand-anything")), - (Join-Path $userProfile ".understand-anything-plugin"), - (Join-Path $repoParentForCandidates "Understand-Anything") -) - -$understandSkill = $understandSkillCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 -$understandPlugin = $understandPluginCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Container } | Select-Object -First 1 - -$repoState = $null -if (-not [string]::IsNullOrWhiteSpace([string]$repoPath) -and (Test-Path -LiteralPath $repoPath -PathType Container)) { - $knowledgeGraph = Join-Path (Join-Path $repoPath ".understand-anything") "knowledge-graph.json" - $repowiseDir = Join-Path $repoPath ".repowise" - $sentruxDir = Join-Path $sentruxScope ".sentrux" - $repoState = [ordered]@{ - path = $repoPath - exists = $true - isGitRepo = Test-Path -LiteralPath (Join-Path $repoPath ".git") - understandGraph = Test-Path -LiteralPath $knowledgeGraph -PathType Leaf - repowiseState = Test-Path -LiteralPath $repowiseDir -PathType Container - sentruxScope = $sentruxScope - sentruxRules = Test-Path -LiteralPath (Join-Path $sentruxDir "rules.toml") -PathType Leaf - sentruxBaseline = Test-Path -LiteralPath (Join-Path $sentruxDir "baseline.json") -PathType Leaf - } -} -elseif (-not [string]::IsNullOrWhiteSpace([string]$repoPath)) { - $repoState = [ordered]@{ - path = $repoPath - exists = $false - } -} - -# The structural gate engine ships inside the code-intel binary; an external -# sentrux on PATH is an optional overlay, not a bootstrap requirement. -$builtinSentrux = ($null -ne (Get-Command "code-intel" -ErrorAction SilentlyContinue)) -or - (Test-Path -LiteralPath (Join-Path $pipelineRoot "target/release/code-intel.exe") -PathType Leaf) -or - (Test-Path -LiteralPath (Join-Path $pipelineRoot "target/release/code-intel") -PathType Leaf) -or - (Test-Path -LiteralPath (Join-Path $pipelineRoot "target/debug/code-intel.exe") -PathType Leaf) -or - (Test-Path -LiteralPath (Join-Path $pipelineRoot "target/debug/code-intel") -PathType Leaf) -$tools = @( - Test-Tool "rg" $true - Test-Tool "git" $true - Test-Tool "python" $true - Test-Tool "repowise" ([bool]$RequireRepowise) - Test-Tool "repomix" $false - Test-Tool "sentrux" (-not $builtinSentrux) +$exe = if ($IsWindows) { "code-intel.exe" } else { "code-intel" } +# Repo-local builds win over an installed copy on PATH: this script probes +# *this* checkout, so a globally installed older binary must not answer for it. +$candidates = @( + (Join-Path $pipelineRoot "target/release/$exe"), + (Join-Path $pipelineRoot "target/debug/$exe"), + (Get-Command "code-intel" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source) ) -$sentruxCore = Test-CommandOutput "sentrux-core" { sentrux check --help } "Enforce architectural rules" -# Tier: free is healthy without the SENTRUX_AUTO_PRO opt-in (Pro auto-activation -# is opt-in; see tools/sentrux-shim/sentrux-shim.ps1). -$sentruxTierPattern = if ($env:SENTRUX_AUTO_PRO -in @("1", "true", "True", "TRUE")) { "Tier:\s+pro" } else { "Tier:\s+(pro|free)" } -$sentruxPro = Test-CommandOutput "sentrux-pro" { sentrux pro status } $sentruxTierPattern - -# CODE_INTEL_HOME must be compared against the default derivation (what -# Get-CodeIntelHome returns with the variable unset: the pipeline root), not -# against Get-CodeIntelHome's own env-derived output — that comparison passed -# for any set value, even a deleted directory. -$codeIntelHomeDefault = Resolve-CodeIntelPath $pipelineRoot -$codeIntelHomeSet = -not [string]::IsNullOrWhiteSpace($env:CODE_INTEL_HOME) -$codeIntelHomeResolved = if ($codeIntelHomeSet) { Resolve-CodeIntelPath $env:CODE_INTEL_HOME } else { "" } -$codeIntelHomeExists = $codeIntelHomeSet -and (Test-Path -LiteralPath $codeIntelHomeResolved -PathType Container) -$codeIntelHomeMatchesDefault = $codeIntelHomeSet -and $codeIntelHomeResolved -eq $codeIntelHomeDefault -# Warning only outside -Json: a child pwsh renders warnings on stdout, which -# would corrupt the JSON payload; matchesDefault/ok carry the signal there. -if (-not $Json -and $codeIntelHomeExists -and -not $codeIntelHomeMatchesDefault) { - Write-Warning "CODE_INTEL_HOME resolves to $codeIntelHomeResolved but the default derivation is $codeIntelHomeDefault" -} - -$checks = [ordered]@{ - pipelineScript = [ordered]@{ - path = $pipelineScript - found = Test-Path -LiteralPath $pipelineScript -PathType Leaf - } - config = [ordered]@{ - path = $Config - found = Test-Path -LiteralPath $Config -PathType Leaf - parsed = ($null -ne $configData -or [string]::IsNullOrWhiteSpace($configParseError)) - parseError = if ($null -ne $configParseError) { $configParseError } else { "" } - } - tools = $tools - sentrux = [ordered]@{ - core = $sentruxCore - pro = $sentruxPro - builtin = [ordered]@{ found = $builtinSentrux } - } - understandAnything = [ordered]@{ - skillFound = [bool]$understandSkill - skillPath = if ($understandSkill) { [string]$understandSkill } else { "" } - 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 = ($null -ne $codeIntelGraphBinary) - binaryPath = if ($null -ne $codeIntelGraphBinary) { $codeIntelGraphBinary } else { "" } - command = "$codeIntelGraphCommandBinary graph --repo --language zh --write --json" - } - repo = $repoState - env = [ordered]@{ - codeIntelHome = [ordered]@{ - expected = $codeIntelHomeDefault - value = if ($codeIntelHomeSet) { $env:CODE_INTEL_HOME } else { "" } - resolved = $codeIntelHomeResolved - exists = $codeIntelHomeExists - matchesDefault = $codeIntelHomeMatchesDefault - ok = ($codeIntelHomeExists -and $codeIntelHomeMatchesDefault) - } - } -} - -$missing = New-Object System.Collections.Generic.List[string] -if (-not $checks.pipelineScript.found) { $missing.Add("pipeline script") } -if (-not $checks.config.found) { $missing.Add("pipeline config") } -if ($checks.config.found -and -not $checks.config.parsed) { $missing.Add("pipeline config: invalid JSON ($configParseError)") } -foreach ($tool in $tools) { - if ($tool.required -and -not $tool.found) { $missing.Add($tool.name) } -} -if (-not $sentruxCore.found -and -not $builtinSentrux) { $missing.Add("sentrux core") } -if (-not $sentruxPro.found -and -not $builtinSentrux) { $missing.Add("sentrux pro auto-activation") } -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") } -if ($codeIntelHomeSet -and -not $codeIntelHomeExists) { $missing.Add("CODE_INTEL_HOME: directory does not exist ($codeIntelHomeResolved)") } - -$result = [ordered]@{ - schema = "code-intel-doctor-bootstrap-observation.v1" - authority = "observation_only" - ok = $missing.Count -eq 0 - missing = $missing - platform = [ordered]@{ - os = $effectivePlatform - shell = $PSVersionTable.PSEdition - psVersion = $PSVersionTable.PSVersion.ToString() - } - paths = [ordered]@{ - home = $paths.home - dataRoot = $paths.dataRoot - bin = $paths.bin - codeIntelHome = $paths.codeIntelHome - } - checks = $checks - strict = [ordered]@{ - requireRepowise = [bool]$RequireRepowise - requireUnderstand = [bool]$RequireUnderstand - } -} - -if ($Json) { - $result | ConvertTo-Json -Depth 8 -} -else { - if ($result.ok) { - Write-Host "Code intel doctor: OK" - } - else { - Write-Host "Code intel doctor: missing $($missing -join ', ')" - } - - Write-Host "Pipeline: $pipelineScript" - Write-Host "Config: $Config" - foreach ($tool in $tools) { - $mark = if ($tool.found) { "OK" } else { "MISSING" } - Write-Host "$mark $($tool.name) $($tool.source)" - } - $coreMark = if ($sentruxCore.found -or $builtinSentrux) { "OK" } else { "MISSING" } - $proMark = if ($sentruxPro.found -or $builtinSentrux) { "OK" } else { "MISSING" } - 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" } - $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)" - if ($repoState.exists) { - Write-Host "Understand graph: $($repoState.understandGraph)" - Write-Host "Repowise state: $($repoState.repowiseState)" - Write-Host "Sentrux scope: $($repoState.sentruxScope)" - Write-Host "Sentrux rules: $($repoState.sentruxRules)" - Write-Host "Sentrux baseline: $($repoState.sentruxBaseline)" - } - } -} - -if (-not $result.ok) { +$cli = @($candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and (Test-Path -LiteralPath $_ -PathType Leaf) }) | Select-Object -First 1 + +if ($null -eq $cli) { + # A missing binary is itself a doctor finding, not a crash: installers + # parse this JSON and must keep reporting the rest of their own checks. + $absent = [ordered]@{ + schema = "code-intel-doctor-bootstrap-observation.v1" + authority = "observation_only" + source = "forwarder" + ok = $false + missing = @("code-intel binary") + } + if ($Json) { $absent | ConvertTo-Json -Depth 4 } else { Write-Host "Code intel doctor: missing code-intel binary" } exit 1 } -exit 0 +$forward = @("doctor", "bootstrap", "--pipeline-root", $pipelineRoot, "--platform", $Platform) +if (-not [string]::IsNullOrWhiteSpace($Config)) { $forward += @("--config", $Config) } +if (-not [string]::IsNullOrWhiteSpace($Repo)) { $forward += @("--repo", $Repo) } +if (-not [string]::IsNullOrWhiteSpace($RepoPath)) { $forward += @("--repo-path", $RepoPath) } +$forward += if ($RequireRepowise) { "--require-repowise" } else { "--no-require-repowise" } +if ($RequireUnderstand) { $forward += "--require-understand" } +if ($Json) { $forward += "--json" } + +& $cli @forward +exit $LASTEXITCODE diff --git a/archive/scripts/tests/test-regression-fixes.ps1 b/archive/scripts/tests/test-regression-fixes.ps1 index 2ef2af0..7a6c0ac 100644 --- a/archive/scripts/tests/test-regression-fixes.ps1 +++ b/archive/scripts/tests/test-regression-fixes.ps1 @@ -1278,13 +1278,9 @@ Test-Case "installer: bundled skill parity ignores __pycache__ so a local bootst function New-DoctorScratchRoot { param([string]$Dir) - # mirror the real layout: the doctor and its platform module live under - # archive/, while crates/ and target/ stay at the repository root - $archiveDir = Join-Path $Dir "archive" - New-Item -ItemType Directory -Force -Path (Join-Path $archiveDir "tools") | Out-Null - Copy-Item -LiteralPath (Join-Path $root "check-code-intel-tools.ps1") -Destination (Join-Path $archiveDir "check-code-intel-tools.ps1") - Copy-Item -LiteralPath (Join-Path $root "tools\code-intel-platform.psm1") -Destination (Join-Path $archiveDir "tools\code-intel-platform.psm1") - + # mirror the real layout: archive/ holds the PowerShell entry points while + # crates/ and target/ stay at the repository root + New-Item -ItemType Directory -Force -Path (Join-Path $Dir "archive") | Out-Null $crateDir = Join-Path (Join-Path $Dir "crates") "code-intel-cli" New-Item -ItemType Directory -Force -Path (Join-Path $crateDir "src") | Out-Null Set-Content -LiteralPath (Join-Path $crateDir "Cargo.toml") -Value "[package]" -Encoding UTF8 @@ -1297,8 +1293,18 @@ function Invoke-DoctorScratch { [string[]]$ExtraArgs = @() ) - $doctor = Join-Path (Join-Path $Dir "archive") "check-code-intel-tools.ps1" - $raw = @(& pwsh -NoLogo -NoProfile -File $doctor -Json @ExtraArgs 2>&1) + # The probe is native since T3 (#48): drive the real binary against the + # scratch root rather than the retired script. The scratch tree's own + # target/ holds a fake binary on purpose, so it must never be the one run. + $binaryName = if ($IsWindows) { "code-intel.exe" } else { "code-intel" } + $repoRoot = Split-Path -Parent $root + $cli = @( + (Join-Path (Join-Path (Join-Path $repoRoot "target") "release") $binaryName), + (Join-Path (Join-Path (Join-Path $repoRoot "target") "debug") $binaryName) + ) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if ($null -eq $cli) { throw "code-intel binary not built; run cargo build -p code-intel" } + + $raw = @(& $cli doctor bootstrap --pipeline-root $Dir --json @ExtraArgs 2>&1) return ($raw -join "`n") | ConvertFrom-Json } @@ -1311,7 +1317,7 @@ Test-Case "doctor graph provider: a target/release platform binary satisfies the New-Item -ItemType Directory -Force -Path (Split-Path -Parent $releaseBinary) | Out-Null Set-Content -LiteralPath $releaseBinary -Value "fake binary" -Encoding UTF8 - $json = Invoke-DoctorScratch $dir @("-RequireUnderstand") + $json = Invoke-DoctorScratch $dir @("--require-understand") Assert-True $json.checks.graphProvider.sourceFound "chained Join-Path must find crates/code-intel-cli/src/graph.rs" Assert-True $json.checks.graphProvider.cargoFound "chained Join-Path must find crates/code-intel-cli/Cargo.toml" Assert-True $json.checks.graphProvider.binaryFound "a target/release build must satisfy the binary check (regression: only target\debug\code-intel.exe was probed)" diff --git a/archive/tools/compatibility/New-DoctorWrapperRetirementPacket.ps1 b/archive/tools/compatibility/New-DoctorWrapperRetirementPacket.ps1 index 975ae46..3d1109f 100644 --- a/archive/tools/compatibility/New-DoctorWrapperRetirementPacket.ps1 +++ b/archive/tools/compatibility/New-DoctorWrapperRetirementPacket.ps1 @@ -9,7 +9,10 @@ $routeMatches=@($patterns|ForEach-Object{$all=[regex]::Matches($base,$_);if($all $checkPath=Join-Path $RepoRoot "check-code-intel-tools.ps1";$checkHash=(Get-FileHash $checkPath -Algorithm SHA256).Hash.ToLowerInvariant();$registry=Get-Content (Join-Path $PipelineRepoRoot "orchestration/integrations.json") -Raw|ConvertFrom-Json;$doctor=@($registry.integrations|Where-Object id -eq doctor);if($doctor.Count-ne1-or$doctor[0].owner-ne"code-intel-pipeline"){throw "B07 doctor ownership missing"};$extension=[string]$doctor[0].extensionPoint;$nonAuthoritative=$extension-match'observation-only bootstrap';$expiryDeclared=$extension-match'expir|sunset|remove-after' $tests=@('doctor_cli_emits_one_envelope_and_snapshot_bound_redacted_observation','doctor_cli_fails_closed_without_verified_snapshot_input','doctor_cli_reports_nonconforming_provider_and_manifest_drift_as_domain_failure');foreach($n in $tests){& cargo test -q -p code-intel --test doctor_envelope $n -- --exact|Out-Null;if($LASTEXITCODE-ne0){throw "B10 test failed $n"}} $audit=& $CodeIntel orchestrate --action Validate --manifest (Join-Path $PipelineRepoRoot "orchestration/integrations.json") --json|ConvertFrom-Json;if(-not$audit.ok-or-not$audit.registryAudit.ok){throw "B07 audit failed"} -$frozen=@('invoke-code-intel.ps1','check-code-intel-tools.ps1','crates/code-intel-cli/src/doctor_adapter.rs','crates/code-intel-cli/tests/doctor_envelope.rs','crates/code-intel-cli/src/dag_run.rs','orchestration/integrations.json');$snapshot=S (($frozen|ForEach-Object{(Get-FileHash (Join-Path $RepoRoot $_) -Algorithm SHA256).Hash.ToLowerInvariant()})-join"`n") +# PowerShell entry points are $RepoRoot-relative (archive/); crates/ and +# orchestration/ stayed at the repository root one level above it. +function FrozenPath([string]$r){if($r-like'crates/*'-or$r-like'orchestration/*'){Join-Path $PipelineRepoRoot $r}else{Join-Path $RepoRoot $r}} +$frozen=@('invoke-code-intel.ps1','check-code-intel-tools.ps1','crates/code-intel-cli/src/doctor_adapter.rs','crates/code-intel-cli/tests/doctor_envelope.rs','crates/code-intel-cli/src/dag_run.rs','orchestration/integrations.json');$snapshot=S (($frozen|ForEach-Object{(Get-FileHash (FrozenPath $_) -Algorithm SHA256).Hash.ToLowerInvariant()})-join"`n") $rid="retire-doctor-wrapper-branch";$bid="invoke-code-intel.doctor.direct-production";$rep="doctor";$call="invoke-code-intel.ps1::$bid";$expiry=$EvaluatedAt+2592000 function E([string]$n,[string]$c,[object]$d){$v=[ordered]@{schema="code-intel-compatibility-retirement-evidence.v1";snapshotIdentity=$snapshot;id="e09.$n";evidenceClass=$c;retirementId=$rid;legacyBranchId=$bid;replacementCapabilityId=$rep;details=$d};$r="evidence/$n.json";W (Join-Path $OutDir $r) $v;Ref "code-intel-compatibility-retirement-evidence.v1" "compatibility.retirement-evidence" $r} $atom=E "replacement-atom" "replacement_atom" ([ordered]@{outcome="blocked";status="pending_public_preflight_route_and_bootstrap_expiry";capability=$rep;b10EnvelopeAvailable=$true;publicWrapperUsesDirectDoctor=$true;retainedBootstrap="check-code-intel-tools.ps1";bootstrapNonAuthoritative=$nonAuthoritative;bootstrapRegistered=$true;bootstrapOwner="code-intel-pipeline";bootstrapExpiryDeclared=$expiryDeclared;blocker="public preflight still invokes the bootstrap directly and retained bootstrap has no explicit expiry"}) diff --git a/archive/tools/compatibility/Test-DoctorWrapperRetirementPacket.ps1 b/archive/tools/compatibility/Test-DoctorWrapperRetirementPacket.ps1 index a7a9911..1a1e2eb 100644 --- a/archive/tools/compatibility/Test-DoctorWrapperRetirementPacket.ps1 +++ b/archive/tools/compatibility/Test-DoctorWrapperRetirementPacket.ps1 @@ -31,8 +31,17 @@ $snapshotInputs = @( "crates/code-intel-cli/src/doctor_adapter.rs", "crates/code-intel-cli/tests/doctor_envelope.rs", "crates/code-intel-cli/src/dag_run.rs", "orchestration/integrations.json" ) +# PowerShell entry points are $RepoRoot-relative (archive/); crates/ and +# orchestration/ stayed at the repository root one level above it. Must match +# New-DoctorWrapperRetirementPacket.ps1's FrozenPath resolution exactly. +function Resolve-FrozenPath([string]$Relative) { + if ($Relative -like "crates/*" -or $Relative -like "orchestration/*") { + return (Join-Path $PipelineRepoRoot $Relative) + } + return (Join-Path $RepoRoot $Relative) +} $currentSnapshotIdentity = Get-Sha256Text (($snapshotInputs | ForEach-Object { - (Get-FileHash -LiteralPath (Join-Path $RepoRoot $_) -Algorithm SHA256).Hash.ToLowerInvariant() + (Get-FileHash -LiteralPath (Resolve-FrozenPath $_) -Algorithm SHA256).Hash.ToLowerInvariant() }) -join "`n") foreach ($artifact in @($ticket, $manifest, $decision, $diff)) { if ($artifact.snapshotIdentity -ne $currentSnapshotIdentity) { throw "E09 packet is stale relative to its frozen source set" } diff --git a/crates/code-intel-cli/src/doctor_adapter.rs b/crates/code-intel-cli/src/doctor_adapter.rs index 4c982c2..4e9ba46 100644 --- a/crates/code-intel-cli/src/doctor_adapter.rs +++ b/crates/code-intel-cli/src/doctor_adapter.rs @@ -10,8 +10,12 @@ use crate::adapter_contract::AdapterDomainVerdict; use crate::artifact_ref::VerifiedArtifact; use crate::capability::sha256_hex; -#[path = "tool_path.rs"] -mod tool_path; +// Included by path rather than imported from the crate root: several +// integration tests pull this adapter into their own crate via `#[path]`, and +// those roots do not declare the binary's module list. Same convention the +// adapter already used for `tool_path`. +#[path = "doctor_bootstrap.rs"] +mod doctor_bootstrap; pub(crate) fn execute( request: &Value, @@ -155,111 +159,28 @@ fn validate_snapshot_input( Ok(()) } +/// Run the bootstrap probe in-process. +/// +/// Before T3 this shelled out to `archive/check-code-intel-tools.ps1` and fell +/// back to a hand-written stub when `pwsh` or the script was absent — a stub +/// that answered `graphProvider` with hardcoded `true`s and so could not +/// report the very drift doctor exists to catch. The probe is native now, so +/// there is one implementation, no `pwsh` dependency on the kernel path, and +/// the same answers on every platform. fn run_bootstrap(options: &Options) -> Result { - // The PowerShell bootstrap stays authoritative when it is present; a - // kernel run must not process-fail just because the script or pwsh is - // absent (bare `run execute`, extracted release package). Contract - // failures do NOT fall back: a script that ran and produced a - // nonconforming observation is an integrity signal, not an absence. - match run_script_bootstrap(options) { - Ok(value) => Ok(value), - Err(AdapterError::Unavailable(_)) => Ok(native_bootstrap(options)), - Err(error) => Err(error), - } -} - -fn native_bootstrap(options: &Options) -> Value { - let prefix = options.tool_path_prefix.as_deref(); - let rg = tool_available("rg", prefix); - let git = tool_available("git", prefix); - let repowise = tool_available("repowise", prefix); - let understand = tool_available("understand", prefix); - let external_sentrux = tool_available("sentrux", prefix); - let mut ok = rg && git && options.repo_path.is_dir(); - if options.require_repowise { - ok &= repowise; - } - if options.require_understand { - ok &= understand; - } - json!({ - "schema":"code-intel-doctor-bootstrap-observation.v1", - "authority":"observation_only", - "source":"native-fallback", - "ok": ok, - "checks":{ - "repo":{"exists": options.repo_path.is_dir()}, - "tools":[ - {"name":"rg","required":true,"found":rg}, - {"name":"git","required":true,"found":git}, - {"name":"repowise","required":options.require_repowise,"found":repowise}, - {"name":"understand","required":options.require_understand,"found":understand}, - {"name":"sentrux","required":false,"found":external_sentrux} - ], - "sentrux":{ - "builtin":{"found":true}, - "core":{"found":external_sentrux}, - "pro":{"found":false} - }, - "graphProvider":{"sourceFound":true,"cargoFound":true,"binaryFound":true} - } - }) -} - -fn tool_available(name: &str, prefix: Option<&Path>) -> bool { - // Delegates to the same candidate-name/PATH search `tool_path::resolve` - // uses to pick an absolute path for `Command::new`, so presence-checking - // here and path-resolution at the actual launch site never drift apart. - tool_path::locate(name, prefix).is_some() -} - -fn run_script_bootstrap(options: &Options) -> Result { - let script = pipeline_root().join("archive/check-code-intel-tools.ps1"); - if !script.is_file() { - return Err(AdapterError::Unavailable(format!( - "doctor bootstrap adapter is unavailable: {}", - script.display() - ))); - } - let mut command = Command::new("pwsh"); - command - .args(["-NoLogo", "-NoProfile", "-File"]) - .arg(&script) - .arg("-RepoPath") - .arg(&options.repo_path) - .arg("-Platform") - .arg(&options.platform) - .arg(format!("-RequireRepowise:${}", options.require_repowise)) - .arg(format!( - "-RequireUnderstand:${}", - options.require_understand - )) - .arg("-Json"); - if let Some(config) = &options.config_path { - command.arg("-Config").arg(config); - } - if let Some(prefix) = &options.tool_path_prefix { - let mut paths = vec![prefix.clone()]; - paths.extend(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - )); - let path = std::env::join_paths(paths).map_err(|error| { - AdapterError::InvalidOptions(format!("compose options.toolPathPrefix PATH: {error}")) - })?; - command - .env_remove("PATH") - .env_remove("Path") - .env("PATH", path); - } - let output = command - .output() - .map_err(|error| AdapterError::Unavailable(format!("start doctor bootstrap: {error}")))?; - let value: Value = serde_json::from_slice(&output.stdout).map_err(|error| { - AdapterError::Contract(format!( - "doctor bootstrap stdout is not one JSON observation: {error}" - )) - })?; - if value["schema"] != "code-intel-doctor-bootstrap-observation.v1" + let mut probe = doctor_bootstrap::Options::new(pipeline_root()); + probe.repo_path = Some(options.repo_path.to_string_lossy().into_owned()); + probe.config = options.config_path.clone(); + probe.platform = options.platform.clone(); + probe.require_repowise = options.require_repowise; + probe.require_understand = options.require_understand; + probe.tool_path_prefix = options.tool_path_prefix.clone(); + let value = doctor_bootstrap::observe(&probe) + .map_err(|error| AdapterError::InvalidOptions(format!("doctor bootstrap: {error}")))?; + // Kept as an explicit boundary check rather than an invariant assumed from + // the call above: the adapter must never publish an observation that does + // not carry the non-authoritative v1 contract, whoever produced it. + if value["schema"] != doctor_bootstrap::BOOTSTRAP_SCHEMA || value["authority"] != "observation_only" || !value["ok"].is_boolean() { @@ -573,6 +494,7 @@ mod tests { .unwrap(); for relative in [ "crates/code-intel-cli/src/doctor_adapter.rs", + "crates/code-intel-cli/src/doctor_bootstrap.rs", "crates/code-intel-cli/src/capability_inventory.rs", ] { let actual = sha256_hex(&fs::read(root.join(relative)).unwrap()); diff --git a/crates/code-intel-cli/src/doctor_bootstrap.rs b/crates/code-intel-cli/src/doctor_bootstrap.rs new file mode 100644 index 0000000..c75f6ed --- /dev/null +++ b/crates/code-intel-cli/src/doctor_bootstrap.rs @@ -0,0 +1,1124 @@ +//! Native bootstrap/environment probe — the Rust owner of what +//! `archive/check-code-intel-tools.ps1` used to compute in PowerShell. +//! +//! Emits `code-intel-doctor-bootstrap-observation.v1`, the same +//! non-authoritative observation the doctor capability adapter consumes. The +//! PowerShell entry point is now a thin forwarder onto this module, so there +//! is exactly one implementation of the probe instead of a script plus a +//! divergent in-process fallback that hardcoded its graph-provider answers. +//! +//! Everything here is observation only: it reports presence and readiness of +//! tools, providers, config and repository state. It never writes, never +//! claims admissibility, and never emits engineering facts — those boundaries +//! belong to `doctor_adapter`. + +use std::env; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value}; + +#[path = "tool_path.rs"] +mod tool_path; + +/// Marker the doctor capability adapter matches on. Kept as a constant so the +/// probe and the adapter's contract check cannot drift. +pub(crate) const BOOTSTRAP_SCHEMA: &str = "code-intel-doctor-bootstrap-observation.v1"; + +/// Substring `sentrux check --help` must print for the core overlay to count +/// as conforming. PowerShell's `-match` is case-insensitive, so this compares +/// case-insensitively too. +const SENTRUX_CORE_MARKER: &str = "Enforce architectural rules"; + +pub(crate) struct Options { + /// Repo alias resolved through `pipeline.config.json`'s `repos` map. + pub(crate) repo: Option, + /// Explicit repository path; takes precedence over `repo`. + pub(crate) repo_path: Option, + /// Pipeline config path; defaults to `/pipeline.config.json`. + pub(crate) config: Option, + /// `auto` | `windows` | `macos` | `linux`. + pub(crate) platform: String, + pub(crate) require_repowise: bool, + pub(crate) require_understand: bool, + /// Directory searched ahead of `PATH` when probing for tools. Lets a test + /// stand up a fixture toolchain without mutating the process environment. + pub(crate) tool_path_prefix: Option, + /// Repository root holding `crates/`, `target/` and `archive/`. + pub(crate) pipeline_root: PathBuf, +} + +impl Options { + pub(crate) fn new(pipeline_root: PathBuf) -> Self { + Self { + repo: None, + repo_path: None, + config: None, + platform: "auto".into(), + require_repowise: true, + require_understand: false, + tool_path_prefix: None, + pipeline_root, + } + } +} + +/// Run the probe and return the observation document. +pub(crate) fn observe(options: &Options) -> Result { + let platform = resolve_platform(&options.platform)?; + let prefix = options.tool_path_prefix.as_deref(); + + let config_path = match &options.config { + Some(path) => path.clone(), + None => options.pipeline_root.join("pipeline.config.json"), + }; + let (config_data, config_parse_error) = load_config(&config_path); + + let repo_path = resolve_repo_path(options, config_data.as_ref()); + let repo_config = match (&options.repo_path, &repo_path) { + // An explicit -RepoPath wins over the alias, so the config entry has + // to be found by reverse path lookup rather than by name. + (Some(_), Some(path)) => find_repo_config_by_path(config_data.as_ref(), path), + _ => options + .repo + .as_deref() + .and_then(|alias| repo_config_by_alias(config_data.as_ref(), alias)), + }; + let sentrux_scope = repo_path + .as_ref() + .map(|path| resolve_sentrux_scope(path, repo_config.as_ref())); + + let pipeline_script = options + .pipeline_root + .join("archive") + .join("run-code-intel.ps1"); + let cli_root = options.pipeline_root.join("crates").join("code-intel-cli"); + let graph_source = cli_root.join("src").join("graph.rs"); + let graph_cargo = cli_root.join("Cargo.toml"); + let binary_name = binary_name(&platform); + let binary_candidates = [ + options + .pipeline_root + .join("target") + .join("release") + .join(&binary_name), + options + .pipeline_root + .join("target") + .join("debug") + .join(&binary_name), + ]; + let graph_binary = binary_candidates + .iter() + .find(|path| path.is_file()) + .cloned(); + let graph_command_binary = graph_binary + .clone() + .unwrap_or_else(|| binary_candidates[0].clone()); + + // The structural gate engine ships inside the code-intel binary; an + // external sentrux on PATH is an optional overlay, not a bootstrap + // requirement. + let builtin_sentrux = tool_path::locate("code-intel", prefix).is_some() + || ["release", "debug"].iter().any(|profile| { + ["code-intel.exe", "code-intel"].iter().any(|name| { + options + .pipeline_root + .join("target") + .join(profile) + .join(name) + .is_file() + }) + }); + + let tools = vec![ + probe_tool("rg", true, prefix), + probe_tool("git", true, prefix), + probe_python(prefix), + probe_tool("repowise", options.require_repowise, prefix), + probe_tool("repomix", false, prefix), + probe_tool("sentrux", !builtin_sentrux, prefix), + ]; + + let sentrux_core = probe_command_output( + "sentrux-core", + "sentrux", + &["check", "--help"], + prefix, + |text| contains_ignore_case(text, SENTRUX_CORE_MARKER), + ); + // Tier: free is healthy without the SENTRUX_AUTO_PRO opt-in (Pro + // auto-activation is opt-in; see archive/tools/sentrux-shim/sentrux-shim.ps1). + let require_pro_tier = matches!( + env::var("SENTRUX_AUTO_PRO").unwrap_or_default().as_str(), + "1" | "true" | "True" | "TRUE" + ); + let sentrux_pro = probe_command_output( + "sentrux-pro", + "sentrux", + &["pro", "status"], + prefix, + |text| matches_tier(text, require_pro_tier), + ); + + let home_dir = home_directory(); + let understand_skill = [".claude", ".agents", ".codex"] + .iter() + .map(|agent| { + home_dir + .join(agent) + .join("skills") + .join("understand") + .join("SKILL.md") + }) + .find(|path| path.is_file()); + let repo_parent = repo_path + .as_ref() + .and_then(|path| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| options.pipeline_root.clone()); + let understand_plugin = [ + home_dir + .join(".claude") + .join("plugins") + .join("cache") + .join("understand-anything"), + home_dir.join(".understand-anything-plugin"), + repo_parent.join("Understand-Anything"), + ] + .into_iter() + .find(|path| path.is_dir()); + + let repo_state = repo_state(repo_path.as_deref(), sentrux_scope.as_deref()); + + let code_intel_home_default = resolve_code_intel_path(&options.pipeline_root); + let code_intel_home_value = env::var("CODE_INTEL_HOME").unwrap_or_default(); + let code_intel_home_set = !code_intel_home_value.trim().is_empty(); + let code_intel_home_resolved = if code_intel_home_set { + display(&resolve_code_intel_path(Path::new(&code_intel_home_value))) + } else { + String::new() + }; + let code_intel_home_exists = + code_intel_home_set && Path::new(&code_intel_home_resolved).is_dir(); + let code_intel_home_matches_default = + code_intel_home_set && code_intel_home_resolved == display(&code_intel_home_default); + + let checks = json!({ + "pipelineScript": { + "path": display(&pipeline_script), + "found": pipeline_script.is_file() + }, + "config": { + "path": display(&config_path), + "found": config_path.is_file(), + "parsed": config_data.is_some() || config_parse_error.is_none(), + "parseError": config_parse_error.clone().unwrap_or_default() + }, + "tools": tools, + "sentrux": { + "core": sentrux_core, + "pro": sentrux_pro, + "builtin": {"found": builtin_sentrux} + }, + "understandAnything": { + "skillFound": understand_skill.is_some(), + "skillPath": understand_skill.as_deref().map(display).unwrap_or_default(), + "pluginFound": understand_plugin.is_some(), + "pluginPath": understand_plugin.as_deref().map(display).unwrap_or_default() + }, + "graphProvider": { + "sourceFound": graph_source.is_file(), + "cargoFound": graph_cargo.is_file(), + "binaryFound": graph_binary.is_some(), + "binaryPath": graph_binary.as_deref().map(display).unwrap_or_default(), + "command": format!( + "{} graph --repo --language zh --write --json", + display(&graph_command_binary) + ) + }, + "repo": repo_state, + "env": { + "codeIntelHome": { + "expected": display(&code_intel_home_default), + "value": if code_intel_home_set { code_intel_home_value.clone() } else { String::new() }, + "resolved": code_intel_home_resolved.clone(), + "exists": code_intel_home_exists, + "matchesDefault": code_intel_home_matches_default, + "ok": code_intel_home_exists && code_intel_home_matches_default + } + } + }); + + let missing = missing_list( + &checks, + &tools, + builtin_sentrux, + options.require_understand, + config_parse_error.as_deref(), + code_intel_home_set, + code_intel_home_exists, + &code_intel_home_resolved, + ); + + let paths = platform_paths(&platform, &options.pipeline_root); + Ok(json!({ + "schema": BOOTSTRAP_SCHEMA, + "authority": "observation_only", + "source": "native", + "ok": missing.is_empty(), + "missing": missing, + "platform": { + "os": platform, + "shell": "Rust", + "psVersion": "" + }, + "paths": paths, + "checks": checks, + "strict": { + "requireRepowise": options.require_repowise, + "requireUnderstand": options.require_understand + } + })) +} + +/// The `missing` list, in the order the PowerShell probe emitted it — several +/// callers (installer checks, CI logs) read it as a comma-joined string. +#[allow(clippy::too_many_arguments)] +fn missing_list( + checks: &Value, + tools: &[Value], + builtin_sentrux: bool, + require_understand: bool, + config_parse_error: Option<&str>, + code_intel_home_set: bool, + code_intel_home_exists: bool, + code_intel_home_resolved: &str, +) -> Vec { + let flag = |pointer: &str| { + checks + .pointer(pointer) + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let mut missing = Vec::new(); + if !flag("/pipelineScript/found") { + missing.push("pipeline script".to_string()); + } + if !flag("/config/found") { + missing.push("pipeline config".to_string()); + } + if flag("/config/found") && !flag("/config/parsed") { + missing.push(format!( + "pipeline config: invalid JSON ({})", + config_parse_error.unwrap_or_default() + )); + } + for tool in tools { + if tool["required"].as_bool().unwrap_or(false) && !tool["found"].as_bool().unwrap_or(false) + { + missing.push(tool["name"].as_str().unwrap_or_default().to_string()); + } + } + if !flag("/sentrux/core/found") && !builtin_sentrux { + missing.push("sentrux core".to_string()); + } + if !flag("/sentrux/pro/found") && !builtin_sentrux { + missing.push("sentrux pro auto-activation".to_string()); + } + if require_understand && !flag("/graphProvider/sourceFound") { + missing.push("internal graph provider source".to_string()); + } + if require_understand && !flag("/graphProvider/cargoFound") { + missing.push("code-intel Rust runtime".to_string()); + } + if checks["repo"].is_object() && !flag("/repo/exists") { + missing.push("repo path".to_string()); + } + if code_intel_home_set && !code_intel_home_exists { + missing.push(format!( + "CODE_INTEL_HOME: directory does not exist ({code_intel_home_resolved})" + )); + } + missing +} + +fn repo_state(repo_path: Option<&Path>, sentrux_scope: Option<&Path>) -> Value { + let Some(repo_path) = repo_path else { + return Value::Null; + }; + if !repo_path.is_dir() { + return json!({"path": display(repo_path), "exists": false}); + } + let scope = sentrux_scope.unwrap_or(repo_path); + let sentrux_dir = scope.join(".sentrux"); + json!({ + "path": display(repo_path), + "exists": true, + "isGitRepo": repo_path.join(".git").exists(), + "understandGraph": repo_path + .join(".understand-anything") + .join("knowledge-graph.json") + .is_file(), + "repowiseState": repo_path.join(".repowise").is_dir(), + "sentruxScope": display(scope), + "sentruxRules": sentrux_dir.join("rules.toml").is_file(), + "sentruxBaseline": sentrux_dir.join("baseline.json").is_file() + }) +} + +fn load_config(path: &Path) -> (Option, Option) { + if !path.is_file() { + return (None, None); + } + match std::fs::read(path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(value) => (Some(value), None), + Err(error) => (None, Some(error.to_string())), + }, + Err(error) => (None, Some(error.to_string())), + } +} + +fn repo_config_by_alias<'a>(config: Option<&'a Value>, alias: &str) -> Option<&'a Value> { + config?.get("repos")?.get(alias) +} + +/// Reverse lookup: which configured repo entry points at `repo_path`. Mirrors +/// the PowerShell `Find-RepoConfigByPath`, including its trailing-separator +/// trim and case-insensitive comparison. +fn find_repo_config_by_path<'a>(config: Option<&'a Value>, repo_path: &Path) -> Option<&'a Value> { + let repos = config?.get("repos")?.as_object()?; + let target = trim_trailing_separator(&display(repo_path)); + repos.values().find(|entry| { + entry + .get("path") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .is_some_and(|path| { + let resolved = display(&resolve_code_intel_path(Path::new(path))); + trim_trailing_separator(&resolved).eq_ignore_ascii_case(&target) + }) + }) +} + +fn resolve_repo_path(options: &Options, config: Option<&Value>) -> Option { + if let Some(repo_path) = options + .repo_path + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let path = PathBuf::from(repo_path); + return Some(if path.is_dir() { + resolve_code_intel_path(&path) + } else { + path + }); + } + let alias = options + .repo + .as_deref() + .filter(|value| !value.trim().is_empty())?; + let configured = repo_config_by_alias(config, alias) + .and_then(|entry| entry.get("path")) + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()); + let path = PathBuf::from(configured.unwrap_or(alias)); + Some(if path.is_dir() { + resolve_code_intel_path(&path) + } else { + path + }) +} + +fn resolve_sentrux_scope(repo_path: &Path, repo_config: Option<&&Value>) -> PathBuf { + let configured = repo_config + .and_then(|entry| entry.get("sentruxPath")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let Some(configured) = configured else { + return repo_path.to_path_buf(); + }; + let scope = if Path::new(configured).is_absolute() { + PathBuf::from(configured) + } else { + repo_path.join(configured) + }; + if scope.is_dir() { + resolve_code_intel_path(&scope) + } else { + scope + } +} + +fn probe_tool(name: &str, required: bool, prefix: Option<&Path>) -> Value { + let found = tool_path::locate(name, prefix); + json!({ + "name": name, + "required": required, + "found": found.is_some(), + "source": found.as_deref().map(display).unwrap_or_default() + }) +} + +/// `python` falls back to `python3`, matching `Get-CodeIntelPythonCommand`. +/// The reported `name` stays `python` so the `missing` list wording does not +/// change with which interpreter happened to be installed. +fn probe_python(prefix: Option<&Path>) -> Value { + let found = + tool_path::locate("python", prefix).or_else(|| tool_path::locate("python3", prefix)); + json!({ + "name": "python", + "required": true, + "found": found.is_some(), + "source": found.as_deref().map(display).unwrap_or_default() + }) +} + +/// Run `program args...` and decide `found` from exit status plus a predicate +/// over the merged stdout/stderr text. A program that cannot be located or +/// launched is a `found: false` observation, never an error: absence of an +/// optional overlay is exactly what this probe exists to report. +fn probe_command_output( + name: &str, + program: &str, + args: &[&str], + prefix: Option<&Path>, + matches: impl Fn(&str) -> bool, +) -> Value { + let Some(binary) = tool_path::locate(program, prefix) else { + return json!({ + "name": name, + "found": false, + "output": format!("{program} was not found on PATH") + }); + }; + let mut command = Command::new(&binary); + command.args(args); + if let Some(prefix) = prefix { + if let Some(path) = prefixed_path(prefix) { + command + .env_remove("PATH") + .env_remove("Path") + .env("PATH", path); + } + } + match command.output() { + Ok(output) => { + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + let text = text.trim().to_string(); + json!({ + "name": name, + "found": output.status.success() && matches(&text), + "output": text + }) + } + Err(error) => json!({"name": name, "found": false, "output": error.to_string()}), + } +} + +fn prefixed_path(prefix: &Path) -> Option { + let mut paths = vec![prefix.to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + env::join_paths(paths).ok() +} + +/// `Tier:\s+pro` when Pro auto-activation is opted into, `Tier:\s+(pro|free)` +/// otherwise. Hand-rolled because the crate carries no regex dependency, and +/// case-insensitive to match PowerShell `-match` semantics. +fn matches_tier(text: &str, require_pro: bool) -> bool { + let lower = text.to_ascii_lowercase(); + let mut rest = lower.as_str(); + while let Some(index) = rest.find("tier:") { + let after = &rest[index + "tier:".len()..]; + let trimmed = after.trim_start_matches([' ', '\t', '\r', '\n']); + if trimmed.len() < after.len() { + if trimmed.starts_with("pro") || (!require_pro && trimmed.starts_with("free")) { + return true; + } + } + rest = after; + } + false +} + +fn contains_ignore_case(text: &str, needle: &str) -> bool { + text.to_ascii_lowercase() + .contains(&needle.to_ascii_lowercase()) +} + +fn platform_paths(platform: &str, pipeline_root: &Path) -> Value { + let home = home_directory(); + let data_root = data_root(platform, &home); + let bin = match env::var("CODE_INTEL_BIN") { + Ok(value) if !value.trim().is_empty() => resolve_code_intel_path(Path::new(&value)), + _ => data_root.join("bin"), + }; + let code_intel_home = match env::var("CODE_INTEL_HOME") { + Ok(value) if !value.trim().is_empty() => resolve_code_intel_path(Path::new(&value)), + _ => resolve_code_intel_path(pipeline_root), + }; + json!({ + "home": display(&home), + "dataRoot": display(&data_root), + "bin": display(&bin), + "codeIntelHome": display(&code_intel_home) + }) +} + +fn data_root(platform: &str, home: &Path) -> PathBuf { + if let Ok(value) = env::var("CODE_INTEL_DATA_ROOT") { + if !value.trim().is_empty() { + return resolve_code_intel_path(Path::new(&value)); + } + } + match platform { + "windows" => env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .filter(|base| !base.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".code-intel")) + .join("code-intel"), + "macos" => home + .join("Library") + .join("Application Support") + .join("code-intel"), + _ => env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|base| !base.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".local").join("share")) + .join("code-intel"), + } +} + +fn home_directory() -> PathBuf { + let raw = if cfg!(windows) { + env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) + } else { + env::var_os("HOME") + }; + raw.map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .map(|path| resolve_code_intel_path(&path)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn binary_name(platform: &str) -> String { + if platform == "windows" { + "code-intel.exe".into() + } else { + "code-intel".into() + } +} + +pub(crate) fn resolve_platform(requested: &str) -> Result { + match requested { + "windows" | "macos" | "linux" => Ok(requested.to_string()), + "auto" => { + if cfg!(windows) { + Ok("windows".into()) + } else if cfg!(target_os = "macos") { + Ok("macos".into()) + } else if cfg!(target_os = "linux") { + Ok("linux".into()) + } else { + Err("Unsupported platform. Pass --platform windows|macos|linux.".into()) + } + } + other => Err(format!( + "--platform must be auto|windows|macos|linux, got {other}" + )), + } +} + +/// `Resolve-CodeIntelPath`: the on-disk absolute path when it exists, an +/// absolute lexically-normalized path when it does not. Windows verbatim +/// (`\\?\`) prefixes are stripped so the value stays comparable to the +/// path strings every other producer in this pipeline emits. +fn resolve_code_intel_path(path: &Path) -> PathBuf { + match std::fs::canonicalize(path) { + Ok(resolved) => strip_verbatim(&resolved), + Err(_) => normalize(&absolute_from_cwd(path)), + } +} + +fn absolute_from_cwd(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + +/// Lexical `.`/`..` collapse, matching `[Path]::GetFullPath` for paths that do +/// not exist on disk (where `canonicalize` cannot help). +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !matches!( + out.components().next_back(), + None | Some(Component::RootDir) | Some(Component::Prefix(_)) + ) { + out.pop(); + } + } + other => out.push(other.as_os_str()), + } + } + out +} + +fn strip_verbatim(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + text.strip_prefix(r"\\?\") + .map(PathBuf::from) + .unwrap_or_else(|| path.to_path_buf()) +} + +fn trim_trailing_separator(value: &str) -> String { + let trimmed = value.trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + value.to_string() + } else { + trimmed.to_string() + } +} + +fn display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// The human-readable rendering the PowerShell probe printed without `-Json`. +/// CI reads these lines, so the wording is preserved verbatim. +pub(crate) fn render_human(observation: &Value) -> String { + let mut lines = Vec::new(); + let missing = observation["missing"] + .as_array() + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + if observation["ok"].as_bool().unwrap_or(false) { + lines.push("Code intel doctor: OK".to_string()); + } else { + lines.push(format!("Code intel doctor: missing {missing}")); + } + + let text = |pointer: &str| { + observation + .pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let flag = |pointer: &str| { + observation + .pointer(pointer) + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let mark = |ok: bool| if ok { "OK" } else { "MISSING" }; + + lines.push(format!("Pipeline: {}", text("/checks/pipelineScript/path"))); + lines.push(format!("Config: {}", text("/checks/config/path"))); + if let Some(tools) = observation + .pointer("/checks/tools") + .and_then(Value::as_array) + { + for tool in tools { + lines.push(format!( + "{} {} {}", + mark(tool["found"].as_bool().unwrap_or(false)), + tool["name"].as_str().unwrap_or_default(), + tool["source"].as_str().unwrap_or_default() + )); + } + } + let builtin = flag("/checks/sentrux/builtin/found"); + lines.push(format!( + "{} sentrux-core {}", + mark(flag("/checks/sentrux/core/found") || builtin), + text("/checks/sentrux/core/output") + )); + lines.push(format!( + "{} sentrux-pro {}", + mark(flag("/checks/sentrux/pro/found") || builtin), + text("/checks/sentrux/pro/output") + )); + lines.push(format!( + "{} internal graph provider source={} cargo={} binary={}", + mark(flag("/checks/graphProvider/sourceFound") && flag("/checks/graphProvider/cargoFound")), + flag("/checks/graphProvider/sourceFound"), + flag("/checks/graphProvider/cargoFound"), + flag("/checks/graphProvider/binaryFound") + )); + lines.push(format!( + "{} external Understand fallback skill={} plugin={}", + mark( + flag("/checks/understandAnything/skillFound") + && flag("/checks/understandAnything/pluginFound") + ), + text("/checks/understandAnything/skillPath"), + text("/checks/understandAnything/pluginPath") + )); + if observation["checks"]["repo"].is_object() { + lines.push(format!("Repo: {}", text("/checks/repo/path"))); + lines.push(format!("Repo exists: {}", flag("/checks/repo/exists"))); + if flag("/checks/repo/exists") { + lines.push(format!( + "Understand graph: {}", + flag("/checks/repo/understandGraph") + )); + lines.push(format!( + "Repowise state: {}", + flag("/checks/repo/repowiseState") + )); + lines.push(format!( + "Sentrux scope: {}", + text("/checks/repo/sentruxScope") + )); + lines.push(format!( + "Sentrux rules: {}", + flag("/checks/repo/sentruxRules") + )); + lines.push(format!( + "Sentrux baseline: {}", + flag("/checks/repo/sentruxBaseline") + )); + } + } + lines.join("\n") +} + +/// `code-intel doctor bootstrap [...]` — the direct CLI surface that replaced +/// `archive/check-code-intel-tools.ps1`. Exits 1 when the probe reports +/// missing prerequisites, matching the script it retired. +pub(crate) fn run_raw(raw: &[String]) -> i32 { + let mut options = Options::new(pipeline_root()); + let mut json_output = false; + let mut index = 0; + while index < raw.len() { + let token = raw[index].as_str(); + let value = || -> Result { + raw.get(index + 1) + .filter(|value| !value.starts_with("--")) + .cloned() + .ok_or_else(|| format!("{token} requires a value")) + }; + let step = match token { + "--json" => { + json_output = true; + 1 + } + "--require-repowise" => { + options.require_repowise = true; + 1 + } + "--no-require-repowise" => { + options.require_repowise = false; + 1 + } + "--require-understand" => { + options.require_understand = true; + 1 + } + "--repo" => match value() { + Ok(found) => { + options.repo = Some(found); + 2 + } + Err(error) => return fail(&error), + }, + "--repo-path" => match value() { + Ok(found) => { + options.repo_path = Some(found); + 2 + } + Err(error) => return fail(&error), + }, + "--config" => match value() { + Ok(found) => { + options.config = Some(PathBuf::from(found)); + 2 + } + Err(error) => return fail(&error), + }, + "--platform" => match value() { + Ok(found) => { + options.platform = found; + 2 + } + Err(error) => return fail(&error), + }, + "--pipeline-root" => match value() { + Ok(found) => { + options.pipeline_root = PathBuf::from(found); + 2 + } + Err(error) => return fail(&error), + }, + other => return fail(&format!("unknown argument for doctor bootstrap: {other}")), + }; + index += step; + } + + let observation = match observe(&options) { + Ok(observation) => observation, + Err(error) => return fail(&error), + }; + if json_output { + match serde_json::to_string_pretty(&observation) { + Ok(text) => println!("{text}"), + Err(error) => return fail(&format!("serialize doctor observation: {error}")), + } + } else { + println!("{}", render_human(&observation)); + } + if observation["ok"].as_bool().unwrap_or(false) { + 0 + } else { + 1 + } +} + +fn fail(message: &str) -> i32 { + eprintln!("error: {message}"); + 65 +} + +/// Repository root: the directory holding `orchestration/`, discovered the +/// same way the capability layer discovers its manifest. +pub(crate) fn pipeline_root() -> PathBuf { + crate::capability::discover_manifest(None) + .and_then(|manifest| manifest.parent()?.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..")) +} + +/// Sorted view of an observation's `checks` keys — used by the coverage +/// assertion so a silently dropped check surfaces as a test failure rather +/// than as a missing field downstream. `serde_json::Map` is a `BTreeMap` +/// here, so iteration is already ordered. +pub(crate) fn check_names(observation: &Value) -> Vec { + observation["checks"] + .as_object() + .map(|checks| checks.keys().cloned().collect()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn scratch(tag: &str) -> PathBuf { + let dir = env::temp_dir().join(format!( + "code-intel-doctor-bootstrap-{}-{tag}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&dir).expect("scratch"); + dir + } + + #[test] + fn tier_pattern_accepts_free_only_without_the_pro_opt_in() { + assert!(matches_tier("Sentrux\nTier: free\n", false)); + assert!(matches_tier("Tier: pro", false)); + assert!(matches_tier("Tier: pro", true)); + assert!(!matches_tier("Tier: free", true)); + // No whitespace after the colon is not a match, same as `\s+`. + assert!(!matches_tier("Tier:free", false)); + assert!(!matches_tier("no tier line here", false)); + } + + #[test] + fn core_marker_comparison_is_case_insensitive_like_powershell_match() { + assert!(contains_ignore_case( + " ENFORCE ARCHITECTURAL RULES for a repo", + SENTRUX_CORE_MARKER + )); + assert!(!contains_ignore_case( + "some other help text", + SENTRUX_CORE_MARKER + )); + } + + #[test] + fn missing_list_preserves_the_retired_scripts_wording_and_order() { + let checks = json!({ + "pipelineScript": {"found": false}, + "config": {"found": true, "parsed": false}, + "sentrux": {"core": {"found": false}, "pro": {"found": false}}, + "graphProvider": {"sourceFound": false, "cargoFound": false}, + "repo": {"path": "x", "exists": false} + }); + let tools = vec![ + json!({"name": "rg", "required": true, "found": false}), + json!({"name": "repomix", "required": false, "found": false}), + ]; + let missing = missing_list( + &checks, + &tools, + false, + true, + Some("bad json"), + true, + false, + "C:/nope", + ); + assert_eq!( + missing, + vec![ + "pipeline script".to_string(), + "pipeline config: invalid JSON (bad json)".to_string(), + "rg".to_string(), + "sentrux core".to_string(), + "sentrux pro auto-activation".to_string(), + "internal graph provider source".to_string(), + "code-intel Rust runtime".to_string(), + "repo path".to_string(), + "CODE_INTEL_HOME: directory does not exist (C:/nope)".to_string(), + ] + ); + } + + #[test] + fn builtin_sentrux_makes_the_external_overlay_optional() { + let checks = json!({ + "pipelineScript": {"found": true}, + "config": {"found": true, "parsed": true}, + "sentrux": {"core": {"found": false}, "pro": {"found": false}}, + "graphProvider": {"sourceFound": true, "cargoFound": true}, + "repo": {"exists": true} + }); + let tools = vec![json!({"name": "sentrux", "required": false, "found": false})]; + assert!(missing_list(&checks, &tools, true, false, None, false, false, "").is_empty()); + } + + #[test] + fn configured_sentrux_path_resolves_the_scope_and_finds_scoped_rules() { + let root = scratch("scope"); + let repo = root.join("ConfiguredRepo"); + let sentrux = repo.join("backend").join(".sentrux"); + fs::create_dir_all(&sentrux).unwrap(); + fs::write(sentrux.join("rules.toml"), b"").unwrap(); + fs::write(sentrux.join("baseline.json"), b"{}").unwrap(); + let config = json!({"repos": {"fixture": { + "path": format!("{}{}", display(&repo), std::path::MAIN_SEPARATOR), + "sentruxPath": "backend" + }}}); + + // Reverse lookup from an explicit --repo-path carrying a `.` segment, + // exactly the shape the retired PowerShell contract test exercised. + let mut options = Options::new(root.clone()); + options.repo_path = Some(display(&repo.join("."))); + let repo_path = resolve_repo_path(&options, Some(&config)).unwrap(); + let entry = find_repo_config_by_path(Some(&config), &repo_path).unwrap(); + let scope = resolve_sentrux_scope(&repo_path, Some(&&entry.clone())); + + let state = repo_state(Some(&repo_path), Some(&scope)); + assert_eq!( + state["sentruxScope"], + json!(display(&resolve_code_intel_path(&repo.join("backend")))) + ); + assert_eq!(state["sentruxRules"], json!(true)); + assert_eq!(state["sentruxBaseline"], json!(true)); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn observation_carries_the_v1_contract_and_every_retired_check() { + let root = scratch("contract"); + let mut options = Options::new(root.clone()); + options.repo_path = Some(display(&root)); + let observation = observe(&options).unwrap(); + assert_eq!(observation["schema"], BOOTSTRAP_SCHEMA); + assert_eq!(observation["authority"], "observation_only"); + assert!(observation["ok"].is_boolean()); + assert_eq!( + check_names(&observation), + vec![ + "config".to_string(), + "env".to_string(), + "graphProvider".to_string(), + "pipelineScript".to_string(), + "repo".to_string(), + "sentrux".to_string(), + "tools".to_string(), + "understandAnything".to_string(), + ] + ); + let tools = observation["checks"]["tools"].as_array().unwrap(); + let names = tools + .iter() + .map(|tool| tool["name"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!( + names, + vec!["rg", "git", "python", "repowise", "repomix", "sentrux"] + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn a_missing_repo_path_is_a_domain_observation_not_an_error() { + let root = scratch("absent"); + let mut options = Options::new(root.clone()); + options.repo_path = Some(display(&root.join("does-not-exist"))); + let observation = observe(&options).unwrap(); + assert_eq!(observation["checks"]["repo"]["exists"], json!(false)); + assert_eq!(observation["ok"], json!(false)); + assert!(observation["missing"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "repo path")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn probe_reports_an_absent_optional_overlay_without_failing() { + let empty = scratch("empty-bin"); + let probe = probe_command_output( + "sentrux-core", + "sentrux", + &["check", "--help"], + Some(&empty), + |_| true, + ); + assert_eq!(probe["name"], "sentrux-core"); + assert!(probe["found"].is_boolean()); + fs::remove_dir_all(empty).ok(); + } + + #[test] + fn human_rendering_keeps_the_retired_scripts_first_line() { + let ok = json!({"ok": true, "missing": [], "checks": {}}); + assert!(render_human(&ok).starts_with("Code intel doctor: OK")); + let bad = json!({"ok": false, "missing": ["rg", "git"], "checks": {}}); + assert!(render_human(&bad).starts_with("Code intel doctor: missing rg, git")); + } + + #[test] + fn platform_resolution_rejects_unknown_values() { + assert_eq!(resolve_platform("linux").unwrap(), "linux"); + assert!(resolve_platform("auto").is_ok()); + assert!(resolve_platform("solaris").is_err()); + } + + #[test] + fn path_normalization_collapses_dot_segments_for_absent_paths() { + let normalized = normalize(Path::new("/a/b/../c/./d")); + assert_eq!(normalized, PathBuf::from("/a/c/d")); + } +} diff --git a/crates/code-intel-cli/src/main.rs b/crates/code-intel-cli/src/main.rs index 437b02f..4288344 100644 --- a/crates/code-intel-cli/src/main.rs +++ b/crates/code-intel-cli/src/main.rs @@ -24,6 +24,7 @@ mod dag_coordinator; mod dag_run; mod decision_port; mod decision_record; +mod doctor_bootstrap; mod evidence_query; mod execution_kernel; mod execution_policy; @@ -513,6 +514,12 @@ struct RawRoute { } const RAW_ROUTES: &[RawRoute] = &[ + RawRoute { + command: "doctor", + subcommand: Some("bootstrap"), + argument_offset: 2, + runner: doctor_bootstrap::run_raw, + }, RawRoute { command: "compatibility", subcommand: Some("retirement-ticket"), @@ -1595,6 +1602,7 @@ Commands: sentrux-normalize --steps [--out ] sentrux-debt-register --failures [--repo ] [--out ] doctor [--artifact-root ] [--json] + doctor bootstrap [--repo ] [--repo-path ] [--config ] [--platform auto|windows|macos|linux] [--no-require-repowise] [--require-understand] [--json] graph --repo [--language zh] [--full] [--write] [--json] provider [--action List|Plan|Validate|Invoke] [--provider repowise|understand] [--operation ] [--repo ] [--language zh] [--write] [--json] provider repowise-adapt --request --artifact-root --evaluated-at --max-age-seconds diff --git a/crates/code-intel-cli/tests/doctor_bootstrap_cli.rs b/crates/code-intel-cli/tests/doctor_bootstrap_cli.rs new file mode 100644 index 0000000..c72b7a9 --- /dev/null +++ b/crates/code-intel-cli/tests/doctor_bootstrap_cli.rs @@ -0,0 +1,220 @@ +//! Contract tests for `code-intel doctor bootstrap`, the subcommand that +//! replaced `archive/check-code-intel-tools.ps1` under T3 (issue #48). +//! +//! What is pinned here is what other components actually read: the +//! observation schema and `observation_only` authority the doctor capability +//! adapter checks, the `missing`/`ok` pair the installer parses, the +//! `checks.repo.*` fields the repo-config contract test asserts on, and the +//! exit code CI gates on. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +fn temp_dir(tag: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "code-intel-doctor-cli-{tag}-{}-{nonce}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).expect("scratch"); + dir +} + +fn pipeline_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .expect("pipeline root") +} + +fn doctor(args: &[&str]) -> (i32, Value, String) { + let output = Command::new(env!("CARGO_BIN_EXE_code-intel")) + .args(["doctor", "bootstrap", "--pipeline-root"]) + .arg(pipeline_root()) + .args(args) + .output() + .expect("run doctor bootstrap"); + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let value = serde_json::from_str::(&stdout).unwrap_or(Value::Null); + (output.status.code().unwrap_or(-1), value, stderr) +} + +#[test] +fn emits_one_observation_only_document_the_adapter_contract_accepts() { + let repo = temp_dir("ok"); + fs::write(repo.join("README.md"), "fixture\n").unwrap(); + let (code, observation, stderr) = doctor(&[ + "--repo-path", + repo.to_str().unwrap(), + "--no-require-repowise", + "--json", + ]); + + assert!(stderr.is_empty(), "{stderr}"); + assert!(matches!(code, 0 | 1), "unexpected exit code {code}"); + assert_eq!( + observation["schema"], + "code-intel-doctor-bootstrap-observation.v1" + ); + assert_eq!(observation["authority"], "observation_only"); + assert!(observation["ok"].is_boolean()); + assert!(observation["missing"].is_array()); + // The three pointers doctor_adapter reads out of the raw observation. + assert!(observation.pointer("/checks/tools").unwrap().is_array()); + assert!(observation + .pointer("/checks/sentrux/builtin/found") + .unwrap() + .is_boolean()); + assert!(observation + .pointer("/checks/graphProvider/sourceFound") + .unwrap() + .is_boolean()); + assert_eq!(observation["checks"]["repo"]["exists"], json!(true)); + fs::remove_dir_all(repo).ok(); +} + +#[test] +fn exit_code_tracks_ok_so_ci_gates_on_it() { + let root = temp_dir("absent"); + let missing_repo = root.join("not-here"); + let (code, observation, _) = doctor(&[ + "--repo-path", + missing_repo.to_str().unwrap(), + "--no-require-repowise", + "--json", + ]); + + assert_eq!(code, 1); + assert_eq!(observation["ok"], json!(false)); + assert!(observation["missing"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "repo path")); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn configured_sentrux_path_is_resolved_through_a_reverse_repo_lookup() { + let root = temp_dir("scope"); + let repo = root.join("ConfiguredRepo"); + let sentrux = repo.join("backend").join(".sentrux"); + fs::create_dir_all(&sentrux).unwrap(); + fs::write(sentrux.join("rules.toml"), b"").unwrap(); + fs::write(sentrux.join("baseline.json"), b"{}").unwrap(); + + let config_path = root.join("pipeline.config.json"); + fs::write( + &config_path, + serde_json::to_vec_pretty(&json!({ + "repos": { + "fixture": { + "path": format!("{}{}", repo.display(), std::path::MAIN_SEPARATOR), + "sentruxPath": "backend" + } + } + })) + .unwrap(), + ) + .unwrap(); + + let (_, observation, _) = doctor(&[ + "--config", + config_path.to_str().unwrap(), + "--repo-path", + repo.join(".").to_str().unwrap(), + "--no-require-repowise", + "--json", + ]); + + let expected = repo.join("backend").canonicalize().unwrap(); + let expected = expected + .to_string_lossy() + .trim_start_matches(r"\\?\") + .to_string(); + assert_eq!( + observation["checks"]["repo"]["sentruxScope"], + json!(expected) + ); + assert_eq!(observation["checks"]["repo"]["sentruxRules"], json!(true)); + assert_eq!( + observation["checks"]["repo"]["sentruxBaseline"], + json!(true) + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn unparsable_config_is_a_domain_finding_not_a_crash() { + let root = temp_dir("badconfig"); + let config_path = root.join("pipeline.config.json"); + fs::write(&config_path, b"{ not json").unwrap(); + + let (code, observation, stderr) = doctor(&[ + "--config", + config_path.to_str().unwrap(), + "--repo-path", + root.to_str().unwrap(), + "--no-require-repowise", + "--json", + ]); + + assert!(stderr.is_empty(), "{stderr}"); + assert_eq!(code, 1); + assert_eq!(observation["checks"]["config"]["found"], json!(true)); + assert_eq!(observation["checks"]["config"]["parsed"], json!(false)); + assert!(observation["missing"] + .as_array() + .unwrap() + .iter() + .any(|value| value + .as_str() + .is_some_and(|text| text.starts_with("pipeline config: invalid JSON")))); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn an_unknown_flag_fails_closed_without_emitting_an_observation() { + let (code, observation, stderr) = doctor(&["--not-a-flag"]); + assert_eq!(code, 65); + assert_eq!(observation, Value::Null); + assert!(stderr.contains("unknown argument for doctor bootstrap")); +} + +#[test] +fn the_powershell_entry_point_is_a_thin_forwarder() { + // The retirement bar for T3 is "<=50 lines shim or deleted". Assert the + // shim stays thin and stays a forwarder rather than growing logic back. + let script = pipeline_root().join("archive/check-code-intel-tools.ps1"); + let text = fs::read_to_string(&script).expect("read forwarder"); + let code_lines = text + .lines() + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') + }) + .count(); + assert!( + code_lines <= 50, + "{} has {code_lines} code lines; T3 caps the shim at 50", + script.display() + ); + assert!(text.contains("doctor"), "forwarder must invoke the binary"); + assert!( + text.contains("bootstrap"), + "forwarder must invoke the binary" + ); +} diff --git a/docs/doctor-envelope.md b/docs/doctor-envelope.md index 0ce7791..58e6169 100644 --- a/docs/doctor-envelope.md +++ b/docs/doctor-envelope.md @@ -6,12 +6,22 @@ same snapshot identity. The environment policy is stored without host paths and is independently SHA-256 bound inside the observation. -`archive/check-code-intel-tools.ps1` remains the shell-compatible fresh-machine probe. -Its JSON is explicitly marked `observation_only`; the Rust adapter whitelists -fields from that probe, reconciles `orchestration/integrations.json`, and removes -paths and command output before publication. Presence, readiness, conformance, -and admissibility are separate fields. Doctor never emits engineering facts and -never claims provider admissibility. +The bootstrap probe itself is native Rust +(`crates/code-intel-cli/src/doctor_bootstrap.rs`, surfaced as `code-intel doctor +bootstrap`). It emits `code-intel-doctor-bootstrap-observation.v1`, explicitly +marked `observation_only`; the adapter whitelists fields from that observation, +reconciles `orchestration/integrations.json`, and removes paths and command +output before publication. Presence, readiness, conformance, and admissibility +are separate fields. Doctor never emits engineering facts and never claims +provider admissibility. + +`archive/check-code-intel-tools.ps1` is a thin forwarder onto that subcommand, +retained for the installer and rollback paths (T3, issue #48). It no longer +computes anything: a missing binary is reported as a `code-intel binary` entry +in `missing` rather than as a crash, so installers can keep reporting their own +checks. Because there is now one probe implementation instead of a script plus +an in-process fallback, the kernel path needs no `pwsh` and answers identically +on every platform. Missing or forged Artifact Refs, invalid bootstrap JSON, or an unreadable manifest fail as contract/runtime errors. Missing tools, nonconforming present diff --git a/orchestration/facade-finalize-policy.v1.json b/orchestration/facade-finalize-policy.v1.json index 48eced5..d951f03 100644 --- a/orchestration/facade-finalize-policy.v1.json +++ b/orchestration/facade-finalize-policy.v1.json @@ -15,7 +15,7 @@ { "surfaceId": "audit.facade-finalize", "path": "archive/Invoke-CompatibilityFacadeFinalize.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "compatibility.facade-finalize", "expiresAt": null, "classification": "platform_glue" }, { "surfaceId": "public.invoke", "path": "archive/invoke-code-intel.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "runtime.code-intel", "expiresAt": null, "classification": "compatibility_facade" }, { "surfaceId": "public.pipeline", "path": "archive/run-code-intel.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "runtime.code-intel", "expiresAt": null, "classification": "compatibility_facade" }, - { "surfaceId": "bootstrap.doctor", "path": "archive/check-code-intel-tools.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "doctor", "expiresAt": null, "classification": "platform_glue" }, + { "surfaceId": "bootstrap.doctor", "path": "archive/check-code-intel-tools.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "doctor", "expiresAt": null, "classification": "compatibility_facade" }, { "surfaceId": "bootstrap.fresh-machine", "path": "archive/bootstrap-new-machine.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "bootstrap.fresh-machine", "expiresAt": null, "classification": "platform_glue" }, { "surfaceId": "compat.index", "path": "archive/update-code-intel-index.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "artifact.index-committed-only", "expiresAt": null, "classification": "compatibility_facade" }, { "surfaceId": "compat.repowise", "path": "archive/Invoke-ScopedRepowise.ps1", "owner": "code-intel-pipeline", "registryParticipantId": "memory.repowise", "expiresAt": null, "classification": "platform_glue" }, diff --git a/orchestration/integrations.json b/orchestration/integrations.json index 1c2c7b1..251b8ab 100644 --- a/orchestration/integrations.json +++ b/orchestration/integrations.json @@ -264,9 +264,9 @@ "id": "doctor", "stage": "preflight", "owner": "code-intel-pipeline", - "kind": "internal-script", + "kind": "internal-rust-binary", "required": true, - "entrypoint": "archive/check-code-intel-tools.ps1", + "entrypoint": "crates/code-intel-cli/src/doctor_bootstrap.rs", "capabilities": [ "preflight", "tool_contract", @@ -274,7 +274,7 @@ "enveloped_readiness_observation" ], "commands": { - "validate": "archive/check-code-intel-tools.ps1 -RepoPath -RequireRepowise -Json", + "validate": "target/debug/code-intel.exe doctor bootstrap --repo-path --require-repowise --json", "capabilityExec": "target/debug/code-intel.exe capability exec doctor --request --out --artifact-root " }, "capabilityDeclaration": { @@ -285,7 +285,8 @@ "id": "doctor.envelope.compat", "version": "1.0.0", "toolchainDigests": [ - "cbce5325f0455639d6957d4b82a4a72af7adde235926aca07acd5dcfa561cb3f", + "0d43645caedd67c3a76533c7de0734822d514a52866f512b1a62332840c9ff15", + "da7afbcc1c3072df79082cc2542daa4a2339fd437386341c75f366afa5c6e149", "129dc6ff0b2f72b0c84f9f770a9ca7c348ada4c9a0e0537e94c63cf29f708bab" ] }, @@ -298,6 +299,7 @@ "algorithm": "sha256", "inputs": [ "crates/code-intel-cli/src/doctor_adapter.rs", + "crates/code-intel-cli/src/doctor_bootstrap.rs", "crates/code-intel-cli/src/capability_inventory.rs" ] }, @@ -306,7 +308,7 @@ "doctor-observation.json", "doctor_json" ], - "extensionPoint": "PowerShell remains an observation-only bootstrap probe until E09; authoritative doctor execution uses the A01 envelope and never promotes readiness into conformance or admissibility." + "extensionPoint": "The probe is native Rust (doctor_bootstrap.rs) and stays an observation-only bootstrap; archive/check-code-intel-tools.ps1 is a thin forwarder kept for installer and rollback paths. Authoritative doctor execution uses the A01 envelope and never promotes readiness into conformance or admissibility." }, { "id": "runtime.code-intel", From a6802a4cf9d2a262f767107f7fa0afd40d180fd7 Mon Sep 17 00:00:00 2001 From: Curry <57929895+2233admin@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:32:03 +0800 Subject: [PATCH 2/4] fix(doctor): split the bootstrap probe so it is not a god file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's self-scan caught a real regression in the previous commit: landing the native probe as one 1124-line file tripped the repo's own structural gate, `god_file_count 32 -> 33` (`god_file: loc > 800 || (functions > 25 && loc > 400)`), which cost 120 quality points and failed `run execute` with exit 10 on all three platforms. The probe is now four cohesive modules instead of one file: doctor_bootstrap/mod.rs envelope assembly, missing list, CLI surface doctor_bootstrap/config.rs pipeline.config.json and repo resolution doctor_bootstrap/paths.rs platform derivation and path resolution doctor_bootstrap/probe.rs tool presence and command-output probing No file is a god file and behavior is unchanged; the split also let several helpers grow real unit tests of their own (repo-alias fallback, rooted vs relative sentruxPath, reverse-lookup edge cases, trailing-separator trimming), so coverage went up rather than moving around. Two follow-on corrections the split required: - the registry entrypoint and toolchainDigestEvidence now name the four module files; `orchestrate --action Validate` rejected the stale single-file path, which is the manifest reconciliation check doing its job - `--config`/`--platform`/`--pipeline-root` argument parsing was rewritten as a flat match over (token, value) instead of nested closures Residual metric movement, recorded in .sentrux/rules.toml with evidence and baselined: coupling_score 45.26 -> 45.79, quality_signal 3607 -> 3603. The `[constraints]` thresholds are untouched. coupling_score is import_lines / file_count * 10, and this tree's average is held down by large PowerShell files carrying almost no import lines, so any idiomatic Rust module sits above it. Closing the gap would have meant dropping ~13 `use` lines for inline fully qualified paths — worse code in service of a line-counting metric. The real regression, the god file, was fixed rather than baselined; god_file_count and cycle_count are unchanged at 32 and 0. Verification: - code-intel run execute --repo . (the exact CI self-scan command): exit 0, outcome completed, every node verdict pass - code-intel sentrux gate .: No degradation detected - cargo test -p code-intel: 45 suites, 0 failed - cargo fmt -p code-intel -- --check: clean - orchestrate --action Validate: ok, 0 errors, registryAudit ok - test-regression-fixes.ps1: 49 passed; test-doctor-repo-config-resolution.ps1: PASS Refs #48 --- .sentrux/baseline.json | 16 +- .sentrux/rules.toml | 31 +- crates/code-intel-cli/src/doctor_adapter.rs | 7 +- crates/code-intel-cli/src/doctor_bootstrap.rs | 1124 ----------------- .../src/doctor_bootstrap/config.rs | 207 +++ .../src/doctor_bootstrap/mod.rs | 771 +++++++++++ .../src/doctor_bootstrap/paths.rs | 203 +++ .../src/doctor_bootstrap/probe.rs | 183 +++ orchestration/integrations.json | 14 +- 9 files changed, 1410 insertions(+), 1146 deletions(-) delete mode 100644 crates/code-intel-cli/src/doctor_bootstrap.rs create mode 100644 crates/code-intel-cli/src/doctor_bootstrap/config.rs create mode 100644 crates/code-intel-cli/src/doctor_bootstrap/mod.rs create mode 100644 crates/code-intel-cli/src/doctor_bootstrap/paths.rs create mode 100644 crates/code-intel-cli/src/doctor_bootstrap/probe.rs diff --git a/.sentrux/baseline.json b/.sentrux/baseline.json index 7eca6e0..a3d8f18 100644 --- a/.sentrux/baseline.json +++ b/.sentrux/baseline.json @@ -5,18 +5,18 @@ }, "metrics": { "complex_fn_count": 12, - "coupling_score": 45.26, - "cross_module_edges": 1041, + "coupling_score": 45.79, + "cross_module_edges": 1076, "cycle_count": 0, - "files": 230, - "functions": 3117, + "files": 235, + "functions": 3181, "god_file_count": 32, "max_complexity": 162, - "quality_signal": 3607, - "total_import_edges": 1041 + "quality_signal": 3603, + "total_import_edges": 1076 }, - "savedAt": 1785242084, + "savedAt": 1785396251, "schema": "code-intel-sentrux-baseline.v2", "scope": ".", - "sourceCommit": "1512c9d5777cf683daa6c95c4c8415164af93cf3" + "sourceCommit": "4be192665d5339ae47a2c80306ac2cc097cb56d1" } diff --git a/.sentrux/rules.toml b/.sentrux/rules.toml index 813b414..496b418 100644 --- a/.sentrux/rules.toml +++ b/.sentrux/rules.toml @@ -7,15 +7,30 @@ # no god files, coupling grade B) is tracked modernization debt # (issue #14, "Remaining tracked debt"). # -# Measured ratchet evidence (sentrux-native 2.0.0, 2026-07-27, security-004 +# Measured ratchet evidence (sentrux-native 2.0.0, 2026-07-30, T3 doctor # tree, recorded together with .sentrux/baseline.json in the same commit): -# max_complexity 162 (archive/scripts/tests/test-code-intel-pipeline.ps1), -# god_file_count 29, coupling_score 45.33, cycle_count 0. -# The 45.13 -> 45.33 coupling and 3968 -> 3967 quality movement is the cost -# of the shared tool_path resolver module (security-004, #33): one new module -# imported by six call sites replaces bare Command::new("rg"/"git") names, so -# the added import edges are the fix working as intended, not drift. God files -# and cycles did not move. +# coupling_score 45.26 -> 45.79, quality_signal 3607 -> 3603, +# god_file_count 32 (unchanged), cycle_count 0 (unchanged). +# Cause: T3 (#48) replaced 409 lines of archive/check-code-intel-tools.ps1 +# with a native Rust probe split across four cohesive modules +# (doctor_bootstrap/{mod,config,paths,probe}.rs) plus one integration test +# file. Import edges went 1041 -> 1076 over 230 -> 235 files. +# +# Why the movement is accepted rather than engineered away: coupling_score is +# import_lines / file_count * 10, and this tree's 45.26 average is held down by +# large PowerShell files that carry almost no import lines. Any idiomatic Rust +# module — roughly four to six `use`/`mod` lines — sits above that average, so +# adding well-factored Rust always pushes the ratio up. Reaching 45.26 here +# would have required dropping ~13 `use` lines in favour of inline fully +# qualified paths: worse code, chosen only to move a line-counting metric. +# What was NOT accepted: the first cut of this work landed the probe as one +# 1124-line file, which tripped god_file_count 32 -> 33 (quality -120). That +# is a real regression and was fixed by the module split above, not baselined. +# Prior entry (2026-07-27, security-004): 45.13 -> 45.33 coupling and +# 3968 -> 3967 quality was the cost of the shared tool_path resolver module +# (#33): one new module imported by six call sites replaces bare +# Command::new("rg"/"git") names, so the added import edges are the fix +# working as intended, not drift. God files and cycles did not move. # Prior entry (2026-07-26): 45.07 -> 45.13 / 3969 -> 3968 was the ast-grep # internalization conformance suite cost. # The previous B / 70 / no-god thresholds were never green under any engine: diff --git a/crates/code-intel-cli/src/doctor_adapter.rs b/crates/code-intel-cli/src/doctor_adapter.rs index 4e9ba46..b87af84 100644 --- a/crates/code-intel-cli/src/doctor_adapter.rs +++ b/crates/code-intel-cli/src/doctor_adapter.rs @@ -14,7 +14,7 @@ use crate::capability::sha256_hex; // integration tests pull this adapter into their own crate via `#[path]`, and // those roots do not declare the binary's module list. Same convention the // adapter already used for `tool_path`. -#[path = "doctor_bootstrap.rs"] +#[path = "doctor_bootstrap/mod.rs"] mod doctor_bootstrap; pub(crate) fn execute( @@ -494,7 +494,10 @@ mod tests { .unwrap(); for relative in [ "crates/code-intel-cli/src/doctor_adapter.rs", - "crates/code-intel-cli/src/doctor_bootstrap.rs", + "crates/code-intel-cli/src/doctor_bootstrap/mod.rs", + "crates/code-intel-cli/src/doctor_bootstrap/config.rs", + "crates/code-intel-cli/src/doctor_bootstrap/paths.rs", + "crates/code-intel-cli/src/doctor_bootstrap/probe.rs", "crates/code-intel-cli/src/capability_inventory.rs", ] { let actual = sha256_hex(&fs::read(root.join(relative)).unwrap()); diff --git a/crates/code-intel-cli/src/doctor_bootstrap.rs b/crates/code-intel-cli/src/doctor_bootstrap.rs deleted file mode 100644 index c75f6ed..0000000 --- a/crates/code-intel-cli/src/doctor_bootstrap.rs +++ /dev/null @@ -1,1124 +0,0 @@ -//! Native bootstrap/environment probe — the Rust owner of what -//! `archive/check-code-intel-tools.ps1` used to compute in PowerShell. -//! -//! Emits `code-intel-doctor-bootstrap-observation.v1`, the same -//! non-authoritative observation the doctor capability adapter consumes. The -//! PowerShell entry point is now a thin forwarder onto this module, so there -//! is exactly one implementation of the probe instead of a script plus a -//! divergent in-process fallback that hardcoded its graph-provider answers. -//! -//! Everything here is observation only: it reports presence and readiness of -//! tools, providers, config and repository state. It never writes, never -//! claims admissibility, and never emits engineering facts — those boundaries -//! belong to `doctor_adapter`. - -use std::env; -use std::path::{Component, Path, PathBuf}; -use std::process::Command; - -use serde_json::{json, Value}; - -#[path = "tool_path.rs"] -mod tool_path; - -/// Marker the doctor capability adapter matches on. Kept as a constant so the -/// probe and the adapter's contract check cannot drift. -pub(crate) const BOOTSTRAP_SCHEMA: &str = "code-intel-doctor-bootstrap-observation.v1"; - -/// Substring `sentrux check --help` must print for the core overlay to count -/// as conforming. PowerShell's `-match` is case-insensitive, so this compares -/// case-insensitively too. -const SENTRUX_CORE_MARKER: &str = "Enforce architectural rules"; - -pub(crate) struct Options { - /// Repo alias resolved through `pipeline.config.json`'s `repos` map. - pub(crate) repo: Option, - /// Explicit repository path; takes precedence over `repo`. - pub(crate) repo_path: Option, - /// Pipeline config path; defaults to `/pipeline.config.json`. - pub(crate) config: Option, - /// `auto` | `windows` | `macos` | `linux`. - pub(crate) platform: String, - pub(crate) require_repowise: bool, - pub(crate) require_understand: bool, - /// Directory searched ahead of `PATH` when probing for tools. Lets a test - /// stand up a fixture toolchain without mutating the process environment. - pub(crate) tool_path_prefix: Option, - /// Repository root holding `crates/`, `target/` and `archive/`. - pub(crate) pipeline_root: PathBuf, -} - -impl Options { - pub(crate) fn new(pipeline_root: PathBuf) -> Self { - Self { - repo: None, - repo_path: None, - config: None, - platform: "auto".into(), - require_repowise: true, - require_understand: false, - tool_path_prefix: None, - pipeline_root, - } - } -} - -/// Run the probe and return the observation document. -pub(crate) fn observe(options: &Options) -> Result { - let platform = resolve_platform(&options.platform)?; - let prefix = options.tool_path_prefix.as_deref(); - - let config_path = match &options.config { - Some(path) => path.clone(), - None => options.pipeline_root.join("pipeline.config.json"), - }; - let (config_data, config_parse_error) = load_config(&config_path); - - let repo_path = resolve_repo_path(options, config_data.as_ref()); - let repo_config = match (&options.repo_path, &repo_path) { - // An explicit -RepoPath wins over the alias, so the config entry has - // to be found by reverse path lookup rather than by name. - (Some(_), Some(path)) => find_repo_config_by_path(config_data.as_ref(), path), - _ => options - .repo - .as_deref() - .and_then(|alias| repo_config_by_alias(config_data.as_ref(), alias)), - }; - let sentrux_scope = repo_path - .as_ref() - .map(|path| resolve_sentrux_scope(path, repo_config.as_ref())); - - let pipeline_script = options - .pipeline_root - .join("archive") - .join("run-code-intel.ps1"); - let cli_root = options.pipeline_root.join("crates").join("code-intel-cli"); - let graph_source = cli_root.join("src").join("graph.rs"); - let graph_cargo = cli_root.join("Cargo.toml"); - let binary_name = binary_name(&platform); - let binary_candidates = [ - options - .pipeline_root - .join("target") - .join("release") - .join(&binary_name), - options - .pipeline_root - .join("target") - .join("debug") - .join(&binary_name), - ]; - let graph_binary = binary_candidates - .iter() - .find(|path| path.is_file()) - .cloned(); - let graph_command_binary = graph_binary - .clone() - .unwrap_or_else(|| binary_candidates[0].clone()); - - // The structural gate engine ships inside the code-intel binary; an - // external sentrux on PATH is an optional overlay, not a bootstrap - // requirement. - let builtin_sentrux = tool_path::locate("code-intel", prefix).is_some() - || ["release", "debug"].iter().any(|profile| { - ["code-intel.exe", "code-intel"].iter().any(|name| { - options - .pipeline_root - .join("target") - .join(profile) - .join(name) - .is_file() - }) - }); - - let tools = vec![ - probe_tool("rg", true, prefix), - probe_tool("git", true, prefix), - probe_python(prefix), - probe_tool("repowise", options.require_repowise, prefix), - probe_tool("repomix", false, prefix), - probe_tool("sentrux", !builtin_sentrux, prefix), - ]; - - let sentrux_core = probe_command_output( - "sentrux-core", - "sentrux", - &["check", "--help"], - prefix, - |text| contains_ignore_case(text, SENTRUX_CORE_MARKER), - ); - // Tier: free is healthy without the SENTRUX_AUTO_PRO opt-in (Pro - // auto-activation is opt-in; see archive/tools/sentrux-shim/sentrux-shim.ps1). - let require_pro_tier = matches!( - env::var("SENTRUX_AUTO_PRO").unwrap_or_default().as_str(), - "1" | "true" | "True" | "TRUE" - ); - let sentrux_pro = probe_command_output( - "sentrux-pro", - "sentrux", - &["pro", "status"], - prefix, - |text| matches_tier(text, require_pro_tier), - ); - - let home_dir = home_directory(); - let understand_skill = [".claude", ".agents", ".codex"] - .iter() - .map(|agent| { - home_dir - .join(agent) - .join("skills") - .join("understand") - .join("SKILL.md") - }) - .find(|path| path.is_file()); - let repo_parent = repo_path - .as_ref() - .and_then(|path| path.parent().map(Path::to_path_buf)) - .unwrap_or_else(|| options.pipeline_root.clone()); - let understand_plugin = [ - home_dir - .join(".claude") - .join("plugins") - .join("cache") - .join("understand-anything"), - home_dir.join(".understand-anything-plugin"), - repo_parent.join("Understand-Anything"), - ] - .into_iter() - .find(|path| path.is_dir()); - - let repo_state = repo_state(repo_path.as_deref(), sentrux_scope.as_deref()); - - let code_intel_home_default = resolve_code_intel_path(&options.pipeline_root); - let code_intel_home_value = env::var("CODE_INTEL_HOME").unwrap_or_default(); - let code_intel_home_set = !code_intel_home_value.trim().is_empty(); - let code_intel_home_resolved = if code_intel_home_set { - display(&resolve_code_intel_path(Path::new(&code_intel_home_value))) - } else { - String::new() - }; - let code_intel_home_exists = - code_intel_home_set && Path::new(&code_intel_home_resolved).is_dir(); - let code_intel_home_matches_default = - code_intel_home_set && code_intel_home_resolved == display(&code_intel_home_default); - - let checks = json!({ - "pipelineScript": { - "path": display(&pipeline_script), - "found": pipeline_script.is_file() - }, - "config": { - "path": display(&config_path), - "found": config_path.is_file(), - "parsed": config_data.is_some() || config_parse_error.is_none(), - "parseError": config_parse_error.clone().unwrap_or_default() - }, - "tools": tools, - "sentrux": { - "core": sentrux_core, - "pro": sentrux_pro, - "builtin": {"found": builtin_sentrux} - }, - "understandAnything": { - "skillFound": understand_skill.is_some(), - "skillPath": understand_skill.as_deref().map(display).unwrap_or_default(), - "pluginFound": understand_plugin.is_some(), - "pluginPath": understand_plugin.as_deref().map(display).unwrap_or_default() - }, - "graphProvider": { - "sourceFound": graph_source.is_file(), - "cargoFound": graph_cargo.is_file(), - "binaryFound": graph_binary.is_some(), - "binaryPath": graph_binary.as_deref().map(display).unwrap_or_default(), - "command": format!( - "{} graph --repo --language zh --write --json", - display(&graph_command_binary) - ) - }, - "repo": repo_state, - "env": { - "codeIntelHome": { - "expected": display(&code_intel_home_default), - "value": if code_intel_home_set { code_intel_home_value.clone() } else { String::new() }, - "resolved": code_intel_home_resolved.clone(), - "exists": code_intel_home_exists, - "matchesDefault": code_intel_home_matches_default, - "ok": code_intel_home_exists && code_intel_home_matches_default - } - } - }); - - let missing = missing_list( - &checks, - &tools, - builtin_sentrux, - options.require_understand, - config_parse_error.as_deref(), - code_intel_home_set, - code_intel_home_exists, - &code_intel_home_resolved, - ); - - let paths = platform_paths(&platform, &options.pipeline_root); - Ok(json!({ - "schema": BOOTSTRAP_SCHEMA, - "authority": "observation_only", - "source": "native", - "ok": missing.is_empty(), - "missing": missing, - "platform": { - "os": platform, - "shell": "Rust", - "psVersion": "" - }, - "paths": paths, - "checks": checks, - "strict": { - "requireRepowise": options.require_repowise, - "requireUnderstand": options.require_understand - } - })) -} - -/// The `missing` list, in the order the PowerShell probe emitted it — several -/// callers (installer checks, CI logs) read it as a comma-joined string. -#[allow(clippy::too_many_arguments)] -fn missing_list( - checks: &Value, - tools: &[Value], - builtin_sentrux: bool, - require_understand: bool, - config_parse_error: Option<&str>, - code_intel_home_set: bool, - code_intel_home_exists: bool, - code_intel_home_resolved: &str, -) -> Vec { - let flag = |pointer: &str| { - checks - .pointer(pointer) - .and_then(Value::as_bool) - .unwrap_or(false) - }; - let mut missing = Vec::new(); - if !flag("/pipelineScript/found") { - missing.push("pipeline script".to_string()); - } - if !flag("/config/found") { - missing.push("pipeline config".to_string()); - } - if flag("/config/found") && !flag("/config/parsed") { - missing.push(format!( - "pipeline config: invalid JSON ({})", - config_parse_error.unwrap_or_default() - )); - } - for tool in tools { - if tool["required"].as_bool().unwrap_or(false) && !tool["found"].as_bool().unwrap_or(false) - { - missing.push(tool["name"].as_str().unwrap_or_default().to_string()); - } - } - if !flag("/sentrux/core/found") && !builtin_sentrux { - missing.push("sentrux core".to_string()); - } - if !flag("/sentrux/pro/found") && !builtin_sentrux { - missing.push("sentrux pro auto-activation".to_string()); - } - if require_understand && !flag("/graphProvider/sourceFound") { - missing.push("internal graph provider source".to_string()); - } - if require_understand && !flag("/graphProvider/cargoFound") { - missing.push("code-intel Rust runtime".to_string()); - } - if checks["repo"].is_object() && !flag("/repo/exists") { - missing.push("repo path".to_string()); - } - if code_intel_home_set && !code_intel_home_exists { - missing.push(format!( - "CODE_INTEL_HOME: directory does not exist ({code_intel_home_resolved})" - )); - } - missing -} - -fn repo_state(repo_path: Option<&Path>, sentrux_scope: Option<&Path>) -> Value { - let Some(repo_path) = repo_path else { - return Value::Null; - }; - if !repo_path.is_dir() { - return json!({"path": display(repo_path), "exists": false}); - } - let scope = sentrux_scope.unwrap_or(repo_path); - let sentrux_dir = scope.join(".sentrux"); - json!({ - "path": display(repo_path), - "exists": true, - "isGitRepo": repo_path.join(".git").exists(), - "understandGraph": repo_path - .join(".understand-anything") - .join("knowledge-graph.json") - .is_file(), - "repowiseState": repo_path.join(".repowise").is_dir(), - "sentruxScope": display(scope), - "sentruxRules": sentrux_dir.join("rules.toml").is_file(), - "sentruxBaseline": sentrux_dir.join("baseline.json").is_file() - }) -} - -fn load_config(path: &Path) -> (Option, Option) { - if !path.is_file() { - return (None, None); - } - match std::fs::read(path) { - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(value) => (Some(value), None), - Err(error) => (None, Some(error.to_string())), - }, - Err(error) => (None, Some(error.to_string())), - } -} - -fn repo_config_by_alias<'a>(config: Option<&'a Value>, alias: &str) -> Option<&'a Value> { - config?.get("repos")?.get(alias) -} - -/// Reverse lookup: which configured repo entry points at `repo_path`. Mirrors -/// the PowerShell `Find-RepoConfigByPath`, including its trailing-separator -/// trim and case-insensitive comparison. -fn find_repo_config_by_path<'a>(config: Option<&'a Value>, repo_path: &Path) -> Option<&'a Value> { - let repos = config?.get("repos")?.as_object()?; - let target = trim_trailing_separator(&display(repo_path)); - repos.values().find(|entry| { - entry - .get("path") - .and_then(Value::as_str) - .filter(|path| !path.trim().is_empty()) - .is_some_and(|path| { - let resolved = display(&resolve_code_intel_path(Path::new(path))); - trim_trailing_separator(&resolved).eq_ignore_ascii_case(&target) - }) - }) -} - -fn resolve_repo_path(options: &Options, config: Option<&Value>) -> Option { - if let Some(repo_path) = options - .repo_path - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - let path = PathBuf::from(repo_path); - return Some(if path.is_dir() { - resolve_code_intel_path(&path) - } else { - path - }); - } - let alias = options - .repo - .as_deref() - .filter(|value| !value.trim().is_empty())?; - let configured = repo_config_by_alias(config, alias) - .and_then(|entry| entry.get("path")) - .and_then(Value::as_str) - .filter(|path| !path.trim().is_empty()); - let path = PathBuf::from(configured.unwrap_or(alias)); - Some(if path.is_dir() { - resolve_code_intel_path(&path) - } else { - path - }) -} - -fn resolve_sentrux_scope(repo_path: &Path, repo_config: Option<&&Value>) -> PathBuf { - let configured = repo_config - .and_then(|entry| entry.get("sentruxPath")) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()); - let Some(configured) = configured else { - return repo_path.to_path_buf(); - }; - let scope = if Path::new(configured).is_absolute() { - PathBuf::from(configured) - } else { - repo_path.join(configured) - }; - if scope.is_dir() { - resolve_code_intel_path(&scope) - } else { - scope - } -} - -fn probe_tool(name: &str, required: bool, prefix: Option<&Path>) -> Value { - let found = tool_path::locate(name, prefix); - json!({ - "name": name, - "required": required, - "found": found.is_some(), - "source": found.as_deref().map(display).unwrap_or_default() - }) -} - -/// `python` falls back to `python3`, matching `Get-CodeIntelPythonCommand`. -/// The reported `name` stays `python` so the `missing` list wording does not -/// change with which interpreter happened to be installed. -fn probe_python(prefix: Option<&Path>) -> Value { - let found = - tool_path::locate("python", prefix).or_else(|| tool_path::locate("python3", prefix)); - json!({ - "name": "python", - "required": true, - "found": found.is_some(), - "source": found.as_deref().map(display).unwrap_or_default() - }) -} - -/// Run `program args...` and decide `found` from exit status plus a predicate -/// over the merged stdout/stderr text. A program that cannot be located or -/// launched is a `found: false` observation, never an error: absence of an -/// optional overlay is exactly what this probe exists to report. -fn probe_command_output( - name: &str, - program: &str, - args: &[&str], - prefix: Option<&Path>, - matches: impl Fn(&str) -> bool, -) -> Value { - let Some(binary) = tool_path::locate(program, prefix) else { - return json!({ - "name": name, - "found": false, - "output": format!("{program} was not found on PATH") - }); - }; - let mut command = Command::new(&binary); - command.args(args); - if let Some(prefix) = prefix { - if let Some(path) = prefixed_path(prefix) { - command - .env_remove("PATH") - .env_remove("Path") - .env("PATH", path); - } - } - match command.output() { - Ok(output) => { - let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); - text.push_str(&String::from_utf8_lossy(&output.stderr)); - let text = text.trim().to_string(); - json!({ - "name": name, - "found": output.status.success() && matches(&text), - "output": text - }) - } - Err(error) => json!({"name": name, "found": false, "output": error.to_string()}), - } -} - -fn prefixed_path(prefix: &Path) -> Option { - let mut paths = vec![prefix.to_path_buf()]; - paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); - env::join_paths(paths).ok() -} - -/// `Tier:\s+pro` when Pro auto-activation is opted into, `Tier:\s+(pro|free)` -/// otherwise. Hand-rolled because the crate carries no regex dependency, and -/// case-insensitive to match PowerShell `-match` semantics. -fn matches_tier(text: &str, require_pro: bool) -> bool { - let lower = text.to_ascii_lowercase(); - let mut rest = lower.as_str(); - while let Some(index) = rest.find("tier:") { - let after = &rest[index + "tier:".len()..]; - let trimmed = after.trim_start_matches([' ', '\t', '\r', '\n']); - if trimmed.len() < after.len() { - if trimmed.starts_with("pro") || (!require_pro && trimmed.starts_with("free")) { - return true; - } - } - rest = after; - } - false -} - -fn contains_ignore_case(text: &str, needle: &str) -> bool { - text.to_ascii_lowercase() - .contains(&needle.to_ascii_lowercase()) -} - -fn platform_paths(platform: &str, pipeline_root: &Path) -> Value { - let home = home_directory(); - let data_root = data_root(platform, &home); - let bin = match env::var("CODE_INTEL_BIN") { - Ok(value) if !value.trim().is_empty() => resolve_code_intel_path(Path::new(&value)), - _ => data_root.join("bin"), - }; - let code_intel_home = match env::var("CODE_INTEL_HOME") { - Ok(value) if !value.trim().is_empty() => resolve_code_intel_path(Path::new(&value)), - _ => resolve_code_intel_path(pipeline_root), - }; - json!({ - "home": display(&home), - "dataRoot": display(&data_root), - "bin": display(&bin), - "codeIntelHome": display(&code_intel_home) - }) -} - -fn data_root(platform: &str, home: &Path) -> PathBuf { - if let Ok(value) = env::var("CODE_INTEL_DATA_ROOT") { - if !value.trim().is_empty() { - return resolve_code_intel_path(Path::new(&value)); - } - } - match platform { - "windows" => env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .filter(|base| !base.as_os_str().is_empty()) - .unwrap_or_else(|| home.join(".code-intel")) - .join("code-intel"), - "macos" => home - .join("Library") - .join("Application Support") - .join("code-intel"), - _ => env::var_os("XDG_DATA_HOME") - .map(PathBuf::from) - .filter(|base| !base.as_os_str().is_empty()) - .unwrap_or_else(|| home.join(".local").join("share")) - .join("code-intel"), - } -} - -fn home_directory() -> PathBuf { - let raw = if cfg!(windows) { - env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) - } else { - env::var_os("HOME") - }; - raw.map(PathBuf::from) - .filter(|path| !path.as_os_str().is_empty()) - .map(|path| resolve_code_intel_path(&path)) - .unwrap_or_else(|| PathBuf::from(".")) -} - -fn binary_name(platform: &str) -> String { - if platform == "windows" { - "code-intel.exe".into() - } else { - "code-intel".into() - } -} - -pub(crate) fn resolve_platform(requested: &str) -> Result { - match requested { - "windows" | "macos" | "linux" => Ok(requested.to_string()), - "auto" => { - if cfg!(windows) { - Ok("windows".into()) - } else if cfg!(target_os = "macos") { - Ok("macos".into()) - } else if cfg!(target_os = "linux") { - Ok("linux".into()) - } else { - Err("Unsupported platform. Pass --platform windows|macos|linux.".into()) - } - } - other => Err(format!( - "--platform must be auto|windows|macos|linux, got {other}" - )), - } -} - -/// `Resolve-CodeIntelPath`: the on-disk absolute path when it exists, an -/// absolute lexically-normalized path when it does not. Windows verbatim -/// (`\\?\`) prefixes are stripped so the value stays comparable to the -/// path strings every other producer in this pipeline emits. -fn resolve_code_intel_path(path: &Path) -> PathBuf { - match std::fs::canonicalize(path) { - Ok(resolved) => strip_verbatim(&resolved), - Err(_) => normalize(&absolute_from_cwd(path)), - } -} - -fn absolute_from_cwd(path: &Path) -> PathBuf { - if path.is_absolute() { - return path.to_path_buf(); - } - env::current_dir() - .map(|cwd| cwd.join(path)) - .unwrap_or_else(|_| path.to_path_buf()) -} - -/// Lexical `.`/`..` collapse, matching `[Path]::GetFullPath` for paths that do -/// not exist on disk (where `canonicalize` cannot help). -fn normalize(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - if !matches!( - out.components().next_back(), - None | Some(Component::RootDir) | Some(Component::Prefix(_)) - ) { - out.pop(); - } - } - other => out.push(other.as_os_str()), - } - } - out -} - -fn strip_verbatim(path: &Path) -> PathBuf { - let text = path.to_string_lossy(); - text.strip_prefix(r"\\?\") - .map(PathBuf::from) - .unwrap_or_else(|| path.to_path_buf()) -} - -fn trim_trailing_separator(value: &str) -> String { - let trimmed = value.trim_end_matches(['/', '\\']); - if trimmed.is_empty() { - value.to_string() - } else { - trimmed.to_string() - } -} - -fn display(path: &Path) -> String { - path.to_string_lossy().into_owned() -} - -/// The human-readable rendering the PowerShell probe printed without `-Json`. -/// CI reads these lines, so the wording is preserved verbatim. -pub(crate) fn render_human(observation: &Value) -> String { - let mut lines = Vec::new(); - let missing = observation["missing"] - .as_array() - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .collect::>() - .join(", ") - }) - .unwrap_or_default(); - if observation["ok"].as_bool().unwrap_or(false) { - lines.push("Code intel doctor: OK".to_string()); - } else { - lines.push(format!("Code intel doctor: missing {missing}")); - } - - let text = |pointer: &str| { - observation - .pointer(pointer) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string() - }; - let flag = |pointer: &str| { - observation - .pointer(pointer) - .and_then(Value::as_bool) - .unwrap_or(false) - }; - let mark = |ok: bool| if ok { "OK" } else { "MISSING" }; - - lines.push(format!("Pipeline: {}", text("/checks/pipelineScript/path"))); - lines.push(format!("Config: {}", text("/checks/config/path"))); - if let Some(tools) = observation - .pointer("/checks/tools") - .and_then(Value::as_array) - { - for tool in tools { - lines.push(format!( - "{} {} {}", - mark(tool["found"].as_bool().unwrap_or(false)), - tool["name"].as_str().unwrap_or_default(), - tool["source"].as_str().unwrap_or_default() - )); - } - } - let builtin = flag("/checks/sentrux/builtin/found"); - lines.push(format!( - "{} sentrux-core {}", - mark(flag("/checks/sentrux/core/found") || builtin), - text("/checks/sentrux/core/output") - )); - lines.push(format!( - "{} sentrux-pro {}", - mark(flag("/checks/sentrux/pro/found") || builtin), - text("/checks/sentrux/pro/output") - )); - lines.push(format!( - "{} internal graph provider source={} cargo={} binary={}", - mark(flag("/checks/graphProvider/sourceFound") && flag("/checks/graphProvider/cargoFound")), - flag("/checks/graphProvider/sourceFound"), - flag("/checks/graphProvider/cargoFound"), - flag("/checks/graphProvider/binaryFound") - )); - lines.push(format!( - "{} external Understand fallback skill={} plugin={}", - mark( - flag("/checks/understandAnything/skillFound") - && flag("/checks/understandAnything/pluginFound") - ), - text("/checks/understandAnything/skillPath"), - text("/checks/understandAnything/pluginPath") - )); - if observation["checks"]["repo"].is_object() { - lines.push(format!("Repo: {}", text("/checks/repo/path"))); - lines.push(format!("Repo exists: {}", flag("/checks/repo/exists"))); - if flag("/checks/repo/exists") { - lines.push(format!( - "Understand graph: {}", - flag("/checks/repo/understandGraph") - )); - lines.push(format!( - "Repowise state: {}", - flag("/checks/repo/repowiseState") - )); - lines.push(format!( - "Sentrux scope: {}", - text("/checks/repo/sentruxScope") - )); - lines.push(format!( - "Sentrux rules: {}", - flag("/checks/repo/sentruxRules") - )); - lines.push(format!( - "Sentrux baseline: {}", - flag("/checks/repo/sentruxBaseline") - )); - } - } - lines.join("\n") -} - -/// `code-intel doctor bootstrap [...]` — the direct CLI surface that replaced -/// `archive/check-code-intel-tools.ps1`. Exits 1 when the probe reports -/// missing prerequisites, matching the script it retired. -pub(crate) fn run_raw(raw: &[String]) -> i32 { - let mut options = Options::new(pipeline_root()); - let mut json_output = false; - let mut index = 0; - while index < raw.len() { - let token = raw[index].as_str(); - let value = || -> Result { - raw.get(index + 1) - .filter(|value| !value.starts_with("--")) - .cloned() - .ok_or_else(|| format!("{token} requires a value")) - }; - let step = match token { - "--json" => { - json_output = true; - 1 - } - "--require-repowise" => { - options.require_repowise = true; - 1 - } - "--no-require-repowise" => { - options.require_repowise = false; - 1 - } - "--require-understand" => { - options.require_understand = true; - 1 - } - "--repo" => match value() { - Ok(found) => { - options.repo = Some(found); - 2 - } - Err(error) => return fail(&error), - }, - "--repo-path" => match value() { - Ok(found) => { - options.repo_path = Some(found); - 2 - } - Err(error) => return fail(&error), - }, - "--config" => match value() { - Ok(found) => { - options.config = Some(PathBuf::from(found)); - 2 - } - Err(error) => return fail(&error), - }, - "--platform" => match value() { - Ok(found) => { - options.platform = found; - 2 - } - Err(error) => return fail(&error), - }, - "--pipeline-root" => match value() { - Ok(found) => { - options.pipeline_root = PathBuf::from(found); - 2 - } - Err(error) => return fail(&error), - }, - other => return fail(&format!("unknown argument for doctor bootstrap: {other}")), - }; - index += step; - } - - let observation = match observe(&options) { - Ok(observation) => observation, - Err(error) => return fail(&error), - }; - if json_output { - match serde_json::to_string_pretty(&observation) { - Ok(text) => println!("{text}"), - Err(error) => return fail(&format!("serialize doctor observation: {error}")), - } - } else { - println!("{}", render_human(&observation)); - } - if observation["ok"].as_bool().unwrap_or(false) { - 0 - } else { - 1 - } -} - -fn fail(message: &str) -> i32 { - eprintln!("error: {message}"); - 65 -} - -/// Repository root: the directory holding `orchestration/`, discovered the -/// same way the capability layer discovers its manifest. -pub(crate) fn pipeline_root() -> PathBuf { - crate::capability::discover_manifest(None) - .and_then(|manifest| manifest.parent()?.parent().map(Path::to_path_buf)) - .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..")) -} - -/// Sorted view of an observation's `checks` keys — used by the coverage -/// assertion so a silently dropped check surfaces as a test failure rather -/// than as a missing field downstream. `serde_json::Map` is a `BTreeMap` -/// here, so iteration is already ordered. -pub(crate) fn check_names(observation: &Value) -> Vec { - observation["checks"] - .as_object() - .map(|checks| checks.keys().cloned().collect()) - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - fn scratch(tag: &str) -> PathBuf { - let dir = env::temp_dir().join(format!( - "code-intel-doctor-bootstrap-{}-{tag}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(&dir).expect("scratch"); - dir - } - - #[test] - fn tier_pattern_accepts_free_only_without_the_pro_opt_in() { - assert!(matches_tier("Sentrux\nTier: free\n", false)); - assert!(matches_tier("Tier: pro", false)); - assert!(matches_tier("Tier: pro", true)); - assert!(!matches_tier("Tier: free", true)); - // No whitespace after the colon is not a match, same as `\s+`. - assert!(!matches_tier("Tier:free", false)); - assert!(!matches_tier("no tier line here", false)); - } - - #[test] - fn core_marker_comparison_is_case_insensitive_like_powershell_match() { - assert!(contains_ignore_case( - " ENFORCE ARCHITECTURAL RULES for a repo", - SENTRUX_CORE_MARKER - )); - assert!(!contains_ignore_case( - "some other help text", - SENTRUX_CORE_MARKER - )); - } - - #[test] - fn missing_list_preserves_the_retired_scripts_wording_and_order() { - let checks = json!({ - "pipelineScript": {"found": false}, - "config": {"found": true, "parsed": false}, - "sentrux": {"core": {"found": false}, "pro": {"found": false}}, - "graphProvider": {"sourceFound": false, "cargoFound": false}, - "repo": {"path": "x", "exists": false} - }); - let tools = vec![ - json!({"name": "rg", "required": true, "found": false}), - json!({"name": "repomix", "required": false, "found": false}), - ]; - let missing = missing_list( - &checks, - &tools, - false, - true, - Some("bad json"), - true, - false, - "C:/nope", - ); - assert_eq!( - missing, - vec![ - "pipeline script".to_string(), - "pipeline config: invalid JSON (bad json)".to_string(), - "rg".to_string(), - "sentrux core".to_string(), - "sentrux pro auto-activation".to_string(), - "internal graph provider source".to_string(), - "code-intel Rust runtime".to_string(), - "repo path".to_string(), - "CODE_INTEL_HOME: directory does not exist (C:/nope)".to_string(), - ] - ); - } - - #[test] - fn builtin_sentrux_makes_the_external_overlay_optional() { - let checks = json!({ - "pipelineScript": {"found": true}, - "config": {"found": true, "parsed": true}, - "sentrux": {"core": {"found": false}, "pro": {"found": false}}, - "graphProvider": {"sourceFound": true, "cargoFound": true}, - "repo": {"exists": true} - }); - let tools = vec![json!({"name": "sentrux", "required": false, "found": false})]; - assert!(missing_list(&checks, &tools, true, false, None, false, false, "").is_empty()); - } - - #[test] - fn configured_sentrux_path_resolves_the_scope_and_finds_scoped_rules() { - let root = scratch("scope"); - let repo = root.join("ConfiguredRepo"); - let sentrux = repo.join("backend").join(".sentrux"); - fs::create_dir_all(&sentrux).unwrap(); - fs::write(sentrux.join("rules.toml"), b"").unwrap(); - fs::write(sentrux.join("baseline.json"), b"{}").unwrap(); - let config = json!({"repos": {"fixture": { - "path": format!("{}{}", display(&repo), std::path::MAIN_SEPARATOR), - "sentruxPath": "backend" - }}}); - - // Reverse lookup from an explicit --repo-path carrying a `.` segment, - // exactly the shape the retired PowerShell contract test exercised. - let mut options = Options::new(root.clone()); - options.repo_path = Some(display(&repo.join("."))); - let repo_path = resolve_repo_path(&options, Some(&config)).unwrap(); - let entry = find_repo_config_by_path(Some(&config), &repo_path).unwrap(); - let scope = resolve_sentrux_scope(&repo_path, Some(&&entry.clone())); - - let state = repo_state(Some(&repo_path), Some(&scope)); - assert_eq!( - state["sentruxScope"], - json!(display(&resolve_code_intel_path(&repo.join("backend")))) - ); - assert_eq!(state["sentruxRules"], json!(true)); - assert_eq!(state["sentruxBaseline"], json!(true)); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn observation_carries_the_v1_contract_and_every_retired_check() { - let root = scratch("contract"); - let mut options = Options::new(root.clone()); - options.repo_path = Some(display(&root)); - let observation = observe(&options).unwrap(); - assert_eq!(observation["schema"], BOOTSTRAP_SCHEMA); - assert_eq!(observation["authority"], "observation_only"); - assert!(observation["ok"].is_boolean()); - assert_eq!( - check_names(&observation), - vec![ - "config".to_string(), - "env".to_string(), - "graphProvider".to_string(), - "pipelineScript".to_string(), - "repo".to_string(), - "sentrux".to_string(), - "tools".to_string(), - "understandAnything".to_string(), - ] - ); - let tools = observation["checks"]["tools"].as_array().unwrap(); - let names = tools - .iter() - .map(|tool| tool["name"].as_str().unwrap_or_default()) - .collect::>(); - assert_eq!( - names, - vec!["rg", "git", "python", "repowise", "repomix", "sentrux"] - ); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn a_missing_repo_path_is_a_domain_observation_not_an_error() { - let root = scratch("absent"); - let mut options = Options::new(root.clone()); - options.repo_path = Some(display(&root.join("does-not-exist"))); - let observation = observe(&options).unwrap(); - assert_eq!(observation["checks"]["repo"]["exists"], json!(false)); - assert_eq!(observation["ok"], json!(false)); - assert!(observation["missing"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "repo path")); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn probe_reports_an_absent_optional_overlay_without_failing() { - let empty = scratch("empty-bin"); - let probe = probe_command_output( - "sentrux-core", - "sentrux", - &["check", "--help"], - Some(&empty), - |_| true, - ); - assert_eq!(probe["name"], "sentrux-core"); - assert!(probe["found"].is_boolean()); - fs::remove_dir_all(empty).ok(); - } - - #[test] - fn human_rendering_keeps_the_retired_scripts_first_line() { - let ok = json!({"ok": true, "missing": [], "checks": {}}); - assert!(render_human(&ok).starts_with("Code intel doctor: OK")); - let bad = json!({"ok": false, "missing": ["rg", "git"], "checks": {}}); - assert!(render_human(&bad).starts_with("Code intel doctor: missing rg, git")); - } - - #[test] - fn platform_resolution_rejects_unknown_values() { - assert_eq!(resolve_platform("linux").unwrap(), "linux"); - assert!(resolve_platform("auto").is_ok()); - assert!(resolve_platform("solaris").is_err()); - } - - #[test] - fn path_normalization_collapses_dot_segments_for_absent_paths() { - let normalized = normalize(Path::new("/a/b/../c/./d")); - assert_eq!(normalized, PathBuf::from("/a/c/d")); - } -} diff --git a/crates/code-intel-cli/src/doctor_bootstrap/config.rs b/crates/code-intel-cli/src/doctor_bootstrap/config.rs new file mode 100644 index 0000000..73a71e8 --- /dev/null +++ b/crates/code-intel-cli/src/doctor_bootstrap/config.rs @@ -0,0 +1,207 @@ +//! `pipeline.config.json` loading and repository resolution. +//! +//! Ports the resolution rules `check-code-intel-tools.ps1` implemented: +//! alias lookup through `repos`, the reverse path lookup an explicit +//! `-RepoPath` triggers, and the `sentruxPath` scope override. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use super::paths::{display, resolve_code_intel_path, trim_trailing_separator}; +use super::Options; + +/// Read and parse the config. A missing file is neither data nor an error; an +/// unparsable one yields the parse message so `missing` can quote it. +pub(super) fn load_config(path: &Path) -> (Option, Option) { + if !path.is_file() { + return (None, None); + } + match std::fs::read(path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(value) => (Some(value), None), + Err(error) => (None, Some(error.to_string())), + }, + Err(error) => (None, Some(error.to_string())), + } +} + +pub(super) fn repo_config_by_alias<'a>( + config: Option<&'a Value>, + alias: &str, +) -> Option<&'a Value> { + config?.get("repos")?.get(alias) +} + +/// Reverse lookup: which configured repo entry points at `repo_path`. Mirrors +/// the PowerShell `Find-RepoConfigByPath`, including its trailing-separator +/// trim and case-insensitive comparison. +pub(super) fn find_repo_config_by_path<'a>( + config: Option<&'a Value>, + repo_path: &Path, +) -> Option<&'a Value> { + let repos = config?.get("repos")?.as_object()?; + let target = trim_trailing_separator(&display(repo_path)); + repos.values().find(|entry| { + entry + .get("path") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .is_some_and(|path| { + let resolved = display(&resolve_code_intel_path(Path::new(path))); + trim_trailing_separator(&resolved).eq_ignore_ascii_case(&target) + }) + }) +} + +/// An explicit `--repo-path` wins over `--repo`; an alias resolves through the +/// config's `repos` map, falling back to treating the alias as a path. +pub(super) fn resolve_repo_path(options: &Options, config: Option<&Value>) -> Option { + if let Some(repo_path) = options + .repo_path + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return Some(existing_or_literal(PathBuf::from(repo_path))); + } + let alias = options + .repo + .as_deref() + .filter(|value| !value.trim().is_empty())?; + let configured = repo_config_by_alias(config, alias) + .and_then(|entry| entry.get("path")) + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()); + Some(existing_or_literal(PathBuf::from( + configured.unwrap_or(alias), + ))) +} + +/// The configured `sentruxPath`, relative to the repo unless already rooted. +/// Absent config leaves the scope at the repository root. +pub(super) fn resolve_sentrux_scope(repo_path: &Path, repo_config: Option<&&Value>) -> PathBuf { + let configured = repo_config + .and_then(|entry| entry.get("sentruxPath")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let Some(configured) = configured else { + return repo_path.to_path_buf(); + }; + let scope = if Path::new(configured).is_absolute() { + PathBuf::from(configured) + } else { + repo_path.join(configured) + }; + existing_or_literal(scope) +} + +/// Resolve a directory that exists; otherwise keep the literal path so the +/// observation can report a missing repo instead of inventing one. +fn existing_or_literal(path: PathBuf) -> PathBuf { + if path.is_dir() { + resolve_code_intel_path(&path) + } else { + path + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn options(repo: Option<&str>, repo_path: Option<&str>) -> Options { + let mut options = Options::new(PathBuf::from(".")); + options.repo = repo.map(str::to_string); + options.repo_path = repo_path.map(str::to_string); + options + } + + #[test] + fn an_absent_config_file_is_neither_data_nor_an_error() { + let (data, error) = load_config(Path::new("does-not-exist.json")); + assert!(data.is_none()); + assert!(error.is_none()); + } + + #[test] + fn an_alias_without_a_configured_path_falls_back_to_the_alias_itself() { + let config = json!({"repos": {"fixture": {}}}); + let resolved = resolve_repo_path(&options(Some("fixture"), None), Some(&config)).unwrap(); + assert_eq!(resolved, PathBuf::from("fixture")); + } + + #[test] + fn an_alias_resolves_through_the_configured_path() { + let config = json!({"repos": {"fixture": {"path": "some/configured/repo"}}}); + let resolved = resolve_repo_path(&options(Some("fixture"), None), Some(&config)).unwrap(); + assert_eq!(resolved, PathBuf::from("some/configured/repo")); + } + + #[test] + fn an_explicit_repo_path_wins_over_the_alias() { + let config = json!({"repos": {"fixture": {"path": "some/configured/repo"}}}); + let resolved = resolve_repo_path( + &options(Some("fixture"), Some("explicit/path")), + Some(&config), + ) + .unwrap(); + assert_eq!(resolved, PathBuf::from("explicit/path")); + } + + #[test] + fn no_repo_selector_resolves_to_nothing() { + assert!(resolve_repo_path(&options(None, None), None).is_none()); + } + + #[test] + fn an_absent_sentrux_path_leaves_the_scope_at_the_repository_root() { + let repo = Path::new("some/repo"); + assert_eq!(resolve_sentrux_scope(repo, None), repo.to_path_buf()); + let entry = json!({}); + assert_eq!( + resolve_sentrux_scope(repo, Some(&&entry)), + repo.to_path_buf() + ); + } + + #[test] + fn a_relative_sentrux_path_is_joined_onto_the_repository() { + let entry = json!({"sentruxPath": "backend"}); + assert_eq!( + resolve_sentrux_scope(Path::new("some/repo"), Some(&&entry)), + PathBuf::from("some/repo").join("backend") + ); + } + + #[test] + fn a_rooted_sentrux_path_is_used_verbatim() { + let rooted = if cfg!(windows) { + r"C:\scoped" + } else { + "/scoped" + }; + let entry = json!({ "sentruxPath": rooted }); + assert_eq!( + resolve_sentrux_scope(Path::new("some/repo"), Some(&&entry)), + PathBuf::from(rooted) + ); + } + + #[test] + fn reverse_lookup_ignores_case_and_a_trailing_separator() { + let here = resolve_code_intel_path(Path::new(".")); + let config = json!({"repos": {"fixture": { + "path": format!("{}{}", display(&here), std::path::MAIN_SEPARATOR), + "sentruxPath": "backend" + }}}); + let entry = find_repo_config_by_path(Some(&config), &here).expect("reverse lookup"); + assert_eq!(entry["sentruxPath"], json!("backend")); + } + + #[test] + fn reverse_lookup_skips_entries_without_a_path() { + let config = json!({"repos": {"fixture": {"sentruxPath": "backend"}}}); + assert!(find_repo_config_by_path(Some(&config), Path::new(".")).is_none()); + } +} diff --git a/crates/code-intel-cli/src/doctor_bootstrap/mod.rs b/crates/code-intel-cli/src/doctor_bootstrap/mod.rs new file mode 100644 index 0000000..d1db53b --- /dev/null +++ b/crates/code-intel-cli/src/doctor_bootstrap/mod.rs @@ -0,0 +1,771 @@ +//! Native bootstrap/environment probe — the Rust owner of what +//! `archive/check-code-intel-tools.ps1` used to compute in PowerShell. +//! +//! Emits `code-intel-doctor-bootstrap-observation.v1`, the same +//! non-authoritative observation the doctor capability adapter consumes. The +//! PowerShell entry point is now a thin forwarder onto this module, so there +//! is exactly one implementation of the probe instead of a script plus a +//! divergent in-process fallback that hardcoded its graph-provider answers. +//! +//! Everything here is observation only: it reports presence and readiness of +//! tools, providers, config and repository state. It never writes, never +//! claims admissibility, and never emits engineering facts — those boundaries +//! belong to `doctor_adapter`. +//! +//! This file assembles the envelope; the three submodules own the concerns it +//! composes — `config` resolves the pipeline config and repository, `paths` +//! derives platform locations, `probe` observes tools and command output. + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +mod config; +mod paths; +mod probe; + +use paths::display; + +/// Marker the doctor capability adapter matches on. Kept as a constant so the +/// probe and the adapter's contract check cannot drift. +pub(crate) const BOOTSTRAP_SCHEMA: &str = "code-intel-doctor-bootstrap-observation.v1"; + +pub(crate) struct Options { + /// Repo alias resolved through `pipeline.config.json`'s `repos` map. + pub(crate) repo: Option, + /// Explicit repository path; takes precedence over `repo`. + pub(crate) repo_path: Option, + /// Pipeline config path; defaults to `/pipeline.config.json`. + pub(crate) config: Option, + /// `auto` | `windows` | `macos` | `linux`. + pub(crate) platform: String, + pub(crate) require_repowise: bool, + pub(crate) require_understand: bool, + /// Directory searched ahead of `PATH` when probing for tools. Lets a test + /// stand up a fixture toolchain without mutating the process environment. + pub(crate) tool_path_prefix: Option, + /// Repository root holding `crates/`, `target/` and `archive/`. + pub(crate) pipeline_root: PathBuf, +} + +impl Options { + pub(crate) fn new(pipeline_root: PathBuf) -> Self { + Self { + repo: None, + repo_path: None, + config: None, + platform: "auto".into(), + require_repowise: true, + require_understand: false, + tool_path_prefix: None, + pipeline_root, + } + } +} + +/// Run the probe and return the observation document. +pub(crate) fn observe(options: &Options) -> Result { + let platform = paths::resolve_platform(&options.platform)?; + let prefix = options.tool_path_prefix.as_deref(); + + let config_path = match &options.config { + Some(path) => path.clone(), + None => options.pipeline_root.join("pipeline.config.json"), + }; + let (config_data, config_parse_error) = config::load_config(&config_path); + + let repo_path = config::resolve_repo_path(options, config_data.as_ref()); + let repo_config = match (&options.repo_path, &repo_path) { + // An explicit --repo-path wins over the alias, so the config entry has + // to be found by reverse path lookup rather than by name. + (Some(_), Some(path)) => config::find_repo_config_by_path(config_data.as_ref(), path), + _ => options + .repo + .as_deref() + .and_then(|alias| config::repo_config_by_alias(config_data.as_ref(), alias)), + }; + let sentrux_scope = repo_path + .as_ref() + .map(|path| config::resolve_sentrux_scope(path, repo_config.as_ref())); + + let pipeline_script = options + .pipeline_root + .join("archive") + .join("run-code-intel.ps1"); + let cli_root = options.pipeline_root.join("crates").join("code-intel-cli"); + let graph_source = cli_root.join("src").join("graph.rs"); + let graph_cargo = cli_root.join("Cargo.toml"); + let binary_candidates = binary_candidates(&options.pipeline_root, &platform); + let graph_binary = binary_candidates + .iter() + .find(|path| path.is_file()) + .cloned(); + let graph_command_binary = graph_binary + .clone() + .unwrap_or_else(|| binary_candidates[0].clone()); + + // The structural gate engine ships inside the code-intel binary; an + // external sentrux on PATH is an optional overlay, not a bootstrap + // requirement. + let builtin_sentrux = probe::locate("code-intel", prefix).is_some() + || built_binaries(&options.pipeline_root).any(|path| path.is_file()); + + let tools = vec![ + probe::probe_tool("rg", true, prefix), + probe::probe_tool("git", true, prefix), + probe::probe_python(prefix), + probe::probe_tool("repowise", options.require_repowise, prefix), + probe::probe_tool("repomix", false, prefix), + probe::probe_tool("sentrux", !builtin_sentrux, prefix), + ]; + + let sentrux_core = probe::probe_command_output( + "sentrux-core", + "sentrux", + &["check", "--help"], + prefix, + |text| probe::contains_ignore_case(text, probe::SENTRUX_CORE_MARKER), + ); + // Tier: free is healthy without the SENTRUX_AUTO_PRO opt-in (Pro + // auto-activation is opt-in; see archive/tools/sentrux-shim/sentrux-shim.ps1). + let require_pro_tier = probe::requires_pro_tier(); + let sentrux_pro = probe::probe_command_output( + "sentrux-pro", + "sentrux", + &["pro", "status"], + prefix, + |text| probe::matches_tier(text, require_pro_tier), + ); + + let home_dir = paths::home_directory(); + let understand_skill = [".claude", ".agents", ".codex"] + .iter() + .map(|agent| { + home_dir + .join(agent) + .join("skills") + .join("understand") + .join("SKILL.md") + }) + .find(|path| path.is_file()); + let repo_parent = repo_path + .as_ref() + .and_then(|path| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| options.pipeline_root.clone()); + let understand_plugin = [ + home_dir + .join(".claude") + .join("plugins") + .join("cache") + .join("understand-anything"), + home_dir.join(".understand-anything-plugin"), + repo_parent.join("Understand-Anything"), + ] + .into_iter() + .find(|path| path.is_dir()); + + let repo_state = repo_state(repo_path.as_deref(), sentrux_scope.as_deref()); + let home = code_intel_home(&options.pipeline_root); + + let checks = json!({ + "pipelineScript": { + "path": display(&pipeline_script), + "found": pipeline_script.is_file() + }, + "config": { + "path": display(&config_path), + "found": config_path.is_file(), + "parsed": config_data.is_some() || config_parse_error.is_none(), + "parseError": config_parse_error.clone().unwrap_or_default() + }, + "tools": tools, + "sentrux": { + "core": sentrux_core, + "pro": sentrux_pro, + "builtin": {"found": builtin_sentrux} + }, + "understandAnything": { + "skillFound": understand_skill.is_some(), + "skillPath": understand_skill.as_deref().map(display).unwrap_or_default(), + "pluginFound": understand_plugin.is_some(), + "pluginPath": understand_plugin.as_deref().map(display).unwrap_or_default() + }, + "graphProvider": { + "sourceFound": graph_source.is_file(), + "cargoFound": graph_cargo.is_file(), + "binaryFound": graph_binary.is_some(), + "binaryPath": graph_binary.as_deref().map(display).unwrap_or_default(), + "command": format!( + "{} graph --repo --language zh --write --json", + display(&graph_command_binary) + ) + }, + "repo": repo_state, + "env": {"codeIntelHome": home.observation()} + }); + + let missing = missing_list(&checks, &tools, builtin_sentrux, options, &home); + Ok(json!({ + "schema": BOOTSTRAP_SCHEMA, + "authority": "observation_only", + "source": "native", + "ok": missing.is_empty(), + "missing": missing, + "platform": {"os": platform, "shell": "Rust", "psVersion": ""}, + "paths": paths::platform_paths(&platform, &options.pipeline_root), + "checks": checks, + "strict": { + "requireRepowise": options.require_repowise, + "requireUnderstand": options.require_understand + } + })) +} + +/// `target/release` then `target/debug`, with the platform-correct name — the +/// order the retired script probed and the order `binaryPath` reports. +fn binary_candidates(pipeline_root: &Path, platform: &str) -> [PathBuf; 2] { + let name = paths::binary_name(platform); + [ + pipeline_root.join("target").join("release").join(&name), + pipeline_root.join("target").join("debug").join(&name), + ] +} + +/// Both platform spellings under both profiles: the built-in engine check must +/// not depend on which platform's binary name this process was built for. +fn built_binaries(pipeline_root: &Path) -> impl Iterator + '_ { + ["release", "debug"].into_iter().flat_map(move |profile| { + ["code-intel.exe", "code-intel"] + .into_iter() + .map(move |name| pipeline_root.join("target").join(profile).join(name)) + }) +} + +/// `CODE_INTEL_HOME` compared against the default derivation — the pipeline +/// root — rather than against its own env-derived value, which would have +/// matched any set value including a deleted directory. +struct CodeIntelHome { + value: String, + resolved: String, + set: bool, + exists: bool, + matches_default: bool, + expected: String, +} + +impl CodeIntelHome { + fn observation(&self) -> Value { + json!({ + "expected": self.expected, + "value": self.value, + "resolved": self.resolved, + "exists": self.exists, + "matchesDefault": self.matches_default, + "ok": self.exists && self.matches_default + }) + } +} + +fn code_intel_home(pipeline_root: &Path) -> CodeIntelHome { + let expected = display(&paths::resolve_code_intel_path(pipeline_root)); + let value = std::env::var("CODE_INTEL_HOME").unwrap_or_default(); + let set = !value.trim().is_empty(); + let resolved = if set { + display(&paths::resolve_code_intel_path(Path::new(&value))) + } else { + String::new() + }; + let exists = set && Path::new(&resolved).is_dir(); + let matches_default = set && resolved == expected; + CodeIntelHome { + value: if set { value } else { String::new() }, + resolved, + set, + exists, + matches_default, + expected, + } +} + +/// The `missing` list, in the order the PowerShell probe emitted it — several +/// callers (installer checks, CI logs) read it as a comma-joined string. +fn missing_list( + checks: &Value, + tools: &[Value], + builtin_sentrux: bool, + options: &Options, + home: &CodeIntelHome, +) -> Vec { + let flag = |pointer: &str| { + checks + .pointer(pointer) + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let mut missing = Vec::new(); + if !flag("/pipelineScript/found") { + missing.push("pipeline script".to_string()); + } + if !flag("/config/found") { + missing.push("pipeline config".to_string()); + } + if flag("/config/found") && !flag("/config/parsed") { + let detail = checks + .pointer("/config/parseError") + .and_then(Value::as_str) + .unwrap_or_default(); + missing.push(format!("pipeline config: invalid JSON ({detail})")); + } + for tool in tools { + if tool["required"].as_bool().unwrap_or(false) && !tool["found"].as_bool().unwrap_or(false) + { + missing.push(tool["name"].as_str().unwrap_or_default().to_string()); + } + } + if !flag("/sentrux/core/found") && !builtin_sentrux { + missing.push("sentrux core".to_string()); + } + if !flag("/sentrux/pro/found") && !builtin_sentrux { + missing.push("sentrux pro auto-activation".to_string()); + } + if options.require_understand && !flag("/graphProvider/sourceFound") { + missing.push("internal graph provider source".to_string()); + } + if options.require_understand && !flag("/graphProvider/cargoFound") { + missing.push("code-intel Rust runtime".to_string()); + } + if checks["repo"].is_object() && !flag("/repo/exists") { + missing.push("repo path".to_string()); + } + if home.set && !home.exists { + missing.push(format!( + "CODE_INTEL_HOME: directory does not exist ({})", + home.resolved + )); + } + missing +} + +fn repo_state(repo_path: Option<&Path>, sentrux_scope: Option<&Path>) -> Value { + let Some(repo_path) = repo_path else { + return Value::Null; + }; + if !repo_path.is_dir() { + return json!({"path": display(repo_path), "exists": false}); + } + let scope = sentrux_scope.unwrap_or(repo_path); + let sentrux_dir = scope.join(".sentrux"); + json!({ + "path": display(repo_path), + "exists": true, + "isGitRepo": repo_path.join(".git").exists(), + "understandGraph": repo_path + .join(".understand-anything") + .join("knowledge-graph.json") + .is_file(), + "repowiseState": repo_path.join(".repowise").is_dir(), + "sentruxScope": display(scope), + "sentruxRules": sentrux_dir.join("rules.toml").is_file(), + "sentruxBaseline": sentrux_dir.join("baseline.json").is_file() + }) +} + +/// Sorted view of an observation's `checks` keys — used by the coverage +/// assertion so a silently dropped check surfaces as a test failure rather +/// than as a missing field downstream. `serde_json::Map` is a `BTreeMap` +/// here, so iteration is already ordered. +pub(crate) fn check_names(observation: &Value) -> Vec { + observation["checks"] + .as_object() + .map(|checks| checks.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Repository root: the directory holding `orchestration/`, discovered the +/// same way the capability layer discovers its manifest. +pub(crate) fn pipeline_root() -> PathBuf { + crate::capability::discover_manifest(None) + .and_then(|manifest| manifest.parent()?.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..")) +} + +/// The human-readable rendering the PowerShell probe printed without `-Json`. +/// CI reads these lines, so the wording is preserved verbatim. +pub(crate) fn render_human(observation: &Value) -> String { + let text = |pointer: &str| { + observation + .pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + let flag = |pointer: &str| { + observation + .pointer(pointer) + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let mark = |ok: bool| if ok { "OK" } else { "MISSING" }; + + let mut lines = vec![headline(observation)]; + lines.push(format!("Pipeline: {}", text("/checks/pipelineScript/path"))); + lines.push(format!("Config: {}", text("/checks/config/path"))); + if let Some(tools) = observation + .pointer("/checks/tools") + .and_then(Value::as_array) + { + for tool in tools { + lines.push(format!( + "{} {} {}", + mark(tool["found"].as_bool().unwrap_or(false)), + tool["name"].as_str().unwrap_or_default(), + tool["source"].as_str().unwrap_or_default() + )); + } + } + let builtin = flag("/checks/sentrux/builtin/found"); + lines.push(format!( + "{} sentrux-core {}", + mark(flag("/checks/sentrux/core/found") || builtin), + text("/checks/sentrux/core/output") + )); + lines.push(format!( + "{} sentrux-pro {}", + mark(flag("/checks/sentrux/pro/found") || builtin), + text("/checks/sentrux/pro/output") + )); + lines.push(format!( + "{} internal graph provider source={} cargo={} binary={}", + mark(flag("/checks/graphProvider/sourceFound") && flag("/checks/graphProvider/cargoFound")), + flag("/checks/graphProvider/sourceFound"), + flag("/checks/graphProvider/cargoFound"), + flag("/checks/graphProvider/binaryFound") + )); + lines.push(format!( + "{} external Understand fallback skill={} plugin={}", + mark( + flag("/checks/understandAnything/skillFound") + && flag("/checks/understandAnything/pluginFound") + ), + text("/checks/understandAnything/skillPath"), + text("/checks/understandAnything/pluginPath") + )); + lines.extend(repo_lines(observation, &text, &flag)); + lines.join("\n") +} + +fn headline(observation: &Value) -> String { + if observation["ok"].as_bool().unwrap_or(false) { + return "Code intel doctor: OK".to_string(); + } + let missing = observation["missing"] + .as_array() + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + format!("Code intel doctor: missing {missing}") +} + +fn repo_lines( + observation: &Value, + text: &impl Fn(&str) -> String, + flag: &impl Fn(&str) -> bool, +) -> Vec { + if !observation["checks"]["repo"].is_object() { + return Vec::new(); + } + let mut lines = vec![ + format!("Repo: {}", text("/checks/repo/path")), + format!("Repo exists: {}", flag("/checks/repo/exists")), + ]; + if flag("/checks/repo/exists") { + lines.push(format!( + "Understand graph: {}", + flag("/checks/repo/understandGraph") + )); + lines.push(format!( + "Repowise state: {}", + flag("/checks/repo/repowiseState") + )); + lines.push(format!( + "Sentrux scope: {}", + text("/checks/repo/sentruxScope") + )); + lines.push(format!( + "Sentrux rules: {}", + flag("/checks/repo/sentruxRules") + )); + lines.push(format!( + "Sentrux baseline: {}", + flag("/checks/repo/sentruxBaseline") + )); + } + lines +} + +/// `code-intel doctor bootstrap [...]` — the direct CLI surface that replaced +/// `archive/check-code-intel-tools.ps1`. Exits 1 when the probe reports +/// missing prerequisites, matching the script it retired. +pub(crate) fn run_raw(raw: &[String]) -> i32 { + let mut options = Options::new(pipeline_root()); + let mut json_output = false; + let mut index = 0; + while index < raw.len() { + let token = raw[index].as_str(); + // A following token that itself looks like a flag is not a value, so + // `--repo --json` fails closed instead of consuming `--json`. + let value = raw.get(index + 1).filter(|value| !value.starts_with("--")); + index += match (token, value) { + ("--json", _) => { + json_output = true; + 1 + } + ("--require-repowise", _) => { + options.require_repowise = true; + 1 + } + ("--no-require-repowise", _) => { + options.require_repowise = false; + 1 + } + ("--require-understand", _) => { + options.require_understand = true; + 1 + } + ("--repo", Some(value)) => { + options.repo = Some(value.clone()); + 2 + } + ("--repo-path", Some(value)) => { + options.repo_path = Some(value.clone()); + 2 + } + ("--config", Some(value)) => { + options.config = Some(PathBuf::from(value)); + 2 + } + ("--platform", Some(value)) => { + options.platform = value.clone(); + 2 + } + ("--pipeline-root", Some(value)) => { + options.pipeline_root = PathBuf::from(value); + 2 + } + ("--repo" | "--repo-path" | "--config" | "--platform" | "--pipeline-root", None) => { + return fail(&format!("{token} requires a value")) + } + (other, _) => return fail(&format!("unknown argument for doctor bootstrap: {other}")), + }; + } + + let observation = match observe(&options) { + Ok(observation) => observation, + Err(error) => return fail(&error), + }; + let rendered = if json_output { + match serde_json::to_string_pretty(&observation) { + Ok(text) => text, + Err(error) => return fail(&format!("serialize doctor observation: {error}")), + } + } else { + render_human(&observation) + }; + println!("{rendered}"); + i32::from(!observation["ok"].as_bool().unwrap_or(false)) +} + +fn fail(message: &str) -> i32 { + eprintln!("error: {message}"); + 65 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "code-intel-doctor-bootstrap-{}-{tag}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(&dir).expect("scratch"); + dir + } + + fn home(set: bool, exists: bool) -> CodeIntelHome { + CodeIntelHome { + value: "C:/nope".into(), + resolved: "C:/nope".into(), + set, + exists, + matches_default: false, + expected: "C:/expected".into(), + } + } + + fn strict(require_understand: bool) -> Options { + let mut options = Options::new(PathBuf::from(".")); + options.require_understand = require_understand; + options + } + + #[test] + fn missing_list_preserves_the_retired_scripts_wording_and_order() { + let checks = json!({ + "pipelineScript": {"found": false}, + "config": {"found": true, "parsed": false, "parseError": "bad json"}, + "sentrux": {"core": {"found": false}, "pro": {"found": false}}, + "graphProvider": {"sourceFound": false, "cargoFound": false}, + "repo": {"path": "x", "exists": false} + }); + let tools = vec![ + json!({"name": "rg", "required": true, "found": false}), + json!({"name": "repomix", "required": false, "found": false}), + ]; + assert_eq!( + missing_list(&checks, &tools, false, &strict(true), &home(true, false)), + vec![ + "pipeline script".to_string(), + "pipeline config: invalid JSON (bad json)".to_string(), + "rg".to_string(), + "sentrux core".to_string(), + "sentrux pro auto-activation".to_string(), + "internal graph provider source".to_string(), + "code-intel Rust runtime".to_string(), + "repo path".to_string(), + "CODE_INTEL_HOME: directory does not exist (C:/nope)".to_string(), + ] + ); + } + + #[test] + fn builtin_sentrux_makes_the_external_overlay_optional() { + let checks = json!({ + "pipelineScript": {"found": true}, + "config": {"found": true, "parsed": true}, + "sentrux": {"core": {"found": false}, "pro": {"found": false}}, + "graphProvider": {"sourceFound": true, "cargoFound": true}, + "repo": {"exists": true} + }); + let tools = vec![json!({"name": "sentrux", "required": false, "found": false})]; + assert!( + missing_list(&checks, &tools, true, &strict(false), &home(false, false)).is_empty() + ); + } + + #[test] + fn observation_carries_the_v1_contract_and_every_retired_check() { + let root = scratch("contract"); + let mut options = Options::new(root.clone()); + options.repo_path = Some(display(&root)); + let observation = observe(&options).unwrap(); + assert_eq!(observation["schema"], BOOTSTRAP_SCHEMA); + assert_eq!(observation["authority"], "observation_only"); + assert!(observation["ok"].is_boolean()); + assert_eq!( + check_names(&observation), + vec![ + "config".to_string(), + "env".to_string(), + "graphProvider".to_string(), + "pipelineScript".to_string(), + "repo".to_string(), + "sentrux".to_string(), + "tools".to_string(), + "understandAnything".to_string(), + ] + ); + let names = observation["checks"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap_or_default().to_string()) + .collect::>(); + assert_eq!( + names, + vec!["rg", "git", "python", "repowise", "repomix", "sentrux"] + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn a_missing_repo_path_is_a_domain_observation_not_an_error() { + let root = scratch("absent"); + let mut options = Options::new(root.clone()); + options.repo_path = Some(display(&root.join("does-not-exist"))); + let observation = observe(&options).unwrap(); + assert_eq!(observation["checks"]["repo"]["exists"], json!(false)); + assert_eq!(observation["ok"], json!(false)); + assert!(observation["missing"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "repo path")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn configured_sentrux_path_resolves_the_scope_and_finds_scoped_rules() { + let root = scratch("scope"); + let repo = root.join("ConfiguredRepo"); + let sentrux = repo.join("backend").join(".sentrux"); + fs::create_dir_all(&sentrux).unwrap(); + fs::write(sentrux.join("rules.toml"), b"").unwrap(); + fs::write(sentrux.join("baseline.json"), b"{}").unwrap(); + let config_path = root.join("pipeline.config.json"); + fs::write( + &config_path, + serde_json::to_vec(&json!({"repos": {"fixture": { + "path": format!("{}{}", display(&repo), std::path::MAIN_SEPARATOR), + "sentruxPath": "backend" + }}})) + .unwrap(), + ) + .unwrap(); + + let mut options = Options::new(root.clone()); + options.config = Some(config_path); + // A `.` segment in the argument, exactly the shape the retired + // PowerShell contract test exercised. + options.repo_path = Some(display(&repo.join("."))); + let observation = observe(&options).unwrap(); + + let expected = display(&paths::resolve_code_intel_path(&repo.join("backend"))); + assert_eq!( + observation["checks"]["repo"]["sentruxScope"], + json!(expected) + ); + assert_eq!(observation["checks"]["repo"]["sentruxRules"], json!(true)); + assert_eq!( + observation["checks"]["repo"]["sentruxBaseline"], + json!(true) + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn human_rendering_keeps_the_retired_scripts_first_line() { + let ok = json!({"ok": true, "missing": [], "checks": {}}); + assert!(render_human(&ok).starts_with("Code intel doctor: OK")); + let bad = json!({"ok": false, "missing": ["rg", "git"], "checks": {}}); + assert!(render_human(&bad).starts_with("Code intel doctor: missing rg, git")); + } + + #[test] + fn binary_candidates_prefer_release_over_debug() { + let candidates = binary_candidates(Path::new("root"), "windows"); + assert!(candidates[0].ends_with(Path::new("target/release/code-intel.exe"))); + assert!(candidates[1].ends_with(Path::new("target/debug/code-intel.exe"))); + } +} diff --git a/crates/code-intel-cli/src/doctor_bootstrap/paths.rs b/crates/code-intel-cli/src/doctor_bootstrap/paths.rs new file mode 100644 index 0000000..822a587 --- /dev/null +++ b/crates/code-intel-cli/src/doctor_bootstrap/paths.rs @@ -0,0 +1,203 @@ +//! Path and platform derivation for the doctor bootstrap probe. +//! +//! Mirrors the helpers the retired `archive/tools/code-intel-platform.psm1` +//! exposed to `check-code-intel-tools.ps1`: platform resolution, +//! `Resolve-CodeIntelPath`, the home/data-root/bin triple, and the +//! platform-correct binary name. + +use std::env; +use std::path::{Component, Path, PathBuf}; + +use serde_json::{json, Value}; + +/// `Get-CodeIntelPlatform`. `auto` resolves from the compile target. +pub(super) fn resolve_platform(requested: &str) -> Result { + match requested { + "windows" | "macos" | "linux" => Ok(requested.to_string()), + "auto" => { + if cfg!(windows) { + Ok("windows".into()) + } else if cfg!(target_os = "macos") { + Ok("macos".into()) + } else if cfg!(target_os = "linux") { + Ok("linux".into()) + } else { + Err("Unsupported platform. Pass --platform windows|macos|linux.".into()) + } + } + other => Err(format!( + "--platform must be auto|windows|macos|linux, got {other}" + )), + } +} + +pub(super) fn binary_name(platform: &str) -> String { + if platform == "windows" { + "code-intel.exe".into() + } else { + "code-intel".into() + } +} + +/// The `paths` block of the observation: `Get-CodeIntelPaths`'s home, +/// dataRoot, bin and codeIntelHome, with the same env-var overrides. +pub(super) fn platform_paths(platform: &str, pipeline_root: &Path) -> Value { + let home = home_directory(); + let data_root = data_root(platform, &home); + let bin = match env::var("CODE_INTEL_BIN") { + Ok(value) if !value.trim().is_empty() => resolve_code_intel_path(Path::new(&value)), + _ => data_root.join("bin"), + }; + let code_intel_home = match env::var("CODE_INTEL_HOME") { + Ok(value) if !value.trim().is_empty() => resolve_code_intel_path(Path::new(&value)), + _ => resolve_code_intel_path(pipeline_root), + }; + json!({ + "home": display(&home), + "dataRoot": display(&data_root), + "bin": display(&bin), + "codeIntelHome": display(&code_intel_home) + }) +} + +/// `Get-CodeIntelDataRoot`: `CODE_INTEL_DATA_ROOT`, else the platform's +/// conventional per-user data location. +fn data_root(platform: &str, home: &Path) -> PathBuf { + if let Ok(value) = env::var("CODE_INTEL_DATA_ROOT") { + if !value.trim().is_empty() { + return resolve_code_intel_path(Path::new(&value)); + } + } + match platform { + "windows" => env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .filter(|base| !base.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".code-intel")) + .join("code-intel"), + "macos" => home + .join("Library") + .join("Application Support") + .join("code-intel"), + _ => env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .filter(|base| !base.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".local").join("share")) + .join("code-intel"), + } +} + +pub(super) fn home_directory() -> PathBuf { + let raw = if cfg!(windows) { + env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) + } else { + env::var_os("HOME") + }; + raw.map(PathBuf::from) + .filter(|path| !path.as_os_str().is_empty()) + .map(|path| resolve_code_intel_path(&path)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// `Resolve-CodeIntelPath`: the on-disk absolute path when it exists, an +/// absolute lexically-normalized path when it does not. Windows verbatim +/// (`\\?\`) prefixes are stripped so the value stays comparable to the path +/// strings every other producer in this pipeline emits. +pub(super) fn resolve_code_intel_path(path: &Path) -> PathBuf { + match std::fs::canonicalize(path) { + Ok(resolved) => strip_verbatim(&resolved), + Err(_) => normalize(&absolute_from_cwd(path)), + } +} + +fn absolute_from_cwd(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + +/// Lexical `.`/`..` collapse, matching `[Path]::GetFullPath` for paths that do +/// not exist on disk (where `canonicalize` cannot help). +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !matches!( + out.components().next_back(), + None | Some(Component::RootDir) | Some(Component::Prefix(_)) + ) { + out.pop(); + } + } + other => out.push(other.as_os_str()), + } + } + out +} + +fn strip_verbatim(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + text.strip_prefix(r"\\?\") + .map(PathBuf::from) + .unwrap_or_else(|| path.to_path_buf()) +} + +pub(super) fn trim_trailing_separator(value: &str) -> String { + let trimmed = value.trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + value.to_string() + } else { + trimmed.to_string() + } +} + +pub(super) fn display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_resolution_rejects_unknown_values() { + assert_eq!(resolve_platform("linux").unwrap(), "linux"); + assert!(resolve_platform("auto").is_ok()); + assert!(resolve_platform("solaris").is_err()); + } + + #[test] + fn binary_name_is_platform_correct() { + assert_eq!(binary_name("windows"), "code-intel.exe"); + assert_eq!(binary_name("linux"), "code-intel"); + assert_eq!(binary_name("macos"), "code-intel"); + } + + #[test] + fn path_normalization_collapses_dot_segments_for_absent_paths() { + assert_eq!( + normalize(Path::new("/a/b/../c/./d")), + PathBuf::from("/a/c/d") + ); + } + + #[test] + fn trailing_separators_are_trimmed_for_config_path_comparison() { + assert_eq!(trim_trailing_separator("C:/repo/"), "C:/repo"); + assert_eq!(trim_trailing_separator(r"C:\repo\"), r"C:\repo"); + // A bare separator must survive: trimming it to "" would make every + // configured path compare equal to the filesystem root. + assert_eq!(trim_trailing_separator("/"), "/"); + } + + #[test] + fn resolved_paths_are_absolute_and_carry_no_verbatim_prefix() { + let resolved = resolve_code_intel_path(Path::new(".")); + assert!(resolved.is_absolute()); + assert!(!resolved.to_string_lossy().starts_with(r"\\?\")); + } +} diff --git a/crates/code-intel-cli/src/doctor_bootstrap/probe.rs b/crates/code-intel-cli/src/doctor_bootstrap/probe.rs new file mode 100644 index 0000000..0b55241 --- /dev/null +++ b/crates/code-intel-cli/src/doctor_bootstrap/probe.rs @@ -0,0 +1,183 @@ +//! Tool presence and command-output probing for the doctor bootstrap. +//! +//! Every function here reports what it observed and never fails: the absence +//! of an optional overlay is the observation, not an error. Tool lookup goes +//! through the shared `tool_path` resolver so presence-checking here and +//! path-resolution at real launch sites cannot drift apart. + +use std::env; +use std::path::Path; +use std::process::Command; + +use serde_json::{json, Value}; + +use super::paths::display; + +#[path = "../tool_path.rs"] +mod tool_path; + +/// Substring `sentrux check --help` must print for the core overlay to count +/// as conforming. PowerShell's `-match` is case-insensitive, so this compares +/// case-insensitively too. +pub(super) const SENTRUX_CORE_MARKER: &str = "Enforce architectural rules"; + +pub(super) fn locate(name: &str, prefix: Option<&Path>) -> Option { + tool_path::locate(name, prefix) +} + +pub(super) fn probe_tool(name: &str, required: bool, prefix: Option<&Path>) -> Value { + let found = tool_path::locate(name, prefix); + json!({ + "name": name, + "required": required, + "found": found.is_some(), + "source": found.as_deref().map(display).unwrap_or_default() + }) +} + +/// `python` falls back to `python3`, matching `Get-CodeIntelPythonCommand`. +/// The reported `name` stays `python` so the `missing` list wording does not +/// change with which interpreter happened to be installed. +pub(super) fn probe_python(prefix: Option<&Path>) -> Value { + let found = + tool_path::locate("python", prefix).or_else(|| tool_path::locate("python3", prefix)); + json!({ + "name": "python", + "required": true, + "found": found.is_some(), + "source": found.as_deref().map(display).unwrap_or_default() + }) +} + +/// Run `program args...` and decide `found` from exit status plus a predicate +/// over the merged stdout/stderr text. A program that cannot be located or +/// launched is a `found: false` observation, never an error. +pub(super) fn probe_command_output( + name: &str, + program: &str, + args: &[&str], + prefix: Option<&Path>, + matches: impl Fn(&str) -> bool, +) -> Value { + let Some(binary) = tool_path::locate(program, prefix) else { + return json!({ + "name": name, + "found": false, + "output": format!("{program} was not found on PATH") + }); + }; + let mut command = Command::new(&binary); + command.args(args); + if let Some(prefix) = prefix { + if let Some(path) = prefixed_path(prefix) { + command + .env_remove("PATH") + .env_remove("Path") + .env("PATH", path); + } + } + match command.output() { + Ok(output) => { + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + let text = text.trim().to_string(); + json!({ + "name": name, + "found": output.status.success() && matches(&text), + "output": text + }) + } + Err(error) => json!({"name": name, "found": false, "output": error.to_string()}), + } +} + +fn prefixed_path(prefix: &Path) -> Option { + let mut paths = vec![prefix.to_path_buf()]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + env::join_paths(paths).ok() +} + +/// Whether Pro auto-activation is opted into, which decides whether a `free` +/// tier still counts as healthy. +pub(super) fn requires_pro_tier() -> bool { + matches!( + env::var("SENTRUX_AUTO_PRO").unwrap_or_default().as_str(), + "1" | "true" | "True" | "TRUE" + ) +} + +/// `Tier:\s+pro` when Pro auto-activation is opted into, `Tier:\s+(pro|free)` +/// otherwise. Hand-rolled because the crate carries no regex dependency, and +/// case-insensitive to match PowerShell `-match` semantics. +pub(super) fn matches_tier(text: &str, require_pro: bool) -> bool { + let lower = text.to_ascii_lowercase(); + let mut rest = lower.as_str(); + while let Some(index) = rest.find("tier:") { + let after = &rest[index + "tier:".len()..]; + let trimmed = after.trim_start_matches([' ', '\t', '\r', '\n']); + if trimmed.len() < after.len() + && (trimmed.starts_with("pro") || (!require_pro && trimmed.starts_with("free"))) + { + return true; + } + rest = after; + } + false +} + +pub(super) fn contains_ignore_case(text: &str, needle: &str) -> bool { + text.to_ascii_lowercase() + .contains(&needle.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tier_pattern_accepts_free_only_without_the_pro_opt_in() { + assert!(matches_tier("Sentrux\nTier: free\n", false)); + assert!(matches_tier("Tier: pro", false)); + assert!(matches_tier("Tier: pro", true)); + assert!(!matches_tier("Tier: free", true)); + // No whitespace after the colon is not a match, same as `\s+`. + assert!(!matches_tier("Tier:free", false)); + assert!(!matches_tier("no tier line here", false)); + } + + #[test] + fn core_marker_comparison_is_case_insensitive_like_powershell_match() { + assert!(contains_ignore_case( + " ENFORCE ARCHITECTURAL RULES for a repo", + SENTRUX_CORE_MARKER + )); + assert!(!contains_ignore_case( + "some other help text", + SENTRUX_CORE_MARKER + )); + } + + #[test] + fn a_missing_tool_is_a_found_false_observation() { + let probe = probe_tool("__code_intel_absent_tool__", true, None); + assert_eq!(probe["found"], json!(false)); + assert_eq!(probe["required"], json!(true)); + assert_eq!(probe["source"], json!("")); + } + + #[test] + fn an_absent_program_reports_without_failing() { + let probe = probe_command_output( + "absent", + "__code_intel_absent_tool__", + &["--help"], + None, + |_| true, + ); + assert_eq!(probe["found"], json!(false)); + assert!(probe["output"] + .as_str() + .unwrap() + .contains("was not found on PATH")); + } +} diff --git a/orchestration/integrations.json b/orchestration/integrations.json index 251b8ab..f0686d3 100644 --- a/orchestration/integrations.json +++ b/orchestration/integrations.json @@ -266,7 +266,7 @@ "owner": "code-intel-pipeline", "kind": "internal-rust-binary", "required": true, - "entrypoint": "crates/code-intel-cli/src/doctor_bootstrap.rs", + "entrypoint": "crates/code-intel-cli/src/doctor_bootstrap/mod.rs", "capabilities": [ "preflight", "tool_contract", @@ -285,8 +285,11 @@ "id": "doctor.envelope.compat", "version": "1.0.0", "toolchainDigests": [ - "0d43645caedd67c3a76533c7de0734822d514a52866f512b1a62332840c9ff15", - "da7afbcc1c3072df79082cc2542daa4a2339fd437386341c75f366afa5c6e149", + "084f4322c59669bce76c846196b48709a14140f56e9452d9057b7929415296e4", + "85ffb7a1bc3ba01cac92438793165893d933aada7cdf6a0217c40c4938617402", + "7db9e80857f3c65aada25041986cb5cb3b50b95fa69de794d55ad38fd4a8d8d4", + "af67633e245665cdccef1b8dac1f672f3aca7af188449cde6e5fc518917c51be", + "c9842468b904868391ff0fd2bb5d9627f4d61ab5f39fa01513dc409d0d2b484d", "129dc6ff0b2f72b0c84f9f770a9ca7c348ada4c9a0e0537e94c63cf29f708bab" ] }, @@ -299,7 +302,10 @@ "algorithm": "sha256", "inputs": [ "crates/code-intel-cli/src/doctor_adapter.rs", - "crates/code-intel-cli/src/doctor_bootstrap.rs", + "crates/code-intel-cli/src/doctor_bootstrap/mod.rs", + "crates/code-intel-cli/src/doctor_bootstrap/config.rs", + "crates/code-intel-cli/src/doctor_bootstrap/paths.rs", + "crates/code-intel-cli/src/doctor_bootstrap/probe.rs", "crates/code-intel-cli/src/capability_inventory.rs" ] }, From 02142706f25d06568785a17100e2af1d7bb3fdfd Mon Sep 17 00:00:00 2001 From: Curry <57929895+2233admin@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:39:54 +0800 Subject: [PATCH 3/4] fix(doctor): silence the dead-code noise the double module inclusion creates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review feedback on PR #70. `doctor_bootstrap` compiles twice: once as the binary's own module and once through the `#[path]` include inside `doctor_adapter`, which several integration tests pull into their own crate roots. The adapter copy needs only `Options`, `observe` and `BOOTSTRAP_SCHEMA`, so the CLI surface has no caller there and every one of those items warned. The allowance goes on the `#[path]` module declaration rather than on the individual items: that declaration is what makes them unreachable, it is one place instead of seven, and `main.rs` declares the same module without it, so genuinely dead code still warns where the module is actually the binary's. `check_names` was deleted outright rather than allowed — it had no caller outside its own test, so the lint was right about that one. The assertion it served is now inline in the test that needed it. The reviewer's other note, a collapsible `if` in `matches_tier`, was already resolved by the module split in the previous commit; the predicate is a single `&&` chain in doctor_bootstrap/probe.rs now. Verification: zero warnings remain from the four doctor_bootstrap modules; cargo test 45 suites 0 failed; sentrux gate reports no degradation; run execute --repo . exits 0; cargo fmt --check clean. Refs #48 --- crates/code-intel-cli/src/doctor_adapter.rs | 8 +++++++ .../src/doctor_bootstrap/mod.rs | 22 +++++++++---------- orchestration/integrations.json | 4 ++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/code-intel-cli/src/doctor_adapter.rs b/crates/code-intel-cli/src/doctor_adapter.rs index b87af84..5fdb871 100644 --- a/crates/code-intel-cli/src/doctor_adapter.rs +++ b/crates/code-intel-cli/src/doctor_adapter.rs @@ -14,6 +14,14 @@ use crate::capability::sha256_hex; // integration tests pull this adapter into their own crate via `#[path]`, and // those roots do not declare the binary's module list. Same convention the // adapter already used for `tool_path`. +// +// `allow(dead_code)` sits here rather than on the individual items because it +// is this inclusion that makes them unreachable: the adapter needs only +// `Options`, `observe` and `BOOTSTRAP_SCHEMA`, so the CLI surface (`run_raw`, +// `render_human`, `pipeline_root`, …) has no caller in this copy. `main.rs` +// declares the same module without the allowance, so genuinely dead code still +// warns where the module is actually the binary's. +#[allow(dead_code)] #[path = "doctor_bootstrap/mod.rs"] mod doctor_bootstrap; diff --git a/crates/code-intel-cli/src/doctor_bootstrap/mod.rs b/crates/code-intel-cli/src/doctor_bootstrap/mod.rs index d1db53b..69804d8 100644 --- a/crates/code-intel-cli/src/doctor_bootstrap/mod.rs +++ b/crates/code-intel-cli/src/doctor_bootstrap/mod.rs @@ -370,17 +370,6 @@ fn repo_state(repo_path: Option<&Path>, sentrux_scope: Option<&Path>) -> Value { }) } -/// Sorted view of an observation's `checks` keys — used by the coverage -/// assertion so a silently dropped check surfaces as a test failure rather -/// than as a missing field downstream. `serde_json::Map` is a `BTreeMap` -/// here, so iteration is already ordered. -pub(crate) fn check_names(observation: &Value) -> Vec { - observation["checks"] - .as_object() - .map(|checks| checks.keys().cloned().collect()) - .unwrap_or_default() -} - /// Repository root: the directory holding `orchestration/`, discovered the /// same way the capability layer discovers its manifest. pub(crate) fn pipeline_root() -> PathBuf { @@ -673,8 +662,17 @@ mod tests { assert_eq!(observation["schema"], BOOTSTRAP_SCHEMA); assert_eq!(observation["authority"], "observation_only"); assert!(observation["ok"].is_boolean()); + // `serde_json::Map` is a `BTreeMap` here, so the key order is stable. + // A silently dropped check has to surface as a test failure rather + // than as a missing field downstream. + let checks = observation["checks"] + .as_object() + .expect("checks object") + .keys() + .cloned() + .collect::>(); assert_eq!( - check_names(&observation), + checks, vec![ "config".to_string(), "env".to_string(), diff --git a/orchestration/integrations.json b/orchestration/integrations.json index f0686d3..fa547e2 100644 --- a/orchestration/integrations.json +++ b/orchestration/integrations.json @@ -285,8 +285,8 @@ "id": "doctor.envelope.compat", "version": "1.0.0", "toolchainDigests": [ - "084f4322c59669bce76c846196b48709a14140f56e9452d9057b7929415296e4", - "85ffb7a1bc3ba01cac92438793165893d933aada7cdf6a0217c40c4938617402", + "21fc5754b9803f9fdee3d0a3eebe7db3d5f2bae1d93cd4218590522c9d3b9f19", + "04894e3ec1a87127491f5584fa3d066b29b1f1506ec32b365e9fd1c2f95ca081", "7db9e80857f3c65aada25041986cb5cb3b50b95fa69de794d55ad38fd4a8d8d4", "af67633e245665cdccef1b8dac1f672f3aca7af188449cde6e5fc518917c51be", "c9842468b904868391ff0fd2bb5d9627f4d61ab5f39fa01513dc409d0d2b484d", From 52a4647d6c3596692f4dd7541269cfb177f4fea5 Mon Sep 17 00:00:00 2001 From: Curry <57929895+2233admin@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:55:20 +0800 Subject: [PATCH 4/4] fix(doctor): resolve the pipeline root from the checkout, not the executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Doctor step failed on all four runners with `missing: ["pipeline script", "pipeline config"]`. Root cause: `run_raw` defaulted the pipeline root to `pipeline_root()`, which walks up from `std::env::current_exe()` in search of `orchestration/integrations.json` — and `install-code-intel-pipeline.ps1` copies that manifest into the bin directory next to the installed binary. An installed `code-intel` therefore resolved its own bin directory as the pipeline checkout and reported every repository-side check absent: "path": "/home/runner/.local/share/code-intel/bin/archive/run-code-intel.ps1" That is correct behavior for capability manifest discovery — an installed binary should use its installed manifest — but wrong for "which pipeline checkout am I inspecting". The retired script never had the ambiguity: it derived the root from `$PSCommandPath`, its own location inside the checkout. The CLI now walks up from the working directory for the same marker and only falls back to manifest discovery, so standing in a checkout resolves that checkout. `--pipeline-root` still overrides, and the PowerShell forwarder keeps passing it explicitly from its own location. `doctor_adapter` is untouched and still uses manifest discovery, which is right for the kernel path where the binary runs from the repository's own target directory. CI keeps calling the subcommand without `--pipeline-root` on purpose, so the default stays exercised rather than papered over by a flag. Verified against the actual failure mode, not just unit tests: reinstalled the pipeline so `code-intel` on PATH is the installed copy, then ran CI's exact command from the checkout — `code-intel doctor bootstrap --repo-path . --no-require-repowise --json` — which now exits 0 with `ok: true` and resolves pipelineScript/config into the repository rather than the bin directory. Also adds a regression test that plants a decoy `orchestration/integrations.json` beside a fake install location, runs from a nested directory of the real checkout with no `--pipeline-root`, and asserts the checkout wins. Full pass: cargo test 45 suites 0 failed; sentrux gate no degradation; run execute --repo . exit 0; cargo fmt --check clean; test-doctor-repo-config-resolution.ps1 PASS; test-regression-fixes.ps1 49 passed. Refs #48 --- .../src/doctor_bootstrap/mod.rs | 27 +++++++- .../tests/doctor_bootstrap_cli.rs | 62 +++++++++++++++++++ orchestration/integrations.json | 2 +- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/crates/code-intel-cli/src/doctor_bootstrap/mod.rs b/crates/code-intel-cli/src/doctor_bootstrap/mod.rs index 69804d8..5b0a388 100644 --- a/crates/code-intel-cli/src/doctor_bootstrap/mod.rs +++ b/crates/code-intel-cli/src/doctor_bootstrap/mod.rs @@ -378,6 +378,31 @@ pub(crate) fn pipeline_root() -> PathBuf { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..")) } +/// The pipeline checkout a bare `code-intel doctor bootstrap` should inspect. +/// +/// Deliberately NOT `pipeline_root()`: that walks up from the executable, and +/// the installer copies `orchestration/integrations.json` next to the +/// installed binary, so an installed `code-intel` would resolve its own bin +/// directory as the pipeline and report every repository-side check missing. +/// The retired script derived the root from its own location inside the +/// checkout; the closest CLI analogue is the checkout the caller is standing +/// in, so walk up from the working directory first and only then fall back to +/// manifest discovery. `--pipeline-root` overrides both. +fn default_pipeline_root() -> PathBuf { + std::env::current_dir() + .ok() + .and_then(|cwd| { + cwd.ancestors() + .find(|dir| { + dir.join("orchestration") + .join("integrations.json") + .is_file() + }) + .map(Path::to_path_buf) + }) + .unwrap_or_else(pipeline_root) +} + /// The human-readable rendering the PowerShell probe printed without `-Json`. /// CI reads these lines, so the wording is preserved verbatim. pub(crate) fn render_human(observation: &Value) -> String { @@ -501,7 +526,7 @@ fn repo_lines( /// `archive/check-code-intel-tools.ps1`. Exits 1 when the probe reports /// missing prerequisites, matching the script it retired. pub(crate) fn run_raw(raw: &[String]) -> i32 { - let mut options = Options::new(pipeline_root()); + let mut options = Options::new(default_pipeline_root()); let mut json_output = false; let mut index = 0; while index < raw.len() { diff --git a/crates/code-intel-cli/tests/doctor_bootstrap_cli.rs b/crates/code-intel-cli/tests/doctor_bootstrap_cli.rs index c72b7a9..b332f95 100644 --- a/crates/code-intel-cli/tests/doctor_bootstrap_cli.rs +++ b/crates/code-intel-cli/tests/doctor_bootstrap_cli.rs @@ -186,6 +186,68 @@ fn unparsable_config_is_a_domain_finding_not_a_crash() { fs::remove_dir_all(root).ok(); } +/// Regression: an installed `code-intel` sits next to a copy of +/// `orchestration/integrations.json` that the installer places in its bin +/// directory, so deriving the pipeline root from the executable resolved that +/// bin directory and reported `pipeline script` / `pipeline config` missing — +/// which is exactly how CI's Doctor step failed on all four runners. Without +/// `--pipeline-root`, the checkout the caller is standing in must win. +#[test] +fn the_pipeline_root_defaults_to_the_checkout_the_caller_stands_in() { + let root = temp_dir("cwd-root"); + let checkout = root.join("checkout"); + let bin = root.join("bin"); + // A decoy manifest beside the "installed" binary location, mirroring what + // install-code-intel-pipeline.ps1 writes. + for dir in [&checkout, &bin] { + fs::create_dir_all(dir.join("orchestration")).unwrap(); + fs::write(dir.join("orchestration").join("integrations.json"), b"{}").unwrap(); + } + fs::create_dir_all(checkout.join("archive")).unwrap(); + fs::write(checkout.join("archive").join("run-code-intel.ps1"), b"").unwrap(); + fs::write(checkout.join("pipeline.config.json"), b"{}").unwrap(); + let crate_src = checkout.join("crates").join("code-intel-cli").join("src"); + fs::create_dir_all(&crate_src).unwrap(); + fs::write(crate_src.join("graph.rs"), b"").unwrap(); + fs::write( + checkout + .join("crates") + .join("code-intel-cli") + .join("Cargo.toml"), + b"[package]", + ) + .unwrap(); + + // No --pipeline-root, and the working directory is a subdirectory of the + // checkout so the upward walk is exercised too. + let nested = checkout.join("crates"); + let output = Command::new(env!("CARGO_BIN_EXE_code-intel")) + .args(["doctor", "bootstrap", "--no-require-repowise", "--json"]) + .current_dir(&nested) + .output() + .expect("run doctor bootstrap"); + let observation: Value = + serde_json::from_slice(&output.stdout).expect("observation is one JSON document"); + + assert_eq!( + observation["checks"]["pipelineScript"]["found"], + json!(true) + ); + assert_eq!(observation["checks"]["config"]["found"], json!(true)); + assert_eq!( + observation["checks"]["graphProvider"]["sourceFound"], + json!(true) + ); + let missing = observation["missing"].as_array().unwrap(); + for absent in ["pipeline script", "pipeline config"] { + assert!( + !missing.iter().any(|value| value == absent), + "{absent} must not be reported when standing in the checkout: {missing:?}" + ); + } + fs::remove_dir_all(root).ok(); +} + #[test] fn an_unknown_flag_fails_closed_without_emitting_an_observation() { let (code, observation, stderr) = doctor(&["--not-a-flag"]); diff --git a/orchestration/integrations.json b/orchestration/integrations.json index fa547e2..7109d2b 100644 --- a/orchestration/integrations.json +++ b/orchestration/integrations.json @@ -286,7 +286,7 @@ "version": "1.0.0", "toolchainDigests": [ "21fc5754b9803f9fdee3d0a3eebe7db3d5f2bae1d93cd4218590522c9d3b9f19", - "04894e3ec1a87127491f5584fa3d066b29b1f1506ec32b365e9fd1c2f95ca081", + "c50788e1e790e496056de6a4ed208c8f5903345f15aef2ab783c9780d648dc34", "7db9e80857f3c65aada25041986cb5cb3b50b95fa69de794d55ad38fd4a8d8d4", "af67633e245665cdccef1b8dac1f672f3aca7af188449cde6e5fc518917c51be", "c9842468b904868391ff0fd2bb5d9627f4d61ab5f39fa01513dc409d0d2b484d",