diff --git a/.github/workflows/blastradius.yml b/.github/workflows/blastradius.yml index 739c92f..c114098 100644 --- a/.github/workflows/blastradius.yml +++ b/.github/workflows/blastradius.yml @@ -106,7 +106,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: realdiff-findings - path: ${{ env.REALDIFF_FINDINGS }} + path: | + ${{ env.REALDIFF_FINDINGS }} + ${{ env.REALDIFF_WORK }}/readiness.json if-no-files-found: error - name: Delete traces and worktrees diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d169c22..acc3512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,22 @@ env: DOTNET_CLI_TELEMETRY_OPTOUT: '1' jobs: + readiness: + name: Readiness contracts (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-2022, ubuntu-24.04] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + - name: Verify readiness policy and safe diagnostics + shell: pwsh + run: pwsh -NoProfile -File tools/verify-readiness.ps1 + rust-engine: name: Rust engine runs-on: windows-2022 @@ -249,8 +265,12 @@ jobs: run: pwsh -NoProfile -File tools/verify-demo-fixtures.ps1 package-proof: - name: Installed binary package proof - runs-on: windows-2022 + name: Installed binary package proof (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-2022, ubuntu-24.04] + runs-on: ${{ matrix.os }} timeout-minutes: 45 steps: - uses: actions/checkout@v4 @@ -306,5 +326,5 @@ jobs: - name: Upload package uses: actions/upload-artifact@v4 with: - name: realdiff-tool + name: realdiff-tool-${{ matrix.os }} path: artifacts/packages/*.nupkg diff --git a/README.md b/README.md index f636024..b0ea5a3 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,95 @@ RealDiff finds **runtime behavior changes that ordinary source review misses** b ## How it works 1. Check out both branches of the pull request. -2. Build each branch with runtime instrumentation woven in. +2. Check prerequisites for each revision, then build with runtime instrumentation. 3. Run the same test suite on both, recording each observed method call's arguments and return value. 4. Diff the two execution traces instead of inferring behavior from the source diff. This is dynamic behavior comparison, not mutation testing, static analysis, or coverage. RealDiff mutates nothing, modifies no test, and does not analyze source to predict behavior. It observes only code that the existing tests execute; source information is used to build, instrument, and map those observations back to the pull request. +## Readiness before analysis + +Use `doctor` to inspect the current checkout before attempting a full analysis: + +```powershell +realdiff doctor C:\src\my-service +realdiff doctor C:\src\my-service --json --out C:\temp\readiness.json +realdiff doctor C:\src\my-service --probe +``` + +```text +Repository -> selected tools + tracer assets + compatibility evidence + | + +-----------------+----------------+ + | | + ready failed / unknown + | | + continue analysis stop with remediation +``` + +The same readiness service covers .NET, Java, Node/TypeScript, Go, Rust, and +Python. It checks only the selected language, not every installed compiler. +Normal analysis checks base and PR separately before build commands and baseline +trace-cache reuse; `warm` checks its target. The combined report is retained as +`/readiness.json`, even when analysis is blocked. + +**Required unknown compatibility blocks analysis.** Missing tools, incomplete +tracer packages, incompatible versions, and unresolved toolchain selection must +be addressed first. Fast inspection does not run project builds, tests, setup +scripts or repository wrappers, and does not install toolchains. Opaque custom +build/test shell commands are not a verifiable toolchain selection and are +reported as unknown; this is an intentional change from attempting them blindly. + +`--probe` explicitly authorizes execution of a bundled instrumentation fixture +with installed tools in a temporary directory. Compiled languages compile and +run a small fixture; Node/Python exercise runtime attachment. A successful probe +requires actual trace and manifest evidence, not just exit code zero. It does +not build the user's project, provision tools, or fetch missing dependencies. +Prepare the required local package caches separately if an offline probe reports +missing dependencies. Temporary directories are **not a security sandbox**. + +The initial fixture routes are xUnit, installed Maven/Gradle with JUnit, Node's +`node --test` or direct Node scripts (CommonJS/ESM, with a locally installed +TypeScript compiler when selected), `go test`, `cargo test`, and pytest/unittest. Repository Java wrappers, +opaque custom commands, and unsupported Node runners remain blocked rather than +being qualified by an unrelated fixture. Dynamic project metadata can also leave +a required check unresolved. + +The direct-Node fixture exercises the production test-root hook, not arbitrary +framework integration. A successful fixture never substitutes for valid +correlated traces from the project's actual test run. + +On Unix, NuGet extraction can discard native payload executable permissions. +If doctor reports a non-executable engine or rewriter, restore its executable +permission as an installation step; fast inspection intentionally does not +change file permissions. + +To authorize those fixture probes for the actual analysis revisions: + +```powershell +realdiff C:\src\my-service --base origin/main --pr HEAD --readiness-probe +``` + +A previous `doctor` result is not reused as permission for a different checkout. +Toolchain identity and execution context are part of baseline cache keys, so +compiler/runtime changes invalidate old baseline recordings. Docker compiler +versions are distribution defaults, not proof that every other version is +unsupported. The Go and Rust rewriters still have parser and injected-runtime +compatibility boundaries; doctor does not make them universally compiler-independent. + +Reports use `realdiff.readiness/1` for one checkout and +`realdiff.readiness-set/1` for an analysis run. Check states are `passed`, +`failed`, `unknown`, and `skipped`; overall status is `ready`, `blocked`, or +`error`. JSON output is written alone to stdout; diagnostics go to stderr. +Standalone doctor exits 0 when prerequisites pass, 3 when blocked, and 4 on an +internal/reporting failure. **Exit 0 is not a clean PR verdict.** A fixture proves +only its exercised capability; normal instrumented builds and trace validation +remain necessary for real project code. + +The GitHub action exposes `readiness-probe` (default `false`) and a `readiness` +artifact-path output. MCP and the supplied CI workflows retain readiness +diagnostics when no behavioral findings can be produced. + ## Worked example Suppose a pull request tries to remove the allocation made by `OrderBy`. In this diff, lines beginning with `-` are the stable base implementation, lines beginning with `+` are the proposed in-place sort, and the highlighted behavioral change is the new `ordered.Sort(...)` call: @@ -355,13 +438,13 @@ baseline: Configuration overrides inference field by field; detection fills fields left unset. `workdir` must remain inside the repository. `test_projects` selects .NET test projects by repository-relative glob, while `source_roots` supplies repository-relative Java source directories. Include/exclude values augment tracing scope, redaction values augment the corresponding environment rules, and the nested baseline uses the same schema as `.realdiff/baseline.yml`. -The effective build and test commands run unchanged for both base and PR revisions. Custom tests do not replace instrumentation: .NET receives the woven/injected environment, Java receives the javaagent through `JAVA_TOOL_OPTIONS`, Node receives the loader/hooks through `NODE_OPTIONS`, and Go/Rust tests execute in their rewritten caches. Go `exclude_namespaces` entries may name exact repository-relative `.go` files; excluded functions remain executable through passthrough companions but are recorded as `ExcludedByScope` and emit no events. A command that exits successfully but produces zero trace events is refused with exit `3` and reports the command and trace/manifest counts. +Readiness currently blocks opaque custom build/test commands because their toolchain selection cannot be verified. For supported inferred commands, .NET receives the woven/injected environment, Java receives the javaagent, Node receives the loader/hooks through `NODE_OPTIONS`, and Go/Rust tests execute in their rewritten caches. Go `exclude_namespaces` entries may name exact repository-relative `.go` files; excluded functions remain executable through passthrough companions but are recorded as `ExcludedByScope` and emit no events. A command that exits successfully but produces zero trace events is refused with exit `3` and reports the command and trace/manifest counts. Automatic detection recognizes conventional root or unambiguous nested `.sln`/`.csproj`, Java `pom.xml`/`build.gradle`/`build.gradle.kts`, Node `package.json`, Go `go.mod`, Cargo `Cargo.toml`, and Python `pyproject.toml`/`setup.py`/`requirements.txt` entry points. Java execution prefers `mvnw`/`gradlew` and falls back to Maven/Gradle on `PATH`; Node execution selects npm, pnpm, Yarn, or Bun from its single lockfile and refuses missing or ambiguous lockfiles. Mixed-language repositories, monorepos, and multiple entry points are refused rather than guessed; set `language` and `workdir` (plus both commands when the language normally has a build step and no conventional entry point exists) to resolve them. ### Base trace cache -RealDiff caches the three validated noise-baseline traces when `--cache-dir` is supplied. Persistence is opt-in. The key contains the target SHA, language, a content fingerprint of the installed tracer, and the effective scope/redaction configuration. A tracer, scope, or redaction change therefore cannot reuse stale evidence. The storage boundary is pluggable; this release includes the local-directory backend, which can be placed on a CI-native or S3-compatible mounted cache. Entries expire after one day by default; use `--cache-retention` to state a different window. +RealDiff caches the three validated noise-baseline traces when `--cache-dir` is supplied. Persistence is opt-in. The key contains the target SHA, language, a content fingerprint of the installed tracer, effective scope/redaction configuration, and verified execution context (compiler/runtime/runner identities, platform, commands and selected settings). Changes to these inputs force a miss; legacy entries are not reused. The storage boundary is pluggable; this release includes the local-directory backend, which can be placed on a CI-native or S3-compatible mounted cache. Entries expire after one day by default; use `--cache-retention` to state a different window. On a hit, PR analysis restores the three baseline samples and performs only the PR instrumented run. A missing, malformed, or unavailable cache entry is reported as a miss and falls back to the existing four-run path. The console and `findings.json.baseTraceCache` report `hit`, `miss`, or `disabled`, the cache key/backend, and measured baseline wall-clock time saved. diff --git a/action.yml b/action.yml index ad2c2a3..44a19d3 100644 --- a/action.yml +++ b/action.yml @@ -37,6 +37,10 @@ inputs: description: Include lower-confidence and nondeterministic findings in comments. required: false default: 'false' + readiness-probe: + description: Explicitly run bundled offline instrumentation probes before analysis. + required: false + default: 'false' outputs: analysis-exit: description: RealDiff analysis exit code before posting policy. @@ -46,6 +50,8 @@ outputs: description: Findings artifact status. verdict: description: Findings artifact verdict. + readiness: + description: Path to the readiness report, including when analysis is blocked. runs: using: docker image: docker://ghcr.io/issacnitin/realdiff:main @@ -59,3 +65,4 @@ runs: - ${{ inputs.gate }} - ${{ inputs.post }} - ${{ inputs.strict }} + - ${{ inputs.readiness-probe }} diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 761d907..bc9733f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -46,15 +46,20 @@ jobs: realdiff "${args[@]}" analysis_exit=$? set -e + if [[ -f '$(behaviorDiffWork)/readiness.json' ]]; then + cp '$(behaviorDiffWork)/readiness.json' '$(Build.ArtifactStagingDirectory)/realdiff/readiness.json' + fi echo "##vso[task.setvariable variable=behaviorDiffAnalysisExit]$analysis_exit" if [[ ! -f '$(behaviorDiffFindings)' ]]; then echo "##vso[task.logissue type=error]RealDiff exited $analysis_exit without findings.json." + if (( analysis_exit == 0 )); then exit 4; fi exit "$analysis_exit" fi if (( analysis_exit != 0 && analysis_exit != 1 )); then - echo "##vso[task.logissue type=warning]RealDiff produced a non-verdict (exit $analysis_exit)." + echo "##vso[task.logissue type=error]RealDiff could not analyze (exit $analysis_exit); inspect readiness.json." + exit "$analysis_exit" fi displayName: Analyze runtime behavior diff --git a/docker/action-entrypoint.sh b/docker/action-entrypoint.sh index f0dc996..343d882 100644 --- a/docker/action-entrypoint.sh +++ b/docker/action-entrypoint.sh @@ -9,6 +9,7 @@ cache_retention="${5:-1d}" gate="${6:-warn-only}" post="${7:-true}" strict="${8:-false}" +readiness_probe="${9:-false}" mkdir -p "$(dirname "$findings")" "$cache_dir" @@ -17,6 +18,9 @@ analysis_args=("$repo" --ci=github --work "$work" --findings "$findings" if [[ "${strict,,}" == "true" ]]; then analysis_args+=(--strict) fi +if [[ "${readiness_probe,,}" == "true" ]]; then + analysis_args+=(--readiness-probe) +fi set +e realdiff "${analysis_args[@]}" @@ -36,11 +40,19 @@ if [[ -n "${GITHUB_OUTPUT:-}" ]]; then echo "findings=$findings" echo "status=$status" echo "verdict=$verdict" + echo "readiness=$work/readiness.json" } >> "$GITHUB_OUTPUT" fi if [[ ! -f "$findings" ]]; then echo "RealDiff exited $analysis_exit without writing $findings" >&2 + echo "Readiness diagnostics: $work/readiness.json" >&2 + if (( analysis_exit == 0 )); then exit 4; fi + exit "$analysis_exit" +fi + +if (( analysis_exit != 0 && analysis_exit != 1 )); then + echo "Analysis did not complete; readiness diagnostics: $work/readiness.json" >&2 exit "$analysis_exit" fi diff --git a/src/RealDiff.Cli/AssemblyInfo.cs b/src/RealDiff.Cli/AssemblyInfo.cs index cdd36d1..77ebd50 100644 --- a/src/RealDiff.Cli/AssemblyInfo.cs +++ b/src/RealDiff.Cli/AssemblyInfo.cs @@ -4,3 +4,4 @@ [assembly: InternalsVisibleTo("RealDiff.AnthropicLive")] [assembly: InternalsVisibleTo("RealDiff.CommentPreview")] [assembly: InternalsVisibleTo("RealDiff.CrossLanguageConsumerProof")] +[assembly: InternalsVisibleTo("RealDiff.ReadinessProof")] diff --git a/src/RealDiff.Cli/CrossLanguageExecution.cs b/src/RealDiff.Cli/CrossLanguageExecution.cs index a6ca04e..2ccd364 100644 --- a/src/RealDiff.Cli/CrossLanguageExecution.cs +++ b/src/RealDiff.Cli/CrossLanguageExecution.cs @@ -120,7 +120,8 @@ private void WarmJava(LanguageDetection detection, string baseTree, string targe targetSha, "java", TracerFingerprint.ForFile(agent), - Pipeline.ScopeConfig(scope)); + Pipeline.ScopeConfig(scope), + ReadinessExecution.Fingerprint(detection.WorkDirectory)); if (_cache.TryRestore(key, out _)) { return; @@ -152,7 +153,8 @@ private void WarmNode(LanguageDetection detection, string baseTree, string targe targetSha, "node", TracerFingerprint.ForDirectory(tracer), - Pipeline.ScopeConfig(scope)); + Pipeline.ScopeConfig(scope), + ReadinessExecution.Fingerprint(detection.WorkDirectory)); if (_cache.TryRestore(key, out _)) { return; @@ -204,7 +206,8 @@ private CrossLanguageRunSet RunJava( targetSha, "java", TracerFingerprint.ForFile(agent), - Pipeline.ScopeConfig(scope)); + Pipeline.ScopeConfig(scope), + ReadinessExecution.Fingerprint(baseDetection.WorkDirectory)); bool cacheHit = _cache.TryRestore(key, out TraceCacheEntry? cacheEntry); Console.WriteLine(); @@ -276,7 +279,8 @@ private CrossLanguageRunSet RunNode( targetSha, "node", TracerFingerprint.ForDirectory(tracer), - Pipeline.ScopeConfig(scope)); + Pipeline.ScopeConfig(scope), + ReadinessExecution.Fingerprint(baseDetection.WorkDirectory)); bool cacheHit = _cache.TryRestore(key, out TraceCacheEntry? cacheEntry); Console.WriteLine(); @@ -317,7 +321,7 @@ private void WarmGo(LanguageDetection detection, string baseTree, string targetS RunConfiguredBuild("base", detection); string rewriter = ResolveGoRewriter(); var key = new TraceCacheKey(targetSha, "go", TracerFingerprint.ForFile(rewriter), Pipeline.ScopeConfig( - string.Join(";", detection.ExcludeNamespaces))); + string.Join(";", detection.ExcludeNamespaces)), ReadinessExecution.Fingerprint(detection.WorkDirectory)); if (_cache.TryRestore(key, out _)) { return; @@ -345,7 +349,7 @@ private CrossLanguageRunSet RunGo( string rewriter = ResolveGoRewriter(); Console.WriteLine(" go rewriter: " + rewriter); var key = new TraceCacheKey(targetSha, "go", TracerFingerprint.ForFile(rewriter), Pipeline.ScopeConfig( - string.Join(";", baseDetection.ExcludeNamespaces))); + string.Join(";", baseDetection.ExcludeNamespaces)), ReadinessExecution.Fingerprint(baseDetection.WorkDirectory)); bool cacheHit = _cache.TryRestore(key, out TraceCacheEntry? cacheEntry); string base1; string base2; @@ -393,6 +397,7 @@ private string RunGoTests(string label, LanguageDetection detection, string cach throw new CliException("Go source rewriting failed." + Environment.NewLine + Shell.Tail(rewrite.Output, 25), ExitCodes.RepoDoesNotBuild); } + ReadinessExecution.BindCopy(detection.WorkDirectory, rewritten); var environment = new Dictionary { ["REALDIFF_TRACE"] = Path.Combine(directory, "run.ndjson"), @@ -465,7 +470,7 @@ private CrossLanguageRunSet RunPython( } Pipeline.AssertTestIdsPresent(base1); var prStopwatch = Stopwatch.StartNew(); - string pr = RunPythonTests("pr_run", prDetection, python, tracer); + string pr = RunPythonTests("pr_run", prDetection, ResolvePythonRuntime(prDetection.WorkDirectory), tracer); prStopwatch.Stop(); _timings.InstrumentedRunMilliseconds += prStopwatch.ElapsedMilliseconds; return new CrossLanguageRunSet { Base1 = base1, Base2 = base2, Base3 = base3, Pr = pr, BaseRoot = baseRoot }; @@ -524,14 +529,16 @@ private static TraceCacheKey PythonCacheKey( path => !path.Replace('\\', '/').Contains("/__pycache__/", StringComparison.Ordinal) && Path.GetExtension(path) == ".py"); string scope = string.Join(";", detection.IncludeNamespaces) + "\npython=" + python.Version; - return new TraceCacheKey(targetSha, "python", fingerprint, Pipeline.ScopeConfig(scope)); + return new TraceCacheKey(targetSha, "python", fingerprint, Pipeline.ScopeConfig(scope), + ReadinessExecution.Fingerprint(detection.WorkDirectory)); } private void WarmRust(LanguageDetection detection, string baseTree, string targetSha) { if (detection.HasCustomBuild) RunConfiguredBuild("base", detection); string tracer = ResolveRustTracer(); - var key = new TraceCacheKey(targetSha, "rust", TracerFingerprint.ForFile(tracer), Pipeline.ScopeConfig(string.Empty)); + var key = new TraceCacheKey(targetSha, "rust", TracerFingerprint.ForFile(tracer), Pipeline.ScopeConfig(string.Empty), + ReadinessExecution.Fingerprint(detection.WorkDirectory)); if (_cache.TryRestore(key, out _)) { return; @@ -557,7 +564,8 @@ private CrossLanguageRunSet RunRust( if (prDetection.HasCustomBuild) RunConfiguredBuild("pr", prDetection); string tracer = ResolveRustTracer(); Console.WriteLine(" rust tracer: " + tracer); - var key = new TraceCacheKey(targetSha, "rust", TracerFingerprint.ForFile(tracer), Pipeline.ScopeConfig(string.Empty)); + var key = new TraceCacheKey(targetSha, "rust", TracerFingerprint.ForFile(tracer), Pipeline.ScopeConfig(string.Empty), + ReadinessExecution.Fingerprint(baseDetection.WorkDirectory)); bool cacheHit = _cache.TryRestore(key, out TraceCacheEntry? cacheEntry); string base1; string base2; @@ -601,6 +609,7 @@ private string RunRustTests(string label, LanguageDetection detection, string ca } using JsonDocument report = JsonDocument.Parse(rewrite.Output.Trim()); string rewritten = report.RootElement.GetProperty("output").GetString()!; + ReadinessExecution.BindCopy(detection.WorkDirectory, rewritten); string trace = Path.Combine(directory, "run.rust.ndjson"); var environment = new Dictionary { ["REALDIFF_RUST_EXIT_TRACE"] = trace }; ProcessResult test; @@ -903,7 +912,7 @@ private static IReadOnlyList DeriveNodeScopes(string baseDirectory, stri return scopes; } - private static string ResolveJavaAgent() + internal static string ResolveJavaAgent() { string? configured = Environment.GetEnvironmentVariable("REALDIFF_JAVA_AGENT"); if (!string.IsNullOrWhiteSpace(configured)) @@ -946,7 +955,7 @@ private static string ResolveJavaAgent() + packaged + ", or build src/RealDiff.Java.Agent first."); } - private static string ResolveNodeTracer() + internal static string ResolveNodeTracer() { string? configured = Environment.GetEnvironmentVariable("REALDIFF_NODE_TRACER"); if (!string.IsNullOrWhiteSpace(configured)) @@ -975,6 +984,9 @@ private static string ResolveNodeTracer() private static PythonRuntimeInfo ResolvePythonRuntime(string workingDirectory) { + ReadinessTool? verified = ReadinessExecution.Find(workingDirectory)?.Tools + .FirstOrDefault(tool => tool.Name == "python"); + if (verified != null) return new PythonRuntimeInfo(verified.Executable, verified.Version); string? configured = Environment.GetEnvironmentVariable("REALDIFF_PYTHON"); var candidates = new List(); if (!string.IsNullOrWhiteSpace(configured)) candidates.Add(configured); @@ -1026,7 +1038,7 @@ private static PythonRuntimeInfo ResolvePythonRuntime(string workingDirectory) ExitCodes.RunInvalid); } - private static string ResolvePythonTracer() + internal static string ResolvePythonTracer() { string? configured = Environment.GetEnvironmentVariable("REALDIFF_PYTHON_TRACER"); if (!string.IsNullOrWhiteSpace(configured)) return ValidatePythonTracer(Path.GetFullPath(configured)); @@ -1062,11 +1074,13 @@ private static string ValidatePythonTracer(string directory) return directory; } - private static string ResolveRustTracer() + internal static string ResolveRustTracer() { string? configured = Environment.GetEnvironmentVariable("REALDIFF_RUST_TRACER"); - if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) + if (!string.IsNullOrWhiteSpace(configured)) { + if (!File.Exists(configured)) + throw new CliException("REALDIFF_RUST_TRACER does not name an existing executable.", ExitCodes.RunInvalid); return Path.GetFullPath(configured); } string fileName = OperatingSystem.IsWindows() ? "realdiff-rust-rewrite.exe" : "realdiff-rust-rewrite"; @@ -1088,11 +1102,13 @@ private static string ResolveRustTracer() throw new CliException("RealDiff Rust tracer was not found. Set REALDIFF_RUST_TRACER or build src/RealDiff.Rust.Tracer."); } - private static string ResolveGoRewriter() + internal static string ResolveGoRewriter() { string? configured = Environment.GetEnvironmentVariable("REALDIFF_GO_REWRITER"); - if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) + if (!string.IsNullOrWhiteSpace(configured)) { + if (!File.Exists(configured)) + throw new CliException("REALDIFF_GO_REWRITER does not name an existing executable.", ExitCodes.RunInvalid); return Path.GetFullPath(configured); } string fileName = OperatingSystem.IsWindows() ? "realdiff-go-rewrite.exe" : "realdiff-go-rewrite"; @@ -1215,7 +1231,7 @@ private static ProcessResult RunScriptCommand( return Shell.Run("cmd.exe", commandArguments, workingDirectory, environment); } - private static TraceSummary ValidateTrace(string directory, string label, string output) + internal static TraceSummary ValidateTrace(string directory, string label, string output) { string[] traces = Directory.GetFiles(directory, "run.*.ndjson") .Where(path => !path.Contains(".manifest.", StringComparison.Ordinal)) @@ -1299,7 +1315,7 @@ internal ProcessResult Run( Shell.Run(_fileName, _prefix.Concat(arguments), workingDirectory, environment); } - private sealed class TraceSummary + internal sealed class TraceSummary { internal TraceSummary(int files, long bytes, int records) { diff --git a/src/RealDiff.Cli/EngineDispatch.cs b/src/RealDiff.Cli/EngineDispatch.cs index e320863..88a5bfb 100644 --- a/src/RealDiff.Cli/EngineDispatch.cs +++ b/src/RealDiff.Cli/EngineDispatch.cs @@ -346,7 +346,7 @@ private static void RunRustArtifact(IEnumerable arguments, string artifa return index < 0 ? null : output.Substring(index + prefix.Length).Trim(); } - private static string ResolveRustEngine() + internal static string ResolveRustEngine(bool repairPermissions = true) { string? configured = Environment.GetEnvironmentVariable("REALDIFF_RUST_ENGINE"); if (!string.IsNullOrWhiteSpace(configured)) @@ -357,7 +357,7 @@ private static string ResolveRustEngine() throw new CliException("REALDIFF_RUST_ENGINE does not name an existing executable: " + fullPath); } - return EnsureExecutable(fullPath); + return repairPermissions ? EnsureExecutable(fullPath) : fullPath; } string fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) @@ -367,7 +367,7 @@ private static string ResolveRustEngine() string packaged = Path.Combine(AppContext.BaseDirectory, "engines", "rust", rid, fileName); if (File.Exists(packaged)) { - return EnsureExecutable(packaged); + return repairPermissions ? EnsureExecutable(packaged) : packaged; } foreach (string root in CandidateSourceRoots()) @@ -375,7 +375,7 @@ private static string ResolveRustEngine() string source = Path.Combine(root, "src", "RealDiff.Engine.Rust", "target", "release", fileName); if (File.Exists(source)) { - return EnsureExecutable(source); + return repairPermissions ? EnsureExecutable(source) : source; } } diff --git a/src/RealDiff.Cli/LanguageDetection.cs b/src/RealDiff.Cli/LanguageDetection.cs index 2dc5e7e..9be8304 100644 --- a/src/RealDiff.Cli/LanguageDetection.cs +++ b/src/RealDiff.Cli/LanguageDetection.cs @@ -55,9 +55,9 @@ internal static class LanguageDetector @"^\s*package\s+([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\s*;", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.CultureInvariant); - internal static LanguageDetection Detect(string repository) + internal static LanguageDetection Detect(string repository, bool useLauncherConfig = true) { - LoadedRepositoryConfig config = RepositoryConfigLoader.Load(repository); + LoadedRepositoryConfig config = RepositoryConfigLoader.Load(repository, useLauncherConfig); string root = config.RepositoryRoot; string workDirectory = RepositoryConfigLoader.ResolveWorkDirectory(config); List candidates = RootCandidates(workDirectory); diff --git a/src/RealDiff.Cli/Program.cs b/src/RealDiff.Cli/Program.cs index 41d4ae3..0805445 100644 --- a/src/RealDiff.Cli/Program.cs +++ b/src/RealDiff.Cli/Program.cs @@ -12,6 +12,9 @@ internal static class Program { internal static int Main(string[] args) { + if (args.Length > 0 && args[0] == "doctor") + return DoctorCommand.Run(args.Skip(1).ToArray()); + if (args.Length == 2 && (args[0] == "detect" || args[0] == "detect-language")) { try @@ -73,6 +76,7 @@ internal static int Main(string[] args) bool keep = false; bool noBaseline = false; bool strict = false; + bool readinessProbe = false; var positional = new List(); for (int i = firstOption; i < args.Length; i++) @@ -93,6 +97,7 @@ internal static int Main(string[] args) case "--keep-traces": traceRetention = ParseDuration(Next(args, ref i)); break; case "--keep": keep = true; break; case "--strict": strict = true; break; + case "--readiness-probe": readinessProbe = true; break; case "--engine": Console.Error.WriteLine("--engine was removed; RealDiff uses the Rust engine."); return ExitCodes.BuildOrTestFailure; @@ -141,6 +146,9 @@ internal static int Main(string[] args) try { + if (File.Exists(findingsPath)) File.Delete(findingsPath); + string previousReadiness = Path.Combine(workDirectory, "readiness.json"); + if (File.Exists(previousReadiness)) File.Delete(previousReadiness); string resolvedRepository = RefResolution.ResolveRepository(repo, ciProvider); LoadedRepositoryConfig repositoryConfig = RepositoryConfigLoader.Load(resolvedRepository); RepositoryConfigLoader.ApplyEnvironment(repositoryConfig); @@ -177,15 +185,17 @@ internal static int Main(string[] args) cacheRetention, traceRetention, warmOnly, - strict); + strict, + readinessProbe); return pipeline.Run(); } catch (CliException ex) { Console.Error.WriteLine(); Console.Error.WriteLine("FAILED: " + ex.Message); + PersistEarlyReadinessFailure(workDirectory, ex.Message); ResolvedRefs? refs = pipeline?.ResolvedRefs; - EngineDispatch.WriteInvalidFindings( + WriteFailureFindings( findingsPath, ex.ExitCode == ExitCodes.RunInvalid ? "refused" : "failed", ex.ExitCode, @@ -200,8 +210,9 @@ internal static int Main(string[] args) string reason = ex.GetType().Name + ": " + ex.Message; Console.Error.WriteLine(); Console.Error.WriteLine("REFUSED: " + reason); + PersistEarlyReadinessFailure(workDirectory, reason); ResolvedRefs? refs = pipeline?.ResolvedRefs; - EngineDispatch.WriteInvalidFindings( + WriteFailureFindings( findingsPath, "refused", ExitCodes.RunInvalid, @@ -216,8 +227,9 @@ internal static int Main(string[] args) string reason = ex.GetType().Name + ": " + ex.Message; Console.Error.WriteLine(); Console.Error.WriteLine("FAILED: " + reason); + PersistEarlyReadinessFailure(workDirectory, reason); ResolvedRefs? refs = pipeline?.ResolvedRefs; - EngineDispatch.WriteInvalidFindings( + WriteFailureFindings( findingsPath, "failed", ExitCodes.BuildOrTestFailure, @@ -229,6 +241,41 @@ internal static int Main(string[] args) } } + private static void PersistEarlyReadinessFailure(string work, string reason) + { + string path = Path.Combine(work, "readiness.json"); + if (File.Exists(path)) return; + var report = new ReadinessReport { Revision = "unresolved" }; + report.Add("analysis.setup", "failed", reason, "Correct the setup error and rerun analysis."); + try + { + ReadinessService.Write(path, new + { + schema = "realdiff.readiness-set/1", + status = report.Status, + revisions = new[] { report }, + }); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine("Cannot persist readiness.json (" + ex.GetType().Name + ")."); + } + } + + private static void WriteFailureFindings(string path, string status, int exitCode, string reason, + string? baseSha, string? prSha, string? mergeBaseSha) + { + try + { + EngineDispatch.WriteInvalidFindings(path, status, exitCode, reason, baseSha, prSha, mergeBaseSha); + } + catch (Exception ex) when (ex is CliException or System.ComponentModel.Win32Exception or IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine("Could not write findings because the reporting engine is unavailable (" + + ex.GetType().Name + "). The analysis failed; consult readiness.json and the original diagnostic."); + } + } + private static string Next(string[] args, ref int i) { if (i + 1 >= args.Length) @@ -261,6 +308,8 @@ private static void Usage() Console.WriteLine("usage: realdiff --base --pr [--work ] [--findings ] [--baseline |--no-baseline] [--strict] [--cache-dir ] [--cache-retention <12h|7d>] [--keep-traces <12h|7d>] [--keep]"); Console.WriteLine(" realdiff warm --target --cache-dir [--cache-retention <12h|7d>] [--work ] [--keep]"); Console.WriteLine(" realdiff detect "); + Console.WriteLine(" realdiff doctor [--json] [--out ] [--probe]"); + Console.WriteLine(" analysis/warm: --readiness-probe explicitly runs a bundled instrumentation fixture"); Console.WriteLine(" realdiff [] --ci=azuredevops [--work ] [--findings ] [--keep]"); Console.WriteLine(" realdiff [] --ci=github [--work ] [--findings ] [--keep]"); Console.WriteLine(" realdiff post --provider= --findings [--gate warn-only|fail-on-findings]"); @@ -289,6 +338,7 @@ internal sealed class Pipeline private readonly bool _warmOnly; private readonly TimeSpan? _traceRetention; private readonly bool _strict; + private readonly bool _readinessProbe; private readonly PipelineTimings _timings = new PipelineTimings(); internal ResolvedRefs? ResolvedRefs { get; private set; } @@ -306,7 +356,8 @@ internal Pipeline( TimeSpan cacheRetention, TimeSpan? traceRetention, bool warmOnly, - bool strict) + bool strict, + bool readinessProbe = false) { _repo = repo; _baseRef = baseRef; @@ -323,10 +374,12 @@ internal Pipeline( _traceRetention = traceRetention; _warmOnly = warmOnly; _strict = strict; + _readinessProbe = readinessProbe; } internal int Run() { + ReadinessExecution.Clear(); SweepExpiredTraces(Path.GetDirectoryName(_work)); Directory.CreateDirectory(_work); Console.WriteLine("realdiff"); @@ -361,8 +414,34 @@ internal int Run() Shell.Git(_repo, "worktree", "add", "--detach", prTree, refs.PrSha); } - LanguageDetection baseDetection = LanguageDetector.Detect(baseTree); - LanguageDetection prDetection = _warmOnly ? baseDetection : LanguageDetector.Detect(prTree); + Console.WriteLine(); + Console.WriteLine("=== readiness: checking actual revisions ==="); + ReadinessReport baseReadiness = ReadinessService.Check(baseTree, refs.BaseSha, _readinessProbe); + ReadinessReport? prReadiness = _warmOnly ? null : ReadinessService.Check(prTree, refs.PrSha, _readinessProbe); + var readinessReports = new[] { baseReadiness, prReadiness }.Where(report => report != null).Cast().ToArray(); + string readinessPath = Path.Combine(_work, "readiness.json"); + ReadinessService.Write(readinessPath, new + { + schema = "realdiff.readiness-set/1", + status = readinessReports.Any(report => report.Status == "error") ? "error" : + readinessReports.Any(report => report.Status == "blocked") ? "blocked" : "ready", + revisions = readinessReports, + }); + foreach (ReadinessReport report in readinessReports) ReadinessService.Print(report); + Console.WriteLine(" readiness artifact: " + readinessPath); + if (readinessReports.Any(report => report.Status != "ready")) + throw new CliException("Readiness blocked analysis. " + string.Join("; ", + readinessReports.Where(report => report.Status != "ready") + .Select(report => report.Revision + ": " + report.Summary)), ExitCodes.RunInvalid); + ReadinessExecution.Register(baseTree, baseReadiness); + if (prReadiness != null) ReadinessExecution.Register(prTree, prReadiness); + if (prReadiness != null && !baseReadiness.Tools.Select(tool => (tool.Name, tool.Executable, tool.Version)) + .OrderBy(tool => tool.Name, StringComparer.Ordinal) + .SequenceEqual(prReadiness.Tools.Select(tool => (tool.Name, tool.Executable, tool.Version)) + .OrderBy(tool => tool.Name, StringComparer.Ordinal))) + Console.WriteLine(" WARNING: base and PR use different toolchain contexts; differences may include toolchain effects."); + LanguageDetection baseDetection = baseReadiness.Detection!; + LanguageDetection prDetection = prReadiness?.Detection ?? baseDetection; if (!_warmOnly) { AssertLanguageSymmetry(baseDetection, prDetection); @@ -450,7 +529,8 @@ internal int Run() return name is "realdiff.dll" or "realdiff-weaver.dll" or "RealDiff.Contracts.dll" or "RealDiff.Tracer.dll" or "RealDiff.Tracer.Xunit.dll" or "Mono.Cecil.dll"; }); - var cacheKey = new TraceCacheKey(refs.BaseSha, "dotnet", tracerVersion, scopeConfig); + var cacheKey = new TraceCacheKey(refs.BaseSha, "dotnet", tracerVersion, scopeConfig, + ReadinessExecution.Fingerprint(baseDetection.WorkDirectory)); bool cacheHit = _cache.TryRestore(cacheKey, out TraceCacheEntry? cacheEntry); if (_warmOnly) { diff --git a/src/RealDiff.Cli/Readiness/DoctorCommand.cs b/src/RealDiff.Cli/Readiness/DoctorCommand.cs new file mode 100644 index 0000000..bbc97f0 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/DoctorCommand.cs @@ -0,0 +1,66 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace RealDiff.Cli +{ + internal static class DoctorCommand + { + internal static int Run(string[] args) + { + string? repository = null; + string? output = null; + bool json = Array.IndexOf(args, "--json") >= 0; + bool probe = false; + var report = new ReadinessReport(); + try + { + for (int index = 0; index < args.Length; index++) + { + switch (args[index]) + { + case "--json": break; + case "--probe": probe = true; break; + case "--out": + if (++index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal)) + throw new CliException("--out requires a file path.", ExitCodes.RunInvalid); + output = args[index]; + break; + case "--help": + case "-h": + Console.WriteLine("usage: realdiff doctor [--json] [--out ] [--probe]"); + return 0; + default: + if (args[index].StartsWith("-", StringComparison.Ordinal) || repository != null) + throw new CliException("Unknown doctor argument: " + args[index], ExitCodes.RunInvalid); + repository = args[index]; + break; + } + } + if (repository is null) throw new CliException("doctor requires a repository path.", ExitCodes.RunInvalid); + report = ReadinessService.Check(repository, probe: probe); + } + catch (CliException ex) + { + report.Add("doctor.arguments", "failed", ex.Message, "Use realdiff doctor --help."); + } + catch (ArgumentException) + { + report.Add("doctor.arguments", "failed", "A supplied path is invalid.", "Supply a valid repository/output path."); + } + try + { + if (output != null) ReadinessService.Write(output, report); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + report.InternalError = true; + report.Add("doctor.output", "failed", "Cannot persist the readiness report: " + ex.GetType().Name, + "Choose a writable output path."); + } + if (json) Console.WriteLine(JsonSerializer.Serialize(report, ReadinessReport.JsonOptions)); + else ReadinessService.Print(report); + return report.ExitCode; + } + } +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/dotnet/Probe.cs b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/Probe.cs new file mode 100644 index 0000000..610294e --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/Probe.cs @@ -0,0 +1,14 @@ +using Xunit; + +namespace ReadinessFixture +{ + public class ReadinessProbeTests + { + [Fact] + public void ReadinessProbe() + { + for (int i = 0; i < 2; i++) + Assert.Equal("probe-input-return", ProbeSubject.ProbeEcho("probe-input")); + } + } +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/dotnet/Probe.csproj b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/Probe.csproj new file mode 100644 index 0000000..795bcd9 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/Probe.csproj @@ -0,0 +1,20 @@ + + + net8.0 + true + false + false + portable + false + + + + + + + + $(ProbeKit)/RealDiff.Tracer.dll + $(ProbeKit)/RealDiff.Contracts.dll + + + diff --git a/src/RealDiff.Cli/Readiness/Fixtures/dotnet/subject/ProbeSubject.cs b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/subject/ProbeSubject.cs new file mode 100644 index 0000000..21ccdbf --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/subject/ProbeSubject.cs @@ -0,0 +1,7 @@ +namespace ReadinessFixture +{ + public static class ProbeSubject + { + public static string ProbeEcho(string value) => value + "-return"; + } +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/dotnet/subject/Subject.csproj b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/subject/Subject.csproj new file mode 100644 index 0000000..cf227ae --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/dotnet/subject/Subject.csproj @@ -0,0 +1,7 @@ + + + net8.0 + portable + false + + diff --git a/src/RealDiff.Cli/Readiness/Fixtures/go/go.mod b/src/RealDiff.Cli/Readiness/Fixtures/go/go.mod new file mode 100644 index 0000000..2f1a31a --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/go/go.mod @@ -0,0 +1,3 @@ +module example.com/readiness + +go 1.23 diff --git a/src/RealDiff.Cli/Readiness/Fixtures/go/probe.go b/src/RealDiff.Cli/Readiness/Fixtures/go/probe.go new file mode 100644 index 0000000..28f1675 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/go/probe.go @@ -0,0 +1,5 @@ +package readiness + +func ProbeEcho(value string) string { + return value + "-return" +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/go/probe_test.go b/src/RealDiff.Cli/Readiness/Fixtures/go/probe_test.go new file mode 100644 index 0000000..90308cb --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/go/probe_test.go @@ -0,0 +1,11 @@ +package readiness + +import "testing" + +func TestReadinessProbe(t *testing.T) { + for i := 0; i < 2; i++ { + if got := ProbeEcho("probe-input"); got != "probe-input-return" { + t.Fatalf("unexpected result: %s", got) + } + } +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/java/build.gradle b/src/RealDiff.Cli/Readiness/Fixtures/java/build.gradle new file mode 100644 index 0000000..244f5dc --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/java/build.gradle @@ -0,0 +1,15 @@ +plugins { id 'java' } +repositories { mavenCentral() } +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.4' +} +tasks.withType(JavaCompile).configureEach { + options.release = 11 + options.debug = true +} +test { + useJUnitPlatform() + jvmArgs '--add-opens=java.base/java.util=ALL-UNNAMED' + jvmArgs "-javaagent:${System.getenv('REALDIFF_PROBE_JAVA_AGENT')}" +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/java/pom.xml b/src/RealDiff.Cli/Readiness/Fixtures/java/pom.xml new file mode 100644 index 0000000..f23c116 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/java/pom.xml @@ -0,0 +1,23 @@ + + 4.0.0 + io.realdiff + readiness-probe + 0.0.0 + + 11 + UTF-8 + + + org.junit.jupiterjunit-jupiter5.11.4test + + + org.apache.maven.pluginsmaven-resources-plugin3.3.1 + org.apache.maven.pluginsmaven-compiler-plugin3.13.0 + + org.apache.maven.pluginsmaven-surefire-plugin3.5.2 + + --add-opens java.base/java.util=ALL-UNNAMED -javaagent:"${env.REALDIFF_PROBE_JAVA_AGENT}" + + + + diff --git a/src/RealDiff.Cli/Readiness/Fixtures/java/settings.gradle b/src/RealDiff.Cli/Readiness/Fixtures/java/settings.gradle new file mode 100644 index 0000000..58f0157 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/java/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'readiness-probe' diff --git a/src/RealDiff.Cli/Readiness/Fixtures/java/src/main/java/io/realdiff/probe/ProbeSubject.java b/src/RealDiff.Cli/Readiness/Fixtures/java/src/main/java/io/realdiff/probe/ProbeSubject.java new file mode 100644 index 0000000..efc83b4 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/java/src/main/java/io/realdiff/probe/ProbeSubject.java @@ -0,0 +1,7 @@ +package io.realdiff.probe; + +public class ProbeSubject { + public static String probeEcho(String value) { + return value + "-return"; + } +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/java/src/test/java/io/realdiff/probe/ReadinessProbeTest.java b/src/RealDiff.Cli/Readiness/Fixtures/java/src/test/java/io/realdiff/probe/ReadinessProbeTest.java new file mode 100644 index 0000000..8200393 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/java/src/test/java/io/realdiff/probe/ReadinessProbeTest.java @@ -0,0 +1,13 @@ +package io.realdiff.probe; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ReadinessProbeTest { + @Test + public void readinessProbe() { + for (int i = 0; i < 2; i++) { + assertEquals("probe-input-return", ProbeSubject.probeEcho("probe-input")); + } + } +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/package.json b/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/package.json new file mode 100644 index 0000000..5735372 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/package.json @@ -0,0 +1 @@ +{"name":"realdiff-readiness-probe","private":true,"scripts":{"test":"node --test probe.test.cjs"}} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/probe.js b/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/probe.js new file mode 100644 index 0000000..5f1a8c3 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/probe.js @@ -0,0 +1,5 @@ +'use strict'; +function probeEcho(value) { + return value + '-return'; +} +module.exports = { probeEcho }; diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/probe.test.cjs b/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/probe.test.cjs new file mode 100644 index 0000000..391ac60 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-cjs/probe.test.cjs @@ -0,0 +1,9 @@ +'use strict'; +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { createTestAdapter } = require(path.join(process.env.REALDIFF_NODE_ROOT, 'src', 'test-adapter.cjs')); +const test = createTestAdapter(require('node:test'), 'node:test'); +const { probeEcho } = require('./probe.js'); +test('readiness-probe', () => { + for (let i = 0; i < 2; i++) assert.equal(probeEcho('probe-input'), 'probe-input-return'); +}); diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-direct/probe.run.cjs b/src/RealDiff.Cli/Readiness/Fixtures/node-direct/probe.run.cjs new file mode 100644 index 0000000..f17c8e3 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-direct/probe.run.cjs @@ -0,0 +1,11 @@ +'use strict'; +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { runtime } = require(path.join(process.env.REALDIFF_NODE_ROOT, 'register.cjs')); +runtime.withTestRoot('readiness-probe', async () => { + const { probeEcho } = await import('./probe.js'); + for (let i = 0; i < 2; i++) assert.equal(probeEcho('probe-input'), 'probe-input-return'); +}).catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-esm/package.json b/src/RealDiff.Cli/Readiness/Fixtures/node-esm/package.json new file mode 100644 index 0000000..c92dd35 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-esm/package.json @@ -0,0 +1 @@ +{"name":"realdiff-readiness-probe","private":true,"type":"module","scripts":{"test":"node --test probe.test.cjs"}} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-esm/probe.js b/src/RealDiff.Cli/Readiness/Fixtures/node-esm/probe.js new file mode 100644 index 0000000..85c12a3 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-esm/probe.js @@ -0,0 +1,3 @@ +export function probeEcho(value) { + return value + '-return'; +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-esm/probe.test.cjs b/src/RealDiff.Cli/Readiness/Fixtures/node-esm/probe.test.cjs new file mode 100644 index 0000000..8a0af2b --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-esm/probe.test.cjs @@ -0,0 +1,9 @@ +'use strict'; +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { createTestAdapter } = require(path.join(process.env.REALDIFF_NODE_ROOT, 'src', 'test-adapter.cjs')); +const test = createTestAdapter(require('node:test'), 'node:test'); +test('readiness-probe', async () => { + const { probeEcho } = await import('./probe.js'); + for (let i = 0; i < 2; i++) assert.equal(probeEcho('probe-input'), 'probe-input-return'); +}); diff --git a/src/RealDiff.Cli/Readiness/Fixtures/node-ts/probe.ts b/src/RealDiff.Cli/Readiness/Fixtures/node-ts/probe.ts new file mode 100644 index 0000000..9a3ed04 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/node-ts/probe.ts @@ -0,0 +1,3 @@ +export function probeEcho(value: string): string { + return value + '-return'; +} diff --git a/src/RealDiff.Cli/Readiness/Fixtures/python/probe_subject.py b/src/RealDiff.Cli/Readiness/Fixtures/python/probe_subject.py new file mode 100644 index 0000000..a565085 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/python/probe_subject.py @@ -0,0 +1,2 @@ +def probe_echo(value): + return value + "-return" diff --git a/src/RealDiff.Cli/Readiness/Fixtures/python/test_probe.py b/src/RealDiff.Cli/Readiness/Fixtures/python/test_probe.py new file mode 100644 index 0000000..0e8322f --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/python/test_probe.py @@ -0,0 +1,9 @@ +import unittest + +from probe_subject import probe_echo + + +class ReadinessProbe(unittest.TestCase): + def test_readiness_probe(self): + for _ in range(2): + self.assertEqual(probe_echo("probe-input"), "probe-input-return") diff --git a/src/RealDiff.Cli/Readiness/Fixtures/rust/Cargo.toml b/src/RealDiff.Cli/Readiness/Fixtures/rust/Cargo.toml new file mode 100644 index 0000000..81ba2d0 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/rust/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "readiness_probe" +version = "0.0.0" +edition = "2021" + +[workspace] diff --git a/src/RealDiff.Cli/Readiness/Fixtures/rust/src/lib.rs b/src/RealDiff.Cli/Readiness/Fixtures/rust/src/lib.rs new file mode 100644 index 0000000..2d031b6 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/Fixtures/rust/src/lib.rs @@ -0,0 +1,13 @@ +pub fn probe_echo(value: &str) -> String { + format!("{}-return", value) +} + +#[cfg(test)] +mod tests { + #[test] + fn readiness_probe() { + for _ in 0..2 { + assert_eq!(super::probe_echo("probe-input"), "probe-input-return"); + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessBinary.cs b/src/RealDiff.Cli/Readiness/ReadinessBinary.cs new file mode 100644 index 0000000..da7863b --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessBinary.cs @@ -0,0 +1,51 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Runtime.InteropServices; + +namespace RealDiff.Cli +{ + internal static class ReadinessBinary + { + internal static bool MatchesHost(string path) + { + using FileStream stream = File.OpenRead(path); + Span header = stackalloc byte[64]; + if (stream.Length < header.Length) return false; + stream.ReadExactly(header); + Architecture? architecture = null; + if (OperatingSystem.IsWindows()) + { + if (header[0] != 'M' || header[1] != 'Z') return false; + int offset = BinaryPrimitives.ReadInt32LittleEndian(header.Slice(60, 4)); + if (offset < 0 || offset > stream.Length - 6) return false; + stream.Position = offset; + Span pe = stackalloc byte[6]; + stream.ReadExactly(pe); + if (pe[0] != 'P' || pe[1] != 'E' || pe[2] != 0 || pe[3] != 0) return false; + architecture = BinaryPrimitives.ReadUInt16LittleEndian(pe.Slice(4)) switch + { + 0x8664 => Architecture.X64, 0xaa64 => Architecture.Arm64, 0x14c => Architecture.X86, _ => null, + }; + } + else if (OperatingSystem.IsLinux()) + { + if (header[0] != 0x7f || header[1] != 'E' || header[2] != 'L' || header[3] != 'F' || header[5] != 1) + return false; + architecture = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(18, 2)) switch + { + 62 => Architecture.X64, 183 => Architecture.Arm64, 3 => Architecture.X86, _ => null, + }; + } + else if (OperatingSystem.IsMacOS()) + { + if (BinaryPrimitives.ReadUInt32LittleEndian(header) != 0xfeedfacf) return false; + architecture = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(4)) switch + { + 0x01000007 => Architecture.X64, 0x0100000c => Architecture.Arm64, _ => null, + }; + } + return architecture == RuntimeInformation.ProcessArchitecture; + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessExecution.cs b/src/RealDiff.Cli/Readiness/ReadinessExecution.cs new file mode 100644 index 0000000..a61b130 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessExecution.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace RealDiff.Cli +{ + // Selections are scoped to owned worktrees, not process-wide toolchain environment changes. + internal static class ReadinessExecution + { + private static readonly Dictionary Contexts = + new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + + internal static void Register(string root, ReadinessReport report) + { + if (report.Status != "ready") throw new CliException("Cannot execute a blocked readiness context.", ExitCodes.RunInvalid); + Contexts[Path.GetFullPath(root)] = report; + } + + internal static void BindCopy(string source, string destination) + { + ReadinessReport? report = Find(source); + if (report != null) Register(destination, report); + } + + internal static void Clear() => Contexts.Clear(); + + internal static ReadinessReport? Find(string directory) + { + string path = Path.GetFullPath(directory); + return Contexts.OrderByDescending(pair => pair.Key.Length) + .Where(pair => path.Equals(pair.Key, PathComparison) + || path.StartsWith(pair.Key.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, PathComparison)) + .Select(pair => pair.Value).FirstOrDefault(); + } + + private static StringComparison PathComparison => OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + internal static string Fingerprint(string directory) + { + ReadinessReport? report = Find(directory); + if (report is null) + throw new CliException("No verified execution context is available for the trace cache.", ExitCodes.RunInvalid); + var context = new + { + schema = "realdiff.execution-context/1", + report.Language, + report.Platform, + report.BuildCommand, + report.TestCommand, + tools = report.Tools.OrderBy(tool => tool.Name, StringComparer.Ordinal) + .Select(tool => new { tool.Name, tool.Executable, tool.Version, tool.Requirement }), + environment = report.Environment.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Where(pair => pair.Key is not "PATH"), + }; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(context)))).ToLowerInvariant(); + } + + internal static void Apply(ref string executable, string directory, ref IDictionary? environment) + { + ReadinessReport? report = Find(directory); + if (report is null) return; + var selected = new Dictionary(report.Environment, StringComparer.OrdinalIgnoreCase); + string[] toolDirectories = report.Tools.Select(tool => Path.GetDirectoryName(tool.Executable)) + .Where(path => !string.IsNullOrEmpty(path)).Cast().Distinct().ToArray(); + string existingPath = System.Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + selected["PATH"] = toolDirectories.Length == 0 ? existingPath : + string.Join(Path.PathSeparator, toolDirectories) + Path.PathSeparator + existingPath; + if (environment != null) + foreach (var pair in environment) selected[pair.Key] = pair.Value; + environment = selected; + if (!Path.IsPathRooted(executable)) + { + string name = Path.GetFileNameWithoutExtension(executable); + ReadinessTool? tool = report.Tools.FirstOrDefault(tool => tool.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (tool != null && Path.IsPathRooted(tool.Executable)) executable = tool.Executable; + } + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessInspection.Managed.cs b/src/RealDiff.Cli/Readiness/ReadinessInspection.Managed.cs new file mode 100644 index 0000000..06b0f03 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessInspection.Managed.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace RealDiff.Cli +{ + internal static partial class ReadinessInspection + { + private static XDocument? ReadXml(string path, ReadinessReport report) + { + string? text = ReadMetadata(path, report); + if (text == null) return null; + try + { + using var reader = XmlReader.Create(new StringReader(text), new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + }); + return XDocument.Load(reader); + } + catch (XmlException) + { + Unknown(report, "metadata.xml", Path.GetFileName(path) + " is not supported well-formed XML.", + "Correct the static XML metadata; external entities and DTDs are not evaluated."); + return null; + } + } + + private static void InspectDotNet(LanguageDetection detection, ReadinessReport report) + { + string? dotnet = FindExecutable("dotnet", report); + var safe = new Dictionary + { + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", + ["DOTNET_NOLOGO"] = "1", ["DOTNET_STARTUP_HOOKS"] = "", ["DOTNET_ADDITIONAL_DEPS"] = "", + ["MSBuildEnableWorkloadResolver"] = "false", ["CORECLR_ENABLE_PROFILING"] = "0", ["COR_ENABLE_PROFILING"] = "0", + }; + string? globalPath = Nearest(detection.WorkDirectory, "global.json"); + if (globalPath != null && !Inside(globalPath, report.Repository)) + Unknown(report, "dotnet.sdk.boundary", "SDK selection is inherited from global.json outside the inspected checkout.", + "Include the SDK selection policy inside the checkout so rewritten copies resolve the same installed SDK."); + string? requested = null; + string? global = globalPath == null ? null : ReadMetadata(globalPath, report); + if (global != null) + { + using JsonDocument settings = JsonDocument.Parse(global, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); + if (settings.RootElement.ValueKind != JsonValueKind.Object) + Unknown(report, "dotnet.global-json", "global.json must contain an object."); + else if (settings.RootElement.TryGetProperty("sdk", out JsonElement sdk)) + { + requested = JsonString(sdk, "version", report, "global.json sdk"); + if (requested != null && ParseVersion(requested) == null) + Unknown(report, "dotnet.sdk.requirement", "global.json SDK version is not a stable literal version."); + if (sdk.ValueKind == JsonValueKind.Object && sdk.TryGetProperty("paths", out _)) + Unknown(report, "dotnet.sdk.paths", "global.json custom SDK paths are not covered by this fast selector.", + "Select an SDK installed in the dotnet host inventory without custom sdk.paths."); + JsonString(sdk, "rollForward", report, "global.json sdk"); + } + } + ProcessResult? inventory = Diagnose(report, "dotnet.sdk.inventory", dotnet, new[] { "--list-sdks" }, detection.WorkDirectory, safe); + ProcessResult? runtimeInventory = Diagnose(report, "dotnet.runtime.inventory", dotnet, new[] { "--list-runtimes" }, detection.WorkDirectory, safe); + ProcessResult? selected = Diagnose(report, "dotnet.sdk.selection", dotnet, new[] { "--version" }, detection.WorkDirectory, safe); + if (selected == null || dotnet == null) return; + Version? version = RecordTool(report, "dotnet", dotnet, selected.Output.Trim(), requested, globalPath == null ? "installed SDK default" : "global.json"); + report.Environment["REALDIFF_READINESS_DOTNET_SDK"] = selected.Output.Trim(); + if (inventory != null) + { + string[] sdks = inventory.Output.Split('\n').Select(line => line.Trim().Split(' ')[0]).Where(value => value.Length > 0).ToArray(); + if (!sdks.Contains(selected.Output.Trim(), StringComparer.Ordinal)) + Unknown(report, "dotnet.sdk.inventory", "The selected SDK is not in the installed dotnet host inventory."); + } + var runtimes = new List<(string Name, Version Version)>(); + if (runtimeInventory != null) + { + foreach (string line in runtimeInventory.Output.Split('\n')) + { + Match runtime = Regex.Match(line, @"^(\S+)\s+(\S+)\s+\["); + Version? runtimeVersion = runtime.Success ? ParseVersion(runtime.Groups[2].Value) : null; + if (runtimeVersion != null) runtimes.Add((runtime.Groups[1].Value, runtimeVersion)); + } + report.Environment["REALDIFF_READINESS_DOTNET_RUNTIMES"] = + string.Join(";", runtimes.Select(runtime => runtime.Name + "=" + runtime.Version).OrderBy(value => value, StringComparer.Ordinal)); + bool weaverRuntime = runtimes.Any(runtime => runtime.Name == "Microsoft.NETCore.App" && runtime.Version.Major >= 8); + report.Add("dotnet.weaver-runtime", weaverRuntime ? "passed" : "failed", + weaverRuntime ? "An installed .NET 8+ runtime is available for the bundled weaver's roll-forward policy." : "No .NET 8+ runtime is installed for the bundled weaver.", + weaverRuntime ? null : "Install a supported .NET runtime explicitly."); + } + var tfms = new HashSet(StringComparer.Ordinal); + string[] projects = ProjectFiles(detection.WorkDirectory, "*.*proj", report) + .Where(path => Path.GetExtension(path) is ".csproj" or ".fsproj" or ".vbproj").ToArray(); + var files = new HashSet(projects, StringComparer.Ordinal); + foreach (string project in projects) + foreach (string directory in Ancestors(Path.GetDirectoryName(project)!)) + foreach (string name in new[] { "Directory.Build.props", "Directory.Build.targets" }) + { + string path = Path.Combine(directory, name); + if (File.Exists(path)) + { + files.Add(path); + if (!Inside(path, report.Repository)) + Unknown(report, "dotnet.metadata.boundary", name + " is inherited from outside the inspected checkout.", + "Include required MSBuild metadata inside the selected checkout."); + } + } + foreach (string file in files.OrderBy(path => path, StringComparer.Ordinal)) + { + XDocument? project = ReadXml(file, report); + if (project == null) continue; + foreach (XElement element in project.Descendants()) + { + if (element.Name.LocalName is "TargetFramework" or "TargetFrameworks") + { + if (element.AncestorsAndSelf().Any(parent => parent.Attribute("Condition") != null) + || element.Value.Contains("$(", StringComparison.Ordinal)) + Unknown(report, "dotnet.tfm", "Conditional or computed target framework metadata cannot be established without MSBuild evaluation.", + "Expose literal, unconditional target frameworks; fast inspection never evaluates project targets."); + else + foreach (string tfm in element.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + tfms.Add(tfm); + } + if (element.Name.LocalName == "Import") + Unknown(report, "dotnet.import", "Explicit MSBuild imports can change framework or SDK requirements.", + "Expose supported static framework requirements without custom import-based selection."); + if (element.Name.LocalName is "RuntimeIdentifier" or "RuntimeIdentifiers" or "TargetFrameworkVersion" or "MSBuildSDKsPath") + Unknown(report, "dotnet.target-selection", "Project metadata contains target or SDK selection not resolved by fast inspection.", + "Use literal supported host TFMs and the selected dotnet SDK."); + } + } + if (tfms.Count == 0) + Unknown(report, "dotnet.tfm", "No literal target frameworks were found in project metadata.", + "Declare TargetFramework/TargetFrameworks explicitly in the selected projects."); + foreach (string tfm in tfms.OrderBy(value => value, StringComparer.Ordinal)) + { + Match framework = Regex.Match(tfm, @"^net(\d+)\.(\d+)(?:-(windows)[\d.]*)?$"); + if (tfm is "netstandard2.0" or "netstandard2.1") continue; + if (!framework.Success || !int.TryParse(framework.Groups[1].Value, out int major) || major < 5 + || !int.TryParse(framework.Groups[2].Value, out int minor)) + { + Unknown(report, "dotnet.tfm", "Target framework " + tfm + " is outside the statically supported modern .NET host route.", + "Use a supported modern .NET TFM or provide separately verified runtime support."); + continue; + } + Minimum(report, "dotnet.tfm.sdk", version, major + "." + minor, "Target framework " + tfm); + bool available = runtimes.Any(runtime => runtime.Name == "Microsoft.NETCore.App" + && runtime.Version.Major == major && runtime.Version.Minor == minor); + report.Add("dotnet.tfm.runtime", available ? "passed" : "failed", + "Target framework " + tfm + (available ? " has an installed matching runtime." : " has no installed matching runtime."), + available ? null : "Install the matching target runtime explicitly; no runtime installation was attempted."); + if (framework.Groups[3].Success && !OperatingSystem.IsWindows()) + report.Add("dotnet.tfm.platform", "failed", "A Windows target framework cannot run on this host.", "Use a Windows execution host."); + } + report.Environment["REALDIFF_READINESS_DOTNET_TFMS"] = string.Join(";", tfms.OrderBy(value => value, StringComparer.Ordinal)); + CaptureEnvironment(report, "DOTNET_ROOT", "DOTNET_ROOT_X64", "DOTNET_ROOT_ARM64", "DOTNET_ROLL_FORWARD", + "DOTNET_MULTILEVEL_LOOKUP", "DOTNET_STARTUP_HOOKS", "DOTNET_ADDITIONAL_DEPS", "MSBuildSDKsPath", + "CORECLR_ENABLE_PROFILING", "COR_ENABLE_PROFILING"); + foreach (string key in new[] { "DOTNET_STARTUP_HOOKS", "DOTNET_ADDITIONAL_DEPS", "MSBuildSDKsPath" }) + if (!string.IsNullOrEmpty(report.Environment.GetValueOrDefault(key))) + Unknown(report, "dotnet.environment", key + " can change runtime/SDK execution outside verified selection.", + "Unset runtime injection or custom SDK overrides."); + foreach (string key in new[] { "CORECLR_ENABLE_PROFILING", "COR_ENABLE_PROFILING" }) + if (report.Environment.GetValueOrDefault(key) == "1") + Unknown(report, "dotnet.profiler", "Profiler startup injection was disabled during diagnostics.", + "Disable existing profiler injection before using the verified runtime context."); + report.Add("dotnet.sdk.selection", "passed", "Resolved the installed SDK using global.json and the host's actual roll-forward selection without MSBuild evaluation."); + } + + private static void InspectJava(LanguageDetection detection, ReadinessReport report) + { + string? home = Environment.GetEnvironmentVariable("JAVA_HOME"); + string? java = FindExecutable(string.IsNullOrWhiteSpace(home) ? "java" + : Path.Combine(home, "bin", OperatingSystem.IsWindows() ? "java.exe" : "java"), report); + var safe = new Dictionary + { + ["JAVA_TOOL_OPTIONS"] = "", ["JDK_JAVA_OPTIONS"] = "", ["_JAVA_OPTIONS"] = "", ["CLASSPATH"] = "", + }; + ProcessResult? runtime = Diagnose(report, "tool.java", java, new[] { "-version" }, AppContext.BaseDirectory, safe); + Version? version = null; + if (runtime != null && java != null) + { + Match release = Regex.Match(runtime.Output, @"\bversion\s+""([^""]+)"""); + string value = release.Success ? release.Groups[1].Value : "unrecognized"; + if (value.StartsWith("1.", StringComparison.Ordinal)) value = value[2..].Replace('_', '.'); + version = RecordTool(report, "java", java, value); + Minimum(report, "java.agent-runtime", version, "11", "Java agent maven.compiler.release"); + home = Path.GetDirectoryName(Path.GetDirectoryName(java)!); + report.Environment["JAVA_HOME"] = home!; + string? javac = FindExecutable(Path.Combine(home!, "bin", OperatingSystem.IsWindows() ? "javac.exe" : "javac"), report); + ProcessResult? compiler = Diagnose(report, "tool.javac", javac, new[] { "-version" }, AppContext.BaseDirectory, safe); + if (compiler != null && javac != null) + { + Match releaseCompiler = Regex.Match(compiler.Output, @"\bjavac\s+(\S+)"); + string compilerVersion = releaseCompiler.Success ? releaseCompiler.Groups[1].Value : "unrecognized"; + Version? selectedCompiler = RecordTool(report, "javac", javac, compilerVersion); + if (selectedCompiler != version) + Unknown(report, "java.jdk", "Selected java and javac versions do not agree."); + } + } + CaptureEnvironment(report, "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS", "JAVA_OPTS", "MAVEN_OPTS", + "MAVEN_ARGS", "MAVEN_CONFIG", "MAVEN_USER_HOME", "MAVEN_HOME", "M2_HOME", "JAVACMD", "GRADLE_OPTS", "GRADLE_USER_HOME"); + foreach (string key in new[] { "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS", "JAVA_OPTS", "MAVEN_OPTS", + "MAVEN_ARGS", "MAVEN_CONFIG", "JAVACMD", "GRADLE_OPTS" }) + if (!string.IsNullOrEmpty(report.Environment.GetValueOrDefault(key))) + Unknown(report, "java.environment", key + " can change JVM/toolchain startup and was not executed by fast inspection.", + "Unset JVM injection and expose the selected JDK with JAVA_HOME."); + foreach (string key in new[] { "GRADLE_USER_HOME", "MAVEN_USER_HOME", "MAVEN_HOME", "M2_HOME" }) + if (!string.IsNullOrEmpty(report.Environment.GetValueOrDefault(key)) + && !Path.IsPathFullyQualified(report.Environment[key])) + Unknown(report, "java.environment.path", key + " is relative and can resolve differently at another execution directory.", + "Use an absolute path for Java build-tool home selection."); + bool gradle = Path.GetFileName(detection.EntryPoint).StartsWith("build.gradle", StringComparison.Ordinal) + || Path.GetFileName(detection.EntryPoint).StartsWith("settings.gradle", StringComparison.Ordinal); + string manager = gradle ? "gradle" : "mvn"; + string start = Directory.Exists(detection.EntryPoint) ? detection.EntryPoint : Path.GetDirectoryName(detection.EntryPoint)!; + string? wrapper = null; + foreach (string directory in Ancestors(start)) + { + string candidate = Path.Combine(directory, gradle + ? OperatingSystem.IsWindows() ? "gradlew.bat" : "gradlew" + : OperatingSystem.IsWindows() ? "mvnw.cmd" : "mvnw"); + if (File.Exists(candidate)) { wrapper = candidate; break; } + if (Path.GetFullPath(directory) == Path.GetFullPath(report.Repository)) break; + } + if (wrapper != null) + { + string properties = Path.Combine(Path.GetDirectoryName(wrapper)!, gradle ? "gradle" : ".mvn", "wrapper", + gradle ? "gradle-wrapper.properties" : "maven-wrapper.properties"); + string? text = ReadMetadata(properties, report); + Match release = Regex.Match(text ?? "", gradle ? @"gradle-(\d+(?:\.\d+){1,2})-(?:bin|all)\.zip" + : @"apache-maven-(\d+(?:\.\d+){1,2})-(?:bin|all)\."); + report.Tools.Add(new ReadinessTool(manager, wrapper, release.Success ? release.Groups[1].Value : "unresolved", + null, Path.GetFileName(properties))); + Unknown(report, "java.wrapper", "The selected repository wrapper was inspected statically but not executed; its installed distribution and effective JDK are unresolved.", + "Use a directly installed Maven/Gradle distribution instead of the repository wrapper for fast readiness. A wrapper is never run just to obtain its version."); + } + else + { + string? executable = FindExecutable(manager, report, allowScripts: true); + if (executable == null) + report.Add("tool." + manager, "failed", "No installed " + manager + " executable was found.", "Install the selected Java build tool explicitly."); + else if (gradle) + { + // Gradle startup may provision daemon JVMs. Distribution metadata is sufficient to identify, not launch, it. + string root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(executable)!, "..")); + string lib = Path.Combine(root, "lib"); + string[] jars = Directory.Exists(lib) ? Directory.GetFiles(lib, "gradle-core-*.jar") + .Where(path => Regex.IsMatch(Path.GetFileName(path), @"^gradle-core-\d")).ToArray() : Array.Empty(); + if (jars.Length == 1) + { + Match release = Regex.Match(Path.GetFileName(jars[0]), @"^gradle-core-(\d+(?:\.\d+){1,2})\.jar$"); + RecordTool(report, manager, executable, release.Success ? release.Groups[1].Value : "unrecognized"); + } + else Unknown(report, "tool.gradle", "The installed Gradle distribution version could not be identified without startup.", + "Install a complete Gradle distribution and point PATH at its bin directory."); + } + else + { + string root = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(executable)!, "..")); + string lib = Path.Combine(root, "lib"); + string[] jars = Directory.Exists(lib) ? Directory.GetFiles(lib, "maven-core-*.jar") : Array.Empty(); + if (jars.Length == 1) + { + Match release = Regex.Match(Path.GetFileName(jars[0]), @"^maven-core-(\d+(?:\.\d+){1,2})\.jar$"); + RecordTool(report, manager, executable, release.Success ? release.Groups[1].Value : "unrecognized"); + } + else Unknown(report, "tool.mvn", "The installed Maven distribution version could not be identified without its launcher.", + "Install a complete Maven distribution and put its bin directory on PATH."); + } + } + if (gradle) + { + string gradleHome = Environment.GetEnvironmentVariable("GRADLE_USER_HOME") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gradle"); + if (Path.IsPathFullyQualified(gradleHome)) report.Environment["GRADLE_USER_HOME"] = gradleHome; + string initDirectory = Path.Combine(gradleHome, "init.d"); + if (File.Exists(Path.Combine(gradleHome, "init.gradle")) + || File.Exists(Path.Combine(gradleHome, "init.gradle.kts")) + || Directory.Exists(initDirectory) && Directory.EnumerateFiles(initDirectory, "*.gradle*").Any()) + Unknown(report, "java.gradle-init", "User Gradle initialization scripts can change toolchain selection.", + "Use a Gradle user home without opaque initialization scripts."); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + string name = (string)entry.Key; + if (name.StartsWith("ORG_GRADLE_PROJECT_", StringComparison.Ordinal) + && Regex.IsMatch(name, @"(?i)(java|jdk|jvm|toolchain)")) + { + report.Environment[name] = (string?)entry.Value ?? ""; + Unknown(report, "java.gradle-environment", "Environment-supplied Gradle project properties can select a different JDK.", + "Remove JVM/toolchain project-property overrides from the environment."); + } + } + foreach (string file in ProjectFiles(detection.WorkDirectory, "*.gradle*", report)) + { + string? text = ReadMetadata(file, report); + if (text == null) continue; + var literals = Regex.Matches(text, + @"(?:JavaLanguageVersion\.of\s*\(\s*|jvmToolchain\s*\(\s*|JavaVersion\.VERSION_|(?:sourceCompatibility|targetCompatibility)\s*=\s*['""]?)(\d+(?:[._]\d+)*)"); + foreach (Match literal in literals) + { + string target = literal.Groups[1].Value.Replace('_', '.'); + if (target.StartsWith("1.", StringComparison.Ordinal)) target = target[2..]; + Minimum(report, "java.target", version, target, Path.GetFileName(file)); + } + if (Regex.IsMatch(text, @"\b(toolchain|languageVersion|sourceCompatibility|targetCompatibility)\b") + && literals.Count == 0) + Unknown(report, "java.gradle-requirement", "Gradle computes a Java requirement that cannot be resolved statically.", + "Expose literal Java toolchain/source/target declarations; fast inspection does not execute Gradle code."); + if (text.Contains("toolchain", StringComparison.OrdinalIgnoreCase)) + Unknown(report, "java.gradle-toolchain", "Gradle toolchains can select or download a different JDK from JAVA_HOME.", + "Use the directly selected installed JDK without dynamic Gradle toolchain provisioning."); + } + foreach (string path in new[] { Path.Combine(detection.WorkDirectory, "gradle.properties"), + Path.Combine(Environment.GetEnvironmentVariable("GRADLE_USER_HOME") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".gradle"), "gradle.properties"), + Path.Combine(detection.WorkDirectory, "gradle", "gradle-daemon-jvm.properties") }) + { + string? text = ReadMetadata(path, report); + if (text != null && (Path.GetFileName(path) == "gradle-daemon-jvm.properties" + || Regex.IsMatch(text, @"(?m)^\s*org\.gradle\.(?:java|jvm)"))) + Unknown(report, "java.gradle-jvm", "Gradle configuration can select or provision a different JVM.", + "Disable alternate JVM selection and use the verified installed JAVA_HOME."); + } + } + else + { + foreach (string rc in new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".mavenrc"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "mavenrc_pre.cmd"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "mavenrc_pre.bat"), + Path.Combine(Path.GetPathRoot(detection.WorkDirectory)!, "etc", "mavenrc"), + }) + if (File.Exists(rc) && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MAVEN_SKIP_RC"))) + Unknown(report, "java.maven-rc", "Maven startup scripts can replace the selected JDK or Maven settings.", + "Remove opaque Maven startup scripts or set MAVEN_SKIP_RC=1 explicitly."); + report.Environment["MAVEN_SKIP_RC"] = "1"; + foreach (string file in ProjectFiles(detection.WorkDirectory, "pom.xml", report)) + { + XDocument? pom = ReadXml(file, report); + if (pom == null) continue; + var properties = pom.Descendants().Where(element => element.Parent?.Name.LocalName == "properties") + .GroupBy(element => element.Name.LocalName).ToDictionary(group => group.Key, group => group.Last().Value); + foreach (XElement declaration in pom.Descendants().Where(element => + element.Name.LocalName is "maven.compiler.release" or "maven.compiler.source" or "maven.compiler.target" + || element.Parent?.Name.LocalName == "configuration" && element.Name.LocalName is "release" or "source" or "target")) + { + if (declaration.Ancestors().Any(element => element.Name.LocalName == "profile")) + { + Unknown(report, "java.maven-profile", "A Maven profile conditionally selects a compiler requirement.", + "Expose unconditional compiler requirements without profile-dependent toolchain selection."); + continue; + } + string value = declaration.Value.Trim(); + Match reference = Regex.Match(value, @"^\$\{([^}]+)\}$"); + if (reference.Success && properties.TryGetValue(reference.Groups[1].Value, out string? resolved)) value = resolved; + if (value.StartsWith("1.", StringComparison.Ordinal)) value = value[2..]; + Minimum(report, "java.target", version, value, "pom.xml " + declaration.Name.LocalName); + } + if (pom.Descendants().Any(element => element.Name.LocalName is "jdkToolchain" or "toolchains" + || element.Name.LocalName == "artifactId" && element.Value == "maven-toolchains-plugin")) + Unknown(report, "java.maven-toolchain", "Maven toolchain configuration can choose a different JDK.", + "Use the verified JAVA_HOME directly or expose a supported literal installed JDK selection."); + if (pom.Descendants().Any(element => element.Name.LocalName == "parent")) + Unknown(report, "java.maven-parent", "Inherited Maven parent compiler/toolchain settings were not evaluated.", + "Expose compiler and JDK requirements without unresolved parent inheritance."); + } + foreach (string name in new[] { "jvm.config", "maven.config", "extensions.xml" }) + { + string? text = ReadMetadata(Path.Combine(start, ".mvn", name), report); + if (!string.IsNullOrWhiteSpace(text)) + Unknown(report, "java.maven-config", ".mvn/" + name + " can alter Maven/JDK execution.", + "Remove opaque startup overrides and use a verifiable installed JDK/build-tool selection."); + } + string toolchains = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".m2", "toolchains.xml"); + if (ReadMetadata(toolchains, report) != null) + Unknown(report, "java.maven-toolchains", "User Maven toolchains.xml may redirect compiler selection.", + "Use an explicit verifiable JDK without user toolchain redirection."); + } + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessInspection.Native.cs b/src/RealDiff.Cli/Readiness/ReadinessInspection.Native.cs new file mode 100644 index 0000000..06bd44e --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessInspection.Native.cs @@ -0,0 +1,440 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace RealDiff.Cli +{ + internal static partial class ReadinessInspection + { + private static void InspectGo(LanguageDetection detection, ReadinessReport report) + { + string? go = FindExecutable("go", report); + var safe = new Dictionary { ["GOTOOLCHAIN"] = "local", ["GOTELEMETRY"] = "off" }; + ProcessResult? initial = Diagnose(report, "tool.go", go, new[] { "version" }, detection.WorkDirectory, safe); + if (initial == null || go == null) return; + Match identity = Regex.Match(initial.Output, @"\bgo version go(\S+)\s+(\S+)"); + Version? installed = identity.Success ? ParseVersion(identity.Groups[1].Value) : null; + if (installed == null) + { + RecordTool(report, "go", go, identity.Success ? identity.Groups[1].Value : "unrecognized"); + return; + } + ProcessResult? initialEnvironment = Diagnose(report, "go.environment", go, + new[] { "env", "-json", "GOENV", "GOWORK", "GOPATH" }, detection.WorkDirectory, safe); + if (initialEnvironment == null) return; + using JsonDocument initialJson = JsonDocument.Parse(initialEnvironment.Output); + string selector = Environment.GetEnvironmentVariable("GOTOOLCHAIN") ?? ""; + string? goenv = JsonString(initialJson.RootElement, "GOENV", report, "go env"); + if (!string.IsNullOrEmpty(goenv) && goenv != "off") + { + string? settings = ReadMetadata(goenv, report, fingerprint: false); + if (selector.Length == 0 && settings != null) + selector = Regex.Match(settings, @"(?m)^GOTOOLCHAIN=(.+)$").Groups[1].Value.Trim(); + } + if (selector.Length == 0) selector = "auto"; + string? work = JsonString(initialJson.RootElement, "GOWORK", report, "go env"); + var requirements = new List<(string Minimum, string Source)>(); + var suggested = new List(); + string? module = Nearest(detection.WorkDirectory, "go.mod"); + foreach (string file in new[] { module, string.IsNullOrEmpty(work) || work == "off" ? null : work } + .Where(path => path != null).Cast().Distinct()) + { + string? text = ReadMetadata(file, report); + if (text == null) continue; + InspectGoLocalPaths(text, file, detection.WorkDirectory, report); + MatchCollection directives = Regex.Matches(text, @"(?m)^\s*(go|toolchain)\s+([^\s/]+)\s*(?://.*)?$"); + foreach (Match directive in directives) + { + string value = directive.Groups[2].Value; + if (directive.Groups[1].Value == "go") requirements.Add((value, Path.GetFileName(file))); + else if (value != "default") suggested.Add(value); + } + int declarations = Regex.Matches(text, @"(?m)^\s*(?:go|toolchain)\b").Count; + if (directives.Count != declarations + || directives.Cast().GroupBy(match => match.Groups[1].Value).Any(group => group.Count() > 1)) + Unknown(report, "go.declarations", Path.GetFileName(file) + " contains malformed or duplicate toolchain directives."); + if (!directives.Cast().Any(match => match.Groups[1].Value == "go")) + Unknown(report, "go.minimum", Path.GetFileName(file) + " does not declare a literal go version.", + "Declare the supported Go version explicitly."); + } + Version desired = installed; + bool auto = selector is "auto" or "path" || selector.EndsWith("+auto", StringComparison.Ordinal) + || selector.EndsWith("+path", StringComparison.Ordinal); + string baseline = selector.Split('+')[0]; + if (baseline is not ("local" or "auto" or "path")) + { + Version? selectedVersion = baseline.StartsWith("go", StringComparison.Ordinal) ? ParseVersion(baseline[2..]) : null; + if (selectedVersion == null) Unknown(report, "go.selection", "GOTOOLCHAIN has an unresolved toolchain selector."); + else desired = selectedVersion; + } + if (selector.Contains('+') && !Regex.IsMatch(selector, @"^(local|go\d+\.\d+(?:\.\d+)?)\+(auto|path)$")) + Unknown(report, "go.selection", "GOTOOLCHAIN has an unsupported selection mode."); + if (auto) + { + foreach (string value in requirements.Select(item => item.Minimum).Concat(suggested.Select(value => value.StartsWith("go") ? value[2..] : value))) + { + Version? candidate = ParseVersion(value); + if (candidate == null) Unknown(report, "go.selection", "Go auto-selection includes a nonnumeric toolchain requirement."); + else if (candidate > desired) desired = candidate; + } + } + if (desired != installed) + { + string name = "go" + desired.ToString(3); + string? resolved = FindExecutable(name, report); + // golang.org/dl launchers can provision on invocation; only inspect the SDK payload itself. + string sdk = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "sdk", name, + "bin", OperatingSystem.IsWindows() ? "go.exe" : "go"); + if (File.Exists(sdk)) resolved = FindExecutable(sdk, report); + if (resolved == null || Path.GetFileNameWithoutExtension(resolved) != "go") + { + Unknown(report, "go.selection", "The effective Go selection requires " + name + ", whose installed compiler payload could not be resolved.", + "Install that Go toolchain explicitly and put its bin directory on PATH. Fast inspection never downloads it."); + return; + } + go = resolved; + ProcessResult? selected = Diagnose(report, "go.selection", go, new[] { "version" }, detection.WorkDirectory, safe); + if (selected == null) return; + identity = Regex.Match(selected.Output, @"\bgo version go(\S+)\s+(\S+)"); + installed = identity.Success ? ParseVersion(identity.Groups[1].Value) : null; + if (installed != desired) + { + Unknown(report, "go.selection", "The installed compiler does not match the effective Go selector."); + return; + } + } + installed = RecordTool(report, "go", go, identity.Groups[1].Value, + string.Join("; ", requirements.Select(item => item.Source + ":go " + item.Minimum)), + "go.mod/go.work and GOTOOLCHAIN"); + foreach (var requirement in requirements) + Minimum(report, "go.minimum", installed, requirement.Minimum, requirement.Source); + ProcessResult? effective = Diagnose(report, "go.environment", go, + new[] { "env", "-json", "GOOS", "GOARCH", "GOROOT", "GOPATH", "GOMODCACHE", "CGO_ENABLED", "GOFLAGS", + "GOEXPERIMENT", "GOWORK", "GOENV", "CC", "CXX", "GOTOOLCHAIN", "GO111MODULE", "GOAMD64", "GOARM", "GOARM64", + "GO386", "GOMIPS", "GOMIPS64", "GOPPC64", "GORISCV64", "GOWASM", "CGO_CFLAGS", "CGO_CPPFLAGS", + "CGO_CXXFLAGS", "CGO_FFLAGS", "CGO_LDFLAGS" }, detection.WorkDirectory, safe); + if (effective == null) return; + using JsonDocument environment = JsonDocument.Parse(effective.Output); + foreach (JsonProperty item in environment.RootElement.EnumerateObject()) + if (item.Value.ValueKind == JsonValueKind.String) + report.Environment[item.Name] = item.Value.GetString()!; + report.Environment["GOTOOLCHAIN"] = "local"; + report.Environment["GOTELEMETRY"] = "off"; + string workspace = report.Environment.GetValueOrDefault("GOWORK", ""); + if (!string.IsNullOrEmpty(workspace) && workspace != "off") + { + if (Path.GetDirectoryName(workspace) == Path.GetFullPath(detection.WorkDirectory)) + { + report.Environment["REALDIFF_READINESS_GO_WORKSPACE"] = "go.work"; + report.Environment["GOWORK"] = "auto"; + } + else Unknown(report, "go.workspace", "The effective workspace is not at the selected workdir root and cannot be reproduced in rewritten copies.", + "Place go.work at the selected workdir root or explicitly set GOWORK=off; only the selected workdir is rewritten."); + } + else report.Environment["GOWORK"] = "off"; + string host = identity.Groups[2].Value; + string compilerPath = Path.Combine(report.Environment.GetValueOrDefault("GOROOT", ""), "pkg", "tool", + host.Replace('/', '_'), OperatingSystem.IsWindows() ? "compile.exe" : "compile"); + string? goCompiler = FindExecutable(compilerPath, report); + ProcessResult? compilerVersion = Diagnose(report, "go.compiler", goCompiler, new[] { "-V" }, AppContext.BaseDirectory, safe); + if (compilerVersion != null && goCompiler != null) + { + Match compilerIdentity = Regex.Match(compilerVersion.Output, @"\bcompile version go(\S+)"); + string compilerRelease = compilerIdentity.Success ? compilerIdentity.Groups[1].Value : "unrecognized"; + Version? actualCompiler = RecordTool(report, "go-compiler", goCompiler, compilerRelease); + if (actualCompiler != installed) + Unknown(report, "go.goroot", "GOROOT selects a compiler payload that differs from the inspected go executable.", + "Select a complete matching Go installation and remove conflicting GOROOT overrides."); + } + string target = report.Environment.GetValueOrDefault("GOOS") + "/" + report.Environment.GetValueOrDefault("GOARCH"); + report.Add("go.target", host == target ? "passed" : "unknown", + host == target ? "Go target matches the selected compiler host." : "Go cross-compilation target differs from the compiler host.", + host == target ? null : "Select a runnable host target; a fixture on another target cannot establish local test execution."); + if (!string.IsNullOrWhiteSpace(report.Environment.GetValueOrDefault("GOFLAGS"))) + Unknown(report, "go.flags", "GOFLAGS can alter build selection and includes settings not interpreted by fast inspection.", + "Unset GOFLAGS or express supported selection in explicit project metadata."); + report.Add("go.selection", "passed", "Pinned the installed Go compiler and GOTOOLCHAIN=local; automatic provisioning is disabled."); + } + + private static void InspectGoLocalPaths(string text, string file, string workdir, ReadinessReport report) + { + bool useBlock = false; + foreach (string line in text.Split('\n')) + { + string value = Regex.Replace(line, @"\s*//.*$", "").Trim(); + if (value.Length == 0) continue; + if (value == "use (") { useBlock = true; continue; } + if (useBlock && value == ")") { useBlock = false; continue; } + string? path = useBlock ? value : value.StartsWith("use ", StringComparison.Ordinal) ? value[4..].Trim() : null; + Match replacement = Regex.Match(value, @"=>\s+([^\s]+)\s*$"); + if (path == null && replacement.Success) + { + string candidate = replacement.Groups[1].Value; + if (candidate.StartsWith(".", StringComparison.Ordinal) || Path.IsPathRooted(candidate)) path = candidate; + } + if (path == null) continue; + if (path.Contains('"') || path.Contains('`') || path.Any(char.IsWhiteSpace)) + { + Unknown(report, "go.workspace.path", "A quoted or computed local Go module path could not be resolved statically.", + "Use simple literal relative workspace/module paths inside the selected workdir."); + continue; + } + string absolute = Path.GetFullPath(path, Path.GetDirectoryName(file)!); + StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + if (!absolute.Equals(Path.GetFullPath(workdir), comparison) && !Inside(absolute, workdir)) + Unknown(report, "go.workspace.path", "A local Go workspace/module dependency is outside the workdir copied by the rewriter.", + "Include local workspace/module dependencies in the selected workdir or use a self-contained module."); + } + if (useBlock) Unknown(report, "go.workspace.syntax", "The Go workspace use block is not terminated."); + } + + private static void InspectRust(LanguageDetection detection, ReadinessReport report) + { + string? rustup = FindExecutable("rustup", report); + string? rustc = null; + string? cargo = null; + var safe = new Dictionary { ["RUSTUP_AUTO_INSTALL"] = "0", ["RUSTUP_SKIP_UPDATE_CHECK"] = "1" }; + if (rustup != null) + { + ProcessResult? inventory = Diagnose(report, "rust.installed", rustup, + new[] { "toolchain", "list", "--verbose" }, detection.WorkDirectory, safe); + ProcessResult? overrides = Diagnose(report, "rust.overrides", rustup, + new[] { "override", "list" }, detection.WorkDirectory, safe); + if (inventory == null || overrides == null) return; + var installed = new Dictionary(StringComparer.Ordinal); + foreach (string line in inventory.Output.Split('\n')) + { + Match item = Regex.Match(line.Trim(), @"^(\S+)\s+(?:\([^)]*\)\s+)?(.+)$"); + if (item.Success && Directory.Exists(item.Groups[2].Value.Trim())) + installed[item.Groups[1].Value] = item.Groups[2].Value.Trim(); + } + string? selected = Environment.GetEnvironmentVariable("RUSTUP_TOOLCHAIN"); + string origin = "RUSTUP_TOOLCHAIN"; + var overridePaths = new Dictionary(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + foreach (string line in overrides.Output.Split('\n')) + { + Match item = Regex.Match(line.Trim(), @"^(.+?)\s+(\S+)\s*$"); + if (item.Success && Path.IsPathRooted(item.Groups[1].Value)) + overridePaths[Path.GetFullPath(item.Groups[1].Value)] = item.Groups[2].Value; + } + foreach (string directory in Ancestors(detection.WorkDirectory)) + { + if (!string.IsNullOrEmpty(selected)) break; + if (overridePaths.TryGetValue(directory, out string? localOverride)) + { + selected = localOverride; + origin = "rustup directory override"; + break; + } + string legacy = Path.Combine(directory, "rust-toolchain"); + string toml = Path.Combine(directory, "rust-toolchain.toml"); + if (File.Exists(legacy) && File.Exists(toml)) + Unknown(report, "rust.selection", "Both rust-toolchain and rust-toolchain.toml exist at the same selection boundary."); + string? file = File.Exists(legacy) ? legacy : File.Exists(toml) ? toml : null; + if (file == null) continue; + string? text = ReadMetadata(file, report); + if (text == null) return; + origin = Path.GetFileName(file); + if (!text.TrimStart().StartsWith("[", StringComparison.Ordinal)) + selected = text.Trim(); + else + { + selected = TomlString(text, "toolchain", "channel", report, origin); + string? path = TomlString(text, "toolchain", "path", report, origin); + if (path != null) selected = Path.GetFullPath(path, directory); + if (Regex.IsMatch(text, @"(?m)^\s*(components|targets)\s*=")) + Unknown(report, "rust.components", "The toolchain file requests components or targets that were not verified as installed.", + "Use an already installed complete toolchain without implicit component provisioning."); + } + if (string.IsNullOrWhiteSpace(selected)) + { + Unknown(report, "rust.selection", "The toolchain file does not identify a literal installed channel or path."); + return; + } + } + string rustupHome = Environment.GetEnvironmentVariable("RUSTUP_HOME") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".rustup"); + // The resolved toolchain below is the execution identity, not unrelated directory override registrations. + string? settings = ReadMetadata(Path.Combine(rustupHome, "settings.toml"), report, fingerprint: false); + if (string.IsNullOrEmpty(selected) && settings != null) + { + selected = TomlString(settings, "", "default_toolchain", report, "rustup/settings.toml"); + origin = "rustup default"; + } + string? payload = null; + if (selected != null && Path.IsPathRooted(selected)) payload = selected; + else if (selected != null) + { + if (!installed.TryGetValue(selected, out payload)) + { + string? host = settings == null ? null : TomlString(settings, "", "default_host_triple", report, "rustup/settings.toml"); + if (host != null && installed.TryGetValue(selected + "-" + host, out payload)) selected += "-" + host; + else + { + string[] matches = installed.Keys.Where(key => key.StartsWith(selected + "-", StringComparison.Ordinal)).ToArray(); + if (matches.Length == 1) { selected = matches[0]; payload = installed[selected]; } + } + } + } + if (payload == null || selected == null) + { + Unknown(report, "rust.selection", "The selected Rust toolchain is not unambiguously installed.", + "Install the selected rustup toolchain explicitly; fast inspection never invokes a provisioning rustc/cargo proxy."); + return; + } + rustc = FindExecutable(Path.Combine(payload, "bin", OperatingSystem.IsWindows() ? "rustc.exe" : "rustc"), report); + cargo = FindExecutable(Path.Combine(payload, "bin", OperatingSystem.IsWindows() ? "cargo.exe" : "cargo"), report); + report.Environment["RUSTUP_TOOLCHAIN"] = selected; + report.Environment["RUSTUP_AUTO_INSTALL"] = "0"; + report.Environment["RUSTUP_SKIP_UPDATE_CHECK"] = "1"; + report.Add("rust.selection", "passed", "Resolved an installed Rust payload from " + origin + ", without running provisioning proxies."); + } + else + { + rustc = FindExecutable("rustc", report); + cargo = FindExecutable("cargo", report); + if (Nearest(detection.WorkDirectory, "rust-toolchain") != null || Nearest(detection.WorkDirectory, "rust-toolchain.toml") != null + || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("RUSTUP_TOOLCHAIN"))) + Unknown(report, "rust.selection", "Rustup selection metadata exists but rustup is unavailable.", + "Make the installed selected rustup toolchain available, or remove inapplicable rustup selection metadata."); + } + string? configuredRustc = Environment.GetEnvironmentVariable("RUSTC"); + if (!string.IsNullOrEmpty(configuredRustc)) rustc = FindExecutable(configuredRustc, report); + ProcessResult? compiler = Diagnose(report, "tool.rustc", rustc, new[] { "-vV" }, detection.WorkDirectory, safe); + ProcessResult? packageManager = Diagnose(report, "tool.cargo", cargo, new[] { "--version" }, detection.WorkDirectory, safe); + Version? version = null; + if (compiler != null && rustc != null) + { + Match release = Regex.Match(compiler.Output, @"(?m)^release:\s*(\S+)"); + version = RecordTool(report, "rustc", rustc, release.Success ? release.Groups[1].Value : "unrecognized"); + report.Environment["RUSTC"] = rustc; + report.Environment["REALDIFF_READINESS_RUST_HOST"] = Regex.Match(compiler.Output, @"(?m)^host:\s*(\S+)").Groups[1].Value; + string? rustdoc = FindExecutable(Path.Combine(Path.GetDirectoryName(rustc)!, + OperatingSystem.IsWindows() ? "rustdoc.exe" : "rustdoc"), report); + if (rustdoc != null) report.Environment["RUSTDOC"] = rustdoc; + else Unknown(report, "rust.rustdoc", "The selected compiler payload has no installed rustdoc for Cargo doctests.", + "Install the complete selected Rust toolchain, including rustdoc."); + } + if (packageManager != null && cargo != null) + { + Match release = Regex.Match(packageManager.Output, @"\bcargo\s+(\S+)"); + RecordTool(report, "cargo", cargo, release.Success ? release.Groups[1].Value : "unrecognized"); + } + CaptureEnvironment(report, "RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "CARGO_BUILD_TARGET", "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_HOME", "RUSTUP_HOME", "RUSTDOCFLAGS"); + foreach (string key in new[] { "CARGO_HOME", "RUSTUP_HOME" }) + if (!string.IsNullOrEmpty(report.Environment.GetValueOrDefault(key)) + && !Path.IsPathFullyQualified(report.Environment[key])) + Unknown(report, "rust.environment.path", key + " is relative and can change meaning in a rewritten directory.", + "Use an absolute installed-toolchain home path."); + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("RUSTDOC")) + && Environment.GetEnvironmentVariable("RUSTDOC") != report.Environment.GetValueOrDefault("RUSTDOC")) + Unknown(report, "rust.rustdoc.selection", "RUSTDOC selects a different documentation compiler from the inspected Rust payload.", + "Unset RUSTDOC or select rustdoc from the verified compiler payload."); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + string name = (string)entry.Key; + if (name.StartsWith("CARGO_TARGET_", StringComparison.OrdinalIgnoreCase) + && (name.EndsWith("_RUNNER", StringComparison.OrdinalIgnoreCase) + || name.EndsWith("_RUSTFLAGS", StringComparison.OrdinalIgnoreCase) + || name.EndsWith("_LINKER", StringComparison.OrdinalIgnoreCase)) + && entry.Value is string value && value.Length > 0) + { + report.Environment[name] = value; + Unknown(report, "rust.target-environment", name + " changes the selected target's compilation or execution.", + "Remove target runner/linker/flag overrides or expose a supported verifiable host configuration."); + } + } + foreach (string key in new[] { "RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", "RUSTFLAGS", "RUSTDOCFLAGS", "CARGO_ENCODED_RUSTFLAGS", "CARGO_BUILD_TARGET" }) + if (!string.IsNullOrEmpty(report.Environment.GetValueOrDefault(key))) + Unknown(report, "rust.environment", key + " can select unverified compilation behavior.", + "Unset the override or expose an independently verifiable host compiler configuration."); + string cargoHome = Environment.GetEnvironmentVariable("CARGO_HOME") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cargo"); + foreach (string directory in Ancestors(detection.WorkDirectory).Concat(new[] + { + cargoHome, + }).Distinct()) + { + foreach (string file in new[] { Path.Combine(directory, ".cargo", "config"), Path.Combine(directory, ".cargo", "config.toml"), + Path.Combine(directory, "config.toml") }.Where(File.Exists).Distinct()) + { + string? config = ReadMetadata(file, report); + if (!string.IsNullOrWhiteSpace(config) && !Inside(file, detection.WorkDirectory) && !Inside(file, cargoHome)) + Unknown(report, "rust.config.boundary", "Cargo configuration is inherited from outside the workdir that will be rewritten.", + "Include required Cargo configuration inside the selected workdir."); + if (config != null && Regex.IsMatch(config, @"(?m)^\s*(rustc|rustc-wrapper|rustc-workspace-wrapper|target|runner|rustflags)\s*=|^\s*\[env")) + Unknown(report, "rust.cargo-config", "Cargo configuration can change the compiler, target, flags, or environment.", + "Use a literal default-host configuration without compiler/runner overrides for fast inspection."); + } + } + string manifest = Path.Combine(detection.WorkDirectory, "Cargo.toml"); + string? manifestText = ReadMetadata(manifest, report); + if (manifestText == null) return; + foreach (string file in ProjectFiles(detection.WorkDirectory, "Cargo.toml", report)) + { + string? text = file == manifest ? manifestText : ReadMetadata(file, report); + if (text == null || !Regex.IsMatch(text, @"(?m)^\s*\[package\]")) continue; + foreach (string key in new[] { "rust-version", "edition" }) + { + string? value = TomlString(text, "package", key, report, Path.GetFileName(file)); + if (Regex.IsMatch(text, @"(?m)^\s*" + key + @"\.workspace\s*=\s*true")) + { + string? workspaceText = null; + foreach (string candidate in Ancestors(Path.GetDirectoryName(file)!).Select(parent => Path.Combine(parent, "Cargo.toml"))) + { + string? candidateText = ReadMetadata(candidate, report); + if (candidateText != null && Regex.IsMatch(candidateText, @"(?m)^\s*\[workspace(?:\.package)?\]")) + { + workspaceText = candidateText; + if (!Inside(candidate, detection.WorkDirectory)) + Unknown(report, "rust.workspace.boundary", "Cargo workspace inheritance comes from outside the rewritten workdir.", + "Select the workspace root so its package metadata is copied with the crate."); + break; + } + } + value = workspaceText == null ? null : TomlString(workspaceText, "workspace.package", key, report, "workspace Cargo.toml"); + if (value == null) Unknown(report, "rust.workspace", "Cargo " + key + " workspace inheritance could not be resolved."); + } + if (key == "rust-version" && value != null) Minimum(report, "rust.minimum", version, value, "Cargo.toml rust-version"); + if (key == "edition") + { + string edition = value ?? "2015"; + string? minimum = edition switch { "2015" => "1.0", "2018" => "1.31", "2021" => "1.56", "2024" => "1.85", _ => null }; + if (minimum == null) Unknown(report, "rust.edition", "Cargo declares an unrecognized edition."); + else Minimum(report, "rust.edition", version, minimum, "Cargo edition " + edition); + } + } + } + report.Limitations.Add("Rust edition/MSRV checks cover static manifests; injected runtime dependencies and arbitrary build.rs behavior are not established by version inspection."); + } + + private static IEnumerable ProjectFiles(string root, string pattern, ReadinessReport report) + { + var pending = new Stack(); + pending.Push(root); + int visited = 0; + while (pending.Count > 0) + { + if (++visited > 2048) + { + Unknown(report, "metadata.traversal", "The project metadata traversal exceeded 2048 directories.", "Select a narrower workdir."); + yield break; + } + string directory = pending.Pop(); + foreach (string file in Directory.EnumerateFiles(directory, pattern).OrderBy(path => path, StringComparer.Ordinal)) yield return file; + foreach (string child in Directory.EnumerateDirectories(directory).OrderByDescending(path => path, StringComparer.Ordinal)) + { + if (Path.GetFileName(child) is ".git" or "node_modules" or "target" or "bin" or "obj" or ".venv" or "venv") continue; + if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0) continue; + pending.Push(child); + } + } + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessInspection.Scripting.cs b/src/RealDiff.Cli/Readiness/ReadinessInspection.Scripting.cs new file mode 100644 index 0000000..d01623a --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessInspection.Scripting.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace RealDiff.Cli +{ + internal static partial class ReadinessInspection + { + private static void InspectNode(LanguageDetection detection, ReadinessReport report) + { + string manager = NodePackageManagers.Detect(detection.WorkDirectory); + string? node = FindExecutable("node", report); + var safe = new Dictionary { ["NODE_OPTIONS"] = "", ["NODE_PATH"] = "", ["COREPACK_ENABLE_NETWORK"] = "0", + ["COREPACK_ENABLE_DOWNLOAD_PROMPT"] = "0" }; + ProcessResult? result = Diagnose(report, "tool.node", node, new[] { "--version" }, AppContext.BaseDirectory, safe); + Version? nodeVersion = result == null || node == null ? null + : RecordTool(report, "node", node, result.Output.Trim().TrimStart('v')); + string? packageText = ReadMetadata(Path.Combine(detection.WorkDirectory, "package.json"), report); + if (packageText == null) return; + using JsonDocument package = JsonDocument.Parse(packageText); + string? declaration = JsonString(package.RootElement, "packageManager", report, "package.json"); + string? managerRequirement = null; + if (declaration != null) + { + Match declared = Regex.Match(declaration, @"^(npm|pnpm|yarn|bun)@(\d+\.\d+\.\d+)(?:\+sha(?:224|256|384|512)\.[A-Za-z0-9+/=]+)?$"); + if (!declared.Success) Unknown(report, "node.packageManager", "package.json packageManager is not a supported literal released version."); + else + { + managerRequirement = declared.Groups[2].Value; + report.Add("node.packageManager.selection", manager == declared.Groups[1].Value ? "passed" : "failed", + "The lockfile selects " + manager + "; packageManager selects " + declared.Groups[1].Value + ".", + manager == declared.Groups[1].Value ? null : "Make packageManager and the single selected lockfile agree."); + } + } + string? executable = FindExecutable(manager, report, allowScripts: true); + Version? managerVersion = null; + if (executable == null) + report.Add("tool." + manager, "failed", "The lockfile-selected " + manager + " executable is unavailable.", + "Install " + manager + " explicitly. Fast inspection will not invoke Corepack provisioning."); + else + { + string? script = null; + string? metadata = null; + string directory = Path.GetDirectoryName(executable)!; + string? adjacentMetadata = Nearest(directory, "package.json"); + if (adjacentMetadata != null && !Inside(adjacentMetadata, CheckoutRoot(report))) + { + string? adjacentText = ReadMetadata(adjacentMetadata, report); + if (adjacentText != null) + { + using JsonDocument adjacent = JsonDocument.Parse(adjacentText); + string? name = JsonString(adjacent.RootElement, "name", report, "installed package manager"); + if (name == manager || manager == "yarn" && name == "@yarnpkg/cli-dist") metadata = adjacentMetadata; + } + } + foreach (string root in new[] { directory, Path.GetFullPath(Path.Combine(directory, "..", "lib")), + Path.GetFullPath(Path.Combine(directory, "..")) }.Distinct()) + { + if (metadata != null) break; + foreach (string packageName in manager == "yarn" ? new[] { "yarn", "@yarnpkg/cli-dist" } : new[] { manager }) + { + string candidate = Path.Combine(root, "node_modules", packageName.Replace('/', Path.DirectorySeparatorChar), "package.json"); + if (!File.Exists(candidate) || Inside(candidate, CheckoutRoot(report))) continue; + metadata = candidate; + break; + } + if (metadata != null) break; + } + if (manager == "bun" && (Path.GetExtension(executable).Equals(".exe", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())) + { + ProcessResult? bun = Diagnose(report, "tool.bun", executable, new[] { "--version" }, AppContext.BaseDirectory, safe); + if (bun != null) managerVersion = RecordTool(report, manager, executable, bun.Output.Trim(), managerRequirement, "package.json/lockfile"); + } + else + { + string? launcher = new FileInfo(executable).Length < 65536 ? File.ReadAllText(executable) : null; + if (launcher != null && launcher.Contains("corepack", StringComparison.OrdinalIgnoreCase)) + Unknown(report, "node.manager.dispatch", "The selected package-manager launcher is a Corepack shim.", + "Install the selected manager directly and put it before Corepack on PATH. Fast inspection never provisions managers."); + else if (metadata != null) + { + string? text = ReadMetadata(metadata, report); + if (text != null) + { + using JsonDocument installed = JsonDocument.Parse(text); + string? installedVersion = JsonString(installed.RootElement, "version", report, "installed package manager"); + if (installed.RootElement.TryGetProperty("bin", out JsonElement bin)) + { + string? relative = bin.ValueKind == JsonValueKind.String ? bin.GetString() + : bin.ValueKind == JsonValueKind.Object && bin.TryGetProperty(manager, out JsonElement entry) + && entry.ValueKind == JsonValueKind.String ? entry.GetString() : null; + if (relative != null) + { + string candidate = Path.GetFullPath(relative, Path.GetDirectoryName(metadata)!); + if (Inside(candidate, Path.GetDirectoryName(metadata)!) && !Inside(candidate, CheckoutRoot(report)) + && File.Exists(candidate)) script = candidate; + } + } + if (script != null && node != null && installedVersion != null) + { + string launcherPath = executable.Replace('\\', '/'); + string scriptPath = script.Replace('\\', '/'); + string relativeScript = Path.GetRelativePath(directory, script).Replace('\\', '/'); + string normalizedLauncher = (launcher ?? "").Replace('\\', '/'); + if (!launcherPath.Equals(scriptPath, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) + && !normalizedLauncher.Contains(relativeScript, StringComparison.OrdinalIgnoreCase) + && !normalizedLauncher.Contains("node_modules/" + (manager == "yarn" && metadata.Contains("cli-dist") ? "@yarnpkg/cli-dist" : manager) + "/", StringComparison.OrdinalIgnoreCase)) + Unknown(report, "node.manager.launcher", "The manager launcher could not be tied to the inspected installed package.", + "Use the standard standalone manager launcher without custom dispatch."); + ProcessResult? actual = Diagnose(report, "tool." + manager, node, + new[] { script, "--version" }, AppContext.BaseDirectory, safe); + if (actual != null) + { + string actualVersion = actual.Output.Trim(); + managerVersion = RecordTool(report, manager, executable, actualVersion, managerRequirement, "package.json/lockfile"); + if (actualVersion != installedVersion) + Unknown(report, "node.manager.identity", "Installed manager metadata and the diagnostic version disagree."); + } + } + else Unknown(report, "node.manager.identity", "The installed manager's version entry point could not be resolved without invoking its launcher."); + } + } + else Unknown(report, "node.manager.identity", "The selected manager's installed payload could not be established without running a potentially provisioning launcher.", + "Install a standalone manager with discoverable package metadata and put it on PATH."); + } + } + if (managerRequirement != null) + Range(report, "node.manager.requirement", managerVersion, managerRequirement, "package.json packageManager"); + if (package.RootElement.ValueKind == JsonValueKind.Object && package.RootElement.TryGetProperty("engines", out JsonElement engines)) + { + string? engine = JsonString(engines, "node", report, "package.json engines"); + if (engine != null) Range(report, "node.engines.node", nodeVersion, engine, "package.json engines.node"); + string? selectedManager = JsonString(engines, manager, report, "package.json engines"); + if (selectedManager != null) Range(report, "node.engines.manager", managerVersion, selectedManager, "package.json engines." + manager); + } + foreach (string name in new[] { "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb" }) + { + string path = Path.Combine(detection.WorkDirectory, name); + if (File.Exists(path) && name != "bun.lockb") ReadMetadata(path, report); + } + foreach (string name in new[] { ".npmrc", ".yarnrc", ".yarnrc.yml", "pnpm-workspace.yaml", "bunfig.toml", ".nvmrc", ".node-version", ".tool-versions" }) + { + string? path = Nearest(detection.WorkDirectory, name); + if (path == null) continue; + string? text = ReadMetadata(path, report); + if (text == null) continue; + if (name is ".node-version" or ".nvmrc") + { + string requested = text.Trim().TrimStart('v'); + Range(report, "node.version-file", nodeVersion, requested, name); + } + if (Regex.IsMatch(text, @"(?im)^\s*(yarn[-Pp]ath|yarn-path|use-node-version|node-version|manage-package-manager-versions|shell-emulator|script-shell)\s*[:=]") + || Regex.IsMatch(text, @"(?im)^\s*nodeLinker\s*:\s*['""]?pnp")) + Unknown(report, "node.configuration", name + " can redirect the selected manager, runtime, or script environment.", + "Remove toolchain redirection or expose the effective installed executable directly."); + } + CaptureEnvironment(report, "NODE_OPTIONS", "NODE_PATH", "npm_config_prefix", "npm_config_userconfig", "YARN_RC_FILENAME"); + if (!string.IsNullOrEmpty(report.Environment.GetValueOrDefault("NODE_OPTIONS")) || !string.IsNullOrEmpty(report.Environment.GetValueOrDefault("NODE_PATH"))) + Unknown(report, "node.startup", "Node startup injection is configured and was deliberately disabled during version inspection.", + "Unset NODE_OPTIONS/NODE_PATH so diagnostics and actual execution have the same verified runtime context."); + report.Environment["COREPACK_ENABLE_NETWORK"] = "0"; + report.Environment["COREPACK_ENABLE_DOWNLOAD_PROMPT"] = "0"; + report.Environment["REALDIFF_READINESS_NODE_MANAGER"] = manager; + } + + private static void InspectPython(LanguageDetection detection, ReadinessReport report) + { + string? configured = Environment.GetEnvironmentVariable("REALDIFF_PYTHON"); + var candidates = new List(); + if (!string.IsNullOrWhiteSpace(configured)) candidates.Add(configured); + if (OperatingSystem.IsWindows()) + { + candidates.Add(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "Python", "Python312", "python.exe")); + candidates.Add("python.exe"); + } + else candidates.AddRange(new[] { "python3.12", "python3", "python" }); + string? executable = null; + ProcessResult? result = null; + var safe = new Dictionary { ["PYTHONPATH"] = "", ["PYTHONHOME"] = "", ["PYTHONSTARTUP"] = "", + ["PYTHONINSPECT"] = "", ["PYTHONUSERBASE"] = "" }; + foreach (string candidate in candidates.Distinct(StringComparer.Ordinal)) + { + executable = FindExecutable(candidate, report, preserveLinks: true); + if (executable == null) + { + if (candidate == configured) + { + report.Add("python.selection", "failed", "REALDIFF_PYTHON does not identify an accessible installed interpreter outside checkout scripts.", + "Set REALDIFF_PYTHON to an installed Python executable."); + return; + } + continue; + } + result = Diagnose(report, "tool.python", executable, new[] + { + "-I", "-S", "-c", + "import sys,json,sysconfig; print(json.dumps({'version':'.'.join(map(str,sys.version_info[:3]))," + + "'monitoring':hasattr(sys,'monitoring'),'executable':sys.executable,'paths':sysconfig.get_paths()," + + "'prefix':sys.prefix,'base_prefix':sys.base_prefix}))", + }, AppContext.BaseDirectory, safe); + break; + } + if (executable == null) + { + report.Add("tool.python", "failed", "No installed Python interpreter was found using RealDiff's interpreter preferences.", + "Install Python 3.12+ and set REALDIFF_PYTHON to its executable."); + return; + } + if (result == null) return; + using JsonDocument runtime = JsonDocument.Parse(result.Output); + string? versionText = JsonString(runtime.RootElement, "version", report, "Python runtime"); + Version? version = RecordTool(report, "python", executable, versionText ?? "unrecognized", ">=3.12 with sys.monitoring", "RealDiff Python tracer"); + bool monitoring = runtime.RootElement.TryGetProperty("monitoring", out JsonElement enabled) && enabled.ValueKind == JsonValueKind.True; + report.Add("python.monitoring", version != null && version >= new Version(3, 12, 0) && monitoring ? "passed" : "failed", + monitoring ? "Isolated interpreter exposes sys.monitoring." : "Isolated interpreter does not expose sys.monitoring.", + monitoring ? null : "Select Python 3.12+ with sys.monitoring; sys.settrace is not supported."); + report.Environment["REALDIFF_PYTHON"] = executable; + CaptureEnvironment(report, "VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME", "PYTHONUSERBASE", "PYTHONNOUSERSITE"); + if (!string.IsNullOrEmpty(report.Environment.GetValueOrDefault("PYTHONHOME")) + || !string.IsNullOrEmpty(report.Environment.GetValueOrDefault("PYTHONPATH")) + || !string.IsNullOrEmpty(report.Environment.GetValueOrDefault("PYTHONUSERBASE"))) + Unknown(report, "python.environment", "Python startup/search-path overrides can change the runtime or runner outside isolated diagnostics.", + "Unset PYTHONHOME/PYTHONPATH/PYTHONUSERBASE and select the interpreter explicitly with REALDIFF_PYTHON."); + string? pyproject = ReadMetadata(Path.Combine(detection.WorkDirectory, "pyproject.toml"), report); + bool requirementFound = false; + if (pyproject != null) + { + string? requirement = TomlString(pyproject, "project", "requires-python", report, "pyproject.toml"); + if (requirement != null) + { + Range(report, "python.requires-python", version, requirement, "pyproject.toml requires-python", python: true); + requirementFound = true; + } + string? poetry = TomlString(pyproject, "tool.poetry.dependencies", "python", report, "pyproject.toml"); + if (poetry != null) + { + Range(report, "python.poetry", version, poetry, "Poetry Python requirement"); + requirementFound = true; + } + if (Regex.IsMatch(pyproject, @"(?s)\bdynamic\s*=\s*\[[^\]]*['""]requires-python['""]")) + Unknown(report, "python.dynamic", "Python requires-python is computed dynamically.", + "Declare requires-python as static project metadata; fast inspection never runs setup.py."); + } + string? setup = ReadMetadata(Path.Combine(detection.WorkDirectory, "setup.cfg"), report); + if (setup != null) + { + Match requirement = Regex.Match(setup, @"(?m)^\s*python_requires\s*=\s*(.+)$"); + if (requirement.Success) + { + Range(report, "python.python_requires", version, requirement.Groups[1].Value.Trim(), "setup.cfg python_requires", python: true); + requirementFound = true; + } + } + if (File.Exists(Path.Combine(detection.WorkDirectory, "setup.py")) && !requirementFound) + Unknown(report, "python.dynamic", "setup.py may define the Python requirement dynamically.", + "Declare requires-python in pyproject.toml or python_requires in setup.cfg; fast inspection never executes setup.py."); + string? versionFile = Nearest(detection.WorkDirectory, ".python-version"); + if (versionFile != null) + { + string? value = ReadMetadata(versionFile, report); + if (value != null) Range(report, "python.version-file", version, value.Trim(), ".python-version"); + } + if (detection.TestCommand.Contains("pytest", StringComparison.OrdinalIgnoreCase)) + { + var sites = new HashSet(StringComparer.Ordinal); + if (runtime.RootElement.TryGetProperty("paths", out JsonElement paths)) + { + foreach (string key in new[] { "purelib", "platlib" }) + { + string? site = JsonString(paths, key, report, "Python sysconfig"); + if (site != null) sites.Add(site); + } + } + string parent = Path.GetDirectoryName(executable)!; + string prefix = Path.GetDirectoryName(parent)!; + sites.Add(Path.Combine(parent, "Lib", "site-packages")); + sites.Add(Path.Combine(prefix, "Lib", "site-packages")); + if (version != null) sites.Add(Path.Combine(prefix, "lib", "python" + version.ToString(2), "site-packages")); + string? venvConfig = Nearest(Path.GetDirectoryName(executable)!, "pyvenv.cfg"); + if (venvConfig != null) + { + string? config = ReadMetadata(venvConfig, report); + string venv = Path.GetDirectoryName(venvConfig)!; + if (config != null && !Regex.IsMatch(config, @"(?im)^\s*include-system-site-packages\s*=\s*true")) + sites.RemoveWhere(site => !Inside(site, venv)); + } + string[] metadata = sites.Where(Directory.Exists).SelectMany(site => + Directory.EnumerateDirectories(site, "pytest-*.dist-info").Select(directory => Path.Combine(directory, "METADATA"))) + .Where(File.Exists).Distinct().ToArray(); + if (metadata.Length != 1) + Unknown(report, "python.runner", "An unambiguous installed pytest distribution was not found without importing project/site code.", + "Install pytest into the selected interpreter environment explicitly, or select the supported unittest route."); + else + { + string? text = ReadMetadata(metadata[0], report); + Match runner = Regex.Match(text ?? "", @"(?m)^Version:\s*(\S+)"); + RecordTool(report, "pytest", executable, runner.Success ? runner.Groups[1].Value : "unrecognized", + null, "installed pytest METADATA (not imported)"); + } + } + else report.Add("python.runner", "passed", "Selected the standard-library unittest route; no project code was imported."); + report.Add("python.selection", "passed", "Pinned REALDIFF_PYTHON after isolated (-I -S) runtime inspection."); + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessInspection.cs b/src/RealDiff.Cli/Readiness/ReadinessInspection.cs new file mode 100644 index 0000000..8ccbb32 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessInspection.cs @@ -0,0 +1,320 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace RealDiff.Cli +{ + internal static partial class ReadinessInspection + { + internal static void Inspect(LanguageDetection detection, ReadinessReport report) + { + switch (detection.Language) + { + case RepositoryLanguage.Go: InspectGo(detection, report); break; + case RepositoryLanguage.Rust: InspectRust(detection, report); break; + case RepositoryLanguage.DotNet: InspectDotNet(detection, report); break; + case RepositoryLanguage.Java: InspectJava(detection, report); break; + case RepositoryLanguage.Node: InspectNode(detection, report); break; + case RepositoryLanguage.Python: InspectPython(detection, report); break; + } + Qualify(detection, report); + } + + private static void Unknown(ReadinessReport report, string id, string message, string? fix = null) => + report.Add(id, "unknown", message, fix ?? "Expose a literal, installed toolchain selection and run doctor again."); + + private static string? ReadMetadata(string path, ReadinessReport report, bool fingerprint = true) + { + if (!File.Exists(path)) return null; + if (new FileInfo(path).Length > 1024 * 1024) + { + Unknown(report, "metadata.size", "A toolchain metadata file exceeds the 1 MiB inspection limit.", + "Reduce the metadata size or use a smaller explicit workdir."); + return null; + } + string text = File.ReadAllText(path); + if (!fingerprint) return text; + string relative = (Inside(path, report.Repository) ? Path.GetRelativePath(report.Repository, path) + : "external/" + Path.GetFileName(path)).Replace('\\', '/'); + string previous = report.Environment.GetValueOrDefault("REALDIFF_READINESS_METADATA", string.Empty); + report.Environment["REALDIFF_READINESS_METADATA"] = Convert.ToHexString(SHA256.HashData( + Encoding.UTF8.GetBytes(previous + "\n" + relative + "\n" + text))); + return text; + } + + private static IEnumerable Ancestors(string directory) + { + for (DirectoryInfo? current = new DirectoryInfo(directory); current != null; current = current.Parent) + yield return current.FullName; + } + + private static string? Nearest(string directory, string name) => + Ancestors(directory).Select(parent => Path.Combine(parent, name)).FirstOrDefault(File.Exists); + + private static bool Inside(string path, string root) + { + StringComparison comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return Path.GetFullPath(path).StartsWith(Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar, comparison); + } + + private static string CheckoutRoot(ReadinessReport report) => + Ancestors(report.Repository).FirstOrDefault(directory => + Directory.Exists(Path.Combine(directory, ".git")) || File.Exists(Path.Combine(directory, ".git"))) + ?? report.Repository; + + // Never search the current directory implicitly, or run checkout scripts as tool diagnostics. + private static string? FindExecutable(string name, ReadinessReport report, bool allowScripts = false, bool preserveLinks = false) + { + IEnumerable candidates; + if (Path.IsPathRooted(name)) + candidates = new[] { name }; + else if (name.IndexOfAny(new[] { '/', '\\' }) >= 0) + return null; + else + { + string[] extensions = OperatingSystem.IsWindows() + ? (Path.HasExtension(name) ? new[] { "" } : allowScripts ? new[] { ".exe", ".cmd", ".bat" } : new[] { ".exe" }) + : new[] { "" }; + candidates = (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator) + .Select(part => part.Trim().Trim('"')).Where(Path.IsPathRooted) + .SelectMany(directory => extensions.Select(extension => Path.Combine(directory, name + extension))); + } + foreach (string candidate in candidates) + { + if (!File.Exists(candidate)) continue; + string path = Path.GetFullPath(candidate); + string extension = Path.GetExtension(path); + if (!allowScripts && (extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".bat", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".ps1", StringComparison.OrdinalIgnoreCase))) continue; + FileSystemInfo? target = new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true); + string checkout = CheckoutRoot(report); + if (target != null && Inside(target.FullName, checkout)) continue; + if (Inside(path, checkout) && target == null) continue; + if (target != null && !preserveLinks) path = target.FullName; + return path; + } + return null; + } + + private static ProcessResult? Diagnose(ReadinessReport report, string id, string? executable, + IEnumerable arguments, string directory, IDictionary? environment = null) + { + if (executable == null) + { + report.Add(id, "failed", "The selected installed executable was not found outside checkout scripts.", + "Install the required tool explicitly and put its executable on PATH."); + return null; + } + ProcessResult result = Shell.Diagnose(executable, arguments, directory, environment); + if (!result.Ok) + { + report.Add(id, result.ExitCode == -1 ? "failed" : "unknown", + "Installed-tool inspection did not complete successfully (exit " + result.ExitCode + ").", + "Check the installed executable and its configuration; no install or download was attempted."); + return null; + } + return result; + } + + private static Version? ParseVersion(string text) + { + Match match = Regex.Match(text.Trim(), @"^\d+(?:\.\d+){0,3}$", RegexOptions.CultureInvariant); + if (!match.Success) return null; + string[] parts = text.Trim().Split('.'); + return Version.TryParse(string.Join(".", parts.Concat(Enumerable.Repeat("0", Math.Max(0, 3 - parts.Length)))), + out Version? version) ? version : null; + } + + private static Version? RecordTool(ReadinessReport report, string name, string path, string version, + string? requirement = null, string? source = null) + { + Version? parsed = ParseVersion(version); + report.Tools.Add(new ReadinessTool(name, path, version, requirement, source)); + if (parsed == null) Unknown(report, "tool." + name + ".version", "The selected " + name + " version is not a stable numeric version.", + "Select a released tool version with a readable version identity."); + else report.Add("tool." + name, "passed", "Selected installed " + name + " " + version + "."); + return parsed; + } + + private static void Minimum(ReadinessReport report, string id, Version? installed, string required, string source) + { + Version? minimum = ParseVersion(required); + if (installed == null || minimum == null) + Unknown(report, id, "The version requirement in " + source + " could not be compared."); + else + report.Add(id, installed >= minimum ? "passed" : "failed", source + " requires at least " + required + ".", + installed >= minimum ? null : "Select an installed compiler/runtime satisfying the declared requirement."); + } + + // Deliberately bounded subset shared by engines/requires-python. Unsupported syntax is not a pass. + private static bool? MatchesRange(Version installed, string requirement, bool python = false) + { + string[] alternatives = requirement.Split("||", StringSplitOptions.TrimEntries); + bool unresolved = false; + foreach (string alternative in alternatives) + { + bool match = true; + string[] terms = Regex.Replace(alternative.Trim(), @"(>=|<=|==|!=|>|<|=|\^|~)\s+", "$1") + .Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries); + if (terms.Length == 0) return null; + foreach (string term in terms) + { + if (term is "*" or "x" or "X") continue; + Match part = Regex.Match(term, @"^(>=|<=|==|!=|>|<|=|\^|~)?(\d+(?:\.(?:\d+|[xX*])){0,2})$"); + if (!part.Success) { unresolved = true; match = false; continue; } + string op = part.Groups[1].Value; + string number = part.Groups[2].Value; + string[] components = number.Split('.'); + int wildcard = Array.FindIndex(components, value => value is "x" or "X" or "*"); + if (wildcard >= 0 && components.Skip(wildcard).Any(value => value is not ("x" or "X" or "*"))) + return null; + Version? value = ParseVersion(string.Join(".", wildcard < 0 ? components : components.Take(wildcard))); + if (value == null) return null; + if (wildcard >= 0 || (!python && op is "" or "=" && components.Length < 3)) + { + if (op is not ("" or "=" or "==")) return null; + int specified = wildcard < 0 ? components.Length : wildcard; + match &= installed.Major == value.Major && (specified < 2 || installed.Minor == value.Minor); + } + else + { + if (!python && components.Length < 3 && op is ">" or "<=") + { + Version next = components.Length == 1 ? new Version(value.Major + 1, 0, 0) + : new Version(value.Major, value.Minor + 1, 0); + match &= op == ">" ? installed >= next : installed < next; + continue; + } + match &= op switch + { + ">" => installed > value, ">=" => installed >= value, + "<" => installed < value, "<=" => installed <= value, + "!=" => installed != value, "" or "=" or "==" => installed == value, + "^" when !python => installed >= value && installed < (value.Major > 0 || components.Length == 1 + ? new Version(value.Major + 1, 0, 0) : value.Minor > 0 || components.Length == 2 + ? new Version(0, value.Minor + 1, 0) : new Version(0, 0, value.Build + 1)), + "~" when !python => installed >= value && installed < (components.Length == 1 + ? new Version(value.Major + 1, 0, 0) : new Version(value.Major, value.Minor + 1, 0)), + _ => false, + }; + if (python && op is "^" or "~") return null; + } + } + if (match) return true; + } + return unresolved ? null : false; + } + + private static void Range(ReadinessReport report, string id, Version? version, string requirement, string source, bool python = false) + { + bool? matches = version == null ? null : MatchesRange(version, requirement, python); + report.Add(id, matches == true ? "passed" : matches == false ? "failed" : "unknown", + source + " declares " + requirement + ".", + matches == true ? null : "Select a satisfying installed version; use a literal supported version range if static interpretation is unresolved."); + } + + private static string? JsonString(JsonElement element, string property, ReadinessReport report, string source) + { + if (element.ValueKind != JsonValueKind.Object) + { + Unknown(report, "metadata.shape", source + " must be an object."); + return null; + } + if (!element.TryGetProperty(property, out JsonElement value)) return null; + if (value.ValueKind == JsonValueKind.String) return value.GetString(); + Unknown(report, "metadata." + property, source + " has a nonliteral " + property + " requirement."); + return null; + } + + private static string? TomlString(string text, string section, string key, ReadinessReport report, string source) + { + string current = ""; + string? result = null; + foreach (string line in text.Split('\n')) + { + Match header = Regex.Match(line, @"^\s*\[([^\[\]]+)\]\s*(?:#.*)?$"); + if (header.Success) { current = header.Groups[1].Value.Trim(); continue; } + if (current != section) continue; + string keyPattern = "(?:" + Regex.Escape(key) + "|\"" + Regex.Escape(key) + "\"|'" + Regex.Escape(key) + "')"; + Match declaration = Regex.Match(line, @"^\s*" + keyPattern + @"\s*=\s*(.*)$"); + if (!declaration.Success) continue; + Match literal = Regex.Match(declaration.Groups[1].Value, "^[\"']([^\"']*)[\"']\\s*(?:#.*)?$"); + if (!literal.Success || result != null) + Unknown(report, "metadata." + key, source + " has a dynamic, duplicate, or unsupported " + key + " declaration."); + else result = literal.Groups[1].Value; + } + return result; + } + + private static void CaptureEnvironment(ReadinessReport report, params string[] names) + { + foreach (string name in names) + { + string? value = Environment.GetEnvironmentVariable(name); + if (value != null) report.Environment[name] = value; + } + } + + private static void Qualify(LanguageDetection detection, ReadinessReport report) + { + string name = detection.Language switch + { + RepositoryLanguage.DotNet => "dotnet", RepositoryLanguage.Java => "java", + RepositoryLanguage.Node => "node", RepositoryLanguage.Go => "go", + RepositoryLanguage.Rust => "rustc", _ => "python", + }; + ReadinessTool? tool = report.Tools.FirstOrDefault(candidate => candidate.Name == name); + Version? version = tool == null ? null : ParseVersion(tool.Version); + bool qualified = OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.X64 + && version != null && (detection.Language switch + { + RepositoryLanguage.DotNet => version.Major == 8, + RepositoryLanguage.Java => version.Major == 17, + RepositoryLanguage.Node => version.Major == 24, + RepositoryLanguage.Go => version.Major == 1 && version.Minor == 27, + RepositoryLanguage.Rust => version == new Version(1, 98, 0), + RepositoryLanguage.Python => version.Major == 3 && version.Minor == 12, + _ => false, + }); + if (qualified && detection.Language == RepositoryLanguage.Node) + { + string manager = report.Environment.GetValueOrDefault("REALDIFF_READINESS_NODE_MANAGER", ""); + string? managerVersion = report.Tools.FirstOrDefault(candidate => candidate.Name == manager)?.Version; + qualified = manager switch + { + "npm" => managerVersion != null && ParseVersion(managerVersion)?.Major == 11, + "pnpm" => managerVersion == "10.17.1", + "yarn" => managerVersion is "1.22.22" or "4.10.3", + "bun" => managerVersion == "1.2.22", + _ => false, + }; + } + if (qualified && detection.Language == RepositoryLanguage.Java) + qualified = report.Tools.Any(candidate => candidate.Name == "gradle" && candidate.Version == "8.10.2"); + if (qualified && detection.Language == RepositoryLanguage.Rust) + qualified = report.Tools.Any(candidate => candidate.Name == "cargo" && candidate.Version == "1.98.0"); + if (qualified && detection.Language == RepositoryLanguage.Python + && detection.TestCommand.Contains("pytest", StringComparison.OrdinalIgnoreCase)) + qualified = report.Tools.Any(candidate => candidate.Name == "pytest" && candidate.Version == "8.4.2"); + if (qualified && detection.Language == RepositoryLanguage.DotNet) + qualified = report.Environment.GetValueOrDefault("REALDIFF_READINESS_DOTNET_TFMS", "") + .Split(';').All(tfm => tfm is "net8.0" or "netstandard2.0"); + report.Add("instrumentation.compatibility", qualified ? "passed" : "unknown", + qualified + ? "Within the Windows x64 CI conformance toolchain family (.github/workflows/ci.yml); evidence is limited to its bundled language fixtures, not every project or future major/minor toolchain." + : "This toolchain/platform combination has no matching repository CI conformance qualification.", + qualified ? null : "Run doctor --probe to establish the bundled fixture capability with this installed toolchain.", + evidence: "qualification"); + if (detection.Language == RepositoryLanguage.Go) + report.Limitations.Add("Go inspection and fixture evidence do not establish complete package type information or support for every source shape."); + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessProbe.cs b/src/RealDiff.Cli/Readiness/ReadinessProbe.cs new file mode 100644 index 0000000..99e50ae --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessProbe.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace RealDiff.Cli +{ + internal static partial class ReadinessProbe + { + internal static void Run(LanguageDetection detection, ReadinessReport report) + { + Console.Error.WriteLine("Readiness probe: executing bundled fixture code and installed tooling in an owned directory. This is not a security sandbox; no packages or toolchains will be downloaded."); + string work = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "RealDiff", "readiness-probes", Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(work); + var context = new ProbeContext(detection, report, work); + string expectedFile; + string expectedMethod; + switch (detection.Language) + { + case RepositoryLanguage.DotNet: + ProbeDotNet(context); + expectedFile = "ProbeSubject.cs"; + expectedMethod = "ProbeEcho"; + break; + case RepositoryLanguage.Java: + ProbeJava(context); + expectedFile = "ProbeSubject.java"; + expectedMethod = "probeEcho"; + break; + case RepositoryLanguage.Node: + expectedFile = ProbeNode(context); + expectedMethod = "probeEcho"; + break; + case RepositoryLanguage.Go: + ProbeGo(context); + expectedFile = "probe.go"; + expectedMethod = "ProbeEcho"; + break; + case RepositoryLanguage.Rust: + ProbeRust(context); + expectedFile = "lib.rs"; + expectedMethod = "probe_echo"; + break; + case RepositoryLanguage.Python: + ProbePython(context); + expectedFile = "probe_subject.py"; + expectedMethod = "probe_echo"; + break; + default: + throw new ProbeBlockedException("No bundled probe exists for this language."); + } + ValidateFixture(context.Output, expectedMethod, expectedFile); + report.Checks.RemoveAll(check => check.Id == "instrumentation.compatibility"); + report.Add("instrumentation.compatibility", "passed", + "The selected production instrumenter emitted the expected fixture calls, arguments, returns, correlated test root, source mapping and reconciled manifest.", + evidence: "probe"); + report.Add("probe.fixture", "passed", "Bundled fixture completed with valid nonempty trace and manifest.", evidence: "probe"); + } + catch (ProbeBlockedException ex) + { + report.Add("probe.fixture", "unknown", ex.Message, + "Install or prepopulate the required tooling/dependency cache explicitly, or use a supported verifiable route, then rerun --probe.", evidence: "probe"); + } + catch (ProbeFailureException ex) + { + report.Add("probe." + ex.Stage, "failed", ex.Message, + "Check the selected runtime and packaged tracer. Probe build/restore stages use local caches only.", evidence: "probe"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or System.Xml.XmlException or CliException) + { + report.Add("probe.fixture", "failed", "Fixture probe could not complete: " + ex.Message, + "Check fixture payload integrity, tooling and writable probe storage.", evidence: "probe"); + } + finally + { + if (Directory.Exists(work)) + { + try { Directory.Delete(work, recursive: true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + report.Add("probe.cleanup", "unknown", "Owned probe files could not be removed: " + work, + "Remove the owned directory after any locked tooling exits.", required: false, evidence: "probe"); + } + } + } + } + + private static void ProbeGo(ProbeContext context) + { + string source = context.Stage("go"); + string rewritten = Path.Combine(context.Work, "rewritten"); + context.Environment["GOPROXY"] = "off"; + context.Environment["GOSUMDB"] = "off"; + context.Environment["GOTOOLCHAIN"] = "local"; + context.Environment["GOWORK"] = "off"; + context.Environment["GOFLAGS"] = string.Empty; + context.Execute("rewrite", context.Asset("go"), new[] { "--source", source, "--out", rewritten }, source); + context.Execute("test", context.Tool("go"), new[] { "test", "-count=1", "./..." }, rewritten); + } + + private static void ProbeRust(ProbeContext context) + { + string source = context.Stage("rust"); + string cache = Path.Combine(context.Work, "rewritten"); + context.Environment["CARGO_NET_OFFLINE"] = "true"; + context.Environment["CARGO_TARGET_DIR"] = Path.Combine(context.Work, "target"); + context.Environment["REALDIFF_RUST_EXIT_TRACE"] = Path.Combine(context.Output, "run.rust.ndjson"); + string compiler = context.Tool("rustc"); + context.Environment["RUSTC"] = compiler; + ProcessResult rewrite = context.Execute("rewrite", context.Asset("rust"), + new[] { "--source", source, "--cache-root", cache }, source); + using JsonDocument document = JsonDocument.Parse(rewrite.Output.Trim()); + if (document.RootElement.ValueKind != JsonValueKind.Object + || !document.RootElement.TryGetProperty("output", out JsonElement output) || output.ValueKind != JsonValueKind.String) + throw new ProbeFailureException("rewrite", "Rust rewrite result has no output path."); + string rewritten = output.GetString()!; + string full = Path.GetFullPath(rewritten); + if (!full.StartsWith(Path.GetFullPath(cache) + Path.DirectorySeparatorChar, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + throw new ProbeFailureException("rewrite", "Rust rewriter output escaped the owned cache directory."); + context.Execute("test", context.Tool("cargo"), + new[] { "test", "--offline", "--quiet", "--manifest-path", Path.Combine(full, "Cargo.toml"), "--", "--test-threads=1" }, full, + missingCacheBlocks: true); + context.Execute("finalize", context.Asset("rust"), + new[] { "finalize", "--origin", Path.Combine(full, ".realdiff-rust-origin.json"), + "--trace", context.Environment["REALDIFF_RUST_EXIT_TRACE"], + "--out", Path.Combine(context.Output, "run.rust.manifest.ndjson") }, full); + } + + private static void ProbePython(ProbeContext context) + { + string command = context.Detection.TestCommand; + bool unittest = Regex.IsMatch(command, @"(?:^|\s)-m\s+unittest(?:\s|$)"); + bool pytest = Regex.IsMatch(command, @"(?:^|\s)(?:pytest|py\.test)(?:\s|$)"); + if (context.Detection.HasCustomTest && !unittest && !pytest) + throw new ProbeBlockedException("The configured Python runner is opaque; no equivalent safe bundled runner route is established."); + string source = context.Stage("python"); + context.Environment["PYTHONPATH"] = context.Asset("python") + Path.PathSeparator + source; + context.Environment["PYTHONNOUSERSITE"] = "1"; + context.Environment["PYTHONDONTWRITEBYTECODE"] = "1"; + context.Environment["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1"; + context.Environment["PYTEST_ADDOPTS"] = string.Empty; + context.Environment["REALDIFF_INCLUDE_NAMESPACES"] = "probe_subject.py,test_probe.py"; + context.Environment["REALDIFF_TRACE"] = Path.Combine(context.Output, "run.python.ndjson"); + context.Environment["REALDIFF_REPOSITORY_ROOT"] = source; + context.Execute("test", context.Tool("python"), unittest + ? new[] { "-m", "unittest", "test_probe" } + : new[] { "-m", "pytest", "-p", "realdiff_python.pytest_plugin", "-q", "test_probe.py" }, source, + missingCacheBlocks: true); + } + + private sealed class ProbeContext + { + internal LanguageDetection Detection { get; } + internal ReadinessReport Report { get; } + internal string Work { get; } + internal string Output { get; } + internal Dictionary Environment { get; } + + internal ProbeContext(LanguageDetection detection, ReadinessReport report, string work) + { + Detection = detection; + Report = report; + Work = work; + Output = Path.Combine(work, "trace"); + Directory.CreateDirectory(Output); + Environment = new Dictionary(report.Environment, StringComparer.OrdinalIgnoreCase); + foreach (string name in System.Environment.GetEnvironmentVariables().Keys) + if (Regex.IsMatch(name, @"(?i)TOKEN|PASSWORD|SECRET|CREDENTIAL|ACCESS_?KEY|PRIVATE_?KEY")) + Environment[name] = string.Empty; + // Do not inherit tracer hooks, build overrides or secret-bearing diagnostic verbosity. + foreach (string name in new[] { "NODE_OPTIONS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS", "_JAVA_OPTIONS", + "MAVEN_OPTS", "MAVEN_ARGS", "GRADLE_OPTS", "PYTHONPATH", "PYTHONSTARTUP", + "VSS_NUGET_EXTERNAL_FEED_ENDPOINTS", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "REALDIFF_EXCLUDE_NAMESPACES", "REALDIFF_INCLUDE_NAMESPACES", "REALDIFF_NAMESPACES", + "REALDIFF_REDACT_NAMES", "REALDIFF_REDACT_TYPES", "REALDIFF_REDACT_PATHS" }) + Environment[name] = string.Empty; + Environment["REALDIFF_TRACE"] = Path.Combine(Output, "run.ndjson"); + Environment["REALDIFF_REPOSITORY_ROOT"] = Path.Combine(work, "source"); + Environment["CI"] = "true"; + Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + Environment["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "true"; + Environment["MSBuildEnableWorkloadResolver"] = "false"; + string[] toolDirectories = report.Tools.Select(tool => Path.GetDirectoryName(tool.Executable)) + .Where(path => !string.IsNullOrEmpty(path)).Cast().Distinct().ToArray(); + Environment["PATH"] = string.Join(Path.PathSeparator, toolDirectories) + + Path.PathSeparator + (System.Environment.GetEnvironmentVariable("PATH") ?? string.Empty); + } + + internal string Tool(string name) + { + ReadinessTool? tool = Report.Tools.FirstOrDefault(tool => tool.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (tool is null || !Path.IsPathRooted(tool.Executable) || !File.Exists(tool.Executable)) + throw new ProbeBlockedException("No verified installed " + name + " executable is available for the fixture."); + return tool.Executable; + } + + internal string Asset(string name) => Report.Assets.TryGetValue(name, out string? asset) + ? asset : throw new ProbeBlockedException("The packaged " + name + " instrumentation payload is unavailable."); + + internal string Stage(string name, string? destination = null) + { + string fixtures = Path.Combine(AppContext.BaseDirectory, "readiness-fixtures", name); + if (!Directory.Exists(fixtures)) + throw new ProbeBlockedException("Bundled fixture payload is missing: " + name + "."); + string source = destination ?? Path.Combine(Work, "source"); + Directory.CreateDirectory(source); + foreach (string file in Directory.EnumerateFiles(fixtures, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(source, Path.GetRelativePath(fixtures, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, overwrite: true); + } + return source; + } + + internal ProcessResult Execute(string stage, string executable, IEnumerable arguments, + string directory, bool missingCacheBlocks = false) + { + string[] args = arguments.ToArray(); + ProcessResult result; + if (OperatingSystem.IsWindows() && Path.GetExtension(executable).ToLowerInvariant() is ".cmd" or ".bat") + { + if (new[] { executable }.Concat(args).Any(value => value.IndexOfAny(new[] { '"', '\r', '\n', '%', '!' }) >= 0)) + throw new ProbeBlockedException("A selected script path or argument cannot be safely represented for Windows command invocation."); + result = Shell.DiagnoseWindowsScript(executable, args, directory, Environment, timeoutSeconds: 90); + } + else + { + result = Shell.Diagnose(executable, args, directory, Environment, timeoutSeconds: 90); + } + if (!result.Ok) + { + // Do not copy tool output into the report: installed tooling can print credentials. + string message = "Bundled fixture " + stage + " failed (exit " + result.ExitCode + ")."; + if (missingCacheBlocks && result.ExitCode > 0 && IsMissingProbeDependency(result.Output)) + throw new ProbeBlockedException(message + " Required local dependency/runner cache is unavailable; no network fallback was attempted."); + throw new ProbeFailureException(stage, message + " Tool output is intentionally omitted because it may contain credentials."); + } + Report.Add("probe." + stage, "passed", "Bundled fixture " + stage + " exited successfully.", required: false, evidence: "probe"); + return result; + } + } + + internal static bool IsMissingProbeDependency(string output) => Regex.IsMatch(output, + @"\berror\s+NU(?:1100|1101|1102|1301)\b" + + @"|^\s*error:\s+no matching package named\b" + + @"|attempting to make an HTTP request,\s*but --offline was specified" + + @"|\bcannot access[^\r\n]*\bin offline mode\b" + + @"|^\s*(?:>\s*)?No cached version of[^\r\n]*available for offline mode\b" + + @"|^\s*npm (?:ERR!|error) code ENOTCACHED\b" + + @"|\bERR_PNPM_NO_OFFLINE_(?:TARBALL|META)\b" + + @"|\bYN0056:[^\r\n]*Cache entry required but missing\b" + + @"|No module named ['""]?pytest['""]?(?:\r?$|\s*$)" + + @"|Cannot find module ['""](?:@babel/(?:parser|traverse|generator|types)|@jridgewell/trace-mapping)['""]", + RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant); + + private sealed class ProbeBlockedException : Exception + { + internal ProbeBlockedException(string message) : base(message) { } + } + + private sealed class ProbeFailureException : Exception + { + internal string Stage { get; } + internal ProbeFailureException(string stage, string message) : base(message) { Stage = stage; } + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessProbeManaged.cs b/src/RealDiff.Cli/Readiness/ReadinessProbeManaged.cs new file mode 100644 index 0000000..efd6886 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessProbeManaged.cs @@ -0,0 +1,197 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace RealDiff.Cli +{ + internal static partial class ReadinessProbe + { + private static void ProbeDotNet(ProbeContext context) + { + string dotnet = context.Tool("dotnet"); + string source = context.Stage("dotnet"); + string kit = context.Asset("dotnet-kit"); + foreach (string file in new[] { "realdiff-weaver.dll", "RealDiff.Tracer.dll", + "RealDiff.Contracts.dll" }) + if (!File.Exists(Path.Combine(kit, file))) + throw new ProbeBlockedException("The .NET fixture requires the packaged " + file + "."); + string sdk = context.Report.Tools.First(tool => tool.Name == "dotnet").Version; + Match version = Regex.Match(sdk, @"(? Path.GetFullPath(path, + context.Detection.Config.RepositoryRoot)).ToArray(); + if (projects.Length == 0 && context.Detection.EntryPoint.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + projects = new[] { context.Detection.EntryPoint }; + var targets = new System.Collections.Generic.List(); + foreach (string targetProject in projects) + { + using var reader = XmlReader.Create(targetProject, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + XDocument metadata = XDocument.Load(reader); + targets.AddRange(metadata.Descendants().Where(element => element.Name.LocalName is "TargetFramework" or "TargetFrameworks") + .SelectMany(element => element.Value.Split(';')).Where(value => Regex.IsMatch(value, @"^net(?:[5-9]|[1-9]\d+)\.0$"))); + } + string? tfm = targets.OrderByDescending(value => int.Parse(value[3..^2], System.Globalization.CultureInfo.InvariantCulture)).FirstOrDefault(); + if (tfm == null) + throw new ProbeBlockedException("A literal supported .NET test target framework could not be selected for the bundled fixture."); + string project = Path.Combine(source, "Probe.csproj"); + string empty = Path.Combine(context.Work, "empty-nuget-source"); + Directory.CreateDirectory(empty); + string config = Path.Combine(context.Work, "NuGet.Config"); + File.WriteAllText(config, ""); + string[] properties = { "-p:TargetFramework=" + tfm, "-p:ProbeKit=" + kit, + "-p:NuGetAudit=false", "-p:ImportDirectoryBuildProps=false", "-p:ImportDirectoryBuildTargets=false", + "-p:ManagePackageVersionsCentrally=false" }; + context.Execute("restore", dotnet, new[] { "restore", project, "--configfile", config, + "--source", empty, "--verbosity", "quiet", "-p:RestoreIgnoreFailedSources=false" }.Concat(properties), + source, missingCacheBlocks: true); + context.Execute("build", dotnet, new[] { "build", project, "--no-restore", "-c", "Release", + "--nologo", "--verbosity", "quiet" }.Concat(properties), source); + string output = Path.Combine(source, "bin", "Release", tfm); + string assembly = Path.Combine(output, "Probe.dll"); + foreach (string target in new[] { Path.Combine(output, "Subject.dll"), assembly }) + { + string[] weave = { Path.Combine(kit, "realdiff-weaver.dll"), "--assembly", target, "--include", "ReadinessFixture" }; + context.Execute(target == assembly ? "weave.tests" : "weave.subject", dotnet, + target == assembly ? weave.Append("--test-assembly") : weave, output); + if (!File.Exists(target + ".woven")) + throw new ProbeFailureException("weave", "The production weaver did not produce the expected woven assembly."); + File.Move(target + ".woven", target, overwrite: true); + } + context.Environment["REALDIFF_NAMESPACES"] = "ReadinessFixture"; + context.Environment["REALDIFF_BACKEND"] = "cecil"; + context.Environment["REALDIFF_CORRELATION"] = "woven"; + context.Environment["REALDIFF_REPOSITORY_ROOT"] = source; + context.Execute("test", dotnet, new[] { "test", assembly, "--nologo" }, output); + } + + private static void ProbeJava(ProbeContext context) + { + bool gradle = Path.GetFileName(context.Detection.EntryPoint) is "build.gradle" or "build.gradle.kts" + or "settings.gradle" or "settings.gradle.kts"; + string manager = context.Tool(gradle ? "gradle" : "mvn"); + string name = Path.GetFileNameWithoutExtension(manager); + if (name is "gradlew" or "mvnw") + throw new ProbeBlockedException("Repository Java wrappers are not executed by the fixture: their offline flags do not prevent downloading an uncached wrapper distribution."); + string source = context.Stage("java"); + string agent = context.Asset("java"); + context.Environment["REALDIFF_REPOSITORY_ROOT"] = source; + context.Environment["REALDIFF_JAVA_SOURCE_ROOTS"] = "src/main/java;src/test/java"; + context.Environment["REALDIFF_NAMESPACES"] = "io.realdiff.probe"; + context.Environment["REALDIFF_PROBE_JAVA_AGENT"] = agent; + if (gradle) + { + context.Execute("test", manager, new[] { "--offline", "--no-daemon", "--no-watch-fs", + "--console=plain", "--rerun-tasks", "-Dorg.gradle.java.installations.auto-download=false", "test" }, + source, missingCacheBlocks: true); + } + else + { + context.Execute("test", manager, new[] { "--offline", "--batch-mode", "--no-transfer-progress", + "-f", Path.Combine(source, "pom.xml"), "test" }, + source, missingCacheBlocks: true); + } + } + + private static string ProbeNode(ProbeContext context) + { + string node = context.Tool("node"); + if (!File.Exists(context.Detection.EntryPoint)) + throw new ProbeBlockedException("A package.json is required to select the Node fixture route."); + using JsonDocument package = JsonDocument.Parse(File.ReadAllText(context.Detection.EntryPoint)); + JsonElement root = package.RootElement; + string script = root.TryGetProperty("scripts", out JsonElement scripts) + && scripts.TryGetProperty("test", out JsonElement test) ? test.GetString() ?? "" : ""; + if (context.Detection.HasCustomTest) + throw new ProbeBlockedException("Custom Node runner commands are opaque; the bundled fixture cannot establish their loader and runner route."); + string? fixtureCommand = GetNodeProbeCommand(script); + if (fixtureCommand == null) + throw new ProbeBlockedException("The bundled Node probe qualifies a direct Node script or node --test with supported literal options, not framework launchers, injected loaders or arbitrary shell commands."); + bool esm = root.TryGetProperty("type", out JsonElement type) && type.GetString() == "module"; + string source = context.Stage(esm ? "node-esm" : "node-cjs"); + if (fixtureCommand.EndsWith("probe.run.cjs", StringComparison.Ordinal)) + context.Stage("node-direct", source); + File.WriteAllText(Path.Combine(source, "package.json"), JsonSerializer.Serialize(new + { + name = "realdiff-readiness-probe", + @private = true, + type = esm ? "module" : "commonjs", + scripts = new { test = fixtureCommand }, + })); + string tracer = context.Asset("node"); + bool typescript = File.Exists(Path.Combine(context.Detection.WorkDirectory, "tsconfig.json")); + if (typescript) + { + string tsc = Path.Combine(context.Detection.WorkDirectory, "node_modules", "typescript", "bin", "tsc"); + if (!File.Exists(tsc)) + throw new ProbeBlockedException("The selected TypeScript route has no locally installed compiler; the probe will not install one."); + context.Stage("node-ts", source); + ProcessResult version = context.Execute("typescript-version", node, new[] { tsc, "--version" }, source); + context.Report.Tools.Add(new ReadinessTool("tsc", tsc, version.Output.Trim())); + context.Execute("build", node, new[] { tsc, Path.Combine(source, "probe.ts"), "--target", "ES2020", + "--module", esm ? "ES2020" : "CommonJS", "--sourceMap", "--inlineSources", "--skipLibCheck" }, source); + } + context.Environment["REALDIFF_NODE_ROOT"] = tracer; + context.Environment["REALDIFF_REPOSITORY_ROOT"] = source; + context.Environment["REALDIFF_NAMESPACES"] = "probe.js"; + context.Environment["NODE_OPTIONS"] = "--require \"" + Path.Combine(tracer, "register.cjs").Replace('\\', '/') + + "\" --loader \"" + new Uri(Path.Combine(tracer, "loader.mjs")).AbsoluteUri + "\""; + context.Environment["npm_config_offline"] = "true"; + context.Environment["npm_config_audit"] = "false"; + context.Environment["npm_config_fund"] = "false"; + context.Environment["COREPACK_ENABLE_NETWORK"] = "0"; + context.Environment["YARN_ENABLE_NETWORK"] = "0"; + string manager = NodePackageManagers.Detect(context.Detection.WorkDirectory); + context.Execute("test", context.Tool(manager), new[] { "run", "test" }, source, missingCacheBlocks: true); + return typescript ? "probe.ts" : "probe.js"; + } + + internal static string? GetNodeProbeCommand(string script) + { + if (script.IndexOfAny(new[] { '\r', '\n' }) >= 0) return null; + string[] tokens = Regex.Split(script.Trim(), @"\s+"); + if (tokens.Length < 2 || tokens[0] != "node") return null; + var options = new System.Collections.Generic.List(); + bool test = false; + bool testOptions = false; + int files = 0; + bool glob = false; + foreach (string token in tokens.Skip(1)) + { + if (token == "--test") + { + if (test || files != 0) return null; + test = true; + options.Add(token); + } + else if (token is "--enable-source-maps" or "--no-warnings" + || Regex.IsMatch(token, @"^--test-(?:concurrency|timeout)=[1-9]\d*$") + || Regex.IsMatch(token, @"^--test-reporter=(?:spec|tap|dot)$")) + { + if (files != 0) return null; + testOptions |= token.StartsWith("--test-", StringComparison.Ordinal); + options.Add(token); + } + else if (Regex.IsMatch(token, @"^(?:[A-Za-z0-9_-]+[/\\])*[A-Za-z0-9_.*?-]+\.(?:cjs|mjs|js)$") + && !token.Split('/', '\\').Contains("node_modules", StringComparer.OrdinalIgnoreCase) + && !token.StartsWith("-", StringComparison.Ordinal)) + { + files++; + glob |= token.Contains('*') || token.Contains('?'); + } + else return null; + } + if (!test && (files != 1 || glob || testOptions)) return null; + return string.Join(" ", new[] { "node" }.Concat(options) + .Append(test ? "probe.test.cjs" : "probe.run.cjs")); + } + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessProbeValidation.cs b/src/RealDiff.Cli/Readiness/ReadinessProbeValidation.cs new file mode 100644 index 0000000..27b2637 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessProbeValidation.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace RealDiff.Cli +{ + internal static partial class ReadinessProbe + { + internal static void ValidateFixture(string directory, string method, string expectedFile) + { + if (Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Any(file => Path.GetFileName(file).Contains("failed", StringComparison.OrdinalIgnoreCase))) + throw new ProbeFailureException("trace", "The tracer emitted a failed marker."); + string[] traceFiles = Directory.GetFiles(directory, "run.*.ndjson") + .Where(path => !path.Contains(".manifest.", StringComparison.Ordinal)).ToArray(); + string[] manifestFiles = Directory.GetFiles(directory, "run.*.manifest.ndjson"); + if (traceFiles.Length == 0 || traceFiles.Length != manifestFiles.Length) + throw new ProbeFailureException("trace", "Every fixture trace must have exactly one matching manifest."); + var events = new List(); + var manifest = new List(); + var subjects = new List<(JsonElement Event, List Events, JsonElement[] Members)>(); + foreach (string trace in traceFiles) + { + string paired = trace[..^".ndjson".Length] + ".manifest.ndjson"; + if (!File.Exists(paired)) + throw new ProbeFailureException("manifest", "A fixture trace has no matching manifest."); + List processEvents = ReadRecords(new[] { trace }); + List processManifest = ReadRecords(new[] { paired }); + JsonElement[] processWriters = processManifest.Where(record => Text(record, "kind") == "writer").ToArray(); + if (processWriters.Length != 1 || Number(processWriters[0], "written") != processEvents.Count + || !processManifest.Any(record => Text(record, "kind") == "run" && Text(record, "schema") == "realdiff.trace/1")) + throw new ProbeFailureException("manifest", "A fixture trace/manifest pair has invalid schema or writer counts."); + events.AddRange(processEvents); + manifest.AddRange(processManifest); + JsonElement[] processMembers = processManifest.Where(record => Text(record, "kind") == "member").ToArray(); + subjects.AddRange(processEvents.Where(record => Text(record, "methodFullName").Contains(method, StringComparison.Ordinal)) + .Select(subject => (subject, processEvents, processMembers))); + } + if (events.Count == 0 || manifest.Count == 0) + throw new ProbeFailureException("trace", "The fixture produced no nonempty trace/manifest pair."); + if (events.Any(record => Text(record, "testId").Length == 0 || Text(record, "methodFullName").Length == 0)) + throw new ProbeFailureException("trace", "A fixture event has no test identity or method."); + if (subjects.Count != 2) + throw new ProbeFailureException("trace", "Expected exactly two deterministic subject calls, observed " + subjects.Count + "."); + foreach ((JsonElement subject, List processEvents, JsonElement[] members) in subjects) + { + string id = Text(subject, "testId"); + string fullName = Text(subject, "methodFullName"); + string source = Text(subject, "filePath").Replace('\\', '/'); + string resolution = Text(subject, "filePathResolution"); + if (string.IsNullOrWhiteSpace(id) || id.Contains("no-test", StringComparison.OrdinalIgnoreCase) + || True(subject, "isHarness") + || !Text(subject, "argsRendered").Contains("probe-input", StringComparison.Ordinal) + || !Text(subject, "returnRendered").Contains("probe-input-return", StringComparison.Ordinal) + || Text(subject, "argsDigest").Length == 0 || Text(subject, "returnDigest").Length == 0 + || !(source == expectedFile || source.EndsWith("/" + expectedFile, StringComparison.Ordinal)) + || resolution is not ("debugInfo" or "sequencePoints" or "stateMachine" or "declaringType") + || Number(subject, "line") <= 0 || Text(subject, "exceptionType").Length != 0) + throw new ProbeFailureException("trace", "Fixture argument/return capture, test identity or exact source attribution is invalid."); + if (!members.Any(member => Text(member, "method") == fullName + && Text(member, "status") is "Patched" or "Woven")) + throw new ProbeFailureException("manifest", "The subject event has no successfully instrumented manifest member."); + JsonElement[] roots = processEvents.Where(root => Text(root, "testId") == id + && members.Any(member => Text(member, "method") == Text(root, "methodFullName") + && True(member, "isTestRoot"))).ToArray(); + if (roots.Length == 0 || !HasRootAncestor(subject, roots, processEvents)) + throw new ProbeFailureException("trace", "The fixture subject call is not correlated to an emitted test-root call."); + } + if (Text(subjects[0].Event, "argsDigest") != Text(subjects[1].Event, "argsDigest") + || Text(subjects[0].Event, "returnDigest") != Text(subjects[1].Event, "returnDigest")) + throw new ProbeFailureException("trace", "Identical fixture calls produced inconsistent argument or return digests."); + JsonElement[] writers = manifest.Where(record => Text(record, "kind") == "writer").ToArray(); + if (writers.Length == 0 || writers.Any(writer => Number(writer, "dropped") != 0 + || Number(writer, "enqueued") != Number(writer, "written") || Number(writer, "capacity") <= 0) + || writers.Sum(writer => Number(writer, "written")) != events.Count) + throw new ProbeFailureException("manifest", "Fixture writer counters do not reconcile with emitted events."); + JsonElement[] assemblies = manifest.Where(record => Text(record, "kind") == "assembly").ToArray(); + if (assemblies.Length == 0 || assemblies.Any(assembly => Number(assembly, "patchFailedMembers") != 0 + || Number(assembly, "discoveredMembers") != Number(assembly, "patchedMembers") + Number(assembly, "skippedMembers"))) + throw new ProbeFailureException("manifest", "Fixture instrumentation coverage counters are inconsistent or contain patch failures."); + if (manifest.Any(record => Text(record, "status") == "PatchFailed" + || NumberIfPresent(record, "lostFrames") != 0 || NumberIfPresent(record, "pendingAssemblies") != 0)) + throw new ProbeFailureException("manifest", "Fixture manifest reports an instrumentation invariant failure."); + } + + private static bool HasRootAncestor(JsonElement subject, JsonElement[] roots, List events) + { + var visited = new HashSet(StringComparer.Ordinal); + JsonElement current = subject; + while (current.TryGetProperty("parentCallId", out JsonElement parent) && parent.ValueKind != JsonValueKind.Null) + { + string id = parent.ToString(); + if (!visited.Add(id)) return false; + if (roots.Any(root => Scalar(root, "callId") == id)) return true; + JsonElement[] candidates = events.Where(record => Scalar(record, "callId") == id + && Text(record, "testId") == Text(subject, "testId") + && Scalar(record, "threadId") == Scalar(subject, "threadId")).ToArray(); + if (candidates.Length != 1) return false; + current = candidates[0]; + } + return false; + } + + private static List ReadRecords(IEnumerable files) + { + var result = new List(); + foreach (string file in files) + { + if (new FileInfo(file).Length > 8 * 1024 * 1024) + throw new ProbeFailureException("trace", "Fixture output exceeds its safety limit."); + foreach (string line in File.ReadLines(file).Where(line => !string.IsNullOrWhiteSpace(line))) + { + using JsonDocument document = JsonDocument.Parse(line); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new ProbeFailureException("trace", "Fixture NDJSON contains a non-object record."); + result.Add(document.RootElement.Clone()); + } + } + return result; + } + + private static string Text(JsonElement record, string name) => + record.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty : string.Empty; + + private static string Scalar(JsonElement record, string name) => + record.TryGetProperty(name, out JsonElement value) ? value.ToString() : string.Empty; + + private static bool True(JsonElement record, string name) => + record.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True; + + private static long Number(JsonElement record, string name) => + record.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out long number) + ? number : throw new ProbeFailureException("manifest", "Fixture output is missing numeric invariant " + name + "."); + + private static long NumberIfPresent(JsonElement record, string name) => + record.TryGetProperty(name, out _) ? Number(record, name) : 0; + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessReport.cs b/src/RealDiff.Cli/Readiness/ReadinessReport.cs new file mode 100644 index 0000000..c77f578 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessReport.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace RealDiff.Cli +{ + internal sealed record ReadinessCheck( + string Id, + string Status, + string Message, + string? Remediation = null, + bool Required = true, + string Evidence = "inspection"); + + internal sealed record ReadinessTool( + string Name, + string Executable, + string Version, + string? Requirement = null, + string? RequirementSource = null); + + internal sealed class ReadinessReport + { + public string Schema => "realdiff.readiness/1"; + public string Repository { get; set; } = string.Empty; + public string Revision { get; set; } = "working-tree"; + public string Language { get; set; } = string.Empty; + public string BuildCommand { get; set; } = string.Empty; + public string TestCommand { get; set; } = string.Empty; + public string Platform { get; set; } = + System.Runtime.InteropServices.RuntimeInformation.OSDescription + "/" + + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture; + public List Checks { get; } = new(); + public List Tools { get; } = new(); + public List Limitations { get; } = new() + { + "Readiness checks prerequisites, not the correctness or safety of a pull request.", + "A fixture probe does not establish compatibility with every source shape in the project.", + }; + public string Status => InternalError ? "error" : + Checks.Any(check => check.Required && check.Status is "failed" or "unknown") ? "blocked" : "ready"; + public int ExitCode => InternalError ? ExitCodes.BuildOrTestFailure : + Status == "ready" ? ExitCodes.NoUnexpected : ExitCodes.RunInvalid; + [JsonIgnore] + internal bool InternalError { get; set; } + [JsonIgnore] + internal Dictionary Environment { get; } = new(StringComparer.OrdinalIgnoreCase); + [JsonIgnore] + internal Dictionary Assets { get; } = new(StringComparer.OrdinalIgnoreCase); + [JsonIgnore] + internal LanguageDetection? Detection { get; set; } + + internal void Add(string id, string status, string message, string? remediation = null, + bool required = true, string evidence = "inspection") => + Checks.Add(new ReadinessCheck(id, status, message, remediation, required, evidence)); + + internal string Summary => string.Join("; ", Checks + .Where(check => check.Required && check.Status is "failed" or "unknown") + .Select(check => check.Id + ": " + check.Message)); + + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + } +} diff --git a/src/RealDiff.Cli/Readiness/ReadinessService.cs b/src/RealDiff.Cli/Readiness/ReadinessService.cs new file mode 100644 index 0000000..c9bcd40 --- /dev/null +++ b/src/RealDiff.Cli/Readiness/ReadinessService.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace RealDiff.Cli +{ + internal static class ReadinessService + { + internal static ReadinessReport Check(string repository, string revision = "working-tree", bool probe = false) + { + var report = new ReadinessReport { Repository = repository, Revision = revision }; + try + { + report.Repository = Path.GetFullPath(repository); + if (!Directory.Exists(report.Repository)) + { + report.Add("repository.exists", "failed", "Repository directory does not exist.", "Supply an existing checkout."); + return report; + } + ProcessResult git = Shell.Diagnose("git", + new[] { "-C", report.Repository, "rev-parse", "--is-inside-work-tree" }, report.Repository); + report.Add("repository.git", git.Ok && git.Output.Trim() == "true" ? "passed" : "failed", + git.Ok && git.Output.Trim() == "true" ? "Git working tree is available." : "Git working tree could not be resolved.", + git.Ok ? null : "Install Git and supply a valid working tree."); + LanguageDetection detection = LanguageDetector.Detect(report.Repository, useLauncherConfig: false); + report.Detection = detection; + report.Language = detection.Language.ToString().ToLowerInvariant(); + report.BuildCommand = detection.BuildCommand; + report.TestCommand = detection.TestCommand; + report.Add("repository.configuration", "passed", "Selected " + report.Language + " in " + detection.Workdir + "."); + if (detection.HasCustomBuild || detection.HasCustomTest) + report.Add("commands.selection", "unknown", + "Custom build/test commands can change the effective toolchain.", + "Use the supported default commands with an externally selected, verifiable toolchain. A fixture cannot establish arbitrary shell behavior."); + + ResolveAsset(report, "engine", () => EngineDispatch.ResolveRustEngine(repairPermissions: false), executable: true); + switch (detection.Language) + { + case RepositoryLanguage.DotNet: + report.Assets["dotnet-kit"] = AppContext.BaseDirectory; + foreach (string file in new[] { "realdiff-weaver.dll", "realdiff-weaver.runtimeconfig.json", + "RealDiff.Tracer.dll", "RealDiff.Contracts.dll", "Mono.Cecil.dll" }) + ResolveAsset(report, "dotnet." + file, () => Path.Combine(AppContext.BaseDirectory, file)); + break; + case RepositoryLanguage.Java: + ResolveAsset(report, "java", CrossLanguageExecution.ResolveJavaAgent); + break; + case RepositoryLanguage.Node: + ResolveAsset(report, "node", CrossLanguageExecution.ResolveNodeTracer, directory: true); + break; + case RepositoryLanguage.Go: + ResolveAsset(report, "go", CrossLanguageExecution.ResolveGoRewriter, executable: true); + break; + case RepositoryLanguage.Rust: + ResolveAsset(report, "rust", CrossLanguageExecution.ResolveRustTracer, executable: true); + break; + case RepositoryLanguage.Python: + ResolveAsset(report, "python", CrossLanguageExecution.ResolvePythonTracer, directory: true); + break; + } + ReadinessInspection.Inspect(detection, report); + if (probe) + { + bool missingPrerequisites = report.Checks.Any(check => check.Required + && check.Id != "instrumentation.compatibility" && check.Status is "failed" or "unknown"); + if (missingPrerequisites) + report.Add("probe", "skipped", "Fix unresolved prerequisites before running the fixture.", required: false); + else + { + ReadinessProbe.Run(detection, report); + } + } + else + report.Add("probe", "skipped", "No fixture was executed. Use --probe to explicitly authorize it.", required: false); + } + catch (CliException ex) + { + report.Add("readiness.configuration", "failed", ex.Message, "Correct the reported configuration or install the missing prerequisite."); + } + catch (JsonException) + { + report.Add("readiness.metadata", "unknown", "A required metadata file is not valid JSON.", "Correct the metadata and run doctor again."); + } + catch (IOException ex) + { + report.Add("readiness.io", "failed", "Could not access readiness inputs: " + ex.GetType().Name, + "Check file availability and permissions."); + } + catch (UnauthorizedAccessException) + { + report.Add("readiness.access", "failed", "A readiness input is inaccessible.", "Check file permissions."); + } + return report; + } + + private static void ResolveAsset(ReadinessReport report, string name, Func resolve, + bool directory = false, bool executable = false) + { + try + { + string path = resolve(); + bool exists = directory ? Directory.Exists(path) : File.Exists(path); + bool canExecute = !executable || OperatingSystem.IsWindows() || !exists + || (File.GetUnixFileMode(path) & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; + bool architectureMatches = !executable || !exists || ReadinessBinary.MatchesHost(path); + bool valid = exists && canExecute && architectureMatches; + report.Add("asset." + name, valid ? "passed" : "failed", + valid ? name + " payload is present." : name + " payload is missing, not executable, or incompatible with the host architecture.", + valid ? null : "Reinstall the complete RealDiff distribution for this platform and correct executable permissions."); + if (valid) report.Assets[name] = path; + } + catch (CliException ex) + { + report.Add("asset." + name, "failed", ex.Message, "Install the complete RealDiff distribution or correct the configured asset path."); + } + } + + internal static void Write(string path, object report) + { + string fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + string temporary = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporary, JsonSerializer.Serialize(report, ReadinessReport.JsonOptions) + Environment.NewLine); + File.Move(temporary, fullPath, overwrite: true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + internal static void Print(ReadinessReport report) + { + Console.WriteLine("Readiness: " + report.Status + " (" + report.Revision + ", " + report.Language + ")"); + foreach (ReadinessTool tool in report.Tools) + Console.WriteLine(" " + tool.Name + ": " + tool.Version); + foreach (ReadinessCheck check in report.Checks) + { + Console.WriteLine(" [" + check.Status + "] " + check.Id + ": " + check.Message); + if (check.Remediation != null && check.Status is "failed" or "unknown") + Console.WriteLine(" Fix: " + check.Remediation); + } + Console.WriteLine(" Prerequisites only; this is not a correctness or PR-safety verdict."); + } + } +} diff --git a/src/RealDiff.Cli/RealDiff.Cli.csproj b/src/RealDiff.Cli/RealDiff.Cli.csproj index 5c96d04..a1970d2 100644 --- a/src/RealDiff.Cli/RealDiff.Cli.csproj +++ b/src/RealDiff.Cli/RealDiff.Cli.csproj @@ -50,6 +50,18 @@ + + + + + + + diff --git a/src/RealDiff.Cli/RepositoryConfig.cs b/src/RealDiff.Cli/RepositoryConfig.cs index 4abe7f8..3e3db36 100644 --- a/src/RealDiff.Cli/RepositoryConfig.cs +++ b/src/RealDiff.Cli/RepositoryConfig.cs @@ -69,11 +69,11 @@ internal static class RepositoryConfigLoader { private static readonly IDeserializer Yaml = new DeserializerBuilder().Build(); - internal static LoadedRepositoryConfig Load(string repository) + internal static LoadedRepositoryConfig Load(string repository, bool useLauncherConfig = true) { string root = Path.GetFullPath(repository); string? launcherConfig = Environment.GetEnvironmentVariable("REALDIFF_LAUNCHER_CONFIG"); - if (!string.IsNullOrWhiteSpace(launcherConfig)) + if (useLauncherConfig && !string.IsNullOrWhiteSpace(launcherConfig)) { try { diff --git a/src/RealDiff.Cli/Shell.cs b/src/RealDiff.Cli/Shell.cs index a6c00c2..9392601 100644 --- a/src/RealDiff.Cli/Shell.cs +++ b/src/RealDiff.Cli/Shell.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text; +using System.ComponentModel; namespace RealDiff.Cli { @@ -25,6 +26,7 @@ internal static ProcessResult Run( IDictionary? environment = null, bool echo = false) { + ReadinessExecution.Apply(ref fileName, workingDirectory, ref environment); var info = new ProcessStartInfo(fileName) { WorkingDirectory = workingDirectory, @@ -59,6 +61,106 @@ internal static ProcessResult Run( return new ProcessResult { ExitCode = process.ExitCode, Output = output.ToString() }; } + internal static ProcessResult Diagnose( + string fileName, + IEnumerable arguments, + string workingDirectory, + IDictionary? environment = null, + int timeoutSeconds = 15) + { + var info = new ProcessStartInfo(fileName); + foreach (string argument in arguments) info.ArgumentList.Add(argument); + return Diagnose(info, workingDirectory, environment, timeoutSeconds); + } + + internal static ProcessResult DiagnoseWindowsScript( + string fileName, + IEnumerable arguments, + string workingDirectory, + IDictionary? environment = null, + int timeoutSeconds = 15) + { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException(); + string[] values = new[] { fileName }.Concat(arguments).ToArray(); + if (values.Any(value => value.IndexOfAny(new[] { '"', '\r', '\n', '%', '!' }) >= 0)) + throw new ArgumentException("The script path or arguments cannot be safely represented for Windows command invocation."); + // cmd.exe does not understand the C-runtime quote escaping used by ArgumentList. + var info = new ProcessStartInfo(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe") + { + Arguments = "/d /s /c \"" + string.Join(" ", values.Select(value => "\"" + value + "\"")) + "\"", + }; + return Diagnose(info, workingDirectory, environment, timeoutSeconds); + } + + private static ProcessResult Diagnose( + ProcessStartInfo info, + string workingDirectory, + IDictionary? environment, + int timeoutSeconds) + { + const int outputLimit = 32768; + info.WorkingDirectory = workingDirectory; + info.RedirectStandardOutput = true; + info.RedirectStandardError = true; + info.UseShellExecute = false; + if (environment != null) + foreach (var pair in environment) info.Environment[pair.Key] = pair.Value; + var output = new StringBuilder(); + bool truncated = false; + using var cancellation = new System.Threading.CancellationTokenSource(); + System.Threading.CancellationToken token = cancellation.Token; + async System.Threading.Tasks.Task Capture(StreamReader reader) + { + var buffer = new char[4096]; + try + { + int read; + while ((read = await reader.ReadAsync(buffer.AsMemory(), token).ConfigureAwait(false)) != 0) + { + lock (output) + { + int remaining = outputLimit - output.Length; + if (read > remaining) truncated = true; + if (remaining > 0) output.Append(buffer, 0, Math.Min(remaining, read)); + } + } + return true; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { return false; } + catch (ObjectDisposedException) when (token.IsCancellationRequested) { return false; } + catch (IOException) { return false; } + } + using var process = new Process { StartInfo = info }; + try + { + process.Start(); + } + catch (Win32Exception) + { + return new ProcessResult { ExitCode = -1, Output = "Executable is missing or cannot be started." }; + } + System.Threading.Tasks.Task readers = System.Threading.Tasks.Task.WhenAll( + Capture(process.StandardOutput), Capture(process.StandardError)); + if (!process.WaitForExit(checked(timeoutSeconds * 1000))) + { + try { process.Kill(entireProcessTree: true); } + catch (InvalidOperationException) when (process.HasExited) { } + process.WaitForExit(2000); + cancellation.Cancel(); + return new ProcessResult { ExitCode = -2, Output = "Diagnostic command timed out." }; + } + if (!readers.Wait(TimeSpan.FromSeconds(2)) || readers.Result.Any(success => !success)) + { + cancellation.Cancel(); + return new ProcessResult { ExitCode = -2, Output = "Diagnostic output streams did not close." }; + } + return new ProcessResult + { + ExitCode = truncated ? -3 : process.ExitCode, + Output = truncated ? "Diagnostic output exceeded the safety limit." : output.ToString(), + }; + } + internal static ProcessResult RunCommand( string command, string workingDirectory, diff --git a/src/RealDiff.Cli/TraceCache.cs b/src/RealDiff.Cli/TraceCache.cs index 178b5a6..bd13588 100644 --- a/src/RealDiff.Cli/TraceCache.cs +++ b/src/RealDiff.Cli/TraceCache.cs @@ -14,13 +14,14 @@ internal sealed record TraceCacheKey( string TargetSha, string Language, string TracerVersion, - string ScopeConfig) + string ScopeConfig, + string ExecutionContext = "") { internal string Id { get { - string canonical = string.Join("\n", TargetSha, Language, TracerVersion, ScopeConfig); + string canonical = string.Join("\n", "realdiff.trace-cache/2", TargetSha, Language, TracerVersion, ScopeConfig, ExecutionContext); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); } } @@ -200,11 +201,12 @@ public bool TryRestore(TraceCacheKey key, string destination, out TraceCacheEntr CacheMetadata? metadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath), Json); if (metadata is null - || metadata.Schema != "realdiff.trace-cache/1" + || metadata.Schema != "realdiff.trace-cache/2" || metadata.TargetSha != key.TargetSha || metadata.Language != key.Language || metadata.TracerVersion != key.TracerVersion || metadata.ScopeConfig != key.ScopeConfig + || metadata.ExecutionContext != key.ExecutionContext || string.IsNullOrWhiteSpace(metadata.BaseRoot) || metadata.TraceWallClockMilliseconds < 0 || !DateTimeOffset.TryParse(metadata.CreatedUtc, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTimeOffset created) @@ -258,11 +260,12 @@ public void Store(TraceCacheKey key, string source, TraceCacheEntry entry) var metadata = new CacheMetadata { - Schema = "realdiff.trace-cache/1", + Schema = "realdiff.trace-cache/2", TargetSha = key.TargetSha, Language = key.Language, TracerVersion = key.TracerVersion, ScopeConfig = key.ScopeConfig, + ExecutionContext = key.ExecutionContext, BaseRoot = entry.BaseRoot, TraceWallClockMilliseconds = entry.TraceWallClockMilliseconds, CreatedUtc = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), @@ -365,6 +368,7 @@ private sealed class CacheMetadata public string Language { get; init; } = string.Empty; public string TracerVersion { get; init; } = string.Empty; public string ScopeConfig { get; init; } = string.Empty; + public string ExecutionContext { get; init; } = string.Empty; public string BaseRoot { get; init; } = string.Empty; public long TraceWallClockMilliseconds { get; init; } public string CreatedUtc { get; init; } = string.Empty; diff --git a/src/RealDiff.Launcher.Rust/src/lib.rs b/src/RealDiff.Launcher.Rust/src/lib.rs index 14f6aca..129d969 100644 --- a/src/RealDiff.Launcher.Rust/src/lib.rs +++ b/src/RealDiff.Launcher.Rust/src/lib.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_yaml::Value; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{OsStr, OsString}; use std::fs; use std::fs::OpenOptions; @@ -11,7 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; const INVALID: i32 = 3; const FAILED: i32 = 4; -const LANGUAGES: [&str; 5] = ["dotnet", "java", "node", "go", "rust"]; +const LANGUAGES: [&str; 6] = ["dotnet", "java", "node", "go", "rust", "python"]; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields, rename_all(serialize = "PascalCase"))] @@ -23,6 +23,8 @@ struct RepositoryConfig { #[serde(default)] test_projects: Vec, #[serde(default)] + source_roots: Vec, + #[serde(default)] include_namespaces: Vec, #[serde(default)] exclude_namespaces: Vec, @@ -56,6 +58,7 @@ enum Language { Node, Go, Rust, + Python, } #[derive(Clone, Debug)] @@ -147,7 +150,8 @@ fn route(args: &[OsString]) -> Result { .then(|| Route::Detect(PathBuf::from(&args[1]))) .ok_or_else(|| "usage: realdiff detect ".to_owned()); } - if first == "post" || first == "baseline" { + // Doctor owns validation so even malformed arguments/configuration can produce a report. + if first == "doctor" || first == "post" || first == "baseline" { return Ok(Route::Managed(None)); } @@ -164,7 +168,13 @@ fn route(args: &[OsString]) -> Result { "--cache-retention", "--keep-traces", ]; - let flag_options = ["--no-baseline", "--no-cache", "--keep", "--strict"]; + let flag_options = [ + "--no-baseline", + "--no-cache", + "--keep", + "--strict", + "--readiness-probe", + ]; let mut repository = None; let mut ci = None; let mut index = start; @@ -175,7 +185,10 @@ fn route(args: &[OsString]) -> Result { } if value_options.contains(&argument.as_str()) { index += 1; - if index >= args.len() { + if index >= args.len() + || text(&args[index]).trim().is_empty() + || text(&args[index]).starts_with("--") + { return Err(format!("{argument} requires a value")); } if argument == "--ci" { @@ -329,13 +342,14 @@ fn validate_config(config: &RepositoryConfig, path: &Path) -> Result<(), String> if let Some(language) = config.language.as_deref() { if !LANGUAGES.contains(&language.trim().to_ascii_lowercase().as_str()) { return Err(format!( - "Unsupported language '{language}' in {}. Expected dotnet, java, node, go, or rust.", + "Unsupported language '{language}' in {}. Expected dotnet, java, node, go, rust, or python.", path.display() )); } } for (name, values) in [ ("test_projects", &config.test_projects), + ("source_roots", &config.source_roots), ("include_namespaces", &config.include_namespaces), ("exclude_namespaces", &config.exclude_namespaces), ("redaction.names", &config.redaction.names), @@ -368,7 +382,7 @@ fn detect(repository: &Path) -> Result { candidates.retain(|candidate| candidate.language == language); } if candidates.is_empty() && configured_language.is_none() { - return Err(detection_failure("Could not detect a supported repository language. Expected a solution/project, pom.xml, package.json, go.mod, or Cargo.toml.")); + return Err(detection_failure("Could not detect a supported repository language. Expected a solution/project, pom.xml, package.json, go.mod, Cargo.toml, pyproject.toml, setup.py, or requirements.txt.")); } let (language, marker) = if let Some(language) = configured_language { @@ -425,7 +439,7 @@ fn detect(repository: &Path) -> Result { let test_projects = configured_or(&config.value.test_projects, inferred_tests); let include_namespaces = configured_or( &config.value.include_namespaces, - infer_scope(language, &config.root, &workdir), + infer_scope(language, &config.root, &workdir)?, ); let build = config .value @@ -505,15 +519,63 @@ fn scan_candidates(root: &Path, recursive: bool) -> Result, Strin .map(|path| Candidate { language, path }), ); } + let mut python_files = Vec::new(); + visit_filtered(root, root, recursive, &mut python_files, python_build_output)?; + let mut python_markers = BTreeMap::::new(); + for path in &python_files { + let Some(rank) = python_marker_rank(path) else { + continue; + }; + if python_build_output(path) { + continue; + } + let directory = path.parent().unwrap_or(root).to_string_lossy().to_lowercase(); + let marker = python_markers.entry(directory).or_insert_with(|| path.clone()); + if Some(rank) < python_marker_rank(marker) { + *marker = path.clone(); + } + } + result.extend(python_markers.into_values().map(|path| Candidate { + language: Language::Python, + path, + })); result.sort_by(|left, right| left.path.cmp(&right.path)); Ok(result) } +fn python_marker_rank(path: &Path) -> Option { + match path.file_name().and_then(OsStr::to_str) { + Some("pyproject.toml") => Some(0), + Some("setup.py") => Some(1), + Some("requirements.txt") => Some(2), + _ => None, + } +} + +fn python_build_output(path: &Path) -> bool { + path.components().any(|part| { + matches!( + part.as_os_str().to_str(), + Some("bin" | "obj" | "target" | "node_modules" | "__pycache__" | ".pytest_cache" | "dist") + ) + }) +} + fn visit( root: &Path, directory: &Path, recursive: bool, files: &mut Vec, +) -> Result<(), String> { + visit_filtered(root, directory, recursive, files, ignored) +} + +fn visit_filtered( + root: &Path, + directory: &Path, + recursive: bool, + files: &mut Vec, + ignore: fn(&Path) -> bool, ) -> Result<(), String> { for entry in fs::read_dir(directory) .map_err(|error| format!("Could not scan repository {}: {error}", root.display()))? @@ -522,8 +584,8 @@ fn visit( let path = entry.path(); if path.is_file() { files.push(path); - } else if recursive && path.is_dir() && !ignored(&path) { - visit(root, &path, true, files)?; + } else if recursive && path.is_dir() && !ignore(&path) { + visit_filtered(root, &path, true, files, ignore)?; } } Ok(()) @@ -551,8 +613,12 @@ fn infer_dotnet_tests(root: &Path) -> Vec { tests } -fn infer_scope(language: Language, repository: &Path, workdir: &Path) -> Vec { - match language { +fn infer_scope( + language: Language, + repository: &Path, + workdir: &Path, +) -> Result, String> { + Ok(match language { Language::Node => ["src", "lib", "app", "dist"] .into_iter() .filter(|name| workdir.join(name).is_dir()) @@ -561,7 +627,28 @@ fn infer_scope(language: Language, repository: &Path, workdir: &Path) -> Vec infer_java_packages(workdir), Language::Dotnet => infer_dotnet_namespaces(workdir), Language::Go | Language::Rust => vec![relative(repository, workdir)], - } + Language::Python => { + let mut scopes = Vec::new(); + for entry in fs::read_dir(workdir) + .map_err(|error| format!("Could not scan Python scope: {error}"))? + { + let entry = entry + .map_err(|error| format!("Could not scan Python scope: {error}"))?; + let name = entry.file_name().to_string_lossy().into_owned(); + if entry.path().is_dir() + && (matches!(name.as_str(), "src" | "lib" | "app") + || name.to_ascii_lowercase().starts_with("test")) + { + scopes.push(name); + } + } + scopes.sort(); + if scopes.is_empty() { + scopes.push(relative(repository, workdir)); + } + scopes + } + }) } fn infer_java_packages(root: &Path) -> Vec { @@ -664,6 +751,7 @@ fn default_build(language: Language, entry: &str) -> String { Language::Node => "npm ci && npm run build --if-present".to_owned(), Language::Go => "go build ./...".to_owned(), Language::Rust => "cargo build".to_owned(), + Language::Python => String::new(), } } @@ -682,6 +770,7 @@ fn default_test(language: Language, projects: &[String]) -> String { Language::Node => "npm test".to_owned(), Language::Go => "go test ./...".to_owned(), Language::Rust => "cargo test -- --test-threads=1".to_owned(), + Language::Python => "python -m pytest".to_owned(), } } @@ -721,6 +810,7 @@ fn parse_language(value: &str) -> Result { "node" => Ok(Language::Node), "go" => Ok(Language::Go), "rust" => Ok(Language::Rust), + "python" => Ok(Language::Python), _ => Err(format!("Unsupported language: {value}")), } } @@ -732,6 +822,7 @@ fn language_name(language: Language) -> &'static str { Language::Node => "node", Language::Go => "go", Language::Rust => "rust", + Language::Python => "python", } } @@ -760,7 +851,11 @@ fn text(value: &OsString) -> String { #[cfg(test)] mod tests { use super::*; - use tempfile::tempdir; + use tempfile::{tempdir_in, TempDir}; + + fn tempdir() -> std::io::Result { + tempdir_in(env!("CARGO_MANIFEST_DIR")) + } #[test] fn detects_all_languages() { @@ -770,6 +865,9 @@ mod tests { ("package.json", "node"), ("go.mod", "go"), ("Cargo.toml", "rust"), + ("pyproject.toml", "python"), + ("setup.py", "python"), + ("requirements.txt", "python"), ] { let directory = tempdir().unwrap(); fs::write(directory.path().join(marker), "").unwrap(); @@ -819,6 +917,7 @@ mod tests { fn handoff_uses_managed_property_names() { let config = RepositoryConfig { test_projects: vec!["tests/**/*.csproj".to_owned()], + source_roots: vec!["src/main/java".to_owned(), "generated/java".to_owned()], include_namespaces: vec!["Acme".to_owned()], redaction: RedactionConfig { names: vec!["password".to_owned()], @@ -832,9 +931,219 @@ mod tests { }; let json = serde_json::to_string(&envelope).unwrap(); assert!(json.contains("\"TestProjects\""), "{json}"); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!( + value["Config"]["SourceRoots"], + serde_json::json!(["src/main/java", "generated/java"]) + ); assert!(json.contains("\"IncludeNamespaces\""), "{json}"); assert!(json.contains("\"Redaction\":{\"Names\""), "{json}"); assert!(!json.contains("test_projects"), "{json}"); + assert!(!json.contains("source_roots"), "{json}"); + } + + #[test] + fn parses_python_and_source_roots_without_losing_handoff_values() { + let directory = tempdir().unwrap(); + fs::create_dir(directory.path().join(".realdiff")).unwrap(); + fs::write( + directory.path().join(".realdiff/config.yml"), + "language: ' Python '\nsource_roots: [src/main/java, generated/java]\n", + ) + .unwrap(); + let loaded = load_config(directory.path()).unwrap(); + assert_eq!(parse_language(loaded.value.language.as_deref().unwrap()).unwrap(), Language::Python); + let envelope = ConfigEnvelope { + config_path: &loaded.path, + config: &loaded.value, + }; + let json = serde_json::to_value(&envelope).unwrap(); + assert_eq!(json["Config"]["Language"], " Python "); + assert_eq!( + json["Config"]["SourceRoots"], + serde_json::json!(["src/main/java", "generated/java"]) + ); + } + + #[test] + fn rejects_invalid_config_fields_and_source_roots() { + for yaml in ["source_roots: [' ']", "language: unsupported"] { + let config: RepositoryConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(validate_config(&config, Path::new("config.yml")).is_err(), "{yaml}"); + } + for yaml in ["source_roots: [", "source_root: [src]", "source_roots: src"] { + assert!(serde_yaml::from_str::(yaml).is_err(), "{yaml}"); + } + let config: RepositoryConfig = serde_yaml::from_str("source_roots: []").unwrap(); + assert!(validate_config(&config, Path::new("config.yml")).is_ok()); + } + + #[test] + fn python_markers_share_one_entry_point_per_directory_in_priority_order() { + let directory = tempdir().unwrap(); + for marker in ["requirements.txt", "setup.py", "pyproject.toml"] { + fs::write(directory.path().join(marker), "").unwrap(); + let detection = detect(directory.path()).unwrap(); + assert_eq!(detection.language, Language::Python); + assert_eq!(detection.entry_point, marker); + assert_eq!(detection.build, ""); + assert_eq!(detection.test, "python -m pytest"); + assert_eq!(detection.include_namespaces, ["."]); + assert!(render(&detection).contains("build: ''\n")); + } + } + + #[test] + fn python_recursive_markers_remain_ambiguous_across_directories() { + let directory = tempdir().unwrap(); + let service = directory.path().join("service"); + fs::create_dir(&service).unwrap(); + for marker in ["pyproject.toml", "setup.py", "requirements.txt"] { + fs::write(service.join(marker), "").unwrap(); + } + assert_eq!(detect(directory.path()).unwrap().entry_point, "service/pyproject.toml"); + fs::create_dir(directory.path().join("other")).unwrap(); + fs::write(directory.path().join("other").join("requirements.txt"), "").unwrap(); + let error = detect(directory.path()).unwrap_err(); + assert!(error.contains("Multiple python build entry points"), "{error}"); + fs::write(directory.path().join("pyproject.toml"), "").unwrap(); + assert_eq!(detect(directory.path()).unwrap().entry_point, "pyproject.toml"); + } + + #[test] + fn python_ignores_only_managed_build_output_markers() { + let directory = tempdir().unwrap(); + for name in ["bin", "obj", "target", "node_modules", "__pycache__", ".pytest_cache", "dist"] { + fs::create_dir(directory.path().join(name)).unwrap(); + fs::write(directory.path().join(name).join("pyproject.toml"), "").unwrap(); + } + assert!(scan_candidates(directory.path(), true).unwrap().is_empty()); + // A virtual environment is not excluded by the managed detection rules. + fs::create_dir(directory.path().join(".venv")).unwrap(); + fs::write(directory.path().join(".venv").join("setup.py"), "").unwrap(); + let detection = detect(directory.path()).unwrap(); + assert_eq!(detection.entry_point, ".venv/setup.py"); + } + + #[test] + fn python_git_directory_rules_do_not_broaden_other_languages() { + let directory = tempdir().unwrap(); + fs::create_dir(directory.path().join(".git")).unwrap(); + fs::write(directory.path().join(".git").join("Cargo.toml"), "").unwrap(); + assert!(scan_candidates(directory.path(), true).unwrap().is_empty()); + fs::write(directory.path().join(".git").join("requirements.txt"), "").unwrap(); + assert_eq!(detect(directory.path()).unwrap().entry_point, ".git/requirements.txt"); + } + + #[test] + fn python_scope_matches_managed_directory_names_and_workdir_fallback() { + let directory = tempdir().unwrap(); + fs::create_dir(directory.path().join("service")).unwrap(); + let service = directory.path().join("service"); + fs::create_dir(directory.path().join(".realdiff")).unwrap(); + fs::write( + directory.path().join(".realdiff/config.yml"), + "language: python\nworkdir: service\n", + ).unwrap(); + fs::write(service.join("pyproject.toml"), "").unwrap(); + assert_eq!(detect(directory.path()).unwrap().include_namespaces, ["service"]); + for name in ["src", "lib", "app", "Tests", "test_support", "dist", "vendor", "SRC"] { + // Windows cannot create both src and SRC as distinct directories. + fs::create_dir_all(service.join(name)).unwrap(); + } + fs::write(service.join("test_file.py"), "").unwrap(); + assert_eq!( + detect(directory.path()).unwrap().include_namespaces, + ["Tests", "app", "lib", "src", "test_support"] + ); + } + + #[test] + fn configured_python_commands_and_scope_override_defaults_without_marker() { + let directory = tempdir().unwrap(); + fs::create_dir(directory.path().join(".realdiff")).unwrap(); + fs::write( + directory.path().join(".realdiff/config.yml"), + "language: PYTHON\nbuild: python build.py\ntest: python -m unittest\ninclude_namespaces: [domain]\n", + ).unwrap(); + let detection = detect(directory.path()).unwrap(); + assert_eq!(detection.language, Language::Python); + assert_eq!(detection.build, "python build.py"); + assert_eq!(detection.test, "python -m unittest"); + assert_eq!(detection.include_namespaces, ["domain"]); + assert!(detection.configured); + } + + #[test] + fn python_does_not_hide_other_language_candidates_or_detect_arbitrary_sources() { + let directory = tempdir().unwrap(); + fs::write(directory.path().join("module.py"), "").unwrap(); + assert!(detect(directory.path()).is_err()); + fs::write(directory.path().join("pyproject.toml"), "").unwrap(); + fs::write(directory.path().join("package.json"), "").unwrap(); + assert!(detect(directory.path()).unwrap_err().contains("Repository language is ambiguous")); + } + + #[test] + fn routes_doctor_without_config_handoff_even_with_invalid_arguments() { + for arguments in [ + vec!["doctor", "repository"], + vec!["doctor", "repository", "--json", "--out", "report.json", "--probe"], + vec!["doctor", "--out", "report.json", "--probe", "repository", "--json"], + vec!["doctor"], + vec!["doctor", "--json"], + vec!["doctor", "repository", "--out"], + vec!["doctor", "repository", "--out", "--json"], + vec!["doctor", "repository", "--out", ""], + vec!["doctor", "repository", "--unknown"], + vec!["doctor", "repository", "--json=true"], + vec!["doctor", "repository", "extra"], + vec!["doctor", "repository", "--readiness-probe"], + ] { + let args: Vec<_> = arguments.iter().map(OsString::from).collect(); + assert!(matches!(route(&args).unwrap(), Route::Managed(None)), "{arguments:?}"); + } + } + + #[test] + fn doctor_does_not_load_missing_repository_or_malformed_config() { + let directory = tempdir().unwrap(); + let repository = directory.path().join("repository"); + let args = [OsString::from("doctor"), repository.as_os_str().to_owned(), OsString::from("--json")]; + assert!(load_config(&repository).is_err()); + assert!(matches!(route(&args).unwrap(), Route::Managed(None))); + fs::create_dir_all(repository.join(".realdiff")).unwrap(); + fs::write(repository.join(".realdiff").join("config.yml"), "source_roots: [").unwrap(); + assert!(load_config(&repository).is_err()); + assert!(matches!(route(&args).unwrap(), Route::Managed(None))); + } + + #[test] + fn routes_readiness_probe_for_analysis_and_warm() { + for arguments in [ + vec!["repository", "--base", "main", "--pr", "HEAD", "--readiness-probe"], + vec!["warm", "--readiness-probe", "repository", "--target", "HEAD"], + ] { + let args: Vec<_> = arguments.iter().map(OsString::from).collect(); + assert!(matches!( + route(&args).unwrap(), + Route::Managed(Some(repository)) if repository == Path::new("repository") + )); + } + for arguments in [ + vec!["repository", "--readiness-probe=true"], + vec!["repository", "--probe"], + vec!["warm", "repository", "--target"], + vec!["warm", "repository", "--target", "--readiness-probe"], + vec!["repository", "--base", "--readiness-probe"], + vec!["repository", "--base", ""], + vec!["repository", "--readiness-probe", "extra"], + vec!["detect"], + vec!["detect", "repository", "--probe"], + ] { + let args: Vec<_> = arguments.iter().map(OsString::from).collect(); + assert!(route(&args).is_err(), "{arguments:?}"); + } } #[test] diff --git a/src/RealDiff.Mcp/AnalysisRunner.cs b/src/RealDiff.Mcp/AnalysisRunner.cs index 63c9749..086036b 100644 --- a/src/RealDiff.Mcp/AnalysisRunner.cs +++ b/src/RealDiff.Mcp/AnalysisRunner.cs @@ -27,7 +27,7 @@ private static void Run(RunRecord record) try { record.Status = "running"; - record.Phase = "building and tracing both worktrees"; + record.Phase = "checking readiness, then building and tracing both worktrees"; record.Progress = 10; RunStore.Save(record); @@ -61,7 +61,7 @@ private static void Run(RunRecord record) File.WriteAllText(Path.Combine(runDir, "cli.log"), stdout + Environment.NewLine + stderr); record.ExitCode = process.ExitCode; - foreach (string name in new[] { "findings.json", "divergence-set.json", "frontier-report.json" }) + foreach (string name in new[] { "findings.json", "divergence-set.json", "frontier-report.json", "readiness.json" }) { string source = Path.Combine(work, name); if (File.Exists(source)) @@ -91,6 +91,17 @@ private static void Run(RunRecord record) Fail(record, "failed", RefusalReason(findings!) ?? "the analysis failed without a reason"); return; default: + using (JsonDocument? readiness = RunStore.LoadArtifact(record.RunId, "readiness.json")) + { + if (readiness != null + && readiness.RootElement.TryGetProperty("status", out JsonElement readinessStatus) + && readinessStatus.GetString() is "blocked" or "error") + { + Fail(record, readinessStatus.GetString() == "blocked" ? "refused" : "failed", + "Readiness did not pass. See readiness.json and cli.log for the missing prerequisites. No behavioral analysis result is available."); + return; + } + } Fail(record, "failed", "the CLI did not produce a valid findings.json (exit " + process.ExitCode + "). This is not a clean result. Last output: " + Tail(stdout + stderr)); return; diff --git a/tools/ReadinessProof/Program.cs b/tools/ReadinessProof/Program.cs new file mode 100644 index 0000000..4878d31 --- /dev/null +++ b/tools/ReadinessProof/Program.cs @@ -0,0 +1,346 @@ +using System.Text.Json; +using RealDiff.Cli; + +if (args.Contains("--sleep")) +{ + Thread.Sleep(TimeSpan.FromSeconds(30)); + return; +} +if (args.Contains("--spam")) +{ + for (int index = 0; index < 1000; index++) Console.WriteLine(new string('x', 100)); + return; +} + +int assertions = 0; +void Assert(bool condition, string message) +{ + assertions++; + if (!condition) throw new InvalidOperationException(message); +} + +var root = Path.Combine(Path.GetTempPath(), "realdiff-readiness-proof-" + Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(root); +string? oldEngine = Environment.GetEnvironmentVariable("REALDIFF_RUST_ENGINE"); +string? oldHandoff = Environment.GetEnvironmentVariable("REALDIFF_LAUNCHER_CONFIG"); +TextWriter stdout = Console.Out; +try +{ + var model = new ReadinessReport(); + model.Add("optional-probe", "skipped", "Not requested.", required: false); + Assert(model.Status == "ready", "Skipped optional probe must not block."); + model.Add("compatibility", "unknown", "No evidence."); + Assert(model.Status == "blocked" && model.ExitCode == 3, "Required unknown must block."); + model.Checks.Clear(); + model.Add("tool", "failed", "Not installed."); + Assert(model.Status == "blocked", "Missing prerequisite must block."); + model.InternalError = true; + Assert(model.Status == "error" && model.ExitCode == 4, "Internal failures must not become refusals or ready."); + + var a = new TraceCacheKey("sha", "go", "tracer", "scope", "go-toolchain-A"); + var b = new TraceCacheKey("sha", "go", "tracer", "scope", "go-toolchain-B"); + Assert(a.Id != b.Id, "Compiler changes must invalidate baseline traces."); + Assert(a.Id == new TraceCacheKey("sha", "go", "tracer", "scope", "go-toolchain-A").Id, + "Identical warm/analyze context must produce the same cache key."); + var cacheSource = Path.Combine(root, "cache-source"); + foreach (int run in new[] { 1, 2, 3 }) + { + string directory = Path.Combine(cacheSource, "base_run" + run); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "run.unit.ndjson"), "{}\n"); + File.WriteAllText(Path.Combine(directory, "run.unit.manifest.ndjson"), "{}\n"); + } + var cacheRoot = Path.Combine(root, "cache"); + var store = new LocalDirectoryTraceCacheStore(cacheRoot, TimeSpan.FromDays(1)); + store.Store(a, cacheSource, new TraceCacheEntry("base", 1)); + Assert(store.TryRestore(a, Path.Combine(root, "restore-a"), out _), "Same-context trace cache should restore."); + Assert(!store.TryRestore(b, Path.Combine(root, "restore-b"), out _), "Different toolchains must never share stored traces."); + string metadataPath = Path.Combine(cacheRoot, a.Id, "metadata.json"); + File.WriteAllText(metadataPath, File.ReadAllText(metadataPath).Replace("realdiff.trace-cache/2", "realdiff.trace-cache/1")); + Assert(!store.TryRestore(a, Path.Combine(root, "restore-legacy"), out _), "Legacy metadata must not be trusted."); + + var context = new ReadinessReport { Language = "go", BuildCommand = "go build ./...", TestCommand = "go test ./..." }; + context.Tools.Add(new ReadinessTool("go", Path.Combine(root, "tool", "go"), "test-version")); + context.Environment["GOTOOLCHAIN"] = "local"; + ReadinessExecution.Register(root, context); + var fingerprint = ReadinessExecution.Fingerprint(root); + context.Tools[0] = context.Tools[0] with { Version = "changed-version" }; + Assert(fingerprint != ReadinessExecution.Fingerprint(root), "Execution fingerprint must include tool version."); + context.Environment["GOARCH"] = "arm64"; + Assert(fingerprint != ReadinessExecution.Fingerprint(root), "Target settings must enter context."); + var copy = Path.Combine(root, "copy"); + Directory.CreateDirectory(copy); + ReadinessExecution.BindCopy(root, copy); + Assert(ReadinessExecution.Find(copy) == context, "Rewritten copies must inherit verified selection."); + string command = "go"; + IDictionary? environment = new Dictionary { ["REALDIFF_TRACE"] = "trace" }; + ReadinessExecution.Apply(ref command, copy, ref environment); + Assert(command == context.Tools[0].Executable && environment!["GOTOOLCHAIN"] == "local" + && environment["REALDIFF_TRACE"] == "trace", "Execution must use verified tools without losing trace settings."); + ReadinessExecution.Clear(); + + string dotnet = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"; + var missing = Shell.Diagnose(Path.Combine(root, "missing-executable"), Array.Empty(), root); + Assert(missing.ExitCode == -1, "Missing diagnostics executable must be explicit."); + string proof = System.Reflection.Assembly.GetExecutingAssembly().Location; + Assert(Shell.Diagnose(dotnet, new[] { proof, "--sleep" }, root, timeoutSeconds: 1).ExitCode == -2, + "Diagnostics must time out."); + Assert(Shell.Diagnose(dotnet, new[] { proof, "--spam" }, root).ExitCode == -3, + "Diagnostics output must be bounded."); + + using (var writer = new StringWriter()) + { + Console.SetOut(writer); + int exit = DoctorCommand.Run(new[] { "--json", "--out" }); + using var document = JsonDocument.Parse(writer.ToString()); + Assert(exit == 3 && document.RootElement.GetProperty("status").GetString() == "blocked", + "Argument failures must be structured in JSON mode."); + } + Console.SetOut(stdout); + + Environment.SetEnvironmentVariable("REALDIFF_RUST_ENGINE", Path.Combine(root, "missing-engine")); + // A handoff for another checkout must not override the selected revision's config. + var handoff = Path.Combine(root, "handoff.json"); + File.WriteAllText(handoff, "{\"ConfigPath\":\"wrong\",\"Config\":{\"Language\":\"rust\",\"Build\":\"bad\",\"Test\":\"bad\"}}"); + Environment.SetEnvironmentVariable("REALDIFF_LAUNCHER_CONFIG", handoff); + foreach (var language in new[] { "dotnet", "java", "node", "go", "rust", "python" }) + { + string repository = Path.Combine(root, language); + Directory.CreateDirectory(repository); + Assert(Shell.Diagnose("git", new[] { "init", "--quiet", repository }, root).Ok, "Fixture git init failed."); + string file = language switch + { + "dotnet" => "App.csproj", "java" => "pom.xml", "node" => "package.json", + "go" => "go.mod", "rust" => "Cargo.toml", _ => "pyproject.toml", + }; + string content = language switch + { + "dotnet" => "net8.0", + "java" => "4.0.0proofproof1", + "node" => "{\"name\":\"proof\",\"scripts\":{\"test\":\"node test.js\"}}", + "go" => "module example.invalid/readiness\n\ngo 1.23\n", + "rust" => "[package]\nname='readiness-proof'\nversion='0.1.0'\nedition='2021'\n", + _ => "[project]\nname='readiness-proof'\nversion='0.1.0'\nrequires-python='>=3.12'\n", + }; + File.WriteAllText(Path.Combine(repository, file), content); + if (language == "node") File.WriteAllText(Path.Combine(repository, "package-lock.json"), "{\"lockfileVersion\":3}"); + File.WriteAllText(Path.Combine(repository, "sitecustomize.py"), + "raise RuntimeError('readiness must not import checkout startup code')\n"); + if (language == "python") + File.WriteAllText(Path.Combine(repository, "setup.py"), + "raise RuntimeError('readiness must not execute setup.py')\n"); + var before = Directory.GetFiles(repository, "*", SearchOption.AllDirectories) + .ToDictionary(path => path, path => Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(path)))); + ReadinessReport result = ReadinessService.Check(repository); + Assert(result.Language == language, "Managed readiness detected the wrong language: " + language); + Assert(result.Status == "blocked", "Missing engine must block " + language); + Assert(result.Checks.Any(check => check.Id == "asset.engine" && check.Status == "failed"), + "Missing engine diagnostic must survive " + language); + Assert(before.All(pair => File.Exists(pair.Key) + && pair.Value == Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(pair.Key)))) && + before.Count == Directory.GetFiles(repository, "*", SearchOption.AllDirectories).Length, + "Fast readiness changed the checkout for " + language); + string output = Path.Combine(root, language + ".json"); + ReadinessService.Write(output, result); + using var saved = JsonDocument.Parse(File.ReadAllText(output)); + Assert(saved.RootElement.GetProperty("schema").GetString() == "realdiff.readiness/1", + "Readiness artifact schema is missing."); + } + // Synthetic records test validator rejection paths, not instrumenter compatibility. + string probeDirectory = Path.Combine(root, "probe-validator"); + Directory.CreateDirectory(probeDirectory); + string probeTrace = Path.Combine(probeDirectory, "run.fixture.ndjson"); + string probeManifest = Path.Combine(probeDirectory, "run.fixture.manifest.ndjson"); + string[] probeEvents = + { + """{"testId":"fixture-test","methodFullName":"fixture::probe_echo","callId":2,"parentCallId":1,"argsDigest":"args","returnDigest":"returns","argsRendered":"probe-input","returnRendered":"probe-input-return","filePath":"src/probe.rs","filePathResolution":"debugInfo","line":1}""", + """{"testId":"fixture-test","methodFullName":"fixture::probe_echo","callId":3,"parentCallId":1,"argsDigest":"args","returnDigest":"returns","argsRendered":"probe-input","returnRendered":"probe-input-return","filePath":"src/probe.rs","filePathResolution":"debugInfo","line":1}""", + """{"testId":"fixture-test","methodFullName":"fixture::test_root","callId":1,"isHarness":true}""", + }; + const string validProbeManifest = """ + {"kind":"run","schema":"realdiff.trace/1","language":"rust"} + {"kind":"assembly","assembly":"fixture","patchFailedMembers":0,"discoveredMembers":2,"patchedMembers":2,"skippedMembers":0} + {"kind":"member","method":"fixture::probe_echo","status":"Patched"} + {"kind":"member","method":"fixture::test_root","status":"Patched","isTestRoot":true} + {"kind":"writer","enqueued":3,"written":3,"dropped":0,"capacity":16} + """; + void WriteProbeRecords(IEnumerable events, string manifest) + { + File.WriteAllLines(probeTrace, events); + File.WriteAllText(probeManifest, manifest); + } + void RejectProbeRecords(string name, IEnumerable events, string manifest, string expectedMessage) + { + WriteProbeRecords(events, manifest); + bool rejected = false; + try + { + ReadinessProbe.ValidateFixture(probeDirectory, "probe_echo", "probe.rs"); + } + catch (Exception exception) when (exception.GetType().Name == "ProbeFailureException") + { + rejected = exception.Message.Contains(expectedMessage, StringComparison.Ordinal); + } + Assert(rejected, "Fixture validator did not reject " + name + " at its expected guard."); + } + WriteProbeRecords(probeEvents, validProbeManifest); + ReadinessProbe.ValidateFixture(probeDirectory, "probe_echo", "probe.rs"); + Assert(true, "The minimal valid fixture contract must pass before testing individual invalid mutations."); + RejectProbeRecords("zero events", Array.Empty(), + validProbeManifest.Replace("\"enqueued\":3,\"written\":3", "\"enqueued\":0,\"written\":0"), + "no nonempty trace"); + RejectProbeRecords("missing emitted root", probeEvents.Take(2), + validProbeManifest.Replace("\"enqueued\":3,\"written\":3", "\"enqueued\":2,\"written\":2"), + "not correlated"); + RejectProbeRecords("missing manifest root", probeEvents, + validProbeManifest.Replace("\"isTestRoot\":true", "\"isTestRoot\":false"), "not correlated"); + RejectProbeRecords("broken root ancestry", + probeEvents.Select(record => record.Replace("\"parentCallId\":1", "\"parentCallId\":99")), + validProbeManifest, "not correlated"); + foreach (string digest in new[] { "argsDigest", "returnDigest" }) + { + string[] mismatched = (string[])probeEvents.Clone(); + mismatched[1] = mismatched[1].Replace( + "\"" + digest + "\":\"" + (digest == "argsDigest" ? "args" : "returns") + "\"", + "\"" + digest + "\":\"changed\""); + RejectProbeRecords("inconsistent " + digest, mismatched, validProbeManifest, "inconsistent argument or return digests"); + } + RejectProbeRecords("wrong source attribution", + probeEvents.Select(record => record.Replace("src/probe.rs", "src/wrong.rs")), + validProbeManifest, "exact source attribution is invalid"); + RejectProbeRecords("writer event-count mismatch", probeEvents, + validProbeManifest.Replace("\"written\":3", "\"written\":4"), "invalid schema or writer counts"); + RejectProbeRecords("writer enqueue mismatch", probeEvents, + validProbeManifest.Replace("\"enqueued\":3", "\"enqueued\":4"), "writer counters do not reconcile"); + RejectProbeRecords("dropped events", probeEvents, + validProbeManifest.Replace("\"dropped\":0", "\"dropped\":1"), "writer counters do not reconcile"); + RejectProbeRecords("coverage mismatch", probeEvents, + validProbeManifest.Replace("\"discoveredMembers\":2", "\"discoveredMembers\":3"), "coverage counters are inconsistent"); + string otherProbeTrace = Path.Combine(probeDirectory, "run.other.ndjson"); + string otherProbeManifest = Path.Combine(probeDirectory, "run.other.manifest.ndjson"); + try + { + string twoEventManifest = validProbeManifest.Replace("\"enqueued\":3,\"written\":3", "\"enqueued\":2,\"written\":2"); + WriteProbeRecords(new[] { probeEvents[0], probeEvents[2] }, twoEventManifest); + File.WriteAllLines(otherProbeTrace, new[] { probeEvents[1], probeEvents[2] }); + File.WriteAllText(otherProbeManifest, twoEventManifest); + ReadinessProbe.ValidateFixture(probeDirectory, "probe_echo", "probe.rs"); + Assert(true, "Two independently valid process pairs with colliding call IDs must pass."); + + File.WriteAllLines(otherProbeTrace, new[] { probeEvents[2] }); + File.WriteAllText(otherProbeManifest, + validProbeManifest.Replace("\"enqueued\":3,\"written\":3", "\"enqueued\":1,\"written\":1")); + RejectProbeRecords("root borrowed from another process", probeEvents.Take(2), twoEventManifest, "not correlated"); + RejectProbeRecords("member and root borrowed from another process", probeEvents.Take(2), + twoEventManifest.Replace("fixture::probe_echo", "fixture::unrelated") + .Replace("\"isTestRoot\":true", "\"isTestRoot\":false"), + "no successfully instrumented manifest member"); + } + finally + { + File.Delete(otherProbeTrace); + File.Delete(otherProbeManifest); + } + foreach (string output in new[] + { + "Probe.csproj : error NU1101: Unable to find package xunit.", + "error: no matching package named `sha2` found\nlocation searched: crates.io index", + "[ERROR] Cannot access central in offline mode and the artifact has not been downloaded from it before.", + "> No cached version of org.junit.jupiter:junit-jupiter:5.11.4 available for offline mode.", + "npm error code ENOTCACHED", + "python.exe: No module named pytest", + }) + Assert(ReadinessProbe.IsMissingProbeDependency(output), "Recognized missing offline dependency must be classified as blocked."); + foreach (string output in new[] + { + "cargo test --offline\nerror[E0308]: mismatched types", + "[ERROR] COMPILATION ERROR\n[INFO] Running in offline mode", + "AssertionError: unable to resolve expected value", + "SyntaxError: invalid syntax\nRun --offline without downloads.", + "ModuleNotFoundError: No module named 'probe_subject'", + "AssertionError: value was not in cache", + "npm error code ELIFECYCLE\nTests failed while npm_config_offline=true", + }) + Assert(!ReadinessProbe.IsMissingProbeDependency(output), "Test/build failures must not be disguised as missing dependency caches."); + + foreach ((string script, string expected) in new[] + { + ("node test/run.cjs", "node probe.run.cjs"), + ("node --test test/*.test.js", "node --test probe.test.cjs"), + ("node --enable-source-maps --test test/*.test.js", "node --enable-source-maps --test probe.test.cjs"), + ("node --test --test-concurrency=1", "node --test --test-concurrency=1 probe.test.cjs"), + }) + Assert(ReadinessProbe.GetNodeProbeCommand(script) == expected, "Supported Node fixture route was not selected."); + foreach (string script in new[] + { + "node --test && echo success", "node --test; echo success", "node --require hook.cjs --test", + "node --loader custom.mjs --test", "node node_modules/jest/bin/jest.js", "npm run build && node test/run.cjs", + "jest", "vitest run", "node -e \"execute()\"", "node test/run.cjs --custom-option", "node\n--test", + }) + Assert(ReadinessProbe.GetNodeProbeCommand(script) == null, "Opaque Node shell/framework route must stay unsupported."); + + if (OperatingSystem.IsWindows()) + { + string scriptDirectory = Path.Combine(root, "windows script"); + Directory.CreateDirectory(scriptDirectory); + string script = Path.Combine(scriptDirectory, "fixture runner.cmd"); + File.WriteAllText(script, "@echo off\r\necho ARG=[%~1]\r\nexit /b 0\r\n"); + ProcessResult spaced = Shell.DiagnoseWindowsScript(script, new[] { "argument with spaces" }, scriptDirectory); + Assert(spaced.Ok && spaced.Output.Trim() == "ARG=[argument with spaces]", + "Windows diagnostics must preserve spaces in both a .cmd path and its argument."); + foreach (string invalid in new[] { "bad%value", "bad!value", "bad\"value", "bad\rvalue", "bad\nvalue" }) + { + foreach (bool inPath in new[] { false, true }) + { + bool rejected = false; + try + { + Shell.DiagnoseWindowsScript(inPath ? Path.Combine(scriptDirectory, invalid + ".cmd") : script, + new[] { inPath ? "safe" : invalid }, scriptDirectory); + } + catch (ArgumentException) + { + rejected = true; + } + Assert(rejected, "Windows diagnostics must reject unsafe characters in script " + (inPath ? "paths." : "arguments.")); + } + } + } + + string pipelineRepository = Path.Combine(root, "go"); + Assert(Shell.Diagnose("git", new[] { "-C", pipelineRepository, "add", "." }, root).Ok, "Could not stage fixture."); + Assert(Shell.Diagnose("git", new[] { "-C", pipelineRepository, "-c", "user.name=Readiness proof", + "-c", "user.email=readiness@example.invalid", "commit", "--quiet", "-m", "fixture" }, root).Ok, + "Could not create fixture history."); + string pipelineWork = Path.Combine(root, "pipeline"); + var pipeline = new Pipeline(pipelineRepository, "HEAD", "HEAD", null, pipelineWork, + Path.Combine(pipelineWork, "findings.json"), null, false, null, TimeSpan.FromDays(1), null, false, false); + try + { + pipeline.Run(); + Assert(false, "Analysis must refuse before building with missing prerequisites."); + } + catch (CliException exception) + { + Assert(exception.ExitCode == 3, "Readiness must be a typed refusal."); + } + using (var readinessSet = JsonDocument.Parse(File.ReadAllText(Path.Combine(pipelineWork, "readiness.json")))) + { + Assert(readinessSet.RootElement.GetProperty("status").GetString() == "blocked", "Missing engine lost the readiness report."); + Assert(readinessSet.RootElement.GetProperty("revisions").GetArrayLength() == 2, + "Automatic analysis must inspect both revisions independently."); + } + Assert(!Directory.EnumerateFiles(pipelineWork, "*.ndjson", SearchOption.AllDirectories).Any(), + "Blocked readiness must not produce runtime traces."); + Console.WriteLine($"Readiness proof passed: {assertions} assertions."); +} +finally +{ + Console.SetOut(stdout); + ReadinessExecution.Clear(); + Environment.SetEnvironmentVariable("REALDIFF_RUST_ENGINE", oldEngine); + Environment.SetEnvironmentVariable("REALDIFF_LAUNCHER_CONFIG", oldHandoff); + foreach (string file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, File.GetAttributes(file) & ~FileAttributes.ReadOnly); + Directory.Delete(root, recursive: true); +} diff --git a/tools/ReadinessProof/ReadinessProof.csproj b/tools/ReadinessProof/ReadinessProof.csproj new file mode 100644 index 0000000..db0958f --- /dev/null +++ b/tools/ReadinessProof/ReadinessProof.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + RealDiff.ReadinessProof + enable + enable + + + + + diff --git a/tools/verify-cli-package.ps1 b/tools/verify-cli-package.ps1 index c2ee053..beab133 100644 --- a/tools/verify-cli-package.ps1 +++ b/tools/verify-cli-package.ps1 @@ -20,6 +20,7 @@ $previousGoRewriter = $env:REALDIFF_GO_REWRITER $previousRustTracer = $env:REALDIFF_RUST_TRACER $previousPythonTracer = $env:REALDIFF_PYTHON_TRACER $previousPython = $env:REALDIFF_PYTHON +$previousNuGetPackages = $env:NUGET_PACKAGES function Invoke-Checked([string]$label, [scriptblock]$command) { & $command | ForEach-Object { Write-Host $_ } @@ -109,11 +110,23 @@ function Assert-RunArtifacts( [pscustomobject]@{ Language = $language; Runs = 4; Events = $events.Count; Tracer = $selectedPath } } +function Invoke-FixtureProof([string]$language, [object]$reference, [string]$cli) { + $readiness = Join-Path $work "$language-readiness.json" + Invoke-Checked "$language installed fixture probe" { + & $cli doctor $reference.Directory --probe --out $readiness + } + $report = Get-Content $readiness -Raw | ConvertFrom-Json + if ($report.status -ne 'ready' -or -not @($report.checks | Where-Object { + $_.id -eq 'probe.fixture' -and $_.status -eq 'passed' + }).Count) { throw "$language installed fixture did not establish readiness" } +} + function Invoke-LanguageProof([string]$language, [object]$reference, [string]$cli) { $runWork = Join-Path $work "$language-work" $findings = Join-Path $work "$language-findings.json" + Invoke-FixtureProof $language $reference $cli $output = @(& $cli $reference.Directory --base $reference.Base --pr $reference.Pr ` - --work $runWork --findings $findings --keep-traces 1d 2>&1) + --work $runWork --findings $findings --keep-traces 1d --readiness-probe 2>&1) $exitCode = $LASTEXITCODE $output | ForEach-Object { Write-Host $_ } if ($exitCode -ne 0) { throw "$language installed CLI invocation failed with exit code $exitCode" } @@ -125,6 +138,9 @@ try { Write-Host '=== Stage cross-language tracers ===' -ForegroundColor Cyan & (Join-Path $PSScriptRoot 'Stage-CrossLanguageTracers.ps1') -OutputDirectory $tracers if ($LASTEXITCODE -ne 0) { throw "Tracer staging failed with exit code $LASTEXITCODE" } + Invoke-Checked 'Prepare Java test-provider cache for offline probes' { + & mvn --batch-mode --no-transfer-progress -q -f (Join-Path $repo 'src/RealDiff.Java.Agent/pom.xml') test + } Write-Host '=== Stage Rust engine ===' -ForegroundColor Cyan & (Join-Path $PSScriptRoot 'Stage-RustEngine.ps1') -OutputDirectory $rustEngine if ($LASTEXITCODE -ne 0) { throw "Rust engine staging failed with exit code $LASTEXITCODE" } @@ -171,6 +187,16 @@ try { 'tools/net8.0/any/tracers/python/realdiff_python/canonical.py', 'tools/net8.0/any/tracers/python/realdiff_python/pytest_plugin.py', 'tools/net8.0/any/Mono.Cecil.dll', + 'tools/net8.0/any/readiness-fixtures/dotnet/Probe.csproj', + 'tools/net8.0/any/readiness-fixtures/dotnet/subject/ProbeSubject.cs', + 'tools/net8.0/any/readiness-fixtures/java/pom.xml', + 'tools/net8.0/any/readiness-fixtures/java/build.gradle', + 'tools/net8.0/any/readiness-fixtures/node-cjs/probe.js', + 'tools/net8.0/any/readiness-fixtures/node-esm/probe.js', + 'tools/net8.0/any/readiness-fixtures/node-ts/probe.ts', + 'tools/net8.0/any/readiness-fixtures/go/probe.go', + 'tools/net8.0/any/readiness-fixtures/rust/src/lib.rs', + 'tools/net8.0/any/readiness-fixtures/python/probe_subject.py', $goTracerPackageEntry, $rustPackageEntry, $rustTracerPackageEntry @@ -191,19 +217,41 @@ try { } $version = $versionNode.InnerText.Trim() Write-Host '=== Install packed CLI ===' -ForegroundColor Cyan + $env:NUGET_PACKAGES = Join-Path $work 'install-cache' + $installConfig = Join-Path $work 'install.NuGet.Config' + ('' ` + -f [Security.SecurityElement]::Escape($packages)) | Set-Content $installConfig Invoke-Checked 'CLI tool install' { & dotnet tool install RealDiff.Tool --tool-path $toolPath --version $version ` - --add-source $packages --ignore-failed-sources + --configfile $installConfig } $launcher = if ($IsWindows) { 'realdiff.exe' } else { 'realdiff' } $cli = Join-Path $toolPath $launcher if (-not (Test-Path $cli -PathType Leaf)) { throw "Installed CLI launcher was not found: $cli" } + if (-not $IsWindows) { + # NuGet extraction may discard native payload modes; installation, not doctor, repairs them. + foreach ($file in Get-ChildItem $toolPath -File -Recurse | Where-Object { + $_.Name -in @('realdiff-engine', 'realdiff-go-rewrite', 'realdiff-rust-rewrite') + }) { + [IO.File]::SetUnixFileMode($file.FullName, + [IO.File]::GetUnixFileMode($file.FullName) -bor [IO.UnixFileMode]::UserExecute) + } + } $env:REALDIFF_JAVA_AGENT = $null $env:REALDIFF_NODE_TRACER = $null $env:REALDIFF_GO_REWRITER = $null $env:REALDIFF_RUST_TRACER = $null $env:REALDIFF_PYTHON_TRACER = $null + $dotnetFixtures = @(Get-ChildItem $toolPath -Directory -Recurse | Where-Object { + $_.Name -eq 'dotnet' -and $_.Parent.Name -eq 'readiness-fixtures' + }) + if ($dotnetFixtures.Count -ne 1) { throw 'The installed .NET readiness fixture is missing or duplicated' } + $dotnet = New-ReferenceRepository 'dotnet' $dotnetFixtures[0].FullName + Invoke-Checked 'Prepare .NET fixture dependencies for offline probing' { + & dotnet restore (Join-Path $dotnet.Directory 'Probe.csproj') --nologo --verbosity quiet + } + Invoke-FixtureProof 'dotnet' $dotnet $cli $java = New-ReferenceRepository 'java' (Join-Path $repo 'samples/JavaReference') $node = New-ReferenceRepository 'node' (Join-Path $repo 'samples/NodeReference') $go = New-ReferenceRepository 'go' (Join-Path $repo 'samples/GoReference') @@ -237,6 +285,7 @@ finally { $env:REALDIFF_RUST_TRACER = $previousRustTracer $env:REALDIFF_PYTHON_TRACER = $previousPythonTracer $env:REALDIFF_PYTHON = $previousPython + $env:NUGET_PACKAGES = $previousNuGetPackages if ($ownsWork -and -not $KeepWork) { Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue } else { diff --git a/tools/verify-language-detection.ps1 b/tools/verify-language-detection.ps1 index b4632db..5fddd4a 100644 --- a/tools/verify-language-detection.ps1 +++ b/tools/verify-language-detection.ps1 @@ -66,6 +66,7 @@ try { Assert-Language 'node' 'package.json' 'node' 'npm ci' 'npm run test' Assert-Language 'go' 'go.mod' 'go' 'go build ./...' 'go test ./...' Assert-Language 'rust' 'Cargo.toml' 'rust' 'cargo build' 'cargo test -- --test-threads=1' + Assert-Language 'python' 'pyproject.toml' 'python' '' 'python -m pytest' Assert-NodeManager 'node-npm' 'package-lock.json' 'npm ci && npm run build' 'npm run test' Assert-NodeManager 'node-pnpm' 'pnpm-lock.yaml' 'pnpm install --frozen-lockfile && pnpm run build' 'pnpm run test' diff --git a/tools/verify-readiness.ps1 b/tools/verify-readiness.ps1 new file mode 100644 index 0000000..eda1c90 --- /dev/null +++ b/tools/verify-readiness.ps1 @@ -0,0 +1,8 @@ +#requires -Version 7.0 +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$project = Join-Path $PSScriptRoot 'ReadinessProof/ReadinessProof.csproj' +& dotnet run --project $project --configuration Release +if ($LASTEXITCODE -ne 0) { throw "Readiness proof failed with exit $LASTEXITCODE" } diff --git a/tools/verify-repository-config.ps1 b/tools/verify-repository-config.ps1 index 882835d..c70bc17 100644 --- a/tools/verify-repository-config.ps1 +++ b/tools/verify-repository-config.ps1 @@ -75,25 +75,28 @@ baseline: Invoke-Checked 'CLI build' { & dotnet build $project -c Release --nologo -v quiet } $nodeTracer = Join-Path $repo 'src/RealDiff.Node' - Invoke-Checked 'Node tracer install' { & npm ci --prefix $nodeTracer --ignore-scripts --no-audit --no-fund } $env:REALDIFF_NODE_TRACER = $nodeTracer $output = @(Invoke-RealDiff @($fixture, '--base', $base, '--pr', $pr, '--work', $analysisWork, '--findings', $findings, '--no-cache', '--keep', '--keep-traces', '1d') 2>&1) $exit = $LASTEXITCODE $output | ForEach-Object { Write-Host $_ } - if ($exit -ne 0) { throw "configured analysis exited $exit" } + if ($exit -ne 3) { throw "opaque configured commands must be refused by readiness; got exit $exit" } $configuredBuilds = @($output | Where-Object { $_ -match '^ (base|pr) configured command:' }).Count - if ($configuredBuilds -ne 2) { - throw "expected two configured build invocations, got $configuredBuilds" + if ($configuredBuilds -ne 0) { + throw "readiness must refuse before configured builds execute, got $configuredBuilds" } $document = Get-Content $findings -Raw | ConvertFrom-Json - if ($document.status -ne 'analyzed' -or -not $document.isCleanResult) { - throw "configured findings were not clean analyzed output" + if ($document.status -ne 'refused' -or $document.isCleanResult) { + throw "unverified custom commands must produce refused findings" } $eventCount = @(Get-ChildItem $analysisWork -Recurse -File -Filter 'run.*.ndjson' | Where-Object Name -NotLike '*.manifest.ndjson' | ForEach-Object { Get-Content $_.FullName }).Count - if ($eventCount -le 0) { throw 'configured test command produced no trace events' } + if ($eventCount -ne 0) { throw 'readiness refusal unexpectedly executed tests' } + $readiness = Get-Content (Join-Path $analysisWork 'readiness.json') -Raw | ConvertFrom-Json + $unknownCommands = @($readiness.revisions | ForEach-Object checks | + Where-Object { $_.id -eq 'commands.selection' -and $_.status -eq 'unknown' }) + if ($unknownCommands.Count -ne 2) { throw 'both revisions must expose unknown command selection' } (Get-Content (Join-Path $fixture '.realdiff/config.yml') -Raw).Replace('test: npm test', 'test: node -e "process.exit(0)"') | Set-Content (Join-Path $fixture '.realdiff/config.yml') @@ -113,12 +116,11 @@ baseline: throw "instrumentation bypass exit was $bypassExit, expected 3: $($bypassOutput -join "`n")" } $bypassText = $bypassOutput -join "`n" - if ($bypassText -notmatch 'NO EVENTS: base_run1 produced 1 trace file\(s\), 0 event\(s\), and 1 manifest\(s\)' ` - -or $bypassText -notmatch 'configured command: node -e "process.exit\(0\)"') { - throw "instrumentation bypass refusal omitted the zero-event/configured-command evidence: $bypassText" + if ($bypassText -notmatch 'commands.selection' -or $bypassText -match 'configured command: node -e') { + throw "instrumentation bypass must be refused before command execution: $bypassText" } - Write-Host 'Repository custom command execution: PASS' -ForegroundColor Green + Write-Host 'Repository custom command readiness refusal: PASS' -ForegroundColor Green Write-Host " builds=$configuredBuilds tracedEvents=$eventCount bypassExit=$bypassExit" } finally {