From 12833d7fe4d04b0d394a7f1ea405aded217ef93f Mon Sep 17 00:00:00 2001 From: jamesadevine Date: Sat, 13 Jun 2026 22:22:35 +0100 Subject: [PATCH 01/17] feat(inspect): add IR-based pipeline reasoning tools (inspect, graph, whatif, lint, catalog, trace, mcp-author) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an agent-facing read-only surface over the typed pipeline IR so agents can reason about pipeline structure and failures programmatically. - New public summary types in `src/compile/ir/summary.rs` (PipelineSummary, GraphSummary, etc.) with `schema_version` pinned at 1. - New `compile::build_pipeline_ir(path) -> (FrontMatter, Pipeline)` read-only entry point that builds the typed IR without writing YAML. - New `src/inspect/` module + CLI subcommands: * `inspect ` — pipeline summary (text/json) * `graph dump|deps|outputs` — resolved dependency graph * `whatif --fail ` — static reachability over conditions * `lint ` — structural checks * `catalog --kind ` — safe-outputs/runtimes/tools/engines/models * `trace ` — joins ADO timeline with the IR graph - New `src/mcp_author/` stdio MCP server exposing every command above as a read-only MCP tool for IDE / Copilot Chat integration. - Audit integration: `AuditData.pipeline_graph`, `JobData.upstream_jobs` / `downstream_jobs`, and a new finding kind for downstream-skip impact. - Docs: `docs/mcp-author.md`, public-JSON-summary section in `docs/ir.md`, new commands in `docs/cli.md`, audit fields in `docs/audit.md`, prompt updates for debug + update workflows. Build: `cargo build`, `cargo test` (1871 + 4 passed), `cargo clippy` (0 warnings). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 24 +- docs/audit.md | 22 +- docs/cli.md | 23 + docs/ir.md | 80 +++ docs/mcp-author.md | 72 +++ prompts/debug-ado-agentic-workflow.md | 31 ++ prompts/update-ado-agentic-workflow.md | 20 + src/audit/analyzers/jobs.rs | 1 + src/audit/cli.rs | 92 +++- src/audit/findings.rs | 103 +++- src/audit/mod.rs | 8 +- src/audit/model.rs | 23 + src/audit/pipeline_graph.rs | 252 +++++++++ src/audit/render/console.rs | 4 + src/audit/render/json.rs | 2 + src/compile/ir/mod.rs | 1 + src/compile/ir/summary.rs | 679 ++++++++++++++++++++++++ src/compile/mod.rs | 102 +++- src/inspect/catalog.rs | 322 ++++++++++++ src/inspect/cli.rs | 366 +++++++++++++ src/inspect/graph_deps.rs | 685 +++++++++++++++++++++++++ src/inspect/graph_outputs.rs | 317 ++++++++++++ src/inspect/graph_query.rs | 168 ++++++ src/inspect/lint.rs | 429 ++++++++++++++++ src/inspect/mod.rs | 43 ++ src/inspect/trace.rs | 410 +++++++++++++++ src/inspect/whatif.rs | 450 ++++++++++++++++ src/main.rs | 226 +++++++- src/mcp_author/mod.rs | 344 +++++++++++++ src/mcp_author/tests.rs | 83 +++ tests/inspect_integration.rs | 116 +++++ 31 files changed, 5460 insertions(+), 38 deletions(-) create mode 100644 docs/mcp-author.md create mode 100644 src/audit/pipeline_graph.rs create mode 100644 src/compile/ir/summary.rs create mode 100644 src/inspect/catalog.rs create mode 100644 src/inspect/cli.rs create mode 100644 src/inspect/graph_deps.rs create mode 100644 src/inspect/graph_outputs.rs create mode 100644 src/inspect/graph_query.rs create mode 100644 src/inspect/lint.rs create mode 100644 src/inspect/mod.rs create mode 100644 src/inspect/trace.rs create mode 100644 src/inspect/whatif.rs create mode 100644 src/mcp_author/mod.rs create mode 100644 src/mcp_author/tests.rs create mode 100644 tests/inspect_integration.rs diff --git a/AGENTS.md b/AGENTS.md index 4c8fd31b..bc495458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,20 @@ Every compiled pipeline runs as three sequential jobs: │ │ │ ├── 0002_pool_object_form.rs # Legacy scalar pool → object form codemod │ │ │ └── helpers.rs # take_key, insert_no_overwrite, rename_key, ConflictPolicy │ │ ├── codemod_integration_test.rs # White-box rewrite-path tests (stub registry injection) -│ │ └── types.rs # Front matter grammar and types +│ │ ├── types.rs # Front matter grammar and types +│ │ └── ir/ # Typed Azure DevOps pipeline IR (see docs/ir.md) +│ │ ├── mod.rs # Pipeline / PipelineBody / PipelineShape root types +│ │ ├── ids.rs # Typed StageId / JobId / StepId newtypes +│ │ ├── step.rs # Step variants (Bash, Task, Checkout, Download, Publish, RawYaml) +│ │ ├── job.rs # Job, Pool, TemplateContext, JobVariable +│ │ ├── stage.rs # Stage + external-params wrap +│ │ ├── env.rs # Typed EnvValue (Literal, AdoMacro, PipelineVar, Secret, StepOutput, Coalesce, Concat) +│ │ ├── condition.rs # Typed Condition / Expr AST + codegen to ADO condition syntax +│ │ ├── output.rs # OutputDecl / OutputRef + location-aware lowering +│ │ ├── graph.rs # Dependency graph: validation, edge derivation, isOutput promotion, cycle detection +│ │ ├── lower.rs # IR → serde_yaml::Value lowering +│ │ ├── emit.rs # Thin `lower() + serde_yaml::to_string()` wrapper +│ │ └── summary.rs # Public, serializable PipelineSummary / GraphSummary for agent-facing tooling (see docs/ir.md Public JSON summary) │ ├── init.rs # Repository initialization for AI-first authoring │ ├── execute.rs # Stage 3 safe output execution │ ├── fuzzy_schedule.rs # Fuzzy schedule parsing @@ -130,6 +143,10 @@ Every compiled pipeline runs as three sequential jobs: │ │ ├── mod.rs │ │ ├── console.rs # Human-readable console report │ │ └── json.rs # Machine-readable AuditData JSON +│ ├── inspect/ # `ado-aw inspect` / `graph` / (planned) `trace` / `whatif` / `lint` / `catalog` — read-only IR queries +│ │ ├── mod.rs # Module entry; public re-exports of every dispatcher +│ │ ├── cli.rs # Dispatchers (`dispatch_inspect`, `dispatch_graph`, …) and option structs +│ │ └── graph_query.rs # Text/DOT renderers for the resolved dependency graph │ ├── detect.rs # Agentic pipeline detection — discovers compiled pipelines; used by all lifecycle commands │ ├── update_check.rs # Version update check — queries GitHub Releases and prints advisory when newer version is available │ ├── ndjson.rs # NDJSON parsing utilities @@ -276,7 +293,7 @@ index to jump to the right page. ### Compiler internals & operations -- [`docs/ir.md`](docs/ir.md) — typed Azure DevOps pipeline IR (`Pipeline`, jobs/stages/steps, output refs, graph pass, lowering, and target builders). +- [`docs/ir.md`](docs/ir.md) — typed Azure DevOps pipeline IR (`Pipeline`, jobs/stages/steps, output refs, graph pass, lowering, target builders, and the public JSON summary consumed by agent-facing tooling). - [`docs/cli.md`](docs/cli.md) — `ado-aw` CLI commands (`init`, `compile`, `check`, `mcp`, `mcp-http`, `execute`, `secrets`, `enable`, `disable`, `remove`, `list`, `status`, `run`, `audit`; `configure` is a deprecated hidden alias). @@ -285,6 +302,9 @@ index to jump to the right page. report shape. - [`docs/mcp.md`](docs/mcp.md) — MCP server configuration (stdio containers, HTTP servers, env passthrough). +- [`docs/mcp-author.md`](docs/mcp-author.md) — author-facing MCP server + (stdio); exposes `inspect`, `graph`, `whatif`, `lint`, `catalog`, `trace`, + `audit_build` over MCP for IDE/Copilot Chat agents. - [`docs/mcpg.md`](docs/mcpg.md) — MCP Gateway architecture and pipeline integration. - [`docs/network.md`](docs/network.md) — AWF network isolation, default diff --git a/docs/audit.md b/docs/audit.md index d2c139d8..6d748e64 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -86,9 +86,10 @@ Current top-level keys include the following. Optional sections are omitted from | `rejected_safe_outputs` | Rollup of rejections by reason / threat flag. | | `detection_analysis` | `threat-analysis.json`. | | `mcp_server_health` | MCPG logs aggregated per server. | +| `pipeline_graph` | Optional typed-IR `PipelineSummary` rebuilt from local source metadata (`aw_info.json.source`) for graph correlation. | | `mcp_tool_usage` | MCPG logs aggregated per `(server, tool)`. | | `mcp_failures` | MCPG `tool_error` / `server_error` events. | -| `jobs` | ADO `/timeline` records filtered to `type: Job`. | +| `jobs` | ADO `/timeline` records filtered to `type: Job`; when `pipeline_graph` is available, each entry may include `upstream_jobs` and `downstream_jobs` from IR job edges. | | `firewall_analysis` | AWF Squid proxy logs aggregated by domain. | | `policy_analysis` | AWF policy artifacts aggregated into allow / deny summaries. | | `missing_tools` / `missing_data` / `noops` | NDJSON entries from the corresponding SafeOutputs MCP tools. | @@ -109,6 +110,23 @@ Additionally, exactly one severity-`high` finding is emitted summarizing the gat Per-item detection verdicts are not currently available. `threat-analysis.md` emits an aggregate verdict only; per-item verdicts are a follow-up that should stay aligned with gh-aw. +## Pipeline graph correlation + +After the standard analyzers run, `audit` looks for +`agent_outputs[_]/staging/aw_info.json` (falling back to the artifact +top level) and resolves its `source` path relative to the current working +directory. If that markdown source exists locally, the command rebuilds the +typed IR with the same public summary shape emitted by `ado-aw inspect --json` +and stores it under `pipeline_graph.graph`. The audit embeds the full +`PipelineSummary` rather than a reduced subset so audit, inspect, graph, and +trace consumers share one schema. + +When graph correlation succeeds, `jobs[]` entries also gain optional +`upstream_jobs` and `downstream_jobs` arrays. These are omitted when empty or +when the source markdown is unavailable locally. Failed jobs with downstream +edges emit a medium-severity finding summarizing the downstream runtime +classifications. + ## Cache behavior `/build-/run-summary.json` is written after a successful run. On subsequent invocations against the same build: @@ -135,7 +153,7 @@ Per-item detection verdicts are not currently available. `threat-analysis.md` em ## Related Documentation -- [CLI Commands](cli.md) — full CLI reference +- [CLI Commands](cli.md) — full CLI reference, including `trace` - [Front Matter](front-matter.md) — agent file format - [Safe Outputs](safe-outputs.md) — what proposals look like - [Network](network.md) — AWF firewall configuration diff --git a/docs/cli.md b/docs/cli.md index 2e75de4d..ad1b3020 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,6 +23,7 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - Useful for CI checks to ensure pipelines are regenerated after source changes - `mcp ` - Run SafeOutputs as a stdio MCP server - `--enabled-tools ` - Restrict available tools to those named (repeatable) +- `mcp-author` - Run the author-facing stdio MCP server for IDE/Copilot Chat integrations. See [`mcp-author.md`](mcp-author.md) for the full tool surface and trust model. - `mcp-http ` - Run SafeOutputs as an HTTP MCP server (for MCPG integration) - `--port ` - Port to listen on (default: 8100) - `--api-key ` - API key for authentication (auto-generated if not provided) @@ -143,6 +144,28 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe - `--no-cache` - Ignore `/build-/run-summary.json` and re-process the build. - See [`audit.md`](audit.md) for accepted build-reference formats, output layout, cache semantics, and the `AuditData` report shape. +- `trace [--step ] [--json]` - Query audit telemetry plus local typed-IR graph correlation to explain failed-job chains and downstream skip classifications. Uses `./logs/build-/` cache when present and degrades to runtime-only output when the source markdown is not local. + - `--step ` - Focus the report on a named IR step and show the containing job's runtime status plus upstream/downstream job classifications. + - `--json` - Emit a structured `TraceReport`. + - `--org `, `--project `, `--pat ` / `AZURE_DEVOPS_EXT_PAT` - Same ADO context/auth passthroughs as `audit`. + +- `inspect [--json]` - Build the typed IR for an agent source file and emit a terse summary (jobs, stages, steps, output decls, derived `dependsOn` edges, isOutput-promoted outputs). + - `` - Path to the agent markdown file. + - `--json` - Emit the full [`PipelineSummary`](ir.md#public-json-summary-irsummary) as pretty-printed JSON instead of the human view. + - No YAML is written; this is a read-only query over the same IR the compiler builds. + +- `graph dump [--format text|json|dot]` - Print the resolved dependency graph (job edges, stage edges, step locations, outputs needing `isOutput=true`). The graph dump now uses an explicit `dump` subcommand so `graph deps` and `graph outputs` can share the namespace. + - `` - Path to the agent markdown file. + - `--format text` - Default. Human-scannable plain text. + - `--format json` - Emit the [`GraphSummary`](ir.md#public-json-summary-irsummary) JSON. + - `--format dot` - Emit Graphviz DOT. Pipe to `dot -Tsvg -o pipeline.svg` to visualize. + +- `graph deps [--direction upstream|downstream] [--json]` - Traverse transitive job and step-output dependencies for one step. If `` names a job with no matching step, the command falls back to job-level traversal. + +- `graph outputs [--producer ] [--consumer ] [--json]` - Print declared step outputs and the steps that read them from `env` or `condition`. + +- `whatif --fail [--json]` - Statically classify downstream jobs that would be skipped, or would run anyway, if a step or job failed. + ### Hidden Build-Time Tools These commands are not shown in `--help` but are available for contributors working on the ado-aw compiler itself: diff --git a/docs/ir.md b/docs/ir.md index 0f289b42..e6f3001a 100644 --- a/docs/ir.md +++ b/docs/ir.md @@ -263,3 +263,83 @@ The production target wrappers are: The canonical 5-job Setup → Agent → Detection → SafeOutputs → Teardown shape itself lives in `agentic_pipeline.rs` and is reused unchanged by every wrapper above; extensions plug into it via `Declarations` (steps, env, hosts, MCPG entries, and Agent-job condition clauses — see `Declarations::agent_conditions`). When adding a target, follow the same pattern: parse and validate front matter, collect extension `Declarations`, build typed jobs/stages/steps, set the correct `PipelineShape`, and call the shared emit path. + +## Public JSON summary (`ir::summary`) + +The internal IR types (`Pipeline`, `Job`, `Step`, `Graph`, …) are +intentionally tied to the compiler's lowering needs and are **not** +public API. To give agent-facing tooling a stable view of a compiled +pipeline, `src/compile/ir/summary.rs` defines a parallel +**summary tree** with `#[derive(Serialize)]` that is consumed by: + +- `ado-aw inspect [--json]` — top-level pipeline summary. +- `ado-aw graph dump [--format text|json|dot]` — resolved + dependency graph (subset of the summary). +- `ado-aw graph deps ` and `ado-aw graph outputs + ` — focused graph queries over step dependencies and output + declaration/reference edges. +- `ado-aw whatif --fail ` — static + downstream skip classification from graph reachability and rendered + conditions. +- The `ado-aw audit` JSON (`AuditData.pipeline_graph`) and the + author-MCP server. + +### Stability contract + +`PipelineSummary::schema_version` (currently `1`) is the public schema +version. **Bump** it when the JSON shape changes in a way a downstream +consumer would notice (renamed field, removed variant, changed +semantics). Additive changes (new optional fields, new enum variants +in `unknown`-tolerant contexts) do not require a bump. + +The summary is the public schema. Internal IR types may change freely +without bumping the summary version, as long as the summary lowering +keeps the existing field set populated correctly. + +### Shape + +```jsonc +{ + "schema_version": 1, + "name": "", + "shape": "standalone" | "1es" | "job-template" | "stage-template", + "body": { "kind": "jobs", "jobs": [...] } + // OR + { "kind": "stages", "stages": [...] }, + "graph": { + "step_locations": [{ "step", "stage?", "job", "outputs": [...] }], + "job_edges": [{ "consumer", "producer" }], // consumer dependsOn producer + "stage_edges": [{ "consumer", "producer" }], + "outputs_needing_is_output": [{ "step", "outputs": [...] }] + } +} +``` + +Per-`JobSummary`: `id`, `stage?`, `display_name`, `depends_on`, +`condition?` (lowered ADO condition string), `pool`, `steps`. + +Per-`StepSummary`: `id?`, `kind` (`bash` / `task` / `checkout` / +`download` / `publish` / `raw_yaml`), `display_name?`, `task?`, +`condition?`, `outputs[]` (`{name, is_secret, auto_is_output}`), +`env_refs[]` (`{step, name}`), `condition_refs[]` (`{step, name}`). + +`condition?` is the lowered ADO condition string (e.g. +`"eq(dependencies.Detection.outputs['threatAnalysis.SafeToProcess'], 'true')"`), +not the typed AST — consumers don't need the AST to reason about +"would this run if X failed?". + +### Construction + +```rust +let (front_matter, pipeline) = ado_aw::compile::build_pipeline_ir(&source).await?; +let summary = ado_aw::compile::ir::summary::PipelineSummary::from_pipeline(&pipeline)?; +let json = serde_json::to_string_pretty(&summary)?; +``` + +`build_pipeline_ir` is the public read-only entry point: it parses +and sanitises front matter, runs the same target dispatch as +`compile_pipeline`, and returns the typed `Pipeline` without writing +any YAML. `PipelineSummary::from_pipeline` runs the graph pass +(reusing `graph::build_graph` for validation + edge derivation) and +populates `auto_is_output` for any output that has at least one +cross-step consumer — without mutating the input pipeline. diff --git a/docs/mcp-author.md b/docs/mcp-author.md new file mode 100644 index 00000000..5cc5ba3d --- /dev/null +++ b/docs/mcp-author.md @@ -0,0 +1,72 @@ +# Author MCP Server + +_Part of the [ado-aw documentation](../AGENTS.md)._ + +`ado-aw mcp-author` runs a local, author/debug-facing MCP server over stdio for +IDE and Copilot Chat integrations. It exposes read-only workflow inspection, +graph, lint, what-if, trace, and audit tools. + +It is **not** the SafeOutputs MCP server embedded in compiled pipelines. The +pipeline SafeOutputs server records proposed mutations for Stage 3 execution; +`mcp-author` is a local helper for humans and agents authoring or debugging +workflows. + +## Tool surface + +| Tool | Description | Input shape | +| --- | --- | --- | +| `inspect_workflow` | Build and return the public `PipelineSummary`. | `{ "source_path": "agents/example.md" }` | +| `graph_summary` | Return the resolved `GraphSummary`. | `{ "source_path": "agents/example.md" }` | +| `graph_dump` | Render the graph as text or Graphviz DOT. | `{ "source_path": "...", "format": "text" \| "dot" }` | +| `step_dependencies` | Traverse dependencies for a step or job id. | `{ "source_path": "...", "step_id": "Agent", "direction": "upstream" \| "downstream" }` | +| `step_outputs` | List declared outputs and consumers. | `{ "source_path": "...", "producer": null, "consumer": null }` | +| `trace_failure` | Trace a build's failed-job chain using audit data plus any local IR graph. | `{ "build_id_or_url": "123", "step": null, "org": null, "project": null, "pat": null }` | +| `whatif` | Classify downstream jobs if a step or job fails. | `{ "source_path": "...", "failing_id": "Agent" }` | +| `lint_workflow` | Run structural lint checks. | `{ "source_path": "agents/example.md" }` | +| `catalog` | List safe-outputs, runtimes, tools, engines, and models. | `{ "kind": "safe-outputs" }` | +| `audit_build` | Download and analyze a build; same shape as `ado-aw audit --json`. | `{ "build_id_or_url": "123", "org": null, "project": null, "pat": null, "artifacts": null, "no_cache": false }` | + +## Trust model + +`mcp-author` runs as the invoking local user. It has no bounding directory, +sandbox, or pipeline-style filesystem restrictions. ADO-facing calls (`audit`, +`trace`) use the same `resolve_auth()` path as `ado-aw audit`: explicit PAT, +environment, or Azure CLI fallback depending on local configuration. + +## IDE configuration + +### VS Code MCP + +```json +{ + "mcp": { + "servers": { + "ado-aw-author": { + "command": "ado-aw", + "args": ["mcp-author"] + } + } + } +} +``` + +### Claude Desktop + +Add this to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "ado-aw-author": { + "command": "ado-aw", + "args": ["mcp-author"] + } + } +} +``` + +## Related references + +- [`docs/ir.md#public-json-summary-irsummary`](ir.md#public-json-summary-irsummary) — public summary schema contract. +- [`docs/audit.md`](audit.md) — `audit_build` and `trace_failure` build reference and report details. +- [`docs/cli.md`](cli.md) — CLI counterparts for every MCP tool. diff --git a/prompts/debug-ado-agentic-workflow.md b/prompts/debug-ado-agentic-workflow.md index 5c426bca..4a273322 100644 --- a/prompts/debug-ado-agentic-workflow.md +++ b/prompts/debug-ado-agentic-workflow.md @@ -79,6 +79,37 @@ The output JSON contains the full `AuditData` (see [What `ado-aw audit` extracts If the CLI is not available, fall through to the MCP-based steps below. +#### 2a-prime-bis. Pair `audit` with the IR (when you have local CLI access) + +`ado-aw audit` answers "what happened at runtime?". `ado-aw inspect` / +`graph` / `whatif` answer "what *should* happen, and what depends on +what?". Pair them when an audit finding points at a specific job / +step: + +```bash +# Get the typed-IR summary for the source the build came from +ado-aw inspect path/to/agent.md --json > ir.json + +# Print the resolved dependency graph (text, JSON, or Graphviz DOT) +ado-aw graph dump path/to/agent.md --format text +ado-aw graph dump path/to/agent.md --format dot | dot -Tsvg -o pipeline.svg +``` + +Use these to answer questions the audit alone cannot: + +- "Detection failed — which jobs were going to consume its output?" + → `ado-aw inspect --json | jq '.graph.job_edges[] | select(.producer == "Detection")'` +- "If `synthPr` failed, what skips downstream?" + → (when wired) `ado-aw whatif --fail synthPr` +- "Which step produced the empty output the agent step couldn't read?" + → `ado-aw inspect --json` then locate the `env_refs` / + `outputs_needing_is_output` entry that matches. + +The IR view is **statically derived from the agent source**, so it +reflects the pipeline shape the build was supposed to take. If the +build's compiled `.lock.yml` diverged from what the current source +would compile to, `ado-aw check ` will catch it. + #### 2a. Find the Pipeline Definition Use `mcp_ado_pipelines_get_build_definitions` to locate the pipeline by name or definition ID. diff --git a/prompts/update-ado-agentic-workflow.md b/prompts/update-ado-agentic-workflow.md index 93fb0f03..a3082566 100644 --- a/prompts/update-ado-agentic-workflow.md +++ b/prompts/update-ado-agentic-workflow.md @@ -58,6 +58,26 @@ permissions → parameters Run through the validation checklist (see below) before finalizing. Fix any issues and inform the user of corrections made. +When you have local CLI access, two read-only commands give a quick +structural sanity check **before** you recompile or hand off to the +user: + +```bash +# Compact summary of jobs, stages, steps, output decls, derived dependsOn +ado-aw inspect path/to/agent.md + +# Resolved dependency graph (text by default; --format dot pipes to Graphviz) +ado-aw graph dump path/to/agent.md +``` + +These build the typed IR from the source and answer "did my change +add/remove the expected jobs?" and "did the output / dependency wiring +end up where I expected?" without writing any YAML to disk. The audit +docs in [`docs/audit.md`](../docs/audit.md) and the IR JSON contract +in [`docs/ir.md`](../docs/ir.md#public-json-summary-irsummary) cover +the underlying `PipelineSummary` schema if you want to script against +the JSON form. + ### Step 4 — Recompile (if needed) After any **front matter** changes, the pipeline YAML must be regenerated: diff --git a/src/audit/analyzers/jobs.rs b/src/audit/analyzers/jobs.rs index 6051c184..a95b6b25 100644 --- a/src/audit/analyzers/jobs.rs +++ b/src/audit/analyzers/jobs.rs @@ -82,6 +82,7 @@ fn record_to_job(record: &Value) -> Option { started_at, finished_at, status, + ..Default::default() }) } diff --git a/src/audit/cli.rs b/src/audit/cli.rs index c83eb404..19f80dbb 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -15,6 +15,7 @@ use crate::audit::analyzers::{ use crate::audit::cache::{RunSummary, load_run_summary, save_run_summary}; use crate::audit::findings; use crate::audit::model::{AuditData, ErrorInfo, FileInfo, OverviewData}; +use crate::audit::pipeline_graph; use crate::audit::render; use crate::audit::url::{ParsedBuildRef, parse_build_ref}; @@ -30,6 +31,26 @@ pub struct AuditOptions<'a> { } pub async fn dispatch(opts: AuditOptions<'_>) -> Result<()> { + let result = fetch_audit_data_inner(opts).await?; + render_audit(&result.audit, result.json)?; + if !result.json && !result.from_cache { + eprintln!("✓ Audit complete. Reports in {}", result.run_dir.display()); + } + Ok(()) +} + +pub async fn fetch_audit_data(opts: AuditOptions<'_>) -> Result { + Ok(fetch_audit_data_inner(opts).await?.audit) +} + +struct FetchAuditDataResult { + audit: AuditData, + run_dir: PathBuf, + json: bool, + from_cache: bool, +} + +async fn fetch_audit_data_inner(opts: AuditOptions<'_>) -> Result { let parsed = parse_build_ref(opts.build_id_or_url)?; let artifact_filters = normalize_artifact_filters(opts.artifacts)?; let cwd = tokio::fs::canonicalize(".") @@ -52,8 +73,22 @@ pub async fn dispatch(opts: AuditOptions<'_>) -> Result<()> { summary.processed_at.to_rfc3339() ); } - render_audit(&summary.audit_data, opts.json)?; - return Ok(()); + let mut audit = summary.audit_data; + if let Err(error) = pipeline_graph::populate_pipeline_graph(&mut audit, &run_dir).await { + warn_and_record( + &mut audit, + "audit::pipeline_graph", + format!("pipeline graph correlation failed: {:#}", error), + ); + } + findings::derive_findings(&mut audit); + audit.metrics.warning_count = audit.warnings.len() as u64; + return Ok(FetchAuditDataResult { + audit, + run_dir, + json: opts.json, + from_cache: true, + }); } let client = reqwest::Client::builder() @@ -68,9 +103,16 @@ pub async fn dispatch(opts: AuditOptions<'_>) -> Result<()> { }; let filters = artifact_filters.as_deref(); - let saw_artifact_auth_error = - fetch_and_record_artifacts(&client, &ctx, &auth, parsed.build_id, filters, &run_dir, &mut audit) - .await?; + let saw_artifact_auth_error = fetch_and_record_artifacts( + &client, + &ctx, + &auth, + parsed.build_id, + filters, + &run_dir, + &mut audit, + ) + .await?; if saw_artifact_auth_error && !has_any_local_artifacts(&run_dir).await { anyhow::bail!( @@ -79,8 +121,24 @@ pub async fn dispatch(opts: AuditOptions<'_>) -> Result<()> { ); } - run_analyzers(&client, &ctx, &auth, parsed.build_id, filters, &run_dir, &mut audit).await; + run_analyzers( + &client, + &ctx, + &auth, + parsed.build_id, + filters, + &run_dir, + &mut audit, + ) + .await; populate_performance_metrics(&mut audit); + if let Err(error) = pipeline_graph::populate_pipeline_graph(&mut audit, &run_dir).await { + warn_and_record( + &mut audit, + "audit::pipeline_graph", + format!("pipeline graph correlation failed: {:#}", error), + ); + } audit.metrics.error_count = audit.errors.len() as u64; audit.metrics.warning_count = audit.warnings.len() as u64; @@ -97,11 +155,12 @@ pub async fn dispatch(opts: AuditOptions<'_>) -> Result<()> { ) .await?; - render_audit(&audit, opts.json)?; - if !opts.json { - eprintln!("✓ Audit complete. Reports in {}", run_dir.display()); - } - Ok(()) + Ok(FetchAuditDataResult { + audit, + run_dir, + json: opts.json, + from_cache: false, + }) } /// Download all selected artifacts for the build, recording auth errors and @@ -152,7 +211,10 @@ async fn fetch_and_record_artifacts( warn_and_record( audit, "audit::artifacts", - format!("failed to download artifact '{}': {:#}", artifact.name, error), + format!( + "failed to download artifact '{}': {:#}", + artifact.name, error + ), ); } } @@ -170,8 +232,7 @@ async fn fetch_and_record_artifacts( ); } Err(error) => { - return Err(error) - .context(format!("failed to list artifacts for build {}", build_id)); + return Err(error).context(format!("failed to list artifacts for build {}", build_id)); } } Ok(saw_artifact_auth_error) @@ -891,7 +952,8 @@ mod tests { #[test] fn validate_host_accepts_dev_azure_com_case_insensitively() { - validate_audit_url_host("Dev.Azure.Com", None).expect("cloud host match is case-insensitive"); + validate_audit_url_host("Dev.Azure.Com", None) + .expect("cloud host match is case-insensitive"); } #[test] diff --git a/src/audit/findings.rs b/src/audit/findings.rs index bdb53d0b..4828e931 100644 --- a/src/audit/findings.rs +++ b/src/audit/findings.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use crate::audit::model::{AuditData, Finding, Recommendation, Severity}; +use crate::audit::model::{AuditData, Finding, JobData, Recommendation, Severity}; /// Aggregate findings + recommendations from every populated section /// of `AuditData`. Pure function; does not mutate the input. @@ -21,6 +21,7 @@ pub fn derive_findings(audit: &mut AuditData) { add_missing_data_cluster(audit, &mut findings, &mut recommendations); add_no_safe_outputs_proposed(audit, &mut findings, &mut recommendations); add_error_count_findings(audit, &mut findings, &mut recommendations); + add_downstream_impact_findings(audit, &mut findings, &mut recommendations); audit.key_findings = findings; audit.recommendations = recommendations; @@ -363,6 +364,71 @@ fn add_error_count_findings( ); } +fn add_downstream_impact_findings( + audit: &AuditData, + findings: &mut Vec, + _recommendations: &mut Vec, +) { + for job in &audit.jobs { + if !job_failed(job) || job.downstream_jobs.is_empty() { + continue; + } + + let downstream = job + .downstream_jobs + .iter() + .map(|downstream_job| { + let classification = audit + .jobs + .iter() + .find(|candidate| job_name_matches(candidate, downstream_job)) + .map(job_classification) + .unwrap_or_else(|| String::from("expected to skip")); + format!("{downstream_job}: {classification}") + }) + .collect::>() + .join("; "); + + push_finding( + findings, + Finding { + category: String::from("pipeline_graph"), + severity: Severity::Medium, + title: format!("Downstream jobs skipped due to {} failure", job.name), + description: format!( + "The typed pipeline graph shows downstream impact from {}: {}.", + job.name, downstream + ), + impact: None, + }, + ); + } +} + +fn job_failed(job: &JobData) -> bool { + let result = job.result.as_deref().unwrap_or_default(); + result.eq_ignore_ascii_case("failed") + || result.eq_ignore_ascii_case("canceled") + || job.status.eq_ignore_ascii_case("failed") +} + +fn job_name_matches(job: &JobData, ir_job: &str) -> bool { + job.name == ir_job + || job + .name + .rsplit('.') + .next() + .is_some_and(|suffix| suffix == ir_job) +} + +fn job_classification(job: &JobData) -> String { + job.result + .as_deref() + .filter(|result| !result.trim().is_empty()) + .unwrap_or(&job.status) + .to_string() +} + fn push_finding(findings: &mut Vec, finding: Finding) { if !findings.contains(&finding) { findings.push(finding); @@ -379,7 +445,7 @@ fn push_recommendation(recommendations: &mut Vec, recommendation mod tests { use super::derive_findings; use crate::audit::model::{ - AuditData, DomainStat, Finding, FirewallAnalysis, MCPServerHealth, MCPServerStats, + AuditData, DomainStat, Finding, FirewallAnalysis, JobData, MCPServerHealth, MCPServerStats, MetricsData, MissingDataReport, MissingToolReport, NoopReport, Recommendation, SafeOutputSummary, Severity, }; @@ -661,6 +727,39 @@ mod tests { assert!(audit.recommendations.is_empty()); } + #[test] + fn downstream_impact_rule_emits_finding_for_failed_job() { + let mut audit = AuditData { + jobs: vec![ + JobData { + name: String::from("Agent"), + status: String::from("completed"), + result: Some(String::from("failed")), + downstream_jobs: vec![String::from("Detection"), String::from("SafeOutputs")], + ..Default::default() + }, + JobData { + name: String::from("Detection"), + status: String::from("completed"), + result: Some(String::from("skipped")), + ..Default::default() + }, + ], + ..Default::default() + }; + + derive_findings(&mut audit); + + let finding = finding_by_title(&audit, "Downstream jobs skipped due to Agent failure"); + assert_eq!(finding.severity, Severity::Medium); + assert!(finding.description.contains("Detection: skipped")); + assert!( + finding + .description + .contains("SafeOutputs: expected to skip") + ); + } + #[test] fn combined_findings_are_appended_and_preserved_across_passes() { let mut audit = AuditData { diff --git a/src/audit/mod.rs b/src/audit/mod.rs index 77778e0e..e75bfa19 100644 --- a/src/audit/mod.rs +++ b/src/audit/mod.rs @@ -7,10 +7,11 @@ pub mod cache; pub mod cli; pub mod findings; pub mod model; +pub mod pipeline_graph; pub mod render; pub mod url; -pub use cli::{AuditOptions, dispatch}; +pub use cli::{AuditOptions, dispatch, fetch_audit_data}; #[allow(unused_imports)] pub use model::*; @@ -26,7 +27,10 @@ pub use model::*; /// compares numerically so the highest-numbered build wins. pub(crate) fn cmp_numeric_suffix(a: &str, b: &str) -> std::cmp::Ordering { fn suffix(s: &str) -> u64 { - s.rsplit('_').next().and_then(|s| s.parse().ok()).unwrap_or(0) + s.rsplit('_') + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0) } suffix(a).cmp(&suffix(b)).then_with(|| a.cmp(b)) } diff --git a/src/audit/model.rs b/src/audit/model.rs index 0ff720fe..5bb26c2a 100644 --- a/src/audit/model.rs +++ b/src/audit/model.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; +use crate::compile::ir::summary::PipelineSummary; + fn is_zero_u64(value: &u64) -> bool { *value == 0 } @@ -59,6 +61,9 @@ pub struct AuditData { /// MCP server reliability and call health derived from gateway logs. #[serde(skip_serializing_if = "Option::is_none")] pub mcp_server_health: Option, + /// Optional typed-IR graph correlation for the pipeline source that produced this build. + #[serde(skip_serializing_if = "Option::is_none")] + pub pipeline_graph: Option, /// Job-level status data derived from the Azure DevOps build timeline. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub jobs: Vec, @@ -300,6 +305,16 @@ pub struct AuditEngineConfig { pub timeout_minutes: Option, } +/// Typed-IR graph correlation derived from the source markdown for this audited run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PipelineGraphSection { + /// Source markdown path used to rebuild the typed IR. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_path: String, + /// Full public pipeline summary, matching `ado-aw inspect --json`. + pub graph: PipelineSummary, +} + /// Job-level status information for one stage in the build timeline. /// /// This is derived from Azure DevOps timeline records for the audited build. @@ -324,6 +339,12 @@ pub struct JobData { /// Job finish timestamp. #[serde(skip_serializing_if = "Option::is_none")] pub finished_at: Option, + /// Upstream job IDs from typed-IR graph correlation. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub upstream_jobs: Vec, + /// Downstream job IDs from typed-IR graph correlation. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub downstream_jobs: Vec, } /// Metadata about a file downloaded while assembling the audit. @@ -942,6 +963,7 @@ mod tests { unreliable: true, }], }), + pipeline_graph: None, jobs: vec![JobData { name: String::from("Agent"), status: String::from("completed"), @@ -949,6 +971,7 @@ mod tests { duration: Some(String::from("4m")), started_at: Some(String::from("2026-05-21T12:01:00Z")), finished_at: Some(String::from("2026-05-21T12:05:00Z")), + ..Default::default() }], downloaded_files: vec![FileInfo { path: String::from("logs\\build-42\\agent_outputs_42\\otel.jsonl"), diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs new file mode 100644 index 00000000..c72f2f42 --- /dev/null +++ b/src/audit/pipeline_graph.rs @@ -0,0 +1,252 @@ +//! Pipeline-IR graph correlation for `ado-aw audit`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::audit::model::{AuditData, AwInfo, ErrorInfo, PipelineGraphSection}; +use crate::compile::ir::summary::{JobSummary, PipelineBodySummary, PipelineSummary}; + +/// Populate `audit.pipeline_graph` and per-job upstream/downstream IR edges. +/// +/// The source markdown is resolved from the runtime `aw_info.json` metadata +/// emitted by the Agent job. Missing local sources are common when auditing an +/// arbitrary build, so absence is recorded as a warning rather than an error. +pub async fn populate_pipeline_graph(audit: &mut AuditData, run_dir: &Path) -> Result<()> { + let source = match read_source_from_aw_info(run_dir).await.transpose()? { + Some(source) if !source.trim().is_empty() => Some(source), + _ => audit + .overview + .aw_info + .as_ref() + .and_then(|info| info.source.clone()), + }; + let Some(source) = source else { + record_warning( + audit, + "audit::pipeline_graph", + "could not locate aw_info.json source metadata; skipping IR graph correlation", + ); + return Ok(()); + }; + + let source_path = resolve_source_path(&source).await?; + if tokio::fs::metadata(&source_path).await.is_err() { + record_warning( + audit, + "audit::pipeline_graph", + format!( + "source markdown '{}' is not available locally; skipping IR graph correlation", + source_path.display() + ), + ); + return Ok(()); + } + + let resolved_source_path = tokio::fs::canonicalize(&source_path) + .await + .unwrap_or_else(|_| source_path.clone()); + let (_fm, pipeline) = crate::compile::build_pipeline_ir(&resolved_source_path) + .await + .with_context(|| format!("build IR for {}", resolved_source_path.display()))?; + let summary = PipelineSummary::from_pipeline(&pipeline) + .with_context(|| format!("summarize IR for {}", resolved_source_path.display()))?; + + populate_job_edges(audit, &summary); + audit.pipeline_graph = Some(PipelineGraphSection { + source_path: resolved_source_path.display().to_string(), + graph: summary, + }); + Ok(()) +} + +fn populate_job_edges(audit: &mut AuditData, summary: &PipelineSummary) { + for job in &mut audit.jobs { + let Some(ir_job) = find_matching_job_summary(summary, &job.name) else { + continue; + }; + let job_id = ir_job.id.as_str(); + job.upstream_jobs = summary + .graph + .job_edges + .iter() + .filter(|edge| edge.consumer == job_id) + .map(|edge| edge.producer.clone()) + .collect(); + job.downstream_jobs = summary + .graph + .job_edges + .iter() + .filter(|edge| edge.producer == job_id) + .map(|edge| edge.consumer.clone()) + .collect(); + } +} + +fn find_matching_job_summary<'a>( + summary: &'a PipelineSummary, + timeline_name: &str, +) -> Option<&'a JobSummary> { + all_jobs(summary) + .into_iter() + .find(|job| timeline_name_matches_job(timeline_name, &job.id, job.stage.as_deref())) +} + +pub(crate) fn timeline_name_matches_job( + timeline_name: &str, + job_id: &str, + stage: Option<&str>, +) -> bool { + let timeline_name = timeline_name.trim(); + if timeline_name == job_id { + return true; + } + if let Some(stage) = stage + && timeline_name == format!("{stage}.{job_id}") + { + return true; + } + timeline_name + .rsplit('.') + .next() + .is_some_and(|suffix| suffix == job_id) +} + +pub(crate) fn all_jobs(summary: &PipelineSummary) -> Vec<&JobSummary> { + match &summary.body { + PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), + PipelineBodySummary::Stages { stages } => { + stages.iter().flat_map(|stage| stage.jobs.iter()).collect() + } + } +} + +async fn read_source_from_aw_info(run_dir: &Path) -> Option> { + let agent_outputs = find_artifact_dir(run_dir, "agent_outputs").await?; + for path in [ + agent_outputs.join("staging").join("aw_info.json"), + agent_outputs.join("aw_info.json"), + ] { + if tokio::fs::metadata(&path).await.is_err() { + continue; + } + let contents = match tokio::fs::read_to_string(&path).await { + Ok(contents) => contents, + Err(error) => return Some(Err(error).context(format!("read {}", path.display()))), + }; + let aw_info = match serde_json::from_str::(&contents) { + Ok(aw_info) => aw_info, + Err(error) => return Some(Err(error).context(format!("parse {}", path.display()))), + }; + return Some(Ok(aw_info.source.unwrap_or_default())); + } + None +} + +async fn resolve_source_path(source: &str) -> Result { + let normalized = normalize_source_path(source); + let path = PathBuf::from(normalized); + if path.is_absolute() { + return Ok(path); + } + let cwd = tokio::fs::canonicalize(".") + .await + .context("Could not resolve current directory")?; + Ok(cwd.join(path)) +} + +fn normalize_source_path(source: &str) -> String { + let trimmed = source.trim(); + if std::path::MAIN_SEPARATOR == '/' { + trimmed.replace('\\', "/") + } else { + trimmed.replace('/', "\\") + } +} + +async fn find_artifact_dir(run_dir: &Path, prefix: &str) -> Option { + let mut entries = tokio::fs::read_dir(run_dir).await.ok()?; + let mut hits: Vec<(String, PathBuf)> = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) + && let Some(name) = entry.file_name().to_str() + && (name == prefix || name.starts_with(&format!("{prefix}_"))) + { + hits.push((name.to_string(), entry.path())); + } + } + hits.sort_by(|(a, _), (b, _)| crate::audit::cmp_numeric_suffix(a, b)); + hits.pop().map(|(_, path)| path) +} + +fn record_warning(audit: &mut AuditData, source: &str, message: impl Into) { + audit.warnings.push(ErrorInfo { + source: source.to_string(), + message: message.into(), + timestamp: None, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::model::JobData; + + #[tokio::test] + async fn populate_pipeline_graph_correlates_jobs_from_aw_info_source() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let run_dir = temp_dir.path().join("build-42"); + let staging_dir = run_dir.join("agent_outputs_42").join("staging"); + tokio::fs::create_dir_all(&staging_dir) + .await + .expect("create staging"); + + let source_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("safe-outputs") + .join("create-pull-request.md"); + let aw_info = serde_json::json!({ + "source": source_path.display().to_string(), + "target": "standalone" + }); + tokio::fs::write(staging_dir.join("aw_info.json"), aw_info.to_string()) + .await + .expect("write aw_info"); + + let mut audit = AuditData { + jobs: vec![ + JobData { + name: "Agent".to_string(), + status: "completed".to_string(), + result: Some("succeeded".to_string()), + ..Default::default() + }, + JobData { + name: "Detection".to_string(), + status: "completed".to_string(), + result: Some("succeeded".to_string()), + ..Default::default() + }, + ], + ..Default::default() + }; + + populate_pipeline_graph(&mut audit, &run_dir) + .await + .expect("populate graph"); + + assert!(audit.pipeline_graph.is_some()); + let agent = audit + .jobs + .iter() + .find(|job| job.name == "Agent") + .expect("agent job"); + assert!(agent.downstream_jobs.iter().any(|job| job == "Detection")); + let detection = audit + .jobs + .iter() + .find(|job| job.name == "Detection") + .expect("detection job"); + assert!(detection.upstream_jobs.iter().any(|job| job == "Agent")); + } +} diff --git a/src/audit/render/console.rs b/src/audit/render/console.rs index eafb215f..0b33d855 100644 --- a/src/audit/render/console.rs +++ b/src/audit/render/console.rs @@ -1297,6 +1297,7 @@ By threat: unreliable: true, }], }), + pipeline_graph: None, jobs: vec![ JobData { name: "Agent".to_string(), @@ -1305,6 +1306,7 @@ By threat: duration: Some("2m 30s".to_string()), started_at: Some("2026-05-21T12:01:00Z".to_string()), finished_at: Some("2026-05-21T12:03:30Z".to_string()), + ..Default::default() }, JobData { name: "Detection".to_string(), @@ -1313,6 +1315,7 @@ By threat: duration: Some("30s".to_string()), started_at: Some("2026-05-21T12:03:30Z".to_string()), finished_at: Some("2026-05-21T12:04:00Z".to_string()), + ..Default::default() }, JobData { name: "SafeOutputs".to_string(), @@ -1321,6 +1324,7 @@ By threat: duration: Some("12s".to_string()), started_at: Some("2026-05-21T12:04:00Z".to_string()), finished_at: Some("2026-05-21T12:04:12Z".to_string()), + ..Default::default() }, ], downloaded_files: vec![FileInfo { diff --git a/src/audit/render/json.rs b/src/audit/render/json.rs index 94d6ff01..24b279d7 100644 --- a/src/audit/render/json.rs +++ b/src/audit/render/json.rs @@ -156,6 +156,7 @@ mod tests { unreliable: true, }], }), + pipeline_graph: None, jobs: vec![JobData { name: String::from("Agent"), status: String::from("completed"), @@ -163,6 +164,7 @@ mod tests { duration: Some(String::from("4m")), started_at: Some(String::from("2026-05-21T12:01:00Z")), finished_at: Some(String::from("2026-05-21T12:05:00Z")), + ..Default::default() }], downloaded_files: vec![FileInfo { path: String::from("logs\\build-42\\agent_outputs_42\\otel.jsonl"), diff --git a/src/compile/ir/mod.rs b/src/compile/ir/mod.rs index 0d1ea28b..29a566f4 100644 --- a/src/compile/ir/mod.rs +++ b/src/compile/ir/mod.rs @@ -45,6 +45,7 @@ pub mod lower; pub mod output; pub mod stage; pub mod step; +pub mod summary; pub mod tasks; use ids::StageId; diff --git a/src/compile/ir/summary.rs b/src/compile/ir/summary.rs new file mode 100644 index 00000000..014261dd --- /dev/null +++ b/src/compile/ir/summary.rs @@ -0,0 +1,679 @@ +//! Serializable, agent-facing summary of a typed [`Pipeline`]. +//! +//! The internal IR (`Pipeline`, `Job`, `Step`, `Graph`, …) is rich and +//! intentionally tied to the compiler's lowering needs. Exposing those +//! shapes directly over MCP / JSON would lock us into every internal +//! field rename. Instead, this module defines a parallel "summary" +//! tree with `#[derive(Serialize)]` that captures the agent-relevant +//! signals (ids, kinds, conditions, output declarations, output +//! references, derived dependency edges) and intentionally **omits** +//! internal-only bookkeeping (template wraps, 1ES templateContext, +//! lowering hints). +//! +//! ## Stability contract +//! +//! [`PipelineSummary::schema_version`] is pinned. Bump it whenever +//! the JSON shape changes in a way a downstream consumer would +//! notice (renamed field, removed variant, changed semantics). +//! Additive changes — new optional fields, new enum variants in +//! `unknown`-tolerant contexts — do not require a bump. +//! +//! The summary is the **public** schema. The internal IR types +//! (`super::Pipeline` and friends) are NOT public API and may change +//! freely. + +use std::collections::BTreeSet; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use super::condition::{Condition, Expr, codegen::CondCodegenCtx, codegen::lower_condition}; +use super::env::EnvValue; +use super::graph::{Graph, build_graph}; +use super::output::{OutputDecl, OutputRef}; +use super::step::Step; +use super::{Pipeline, PipelineBody, PipelineShape}; + +/// Current public schema version. Bump when the JSON shape changes +/// in a backwards-incompatible way. +pub const SCHEMA_VERSION: u32 = 1; + +/// Public, serializable summary of a compiled pipeline. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PipelineSummary { + /// Public schema version; see [`SCHEMA_VERSION`]. + pub schema_version: u32, + /// Top-level `name:` (the ADO build-number format string). + pub name: String, + /// Compile target: `"standalone"`, `"1es"`, `"job-template"`, + /// `"stage-template"`. + pub shape: String, + /// Either a flat list of jobs (`standalone`, `job-template`) or + /// a list of stages (`1es`, `stage-template`). + pub body: PipelineBodySummary, + /// Resolved dependency graph. + pub graph: GraphSummary, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PipelineBodySummary { + Jobs { jobs: Vec }, + Stages { stages: Vec }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StageSummary { + pub id: String, + pub display_name: String, + pub depends_on: Vec, + /// Lowered ADO condition string, when one is set on the stage. + pub condition: Option, + pub jobs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JobSummary { + pub id: String, + /// `None` for top-level jobs in a flat `Jobs` pipeline. + pub stage: Option, + pub display_name: String, + pub depends_on: Vec, + /// Lowered ADO condition string, when one is set on the job. + pub condition: Option, + pub pool: PoolSummary, + pub steps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PoolSummary { + VmImage { + image: String, + }, + Named { + name: String, + image: Option, + os: Option, + }, +} + +/// A single step's public summary. +/// +/// `kind` discriminates the step shape and the rest of the fields +/// are populated per kind. `id` is the ADO step `name:` (required +/// when other steps consume this step's outputs). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StepSummary { + pub id: Option, + pub kind: StepKind, + pub display_name: Option, + /// For `task` steps: the ADO task identifier (e.g. `"NodeTool@0"`). + pub task: Option, + /// Lowered ADO condition string, when one is set on the step. + pub condition: Option, + /// Step outputs **declared** by this step (`BashStep::outputs`). + pub outputs: Vec, + /// Other-step outputs **read** by this step's `env:` map. + pub env_refs: Vec, + /// Other-step outputs **read** by this step's `condition:`. + pub condition_refs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StepKind { + Bash, + Task, + Checkout, + Download, + Publish, + RawYaml, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OutputDeclSummary { + pub name: String, + pub is_secret: bool, + /// `true` when at least one cross-step consumer reads this + /// output; the producer must emit `isOutput=true` in its + /// `##vso[task.setvariable …]` directive. + pub auto_is_output: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OutputRefSummary { + /// Producer step id. + pub step: String, + /// Output variable name (matches an `OutputDecl::name`). + pub name: String, +} + +/// JSON-friendly view of the IR's typed [`Graph`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GraphSummary { + /// Every step that carries an id, with its location and declared + /// outputs. + pub step_locations: Vec, + /// Derived job-level `dependsOn` edges (`consumer → producer`). + pub job_edges: Vec, + /// Derived stage-level `dependsOn` edges (`consumer → producer`). + pub stage_edges: Vec, + /// Producer-step outputs that need `isOutput=true` because at + /// least one cross-step consumer reads them. + pub outputs_needing_is_output: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StepLocationEntry { + pub step: String, + pub stage: Option, + pub job: String, + pub outputs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EdgeEntry { + /// The job/stage that has the `dependsOn` entry. + pub consumer: String, + /// The job/stage being depended on. + pub producer: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StepOutputsEntry { + pub step: String, + pub outputs: Vec, +} + +impl PipelineSummary { + /// Build a public summary from a typed [`Pipeline`]. + /// + /// Runs the graph pass to derive `depends_on` and validate + /// output references — same flow the YAML emit takes. Returns + /// the graph errors verbatim so summary callers see the same + /// errors a compile would surface. + pub fn from_pipeline(p: &Pipeline) -> Result { + let graph = build_graph(p)?; + let body = match &p.body { + PipelineBody::Jobs(jobs) => PipelineBodySummary::Jobs { + jobs: jobs + .iter() + .map(|j| summarize_job(None, j, &graph)) + .collect(), + }, + PipelineBody::Stages(stages) => PipelineBodySummary::Stages { + stages: stages + .iter() + .map(|s| { + let stage_id = s.id.as_str().to_string(); + StageSummary { + id: stage_id.clone(), + display_name: s.display_name.clone(), + depends_on: s + .depends_on + .iter() + .map(|d| d.as_str().to_string()) + .collect(), + condition: s.condition.as_ref().and_then(|c| { + // Conditions on a stage have no + // step-output context; render with + // an empty graph and a placeholder + // job so callers see the lowered + // string. Stage-level conditions + // today never reference step + // outputs. + render_condition(c, &graph, None, None) + }), + jobs: s + .jobs + .iter() + .map(|j| summarize_job(Some(stage_id.clone()), j, &graph)) + .collect(), + } + }) + .collect(), + }, + }; + + Ok(PipelineSummary { + schema_version: SCHEMA_VERSION, + name: p.name.clone(), + shape: shape_label(&p.shape).to_string(), + body, + graph: GraphSummary::from_graph(&graph), + }) + } +} + +fn shape_label(shape: &PipelineShape) -> &'static str { + match shape { + PipelineShape::Standalone => "standalone", + PipelineShape::OneEs { .. } => "1es", + PipelineShape::JobTemplate { .. } => "job-template", + PipelineShape::StageTemplate { .. } => "stage-template", + } +} + +fn summarize_job(stage: Option, j: &super::job::Job, graph: &Graph) -> JobSummary { + let job_id_str = j.id.as_str().to_string(); + let stage_clone = stage.clone(); + let stage_for_render = stage_clone.as_deref(); + JobSummary { + id: job_id_str.clone(), + stage, + display_name: j.display_name.clone(), + depends_on: j + .depends_on + .iter() + .map(|d| d.as_str().to_string()) + .collect(), + condition: j + .condition + .as_ref() + .and_then(|c| render_condition(c, graph, stage_for_render, Some(&job_id_str))), + pool: summarize_pool(&j.pool), + steps: j + .steps + .iter() + .map(|s| summarize_step(s, graph, stage_for_render, &job_id_str)) + .collect(), + } +} + +fn summarize_pool(p: &super::job::Pool) -> PoolSummary { + match p { + super::job::Pool::VmImage(image) => PoolSummary::VmImage { + image: image.clone(), + }, + super::job::Pool::Named { name, image, os } => PoolSummary::Named { + name: name.clone(), + image: image.clone(), + os: os.clone(), + }, + } +} + +fn summarize_step(step: &Step, graph: &Graph, stage: Option<&str>, job: &str) -> StepSummary { + let (id, kind, display_name, task, condition, mut outputs, env_refs, condition_refs) = + match step { + Step::Bash(b) => { + let env_refs = collect_env_refs(b.env.values()); + let cond_refs = b + .condition + .as_ref() + .map(collect_condition_refs) + .unwrap_or_default(); + ( + b.id.as_ref().map(|i| i.as_str().to_string()), + StepKind::Bash, + Some(b.display_name.clone()), + None, + b.condition + .as_ref() + .and_then(|c| render_condition(c, graph, stage, Some(job))), + b.outputs + .iter() + .map(summarize_output_decl) + .collect::>(), + env_refs.into_iter().map(summarize_output_ref).collect(), + cond_refs.into_iter().map(summarize_output_ref).collect(), + ) + } + Step::Task(t) => { + let env_refs = collect_env_refs(t.env.values()); + let cond_refs = t + .condition + .as_ref() + .map(collect_condition_refs) + .unwrap_or_default(); + ( + t.id.as_ref().map(|i| i.as_str().to_string()), + StepKind::Task, + Some(t.display_name.clone()), + Some(t.task.clone()), + t.condition + .as_ref() + .and_then(|c| render_condition(c, graph, stage, Some(job))), + Vec::new(), + env_refs.into_iter().map(summarize_output_ref).collect(), + cond_refs.into_iter().map(summarize_output_ref).collect(), + ) + } + Step::Checkout(_) => ( + None, + StepKind::Checkout, + None, + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + ), + Step::Download(d) => { + let cond_refs = d + .condition + .as_ref() + .map(collect_condition_refs) + .unwrap_or_default(); + ( + None, + StepKind::Download, + Some(format!("download: {}", d.artifact)), + None, + d.condition + .as_ref() + .and_then(|c| render_condition(c, graph, stage, Some(job))), + Vec::new(), + Vec::new(), + cond_refs.into_iter().map(summarize_output_ref).collect(), + ) + } + Step::Publish(p) => { + let cond_refs = p + .condition + .as_ref() + .map(collect_condition_refs) + .unwrap_or_default(); + ( + None, + StepKind::Publish, + Some(format!("publish: {}", p.artifact)), + None, + p.condition + .as_ref() + .and_then(|c| render_condition(c, graph, stage, Some(job))), + Vec::new(), + Vec::new(), + cond_refs.into_iter().map(summarize_output_ref).collect(), + ) + } + Step::RawYaml(_) => ( + None, + StepKind::RawYaml, + None, + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + ), + }; + // Patch auto_is_output from the graph's outputs_needing_is_output + // index so it's accurate without requiring the caller to mutate + // the Pipeline via apply_auto_is_output. + if let Some(step_id) = id.as_deref() + && !outputs.is_empty() + { + let key = super::ids::StepId::new(step_id).ok(); + if let Some(k) = key + && let Some(needs) = graph.outputs_needing_is_output.get(&k) + { + for o in outputs.iter_mut() { + if needs.contains(&o.name) { + o.auto_is_output = true; + } + } + } + } + StepSummary { + id, + kind, + display_name, + task, + condition, + outputs, + env_refs, + condition_refs, + } +} + +fn summarize_output_decl(d: &OutputDecl) -> OutputDeclSummary { + OutputDeclSummary { + name: d.name.clone(), + is_secret: d.is_secret, + auto_is_output: d.auto_is_output, + } +} + +fn summarize_output_ref(r: OutputRef) -> OutputRefSummary { + OutputRefSummary { + step: r.step.as_str().to_string(), + name: r.name, + } +} + +fn render_condition( + c: &Condition, + graph: &Graph, + stage: Option<&str>, + job: Option<&str>, +) -> Option { + // Build typed stage/job ids for the codegen context. If the + // caller is rendering a stage-level condition we synthesise a + // dummy job — stage-level conditions in the canonical pipeline + // never reference step outputs, but the codegen API still + // requires a `&JobId`, and a placeholder is fine because no + // `Expr::StepOutput` should reach this path. + let job_id = super::ids::JobId::new(job.unwrap_or("_stage_placeholder")).ok()?; + let stage_id = stage + .map(super::ids::StageId::new) + .transpose() + .ok() + .flatten(); + let ctx = CondCodegenCtx { + graph, + stage: stage_id.as_ref(), + job: &job_id, + }; + lower_condition(&ctx, c).ok() +} + +fn collect_env_refs<'a, I: IntoIterator>(values: I) -> Vec { + let mut out = Vec::new(); + for v in values { + walk_env(v, &mut out); + } + out +} + +fn walk_env(v: &EnvValue, out: &mut Vec) { + match v { + EnvValue::StepOutput(r) => out.push(r.clone()), + EnvValue::Coalesce(parts) | EnvValue::Concat(parts) => { + for p in parts { + walk_env(p, out); + } + } + _ => {} + } +} + +fn collect_condition_refs(c: &Condition) -> Vec { + let mut out = Vec::new(); + walk_cond(c, &mut out); + out +} + +fn walk_cond(c: &Condition, out: &mut Vec) { + match c { + Condition::And(parts) | Condition::Or(parts) => { + for p in parts { + walk_cond(p, out); + } + } + Condition::Not(inner) => walk_cond(inner, out), + Condition::Eq(a, b) | Condition::Ne(a, b) => { + walk_expr(a, out); + walk_expr(b, out); + } + _ => {} + } +} + +fn walk_expr(e: &Expr, out: &mut Vec) { + if let Expr::StepOutput(r) = e { + out.push(r.clone()); + } +} + +impl GraphSummary { + fn from_graph(g: &Graph) -> Self { + let step_locations = g + .step_locations + .iter() + .map(|(step, loc)| StepLocationEntry { + step: step.as_str().to_string(), + stage: loc.stage.as_ref().map(|s| s.as_str().to_string()), + job: loc.job.as_str().to_string(), + outputs: loc.outputs.iter().cloned().collect(), + }) + .collect(); + let job_edges = g + .job_edges + .iter() + .map(|(c, p)| EdgeEntry { + consumer: c.as_str().to_string(), + producer: p.as_str().to_string(), + }) + .collect(); + let stage_edges = g + .stage_edges + .iter() + .map(|(c, p)| EdgeEntry { + consumer: c.as_str().to_string(), + producer: p.as_str().to_string(), + }) + .collect(); + let outputs_needing_is_output = g + .outputs_needing_is_output + .iter() + .map(|(step, outs)| StepOutputsEntry { + step: step.as_str().to_string(), + outputs: outs + .iter() + .cloned() + .collect::>() + .into_iter() + .collect(), + }) + .collect(); + GraphSummary { + step_locations, + job_edges, + stage_edges, + outputs_needing_is_output, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::ir::condition::{Condition, Expr}; + use crate::compile::ir::env::EnvValue; + use crate::compile::ir::ids::{JobId, StepId}; + use crate::compile::ir::job::{Job, Pool}; + use crate::compile::ir::output::{OutputDecl, OutputRef}; + use crate::compile::ir::step::{BashStep, Step}; + use crate::compile::ir::{Pipeline, PipelineBody, PipelineShape, Resources, Triggers}; + + fn fixture_pipeline() -> Pipeline { + let producer = Step::Bash( + BashStep::new("setup", "echo hi") + .with_id(StepId::new("synthPr").unwrap()) + .with_output(OutputDecl::new("AW_SYNTHETIC_PR_ID")), + ); + let consumer = Step::Bash( + BashStep::new("run", "echo bye") + .with_env( + "PR_ID", + EnvValue::step_output(OutputRef::new( + StepId::new("synthPr").unwrap(), + "AW_SYNTHETIC_PR_ID", + )), + ) + .with_condition(Condition::Eq( + Expr::StepOutput(OutputRef::new( + StepId::new("synthPr").unwrap(), + "AW_SYNTHETIC_PR_ID", + )), + Expr::Literal("42".into()), + )), + ); + + let setup = { + let mut j = Job::new( + JobId::new("Setup").unwrap(), + "Setup", + Pool::VmImage("ubuntu-22.04".into()), + ); + j.steps.push(producer); + j + }; + let agent = { + let mut j = Job::new( + JobId::new("Agent").unwrap(), + "Agent", + Pool::VmImage("ubuntu-22.04".into()), + ); + j.steps.push(consumer); + j + }; + + Pipeline { + name: "T".into(), + parameters: Vec::new(), + resources: Resources::default(), + triggers: Triggers::default(), + variables: Vec::new(), + body: PipelineBody::Jobs(vec![setup, agent]), + shape: PipelineShape::Standalone, + } + } + + #[test] + fn summary_schema_version_is_pinned() { + assert_eq!(SCHEMA_VERSION, 1); + } + + #[test] + fn from_pipeline_round_trips_jobs_and_graph() { + let p = fixture_pipeline(); + let s = PipelineSummary::from_pipeline(&p).unwrap(); + assert_eq!(s.shape, "standalone"); + let jobs = match s.body { + PipelineBodySummary::Jobs { jobs } => jobs, + _ => panic!("expected jobs body"), + }; + assert_eq!(jobs.len(), 2); + let agent = jobs.iter().find(|j| j.id == "Agent").unwrap(); + assert_eq!(agent.steps.len(), 1); + let step = &agent.steps[0]; + assert_eq!(step.env_refs.len(), 1); + assert_eq!(step.env_refs[0].step, "synthPr"); + assert_eq!(step.condition_refs.len(), 1); + // Graph derived a job edge Agent -> Setup + assert!( + s.graph + .job_edges + .iter() + .any(|e| e.consumer == "Agent" && e.producer == "Setup"), + "expected derived edge Agent -> Setup, got {:?}", + s.graph.job_edges + ); + // Producer output is marked auto_is_output + let setup = jobs.iter().find(|j| j.id == "Setup").unwrap(); + let prod_step = &setup.steps[0]; + assert!(prod_step.outputs[0].auto_is_output); + } + + #[test] + fn serializes_to_json_without_panicking() { + let p = fixture_pipeline(); + let s = PipelineSummary::from_pipeline(&p).unwrap(); + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains("\"schema_version\":1")); + assert!(json.contains("\"shape\":\"standalone\"")); + } +} diff --git a/src/compile/mod.rs b/src/compile/mod.rs index eb5f6dd2..1d1dd161 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -434,8 +434,7 @@ pub async fn compile_all_pipelines(skip_integrity: bool, debug_pipeline: bool) - for pipeline in &detected { let yaml_output_path = root.join(&pipeline.yaml_path); - let source_path = - resolve_pipeline_source_path(&yaml_output_path, &pipeline.source, root); + let source_path = resolve_pipeline_source_path(&yaml_output_path, &pipeline.source, root); if !source_path.exists() { eprintln!( @@ -779,10 +778,7 @@ fn format_pipeline_version_status(version: &str, current_version: &str) -> Strin /// Tries the path relative to the YAML file's directory first, then relative /// to the scan root. This mirrors the lookup the ADO pipeline itself uses. fn resolve_pipeline_source_path(yaml_output_path: &Path, source: &str, root: &Path) -> PathBuf { - let candidate_from_yaml_dir = yaml_output_path - .parent() - .unwrap_or(root) - .join(source); + let candidate_from_yaml_dir = yaml_output_path.parent().unwrap_or(root).join(source); if candidate_from_yaml_dir.exists() { candidate_from_yaml_dir } else { @@ -844,7 +840,6 @@ fn log_pipeline_metadata(front_matter: &FrontMatter) { debug!("Repositories: {}", front_matter.repositories.len()); } -/// Walk up from `start` to find the nearest directory containing `.git`. /// Walk up from `start` looking for the nearest ancestor containing a /// `.git` directory or file. /// @@ -869,6 +864,91 @@ pub fn find_repo_root(start: &Path) -> Option { } } +/// Public, read-only entry point that returns the typed [`ir::Pipeline`] +/// for an agent source file **without** writing any YAML. +/// +/// Mirrors [`compile_pipeline`]'s parse/sanitize/resolve-repos flow, +/// then dispatches to the appropriate `build_*_pipeline` IR builder +/// for the front-matter target. Used by commands that need to reason +/// about a pipeline's structure (e.g. `ado-aw inspect`, `ado-aw graph`) +/// rather than rebuild it. +/// +/// Returns both the sanitized front matter and the typed pipeline so +/// callers do not need to re-parse the source to get at high-level +/// fields like `front_matter.target`. +/// +/// **Codemods are applied in memory only**, matching `check_pipeline`'s +/// behavior: this function never rewrites the source on disk. +pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pipeline)> { + let content = tokio::fs::read_to_string(input_path) + .await + .with_context(|| format!("Failed to read input file: {}", input_path.display()))?; + + let parsed = common::parse_markdown_detailed(&content)?; + let mut front_matter = parsed.front_matter; + let markdown_body = parsed.markdown_body; + + use crate::sanitize::SanitizeConfig; + front_matter.sanitize_config_fields(); + + let (resolved_repos, resolved_checkout) = common::resolve_repos(&front_matter)?; + front_matter.repositories = resolved_repos; + front_matter.checkout = resolved_checkout; + common::validate_checkout_list(&front_matter.repositories, &front_matter.checkout)?; + + // Inferred output path for the marker step. Defaults to + // `.lock.yml` next to the source, same default as + // `compile_pipeline` when `--output` is omitted. + let output_path = input_path.with_extension("lock.yml"); + + let extensions = extensions::collect_extensions(&front_matter); + let ctx = extensions::CompileContext::new(&front_matter, input_path).await?; + + let pipeline = match front_matter.target { + CompileTarget::Standalone => standalone_ir::build_standalone_pipeline( + &front_matter, + &extensions, + &ctx, + input_path, + &output_path, + &markdown_body, + /* skip_integrity */ true, + /* debug_pipeline */ false, + )?, + CompileTarget::OneES => onees_ir::build_onees_pipeline( + &front_matter, + &extensions, + &ctx, + input_path, + &output_path, + &markdown_body, + true, + false, + )?, + CompileTarget::Job => job_ir::build_job_pipeline( + &front_matter, + &extensions, + &ctx, + input_path, + &output_path, + &markdown_body, + true, + false, + )?, + CompileTarget::Stage => stage_ir::build_stage_pipeline( + &front_matter, + &extensions, + &ctx, + input_path, + &output_path, + &markdown_body, + true, + false, + )?, + }; + Ok((front_matter, pipeline)) +} + /// Clean up spacing artifacts in generated YAML. /// /// After template placeholder replacement, empty placeholders leave behind @@ -1184,7 +1264,8 @@ description: "A test agent for directory output" #[tokio::test] async fn read_existing_pipeline_version_returns_none_for_missing_file() { - let version = read_existing_pipeline_version(Path::new("/tmp/does-not-exist.lock.yml")).await; + let version = + read_existing_pipeline_version(Path::new("/tmp/does-not-exist.lock.yml")).await; assert!(version.is_none(), "expected None for a non-existent file"); } @@ -1200,7 +1281,10 @@ description: "A test agent for directory output" )); std::fs::write(&temp, "# plain yaml\nname: foo\n").unwrap(); let version = read_existing_pipeline_version(&temp).await; - assert!(version.is_none(), "expected None when file has no @ado-aw header"); + assert!( + version.is_none(), + "expected None when file has no @ado-aw header" + ); let _ = std::fs::remove_file(&temp); } diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs new file mode 100644 index 00000000..19d5462f --- /dev/null +++ b/src/inspect/catalog.rs @@ -0,0 +1,322 @@ +//! In-tree registry catalog for CLI consumers. + +use std::error::Error; +use std::fmt; + +use serde::Serialize; + +use crate::engine::DEFAULT_COPILOT_MODEL; +use crate::safeoutputs::{ALL_KNOWN_SAFE_OUTPUTS, ALWAYS_ON_TOOLS, DEBUG_ONLY_TOOLS}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SafeOutputCatalogEntry { + pub name: String, + pub classification: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeCatalogEntry { + pub id: String, + pub default_version: Option, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolCatalogEntry { + pub id: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct Catalog { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub safe_outputs: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub runtimes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub engines: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownCatalogKind { + pub kind: String, +} + +impl fmt::Display for UnknownCatalogKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "unknown --kind '{}' (expected one of: safe-outputs, runtimes, tools, engines, models)", + self.kind + ) + } +} + +impl Error for UnknownCatalogKind {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CatalogKind { + SafeOutputs, + Runtimes, + Tools, + Engines, + Models, +} + +impl CatalogKind { + pub fn parse(kind: &str) -> Result { + match kind { + "safe-outputs" => Ok(Self::SafeOutputs), + "runtimes" => Ok(Self::Runtimes), + "tools" => Ok(Self::Tools), + "engines" => Ok(Self::Engines), + "models" => Ok(Self::Models), + other => Err(UnknownCatalogKind { + kind: other.to_string(), + }), + } + } +} + +pub fn catalog() -> Catalog { + Catalog { + safe_outputs: safe_outputs(), + runtimes: runtimes(), + tools: tools(), + engines: engines(), + models: models(), + } +} + +pub fn catalog_kind(kind: &str) -> Result { + let kind = CatalogKind::parse(kind)?; + Ok(match kind { + CatalogKind::SafeOutputs => Catalog { + safe_outputs: safe_outputs(), + ..Catalog::default() + }, + CatalogKind::Runtimes => Catalog { + runtimes: runtimes(), + ..Catalog::default() + }, + CatalogKind::Tools => Catalog { + tools: tools(), + ..Catalog::default() + }, + CatalogKind::Engines => Catalog { + engines: engines(), + ..Catalog::default() + }, + CatalogKind::Models => Catalog { + models: models(), + ..Catalog::default() + }, + }) +} + +pub fn render_text(catalog: &Catalog) -> String { + let mut out = String::new(); + if !catalog.safe_outputs.is_empty() { + out.push_str("Safe outputs\n"); + for item in &catalog.safe_outputs { + out.push_str(&format!( + " {} [{}] - {}\n", + item.name, item.classification, item.description + )); + } + out.push('\n'); + } + if !catalog.runtimes.is_empty() { + out.push_str("Runtimes\n"); + for item in &catalog.runtimes { + let version = item.default_version.as_deref().unwrap_or("none"); + out.push_str(&format!( + " {} [default: {}] - {}\n", + item.id, version, item.description + )); + } + out.push('\n'); + } + if !catalog.tools.is_empty() { + out.push_str("Tools\n"); + for item in &catalog.tools { + out.push_str(&format!(" {} - {}\n", item.id, item.description)); + } + out.push('\n'); + } + if !catalog.engines.is_empty() { + out.push_str("Engines\n"); + for engine in &catalog.engines { + out.push_str(&format!(" {engine}\n")); + } + out.push('\n'); + } + if !catalog.models.is_empty() { + out.push_str("Models\n"); + for model in &catalog.models { + out.push_str(&format!(" {model}\n")); + } + } + out.trim_end().to_string() +} + +fn safe_outputs() -> Vec { + ALL_KNOWN_SAFE_OUTPUTS + .iter() + .chain(DEBUG_ONLY_TOOLS.iter()) + .copied() + .collect::>() + .into_iter() + .map(|name| SafeOutputCatalogEntry { + name: name.to_string(), + classification: safe_output_classification(name).to_string(), + description: safe_output_description(name).to_string(), + }) + .collect() +} + +fn safe_output_classification(name: &str) -> &'static str { + if DEBUG_ONLY_TOOLS.contains(&name) { + "debug-only" + } else if ALWAYS_ON_TOOLS.contains(&name) { + "always-on" + } else { + "opt-in" + } +} + +fn safe_output_description(name: &str) -> &'static str { + match name { + "add-build-tag" => "Parameters for adding a tag to an Azure DevOps build", + "add-pr-comment" => "Parameters for adding a comment thread on a pull request", + "comment-on-work-item" => "Parameters for commenting on a work item", + "create-branch" => "Parameters for creating a branch", + "create-git-tag" => "Parameters for creating a git tag (agent-provided)", + "create-issue" => "Files a GitHub issue against an operator-configured target repository.", + "create-pull-request" => "Parameters for creating a pull request", + "create-wiki-page" => "Parameters for creating a wiki page (agent-provided)", + "create-work-item" => "Parameters for creating a work item", + "link-work-items" => "Parameters for linking two work items", + "missing-data" => "Parameters for reporting missing data", + "missing-tool" => "Parameters for reporting a missing tool", + "noop" => "Parameters for describing a no operation. Use this if there is no work to do.", + "queue-build" => "Parameters for queuing a build", + "reply-to-pr-comment" => { + "Parameters for replying to an existing review comment thread on a pull request" + } + "report-incomplete" => "Parameters for reporting that a task could not be completed", + "resolve-pr-thread" => "Parameters for resolving or reactivating a PR review thread", + "submit-pr-review" => "Parameters for submitting a pull request review", + "update-pr" => "Parameters for updating a pull request", + "update-wiki-page" => "Parameters for editing a wiki page (agent-provided)", + "update-work-item" => "Parameters for updating a work item", + "upload-build-attachment" => "Parameters for attaching a workspace file to an ADO build.", + "upload-pipeline-artifact" => { + "Parameters for publishing a workspace file as an ADO pipeline artifact." + } + "upload-workitem-attachment" => "Parameters for uploading an attachment to a work item", + _ => "(no description)", + } +} + +fn runtimes() -> Vec { + vec![ + RuntimeCatalogEntry { + id: "lean".to_string(), + default_version: Some("stable".to_string()), + description: "Lean 4 runtime support for the ado-aw compiler.".to_string(), + }, + RuntimeCatalogEntry { + id: "python".to_string(), + default_version: Some("3.x".to_string()), + description: "Python runtime support for the ado-aw compiler.".to_string(), + }, + RuntimeCatalogEntry { + id: "node".to_string(), + default_version: Some("22.x".to_string()), + description: "Node.js runtime support for the ado-aw compiler.".to_string(), + }, + RuntimeCatalogEntry { + id: "dotnet".to_string(), + default_version: Some("8.0.x".to_string()), + description: ".NET runtime support for the ado-aw compiler.".to_string(), + }, + ] +} + +fn tools() -> Vec { + vec![ + ToolCatalogEntry { + id: "bash".to_string(), + description: "Bash command access configured via tools.bash; omitted means unrestricted bash access.".to_string(), + }, + ToolCatalogEntry { + id: "edit".to_string(), + description: "File writing configured via tools.edit; enabled by default.".to_string(), + }, + ToolCatalogEntry { + id: "azure-devops".to_string(), + description: "Azure DevOps first-class tool.".to_string(), + }, + ToolCatalogEntry { + id: "cache-memory".to_string(), + description: "Cache memory first-class tool.".to_string(), + }, + ] +} + +fn engines() -> Vec { + // TODO: Switch to an enum-driven Engine::all_ids() API when engine.rs exposes one. + vec!["copilot".to_string()] +} + +fn models() -> Vec { + // No KNOWN_MODELS registry exists yet; keep this list aligned with + // prompts/create-ado-agentic-workflow.md step 2. + vec![ + DEFAULT_COPILOT_MODEL.to_string(), + "claude-sonnet-4.5".to_string(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_inspect_returns_non_empty_lists_for_every_category() { + let catalog = catalog(); + assert!(!catalog.safe_outputs.is_empty()); + assert!(!catalog.runtimes.is_empty()); + assert!(!catalog.tools.is_empty()); + assert!(!catalog.engines.is_empty()); + assert!(!catalog.models.is_empty()); + } + + #[test] + fn safe_outputs_inspect_catalog_kind_includes_always_on_tools() { + let catalog = catalog_kind("safe-outputs").unwrap(); + let names: Vec<&str> = catalog + .safe_outputs + .iter() + .map(|e| e.name.as_str()) + .collect(); + for always_on in ALWAYS_ON_TOOLS { + assert!( + names.contains(always_on), + "safe-outputs catalog missing always-on tool {always_on}" + ); + } + } + + #[test] + fn unknown_inspect_catalog_kind_returns_typed_error() { + let err = catalog_kind("widgets").unwrap_err(); + assert_eq!(err.kind, "widgets"); + } +} diff --git a/src/inspect/cli.rs b/src/inspect/cli.rs new file mode 100644 index 00000000..67e92a15 --- /dev/null +++ b/src/inspect/cli.rs @@ -0,0 +1,366 @@ +//! CLI dispatchers for the `inspect` family of subcommands. +//! +//! Each `dispatch_*` is the single entry point invoked from +//! `src/main.rs`. Public option structs are by-reference / `Copy` +//! where convenient so call sites stay terse. + +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::audit::model::AuditData; +use crate::compile::{ + build_pipeline_ir, + ir::summary::{GraphSummary, PipelineSummary}, +}; + +use super::{catalog, graph_deps, graph_outputs, graph_query, lint, trace, whatif}; + +/// Options for `ado-aw inspect `. +#[derive(Debug)] +pub struct InspectOptions<'a> { + /// Path to the agent `.md` to inspect. + pub source: &'a Path, + /// Emit machine-readable JSON to stdout when `true`; otherwise + /// render a terse human summary. + pub json: bool, +} + +/// Emit the public [`PipelineSummary`] for an agent source file. +/// +/// In text mode prints a compact, scannable summary suitable for +/// terminals (counts + a few cross-cutting facts). In JSON mode +/// writes the full summary to stdout. +pub async fn dispatch_inspect(opts: InspectOptions<'_>) -> Result<()> { + let summary = build_inspect(opts.source).await?; + + if opts.json { + let json = serde_json::to_string_pretty(&summary)?; + println!("{}", json); + } else { + print_text_inspect(&summary); + } + Ok(()) +} + +/// Build the public [`PipelineSummary`] for an agent source file. +pub async fn build_inspect(source: &Path) -> Result { + let (_fm, pipeline) = build_pipeline_ir(source) + .await + .with_context(|| format!("Failed to build IR for {}", source.display()))?; + PipelineSummary::from_pipeline(&pipeline) +} + +/// Output format selector for `ado-aw graph`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphFormat { + Text, + Json, + Dot, +} + +/// Options for `ado-aw graph `. +#[derive(Debug)] +pub struct GraphOptions<'a> { + pub source: &'a Path, + pub format: GraphFormat, +} + +/// Dump the resolved dependency graph for `source` in the selected +/// format. Delegates the rendering to [`graph_query`]. +pub async fn dispatch_graph(opts: GraphOptions<'_>) -> Result<()> { + let output = build_graph_dump(opts.source, opts.format).await?; + println!("{}", output); + Ok(()) +} + +/// Build the resolved dependency graph summary for an agent source file. +pub async fn build_graph_summary(source: &Path) -> Result { + Ok(build_inspect(source).await?.graph) +} + +/// Render the resolved dependency graph for an agent source file. +pub async fn build_graph_dump(source: &Path, format: GraphFormat) -> Result { + let summary = build_inspect(source).await?; + match format { + GraphFormat::Text => Ok(graph_query::render_text(&summary)), + GraphFormat::Json => serde_json::to_string_pretty(&summary.graph).map_err(Into::into), + GraphFormat::Dot => Ok(graph_query::render_dot(&summary)), + } +} + +/// Options for `ado-aw graph deps `. +#[derive(Debug)] +pub struct GraphDepsOptions<'a> { + /// Path to the agent markdown source. + pub source: &'a Path, + /// Step id to traverse from. + pub step: &'a str, + /// Traversal direction. + pub direction: graph_deps::GraphDepsDirection, + /// Emit machine-readable JSON instead of text. + pub json: bool, +} + +/// Traverse graph dependencies for one named step. +pub async fn dispatch_graph_deps(opts: GraphDepsOptions<'_>) -> Result<()> { + let report = build_graph_deps(opts.source, opts.step, opts.direction).await?; + + if opts.json { + let json = serde_json::to_string_pretty(&report)?; + println!("{}", json); + } else { + println!("{}", graph_deps::render_text(&report)); + } + Ok(()) +} + +/// Build a dependency traversal report for one named step. +pub async fn build_graph_deps( + source: &Path, + step: &str, + direction: graph_deps::GraphDepsDirection, +) -> Result { + let summary = build_inspect(source).await?; + graph_deps::analyze(&summary, step, direction) +} + +/// Options for `ado-aw graph outputs `. +#[derive(Debug)] +pub struct GraphOutputsOptions<'a> { + /// Path to the agent markdown source. + pub source: &'a Path, + /// Optional producer step id filter. + pub producer: Option<&'a str>, + /// Optional consumer step id filter. + pub consumer: Option<&'a str>, + /// Emit machine-readable JSON instead of text. + pub json: bool, +} + +/// Print the declared output ↔ consumer reference table. +pub async fn dispatch_graph_outputs(opts: GraphOutputsOptions<'_>) -> Result<()> { + let edges = build_graph_outputs(opts.source, opts.producer, opts.consumer).await?; + + if opts.json { + let json = serde_json::to_string_pretty(&edges)?; + println!("{}", json); + } else { + println!("{}", graph_outputs::render_text(&edges)); + } + Ok(()) +} + +/// Build the declared-output table, optionally filtered by producer/consumer. +pub async fn build_graph_outputs( + source: &Path, + producer: Option<&str>, + consumer: Option<&str>, +) -> Result> { + let summary = build_inspect(source).await?; + Ok(graph_outputs::output_edges(&summary, producer, consumer)) +} + +/// Options for `ado-aw trace `. +#[derive(Debug)] +pub struct TraceOptions<'a> { + pub build_id_or_url: &'a str, + pub step: Option<&'a str>, + pub json: bool, + pub org: Option<&'a str>, + pub project: Option<&'a str>, + pub pat: Option<&'a str>, +} + +/// Trace a build by joining audit telemetry with the local typed-IR graph. +pub async fn dispatch_trace(opts: TraceOptions<'_>) -> Result<()> { + let (audit, report) = build_trace(&opts).await?; + + if audit.pipeline_graph.is_none() { + eprintln!("warning: source markdown was not available locally; trace is runtime-only"); + } + + if opts.step.is_some() && report.step.is_none() { + eprintln!("warning: requested step was not found in the local IR graph"); + } + + if opts.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print!("{}", trace::render_text(&audit, &report, opts.step)); + } + Ok(()) +} + +/// Build trace audit data and the derived trace report. +pub async fn build_trace(opts: &TraceOptions<'_>) -> Result<(AuditData, trace::TraceReport)> { + let audit = crate::audit::fetch_audit_data(crate::audit::AuditOptions { + build_id_or_url: opts.build_id_or_url, + output: Path::new("./logs"), + json: true, + org: opts.org, + project: opts.project, + pat: opts.pat, + artifacts: None, + no_cache: false, + }) + .await?; + let report = trace::build_trace_report(&audit, opts.step); + Ok((audit, report)) +} + +/// Options for `ado-aw whatif --fail `. +#[derive(Debug)] +pub struct WhatIfOptions<'a> { + /// Path to the agent markdown source. + pub source: &'a Path, + /// Step id or job id that should be treated as failing. + pub fail: &'a str, + /// Emit machine-readable JSON instead of text. + pub json: bool, +} + +/// Classify downstream jobs that would skip if a step or job failed. +pub async fn dispatch_whatif(opts: WhatIfOptions<'_>) -> Result<()> { + let report = build_whatif(opts.source, opts.fail).await?; + + if opts.json { + let json = serde_json::to_string_pretty(&report)?; + println!("{}", json); + } else { + println!("{}", whatif::render_text(&report)); + } + Ok(()) +} + +/// Build a static reachability report for a failing step/job id. +pub async fn build_whatif(source: &Path, fail: &str) -> Result { + let summary = build_inspect(source).await?; + whatif::analyze(&summary, fail) +} + +/// Options for `ado-aw lint `. +#[derive(Debug)] +pub struct LintOptions<'a> { + pub source: &'a Path, + pub json: bool, +} + +/// Run structural lint checks over an agent source file. +/// +/// Returns `true` when at least one error-severity finding was emitted so the +/// CLI can translate that into exit code 1 without treating warnings/infos as +/// hard failures. +pub async fn dispatch_lint(opts: LintOptions<'_>) -> Result { + let report = build_lint(opts.source).await?; + let had_errors = report.summary.errors > 0; + + if opts.json { + let json = serde_json::to_string_pretty(&report)?; + println!("{}", json); + } else { + println!("{}", lint::render_text(&report)); + } + + Ok(had_errors) +} + +/// Build the structural lint report for an agent source file. +pub async fn build_lint(source: &Path) -> Result { + let summary = build_inspect(source).await?; + Ok(lint::report(&summary)) +} + +/// Options for `ado-aw catalog`. +#[derive(Debug)] +pub struct CatalogOptions<'a> { + pub kind: Option<&'a str>, + pub json: bool, +} + +/// Emit the in-tree registry catalog. +pub fn dispatch_catalog(opts: CatalogOptions<'_>) -> Result<()> { + let catalog = build_catalog(opts.kind)?; + + if opts.json { + let json = serde_json::to_string_pretty(&catalog)?; + println!("{}", json); + } else { + println!("{}", catalog::render_text(&catalog)); + } + Ok(()) +} + +/// Build the in-tree registry catalog, optionally filtered by kind. +pub fn build_catalog(kind: Option<&str>) -> Result { + Ok(match kind { + Some(kind) => catalog::catalog_kind(kind)?, + None => catalog::catalog(), + }) +} + +fn print_text_inspect(s: &PipelineSummary) { + use crate::compile::ir::summary::PipelineBodySummary; + + println!("Pipeline: {}", s.name); + println!("Target shape: {}", s.shape); + println!("Schema version: {}", s.schema_version); + println!(); + match &s.body { + PipelineBodySummary::Jobs { jobs } => { + println!("Jobs ({}):", jobs.len()); + for j in jobs { + print_job_summary_line(j); + } + } + PipelineBodySummary::Stages { stages } => { + println!("Stages ({}):", stages.len()); + for st in stages { + let dep = format_depends(&st.depends_on); + println!("- {} ({}){}", st.id, st.display_name, dep); + for j in &st.jobs { + print!(" "); + print_job_summary_line(j); + } + } + } + } + println!(); + println!("Graph:"); + println!( + " step locations: {}", + s.graph.step_locations.len() + ); + println!(" derived job edges: {}", s.graph.job_edges.len()); + println!(" derived stage edges: {}", s.graph.stage_edges.len()); + let need_io: usize = s + .graph + .outputs_needing_is_output + .iter() + .map(|e| e.outputs.len()) + .sum(); + println!(" outputs needing isOutput: {}", need_io); +} + +fn print_job_summary_line(j: &crate::compile::ir::summary::JobSummary) { + let dep = format_depends(&j.depends_on); + let stage = j + .stage + .as_deref() + .map(|s| format!(" [{}]", s)) + .unwrap_or_default(); + let step_count = j.steps.len(); + let id_step_count: usize = j.steps.iter().filter(|s| s.id.is_some()).count(); + println!( + "- {}{} steps: {} ({} named){}", + j.id, stage, step_count, id_step_count, dep + ); +} + +fn format_depends(deps: &[String]) -> String { + if deps.is_empty() { + String::new() + } else { + format!(" depends on: {}", deps.join(", ")) + } +} diff --git a/src/inspect/graph_deps.rs b/src/inspect/graph_deps.rs new file mode 100644 index 00000000..ab49a480 --- /dev/null +++ b/src/inspect/graph_deps.rs @@ -0,0 +1,685 @@ +//! Step-centric dependency traversal for `ado-aw graph deps`. +//! +//! The compiler's public [`PipelineSummary`] already contains the +//! resolved job/stage dependency graph plus per-step output references. +//! This module answers one focused question over that stable summary: +//! what sits upstream or downstream of a single named step? + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::error::Error; +use std::fmt; + +use anyhow::{Result, anyhow}; +use serde::Serialize; + +use crate::compile::ir::summary::{ + EdgeEntry, JobSummary, PipelineBodySummary, PipelineSummary, StepLocationEntry, StepSummary, +}; + +/// Traversal direction for `ado-aw graph deps`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphDepsDirection { + /// Walk producer-side dependencies. + Upstream, + /// Walk consumer-side dependents. + Downstream, +} + +impl GraphDepsDirection { + /// Stable JSON/text label for the direction. + pub fn as_str(self) -> &'static str { + match self { + Self::Upstream => "upstream", + Self::Downstream => "downstream", + } + } +} + +/// A transitive job reached by the query. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct JobDependency { + /// Job id. + pub job: String, + /// Containing stage id for staged pipelines. + pub stage: Option, +} + +/// A transitive step reached by following output references. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StepDependency { + /// Step id, or a stable anonymous label for steps without `id`. + pub step: String, + /// Containing job id. + pub job: String, + /// Containing stage id for staged pipelines. + pub stage: Option, + /// Output edge that caused the step to be reached, when known. + pub via_output: Option, +} + +/// JSON report emitted by `ado-aw graph deps --json`. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct GraphDepsReport { + /// Traversal direction: `upstream` or `downstream`. + pub direction: String, + /// Input step id. + pub step: String, + /// Location of the input step in the pipeline. + pub step_location: StepLocationEntry, + /// Transitive jobs reached through job/stage dependencies. + pub transitive_jobs: Vec, + /// Transitive steps reached through output references. + pub transitive_steps: Vec, +} + +/// Typed errors for graph dependency queries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GraphDepsError { + /// The requested step id is not present in `summary.graph.step_locations`. + StepNotFound { + /// Missing step id. + step: String, + /// Closest known step id, if one was available. + suggestion: Option, + }, +} + +impl fmt::Display for GraphDepsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StepNotFound { step, suggestion } => { + write!(f, "graph deps: step '{step}' not found")?; + if let Some(s) = suggestion { + write!(f, " (closest match: '{s}')")?; + } + Ok(()) + } + } + } +} + +impl Error for GraphDepsError {} + +/// Analyze transitive dependencies for a single named step. +/// +/// If `step` does not match a step id but does match a job id, the +/// query falls back to job-level traversal. That keeps the command +/// useful for canonical jobs such as `SafeOutputs` that may contain no +/// named step with the same id. +pub fn analyze( + summary: &PipelineSummary, + step: &str, + direction: GraphDepsDirection, +) -> Result { + let step_loc = summary + .graph + .step_locations + .iter() + .find(|loc| loc.step == step) + .cloned(); + let job_loc = step_loc + .is_none() + .then(|| find_job(summary, step)) + .flatten(); + let loc = if let Some(loc) = step_loc { + loc + } else if let Some(job) = job_loc { + StepLocationEntry { + step: step.to_string(), + stage: job.stage.clone(), + job: job.id.clone(), + outputs: Vec::new(), + } + } else { + return Err(anyhow!(GraphDepsError::StepNotFound { + step: step.to_string(), + suggestion: closest( + step, + known_step_or_job_ids(summary).iter().map(String::as_str) + ), + })); + }; + + let transitive_jobs = transitive_jobs(summary, &loc, direction); + let transitive_steps = if job_loc.is_some() { + transitive_steps_for_job(summary, &loc.job, direction) + } else { + transitive_steps(summary, step, direction) + }; + + Ok(GraphDepsReport { + direction: direction.as_str().to_string(), + step: step.to_string(), + step_location: loc, + transitive_jobs, + transitive_steps, + }) +} + +/// Render a dependency report as terminal-friendly text. +pub fn render_text(report: &GraphDepsReport) -> String { + let mut out = String::new(); + out.push_str(&format!( + "Graph dependencies for step '{}' ({})\n", + report.step, report.direction + )); + out.push_str("Step location\n"); + out.push_str(&format!( + " {}\n", + qualified( + &report.step_location.stage, + &report.step_location.job, + &report.step_location.step + ) + )); + out.push('\n'); + + out.push_str("Job-level edges\n"); + if report.transitive_jobs.is_empty() { + out.push_str(" (none)\n"); + } else { + for job in &report.transitive_jobs { + out.push_str(&format!(" - {}\n", qualified_job(&job.stage, &job.job))); + } + } + out.push('\n'); + + out.push_str("Step-level output edges\n"); + if report.transitive_steps.is_empty() { + out.push_str(" (none)\n"); + } else { + for step in &report.transitive_steps { + let via = step + .via_output + .as_deref() + .map(|v| format!(" via {v}")) + .unwrap_or_default(); + out.push_str(&format!( + " - {}{}\n", + qualified(&step.stage, &step.job, &step.step), + via + )); + } + } + out +} + +fn transitive_jobs( + summary: &PipelineSummary, + loc: &StepLocationEntry, + direction: GraphDepsDirection, +) -> Vec { + let mut seen: BTreeSet<(Option, String)> = BTreeSet::new(); + + for job in reachable_edges(&summary.graph.job_edges, &loc.job, direction) { + seen.insert((stage_for_job(summary, &job), job)); + } + + if let Some(stage) = &loc.stage { + for reached_stage in reachable_edges(&summary.graph.stage_edges, stage, direction) { + for job in jobs_in_stage(summary, &reached_stage) { + seen.insert((Some(reached_stage.clone()), job)); + } + } + } + + seen.into_iter() + .map(|(stage, job)| JobDependency { job, stage }) + .collect() +} + +fn reachable_edges( + edges: &[EdgeEntry], + start: &str, + direction: GraphDepsDirection, +) -> BTreeSet { + let mut adjacency: BTreeMap> = BTreeMap::new(); + for e in edges { + match direction { + GraphDepsDirection::Upstream => { + adjacency + .entry(e.consumer.clone()) + .or_default() + .insert(e.producer.clone()); + } + GraphDepsDirection::Downstream => { + adjacency + .entry(e.producer.clone()) + .or_default() + .insert(e.consumer.clone()); + } + } + } + let mut seen = BTreeSet::new(); + let mut queue: VecDeque = adjacency + .get(start) + .into_iter() + .flat_map(|next| next.iter().cloned()) + .collect(); + while let Some(node) = queue.pop_front() { + if !seen.insert(node.clone()) { + continue; + } + if let Some(next) = adjacency.get(&node) { + queue.extend(next.iter().cloned()); + } + } + seen +} + +fn transitive_steps( + summary: &PipelineSummary, + step: &str, + direction: GraphDepsDirection, +) -> Vec { + let nodes = step_nodes(summary); + let node_by_step: BTreeMap = nodes + .iter() + .map(|node| (node.step.clone(), node.clone())) + .collect(); + + match direction { + GraphDepsDirection::Upstream => upstream_steps(step, &node_by_step), + GraphDepsDirection::Downstream => downstream_steps(step, &nodes), + } +} + +fn transitive_steps_for_job( + summary: &PipelineSummary, + job: &str, + direction: GraphDepsDirection, +) -> Vec { + let nodes = step_nodes(summary); + let node_by_step: BTreeMap = nodes + .iter() + .map(|node| (node.step.clone(), node.clone())) + .collect(); + + match direction { + GraphDepsDirection::Upstream => { + let refs = nodes + .iter() + .filter(|node| node.job == job) + .flat_map(|node| node.refs.iter().cloned()) + .collect(); + upstream_from_refs(refs, &node_by_step) + } + GraphDepsDirection::Downstream => { + let start_steps: Vec = summary + .graph + .step_locations + .iter() + .filter(|loc| loc.job == job) + .map(|loc| loc.step.clone()) + .collect(); + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for start_step in start_steps { + for dep in downstream_steps(&start_step, &nodes) { + if seen.insert(dep.step.clone()) { + out.push(dep); + } + } + } + out + } + } +} + +fn upstream_steps(step: &str, node_by_step: &BTreeMap) -> Vec { + let Some(node) = node_by_step.get(step) else { + return Vec::new(); + }; + upstream_from_refs(node.refs.clone(), node_by_step) +} + +fn upstream_from_refs( + refs: Vec, + node_by_step: &BTreeMap, +) -> Vec { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + let mut queue: VecDeque = refs.into(); + + while let Some(reference) = queue.pop_front() { + let producer = reference.producer_step.clone(); + if !seen.insert(producer.clone()) { + continue; + } + if let Some(producer_node) = node_by_step.get(&producer) { + out.push(StepDependency { + step: producer.clone(), + job: producer_node.job.clone(), + stage: producer_node.stage.clone(), + via_output: Some(format!("{}.{}", producer, reference.output_name)), + }); + queue.extend(producer_node.refs.iter().cloned()); + } + } + out +} + +fn downstream_steps(step: &str, nodes: &[StepNode]) -> Vec { + let mut reverse: BTreeMap> = BTreeMap::new(); + for node in nodes { + for reference in &node.refs { + reverse + .entry(reference.producer_step.clone()) + .or_default() + .push((node.clone(), reference.output_name.clone())); + } + } + + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + let mut queue = VecDeque::from([step.to_string()]); + while let Some(producer) = queue.pop_front() { + let Some(consumers) = reverse.get(&producer) else { + continue; + }; + for (consumer, output_name) in consumers { + if !seen.insert(consumer.step.clone()) { + continue; + } + out.push(StepDependency { + step: consumer.step.clone(), + job: consumer.job.clone(), + stage: consumer.stage.clone(), + via_output: Some(format!("{}.{}", producer, output_name)), + }); + queue.push_back(consumer.step.clone()); + } + } + out +} + +#[derive(Debug, Clone)] +struct StepNode { + step: String, + job: String, + stage: Option, + refs: Vec, +} + +#[derive(Debug, Clone)] +struct StepReference { + producer_step: String, + output_name: String, +} + +fn step_nodes(summary: &PipelineSummary) -> Vec { + let mut nodes = Vec::new(); + match &summary.body { + PipelineBodySummary::Jobs { jobs } => { + for job in jobs { + push_job_step_nodes(&mut nodes, job); + } + } + PipelineBodySummary::Stages { stages } => { + for stage in stages { + for job in &stage.jobs { + push_job_step_nodes(&mut nodes, job); + } + } + } + } + nodes +} + +fn push_job_step_nodes(nodes: &mut Vec, job: &JobSummary) { + for (idx, step) in job.steps.iter().enumerate() { + let step_label = step_label(step, job, idx); + nodes.push(StepNode { + step: step_label, + job: job.id.clone(), + stage: job.stage.clone(), + refs: step_refs(step), + }); + } +} + +fn step_refs(step: &StepSummary) -> Vec { + step.env_refs + .iter() + .chain(step.condition_refs.iter()) + .map(|r| StepReference { + producer_step: r.step.clone(), + output_name: r.name.clone(), + }) + .collect() +} + +fn step_label(step: &StepSummary, job: &JobSummary, idx: usize) -> String { + step.id + .clone() + .unwrap_or_else(|| format!("{}#{}", job.id, idx + 1)) +} + +fn stage_for_job(summary: &PipelineSummary, job_id: &str) -> Option { + find_job(summary, job_id).and_then(|job| job.stage.clone()) +} + +fn jobs_in_stage(summary: &PipelineSummary, stage_id: &str) -> Vec { + match &summary.body { + PipelineBodySummary::Jobs { .. } => Vec::new(), + PipelineBodySummary::Stages { stages } => stages + .iter() + .find(|stage| stage.id == stage_id) + .map(|stage| stage.jobs.iter().map(|job| job.id.clone()).collect()) + .unwrap_or_default(), + } +} + +fn find_job<'a>(summary: &'a PipelineSummary, job_id: &str) -> Option<&'a JobSummary> { + match &summary.body { + PipelineBodySummary::Jobs { jobs } => jobs.iter().find(|job| job.id == job_id), + PipelineBodySummary::Stages { stages } => stages + .iter() + .flat_map(|stage| stage.jobs.iter()) + .find(|job| job.id == job_id), + } +} + +fn known_step_or_job_ids(summary: &PipelineSummary) -> Vec { + let mut ids: Vec = summary + .graph + .step_locations + .iter() + .map(|loc| loc.step.clone()) + .collect(); + match &summary.body { + PipelineBodySummary::Jobs { jobs } => ids.extend(jobs.iter().map(|job| job.id.clone())), + PipelineBodySummary::Stages { stages } => ids.extend( + stages + .iter() + .flat_map(|stage| stage.jobs.iter().map(|job| job.id.clone())), + ), + } + ids +} + +fn qualified(stage: &Option, job: &str, step: &str) -> String { + match stage { + Some(stage) => format!("{stage}.{job}.{step}"), + None => format!("{job}.{step}"), + } +} + +fn qualified_job(stage: &Option, job: &str) -> String { + match stage { + Some(stage) => format!("{stage}.{job}"), + None => job.to_string(), + } +} + +fn closest<'a>(needle: &str, candidates: impl Iterator) -> Option { + candidates + .map(|candidate| (levenshtein(needle, candidate), candidate)) + .min_by_key(|(distance, candidate)| (*distance, (*candidate).to_string())) + .map(|(_, candidate)| candidate.to_string()) +} + +fn levenshtein(a: &str, b: &str) -> usize { + let mut prev: Vec = (0..=b.chars().count()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut curr = vec![i + 1]; + for (j, cb) in b.chars().enumerate() { + let cost = usize::from(ca != cb); + curr.push((curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost)); + } + prev = curr; + } + prev[b.chars().count()] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::ir::summary::{ + GraphSummary, OutputDeclSummary, OutputRefSummary, PipelineBodySummary, PoolSummary, + StepKind, + }; + + fn summary(jobs: Vec, edges: Vec<(&str, &str)>) -> PipelineSummary { + let step_locations = jobs + .iter() + .flat_map(|job| { + job.steps.iter().filter_map(|step| { + step.id.as_ref().map(|id| StepLocationEntry { + step: id.clone(), + stage: job.stage.clone(), + job: job.id.clone(), + outputs: step.outputs.iter().map(|o| o.name.clone()).collect(), + }) + }) + }) + .collect(); + PipelineSummary { + schema_version: 1, + name: "test".to_string(), + shape: "standalone".to_string(), + body: PipelineBodySummary::Jobs { jobs }, + graph: GraphSummary { + step_locations, + job_edges: edges + .into_iter() + .map(|(consumer, producer)| EdgeEntry { + consumer: consumer.to_string(), + producer: producer.to_string(), + }) + .collect(), + stage_edges: Vec::new(), + outputs_needing_is_output: Vec::new(), + }, + } + } + + fn job(id: &str, steps: Vec) -> JobSummary { + JobSummary { + id: id.to_string(), + stage: None, + display_name: id.to_string(), + depends_on: Vec::new(), + condition: None, + pool: PoolSummary::VmImage { + image: "ubuntu-latest".to_string(), + }, + steps, + } + } + + fn step(id: &str, outputs: &[&str], refs: &[(&str, &str)]) -> StepSummary { + StepSummary { + id: Some(id.to_string()), + kind: StepKind::Bash, + display_name: Some(id.to_string()), + task: None, + condition: None, + outputs: outputs + .iter() + .map(|name| OutputDeclSummary { + name: (*name).to_string(), + is_secret: false, + auto_is_output: false, + }) + .collect(), + env_refs: refs + .iter() + .map(|(producer, name)| OutputRefSummary { + step: (*producer).to_string(), + name: (*name).to_string(), + }) + .collect(), + condition_refs: Vec::new(), + } + } + + #[test] + fn no_upstream_or_downstream_returns_empty_lists() { + let s = summary(vec![job("Solo", vec![step("A", &[], &[])])], vec![]); + + let upstream = analyze(&s, "A", GraphDepsDirection::Upstream).unwrap(); + let downstream = analyze(&s, "A", GraphDepsDirection::Downstream).unwrap(); + + assert!(upstream.transitive_jobs.is_empty()); + assert!(upstream.transitive_steps.is_empty()); + assert!(downstream.transitive_jobs.is_empty()); + assert!(downstream.transitive_steps.is_empty()); + } + + #[test] + fn transitive_walk_crosses_multiple_hops() { + let s = summary( + vec![ + job("Setup", vec![step("A", &["one"], &[])]), + job("Build", vec![step("B", &["two"], &[("A", "one")])]), + job("Test", vec![step("C", &[], &[("B", "two")])]), + ], + vec![("Build", "Setup"), ("Test", "Build")], + ); + + let report = analyze(&s, "C", GraphDepsDirection::Upstream).unwrap(); + + assert_eq!( + report + .transitive_jobs + .iter() + .map(|j| j.job.as_str()) + .collect::>(), + vec!["Build", "Setup"] + ); + assert_eq!( + report + .transitive_steps + .iter() + .map(|s| s.step.as_str()) + .collect::>(), + vec!["B", "A"] + ); + } + + #[test] + fn step_not_found_returns_typed_error() { + let s = summary(vec![job("Solo", vec![step("A", &[], &[])])], vec![]); + + let err = analyze(&s, "Missing", GraphDepsDirection::Upstream).unwrap_err(); + assert!(err.downcast_ref::().is_some()); + } + + #[test] + fn bidirectional_symmetry_for_step_edges() { + let s = summary( + vec![ + job("Setup", vec![step("A", &["one"], &[])]), + job("Build", vec![step("B", &[], &[("A", "one")])]), + ], + vec![("Build", "Setup")], + ); + + let b_upstream = analyze(&s, "B", GraphDepsDirection::Upstream).unwrap(); + let a_downstream = analyze(&s, "A", GraphDepsDirection::Downstream).unwrap(); + + assert!(b_upstream.transitive_steps.iter().any(|s| s.step == "A")); + assert!(a_downstream.transitive_steps.iter().any(|s| s.step == "B")); + } +} diff --git a/src/inspect/graph_outputs.rs b/src/inspect/graph_outputs.rs new file mode 100644 index 00000000..e937ecd3 --- /dev/null +++ b/src/inspect/graph_outputs.rs @@ -0,0 +1,317 @@ +//! Output declaration/reference table for `ado-aw graph outputs`. +//! +//! This module intentionally works from the public [`PipelineSummary`] +//! instead of the compiler's internal graph. That keeps the command's +//! JSON shape aligned with the stable inspect schema while still +//! answering producer/consumer questions precisely. + +use std::collections::BTreeSet; + +use serde::Serialize; + +use crate::compile::ir::summary::{JobSummary, PipelineBodySummary, PipelineSummary, StepSummary}; + +/// Source location of an output reference on a consumer step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputConsumerSource { + /// Reference came from the step's `env:` map. + Env, + /// Reference came from the step's `condition:` expression. + Condition, +} + +/// A step that reads a producer output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutputConsumer { + /// Consumer step id, or a stable anonymous label for steps without `id`. + pub step: String, + /// Whether the reference came from `env` or `condition`. + pub source: OutputConsumerSource, +} + +/// Public output edge emitted by `ado-aw graph outputs --json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutputEdge { + /// Step that declares the output. + pub producer_step: String, + /// Declared output variable name. + pub output_name: String, + /// Whether the output is marked secret. + pub is_secret: bool, + /// Whether the graph pass determined the output needs `isOutput=true`. + pub auto_is_output: bool, + /// Steps that read this output. + pub consumers: Vec, +} + +/// Build the declared-output table, optionally filtering by producer and/or consumer. +pub fn output_edges( + summary: &PipelineSummary, + producer_filter: Option<&str>, + consumer_filter: Option<&str>, +) -> Vec { + let steps = step_records(summary); + let mut edges = Vec::new(); + + for producer in &steps { + let Some(producer_step) = producer.id.as_deref() else { + continue; + }; + if producer_filter.is_some_and(|filter| filter != producer_step) { + continue; + } + + for output in &producer.step.outputs { + let mut consumers = Vec::new(); + for consumer in &steps { + if consumer_filter.is_some_and(|filter| consumer.id.as_deref() != Some(filter)) { + continue; + } + for r in &consumer.step.env_refs { + if r.step == producer_step && r.name == output.name { + consumers.push(OutputConsumer { + step: consumer.label.clone(), + source: OutputConsumerSource::Env, + }); + } + } + for r in &consumer.step.condition_refs { + if r.step == producer_step && r.name == output.name { + consumers.push(OutputConsumer { + step: consumer.label.clone(), + source: OutputConsumerSource::Condition, + }); + } + } + } + + if consumer_filter.is_some() && consumers.is_empty() { + continue; + } + + edges.push(OutputEdge { + producer_step: producer_step.to_string(), + output_name: output.name.clone(), + is_secret: output.is_secret, + auto_is_output: output.auto_is_output, + consumers, + }); + } + } + + edges +} + +/// Render output edges as a concise terminal table. +pub fn render_text(edges: &[OutputEdge]) -> String { + let mut out = String::new(); + if edges.is_empty() { + out.push_str("(no declared outputs)\n"); + return out; + } + + for edge in edges { + let consumers = unique_consumer_steps(edge); + let consumer_text = if consumers.is_empty() { + "[]".to_string() + } else { + format!("[{}]", consumers.into_iter().collect::>().join(", ")) + }; + out.push_str(&format!( + "{}.{} → consumers: {}\n", + edge.producer_step, edge.output_name, consumer_text + )); + } + out +} + +fn unique_consumer_steps(edge: &OutputEdge) -> BTreeSet { + edge.consumers + .iter() + .map(|consumer| consumer.step.clone()) + .collect() +} + +#[derive(Clone)] +struct StepRecord<'a> { + id: Option, + label: String, + step: &'a StepSummary, +} + +fn step_records(summary: &PipelineSummary) -> Vec> { + let mut records = Vec::new(); + match &summary.body { + PipelineBodySummary::Jobs { jobs } => { + for job in jobs { + push_job_steps(&mut records, job); + } + } + PipelineBodySummary::Stages { stages } => { + for stage in stages { + for job in &stage.jobs { + push_job_steps(&mut records, job); + } + } + } + } + records +} + +fn push_job_steps<'a>(records: &mut Vec>, job: &'a JobSummary) { + for (idx, step) in job.steps.iter().enumerate() { + records.push(StepRecord { + id: step.id.clone(), + label: step + .id + .clone() + .unwrap_or_else(|| format!("{}#{}", job.id, idx + 1)), + step, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::ir::summary::{ + EdgeEntry, GraphSummary, OutputDeclSummary, OutputRefSummary, PoolSummary, StepKind, + }; + + fn summary(steps: Vec) -> PipelineSummary { + let jobs = vec![JobSummary { + id: "Job".to_string(), + stage: None, + display_name: "Job".to_string(), + depends_on: Vec::new(), + condition: None, + pool: PoolSummary::VmImage { + image: "ubuntu-latest".to_string(), + }, + steps, + }]; + PipelineSummary { + schema_version: 1, + name: "test".to_string(), + shape: "standalone".to_string(), + body: PipelineBodySummary::Jobs { jobs }, + graph: GraphSummary { + step_locations: Vec::new(), + job_edges: Vec::::new(), + stage_edges: Vec::new(), + outputs_needing_is_output: Vec::new(), + }, + } + } + + fn producer(id: &str, outputs: &[&str]) -> StepSummary { + StepSummary { + id: Some(id.to_string()), + kind: StepKind::Bash, + display_name: Some(id.to_string()), + task: None, + condition: None, + outputs: outputs + .iter() + .map(|name| OutputDeclSummary { + name: (*name).to_string(), + is_secret: false, + auto_is_output: false, + }) + .collect(), + env_refs: Vec::new(), + condition_refs: Vec::new(), + } + } + + fn consumer( + id: &str, + env_refs: &[(&str, &str)], + condition_refs: &[(&str, &str)], + ) -> StepSummary { + StepSummary { + id: Some(id.to_string()), + kind: StepKind::Bash, + display_name: Some(id.to_string()), + task: None, + condition: None, + outputs: Vec::new(), + env_refs: env_refs + .iter() + .map(|(step, name)| OutputRefSummary { + step: (*step).to_string(), + name: (*name).to_string(), + }) + .collect(), + condition_refs: condition_refs + .iter() + .map(|(step, name)| OutputRefSummary { + step: (*step).to_string(), + name: (*name).to_string(), + }) + .collect(), + } + } + + #[test] + fn output_with_no_consumers_is_preserved() { + let s = summary(vec![producer("P", &["value"])]); + + let edges = output_edges(&s, None, None); + + assert_eq!(edges.len(), 1); + assert!(edges[0].consumers.is_empty()); + assert!( + serde_json::to_string(&edges) + .unwrap() + .contains("\"consumers\":[]") + ); + } + + #[test] + fn producer_filter_selects_matching_outputs() { + let s = summary(vec![producer("A", &["one"]), producer("B", &["two"])]); + + let edges = output_edges(&s, Some("B"), None); + + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].producer_step, "B"); + } + + #[test] + fn consumer_filter_selects_outputs_read_by_consumer() { + let s = summary(vec![ + producer("A", &["one"]), + producer("B", &["two"]), + consumer("C", &[("B", "two")], &[]), + ]); + + let edges = output_edges(&s, None, Some("C")); + + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].producer_step, "B"); + assert_eq!(edges[0].consumers[0].step, "C"); + } + + #[test] + fn consumers_include_env_and_condition_refs() { + let s = summary(vec![ + producer("A", &["one"]), + consumer("Env", &[("A", "one")], &[]), + consumer("Cond", &[], &[("A", "one")]), + ]); + + let edges = output_edges(&s, None, None); + let sources = edges[0] + .consumers + .iter() + .map(|consumer| match consumer.source { + OutputConsumerSource::Env => "env", + OutputConsumerSource::Condition => "condition", + }) + .collect::>(); + + assert_eq!(sources, vec!["env", "condition"]); + } +} diff --git a/src/inspect/graph_query.rs b/src/inspect/graph_query.rs new file mode 100644 index 00000000..8c7c713b --- /dev/null +++ b/src/inspect/graph_query.rs @@ -0,0 +1,168 @@ +//! Graph-query rendering helpers. +//! +//! `cli::dispatch_graph` builds the [`PipelineSummary`] (which +//! contains the resolved [`crate::compile::ir::summary::GraphSummary`]) +//! and asks this module to render it in the user-selected format. +//! +//! Text mode is human-scannable; JSON is the public schema (rendered +//! by `cli::dispatch_graph` directly via serde); DOT is a tiny +//! Graphviz adapter so users can pipe to `dot -Tsvg`. + +use crate::compile::ir::summary::{ + EdgeEntry, GraphSummary, PipelineBodySummary, PipelineSummary, StepOutputsEntry, +}; + +/// Render a [`PipelineSummary`] as scannable text suitable for a +/// terminal. +pub fn render_text(s: &PipelineSummary) -> String { + let mut out = String::new(); + out.push_str(&format!("Pipeline: {} ({})\n", s.name, s.shape)); + out.push('\n'); + + out.push_str("Step locations\n"); + if s.graph.step_locations.is_empty() { + out.push_str(" (none)\n"); + } else { + for loc in &s.graph.step_locations { + let stage = loc + .stage + .as_deref() + .map(|s| format!("{}.", s)) + .unwrap_or_default(); + let outs = if loc.outputs.is_empty() { + String::new() + } else { + format!(" outputs=[{}]", loc.outputs.join(", ")) + }; + out.push_str(&format!(" {}{}.{}{}\n", stage, loc.job, loc.step, outs)); + } + } + out.push('\n'); + + out.push_str("Job edges (consumer -> producer)\n"); + render_edges(&s.graph.job_edges, &mut out); + out.push('\n'); + + out.push_str("Stage edges (consumer -> producer)\n"); + render_edges(&s.graph.stage_edges, &mut out); + out.push('\n'); + + out.push_str("Outputs needing isOutput=true\n"); + render_step_outputs(&s.graph.outputs_needing_is_output, &mut out); + + // Job step-count footer so users see at-a-glance how many steps + // each job carries; helpful when comparing builds. + out.push('\n'); + out.push_str("Job step counts\n"); + match &s.body { + PipelineBodySummary::Jobs { jobs } => { + for j in jobs { + out.push_str(&format!(" {}: {}\n", j.id, j.steps.len())); + } + } + PipelineBodySummary::Stages { stages } => { + for st in stages { + for j in &st.jobs { + out.push_str(&format!(" {}.{}: {}\n", st.id, j.id, j.steps.len())); + } + } + } + } + out +} + +/// Render a [`PipelineSummary`] in Graphviz DOT format. +/// +/// Two clusters are emitted — one for jobs, one for stages — and +/// edges point from consumer to producer (matching the IR +/// `depends_on` semantics). Stage-grouped jobs are placed inside +/// their stage's cluster so `dot` lays them out together. +pub fn render_dot(s: &PipelineSummary) -> String { + let mut out = String::new(); + out.push_str("digraph ado_aw_pipeline {\n"); + out.push_str(" rankdir=LR;\n"); + out.push_str(" node [shape=box, fontname=\"Helvetica\"];\n"); + + match &s.body { + PipelineBodySummary::Jobs { jobs } => { + for j in jobs { + out.push_str(&format!( + " \"{}\" [label=\"{}\\n({} steps)\"];\n", + j.id, + escape_dot(&j.display_name), + j.steps.len() + )); + } + } + PipelineBodySummary::Stages { stages } => { + for st in stages { + out.push_str(&format!( + " subgraph \"cluster_{}\" {{\n label=\"{}\";\n style=dashed;\n", + st.id, + escape_dot(&st.display_name), + )); + for j in &st.jobs { + out.push_str(&format!( + " \"{}.{}\" [label=\"{}\\n({} steps)\"];\n", + st.id, + j.id, + escape_dot(&j.display_name), + j.steps.len() + )); + } + out.push_str(" }\n"); + } + } + } + + for e in &s.graph.job_edges { + // Stages-bodied pipelines use `stage.job` as the node id so + // we don't collide on identical job ids across stages. + let (cons, prod) = match &s.body { + PipelineBodySummary::Jobs { .. } => (e.consumer.clone(), e.producer.clone()), + PipelineBodySummary::Stages { stages } => { + let lookup = |job: &str| -> String { + for st in stages { + if st.jobs.iter().any(|j| j.id == job) { + return format!("{}.{}", st.id, job); + } + } + job.to_string() + }; + (lookup(&e.consumer), lookup(&e.producer)) + } + }; + out.push_str(&format!(" \"{}\" -> \"{}\";\n", cons, prod)); + } + out.push_str("}\n"); + out +} + +fn render_edges(edges: &[EdgeEntry], out: &mut String) { + if edges.is_empty() { + out.push_str(" (none)\n"); + } else { + for e in edges { + out.push_str(&format!(" {} -> {}\n", e.consumer, e.producer)); + } + } +} + +fn render_step_outputs(entries: &[StepOutputsEntry], out: &mut String) { + if entries.is_empty() { + out.push_str(" (none)\n"); + } else { + for e in entries { + out.push_str(&format!(" {}: {}\n", e.step, e.outputs.join(", "))); + } + } +} + +fn escape_dot(s: &str) -> String { + s.replace('"', "\\\"") +} + +#[allow(dead_code)] // Re-export shorthand for future call sites. +pub fn graph(s: &PipelineSummary) -> &GraphSummary { + &s.graph +} diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs new file mode 100644 index 00000000..5c5a8d28 --- /dev/null +++ b/src/inspect/lint.rs @@ -0,0 +1,429 @@ +//! Structural lint rules over [`PipelineSummary`]. +//! +//! `build_pipeline_ir()` and [`PipelineSummary::from_pipeline`] already run the +//! compile-time IR graph validation pass. These lint rules are intentionally +//! lighter-weight, user-facing quality checks; a few are defensive guards for +//! callers that might construct summaries without the normal graph pass. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::compile::ir::summary::{JobSummary, PipelineBodySummary, PipelineSummary, StepSummary}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LintSeverity { + Error, + Warning, + Info, +} + +impl LintSeverity { + pub fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warning => "warning", + Self::Info => "info", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LintLocation { + #[serde(skip_serializing_if = "Option::is_none")] + pub stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub job: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub step: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LintFinding { + pub severity: LintSeverity, + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LintSummary { + pub errors: u32, + pub warnings: u32, + pub infos: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LintReport { + pub findings: Vec, + pub summary: LintSummary, +} + +/// Run every lint rule over a public pipeline summary. +pub fn lint(summary: &PipelineSummary) -> Vec { + let mut findings = Vec::new(); + rule_unused_output(summary, &mut findings); + rule_missing_is_output(summary, &mut findings); + rule_anonymous_producer(summary, &mut findings); + rule_no_condition_references(summary, &mut findings); + rule_step_id_collisions(summary, &mut findings); + findings +} + +pub fn report(summary: &PipelineSummary) -> LintReport { + let findings = lint(summary); + let summary = summarize_findings(&findings); + LintReport { findings, summary } +} + +pub fn summarize_findings(findings: &[LintFinding]) -> LintSummary { + let mut summary = LintSummary { + errors: 0, + warnings: 0, + infos: 0, + }; + for finding in findings { + match finding.severity { + LintSeverity::Error => summary.errors += 1, + LintSeverity::Warning => summary.warnings += 1, + LintSeverity::Info => summary.infos += 1, + } + } + summary +} + +pub fn render_text(report: &LintReport) -> String { + let mut out = String::new(); + render_group(&mut out, LintSeverity::Error, "Errors", &report.findings); + render_group( + &mut out, + LintSeverity::Warning, + "Warnings", + &report.findings, + ); + render_group(&mut out, LintSeverity::Info, "Infos", &report.findings); + out +} + +fn render_group(out: &mut String, severity: LintSeverity, heading: &str, findings: &[LintFinding]) { + out.push_str(heading); + out.push('\n'); + let mut any = false; + for finding in findings.iter().filter(|f| f.severity == severity) { + any = true; + out.push_str(&format!( + "{} {}{}: {}\n", + finding.severity.as_str(), + finding.code, + format_location(finding.location.as_ref()), + finding.message + )); + } + if !any { + out.push_str(" (none)\n"); + } +} + +fn format_location(location: Option<&LintLocation>) -> String { + let Some(location) = location else { + return String::new(); + }; + let mut parts = Vec::new(); + if let Some(stage) = &location.stage { + parts.push(format!("stage={stage}")); + } + if let Some(job) = &location.job { + parts.push(format!("job={job}")); + } + if let Some(step) = &location.step { + parts.push(format!("step={step}")); + } + if parts.is_empty() { + String::new() + } else { + format!(" [{}]", parts.join(" ")) + } +} + +fn rule_unused_output(summary: &PipelineSummary, findings: &mut Vec) { + let consumed = consumed_outputs(summary); + for (job, step) in all_steps(summary) { + let Some(step_id) = step.id.as_deref() else { + continue; + }; + for output in &step.outputs { + let key = (step_id.to_string(), output.name.clone()); + if !consumed.contains(&key) { + findings.push(LintFinding { + severity: LintSeverity::Warning, + code: "unused-output".to_string(), + message: format!( + "output '{}.{}' is declared but never read", + step_id, output.name + ), + location: Some(location_for(job, Some(step_id))), + }); + } + } + } +} + +fn rule_missing_is_output(summary: &PipelineSummary, findings: &mut Vec) { + let declarations = output_declarations(summary); + for needed in &summary.graph.outputs_needing_is_output { + for output_name in &needed.outputs { + if let Some((job, step, decl)) = + declarations.get(&(needed.step.clone(), output_name.clone())) + { + // TODO: This should remain quiet while PipelineSummary patches + // auto_is_output from the graph. Keep the guard so lint catches + // future drift between summary generation and graph codegen. + if !decl.auto_is_output { + findings.push(LintFinding { + severity: LintSeverity::Info, + code: "missing-is-output".to_string(), + message: format!( + "output '{}.{}' is consumed across steps but is not marked isOutput=true", + needed.step, output_name + ), + location: Some(location_for(job, step.id.as_deref())), + }); + } + } + } + } +} + +fn rule_anonymous_producer(summary: &PipelineSummary, findings: &mut Vec) { + for (job, step) in all_steps(summary) { + if step.id.is_none() && !step.outputs.is_empty() { + // The normal graph pass rejects this before lint runs. This + // defensive rule also protects callers that lint a PipelineSummary + // produced without build_graph validation. + findings.push(LintFinding { + severity: LintSeverity::Error, + code: "anonymous-producer".to_string(), + message: "step declares outputs but has no step id/name".to_string(), + location: Some(location_for(job, None)), + }); + } + } +} + +fn rule_no_condition_references(summary: &PipelineSummary, findings: &mut Vec) { + for job in all_jobs(summary) { + if !job.depends_on.is_empty() && job.condition.is_none() { + findings.push(LintFinding { + severity: LintSeverity::Info, + code: "no-condition-references".to_string(), + message: format!( + "job '{}' depends on [{}] with no condition; Azure DevOps applies default succeeded(), so all upstream jobs must succeed", + job.id, + job.depends_on.join(", ") + ), + location: Some(location_for(job, None)), + }); + } + } +} + +fn rule_step_id_collisions(summary: &PipelineSummary, findings: &mut Vec) { + let mut seen: BTreeMap = BTreeMap::new(); + for (job, step) in all_steps(summary) { + if let Some(step_id) = step.id.as_deref() + && seen.insert(step_id.to_string(), job).is_some() + { + // The normal graph pass rejects pipeline-wide duplicate step ids. + // Keep this defensive check for summaries that bypassed the graph. + findings.push(LintFinding { + severity: LintSeverity::Error, + code: "step-id-collisions".to_string(), + message: format!("step id '{step_id}' is used more than once in the pipeline"), + location: Some(location_for(job, Some(step_id))), + }); + } + } +} + +fn consumed_outputs(summary: &PipelineSummary) -> BTreeSet<(String, String)> { + summary + .graph + .outputs_needing_is_output + .iter() + .flat_map(|entry| { + entry + .outputs + .iter() + .map(|output| (entry.step.clone(), output.clone())) + }) + .collect() +} + +fn output_declarations( + summary: &PipelineSummary, +) -> BTreeMap< + (String, String), + ( + &JobSummary, + &StepSummary, + &crate::compile::ir::summary::OutputDeclSummary, + ), +> { + let mut declarations = BTreeMap::new(); + for (job, step) in all_steps(summary) { + if let Some(step_id) = step.id.as_deref() { + for decl in &step.outputs { + declarations.insert((step_id.to_string(), decl.name.clone()), (job, step, decl)); + } + } + } + declarations +} + +fn all_jobs(summary: &PipelineSummary) -> Vec<&JobSummary> { + match &summary.body { + PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), + PipelineBodySummary::Stages { stages } => { + stages.iter().flat_map(|stage| stage.jobs.iter()).collect() + } + } +} + +fn all_steps(summary: &PipelineSummary) -> Vec<(&JobSummary, &StepSummary)> { + all_jobs(summary) + .into_iter() + .flat_map(|job| job.steps.iter().map(move |step| (job, step))) + .collect() +} + +fn location_for(job: &JobSummary, step: Option<&str>) -> LintLocation { + LintLocation { + stage: job.stage.clone(), + job: Some(job.id.clone()), + step: step.map(str::to_string), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::ir::summary::{ + GraphSummary, OutputDeclSummary, PipelineBodySummary, PoolSummary, StepKind, + StepOutputsEntry, + }; + + #[test] + fn unused_output_produces_exactly_one_inspect_lint_finding() { + let summary = + summary_with_steps(vec![step_with_output("producer", "value", false)], vec![]); + let findings = lint(&summary); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].code, "unused-output"); + assert_eq!(findings[0].severity, LintSeverity::Warning); + } + + #[test] + fn no_findings_inspect_lint_emits_empty_list_and_zero_errors() { + let summary = summary_with_steps(vec![plain_step("only")], vec![]); + let report = report(&summary); + assert!(report.findings.is_empty()); + assert_eq!(report.summary.errors, 0); + } + + #[test] + fn consumed_outputs_do_not_emit_unused_output_inspect_lint() { + let summary = summary_with_steps( + vec![step_with_output("producer", "pull_request_id", true)], + vec![StepOutputsEntry { + step: "producer".to_string(), + outputs: vec!["pull_request_id".to_string()], + }], + ); + let findings = lint(&summary); + assert!(!findings.iter().any(|f| f.code == "unused-output")); + } + + #[tokio::test] + async fn create_pull_request_fixture_has_no_unused_output_inspect_lint() { + let (_fm, pipeline) = crate::compile::build_pipeline_ir(std::path::Path::new( + "tests\\safe-outputs\\create-pull-request.md", + )) + .await + .unwrap(); + let summary = PipelineSummary::from_pipeline(&pipeline).unwrap(); + let findings = lint(&summary); + assert!(!findings.iter().any(|f| f.code == "unused-output")); + } + + #[test] + fn lint_finding_json_serialization_round_trips_for_inspect() { + let finding = LintFinding { + severity: LintSeverity::Info, + code: "no-condition-references".to_string(), + message: "example".to_string(), + location: Some(LintLocation { + stage: Some("Stage".to_string()), + job: Some("Job".to_string()), + step: None, + }), + }; + let json = serde_json::to_string(&finding).unwrap(); + let round_trip: LintFinding = serde_json::from_str(&json).unwrap(); + assert_eq!(round_trip, finding); + } + + fn summary_with_steps( + steps: Vec, + outputs_needing_is_output: Vec, + ) -> PipelineSummary { + PipelineSummary { + schema_version: 1, + name: "test".to_string(), + shape: "standalone".to_string(), + body: PipelineBodySummary::Jobs { + jobs: vec![JobSummary { + id: "Job".to_string(), + stage: None, + display_name: "Job".to_string(), + depends_on: vec![], + condition: None, + pool: PoolSummary::VmImage { + image: "ubuntu-latest".to_string(), + }, + steps, + }], + }, + graph: GraphSummary { + step_locations: vec![], + job_edges: vec![], + stage_edges: vec![], + outputs_needing_is_output, + }, + } + } + + fn plain_step(id: &str) -> StepSummary { + StepSummary { + id: Some(id.to_string()), + kind: StepKind::Bash, + display_name: Some(id.to_string()), + task: None, + condition: None, + outputs: vec![], + env_refs: vec![], + condition_refs: vec![], + } + } + + fn step_with_output(id: &str, output: &str, auto_is_output: bool) -> StepSummary { + let mut step = plain_step(id); + step.outputs.push(OutputDeclSummary { + name: output.to_string(), + is_secret: false, + auto_is_output, + }); + step + } +} diff --git a/src/inspect/mod.rs b/src/inspect/mod.rs new file mode 100644 index 00000000..df0d6da9 --- /dev/null +++ b/src/inspect/mod.rs @@ -0,0 +1,43 @@ +//! Inspection commands: typed-IR queries over agent source files. +//! +//! This module is the home for every read-only command that loads an +//! agent's `.md`, builds the typed [`crate::compile::ir::Pipeline`] +//! IR, and answers a question about it without producing any YAML on +//! disk. +//! +//! Layout follows `src/audit/`: +//! +//! - `cli.rs` — dispatchers for the public CLI subcommands. +//! - `graph_query.rs` — the `ado-aw graph` family (text/json/dot, +//! `graph deps`, `graph outputs`). +//! +//! Future siblings (called out in the implementation plan, not yet +//! landed): +//! +//! - `trace.rs` — `ado-aw trace`: joins build telemetry from +//! [`crate::audit`] with the IR graph. +//! - `whatif.rs` — `ado-aw whatif`: static reachability ("which jobs +//! skip if X fails?") from the typed `Condition` + `depends_on`. +//! - `lint.rs` — `ado-aw lint`: structural checks layered on top of +//! the compile-stage validators. +//! - `catalog.rs` — `ado-aw catalog`: programmatic listing of +//! in-tree registries (safe-outputs, runtimes, tools, engines, +//! models). + +pub mod catalog; +pub mod cli; +pub mod graph_deps; +pub mod graph_outputs; +pub mod graph_query; +pub mod lint; +pub mod trace; +pub mod whatif; + +pub use cli::{ + CatalogOptions, GraphDepsOptions, GraphFormat, GraphOptions, GraphOutputsOptions, + InspectOptions, LintOptions, TraceOptions, WhatIfOptions, build_catalog, build_graph_deps, + build_graph_dump, build_graph_outputs, build_graph_summary, build_inspect, build_lint, + build_trace, build_whatif, dispatch_catalog, dispatch_graph, dispatch_graph_deps, + dispatch_graph_outputs, dispatch_inspect, dispatch_lint, dispatch_trace, dispatch_whatif, +}; +pub use graph_deps::GraphDepsDirection; diff --git a/src/inspect/trace.rs b/src/inspect/trace.rs new file mode 100644 index 00000000..60555668 --- /dev/null +++ b/src/inspect/trace.rs @@ -0,0 +1,410 @@ +//! `ado-aw trace`: runtime audit data joined with typed-IR graph facts. + +use std::collections::BTreeSet; + +use serde::Serialize; + +use crate::audit::model::{AuditData, JobData}; +use crate::compile::ir::summary::StepLocationEntry; +use crate::inspect::graph_deps::{self, GraphDepsDirection, StepDependency}; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TraceReport { + pub build_id: u64, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub failing_jobs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub step: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TraceJobReport { + pub job: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub stage: Option, + pub status: String, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub upstream: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub downstream: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TraceUpstreamJob { + pub job: String, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TraceDownstreamJob { + pub job: String, + pub classification: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct TraceStepReport { + pub step: String, + pub location: TraceStepLocation, + pub status: String, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub upstream: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub downstream: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub upstream_steps: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub downstream_steps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TraceStepLocation { + #[serde(skip_serializing_if = "Option::is_none")] + pub stage: Option, + pub job: String, +} + +pub fn build_trace_report(audit: &AuditData, step: Option<&str>) -> TraceReport { + let failing_jobs = audit + .jobs + .iter() + .filter(|job| job_failed(job)) + .map(|job| job_report(audit, job)) + .collect(); + + let step_report = step.and_then(|step_id| build_step_report(audit, step_id)); + + TraceReport { + build_id: audit.overview.build_id, + failing_jobs, + step: step_report, + } +} + +pub fn render_text( + audit: &AuditData, + report: &TraceReport, + requested_step: Option<&str>, +) -> String { + let mut out = String::new(); + out.push_str(&format!("Trace for build {}\n", report.build_id)); + match &audit.pipeline_graph { + Some(graph) => out.push_str(&format!("IR graph: {}\n", graph.source_path)), + None => out.push_str("IR graph: unavailable (runtime-only trace)\n"), + } + out.push('\n'); + + out.push_str("Failing job chain\n"); + if report.failing_jobs.is_empty() { + out.push_str(" (no failed jobs)\n"); + } else { + for job in &report.failing_jobs { + render_job_report(job, &mut out); + } + } + + if requested_step.is_some() { + out.push('\n'); + out.push_str("Step trace\n"); + match &report.step { + Some(step) => { + let stage = step + .location + .stage + .as_deref() + .map(|stage| format!("{stage}.")) + .unwrap_or_default(); + out.push_str(&format!( + " {} in {}{}: {}\n", + step.step, stage, step.location.job, step.status + )); + render_upstream(&step.upstream, &mut out); + render_downstream(&step.downstream, &mut out); + render_step_dependencies("upstream steps", &step.upstream_steps, &mut out); + render_step_dependencies("downstream steps", &step.downstream_steps, &mut out); + } + None => out.push_str(" (step not found in local IR graph)\n"), + } + } + + out +} + +fn render_job_report(job: &TraceJobReport, out: &mut String) { + let stage = job + .stage + .as_deref() + .map(|stage| format!(" [{stage}]")) + .unwrap_or_default(); + out.push_str(&format!(" {}{}: {}\n", job.job, stage, job.status)); + render_upstream(&job.upstream, out); + render_downstream(&job.downstream, out); +} + +fn render_upstream(upstream: &[TraceUpstreamJob], out: &mut String) { + if upstream.is_empty() { + out.push_str(" upstream: (none)\n"); + } else { + out.push_str(&format!( + " upstream: {}\n", + upstream + .iter() + .map(|job| format!("{} ({})", job.job, job.status)) + .collect::>() + .join(", ") + )); + } +} + +fn render_downstream(downstream: &[TraceDownstreamJob], out: &mut String) { + if downstream.is_empty() { + out.push_str(" downstream: (none)\n"); + } else { + out.push_str(&format!( + " downstream: {}\n", + downstream + .iter() + .map(|job| format!("{} ({})", job.job, job.classification)) + .collect::>() + .join(", ") + )); + } +} + +fn render_step_dependencies(label: &str, steps: &[StepDependency], out: &mut String) { + if steps.is_empty() { + return; + } + out.push_str(&format!( + " {label}: {}\n", + steps + .iter() + .map(|step| { + let stage = step + .stage + .as_deref() + .map(|stage| format!("{stage}.")) + .unwrap_or_default(); + match &step.via_output { + Some(via) => format!("{}{}.{} via {}", stage, step.job, step.step, via), + None => format!("{}{}.{}", stage, step.job, step.step), + } + }) + .collect::>() + .join(", ") + )); +} + +fn build_step_report(audit: &AuditData, step_id: &str) -> Option { + let graph = audit.pipeline_graph.as_ref()?; + let location = graph + .graph + .graph + .step_locations + .iter() + .find(|location| location.step == step_id)?; + let job = runtime_job_for_location(audit, location); + Some(TraceStepReport { + step: step_id.to_string(), + location: TraceStepLocation { + stage: location.stage.clone(), + job: location.job.clone(), + }, + status: job + .map(job_status) + .unwrap_or_else(|| String::from("unknown")), + upstream: job + .map(|job| upstream_reports(audit, job)) + .unwrap_or_default(), + downstream: job + .map(|job| downstream_reports(audit, job)) + .unwrap_or_default(), + upstream_steps: graph_deps::analyze(&graph.graph, step_id, GraphDepsDirection::Upstream) + .map(|report| report.transitive_steps) + .unwrap_or_default(), + downstream_steps: graph_deps::analyze( + &graph.graph, + step_id, + GraphDepsDirection::Downstream, + ) + .map(|report| report.transitive_steps) + .unwrap_or_default(), + }) +} + +fn job_report(audit: &AuditData, job: &JobData) -> TraceJobReport { + TraceJobReport { + job: job.name.clone(), + stage: stage_for_job(audit, job), + status: job_status(job), + upstream: upstream_reports(audit, job), + downstream: downstream_reports(audit, job), + } +} + +fn upstream_reports(audit: &AuditData, job: &JobData) -> Vec { + collect_related_jobs(audit, job, Direction::Upstream) + .into_iter() + .map(|job_id| TraceUpstreamJob { + status: find_runtime_job(audit, &job_id) + .map(job_status) + .unwrap_or_else(|| String::from("unknown")), + job: job_id, + }) + .collect() +} + +fn downstream_reports(audit: &AuditData, job: &JobData) -> Vec { + collect_related_jobs(audit, job, Direction::Downstream) + .into_iter() + .map(|job_id| TraceDownstreamJob { + classification: find_runtime_job(audit, &job_id) + .map(job_status) + .unwrap_or_else(|| String::from("expected to skip")), + job: job_id, + }) + .collect() +} + +#[derive(Clone, Copy)] +enum Direction { + Upstream, + Downstream, +} + +fn collect_related_jobs(audit: &AuditData, job: &JobData, direction: Direction) -> Vec { + let mut seen = BTreeSet::new(); + let mut ordered = Vec::new(); + collect_related_jobs_inner(audit, job, direction, &mut seen, &mut ordered); + ordered +} + +fn collect_related_jobs_inner( + audit: &AuditData, + job: &JobData, + direction: Direction, + seen: &mut BTreeSet, + ordered: &mut Vec, +) { + let related = match direction { + Direction::Upstream => &job.upstream_jobs, + Direction::Downstream => &job.downstream_jobs, + }; + + for job_id in related { + if !seen.insert(job_id.clone()) { + continue; + } + ordered.push(job_id.clone()); + if let Some(next) = find_runtime_job(audit, job_id) { + collect_related_jobs_inner(audit, next, direction, seen, ordered); + } + } +} + +fn runtime_job_for_location<'a>( + audit: &'a AuditData, + location: &StepLocationEntry, +) -> Option<&'a JobData> { + audit.jobs.iter().find(|job| { + crate::audit::pipeline_graph::timeline_name_matches_job( + &job.name, + &location.job, + location.stage.as_deref(), + ) + }) +} + +fn find_runtime_job<'a>(audit: &'a AuditData, ir_job_id: &str) -> Option<&'a JobData> { + audit.jobs.iter().find(|job| { + job.name == ir_job_id + || job + .name + .rsplit('.') + .next() + .is_some_and(|suffix| suffix == ir_job_id) + }) +} + +fn stage_for_job(audit: &AuditData, runtime_job: &JobData) -> Option { + let graph = audit.pipeline_graph.as_ref()?; + crate::audit::pipeline_graph::all_jobs(&graph.graph) + .into_iter() + .find(|job| { + crate::audit::pipeline_graph::timeline_name_matches_job( + &runtime_job.name, + &job.id, + job.stage.as_deref(), + ) + }) + .and_then(|job| job.stage.clone()) +} + +fn job_failed(job: &JobData) -> bool { + let result = job.result.as_deref().unwrap_or_default(); + result.eq_ignore_ascii_case("failed") + || result.eq_ignore_ascii_case("canceled") + || job.status.eq_ignore_ascii_case("failed") +} + +fn job_status(job: &JobData) -> String { + job.result + .as_deref() + .filter(|result| !result.trim().is_empty()) + .unwrap_or(&job.status) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::audit::model::{AuditData, OverviewData}; + + #[test] + fn build_trace_report_shapes_failed_job_chain_without_network() { + let audit = AuditData { + overview: OverviewData { + build_id: 42, + ..Default::default() + }, + jobs: vec![ + JobData { + name: String::from("Setup"), + status: String::from("completed"), + result: Some(String::from("succeeded")), + ..Default::default() + }, + JobData { + name: String::from("Agent"), + status: String::from("completed"), + result: Some(String::from("failed")), + upstream_jobs: vec![String::from("Setup")], + downstream_jobs: vec![String::from("Detection")], + ..Default::default() + }, + JobData { + name: String::from("Detection"), + status: String::from("completed"), + result: Some(String::from("skipped")), + downstream_jobs: vec![String::from("SafeOutputs")], + ..Default::default() + }, + ], + ..Default::default() + }; + + let report = build_trace_report(&audit, None); + + assert_eq!(report.build_id, 42); + assert_eq!(report.failing_jobs.len(), 1); + assert_eq!(report.failing_jobs[0].job, "Agent"); + assert_eq!(report.failing_jobs[0].upstream[0].status, "succeeded"); + assert_eq!( + report.failing_jobs[0].downstream[0].classification, + "skipped" + ); + assert_eq!( + report.failing_jobs[0].downstream[1].classification, + "expected to skip" + ); + } +} diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs new file mode 100644 index 00000000..9ec2a388 --- /dev/null +++ b/src/inspect/whatif.rs @@ -0,0 +1,450 @@ +//! Static failure reachability for `ado-aw whatif`. +//! +//! The command does not execute a pipeline. It uses the public +//! [`PipelineSummary`] graph and the already-rendered ADO condition +//! strings to classify downstream jobs that would be skipped after a +//! chosen job or step fails. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::error::Error; +use std::fmt; + +use anyhow::{Result, anyhow}; +use serde::Serialize; + +use crate::compile::ir::summary::{EdgeEntry, JobSummary, PipelineBodySummary, PipelineSummary}; + +/// JSON report emitted by `ado-aw whatif --json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WhatIfReport { + /// Failing step or job supplied by the user. + pub failing_node: FailingNode, + /// Downstream jobs classified by whether their rendered condition bypasses failure. + pub downstream_jobs: Vec, +} + +/// The failing node resolved from `--fail`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FailingNode { + /// Node kind: `step` or `job`. + pub kind: String, + /// User-supplied id that matched the node. + pub id: String, + /// Owning job id. + pub job: String, + /// Containing stage id for staged pipelines. + pub stage: Option, +} + +/// Classification for a downstream job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WhatIfClassification { + /// The job requires success of its dependency chain and would be skipped. + Skipped, + /// The job condition explicitly permits running after failure. + RunsAnyway, +} + +impl WhatIfClassification { + fn label(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::RunsAnyway => "runs_anyway", + } + } +} + +/// A downstream job and the reason-bearing condition string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct DownstreamJob { + /// Job id. + pub job: String, + /// Containing stage id for staged pipelines. + pub stage: Option, + /// Static classification. + pub classification: WhatIfClassification, + /// Lowered ADO condition string, when one was explicitly set. + pub condition: Option, +} + +/// Typed errors for `whatif` queries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WhatIfError { + /// The supplied id was neither a known step id nor a known job id. + UnknownFailId { + /// Missing id. + id: String, + /// Closest known step/job id, if one was available. + suggestion: Option, + }, +} + +impl fmt::Display for WhatIfError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownFailId { id, suggestion } => { + write!(f, "whatif: unknown step or job '{id}'")?; + if let Some(s) = suggestion { + write!(f, " (closest match: '{s}')")?; + } + Ok(()) + } + } + } +} + +impl Error for WhatIfError {} + +/// Analyze which downstream jobs would skip if `fail_id` failed. +pub fn analyze(summary: &PipelineSummary, fail_id: &str) -> Result { + let failing_node = resolve_failing_node(summary, fail_id)?; + let mut downstream = reachable_downstream_jobs(summary, &failing_node); + downstream.sort_by(|a, b| { + (a.stage.as_deref(), a.job.as_str()).cmp(&(b.stage.as_deref(), b.job.as_str())) + }); + + Ok(WhatIfReport { + failing_node, + downstream_jobs: downstream, + }) +} + +/// Render a what-if report as terminal-friendly text. +pub fn render_text(report: &WhatIfReport) -> String { + let mut out = String::new(); + out.push_str(&format!( + "What if {} '{}' fails?\n", + report.failing_node.kind, report.failing_node.id + )); + out.push_str(&format!( + "Failing job: {}\n\n", + qualified_job(&report.failing_node.stage, &report.failing_node.job) + )); + + render_group( + &mut out, + "Skipped", + report + .downstream_jobs + .iter() + .filter(|job| job.classification == WhatIfClassification::Skipped), + ); + out.push('\n'); + render_group( + &mut out, + "Runs anyway", + report + .downstream_jobs + .iter() + .filter(|job| job.classification == WhatIfClassification::RunsAnyway), + ); + out +} + +fn render_group<'a>(out: &mut String, title: &str, jobs: impl Iterator) { + out.push_str(title); + out.push('\n'); + let mut any = false; + for job in jobs { + any = true; + let condition = job + .condition + .as_deref() + .map(|c| format!(" condition: {c}")) + .unwrap_or_else(|| " condition: ".to_string()); + out.push_str(&format!( + " - {} ({}){}\n", + qualified_job(&job.stage, &job.job), + job.classification.label(), + condition + )); + } + if !any { + out.push_str(" (none)\n"); + } +} + +fn resolve_failing_node(summary: &PipelineSummary, fail_id: &str) -> Result { + if let Some(loc) = summary + .graph + .step_locations + .iter() + .find(|loc| loc.step == fail_id) + { + return Ok(FailingNode { + kind: "step".to_string(), + id: fail_id.to_string(), + job: loc.job.clone(), + stage: loc.stage.clone(), + }); + } + + if let Some(job) = find_job(summary, fail_id) { + return Ok(FailingNode { + kind: "job".to_string(), + id: fail_id.to_string(), + job: job.id.clone(), + stage: job.stage.clone(), + }); + } + + Err(anyhow!(WhatIfError::UnknownFailId { + id: fail_id.to_string(), + suggestion: closest(fail_id, known_ids(summary).iter().map(String::as_str)), + })) +} + +fn reachable_downstream_jobs( + summary: &PipelineSummary, + failing_node: &FailingNode, +) -> Vec { + let mut keys: BTreeSet<(Option, String)> = BTreeSet::new(); + + for job in reachable_edges(&summary.graph.job_edges, &failing_node.job) { + keys.insert((stage_for_job(summary, &job), job)); + } + + if let Some(stage) = &failing_node.stage { + for downstream_stage in reachable_edges(&summary.graph.stage_edges, stage) { + for job in jobs_in_stage(summary, &downstream_stage) { + keys.insert((Some(downstream_stage.clone()), job)); + } + } + } + + keys.into_iter() + .filter_map(|(stage, job_id)| { + find_job(summary, &job_id).map(|job| DownstreamJob { + job: job.id.clone(), + stage: stage.or_else(|| job.stage.clone()), + classification: classify_condition(&job.condition), + condition: job.condition.clone(), + }) + }) + .collect() +} + +fn classify_condition(condition: &Option) -> WhatIfClassification { + let Some(condition) = condition else { + return WhatIfClassification::Skipped; + }; + let normalized = condition.to_ascii_lowercase().replace(' ', ""); + if normalized.contains("always()") + || normalized.contains("failed()") + || normalized.contains("succeededorfailed()") + { + WhatIfClassification::RunsAnyway + } else { + WhatIfClassification::Skipped + } +} + +fn reachable_edges(edges: &[EdgeEntry], start: &str) -> BTreeSet { + let mut reverse: BTreeMap> = BTreeMap::new(); + for edge in edges { + reverse + .entry(edge.producer.clone()) + .or_default() + .insert(edge.consumer.clone()); + } + + let mut seen = BTreeSet::new(); + let mut queue: VecDeque = reverse + .get(start) + .into_iter() + .flat_map(|next| next.iter().cloned()) + .collect(); + while let Some(node) = queue.pop_front() { + if !seen.insert(node.clone()) { + continue; + } + if let Some(next) = reverse.get(&node) { + queue.extend(next.iter().cloned()); + } + } + seen +} + +fn known_ids(summary: &PipelineSummary) -> Vec { + let mut ids: Vec = summary + .graph + .step_locations + .iter() + .map(|loc| loc.step.clone()) + .collect(); + ids.extend(all_jobs(summary).into_iter().map(|job| job.id.clone())); + ids +} + +fn all_jobs(summary: &PipelineSummary) -> Vec<&JobSummary> { + match &summary.body { + PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), + PipelineBodySummary::Stages { stages } => { + stages.iter().flat_map(|stage| stage.jobs.iter()).collect() + } + } +} + +fn find_job<'a>(summary: &'a PipelineSummary, job_id: &str) -> Option<&'a JobSummary> { + all_jobs(summary).into_iter().find(|job| job.id == job_id) +} + +fn stage_for_job(summary: &PipelineSummary, job_id: &str) -> Option { + find_job(summary, job_id).and_then(|job| job.stage.clone()) +} + +fn jobs_in_stage(summary: &PipelineSummary, stage_id: &str) -> Vec { + match &summary.body { + PipelineBodySummary::Jobs { .. } => Vec::new(), + PipelineBodySummary::Stages { stages } => stages + .iter() + .find(|stage| stage.id == stage_id) + .map(|stage| stage.jobs.iter().map(|job| job.id.clone()).collect()) + .unwrap_or_default(), + } +} + +fn qualified_job(stage: &Option, job: &str) -> String { + match stage { + Some(stage) => format!("{stage}.{job}"), + None => job.to_string(), + } +} + +fn closest<'a>(needle: &str, candidates: impl Iterator) -> Option { + candidates + .map(|candidate| (levenshtein(needle, candidate), candidate)) + .min_by_key(|(distance, candidate)| (*distance, (*candidate).to_string())) + .map(|(_, candidate)| candidate.to_string()) +} + +fn levenshtein(a: &str, b: &str) -> usize { + let mut prev: Vec = (0..=b.chars().count()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut curr = vec![i + 1]; + for (j, cb) in b.chars().enumerate() { + let cost = usize::from(ca != cb); + curr.push((curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost)); + } + prev = curr; + } + prev[b.chars().count()] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::compile::ir::summary::{ + GraphSummary, PipelineBodySummary, PoolSummary, StepKind, StepLocationEntry, StepSummary, + }; + + fn fixture(always_job: Option<&str>) -> PipelineSummary { + let jobs = ["Setup", "Agent", "Detection", "SafeOutputs"] + .into_iter() + .map(|id| JobSummary { + id: id.to_string(), + stage: None, + display_name: id.to_string(), + depends_on: Vec::new(), + condition: if Some(id) == always_job { + Some("always()".to_string()) + } else { + None + }, + pool: PoolSummary::VmImage { + image: "ubuntu-latest".to_string(), + }, + steps: if id == "Setup" { + vec![StepSummary { + id: Some("SetupStep".to_string()), + kind: StepKind::Bash, + display_name: Some("SetupStep".to_string()), + task: None, + condition: None, + outputs: Vec::new(), + env_refs: Vec::new(), + condition_refs: Vec::new(), + }] + } else { + Vec::new() + }, + }) + .collect::>(); + PipelineSummary { + schema_version: 1, + name: "test".to_string(), + shape: "standalone".to_string(), + body: PipelineBodySummary::Jobs { jobs }, + graph: GraphSummary { + step_locations: vec![StepLocationEntry { + step: "SetupStep".to_string(), + stage: None, + job: "Setup".to_string(), + outputs: Vec::new(), + }], + job_edges: vec![ + EdgeEntry { + consumer: "Agent".to_string(), + producer: "Setup".to_string(), + }, + EdgeEntry { + consumer: "Detection".to_string(), + producer: "Agent".to_string(), + }, + EdgeEntry { + consumer: "SafeOutputs".to_string(), + producer: "Detection".to_string(), + }, + ], + stage_edges: Vec::new(), + outputs_needing_is_output: Vec::new(), + }, + } + } + + #[test] + fn fail_setup_marks_canonical_downstream_jobs_skipped() { + let report = analyze(&fixture(None), "Setup").unwrap(); + + assert_eq!( + report + .downstream_jobs + .iter() + .map(|job| (job.job.as_str(), job.classification)) + .collect::>(), + vec![ + ("Agent", WhatIfClassification::Skipped), + ("Detection", WhatIfClassification::Skipped), + ("SafeOutputs", WhatIfClassification::Skipped), + ] + ); + } + + #[test] + fn always_condition_runs_anyway() { + let report = analyze(&fixture(Some("Detection")), "Setup").unwrap(); + + let detection = report + .downstream_jobs + .iter() + .find(|job| job.job == "Detection") + .unwrap(); + assert_eq!(detection.classification, WhatIfClassification::RunsAnyway); + } + + #[test] + fn unknown_fail_id_returns_typed_error() { + let err = analyze(&fixture(None), "unknown-id").unwrap_err(); + + assert!(err.downcast_ref::().is_some()); + } + + #[test] + fn failing_step_in_setup_matches_failing_setup_job() { + let job_report = analyze(&fixture(None), "Setup").unwrap(); + let step_report = analyze(&fixture(None), "SetupStep").unwrap(); + + assert_eq!(job_report.downstream_jobs, step_report.downstream_jobs); + } +} diff --git a/src/main.rs b/src/main.rs index 184532f4..8d21bd87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ +pub mod ado; mod agent_stats; -mod audit; mod allowed_hosts; -pub mod ado; +mod audit; mod compile; mod configure; mod detect; @@ -13,9 +13,11 @@ mod execute; mod fuzzy_schedule; mod hash; mod init; +mod inspect; mod list; mod logging; mod mcp; +mod mcp_author; mod ndjson; mod remove; mod run; @@ -157,6 +159,45 @@ enum SecretsCmd { }, } +#[derive(Subcommand, Debug)] +enum GraphCmd { + /// Dump the resolved graph (`ado-aw graph dump ` replaces the old bare form). + Dump { + /// Path to the agent markdown source. + source: PathBuf, + /// Output format: `text` (default), `json`, or `dot` (Graphviz). + #[arg(long, default_value = "text")] + format: String, + }, + /// Traverse dependencies for one named step. + Deps { + /// Path to the agent markdown source. + source: PathBuf, + /// Step id to traverse from. + step: String, + /// Traversal direction: `upstream` (default) or `downstream`. + #[arg(long, default_value = "upstream")] + direction: String, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Print declared outputs and their consumers. + Outputs { + /// Path to the agent markdown source. + source: PathBuf, + /// Filter to outputs declared by this producer step id. + #[arg(long)] + producer: Option, + /// Filter to outputs read by this consumer step id. + #[arg(long)] + consumer: Option, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand, Debug)] enum Commands { /// Compile markdown to pipeline definition (or recompile all detected pipelines) @@ -201,6 +242,8 @@ enum Commands { #[arg(long = "enabled-tools")] enabled_tools: Vec, }, + /// Run the author-facing MCP server over stdio (IDE/Copilot Chat integration) + McpAuthor {}, /// Execute safe outputs from Stage 1 (Stage 3 of the pipeline) Execute { /// Path to the source markdown file (used to read tool configs from front matter) @@ -484,6 +527,24 @@ enum Commands { #[arg(long)] no_cache: bool, }, + /// Trace a build's failing-job chain using audit data plus the local IR graph. + Trace { + /// Build ID, or full ADO build URL. + build_id_or_url: String, + /// Optional typed-IR step id to focus on. + #[arg(long)] + step: Option, + /// Emit a structured TraceReport as JSON. + #[arg(long)] + json: bool, + /// ADO context overrides (auto-detected from git remote if omitted). + #[arg(long)] + org: Option, + #[arg(long)] + project: Option, + #[arg(long, env = "AZURE_DEVOPS_EXT_PAT")] + pat: Option, + }, /// Export the gate spec JSON Schema (build-time tool for the /// scripts/ado-script TypeScript workspace). #[command(hide = true)] @@ -492,6 +553,47 @@ enum Commands { #[arg(short, long)] output: Option, }, + /// Inspect an agent source file's typed IR: jobs, stages, steps, outputs, derived `dependsOn`. + Inspect { + /// Path to the agent markdown source. + source: PathBuf, + /// Emit the full [`PipelineSummary`] as JSON instead of a terse human summary. + #[arg(long)] + json: bool, + }, + /// Query the resolved dependency graph for an agent source file. + Graph { + #[command(subcommand)] + subcommand: GraphCmd, + }, + /// Static reachability: classify jobs skipped if a step or job fails. + Whatif { + /// Path to the agent markdown source. + source: PathBuf, + /// Step id or job id to treat as failing. + #[arg(long)] + fail: String, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Run structural lint checks over an agent source file. + Lint { + /// Path to the agent markdown source. + source: PathBuf, + /// Emit lint findings as JSON. + #[arg(long)] + json: bool, + }, + /// List safe-outputs, runtimes, tools, engines, and models. + Catalog { + /// Category to emit: safe-outputs, runtimes, tools, engines, or models. + #[arg(long)] + kind: Option, + /// Emit the catalog as JSON. + #[arg(long)] + json: bool, + }, } #[derive(Parser, Debug)] @@ -713,9 +815,7 @@ async fn build_execution_context( ctx.tool_configs.insert("create-issue".to_string(), v); ctx.debug_enabled_tools.insert("create-issue".to_string()); } - Err(e) => log::warn!( - "Failed to serialize ado-aw-debug.create-issue config: {e}" - ), + Err(e) => log::warn!("Failed to serialize ado-aw-debug.create-issue config: {e}"), } } ctx.allowed_repositories = allowed_repositories; @@ -845,6 +945,7 @@ async fn main() -> Result<()> { Some(Commands::Compile { .. }) => "compile", Some(Commands::Check { .. }) => "check", Some(Commands::Mcp { .. }) => "mcp", + Some(Commands::McpAuthor { .. }) => "mcp-author", Some(Commands::Execute { .. }) => "execute", Some(Commands::McpHttp { .. }) => "mcp-http", Some(Commands::Init { .. }) => "init", @@ -857,7 +958,13 @@ async fn main() -> Result<()> { Some(Commands::Status { .. }) => "status", Some(Commands::Run { .. }) => "run", Some(Commands::Audit { .. }) => "audit", + Some(Commands::Trace { .. }) => "trace", Some(Commands::ExportGateSchema { .. }) => "export-gate-schema", + Some(Commands::Inspect { .. }) => "inspect", + Some(Commands::Graph { .. }) => "graph", + Some(Commands::Whatif { .. }) => "whatif", + Some(Commands::Lint { .. }) => "lint", + Some(Commands::Catalog { .. }) => "catalog", None => "ado-aw", }; @@ -880,7 +987,10 @@ async fn main() -> Result<()> { // Also skipped in CI environments to avoid unnecessary outbound calls. let is_pipeline_internal = matches!( command, - Commands::Execute { .. } | Commands::Mcp { .. } | Commands::McpHttp { .. } + Commands::Execute { .. } + | Commands::Mcp { .. } + | Commands::McpAuthor { .. } + | Commands::McpHttp { .. } ); let update_handle = if !is_pipeline_internal && std::env::var_os("CI").is_none() { Some(tokio::spawn(update_check::check_for_update())) @@ -926,6 +1036,9 @@ async fn main() -> Result<()> { }; mcp::run(&output_directory, &bounding_directory, filter.as_deref()).await?; } + Commands::McpAuthor {} => { + mcp_author::run_stdio().await?; + } Commands::Execute { source, safe_output_dir, @@ -1225,6 +1338,24 @@ async fn main() -> Result<()> { }) .await?; } + Commands::Trace { + build_id_or_url, + step, + json, + org, + project, + pat, + } => { + inspect::dispatch_trace(inspect::TraceOptions { + build_id_or_url: &build_id_or_url, + step: step.as_deref(), + json, + org: org.as_deref(), + project: project.as_deref(), + pat: pat.as_deref(), + }) + .await?; + } Commands::ExportGateSchema { output } => { let schema = compile::filter_ir::generate_gate_spec_schema(); match output { @@ -1240,6 +1371,89 @@ async fn main() -> Result<()> { None => print!("{}", schema), } } + Commands::Inspect { source, json } => { + inspect::dispatch_inspect(inspect::InspectOptions { + source: &source, + json, + }) + .await?; + } + Commands::Graph { subcommand } => match subcommand { + GraphCmd::Dump { source, format } => { + let fmt = match format.as_str() { + "text" => inspect::GraphFormat::Text, + "json" => inspect::GraphFormat::Json, + "dot" => inspect::GraphFormat::Dot, + other => anyhow::bail!( + "unknown --format '{other}' (expected one of: text, json, dot)" + ), + }; + inspect::dispatch_graph(inspect::GraphOptions { + source: &source, + format: fmt, + }) + .await?; + } + GraphCmd::Deps { + source, + step, + direction, + json, + } => { + let direction = match direction.as_str() { + "upstream" => inspect::GraphDepsDirection::Upstream, + "downstream" => inspect::GraphDepsDirection::Downstream, + other => anyhow::bail!( + "unknown --direction '{other}' (expected one of: upstream, downstream)" + ), + }; + inspect::dispatch_graph_deps(inspect::GraphDepsOptions { + source: &source, + step: &step, + direction, + json, + }) + .await?; + } + GraphCmd::Outputs { + source, + producer, + consumer, + json, + } => { + inspect::dispatch_graph_outputs(inspect::GraphOutputsOptions { + source: &source, + producer: producer.as_deref(), + consumer: consumer.as_deref(), + json, + }) + .await?; + } + }, + Commands::Whatif { source, fail, json } => { + inspect::dispatch_whatif(inspect::WhatIfOptions { + source: &source, + fail: &fail, + json, + }) + .await?; + } + Commands::Lint { source, json } => { + let had_errors = inspect::dispatch_lint(inspect::LintOptions { + source: &source, + json, + }) + .await?; + if had_errors { + std::process::exit(1); + } + } + Commands::Catalog { kind, json } => { + inspect::dispatch_catalog(inspect::CatalogOptions { + kind: kind.as_deref(), + json, + })?; + } } // Wait for the background update check to finish so the advisory (if any) diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs new file mode 100644 index 00000000..4d64c2ce --- /dev/null +++ b/src/mcp_author/mod.rs @@ -0,0 +1,344 @@ +//! Author-facing MCP server for local IDE integrations. +//! +//! This server exposes read-only compiler inspection, graph, lint, what-if, +//! trace, and audit queries over stdio. It intentionally has no workspace +//! bounding directory: callers run it locally as the invoking user. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use log::{error, info}; +use rmcp::{ + ErrorData as McpError, ServerHandler, ServiceExt, handler::server::tool::ToolRouter, + handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, + transport::stdio, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::inspect::{self, GraphDepsDirection, GraphFormat}; + +#[cfg(test)] +mod tests; + +/// AuthorMcp is safe to clone for concurrent use: it only contains the +/// immutable rmcp tool router. +#[derive(Clone, Debug)] +pub struct AuthorMcp { + #[allow(dead_code)] + tool_router: ToolRouter, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SourcePathParams { + /// Path to the source markdown workflow file to inspect. + source_path: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct GraphDumpParams { + /// Path to the source markdown workflow file. + source_path: String, + /// Render format: "text" (default) or "dot". + format: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct StepDependenciesParams { + /// Path to the source markdown workflow file. + source_path: String, + /// Step id, or job id fallback, to traverse from. + step_id: String, + /// Traversal direction: "upstream" or "downstream". + direction: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct StepOutputsParams { + /// Path to the source markdown workflow file. + source_path: String, + /// Optional producer step id filter. + producer: Option, + /// Optional consumer step id filter. + consumer: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct TraceFailureParams { + /// Build ID, or full Azure DevOps build URL. + build_id_or_url: String, + /// Optional typed-IR step id to focus on. + step: Option, + /// Azure DevOps organization URL or name override. + org: Option, + /// Azure DevOps project name override. + project: Option, + /// Azure DevOps PAT override. If omitted, normal ado-aw auth resolution is used. + pat: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct WhatIfParams { + /// Path to the source markdown workflow file. + source_path: String, + /// Step id or job id to treat as failing. + failing_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CatalogParams { + /// Optional category: safe-outputs, runtimes, tools, engines, or models. + kind: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct AuditBuildParams { + /// Build ID, or full Azure DevOps build URL. + build_id_or_url: String, + /// Azure DevOps organization URL or name override. + org: Option, + /// Azure DevOps project name override. + project: Option, + /// Azure DevOps PAT override. If omitted, normal ado-aw auth resolution is used. + pat: Option, + /// Artifact sets to download. Valid values: agent, detection, safe-outputs. + artifacts: Option>, + /// Force re-processing even if a cached run-summary.json exists. + no_cache: Option, +} + +#[derive(Debug, Serialize)] +struct GraphDumpResult { + text_or_dot: String, +} + +#[tool_router] +impl AuthorMcp { + pub fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + #[tool( + name = "inspect_workflow", + description = "Build and return the public PipelineSummary for a markdown workflow." + )] + async fn inspect_workflow( + &self, + params: Parameters, + ) -> Result { + let source = source_path(¶ms.0.source_path); + let summary = inspect::build_inspect(&source) + .await + .map_err(to_mcp_error)?; + structured_result(summary) + } + + #[tool( + name = "graph_summary", + description = "Return the resolved GraphSummary for a markdown workflow." + )] + async fn graph_summary( + &self, + params: Parameters, + ) -> Result { + let source = source_path(¶ms.0.source_path); + let graph = inspect::build_graph_summary(&source) + .await + .map_err(to_mcp_error)?; + structured_result(graph) + } + + #[tool( + name = "graph_dump", + description = "Render the resolved workflow graph as text or Graphviz DOT." + )] + async fn graph_dump( + &self, + params: Parameters, + ) -> Result { + let format = parse_graph_dump_format(params.0.format.as_deref())?; + let source = source_path(¶ms.0.source_path); + let text_or_dot = inspect::build_graph_dump(&source, format) + .await + .map_err(to_mcp_error)?; + structured_result(GraphDumpResult { text_or_dot }) + } + + #[tool( + name = "step_dependencies", + description = "Traverse upstream or downstream dependencies for a step id." + )] + async fn step_dependencies( + &self, + params: Parameters, + ) -> Result { + let direction = parse_graph_deps_direction(¶ms.0.direction)?; + let source = source_path(¶ms.0.source_path); + let report = inspect::build_graph_deps(&source, ¶ms.0.step_id, direction) + .await + .map_err(to_mcp_error)?; + structured_result(report) + } + + #[tool( + name = "step_outputs", + description = "Return declared step outputs and their consumers, with optional filters." + )] + async fn step_outputs( + &self, + params: Parameters, + ) -> Result { + let source = source_path(¶ms.0.source_path); + let edges = inspect::build_graph_outputs( + &source, + params.0.producer.as_deref(), + params.0.consumer.as_deref(), + ) + .await + .map_err(to_mcp_error)?; + structured_result(edges) + } + + #[tool( + name = "trace_failure", + description = "Trace a build's failing-job chain using audit data plus the local IR graph." + )] + async fn trace_failure( + &self, + params: Parameters, + ) -> Result { + let opts = inspect::TraceOptions { + build_id_or_url: ¶ms.0.build_id_or_url, + step: params.0.step.as_deref(), + json: true, + org: params.0.org.as_deref(), + project: params.0.project.as_deref(), + pat: params.0.pat.as_deref(), + }; + let (_audit, report) = inspect::build_trace(&opts).await.map_err(to_mcp_error)?; + structured_result(report) + } + + #[tool( + name = "whatif", + description = "Classify downstream jobs that would skip if a step or job failed." + )] + async fn whatif(&self, params: Parameters) -> Result { + let source = source_path(¶ms.0.source_path); + let report = inspect::build_whatif(&source, ¶ms.0.failing_id) + .await + .map_err(to_mcp_error)?; + structured_result(report) + } + + #[tool( + name = "lint_workflow", + description = "Run structural lint checks over a markdown workflow." + )] + async fn lint_workflow( + &self, + params: Parameters, + ) -> Result { + let source = source_path(¶ms.0.source_path); + let report = inspect::build_lint(&source).await.map_err(to_mcp_error)?; + structured_result(report) + } + + #[tool( + name = "catalog", + description = "List supported safe-outputs, runtimes, tools, engines, and models." + )] + async fn catalog(&self, params: Parameters) -> Result { + let catalog = inspect::build_catalog(params.0.kind.as_deref()).map_err(to_mcp_error)?; + structured_result(catalog) + } + + #[tool( + name = "audit_build", + description = "Download and analyze a single Azure DevOps build; same JSON shape as `ado-aw audit --json`." + )] + async fn audit_build( + &self, + params: Parameters, + ) -> Result { + let artifacts = params.0.artifacts.as_deref(); + let audit = crate::audit::fetch_audit_data(crate::audit::AuditOptions { + build_id_or_url: ¶ms.0.build_id_or_url, + output: Path::new("./logs"), + json: true, + org: params.0.org.as_deref(), + project: params.0.project.as_deref(), + pat: params.0.pat.as_deref(), + artifacts, + no_cache: params.0.no_cache.unwrap_or(false), + }) + .await + .map_err(to_mcp_error)?; + structured_result(audit) + } +} + +impl Default for AuthorMcp { + fn default() -> Self { + Self::new() + } +} + +#[tool_handler] +impl ServerHandler for AuthorMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("Read-only ado-aw authoring and debugging tools.") + } +} + +pub async fn run_stdio() -> Result<()> { + info!("Starting author-facing MCP server over stdio"); + let service = AuthorMcp::new().serve(stdio()).await.inspect_err(|e| { + error!("Error starting author MCP server: {}", e); + })?; + service + .waiting() + .await + .map_err(|e| anyhow::anyhow!("Author MCP exited with error: {:?}", e))?; + Ok(()) +} + +fn source_path(path: &str) -> PathBuf { + PathBuf::from(path) +} + +fn parse_graph_dump_format(format: Option<&str>) -> Result { + match format.unwrap_or("text") { + "text" => Ok(GraphFormat::Text), + "dot" => Ok(GraphFormat::Dot), + other => Err(McpError::invalid_params( + format!("unknown format '{other}' (expected 'text' or 'dot')"), + None, + )), + } +} + +fn parse_graph_deps_direction(direction: &str) -> Result { + match direction { + "upstream" => Ok(GraphDepsDirection::Upstream), + "downstream" => Ok(GraphDepsDirection::Downstream), + other => Err(McpError::invalid_params( + format!("unknown direction '{other}' (expected 'upstream' or 'downstream')"), + None, + )), + } +} + +fn structured_result(value: T) -> Result { + let value = serde_json::to_value(value).map_err(|e| { + McpError::internal_error(format!("failed to serialize tool result: {e}"), None) + })?; + Ok(CallToolResult::structured(value)) +} + +fn to_mcp_error(error: anyhow::Error) -> McpError { + McpError::internal_error(format!("{error:#}"), None) +} diff --git a/src/mcp_author/tests.rs b/src/mcp_author/tests.rs new file mode 100644 index 00000000..359b14a7 --- /dev/null +++ b/src/mcp_author/tests.rs @@ -0,0 +1,83 @@ +use std::collections::BTreeSet; +use std::path::PathBuf; + +use rmcp::handler::server::wrapper::Parameters; + +use super::*; +use crate::compile::ir::summary::{GraphSummary, PipelineSummary}; +use crate::inspect::lint::LintReport; + +fn fixture_path() -> String { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("safe-outputs") + .join("create-pull-request.md") + .display() + .to_string() +} + +#[test] +fn list_tools_contains_expected_author_surface() { + let server = AuthorMcp::new(); + let names: BTreeSet = server + .tool_router + .list_all() + .iter() + .map(|tool| tool.name.to_string()) + .collect(); + + for expected in [ + "inspect_workflow", + "graph_summary", + "graph_dump", + "step_dependencies", + "step_outputs", + "trace_failure", + "whatif", + "lint_workflow", + "catalog", + "audit_build", + ] { + assert!(names.contains(expected), "missing MCP tool {expected}"); + } +} + +#[tokio::test] +async fn inspect_workflow_returns_pipeline_summary_schema_version_one() { + let server = AuthorMcp::new(); + let result = server + .inspect_workflow(Parameters(SourcePathParams { + source_path: fixture_path(), + })) + .await + .expect("inspect_workflow succeeds"); + + let summary = result + .into_typed::() + .expect("inspect_workflow returns PipelineSummary"); + assert_eq!(summary.schema_version, 1); +} + +#[tokio::test] +async fn graph_summary_and_lint_workflow_smoke_fixture() { + let server = AuthorMcp::new(); + let source_path = fixture_path(); + + let graph = server + .graph_summary(Parameters(SourcePathParams { + source_path: source_path.clone(), + })) + .await + .expect("graph_summary succeeds") + .into_typed::() + .expect("graph_summary returns GraphSummary"); + assert!(!graph.step_locations.is_empty()); + + let lint = server + .lint_workflow(Parameters(SourcePathParams { source_path })) + .await + .expect("lint_workflow succeeds") + .into_typed::() + .expect("lint_workflow returns LintReport"); + assert_eq!(lint.summary.errors, 0); +} diff --git a/tests/inspect_integration.rs b/tests/inspect_integration.rs new file mode 100644 index 00000000..1d4e7d68 --- /dev/null +++ b/tests/inspect_integration.rs @@ -0,0 +1,116 @@ +//! End-to-end tests for the `inspect` and `graph` subcommands. +//! +//! These verify the full path: agent `.md` → `compile::build_pipeline_ir` +//! → `PipelineSummary::from_pipeline` → CLI rendering. The fixtures +//! are copied into a temp dir to avoid the lost-update guard racing +//! parallel tests, matching the convention used in +//! `tests/bash_lint_tests.rs`. + +use std::path::PathBuf; +use std::process::Command; + +fn binary_path() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")) +} + +fn fixture_copy(fixture_name: &str) -> (tempfile::TempDir, PathBuf) { + let workspace = tempfile::tempdir().expect("create temp dir"); + let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("safe-outputs") + .join(fixture_name); + let dst = workspace.path().join(fixture_name); + std::fs::copy(&src, &dst) + .unwrap_or_else(|e| panic!("copy {} into temp dir: {e}", src.display())); + (workspace, dst) +} + +#[test] +fn inspect_emits_pipeline_summary_text() { + let (_workspace, src) = fixture_copy("create-pull-request.md"); + let out = Command::new(binary_path()) + .arg("inspect") + .arg(&src) + .output() + .expect("run ado-aw inspect"); + assert!( + out.status.success(), + "inspect exited non-zero. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("Target shape:")); + assert!( + stdout.contains("Jobs ("), + "expected jobs section, got:\n{stdout}" + ); + assert!(stdout.contains("Graph:")); +} + +#[test] +fn inspect_json_emits_schema_version_one() { + let (_workspace, src) = fixture_copy("create-pull-request.md"); + let out = Command::new(binary_path()) + .arg("inspect") + .arg(&src) + .arg("--json") + .output() + .expect("run ado-aw inspect --json"); + assert!( + out.status.success(), + "inspect --json exited non-zero. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + // schema_version is the public stability contract. + assert!( + stdout.contains("\"schema_version\": 1"), + "expected schema_version: 1 in JSON output, got:\n{stdout}" + ); + assert!(stdout.contains("\"shape\":")); + assert!(stdout.contains("\"graph\":")); +} + +#[test] +fn graph_dot_emits_digraph_with_known_edges() { + let (_workspace, src) = fixture_copy("create-pull-request.md"); + let out = Command::new(binary_path()) + .arg("graph") + .arg("dump") + .arg(&src) + .arg("--format") + .arg("dot") + .output() + .expect("run ado-aw graph dump --format dot"); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.starts_with("digraph ado_aw_pipeline {")); + // Canonical 3-job graph has Detection→Agent and SafeOutputs→Detection. + assert!( + stdout.contains("\"Detection\" -> \"Agent\""), + "expected Detection→Agent edge, got:\n{stdout}" + ); + assert!( + stdout.contains("\"SafeOutputs\" -> \"Detection\""), + "expected SafeOutputs→Detection edge, got:\n{stdout}" + ); +} + +#[test] +fn graph_rejects_unknown_format() { + let (_workspace, src) = fixture_copy("create-pull-request.md"); + let out = Command::new(binary_path()) + .arg("graph") + .arg("dump") + .arg(&src) + .arg("--format") + .arg("yaml") + .output() + .expect("run ado-aw graph dump --format yaml"); + assert!(!out.status.success(), "unknown format should fail"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("unknown --format"), + "expected unknown-format error, got:\n{stderr}" + ); +} From 979dcbb3a385d3a15161e35219e43c071f125395 Mon Sep 17 00:00:00 2001 From: jamesadevine Date: Sat, 13 Jun 2026 22:29:23 +0100 Subject: [PATCH 02/17] fix(inspect): use CARGO_MANIFEST_DIR for lint test fixture path The Windows-style hardcoded path failed on Linux CI. Resolve from CARGO_MANIFEST_DIR so the test works on both platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/inspect/lint.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs index 5c5a8d28..9b1bb31b 100644 --- a/src/inspect/lint.rs +++ b/src/inspect/lint.rs @@ -347,11 +347,13 @@ mod tests { #[tokio::test] async fn create_pull_request_fixture_has_no_unused_output_inspect_lint() { - let (_fm, pipeline) = crate::compile::build_pipeline_ir(std::path::Path::new( - "tests\\safe-outputs\\create-pull-request.md", - )) - .await - .unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("safe-outputs") + .join("create-pull-request.md"); + let (_fm, pipeline) = crate::compile::build_pipeline_ir(&fixture) + .await + .unwrap(); let summary = PipelineSummary::from_pipeline(&pipeline).unwrap(); let findings = lint(&summary); assert!(!findings.iter().any(|f| f.code == "unused-output")); From 1da03b5c748ea173c5b3e84bac116d7167e12563 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 06:50:36 +0000 Subject: [PATCH 03/17] fix(inspect): address PR feedback and suggestions Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/ir.md | 5 ++- src/audit/findings.rs | 19 +-------- src/audit/model.rs | 22 ++++++++++ src/compile/ir/summary.rs | 5 ++- src/inspect/trace.rs | 21 +++------ src/inspect/whatif.rs | 90 ++++++++++++++++++++++++++++++++++++--- src/mcp_author/mod.rs | 5 ++- 7 files changed, 121 insertions(+), 46 deletions(-) diff --git a/docs/ir.md b/docs/ir.md index e6f3001a..40c4c67f 100644 --- a/docs/ir.md +++ b/docs/ir.md @@ -289,8 +289,9 @@ pipeline, `src/compile/ir/summary.rs` defines a parallel `PipelineSummary::schema_version` (currently `1`) is the public schema version. **Bump** it when the JSON shape changes in a way a downstream consumer would notice (renamed field, removed variant, changed -semantics). Additive changes (new optional fields, new enum variants -in `unknown`-tolerant contexts) do not require a bump. +semantics). Additive changes like new optional fields do not require a +bump. New enum variants currently do require a schema-version bump +because the serialized enums do not have catch-all `Unknown` variants. The summary is the public schema. Internal IR types may change freely without bumping the summary version, as long as the summary lowering diff --git a/src/audit/findings.rs b/src/audit/findings.rs index 4828e931..07f42810 100644 --- a/src/audit/findings.rs +++ b/src/audit/findings.rs @@ -370,7 +370,7 @@ fn add_downstream_impact_findings( _recommendations: &mut Vec, ) { for job in &audit.jobs { - if !job_failed(job) || job.downstream_jobs.is_empty() { + if !job.failed() || job.downstream_jobs.is_empty() { continue; } @@ -382,7 +382,7 @@ fn add_downstream_impact_findings( .jobs .iter() .find(|candidate| job_name_matches(candidate, downstream_job)) - .map(job_classification) + .map(JobData::classification) .unwrap_or_else(|| String::from("expected to skip")); format!("{downstream_job}: {classification}") }) @@ -405,13 +405,6 @@ fn add_downstream_impact_findings( } } -fn job_failed(job: &JobData) -> bool { - let result = job.result.as_deref().unwrap_or_default(); - result.eq_ignore_ascii_case("failed") - || result.eq_ignore_ascii_case("canceled") - || job.status.eq_ignore_ascii_case("failed") -} - fn job_name_matches(job: &JobData, ir_job: &str) -> bool { job.name == ir_job || job @@ -421,14 +414,6 @@ fn job_name_matches(job: &JobData, ir_job: &str) -> bool { .is_some_and(|suffix| suffix == ir_job) } -fn job_classification(job: &JobData) -> String { - job.result - .as_deref() - .filter(|result| !result.trim().is_empty()) - .unwrap_or(&job.status) - .to_string() -} - fn push_finding(findings: &mut Vec, finding: Finding) { if !findings.contains(&finding) { findings.push(finding); diff --git a/src/audit/model.rs b/src/audit/model.rs index 5bb26c2a..489ace7d 100644 --- a/src/audit/model.rs +++ b/src/audit/model.rs @@ -347,6 +347,28 @@ pub struct JobData { pub downstream_jobs: Vec, } +impl JobData { + /// Returns true when this job ended in a failure-like state. + pub fn failed(&self) -> bool { + let result = self.result.as_deref().unwrap_or_default(); + result.eq_ignore_ascii_case("failed") + || result.eq_ignore_ascii_case("canceled") + || result.eq_ignore_ascii_case("cancelled") + || self.status.eq_ignore_ascii_case("failed") + || self.status.eq_ignore_ascii_case("canceled") + || self.status.eq_ignore_ascii_case("cancelled") + } + + /// Returns the best available status/result label for reporting. + pub fn classification(&self) -> String { + self.result + .as_deref() + .filter(|result| !result.trim().is_empty()) + .unwrap_or(&self.status) + .to_string() + } +} + /// Metadata about a file downloaded while assembling the audit. /// /// These rows are produced by the artifact download phase for traceability and caching. diff --git a/src/compile/ir/summary.rs b/src/compile/ir/summary.rs index 014261dd..b308dd75 100644 --- a/src/compile/ir/summary.rs +++ b/src/compile/ir/summary.rs @@ -15,8 +15,9 @@ //! [`PipelineSummary::schema_version`] is pinned. Bump it whenever //! the JSON shape changes in a way a downstream consumer would //! notice (renamed field, removed variant, changed semantics). -//! Additive changes — new optional fields, new enum variants in -//! `unknown`-tolerant contexts — do not require a bump. +//! Additive changes such as new optional fields do not require a bump. +//! New enum variants currently require a schema-version bump so older +//! consumers fail loudly instead of misinterpreting data. //! //! The summary is the **public** schema. The internal IR types //! (`super::Pipeline` and friends) are NOT public API and may change diff --git a/src/inspect/trace.rs b/src/inspect/trace.rs index 60555668..bb214051 100644 --- a/src/inspect/trace.rs +++ b/src/inspect/trace.rs @@ -67,7 +67,7 @@ pub fn build_trace_report(audit: &AuditData, step: Option<&str>) -> TraceReport let failing_jobs = audit .jobs .iter() - .filter(|job| job_failed(job)) + .filter(|job| job.failed()) .map(|job| job_report(audit, job)) .collect(); @@ -210,7 +210,7 @@ fn build_step_report(audit: &AuditData, step_id: &str) -> Option Vec { .into_iter() .map(|job_id| TraceUpstreamJob { status: find_runtime_job(audit, &job_id) - .map(job_status) + .map(JobData::classification) .unwrap_or_else(|| String::from("unknown")), job: job_id, }) @@ -258,7 +258,7 @@ fn downstream_reports(audit: &AuditData, job: &JobData) -> Vec Option { .and_then(|job| job.stage.clone()) } -fn job_failed(job: &JobData) -> bool { - let result = job.result.as_deref().unwrap_or_default(); - result.eq_ignore_ascii_case("failed") - || result.eq_ignore_ascii_case("canceled") - || job.status.eq_ignore_ascii_case("failed") -} - fn job_status(job: &JobData) -> String { - job.result - .as_deref() - .filter(|result| !result.trim().is_empty()) - .unwrap_or(&job.status) - .to_string() + job.classification() } #[cfg(test)] diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 9ec2a388..99b23772 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -230,9 +230,9 @@ fn classify_condition(condition: &Option) -> WhatIfClassification { return WhatIfClassification::Skipped; }; let normalized = condition.to_ascii_lowercase().replace(' ', ""); - if normalized.contains("always()") - || normalized.contains("failed()") - || normalized.contains("succeededorfailed()") + if contains_unnegated_call(&normalized, "always()") + || contains_unnegated_call(&normalized, "failed()") + || contains_unnegated_call(&normalized, "succeededorfailed()") { WhatIfClassification::RunsAnyway } else { @@ -240,17 +240,39 @@ fn classify_condition(condition: &Option) -> WhatIfClassification { } } +fn contains_unnegated_call(normalized_condition: &str, call: &str) -> bool { + let mut from = 0; + while let Some(offset) = normalized_condition[from..].find(call) { + let idx = from + offset; + if !is_negated_call(normalized_condition, idx) { + return true; + } + from = idx + call.len(); + } + false +} + +fn is_negated_call(normalized_condition: &str, call_idx: usize) -> bool { + let mut idx = call_idx; + let mut negated = false; + while idx >= 4 && normalized_condition[idx - 4..idx] == *"not(" { + negated = !negated; + idx -= 4; + } + negated +} + fn reachable_edges(edges: &[EdgeEntry], start: &str) -> BTreeSet { - let mut reverse: BTreeMap> = BTreeMap::new(); + let mut downstream: BTreeMap> = BTreeMap::new(); for edge in edges { - reverse + downstream .entry(edge.producer.clone()) .or_default() .insert(edge.consumer.clone()); } let mut seen = BTreeSet::new(); - let mut queue: VecDeque = reverse + let mut queue: VecDeque = downstream .get(start) .into_iter() .flat_map(|next| next.iter().cloned()) @@ -259,7 +281,7 @@ fn reachable_edges(edges: &[EdgeEntry], start: &str) -> BTreeSet { if !seen.insert(node.clone()) { continue; } - if let Some(next) = reverse.get(&node) { + if let Some(next) = downstream.get(&node) { queue.extend(next.iter().cloned()); } } @@ -433,6 +455,60 @@ mod tests { assert_eq!(detection.classification, WhatIfClassification::RunsAnyway); } + #[test] + fn negated_failed_condition_is_skipped() { + let mut summary = fixture(None); + let PipelineBodySummary::Jobs { jobs } = &mut summary.body else { + unreachable!("fixture uses jobs body"); + }; + let detection = jobs.iter_mut().find(|job| job.id == "Detection").unwrap(); + detection.condition = Some("not(failed())".to_string()); + + let report = analyze(&summary, "Setup").unwrap(); + let detection = report + .downstream_jobs + .iter() + .find(|job| job.job == "Detection") + .unwrap(); + assert_eq!(detection.classification, WhatIfClassification::Skipped); + } + + #[test] + fn double_negated_failed_condition_runs_anyway() { + let mut summary = fixture(None); + let PipelineBodySummary::Jobs { jobs } = &mut summary.body else { + unreachable!("fixture uses jobs body"); + }; + let detection = jobs.iter_mut().find(|job| job.id == "Detection").unwrap(); + detection.condition = Some("not(not(failed()))".to_string()); + + let report = analyze(&summary, "Setup").unwrap(); + let detection = report + .downstream_jobs + .iter() + .find(|job| job.job == "Detection") + .unwrap(); + assert_eq!(detection.classification, WhatIfClassification::RunsAnyway); + } + + #[test] + fn negated_always_condition_is_skipped() { + let mut summary = fixture(None); + let PipelineBodySummary::Jobs { jobs } = &mut summary.body else { + unreachable!("fixture uses jobs body"); + }; + let detection = jobs.iter_mut().find(|job| job.id == "Detection").unwrap(); + detection.condition = Some("not(always())".to_string()); + + let report = analyze(&summary, "Setup").unwrap(); + let detection = report + .downstream_jobs + .iter() + .find(|job| job.job == "Detection") + .unwrap(); + assert_eq!(detection.classification, WhatIfClassification::Skipped); + } + #[test] fn unknown_fail_id_returns_typed_error() { let err = analyze(&fixture(None), "unknown-id").unwrap_err(); diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index 4d64c2ce..75f80ab4 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -4,7 +4,7 @@ //! trace, and audit queries over stdio. It intentionally has no workspace //! bounding directory: callers run it locally as the invoking user. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::Result; use log::{error, info}; @@ -264,9 +264,10 @@ impl AuthorMcp { params: Parameters, ) -> Result { let artifacts = params.0.artifacts.as_deref(); + let output = std::env::temp_dir().join("ado-aw").join("audit"); let audit = crate::audit::fetch_audit_data(crate::audit::AuditOptions { build_id_or_url: ¶ms.0.build_id_or_url, - output: Path::new("./logs"), + output: &output, json: true, org: params.0.org.as_deref(), project: params.0.project.as_deref(), From 5f25901368606f01cfea086fdd445070629dc626 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Jun 2026 06:53:28 +0000 Subject: [PATCH 04/17] fix(inspect): follow up review nits Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/audit/model.rs | 1 + src/inspect/whatif.rs | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/audit/model.rs b/src/audit/model.rs index 489ace7d..21dc6c55 100644 --- a/src/audit/model.rs +++ b/src/audit/model.rs @@ -351,6 +351,7 @@ impl JobData { /// Returns true when this job ended in a failure-like state. pub fn failed(&self) -> bool { let result = self.result.as_deref().unwrap_or_default(); + // Be defensive on US/UK spelling variants from upstream sources. result.eq_ignore_ascii_case("failed") || result.eq_ignore_ascii_case("canceled") || result.eq_ignore_ascii_case("cancelled") diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 99b23772..81823273 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -253,11 +253,15 @@ fn contains_unnegated_call(normalized_condition: &str, call: &str) -> bool { } fn is_negated_call(normalized_condition: &str, call_idx: usize) -> bool { + const NOT_PREFIX: &str = "not("; + const NOT_PREFIX_LEN: usize = NOT_PREFIX.len(); let mut idx = call_idx; let mut negated = false; - while idx >= 4 && normalized_condition[idx - 4..idx] == *"not(" { + while idx >= NOT_PREFIX_LEN + && normalized_condition[idx - NOT_PREFIX_LEN..idx] == *NOT_PREFIX + { negated = !negated; - idx -= 4; + idx -= NOT_PREFIX_LEN; } negated } From eda822b29bc5b2d280da4ddba6e5d0322525a5b8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 20:23:32 +0100 Subject: [PATCH 05/17] fix(inspect,audit,mcp-author): address second-pass PR #998 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - mcp_author: manual Debug impls for TraceFailureParams and AuditBuildParams so the optional pat field is no longer leaked via {:?} / dbg!() / rmcp error traces. - audit::pipeline_graph::resolve_source_path: require .md extension and reject parent-dir / tilde components to close the /home/user/.ssh/id_rsa exfiltration vector flagged by the reviewer. populate_pipeline_graph downgrades a rejection to a warning so legitimate audits still complete. Maintainability: - audit::model::JobData: add matches_ir_id helper. findings::add_downstream_impact_findings and inspect::trace::find_runtime_job now both delegate to it instead of carrying duplicate Stage.Job suffix-match heuristics. - audit::findings::add_downstream_impact_findings: emit a Recommendation pointing operators at the failed-job logs (the _recommendations parameter is no longer unused). - inspect::lint::rule_step_id_collisions: collision message now names BOTH the first-seen producer location and the colliding consumer (was only pointing at the second occurrence). - inspect::whatif::classify_condition: doc-comment now records the coverage gap for variable-based conditions (e.g. Agent.JobStatus comparisons) which we deliberately classify as Skipped; new test pins that behaviour. New tests: - audit::pipeline_graph: 4 resolver tests covering the rejected attack patterns and the accepted legitimate absolute-path case. - audit::pipeline_graph::populate_pipeline_graph_records_warning_on_malicious_source — end-to-end test that a malicious source string is recorded as a warning rather than read from disk. - inspect::whatif::variable_based_condition_is_conservatively_skipped — pins the documented limitation. Validated locally with cargo build, cargo test (1880 unit tests + integration suites pass), and cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/findings.rs | 30 +++++--- src/audit/model.rs | 16 +++++ src/audit/pipeline_graph.rs | 134 +++++++++++++++++++++++++++++++++++- src/inspect/lint.rs | 24 +++++-- src/inspect/trace.rs | 9 +-- src/inspect/whatif.rs | 39 +++++++++++ src/mcp_author/mod.rs | 37 +++++++++- 7 files changed, 261 insertions(+), 28 deletions(-) diff --git a/src/audit/findings.rs b/src/audit/findings.rs index 07f42810..4fab2eff 100644 --- a/src/audit/findings.rs +++ b/src/audit/findings.rs @@ -367,7 +367,7 @@ fn add_error_count_findings( fn add_downstream_impact_findings( audit: &AuditData, findings: &mut Vec, - _recommendations: &mut Vec, + recommendations: &mut Vec, ) { for job in &audit.jobs { if !job.failed() || job.downstream_jobs.is_empty() { @@ -381,7 +381,7 @@ fn add_downstream_impact_findings( let classification = audit .jobs .iter() - .find(|candidate| job_name_matches(candidate, downstream_job)) + .find(|candidate| candidate.matches_ir_id(downstream_job)) .map(JobData::classification) .unwrap_or_else(|| String::from("expected to skip")); format!("{downstream_job}: {classification}") @@ -402,16 +402,24 @@ fn add_downstream_impact_findings( impact: None, }, ); - } -} -fn job_name_matches(job: &JobData, ir_job: &str) -> bool { - job.name == ir_job - || job - .name - .rsplit('.') - .next() - .is_some_and(|suffix| suffix == ir_job) + push_recommendation( + recommendations, + Recommendation { + priority: String::from("high"), + action: format!( + "Inspect the {} job logs to identify the root cause; downstream jobs cannot succeed until this is resolved.", + job.name + ), + reason: format!( + "{} failed, which skipped {} downstream job(s).", + job.name, + job.downstream_jobs.len() + ), + example: None, + }, + ); + } } fn push_finding(findings: &mut Vec, finding: Finding) { diff --git a/src/audit/model.rs b/src/audit/model.rs index 21dc6c55..906586f1 100644 --- a/src/audit/model.rs +++ b/src/audit/model.rs @@ -368,6 +368,22 @@ impl JobData { .unwrap_or(&self.status) .to_string() } + + /// Returns `true` when this runtime job corresponds to the typed-IR + /// job id `ir_job_id`. Accepts either the bare id or a + /// `Stage.Job`-style qualified timeline name. + /// + /// Centralised so that `audit::findings` and `inspect::trace` + /// share one definition — a future typo or extension (e.g. handling + /// stage prefixes differently) only needs to change in one place. + pub fn matches_ir_id(&self, ir_job_id: &str) -> bool { + self.name == ir_job_id + || self + .name + .rsplit('.') + .next() + .is_some_and(|suffix| suffix == ir_job_id) + } } /// Metadata about a file downloaded while assembling the audit. diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index c72f2f42..df2a6b00 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -30,7 +30,17 @@ pub async fn populate_pipeline_graph(audit: &mut AuditData, run_dir: &Path) -> R return Ok(()); }; - let source_path = resolve_source_path(&source).await?; + let source_path = match resolve_source_path(&source).await { + Ok(path) => path, + Err(err) => { + record_warning( + audit, + "audit::pipeline_graph", + format!("could not resolve source path: {err:#}; skipping IR graph correlation"), + ); + return Ok(()); + } + }; if tokio::fs::metadata(&source_path).await.is_err() { record_warning( audit, @@ -143,12 +153,52 @@ async fn read_source_from_aw_info(run_dir: &Path) -> Option> { None } +/// Resolve the `source` string taken from a downloaded `aw_info.json` +/// into an on-disk path. +/// +/// **Security**: this value is part of the audited build's artifact +/// payload and must be treated as untrusted. A malicious or prompt- +/// injected build could carry `"source": "/home/user/.ssh/id_rsa"`, +/// and although [`crate::compile::build_pipeline_ir`] would fail to +/// parse it the file would still be read. We mitigate by: +/// +/// - Requiring the path to end with `.md` (the only valid agentic +/// workflow source extension), which closes the +/// arbitrary-file-read vector against keys, `/etc/passwd`, etc. +/// - Rejecting relative paths that contain `..` components or a +/// leading `~` (no directory traversal, no shell-style expansion). +/// - Allowing absolute `.md` paths because legitimate compiled- +/// elsewhere workflows commonly carry an absolute `source`. async fn resolve_source_path(source: &str) -> Result { let normalized = normalize_source_path(source); - let path = PathBuf::from(normalized); + let path = PathBuf::from(&normalized); + + let has_md_extension = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); + if !has_md_extension { + anyhow::bail!( + "refusing source path '{}' from audited build artifact: only `.md` files are valid agentic workflow sources", + normalized + ); + } + if path.is_absolute() { return Ok(path); } + + if path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + || normalized.starts_with('~') + { + anyhow::bail!( + "refusing suspicious relative source path '{}' from audited build artifact", + normalized + ); + } + let cwd = tokio::fs::canonicalize(".") .await .context("Could not resolve current directory")?; @@ -249,4 +299,84 @@ mod tests { .expect("detection job"); assert!(detection.upstream_jobs.iter().any(|job| job == "Agent")); } + + #[tokio::test] + async fn resolve_source_path_rejects_non_markdown_absolute_paths() { + // The exfiltration vector flagged by the PR reviewer: a malicious + // aw_info.json carries an absolute path to a non-`.md` file. The + // resolver must refuse before any file open happens. + assert!( + resolve_source_path("/home/user/.ssh/id_rsa").await.is_err(), + "expected resolver to reject non-markdown absolute path" + ); + } + + #[tokio::test] + async fn resolve_source_path_rejects_parent_traversal() { + assert!( + resolve_source_path("../../../etc/passwd.md") + .await + .is_err(), + "expected resolver to reject parent-dir components" + ); + } + + #[tokio::test] + async fn resolve_source_path_rejects_tilde_prefix() { + assert!( + resolve_source_path("~/secret.md").await.is_err(), + "expected resolver to reject tilde-prefixed path" + ); + } + + #[tokio::test] + async fn resolve_source_path_accepts_markdown_absolute_paths() { + // Legitimate compiled-elsewhere workflows: absolute `.md` paths must still work. + let path = if cfg!(windows) { + r"C:\workflows\foo.md" + } else { + "/repo/workflows/foo.md" + }; + assert!( + resolve_source_path(path).await.is_ok(), + "expected absolute `.md` paths to be accepted" + ); + } + + #[tokio::test] + async fn populate_pipeline_graph_records_warning_on_malicious_source() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let run_dir = temp_dir.path().join("build-99"); + let staging_dir = run_dir.join("agent_outputs_99").join("staging"); + tokio::fs::create_dir_all(&staging_dir) + .await + .expect("create staging"); + + let aw_info = serde_json::json!({ + "source": "/home/user/.ssh/id_rsa", + "target": "standalone" + }); + tokio::fs::write(staging_dir.join("aw_info.json"), aw_info.to_string()) + .await + .expect("write aw_info"); + + let mut audit = AuditData::default(); + populate_pipeline_graph(&mut audit, &run_dir) + .await + .expect("populate graph should not error on malicious source"); + + assert!( + audit.pipeline_graph.is_none(), + "malicious source must not populate pipeline_graph" + ); + assert!( + audit + .warnings + .iter() + .any(|w| w.source == "audit::pipeline_graph" + && w.message.contains("could not resolve source path")), + "expected a warning recording the rejection, got {:?}", + audit.warnings + ); + } } diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs index 9b1bb31b..d8670a12 100644 --- a/src/inspect/lint.rs +++ b/src/inspect/lint.rs @@ -230,19 +230,33 @@ fn rule_no_condition_references(summary: &PipelineSummary, findings: &mut Vec
  • ) { - let mut seen: BTreeMap = BTreeMap::new(); + // Track first-seen job for each step id, then emit one finding per + // collision that names BOTH the original producer location and the + // colliding consumer — otherwise the finding only points at the + // second occurrence and operators have to grep the rest of the + // pipeline to find the duplicate. + let mut first_seen: BTreeMap = BTreeMap::new(); for (job, step) in all_steps(summary) { - if let Some(step_id) = step.id.as_deref() - && seen.insert(step_id.to_string(), job).is_some() - { + let Some(step_id) = step.id.as_deref() else { + continue; + }; + if let Some(producer) = first_seen.get(step_id) { // The normal graph pass rejects pipeline-wide duplicate step ids. // Keep this defensive check for summaries that bypassed the graph. + let producer_location = match &producer.stage { + Some(stage) => format!("{stage}.{}", producer.id), + None => producer.id.clone(), + }; findings.push(LintFinding { severity: LintSeverity::Error, code: "step-id-collisions".to_string(), - message: format!("step id '{step_id}' is used more than once in the pipeline"), + message: format!( + "step id '{step_id}' is used more than once in the pipeline (also seen at {producer_location})" + ), location: Some(location_for(job, Some(step_id))), }); + } else { + first_seen.insert(step_id.to_string(), job); } } } diff --git a/src/inspect/trace.rs b/src/inspect/trace.rs index bb214051..cd13496f 100644 --- a/src/inspect/trace.rs +++ b/src/inspect/trace.rs @@ -315,14 +315,7 @@ fn runtime_job_for_location<'a>( } fn find_runtime_job<'a>(audit: &'a AuditData, ir_job_id: &str) -> Option<&'a JobData> { - audit.jobs.iter().find(|job| { - job.name == ir_job_id - || job - .name - .rsplit('.') - .next() - .is_some_and(|suffix| suffix == ir_job_id) - }) + audit.jobs.iter().find(|job| job.matches_ir_id(ir_job_id)) } fn stage_for_job(audit: &AuditData, runtime_job: &JobData) -> Option { diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 81823273..be4cf70f 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -225,6 +225,25 @@ fn reachable_downstream_jobs( .collect() } +/// Classify a rendered ADO `condition:` string for what-if analysis. +/// +/// Returns [`WhatIfClassification::RunsAnyway`] if the condition +/// contains a recognised failure-bypass marker (`always()`, `failed()`, +/// `succeededOrFailed()`) that is **not** inside an odd number of +/// `not(...)` wrappers. Negation is handled by +/// [`is_negated_call`], so `not(failed())` is treated as `Skipped` and +/// `not(not(failed()))` resolves back to `RunsAnyway`. +/// +/// ## Coverage limitations +/// +/// The classifier only recognises canonical failure-bypass calls and +/// will conservatively report `Skipped` for any condition that gates +/// execution on a variable instead — for example +/// `eq(variables['Agent.JobStatus'], 'Failed')` or +/// `eq(dependencies.Setup.result, 'Failed')`. Treat the `Skipped` +/// classification as a lower bound: a job may still execute at runtime +/// via a variable-based escape hatch we cannot statically detect. The +/// authoritative source remains the live ADO pipeline run. fn classify_condition(condition: &Option) -> WhatIfClassification { let Some(condition) = condition else { return WhatIfClassification::Skipped; @@ -527,4 +546,24 @@ mod tests { assert_eq!(job_report.downstream_jobs, step_report.downstream_jobs); } + + #[test] + fn variable_based_condition_is_conservatively_skipped() { + // Documented limitation: variable-based conditions are not + // statically recognised and conservatively classify as Skipped. + let mut summary = fixture(None); + let PipelineBodySummary::Jobs { jobs } = &mut summary.body else { + unreachable!("fixture uses jobs body"); + }; + let detection = jobs.iter_mut().find(|job| job.id == "Detection").unwrap(); + detection.condition = Some("eq(variables['Agent.JobStatus'], 'Failed')".to_string()); + + let report = analyze(&summary, "Setup").unwrap(); + let detection = report + .downstream_jobs + .iter() + .find(|job| job.job == "Detection") + .unwrap(); + assert_eq!(detection.classification, WhatIfClassification::Skipped); + } } diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index 75f80ab4..708a468d 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -63,7 +63,7 @@ struct StepOutputsParams { consumer: Option, } -#[derive(Debug, Deserialize, JsonSchema)] +#[derive(Deserialize, JsonSchema)] struct TraceFailureParams { /// Build ID, or full Azure DevOps build URL. build_id_or_url: String, @@ -77,6 +77,20 @@ struct TraceFailureParams { pat: Option, } +// Manual `Debug` so any `{:?}` / `dbg!()` of this struct — including the +// rmcp framework's error traces — never reveals the PAT in plaintext. +impl std::fmt::Debug for TraceFailureParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TraceFailureParams") + .field("build_id_or_url", &self.build_id_or_url) + .field("step", &self.step) + .field("org", &self.org) + .field("project", &self.project) + .field("pat", &redacted_pat(&self.pat)) + .finish() + } +} + #[derive(Debug, Deserialize, JsonSchema)] struct WhatIfParams { /// Path to the source markdown workflow file. @@ -91,7 +105,7 @@ struct CatalogParams { kind: Option, } -#[derive(Debug, Deserialize, JsonSchema)] +#[derive(Deserialize, JsonSchema)] struct AuditBuildParams { /// Build ID, or full Azure DevOps build URL. build_id_or_url: String, @@ -107,6 +121,25 @@ struct AuditBuildParams { no_cache: Option, } +// Manual `Debug` so any `{:?}` / `dbg!()` of this struct — including the +// rmcp framework's error traces — never reveals the PAT in plaintext. +impl std::fmt::Debug for AuditBuildParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AuditBuildParams") + .field("build_id_or_url", &self.build_id_or_url) + .field("org", &self.org) + .field("project", &self.project) + .field("pat", &redacted_pat(&self.pat)) + .field("artifacts", &self.artifacts) + .field("no_cache", &self.no_cache) + .finish() + } +} + +fn redacted_pat(pat: &Option) -> &'static str { + if pat.is_some() { "" } else { "" } +} + #[derive(Debug, Serialize)] struct GraphDumpResult { text_or_dot: String, From f64c4d56827e64c34575116164cbeaab94d6b00e Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 20:39:39 +0100 Subject: [PATCH 06/17] fix(inspect): close substring-match bug in whatif + activate missing-is-output lint + refresh inspect mod doc Bugs: - inspect::whatif::contains_unnegated_call: add a word-boundary guard so the canonical-marker search no longer matches `failed()` inside `succeededorfailed()`. Previously `not(succeededOrFailed())` was wrongly classified as RunsAnyway because the inner `failed()` match started at offset 11 and the four chars before it were `edor`, never `not(`. New is_word_boundary_before helper + regression test pinning the corrected behaviour. - inspect::lint::rule_missing_is_output: drop the TODO-guarded auto_is_output check that made the rule unreachable in normal usage. The rule now consistently fires when a cross-step consumer lacks isOutput, catching future drift between summary patching and graph codegen. Doc-comment explains the invariant. Docs: - inspect::mod doc-comment: drop the stale "Future siblings (not yet landed)" section -- trace/whatif/lint/catalog/graph_deps/graph_outputs are all present in this PR. The refreshed layout list documents every module currently in the inspect tree. Validated locally: cargo build, cargo test --bin ado-aw (1881 unit tests pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/inspect/lint.rs | 38 ++++++++++++++++++++++++-------------- src/inspect/mod.rs | 13 ++++++------- src/inspect/whatif.rs | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs index d8670a12..f0f6f0d7 100644 --- a/src/inspect/lint.rs +++ b/src/inspect/lint.rs @@ -170,27 +170,37 @@ fn rule_unused_output(summary: &PipelineSummary, findings: &mut Vec } } +/// Lint rule: every output consumed across step boundaries must be +/// declared with `isOutput=true` so ADO publishes it as a step output. +/// +/// In the normal compile path `PipelineSummary::from_pipeline` already +/// patches `auto_is_output = true` on every affected declaration based +/// on the graph's `outputs_needing_is_output` set, so this rule will +/// stay quiet for well-formed inputs. We still emit a finding when the +/// flag is unset so that: +/// +/// - Summaries constructed without going through `from_pipeline` (e.g. +/// deserialised straight from disk) are still validated. +/// - Future drift between the summary patcher and graph codegen — for +/// instance a new declaration kind that the patcher forgets to touch +/// — produces a real, surfaced finding instead of silently skipping. fn rule_missing_is_output(summary: &PipelineSummary, findings: &mut Vec) { let declarations = output_declarations(summary); for needed in &summary.graph.outputs_needing_is_output { for output_name in &needed.outputs { if let Some((job, step, decl)) = declarations.get(&(needed.step.clone(), output_name.clone())) + && !decl.auto_is_output { - // TODO: This should remain quiet while PipelineSummary patches - // auto_is_output from the graph. Keep the guard so lint catches - // future drift between summary generation and graph codegen. - if !decl.auto_is_output { - findings.push(LintFinding { - severity: LintSeverity::Info, - code: "missing-is-output".to_string(), - message: format!( - "output '{}.{}' is consumed across steps but is not marked isOutput=true", - needed.step, output_name - ), - location: Some(location_for(job, step.id.as_deref())), - }); - } + findings.push(LintFinding { + severity: LintSeverity::Info, + code: "missing-is-output".to_string(), + message: format!( + "output '{}.{}' is consumed across steps but is not marked isOutput=true", + needed.step, output_name + ), + location: Some(location_for(job, step.id.as_deref())), + }); } } } diff --git a/src/inspect/mod.rs b/src/inspect/mod.rs index df0d6da9..0857ad6c 100644 --- a/src/inspect/mod.rs +++ b/src/inspect/mod.rs @@ -8,14 +8,13 @@ //! Layout follows `src/audit/`: //! //! - `cli.rs` — dispatchers for the public CLI subcommands. -//! - `graph_query.rs` — the `ado-aw graph` family (text/json/dot, -//! `graph deps`, `graph outputs`). -//! -//! Future siblings (called out in the implementation plan, not yet -//! landed): -//! +//! - `graph_query.rs` — the `ado-aw graph` family (text/json/dot). +//! - `graph_deps.rs` — `ado-aw graph deps`: per-step upstream / +//! downstream walks over the typed graph. +//! - `graph_outputs.rs` — `ado-aw graph outputs`: producer/consumer +//! relationships for declared step outputs. //! - `trace.rs` — `ado-aw trace`: joins build telemetry from -//! [`crate::audit`] with the IR graph. +//! [`crate::audit`] with the typed-IR graph for failure tracing. //! - `whatif.rs` — `ado-aw whatif`: static reachability ("which jobs //! skip if X fails?") from the typed `Condition` + `depends_on`. //! - `lint.rs` — `ado-aw lint`: structural checks layered on top of diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index be4cf70f..02273955 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -263,7 +263,15 @@ fn contains_unnegated_call(normalized_condition: &str, call: &str) -> bool { let mut from = 0; while let Some(offset) = normalized_condition[from..].find(call) { let idx = from + offset; - if !is_negated_call(normalized_condition, idx) { + // Word-boundary guard so `failed()` does not match inside + // `succeededorfailed()` (which starts at offset 11 within that + // larger call). Without this the negation logic also misfires + // because the four chars before the inner match are `edor`, + // not `not(`, so `not(succeededOrFailed())` was wrongly + // classified as RunsAnyway. + if is_word_boundary_before(normalized_condition, idx) + && !is_negated_call(normalized_condition, idx) + { return true; } from = idx + call.len(); @@ -271,6 +279,15 @@ fn contains_unnegated_call(normalized_condition: &str, call: &str) -> bool { false } +fn is_word_boundary_before(s: &str, idx: usize) -> bool { + if idx == 0 { + return true; + } + s.as_bytes() + .get(idx - 1) + .is_none_or(|&b| !b.is_ascii_alphanumeric()) +} + fn is_negated_call(normalized_condition: &str, call_idx: usize) -> bool { const NOT_PREFIX: &str = "not("; const NOT_PREFIX_LEN: usize = NOT_PREFIX.len(); @@ -532,6 +549,28 @@ mod tests { assert_eq!(detection.classification, WhatIfClassification::Skipped); } + #[test] + fn negated_succeeded_or_failed_condition_is_skipped() { + // Regression for the substring-match bug: `failed()` appears + // inside `succeededorfailed()` at byte offset 11, and the four + // chars before it are `edor` (not `not(`), so the old logic + // wrongly classified `not(succeededOrFailed())` as RunsAnyway. + let mut summary = fixture(None); + let PipelineBodySummary::Jobs { jobs } = &mut summary.body else { + unreachable!("fixture uses jobs body"); + }; + let detection = jobs.iter_mut().find(|job| job.id == "Detection").unwrap(); + detection.condition = Some("not(succeededOrFailed())".to_string()); + + let report = analyze(&summary, "Setup").unwrap(); + let detection = report + .downstream_jobs + .iter() + .find(|job| job.job == "Detection") + .unwrap(); + assert_eq!(detection.classification, WhatIfClassification::Skipped); + } + #[test] fn unknown_fail_id_returns_typed_error() { let err = analyze(&fixture(None), "unknown-id").unwrap_err(); From 160d4f8c42ac3a18c8db9d3ac54607203dfe310a Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 20:58:29 +0100 Subject: [PATCH 07/17] fix(audit,mcp-author,compile): address third-round PR #998 review feedback Bugs: - audit::pipeline_graph::timeline_name_matches_job and audit::model::JobData::matches_ir_id: tighten the dotted-name suffix fallback so multi-level names like `Stage1.SubStage.Agent` no longer spuriously match against the bare id `Agent`. The fallback now only accepts a single-level `Stage.Job` qualifier (rsplit_once + prefix must contain no dot). New regression tests pin the behaviour. - audit::cli::fetch_audit_data_inner: extract the post-fetch enrichment (pipeline_graph + metrics + derive_findings) into a single helper so the fresh-download and cache-load paths both produce structurally identical AuditData. The cache-load path now persists the recomputed data back to run-summary.json when it changed, so subsequent runs see the canonical shape and tooling diffing successive outputs is no longer confused by drift between the saved file and in-memory result. Suggestions: - mcp_author::audit_build: route `no_cache: true` invocations through a per-invocation `tempfile::tempdir()` so two concurrent calls for the same build can no longer race on partially-written artifacts in the shared `${TEMP}/ado-aw/audit/build-` directory. Cached calls continue to use the shared cache root so warm-cache reads still benefit from cross-invocation reuse. - compile::build_pipeline_ir: add `/* skip_integrity */` and `/* debug_pipeline */` inline-name comments to every dispatched target-builder call (only Standalone had them) so future callers cannot cargo-cult the wrong positional bool. Validated locally: cargo build, cargo test (1884 unit tests + every integration suite pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/cli.rs | 55 +++++++++++++++++++++++-------- src/audit/model.rs | 64 +++++++++++++++++++++++++++++++++---- src/audit/pipeline_graph.rs | 16 +++++++--- src/compile/mod.rs | 12 +++---- src/mcp_author/mod.rs | 33 ++++++++++++++++--- 5 files changed, 146 insertions(+), 34 deletions(-) diff --git a/src/audit/cli.rs b/src/audit/cli.rs index 19f80dbb..4f1759fd 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -74,15 +74,31 @@ async fn fetch_audit_data_inner(opts: AuditOptions<'_>) -> Result) -> Result) -> Result bool { - self.name == ir_job_id - || self - .name - .rsplit('.') - .next() - .is_some_and(|suffix| suffix == ir_job_id) + if self.name == ir_job_id { + return true; + } + matches!( + self.name.rsplit_once('.'), + Some((prefix, suffix)) + if suffix == ir_job_id && !prefix.contains('.') + ) } } @@ -1130,4 +1138,48 @@ mod tests { keys_sorted.sort(); assert_eq!(keys_sorted, vec!["downloaded_files", "metrics", "overview"]); } + + #[test] + fn matches_ir_id_accepts_bare_and_single_level_qualified_names() { + let bare = JobData { + name: "Agent".to_string(), + ..Default::default() + }; + assert!(bare.matches_ir_id("Agent")); + + let qualified = JobData { + name: "Pipeline.Agent".to_string(), + ..Default::default() + }; + assert!(qualified.matches_ir_id("Agent")); + } + + #[test] + fn matches_ir_id_rejects_multi_level_suffix() { + // Regression: the old `rsplit('.').next()` form matched the + // last component of any dotted path, which could attach IR + // edges to the wrong runtime job in unusual pipeline shapes. + let job = JobData { + name: "Stage1.SubStage.Agent".to_string(), + ..Default::default() + }; + + assert!( + !job.matches_ir_id("Agent"), + "multi-level dotted timeline names must not match against a bare id" + ); + assert!( + !job.matches_ir_id("SubStage.Agent"), + "matches_ir_id must not match an arbitrary tail substring" + ); + } + + #[test] + fn matches_ir_id_rejects_unrelated_names() { + let job = JobData { + name: "Detection".to_string(), + ..Default::default() + }; + assert!(!job.matches_ir_id("Agent")); + } } diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index df2a6b00..d7aef190 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -116,10 +116,18 @@ pub(crate) fn timeline_name_matches_job( { return true; } - timeline_name - .rsplit('.') - .next() - .is_some_and(|suffix| suffix == job_id) + // Fallback for unusual pipelines where the caller did not supply + // the stage but the timeline still emits a `Stage.Job` name. We + // only accept a *single-level* prefix — strings with two or more + // dots like `Stage1.SubStage.Agent` are rejected even when the + // trailing component matches, because the old + // `rsplit('.').next()` form could attach IR edges to the wrong + // runtime job in unusual pipeline shapes. + matches!( + timeline_name.rsplit_once('.'), + Some((prefix, suffix)) + if suffix == job_id && !prefix.contains('.') + ) } pub(crate) fn all_jobs(summary: &PipelineSummary) -> Vec<&JobSummary> { diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 1d1dd161..a994bcb7 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -922,8 +922,8 @@ pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pi input_path, &output_path, &markdown_body, - true, - false, + /* skip_integrity */ true, + /* debug_pipeline */ false, )?, CompileTarget::Job => job_ir::build_job_pipeline( &front_matter, @@ -932,8 +932,8 @@ pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pi input_path, &output_path, &markdown_body, - true, - false, + /* skip_integrity */ true, + /* debug_pipeline */ false, )?, CompileTarget::Stage => stage_ir::build_stage_pipeline( &front_matter, @@ -942,8 +942,8 @@ pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pi input_path, &output_path, &markdown_body, - true, - false, + /* skip_integrity */ true, + /* debug_pipeline */ false, )?, }; Ok((front_matter, pipeline)) diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index 708a468d..ff5d954a 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -4,7 +4,7 @@ //! trace, and audit queries over stdio. It intentionally has no workspace //! bounding directory: callers run it locally as the invoking user. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::Result; use log::{error, info}; @@ -297,16 +297,41 @@ impl AuthorMcp { params: Parameters, ) -> Result { let artifacts = params.0.artifacts.as_deref(); - let output = std::env::temp_dir().join("ado-aw").join("audit"); + let no_cache = params.0.no_cache.unwrap_or(false); + + // Shared cache root for normal calls (the audit layer creates a + // per-build subdirectory keyed on build id). For `no_cache: + // true` we additionally route through a unique per-invocation + // tempdir so two concurrent calls for the *same* build cannot + // race on partially-written artifacts. + let shared_root = std::env::temp_dir().join("ado-aw").join("audit"); + let invocation_tempdir = if no_cache { + Some(tempfile::Builder::new() + .prefix("ado-aw-mcp-audit-") + .tempdir() + .map_err(|err| { + McpError::internal_error( + format!("failed to create temp dir for no-cache audit: {err}"), + None, + ) + })?) + } else { + None + }; + let output: &Path = invocation_tempdir + .as_ref() + .map(tempfile::TempDir::path) + .unwrap_or(shared_root.as_path()); + let audit = crate::audit::fetch_audit_data(crate::audit::AuditOptions { build_id_or_url: ¶ms.0.build_id_or_url, - output: &output, + output, json: true, org: params.0.org.as_deref(), project: params.0.project.as_deref(), pat: params.0.pat.as_deref(), artifacts, - no_cache: params.0.no_cache.unwrap_or(false), + no_cache, }) .await .map_err(to_mcp_error)?; From 7df9b1a7895107fb9d165df462de1054729ec5af Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 21:13:58 +0100 Subject: [PATCH 08/17] docs(inspect,audit,main): document residual limitations flagged in PR #998 round 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four items in the latest review are documentation-only — the runtime behaviour is already correct and the reviewer asked for explicit comments so future maintainers do not weaken the guards or re-introduce the fixed bugs by accident. - inspect::whatif::classify_condition: doc-comment now explicitly flags the string-literal false-positive (e.g. a condition like eq(variables['result'], 'failed()') would match the literal substring) alongside the existing variable-condition limitation. This is acceptable because ADO conditions are compiler-generated, but the residual gap is now documented. - audit::cli::fetch_audit_data_inner cache write path: add an inline comment recording the deliberate lack of filesystem locking on save_run_summary. Two concurrent ado-aw audit runs for the same build can race on this write; the failure is recorded as a warning rather than aborting the audit, and both writers derive from the same on-disk artifacts so the resulting summary stays internally consistent. - audit::pipeline_graph::resolve_source_path: complete the previously truncated security comment so the residual risk is unambiguous — the .md extension check is the primary gate for absolute paths, and weakening or removing it without adding a containment check would silently re-open the arbitrary-file-read vector flagged earlier. - main: comment on the std::process::exit(1) in the Lint dispatch arm explaining the intentional Drop-bypass (mirrors tsc --noEmit / eslint) and that the only resources in scope are runtime-managed async I/O. No behavioural changes. Validated locally with cargo build, cargo test --bin ado-aw (1884 unit tests pass), and cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/cli.rs | 10 ++++++++++ src/audit/pipeline_graph.rs | 15 +++++++++++++++ src/inspect/whatif.rs | 25 +++++++++++++++++-------- src/main.rs | 6 ++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/audit/cli.rs b/src/audit/cli.rs index 4f1759fd..a74a3c58 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -81,6 +81,16 @@ async fn fetch_audit_data_inner(opts: AuditOptions<'_>) -> Result Option> { /// leading `~` (no directory traversal, no shell-style expansion). /// - Allowing absolute `.md` paths because legitimate compiled- /// elsewhere workflows commonly carry an absolute `source`. +/// +/// ## Residual risk +/// +/// The `.md` extension check is the **primary gate** for absolute +/// paths. A crafted `aw_info.json` carrying +/// `"source": "/home/user/something.md"` will still reach +/// `build_pipeline_ir`, which opens and reads the file. Because that +/// function is read-only and fails gracefully on non-front-matter +/// markdown, the practical blast radius is an unexpected parse error +/// surfaced in the audit warnings — not code execution or +/// credential exfiltration. **Do not weaken or remove the `.md` +/// extension check** without also adding a containment check (e.g. +/// canonicalize + prefix-against-cwd) at the same level; without it +/// any future maintainer would silently re-open the +/// arbitrary-file-read vector. async fn resolve_source_path(source: &str) -> Result { let normalized = normalize_source_path(source); let path = PathBuf::from(&normalized); diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 02273955..faf4bcc4 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -236,14 +236,23 @@ fn reachable_downstream_jobs( /// /// ## Coverage limitations /// -/// The classifier only recognises canonical failure-bypass calls and -/// will conservatively report `Skipped` for any condition that gates -/// execution on a variable instead — for example -/// `eq(variables['Agent.JobStatus'], 'Failed')` or -/// `eq(dependencies.Setup.result, 'Failed')`. Treat the `Skipped` -/// classification as a lower bound: a job may still execute at runtime -/// via a variable-based escape hatch we cannot statically detect. The -/// authoritative source remains the live ADO pipeline run. +/// The classifier is a best-effort static analyser over the rendered +/// condition string, not a semantic ADO expression evaluator. Known +/// limitations: +/// +/// - **Variable-based conditions** such as +/// `eq(variables['Agent.JobStatus'], 'Failed')` or +/// `eq(dependencies.Setup.result, 'Failed')` are conservatively +/// reported as `Skipped`. Treat that result as a lower bound — a +/// job may still execute at runtime via a variable-based escape +/// hatch we cannot statically detect. +/// - **String literals containing marker syntax** trigger a +/// false-positive `RunsAnyway`: a condition like +/// `eq(variables['result'], 'failed()')` would match the literal +/// `failed()` substring even though the call is never invoked. ADO +/// conditions are compiler-generated rather than raw user input, so +/// this is an accepted residual gap; the authoritative source +/// remains the live ADO pipeline run. fn classify_condition(condition: &Option) -> WhatIfClassification { let Some(condition) = condition else { return WhatIfClassification::Skipped; diff --git a/src/main.rs b/src/main.rs index 8d21bd87..46151597 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1445,6 +1445,12 @@ async fn main() -> Result<()> { }) .await?; if had_errors { + // Intentional `exit(1)` (not a returned `Err`): mirrors + // how `tsc --noEmit` / `eslint` signal lint failure to + // CI, so callers can fail a pipeline step on the exit + // code without having to parse stderr. The async I/O + // resources used by `dispatch_lint` are runtime-managed + // and do not leak when we bypass `Drop` here. std::process::exit(1); } } From 7660b068c8cfc3b8a26914a299cae8b1fd467e90 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 21:33:39 +0100 Subject: [PATCH 09/17] refactor(audit,inspect,mcp-author): consolidate audit cache root via crate::audit::default_cache_root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the `./logs` path was hardcoded in three places and the default cache directory disagreed between entry points: - The CLI `audit` command defaulted `--output` to `./logs`. - The CLI `trace` command and `inspect::build_trace` opened `Path::new("./logs")` directly. - The mcp-author `audit_build` tool used `${TEMP}/ado-aw/audit` while the mcp-author `trace_failure` tool inherited the `./logs` default — silently scattering log directories under arbitrary IDE working directories. Introduce `crate::audit::default_cache_root()` returning `${TEMP}/ado-aw/audit` on every platform, re-exported from `crate::audit`. Every entry point now resolves to that helper: - CLI `audit`: `--output` is `Option`, unset → helper. - CLI `trace`: passes `output: None` so `inspect::build_trace` resolves to the helper. - `inspect::build_trace`: default for `TraceOptions::output: None` is the helper. - mcp-author `audit_build`: uses the helper for normal calls and layers a per-invocation `tempfile::tempdir()` on top when `no_cache: true`. - mcp-author `trace_failure`: passes `output: None`. Docs (`docs/audit.md`, `docs/cli.md`, `site/src/content/docs/setup/cli.mdx`, `site/src/content/docs/reference/audit.mdx`, `prompts/debug-ado-agentic-workflow.md`) updated to reflect the new default and to note that the cache is shared across every audit entry point. Validated locally: cargo build, cargo test (1884 unit tests + every integration suite pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/audit.md | 2 +- docs/cli.md | 4 ++-- prompts/debug-ado-agentic-workflow.md | 2 +- site/src/content/docs/reference/audit.mdx | 2 +- site/src/content/docs/setup/cli.mdx | 2 +- src/audit/cli.rs | 20 ++++++++++++++++++++ src/audit/mod.rs | 2 +- src/inspect/cli.rs | 16 +++++++++++++++- src/main.rs | 17 +++++++++++++---- src/mcp_author/mod.rs | 15 +++++++++------ 10 files changed, 64 insertions(+), 18 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index 6d748e64..4a727aa2 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -26,7 +26,7 @@ URL-encoded project segments are decoded before the ADO context is resolved. `t= | Flag | Default | Behavior | | --- | --- | --- | -| `-o, --output ` | `./logs` | Directory under which `/build-/` is written. | +| `-o, --output ` | `${TEMP}/ado-aw/audit` | Directory under which `/build-/` is written. The default is the shared cache root used by every audit entry point (CLI, `ado-aw trace`, and the mcp-author tools), so concurrent invocations reuse each other's downloads. | | `--json` | off | Emit the full `AuditData` as JSON to stdout (suppresses the trailing `Audit complete` stderr line). | | `--org ` | auto | Azure DevOps organization override for bare build IDs. Full build URLs provide the host / org directly. | | `--project ` | auto | Azure DevOps project override for bare build IDs. Full build URLs provide the project directly. | diff --git a/docs/cli.md b/docs/cli.md index ad1b3020..fda7c20e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -135,7 +135,7 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe - `--dry-run` - Print the planned `templateParameters` body without calling the ADO API. - `audit ` - Audit a single Azure DevOps build: download the known stage artifacts, run the audit analyzers, and render a structured console report or `AuditData` JSON. - - `-o, --output ` - Output directory for downloaded artifacts and reports. Defaults to `./logs`; the run is stored under `/build-/`. + - `-o, --output ` - Output directory for downloaded artifacts and reports. Defaults to `${TEMP}/ado-aw/audit`; the run is stored under `/build-/`. The default is shared with `ado-aw trace` and the mcp-author tools so concurrent invocations reuse each other's downloads. - `--json` - Emit machine-readable JSON (`AuditData`) instead of the console report. Suppresses the trailing `Audit complete` stderr line. - `--org ` - Override: Azure DevOps organization (used when the input is a bare build ID). Full build URLs provide the host / org directly. - `--project ` - Override: Azure DevOps project name (used when the input is a bare build ID). Full build URLs provide the project directly. @@ -144,7 +144,7 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe - `--no-cache` - Ignore `/build-/run-summary.json` and re-process the build. - See [`audit.md`](audit.md) for accepted build-reference formats, output layout, cache semantics, and the `AuditData` report shape. -- `trace [--step ] [--json]` - Query audit telemetry plus local typed-IR graph correlation to explain failed-job chains and downstream skip classifications. Uses `./logs/build-/` cache when present and degrades to runtime-only output when the source markdown is not local. +- `trace [--step ] [--json]` - Query audit telemetry plus local typed-IR graph correlation to explain failed-job chains and downstream skip classifications. Shares the `${TEMP}/ado-aw/audit/build-/` cache with `ado-aw audit`, and degrades to runtime-only output when the source markdown is not local. - `--step ` - Focus the report on a named IR step and show the containing job's runtime status plus upstream/downstream job classifications. - `--json` - Emit a structured `TraceReport`. - `--org `, `--project `, `--pat ` / `AZURE_DEVOPS_EXT_PAT` - Same ADO context/auth passthroughs as `audit`. diff --git a/prompts/debug-ado-agentic-workflow.md b/prompts/debug-ado-agentic-workflow.md index 4a273322..b5a4fe12 100644 --- a/prompts/debug-ado-agentic-workflow.md +++ b/prompts/debug-ado-agentic-workflow.md @@ -52,7 +52,7 @@ You need minimal context from the user: - Error messages or log snippets from the failing step - The agent source `.md` file (or path) and the compiled `.lock.yml` (or path) -**Fastest first move when a build ID or URL is available:** run `ado-aw audit --json`. It downloads the build's artifacts, runs every analyzer (firewall, MCP gateway, OTel, safe outputs, detection verdict, timeline, missing tools/data/noops), and emits a structured JSON report you can read directly — much faster than paging through raw logs. The audit caches its results under `./logs/build-/run-summary.json` so re-running is free. +**Fastest first move when a build ID or URL is available:** run `ado-aw audit --json`. It downloads the build's artifacts, runs every analyzer (firewall, MCP gateway, OTel, safe outputs, detection verdict, timeline, missing tools/data/noops), and emits a structured JSON report you can read directly — much faster than paging through raw logs. The audit caches its results under `${TEMP}/ado-aw/audit/build-/run-summary.json` so re-running is free. ### Step 2: Investigate diff --git a/site/src/content/docs/reference/audit.mdx b/site/src/content/docs/reference/audit.mdx index c303b014..749f1d0f 100644 --- a/site/src/content/docs/reference/audit.mdx +++ b/site/src/content/docs/reference/audit.mdx @@ -29,7 +29,7 @@ URL-encoded project segments are decoded automatically. Both `t=` and `s=` are a | Flag | Default | Behavior | |---|---|---| -| `-o, --output ` | `./logs` | Directory under which `/build-/` is written. | +| `-o, --output ` | `${TEMP}/ado-aw/audit` | Directory under which `/build-/` is written. Shared with `ado-aw trace` and the mcp-author tools so concurrent invocations reuse each other's downloads. | | `--json` | off | Emit the full `AuditData` as JSON to stdout. Suppresses the trailing `Audit complete` stderr line. | | `--org ` | auto | ADO organization override for bare build IDs. Full build URLs supply this directly. | | `--project ` | auto | ADO project override for bare build IDs. Full build URLs supply this directly. | diff --git a/site/src/content/docs/setup/cli.mdx b/site/src/content/docs/setup/cli.mdx index c724a7a8..fa5bb184 100644 --- a/site/src/content/docs/setup/cli.mdx +++ b/site/src/content/docs/setup/cli.mdx @@ -228,7 +228,7 @@ ado-aw audit [--json] [--output ] [--artifacts ] Options: - `--json` -- emit the full `AuditData` as JSON to stdout instead of the console report -- `-o, --output ` -- local directory for downloaded artifacts and the cached report (default: `./logs`) +- `-o, --output ` -- local directory for downloaded artifacts and the cached report (default: `${TEMP}/ado-aw/audit`, shared with `ado-aw trace` and the mcp-author tools) - `--artifacts ` -- restrict download to `agent`, `detection`, and/or `safe-outputs` - `--no-cache` -- re-process even when a cached `run-summary.json` already exists - `--org`, `--project`, `--pat` -- same as `enable` diff --git a/src/audit/cli.rs b/src/audit/cli.rs index a74a3c58..0df625ab 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -30,6 +30,26 @@ pub struct AuditOptions<'a> { pub no_cache: bool, } +/// Canonical cache root for downloaded audit artifacts and the +/// `run-summary.json` cache files. +/// +/// Returns `${TEMP}/ado-aw/audit` on every platform. All entry points +/// — the `ado-aw audit` CLI, `ado-aw trace`, the mcp-author +/// `audit_build` and `trace_failure` tools — go through this helper so +/// that runs invoked from different contexts share a single cache +/// location and never silently scatter `./logs/` directories under +/// whatever working directory the caller happened to inherit (most +/// often the IDE's current project when the MCP server is started). +/// +/// The audit layer creates a per-build subdirectory (`build-`) +/// under this root, keyed on the build id, so concurrent runs against +/// different builds are isolated. Callers that need full per-invocation +/// isolation (e.g. `no_cache: true` audits run concurrently against +/// the same build) should layer a unique tempdir on top of this root. +pub fn default_cache_root() -> PathBuf { + std::env::temp_dir().join("ado-aw").join("audit") +} + pub async fn dispatch(opts: AuditOptions<'_>) -> Result<()> { let result = fetch_audit_data_inner(opts).await?; render_audit(&result.audit, result.json)?; diff --git a/src/audit/mod.rs b/src/audit/mod.rs index e75bfa19..ff44b20b 100644 --- a/src/audit/mod.rs +++ b/src/audit/mod.rs @@ -11,7 +11,7 @@ pub mod pipeline_graph; pub mod render; pub mod url; -pub use cli::{AuditOptions, dispatch, fetch_audit_data}; +pub use cli::{AuditOptions, default_cache_root, dispatch, fetch_audit_data}; #[allow(unused_imports)] pub use model::*; diff --git a/src/inspect/cli.rs b/src/inspect/cli.rs index 67e92a15..deb3ff0d 100644 --- a/src/inspect/cli.rs +++ b/src/inspect/cli.rs @@ -170,6 +170,14 @@ pub struct TraceOptions<'a> { pub org: Option<&'a str>, pub project: Option<&'a str>, pub pat: Option<&'a str>, + /// Cache root for downloaded build artifacts. When `None`, + /// [`build_trace`] anchors writes under + /// [`crate::audit::default_cache_root`] + /// (`${TEMP}/ado-aw/audit`) so CLI invocations, the mcp-author + /// `trace_failure` tool, and `ado-aw audit` all share a single + /// cache root — preventing `./logs/` directories from being + /// scattered under arbitrary IDE working directories. + pub output: Option<&'a Path>, } /// Trace a build by joining audit telemetry with the local typed-IR graph. @@ -194,9 +202,15 @@ pub async fn dispatch_trace(opts: TraceOptions<'_>) -> Result<()> { /// Build trace audit data and the derived trace report. pub async fn build_trace(opts: &TraceOptions<'_>) -> Result<(AuditData, trace::TraceReport)> { + // Default to the canonical audit cache root shared with every + // other entry point (CLI `audit`, mcp-author `audit_build` / + // `trace_failure`). Callers may pass `opts.output = Some(&Path)` + // to override (e.g. for tests). + let default_output = crate::audit::default_cache_root(); + let output = opts.output.unwrap_or(default_output.as_path()); let audit = crate::audit::fetch_audit_data(crate::audit::AuditOptions { build_id_or_url: opts.build_id_or_url, - output: Path::new("./logs"), + output, json: true, org: opts.org, project: opts.project, diff --git a/src/main.rs b/src/main.rs index 46151597..e5a9420d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -506,9 +506,12 @@ enum Commands { /// Build ID, or full ADO build URL. build_id_or_url: String, /// Output directory for downloaded artifacts and reports. - /// Default: ./logs (matches gh-aw operator muscle memory). - #[arg(short, long, default_value = "./logs")] - output: PathBuf, + /// Defaults to `${TEMP}/ado-aw/audit` so concurrent invocations + /// from the CLI, `ado-aw trace`, and the mcp-author tools all + /// share a single cache root and never scatter `./logs/` + /// directories under arbitrary working directories. + #[arg(short, long)] + output: Option, /// Emit the report as JSON to stdout instead of console text. #[arg(long)] json: bool, @@ -1326,9 +1329,10 @@ async fn main() -> Result<()> { artifacts, no_cache, } => { + let resolved_output = output.unwrap_or_else(audit::default_cache_root); audit::dispatch(audit::AuditOptions { build_id_or_url: &build_id_or_url, - output: &output, + output: &resolved_output, json, org: org.as_deref(), project: project.as_deref(), @@ -1353,6 +1357,11 @@ async fn main() -> Result<()> { org: org.as_deref(), project: project.as_deref(), pat: pat.as_deref(), + // Default cache root (`${TEMP}/ado-aw/audit`). Keep this + // `None` so CLI and MCP invocations share one cache; pass + // `Some(Path::new(...))` here only if a future flag adds + // a user-configurable override. + output: None, }) .await?; } diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index ff5d954a..220db8a6 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -249,6 +249,9 @@ impl AuthorMcp { org: params.0.org.as_deref(), project: params.0.project.as_deref(), pat: params.0.pat.as_deref(), + // `None` → use the shared cache root via + // `crate::audit::default_cache_root`. + output: None, }; let (_audit, report) = inspect::build_trace(&opts).await.map_err(to_mcp_error)?; structured_result(report) @@ -299,12 +302,12 @@ impl AuthorMcp { let artifacts = params.0.artifacts.as_deref(); let no_cache = params.0.no_cache.unwrap_or(false); - // Shared cache root for normal calls (the audit layer creates a - // per-build subdirectory keyed on build id). For `no_cache: - // true` we additionally route through a unique per-invocation - // tempdir so two concurrent calls for the *same* build cannot - // race on partially-written artifacts. - let shared_root = std::env::temp_dir().join("ado-aw").join("audit"); + // Use the canonical shared cache root for normal calls (the + // audit layer creates a per-build subdirectory keyed on build + // id). For `no_cache: true` we additionally route through a + // unique per-invocation tempdir so two concurrent calls for + // the *same* build cannot race on partially-written artifacts. + let shared_root = crate::audit::default_cache_root(); let invocation_tempdir = if no_cache { Some(tempfile::Builder::new() .prefix("ado-aw-mcp-audit-") From 883f106638b38d97001715d7010ac040a29df4c0 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 22:09:49 +0100 Subject: [PATCH 10/17] refactor(inspect,audit): consolidate PipelineSummary::all_jobs + drop noisy lint rule + restore ./logs CLI default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #998 round 6: - Drop `inspect::lint::rule_no_condition_references`. The rule fires an Info finding for every job that has `depends_on` with no explicit `condition:` — i.e. every non-Setup job in the canonical Setup → Agent → Detection → SafeOutputs shape. ADO's default `succeeded()` semantics are intentional, not a bug, so the rule documented normal behaviour rather than finding problems and made genuine findings harder to spot. Removed entirely; the serialisation roundtrip test that referenced the rule's code string still passes because the literal value is independent of rule registration. - Centralise `all_jobs()` as `PipelineSummary::all_jobs`. Three copies (in `audit::pipeline_graph`, `inspect::lint`, and `inspect::whatif`) collapsed into a single method on the public IR summary type. Call sites in those three modules plus `inspect::trace::stage_for_job` now go through the method. Future body-shape additions only need updating in one place. - Revert the CLI `ado-aw audit --output` default from `${TEMP}/ado-aw/audit` back to `./logs` to preserve backward compatibility for scripts that consume the documented `./logs` location. Non-CLI callers — `inspect::build_trace` (used by the CLI `trace` command and the mcp-author `trace_failure` tool) and the mcp-author `audit_build` tool — continue to anchor under `crate::audit::default_cache_root()` so they do not scatter `./logs/` directories under arbitrary IDE working directories. Docs (`docs/audit.md`, `docs/cli.md`, both site mdx files, and the debug prompt) updated to reflect the per-entry-point split. Validated locally: cargo build, cargo test (1887 unit tests + every integration suite pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/audit.md | 2 +- docs/cli.md | 4 +-- prompts/debug-ado-agentic-workflow.md | 2 +- site/src/content/docs/reference/audit.mdx | 2 +- site/src/content/docs/setup/cli.mdx | 2 +- src/audit/pipeline_graph.rs | 14 +++------- src/compile/ir/summary.rs | 17 ++++++++++++ src/inspect/lint.rs | 32 +++-------------------- src/inspect/trace.rs | 4 ++- src/inspect/whatif.rs | 13 ++------- src/main.rs | 18 +++++++------ 11 files changed, 44 insertions(+), 66 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index 4a727aa2..89fdca1b 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -26,7 +26,7 @@ URL-encoded project segments are decoded before the ADO context is resolved. `t= | Flag | Default | Behavior | | --- | --- | --- | -| `-o, --output ` | `${TEMP}/ado-aw/audit` | Directory under which `/build-/` is written. The default is the shared cache root used by every audit entry point (CLI, `ado-aw trace`, and the mcp-author tools), so concurrent invocations reuse each other's downloads. | +| `-o, --output ` | `./logs` | Directory under which `/build-/` is written. Non-CLI entry points (`ado-aw trace`, the mcp-author tools) instead default to the shared `${TEMP}/ado-aw/audit` cache root so they do not scatter `./logs/` directories under arbitrary working directories. | | `--json` | off | Emit the full `AuditData` as JSON to stdout (suppresses the trailing `Audit complete` stderr line). | | `--org ` | auto | Azure DevOps organization override for bare build IDs. Full build URLs provide the host / org directly. | | `--project ` | auto | Azure DevOps project override for bare build IDs. Full build URLs provide the project directly. | diff --git a/docs/cli.md b/docs/cli.md index fda7c20e..96a842f2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -135,7 +135,7 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe - `--dry-run` - Print the planned `templateParameters` body without calling the ADO API. - `audit ` - Audit a single Azure DevOps build: download the known stage artifacts, run the audit analyzers, and render a structured console report or `AuditData` JSON. - - `-o, --output ` - Output directory for downloaded artifacts and reports. Defaults to `${TEMP}/ado-aw/audit`; the run is stored under `/build-/`. The default is shared with `ado-aw trace` and the mcp-author tools so concurrent invocations reuse each other's downloads. + - `-o, --output ` - Output directory for downloaded artifacts and reports. Defaults to `./logs`; the run is stored under `/build-/`. Non-CLI entry points (`ado-aw trace` and the mcp-author tools) instead anchor under `${TEMP}/ado-aw/audit` so they do not scatter directories under arbitrary working directories. - `--json` - Emit machine-readable JSON (`AuditData`) instead of the console report. Suppresses the trailing `Audit complete` stderr line. - `--org ` - Override: Azure DevOps organization (used when the input is a bare build ID). Full build URLs provide the host / org directly. - `--project ` - Override: Azure DevOps project name (used when the input is a bare build ID). Full build URLs provide the project directly. @@ -144,7 +144,7 @@ Both `--all-repos` and `--source` route through `ado-aw`'s `discover_ado_aw_pipe - `--no-cache` - Ignore `/build-/run-summary.json` and re-process the build. - See [`audit.md`](audit.md) for accepted build-reference formats, output layout, cache semantics, and the `AuditData` report shape. -- `trace [--step ] [--json]` - Query audit telemetry plus local typed-IR graph correlation to explain failed-job chains and downstream skip classifications. Shares the `${TEMP}/ado-aw/audit/build-/` cache with `ado-aw audit`, and degrades to runtime-only output when the source markdown is not local. +- `trace [--step ] [--json]` - Query audit telemetry plus local typed-IR graph correlation to explain failed-job chains and downstream skip classifications. Downloads / caches under `${TEMP}/ado-aw/audit/build-/` (separate from `ado-aw audit`'s `./logs` default so the MCP server and IDE-driven traces do not scatter `./logs/` directories under arbitrary working dirs), and degrades to runtime-only output when the source markdown is not local. - `--step ` - Focus the report on a named IR step and show the containing job's runtime status plus upstream/downstream job classifications. - `--json` - Emit a structured `TraceReport`. - `--org `, `--project `, `--pat ` / `AZURE_DEVOPS_EXT_PAT` - Same ADO context/auth passthroughs as `audit`. diff --git a/prompts/debug-ado-agentic-workflow.md b/prompts/debug-ado-agentic-workflow.md index b5a4fe12..4a273322 100644 --- a/prompts/debug-ado-agentic-workflow.md +++ b/prompts/debug-ado-agentic-workflow.md @@ -52,7 +52,7 @@ You need minimal context from the user: - Error messages or log snippets from the failing step - The agent source `.md` file (or path) and the compiled `.lock.yml` (or path) -**Fastest first move when a build ID or URL is available:** run `ado-aw audit --json`. It downloads the build's artifacts, runs every analyzer (firewall, MCP gateway, OTel, safe outputs, detection verdict, timeline, missing tools/data/noops), and emits a structured JSON report you can read directly — much faster than paging through raw logs. The audit caches its results under `${TEMP}/ado-aw/audit/build-/run-summary.json` so re-running is free. +**Fastest first move when a build ID or URL is available:** run `ado-aw audit --json`. It downloads the build's artifacts, runs every analyzer (firewall, MCP gateway, OTel, safe outputs, detection verdict, timeline, missing tools/data/noops), and emits a structured JSON report you can read directly — much faster than paging through raw logs. The audit caches its results under `./logs/build-/run-summary.json` so re-running is free. ### Step 2: Investigate diff --git a/site/src/content/docs/reference/audit.mdx b/site/src/content/docs/reference/audit.mdx index 749f1d0f..a581d740 100644 --- a/site/src/content/docs/reference/audit.mdx +++ b/site/src/content/docs/reference/audit.mdx @@ -29,7 +29,7 @@ URL-encoded project segments are decoded automatically. Both `t=` and `s=` are a | Flag | Default | Behavior | |---|---|---| -| `-o, --output ` | `${TEMP}/ado-aw/audit` | Directory under which `/build-/` is written. Shared with `ado-aw trace` and the mcp-author tools so concurrent invocations reuse each other's downloads. | +| `-o, --output ` | `./logs` | Directory under which `/build-/` is written. Non-CLI entry points (`ado-aw trace` and the mcp-author tools) default to the shared `${TEMP}/ado-aw/audit` cache root so they do not scatter `./logs/` directories under arbitrary working directories. | | `--json` | off | Emit the full `AuditData` as JSON to stdout. Suppresses the trailing `Audit complete` stderr line. | | `--org ` | auto | ADO organization override for bare build IDs. Full build URLs supply this directly. | | `--project ` | auto | ADO project override for bare build IDs. Full build URLs supply this directly. | diff --git a/site/src/content/docs/setup/cli.mdx b/site/src/content/docs/setup/cli.mdx index fa5bb184..7b338b07 100644 --- a/site/src/content/docs/setup/cli.mdx +++ b/site/src/content/docs/setup/cli.mdx @@ -228,7 +228,7 @@ ado-aw audit [--json] [--output ] [--artifacts ] Options: - `--json` -- emit the full `AuditData` as JSON to stdout instead of the console report -- `-o, --output ` -- local directory for downloaded artifacts and the cached report (default: `${TEMP}/ado-aw/audit`, shared with `ado-aw trace` and the mcp-author tools) +- `-o, --output ` -- local directory for downloaded artifacts and the cached report (default: `./logs`; non-CLI entry points like `ado-aw trace` and the mcp-author tools default to `${TEMP}/ado-aw/audit` instead) - `--artifacts ` -- restrict download to `agent`, `detection`, and/or `safe-outputs` - `--no-cache` -- re-process even when a cached `run-summary.json` already exists - `--org`, `--project`, `--pat` -- same as `enable` diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index dfaeeef2..e2bff707 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::audit::model::{AuditData, AwInfo, ErrorInfo, PipelineGraphSection}; -use crate::compile::ir::summary::{JobSummary, PipelineBodySummary, PipelineSummary}; +use crate::compile::ir::summary::{JobSummary, PipelineSummary}; /// Populate `audit.pipeline_graph` and per-job upstream/downstream IR edges. /// @@ -97,7 +97,8 @@ fn find_matching_job_summary<'a>( summary: &'a PipelineSummary, timeline_name: &str, ) -> Option<&'a JobSummary> { - all_jobs(summary) + summary + .all_jobs() .into_iter() .find(|job| timeline_name_matches_job(timeline_name, &job.id, job.stage.as_deref())) } @@ -130,15 +131,6 @@ pub(crate) fn timeline_name_matches_job( ) } -pub(crate) fn all_jobs(summary: &PipelineSummary) -> Vec<&JobSummary> { - match &summary.body { - PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), - PipelineBodySummary::Stages { stages } => { - stages.iter().flat_map(|stage| stage.jobs.iter()).collect() - } - } -} - async fn read_source_from_aw_info(run_dir: &Path) -> Option> { let agent_outputs = find_artifact_dir(run_dir, "agent_outputs").await?; for path in [ diff --git a/src/compile/ir/summary.rs b/src/compile/ir/summary.rs index b308dd75..54b8ad71 100644 --- a/src/compile/ir/summary.rs +++ b/src/compile/ir/summary.rs @@ -63,6 +63,23 @@ pub enum PipelineBodySummary { Stages { stages: Vec }, } +impl PipelineSummary { + /// Flatten the body into a single slice of [`JobSummary`] entries. + /// + /// Single source of truth for `Jobs`-bodied vs `Stages`-bodied + /// iteration; both `audit::pipeline_graph` and the `inspect` + /// commands go through this so that future shape additions (e.g. a + /// new `Templates` variant) only need to be handled in one place. + pub fn all_jobs(&self) -> Vec<&JobSummary> { + match &self.body { + PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), + PipelineBodySummary::Stages { stages } => { + stages.iter().flat_map(|stage| stage.jobs.iter()).collect() + } + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StageSummary { pub id: String, diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs index f0f6f0d7..cb7b77ec 100644 --- a/src/inspect/lint.rs +++ b/src/inspect/lint.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -use crate::compile::ir::summary::{JobSummary, PipelineBodySummary, PipelineSummary, StepSummary}; +use crate::compile::ir::summary::{JobSummary, PipelineSummary, StepSummary}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -67,7 +67,6 @@ pub fn lint(summary: &PipelineSummary) -> Vec { rule_unused_output(summary, &mut findings); rule_missing_is_output(summary, &mut findings); rule_anonymous_producer(summary, &mut findings); - rule_no_condition_references(summary, &mut findings); rule_step_id_collisions(summary, &mut findings); findings } @@ -222,23 +221,6 @@ fn rule_anonymous_producer(summary: &PipelineSummary, findings: &mut Vec) { - for job in all_jobs(summary) { - if !job.depends_on.is_empty() && job.condition.is_none() { - findings.push(LintFinding { - severity: LintSeverity::Info, - code: "no-condition-references".to_string(), - message: format!( - "job '{}' depends on [{}] with no condition; Azure DevOps applies default succeeded(), so all upstream jobs must succeed", - job.id, - job.depends_on.join(", ") - ), - location: Some(location_for(job, None)), - }); - } - } -} - fn rule_step_id_collisions(summary: &PipelineSummary, findings: &mut Vec) { // Track first-seen job for each step id, then emit one finding per // collision that names BOTH the original producer location and the @@ -306,17 +288,9 @@ fn output_declarations( declarations } -fn all_jobs(summary: &PipelineSummary) -> Vec<&JobSummary> { - match &summary.body { - PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), - PipelineBodySummary::Stages { stages } => { - stages.iter().flat_map(|stage| stage.jobs.iter()).collect() - } - } -} - fn all_steps(summary: &PipelineSummary) -> Vec<(&JobSummary, &StepSummary)> { - all_jobs(summary) + summary + .all_jobs() .into_iter() .flat_map(|job| job.steps.iter().map(move |step| (job, step))) .collect() diff --git a/src/inspect/trace.rs b/src/inspect/trace.rs index cd13496f..78ee71f7 100644 --- a/src/inspect/trace.rs +++ b/src/inspect/trace.rs @@ -320,7 +320,9 @@ fn find_runtime_job<'a>(audit: &'a AuditData, ir_job_id: &str) -> Option<&'a Job fn stage_for_job(audit: &AuditData, runtime_job: &JobData) -> Option { let graph = audit.pipeline_graph.as_ref()?; - crate::audit::pipeline_graph::all_jobs(&graph.graph) + graph + .graph + .all_jobs() .into_iter() .find(|job| { crate::audit::pipeline_graph::timeline_name_matches_job( diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index faf4bcc4..a61259eb 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -344,21 +344,12 @@ fn known_ids(summary: &PipelineSummary) -> Vec { .iter() .map(|loc| loc.step.clone()) .collect(); - ids.extend(all_jobs(summary).into_iter().map(|job| job.id.clone())); + ids.extend(summary.all_jobs().into_iter().map(|job| job.id.clone())); ids } -fn all_jobs(summary: &PipelineSummary) -> Vec<&JobSummary> { - match &summary.body { - PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), - PipelineBodySummary::Stages { stages } => { - stages.iter().flat_map(|stage| stage.jobs.iter()).collect() - } - } -} - fn find_job<'a>(summary: &'a PipelineSummary, job_id: &str) -> Option<&'a JobSummary> { - all_jobs(summary).into_iter().find(|job| job.id == job_id) + summary.all_jobs().into_iter().find(|job| job.id == job_id) } fn stage_for_job(summary: &PipelineSummary, job_id: &str) -> Option { diff --git a/src/main.rs b/src/main.rs index e5a9420d..1e36741b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -506,12 +506,15 @@ enum Commands { /// Build ID, or full ADO build URL. build_id_or_url: String, /// Output directory for downloaded artifacts and reports. - /// Defaults to `${TEMP}/ado-aw/audit` so concurrent invocations - /// from the CLI, `ado-aw trace`, and the mcp-author tools all - /// share a single cache root and never scatter `./logs/` - /// directories under arbitrary working directories. - #[arg(short, long)] - output: Option, + /// Defaults to `./logs` (preserved for operator muscle + /// memory and pre-existing scripts). Non-CLI callers — the + /// mcp-author tools and the `ado-aw trace` command — route + /// through `${TEMP}/ado-aw/audit` via + /// `crate::audit::default_cache_root` instead, so they do + /// not silently scatter `./logs/` directories under + /// arbitrary IDE working directories. + #[arg(short, long, default_value = "./logs")] + output: PathBuf, /// Emit the report as JSON to stdout instead of console text. #[arg(long)] json: bool, @@ -1329,10 +1332,9 @@ async fn main() -> Result<()> { artifacts, no_cache, } => { - let resolved_output = output.unwrap_or_else(audit::default_cache_root); audit::dispatch(audit::AuditOptions { build_id_or_url: &build_id_or_url, - output: &resolved_output, + output: &output, json, org: org.as_deref(), project: project.as_deref(), From 04be14ad59bb9b7cb75282867644992e7b187078 Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 22:24:50 +0100 Subject: [PATCH 11/17] fix(inspect,audit): byte-safe is_negated_call + close symlink-bypass in resolve_source_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from PR #998 round 7: - inspect::whatif::is_negated_call: switch from `&str[range]` indexing to byte-slice comparison via `bytes.get(idx - 4..idx) == Some(b"not(")`. The previous form panicked if the four bytes immediately before the matched call straddled a UTF-8 char boundary — possible if a non-ASCII display name leaked into the condition string. New regression test prepends `é` (a two-byte UTF-8 sequence) before `failed()` and asserts the classifier returns RunsAnyway instead of panicking. - audit::pipeline_graph::resolve_source_path: a symlink at `/tmp/foo.md` → `/etc/passwd` lexically satisfied the `.md` extension check and was passed through unguarded. We now canonicalize absolute paths after the extension check and reject them if the resolved target does not also end in `.md`, closing the symlink-bypass vector. Legitimate `current.md` → `v1.md` style symlinks remain accepted because the resolved target still ends in `.md`. A new `has_md_extension` helper factors out the shared extension test. Two new unix-only regression tests cover both the rejected-symlink and the accepted-symlink cases. The security doc-comment now explicitly calls out the symlink-target re-check so future maintainers do not weaken it. Validated locally: cargo build, cargo test --bin ado-aw (1888 unit tests pass on Windows; +2 unix-only symlink tests compile behind cfg(unix)), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/pipeline_graph.rs | 90 ++++++++++++++++++++++++++++++++----- src/inspect/whatif.rs | 43 +++++++++++++++++- 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index e2bff707..445e61eb 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -168,7 +168,12 @@ async fn read_source_from_aw_info(run_dir: &Path) -> Option> { /// - Rejecting relative paths that contain `..` components or a /// leading `~` (no directory traversal, no shell-style expansion). /// - Allowing absolute `.md` paths because legitimate compiled- -/// elsewhere workflows commonly carry an absolute `source`. +/// elsewhere workflows commonly carry an absolute `source`. For +/// absolute paths that exist on disk we additionally canonicalize +/// and **re-check the extension on the resolved target**, which +/// rejects a symlink-bypass such as `/tmp/foo.md` → `/etc/passwd` +/// where the link itself satisfies the lexical `.md` check but +/// points to an unrelated file. /// /// ## Residual risk /// @@ -180,19 +185,17 @@ async fn read_source_from_aw_info(run_dir: &Path) -> Option> { /// markdown, the practical blast radius is an unexpected parse error /// surfaced in the audit warnings — not code execution or /// credential exfiltration. **Do not weaken or remove the `.md` -/// extension check** without also adding a containment check (e.g. -/// canonicalize + prefix-against-cwd) at the same level; without it -/// any future maintainer would silently re-open the -/// arbitrary-file-read vector. +/// extension check or the symlink-target re-check** without also +/// adding a containment check (e.g. canonicalize + prefix-against- +/// cwd) at the same level; without them any future maintainer would +/// silently re-open the arbitrary-file-read vector via either a +/// non-markdown extension or a `.md` symlink targeting an arbitrary +/// file. async fn resolve_source_path(source: &str) -> Result { let normalized = normalize_source_path(source); let path = PathBuf::from(&normalized); - let has_md_extension = path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); - if !has_md_extension { + if !has_md_extension(&path) { anyhow::bail!( "refusing source path '{}' from audited build artifact: only `.md` files are valid agentic workflow sources", normalized @@ -200,6 +203,22 @@ async fn resolve_source_path(source: &str) -> Result { } if path.is_absolute() { + // If the file exists, resolve symlinks and re-check the + // extension on the actual target. Closes the + // `/tmp/foo.md → /etc/passwd` symlink-bypass vector. We + // intentionally tolerate `canonicalize` failing (the path may + // not exist locally — the caller upstream emits a warning in + // that case) and only enforce the re-check when the link + // resolved successfully. + if let Ok(canonical) = tokio::fs::canonicalize(&path).await + && !has_md_extension(&canonical) + { + anyhow::bail!( + "refusing source path '{}' from audited build artifact: symlink resolves to non-`.md` target '{}'", + normalized, + canonical.display() + ); + } return Ok(path); } @@ -220,6 +239,12 @@ async fn resolve_source_path(source: &str) -> Result { Ok(cwd.join(path)) } +fn has_md_extension(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) +} + fn normalize_source_path(source: &str) -> String { let trimmed = source.trim(); if std::path::MAIN_SEPARATOR == '/' { @@ -358,6 +383,51 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn resolve_source_path_rejects_md_symlink_to_non_md_target() { + // Symlink-bypass regression: `foo.md` → `/etc/passwd` lexically + // satisfies the `.md` extension check but resolves to a + // non-markdown file. The post-canonicalize re-check must + // reject it. + let temp_dir = tempfile::tempdir().expect("tempdir"); + let target = temp_dir.path().join("not_markdown.bin"); + tokio::fs::write(&target, b"binary").await.expect("write target"); + let link = temp_dir.path().join("evil.md"); + tokio::fs::symlink(&target, &link) + .await + .expect("create symlink"); + + let err = resolve_source_path(link.to_str().unwrap()) + .await + .expect_err("symlink to non-md target must be rejected"); + let msg = format!("{err:#}"); + assert!( + msg.contains("symlink resolves to non-`.md` target"), + "expected symlink-target rejection message, got: {msg}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn resolve_source_path_accepts_md_symlink_to_md_target() { + // Legitimate `current.md` → `v1.md` style symlinks must still + // be accepted — the post-canonicalize re-check only rejects + // when the resolved target lacks the `.md` extension. + let temp_dir = tempfile::tempdir().expect("tempdir"); + let target = temp_dir.path().join("v1.md"); + tokio::fs::write(&target, b"# pipeline").await.expect("write target"); + let link = temp_dir.path().join("current.md"); + tokio::fs::symlink(&target, &link) + .await + .expect("create symlink"); + + let resolved = resolve_source_path(link.to_str().unwrap()) + .await + .expect("md symlink to md target must be accepted"); + assert_eq!(resolved, link); + } + #[tokio::test] async fn populate_pipeline_graph_records_warning_on_malicious_source() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index a61259eb..d6aa7924 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -298,12 +298,21 @@ fn is_word_boundary_before(s: &str, idx: usize) -> bool { } fn is_negated_call(normalized_condition: &str, call_idx: usize) -> bool { - const NOT_PREFIX: &str = "not("; + // Compare via the underlying byte slice instead of + // `normalized_condition[idx - NOT_PREFIX_LEN..idx]` so that a + // multi-byte UTF-8 sequence ending immediately before the call + // cannot land on a non-char-boundary and panic. `call_idx` is on a + // boundary (it comes from `str::find`) but `idx - 4` is not + // guaranteed to be. ADO normally emits ASCII-only conditions, but + // a leaked display name could carry non-ASCII bytes — this keeps + // us crash-safe regardless. + const NOT_PREFIX: &[u8] = b"not("; const NOT_PREFIX_LEN: usize = NOT_PREFIX.len(); + let bytes = normalized_condition.as_bytes(); let mut idx = call_idx; let mut negated = false; while idx >= NOT_PREFIX_LEN - && normalized_condition[idx - NOT_PREFIX_LEN..idx] == *NOT_PREFIX + && bytes.get(idx - NOT_PREFIX_LEN..idx) == Some(NOT_PREFIX) { negated = !negated; idx -= NOT_PREFIX_LEN; @@ -605,4 +614,34 @@ mod tests { .unwrap(); assert_eq!(detection.classification, WhatIfClassification::Skipped); } + + #[test] + fn classifier_does_not_panic_on_multibyte_chars_adjacent_to_call() { + // Regression: the old `is_negated_call` indexed the str + // directly with `[idx - 4..idx]`, which panics if the four + // bytes before `failed()` straddle a UTF-8 char boundary. An + // emoji / accented character leaked into a display-name + // segment of the condition could trigger that crash. + // + // The accented `é` is two bytes (0xC3 0xA9), so prepending it + // makes the offset before `failed()` land *inside* the + // multi-byte sequence. The byte-slice comparison must handle + // that gracefully and not match `not(`. + let mut summary = fixture(None); + let PipelineBodySummary::Jobs { jobs } = &mut summary.body else { + unreachable!("fixture uses jobs body"); + }; + let detection = jobs.iter_mut().find(|job| job.id == "Detection").unwrap(); + detection.condition = Some("éfailed()".to_string()); + + let report = analyze(&summary, "Setup").expect("must not panic on multi-byte input"); + let detection = report + .downstream_jobs + .iter() + .find(|job| job.job == "Detection") + .unwrap(); + // `é` is not part of `not(`, so the call is treated as + // un-negated and the job classifies as RunsAnyway. + assert_eq!(detection.classification, WhatIfClassification::RunsAnyway); + } } From 21ae00d00e04920390eb224ea81bff701939b5fa Mon Sep 17 00:00:00 2001 From: James Devine Date: Sun, 14 Jun 2026 22:51:51 +0100 Subject: [PATCH 12/17] fix(inspect,audit,mcp-author,ir): close same-job unused-output false positive + zero-alloc all_jobs + JSON graph_dump + cache-hit doc Bugs: - inspect::lint::consumed_outputs: union the set of outputs flagged by `graph.outputs_needing_is_output` with every step's `env_refs` and `condition_refs`. Same-job consumers do not appear in `outputs_needing_is_output` (ADO does not require isOutput=true for them), and the previous logic mis-classified those outputs as unused. Two new regression tests pin the fix for both env_ref and condition_ref same-job consumers; existing cross-job and fixture tests remain green. Suggestions: - mcp-author `graph_dump`: now accepts `"json"` alongside `"text"` and `"dot"`, matching the CLI's `graph` subcommand. GraphDumpParams docstring + tool description updated; the error message lists all three formats. - audit::cli::derive_post_processing: doc-comment now explicitly records that cache-hit invocations correlate against the **current local source markdown** by design, so a future maintainer does not "fix" it into using a stale cached graph snapshot (the downstream findings rules depend on the fresh correlation). - compile::ir::summary::PipelineSummary::all_jobs: switch the return type from `Vec<&JobSummary>` to `impl Iterator` via a small `AllJobsIter` either-enum, removing the per-call heap allocation on the hot paths (`populate_job_edges`, `find_matching_job_summary`, the inspect traversals). All callers already chained `.into_iter()`; the redundant calls were dropped via `cargo clippy --fix`. Validated locally: cargo build, cargo test (1890 unit tests + every integration suite pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/cli.rs | 12 ++++++++ src/compile/ir/summary.rs | 51 ++++++++++++++++++++++++++----- src/inspect/lint.rs | 64 ++++++++++++++++++++++++++++++++++++--- src/inspect/whatif.rs | 2 +- src/mcp_author/mod.rs | 7 +++-- 5 files changed, 119 insertions(+), 17 deletions(-) diff --git a/src/audit/cli.rs b/src/audit/cli.rs index 0df625ab..5359ae54 100644 --- a/src/audit/cli.rs +++ b/src/audit/cli.rs @@ -208,6 +208,18 @@ async fn fetch_audit_data_inner(opts: AuditOptions<'_>) -> Result Vec<&JobSummary> { + /// Single source of truth for body-shape iteration; both + /// `audit::pipeline_graph` and the `inspect` commands go through + /// this so that future shape additions (e.g. a new `Templates` + /// variant) only need to be handled in one place. + /// + /// Returns an `impl Iterator` rather than a `Vec` so hot paths + /// (`populate_job_edges`, `find_matching_job_summary`, the inspect + /// traversals) avoid a per-call heap allocation. Callers that + /// need a slice can `.collect::>()` at the use site. + pub fn all_jobs(&self) -> impl Iterator + '_ { match &self.body { - PipelineBodySummary::Jobs { jobs } => jobs.iter().collect(), + PipelineBodySummary::Jobs { jobs } => AllJobsIter::Flat(jobs.iter()), PipelineBodySummary::Stages { stages } => { - stages.iter().flat_map(|stage| stage.jobs.iter()).collect() + AllJobsIter::Stages(stages.iter().flat_map(stage_jobs)) } } } } +fn stage_jobs(stage: &StageSummary) -> std::slice::Iter<'_, JobSummary> { + stage.jobs.iter() +} + +/// Either-style iterator that yields the same `&JobSummary` element type +/// for both pipeline body shapes without heap-allocating into a `Vec`. +#[allow(clippy::type_complexity)] +enum AllJobsIter<'a> { + Flat(std::slice::Iter<'a, JobSummary>), + Stages( + std::iter::FlatMap< + std::slice::Iter<'a, StageSummary>, + std::slice::Iter<'a, JobSummary>, + fn(&'a StageSummary) -> std::slice::Iter<'a, JobSummary>, + >, + ), +} + +impl<'a> Iterator for AllJobsIter<'a> { + type Item = &'a JobSummary; + + fn next(&mut self) -> Option { + match self { + Self::Flat(iter) => iter.next(), + Self::Stages(iter) => iter.next(), + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StageSummary { pub id: String, diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs index cb7b77ec..d59012b9 100644 --- a/src/inspect/lint.rs +++ b/src/inspect/lint.rs @@ -254,7 +254,15 @@ fn rule_step_id_collisions(summary: &PipelineSummary, findings: &mut Vec BTreeSet<(String, String)> { - summary + // Cross-step / cross-job consumers are surfaced through + // `outputs_needing_is_output` (the set the compiler patches with + // `isOutput=true`). That set deliberately omits same-job consumers + // because ADO does not require `isOutput=true` for those, so we + // additionally walk every step's `env_refs` and `condition_refs` + // to count references that stay inside one job. Matches + // `graph_deps::step_refs`, which already treats both sets + // uniformly regardless of job boundary. + let mut consumed: BTreeSet<(String, String)> = summary .graph .outputs_needing_is_output .iter() @@ -264,7 +272,13 @@ fn consumed_outputs(summary: &PipelineSummary) -> BTreeSet<(String, String)> { .iter() .map(|output| (entry.step.clone(), output.clone())) }) - .collect() + .collect(); + for (_, step) in all_steps(summary) { + for r in step.env_refs.iter().chain(step.condition_refs.iter()) { + consumed.insert((r.step.clone(), r.name.clone())); + } + } + consumed } fn output_declarations( @@ -291,7 +305,6 @@ fn output_declarations( fn all_steps(summary: &PipelineSummary) -> Vec<(&JobSummary, &StepSummary)> { summary .all_jobs() - .into_iter() .flat_map(|job| job.steps.iter().map(move |step| (job, step))) .collect() } @@ -308,8 +321,8 @@ fn location_for(job: &JobSummary, step: Option<&str>) -> LintLocation { mod tests { use super::*; use crate::compile::ir::summary::{ - GraphSummary, OutputDeclSummary, PipelineBodySummary, PoolSummary, StepKind, - StepOutputsEntry, + GraphSummary, OutputDeclSummary, OutputRefSummary, PipelineBodySummary, PoolSummary, + StepKind, StepOutputsEntry, }; #[test] @@ -343,6 +356,47 @@ mod tests { assert!(!findings.iter().any(|f| f.code == "unused-output")); } + #[test] + fn same_job_env_ref_does_not_emit_unused_output_inspect_lint() { + // Regression: outputs consumed by a peer step **within the + // same job** (via env_refs / condition_refs) do not appear in + // graph.outputs_needing_is_output — ADO does not require + // isOutput=true for same-job reads. consumed_outputs must + // still treat them as consumed so we do not emit a + // false-positive `unused-output` finding. + let mut producer = step_with_output("producer", "value", false); + producer.id = Some("producer".to_string()); + let mut consumer = plain_step("consumer"); + consumer.env_refs.push(OutputRefSummary { + step: "producer".to_string(), + name: "value".to_string(), + }); + + let summary = summary_with_steps(vec![producer, consumer], vec![]); + let findings = lint(&summary); + assert!( + !findings.iter().any(|f| f.code == "unused-output"), + "same-job env_ref consumer must suppress unused-output, got {findings:?}" + ); + } + + #[test] + fn same_job_condition_ref_does_not_emit_unused_output_inspect_lint() { + let producer = step_with_output("producer", "value", false); + let mut consumer = plain_step("consumer"); + consumer.condition_refs.push(OutputRefSummary { + step: "producer".to_string(), + name: "value".to_string(), + }); + + let summary = summary_with_steps(vec![producer, consumer], vec![]); + let findings = lint(&summary); + assert!( + !findings.iter().any(|f| f.code == "unused-output"), + "same-job condition_ref consumer must suppress unused-output, got {findings:?}" + ); + } + #[tokio::test] async fn create_pull_request_fixture_has_no_unused_output_inspect_lint() { let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index d6aa7924..9383838a 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -353,7 +353,7 @@ fn known_ids(summary: &PipelineSummary) -> Vec { .iter() .map(|loc| loc.step.clone()) .collect(); - ids.extend(summary.all_jobs().into_iter().map(|job| job.id.clone())); + ids.extend(summary.all_jobs().map(|job| job.id.clone())); ids } diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index 220db8a6..b1d63c04 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -39,7 +39,7 @@ struct SourcePathParams { struct GraphDumpParams { /// Path to the source markdown workflow file. source_path: String, - /// Render format: "text" (default) or "dot". + /// Render format: "text" (default), "json", or "dot". format: Option, } @@ -185,7 +185,7 @@ impl AuthorMcp { #[tool( name = "graph_dump", - description = "Render the resolved workflow graph as text or Graphviz DOT." + description = "Render the resolved workflow graph as text, JSON (GraphSummary), or Graphviz DOT." )] async fn graph_dump( &self, @@ -375,9 +375,10 @@ fn source_path(path: &str) -> PathBuf { fn parse_graph_dump_format(format: Option<&str>) -> Result { match format.unwrap_or("text") { "text" => Ok(GraphFormat::Text), + "json" => Ok(GraphFormat::Json), "dot" => Ok(GraphFormat::Dot), other => Err(McpError::invalid_params( - format!("unknown format '{other}' (expected 'text' or 'dot')"), + format!("unknown format '{other}' (expected 'text', 'json', or 'dot')"), None, )), } From 216cc6e277dd982a2b968bb06b157c6436a2910a Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 15 Jun 2026 10:39:57 +0100 Subject: [PATCH 13/17] fix(mcp-author,inspect): validate source_path + unwrap graph_dump JSON + perf nits Security: - mcp_author::source_path: was previously `PathBuf::from(path)` with zero validation, so a prompt-injected MCP request such as `inspect_workflow(source_path="../../.ssh/authorized_keys.md")` would reach build_pipeline_ir and read the file. Now mirrors the guards already in `audit::pipeline_graph::resolve_source_path`: require a `.md` extension, reject `..` components and `~` prefix, and canonicalize absolute paths to re-check the extension on the symlink target. Function is now async (`tokio::fs::canonicalize`); all seven call sites were updated to `source_path(&...).await?`. Four new regression tests cover the rejection cases (non-md, `..`, `~`) plus the accepted relative `.md` case. Bugs: - mcp_author::graph_dump: `format = "json"` previously routed through build_graph_dump(Json), which returns a JSON *string*. Wrapping that in `GraphDumpResult { text_or_dot: String }` produced `{"text_or_dot": ""}` and forced callers to parse the inner JSON twice. The json format now short-circuits to build_graph_summary so callers receive a structured GraphSummary object. A new regression test decodes the result as GraphSummary to pin the contract. Suggestions: - inspect::whatif::levenshtein: cache `b.chars().count()` once instead of scanning twice (allocate + final index). - audit::pipeline_graph::find_matching_job_summary, inspect::trace::stage_for_job, inspect::whatif::find_job: drop the redundant `.into_iter()` calls left over from the Vec-to-Iterator migration in `PipelineSummary::all_jobs`. Validated locally: cargo build, cargo test (1895 unit tests pass + all integration suites), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/pipeline_graph.rs | 1 - src/inspect/trace.rs | 1 - src/inspect/whatif.rs | 7 +-- src/mcp_author/mod.rs | 104 ++++++++++++++++++++++++++++++++---- src/mcp_author/tests.rs | 64 ++++++++++++++++++++++ 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index 445e61eb..07646bda 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -99,7 +99,6 @@ fn find_matching_job_summary<'a>( ) -> Option<&'a JobSummary> { summary .all_jobs() - .into_iter() .find(|job| timeline_name_matches_job(timeline_name, &job.id, job.stage.as_deref())) } diff --git a/src/inspect/trace.rs b/src/inspect/trace.rs index 78ee71f7..00f95232 100644 --- a/src/inspect/trace.rs +++ b/src/inspect/trace.rs @@ -323,7 +323,6 @@ fn stage_for_job(audit: &AuditData, runtime_job: &JobData) -> Option { graph .graph .all_jobs() - .into_iter() .find(|job| { crate::audit::pipeline_graph::timeline_name_matches_job( &runtime_job.name, diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 9383838a..420fccf4 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -358,7 +358,7 @@ fn known_ids(summary: &PipelineSummary) -> Vec { } fn find_job<'a>(summary: &'a PipelineSummary, job_id: &str) -> Option<&'a JobSummary> { - summary.all_jobs().into_iter().find(|job| job.id == job_id) + summary.all_jobs().find(|job| job.id == job_id) } fn stage_for_job(summary: &PipelineSummary, job_id: &str) -> Option { @@ -391,7 +391,8 @@ fn closest<'a>(needle: &str, candidates: impl Iterator) -> Optio } fn levenshtein(a: &str, b: &str) -> usize { - let mut prev: Vec = (0..=b.chars().count()).collect(); + let b_len = b.chars().count(); + let mut prev: Vec = (0..=b_len).collect(); for (i, ca) in a.chars().enumerate() { let mut curr = vec![i + 1]; for (j, cb) in b.chars().enumerate() { @@ -400,7 +401,7 @@ fn levenshtein(a: &str, b: &str) -> usize { } prev = curr; } - prev[b.chars().count()] + prev[b_len] } #[cfg(test)] diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index b1d63c04..442f7016 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -161,7 +161,7 @@ impl AuthorMcp { &self, params: Parameters, ) -> Result { - let source = source_path(¶ms.0.source_path); + let source = source_path(¶ms.0.source_path).await?; let summary = inspect::build_inspect(&source) .await .map_err(to_mcp_error)?; @@ -176,7 +176,7 @@ impl AuthorMcp { &self, params: Parameters, ) -> Result { - let source = source_path(¶ms.0.source_path); + let source = source_path(¶ms.0.source_path).await?; let graph = inspect::build_graph_summary(&source) .await .map_err(to_mcp_error)?; @@ -192,7 +192,20 @@ impl AuthorMcp { params: Parameters, ) -> Result { let format = parse_graph_dump_format(params.0.format.as_deref())?; - let source = source_path(¶ms.0.source_path); + let source = source_path(¶ms.0.source_path).await?; + // Route `format = "json"` through `build_graph_summary` so the + // MCP caller receives a structured GraphSummary object rather + // than `{ "text_or_dot": "" }`. The + // `build_graph_dump(Json)` codepath produces a JSON *string* + // intended for the CLI's stdout; wrapping that in + // GraphDumpResult would force callers to parse the inner JSON + // a second time. + if format == GraphFormat::Json { + let graph = inspect::build_graph_summary(&source) + .await + .map_err(to_mcp_error)?; + return structured_result(graph); + } let text_or_dot = inspect::build_graph_dump(&source, format) .await .map_err(to_mcp_error)?; @@ -208,7 +221,7 @@ impl AuthorMcp { params: Parameters, ) -> Result { let direction = parse_graph_deps_direction(¶ms.0.direction)?; - let source = source_path(¶ms.0.source_path); + let source = source_path(¶ms.0.source_path).await?; let report = inspect::build_graph_deps(&source, ¶ms.0.step_id, direction) .await .map_err(to_mcp_error)?; @@ -223,7 +236,7 @@ impl AuthorMcp { &self, params: Parameters, ) -> Result { - let source = source_path(¶ms.0.source_path); + let source = source_path(¶ms.0.source_path).await?; let edges = inspect::build_graph_outputs( &source, params.0.producer.as_deref(), @@ -262,7 +275,7 @@ impl AuthorMcp { description = "Classify downstream jobs that would skip if a step or job failed." )] async fn whatif(&self, params: Parameters) -> Result { - let source = source_path(¶ms.0.source_path); + let source = source_path(¶ms.0.source_path).await?; let report = inspect::build_whatif(&source, ¶ms.0.failing_id) .await .map_err(to_mcp_error)?; @@ -277,7 +290,7 @@ impl AuthorMcp { &self, params: Parameters, ) -> Result { - let source = source_path(¶ms.0.source_path); + let source = source_path(¶ms.0.source_path).await?; let report = inspect::build_lint(&source).await.map_err(to_mcp_error)?; structured_result(report) } @@ -368,8 +381,81 @@ pub async fn run_stdio() -> Result<()> { Ok(()) } -fn source_path(path: &str) -> PathBuf { - PathBuf::from(path) +/// Validate a caller-supplied `source_path` MCP-tool parameter and +/// resolve it to a `PathBuf` suitable for passing to +/// `crate::compile::build_pipeline_ir`. +/// +/// **Security**: the mcp-author server runs in an IDE/Copilot Chat +/// context where the surrounding agent may be processing +/// untrusted content (PR descriptions, issue comments, fetched web +/// pages). A prompt-injected request such as +/// `inspect_workflow(source_path="../../.ssh/authorized_keys.md")` +/// would otherwise reach `build_pipeline_ir`, open the file, and +/// surface the path in the parse-error message. +/// +/// We apply the same guards as +/// [`crate::audit::pipeline_graph::resolve_source_path`]: +/// +/// - Require a `.md` extension (the only valid agentic workflow +/// source extension); closes the arbitrary-file-read vector +/// against keys, `/etc/passwd`, etc. +/// - Reject relative paths that contain `..` components or a leading +/// `~` (no directory traversal, no shell-style expansion). +/// - For absolute paths, canonicalize and re-check the extension on +/// the resolved target so a `foo.md → /etc/passwd` symlink does +/// not satisfy the lexical check. +async fn source_path(path: &str) -> Result { + let trimmed = path.trim(); + let buf = PathBuf::from(trimmed); + + let has_md_extension = buf + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); + if !has_md_extension { + return Err(McpError::invalid_params( + format!( + "refusing source_path '{trimmed}': only `.md` files are valid agentic workflow sources" + ), + None, + )); + } + + if buf.is_absolute() { + // Resolve symlinks and re-check the extension on the target so a + // `foo.md` link pointing at an arbitrary file is rejected. We + // tolerate canonicalize failing (file may not exist locally — + // build_pipeline_ir will then surface a clean read error). + if let Ok(canonical) = tokio::fs::canonicalize(&buf).await { + let target_has_md = canonical + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); + if !target_has_md { + return Err(McpError::invalid_params( + format!( + "refusing source_path '{trimmed}': symlink resolves to non-`.md` target '{}'", + canonical.display() + ), + None, + )); + } + } + return Ok(buf); + } + + if buf + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + || trimmed.starts_with('~') + { + return Err(McpError::invalid_params( + format!("refusing suspicious relative source_path '{trimmed}'"), + None, + )); + } + + Ok(buf) } fn parse_graph_dump_format(format: Option<&str>) -> Result { diff --git a/src/mcp_author/tests.rs b/src/mcp_author/tests.rs index 359b14a7..b051590f 100644 --- a/src/mcp_author/tests.rs +++ b/src/mcp_author/tests.rs @@ -81,3 +81,67 @@ async fn graph_summary_and_lint_workflow_smoke_fixture() { .expect("lint_workflow returns LintReport"); assert_eq!(lint.summary.errors, 0); } + +#[tokio::test] +async fn source_path_rejects_non_markdown_extension() { + // Prompt-injected request would otherwise reach build_pipeline_ir + // and read the file; source_path must refuse before that happens. + let err = source_path("/etc/passwd") + .await + .expect_err("non-md path must be rejected"); + assert!( + format!("{err}").contains("only `.md`"), + "expected non-md rejection message, got: {err}" + ); +} + +#[tokio::test] +async fn source_path_rejects_parent_traversal() { + let err = source_path("../../.ssh/authorized_keys.md") + .await + .expect_err("parent traversal must be rejected"); + assert!( + format!("{err}").contains("suspicious relative source_path"), + "expected traversal rejection message, got: {err}" + ); +} + +#[tokio::test] +async fn source_path_rejects_tilde_prefix() { + let err = source_path("~/private.md") + .await + .expect_err("tilde prefix must be rejected"); + assert!( + format!("{err}").contains("suspicious relative source_path"), + "expected tilde rejection message, got: {err}" + ); +} + +#[tokio::test] +async fn source_path_accepts_legitimate_relative_md() { + source_path("workflows/foo.md") + .await + .expect("plain relative .md path must be accepted"); +} + +#[tokio::test] +async fn graph_dump_json_returns_structured_graph_not_escaped_string() { + // Regression: previously the json format went through + // build_graph_dump(Json) which returns a serialized string; + // wrapping that in GraphDumpResult { text_or_dot: String } + // forced callers to parse the inner JSON twice. Now the json + // format short-circuits to the structured GraphSummary. + let server = AuthorMcp::new(); + let result = server + .graph_dump(Parameters(GraphDumpParams { + source_path: fixture_path(), + format: Some("json".to_string()), + })) + .await + .expect("graph_dump(json) succeeds"); + + let graph = result + .into_typed::() + .expect("graph_dump(json) must return GraphSummary, not GraphDumpResult"); + assert!(!graph.step_locations.is_empty()); +} From d1fd3a0aa054fd769a608ed29bf37671205c4d44 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 15 Jun 2026 11:08:45 +0100 Subject: [PATCH 14/17] refactor(audit,inspect,main): gate downstream-impact finding + rename .graph.graph + clap value-enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #998 round 10: Bugs: - audit::findings::add_downstream_impact_findings: filter the finding to only fire when at least one downstream job actually skipped or was cancelled (or was absent from the runtime timeline, which signals an expected skip). Previously the rule fired whenever an upstream failed and had any IR-derived downstream — including cleanup jobs with always() that successfully ran. Title also reworded to "potentially impacted by {} failure" so even with the gate in place we do not categorically assert "skipped" for jobs that may have bypassed via always(). Recommendation reason updated to match. New `is_skipped_or_cancelled` helper handles US/UK spelling. New regression test pins the bypass-suppression behaviour; the existing test was updated to match the new title. Suggestions: - audit::model::PipelineGraphSection: rename field `graph: PipelineSummary` to `summary: PipelineSummary` so trace.rs no longer reads `.graph.graph.step_locations`. Callers updated (`pipeline_graph.rs`, `trace.rs`), and the same-named doc in `docs/audit.md` switched from `pipeline_graph.graph` to `pipeline_graph.summary`. - main::GraphCmd::Dump/Deps: switch `--format` and `--direction` from `String` + manual match to `clap::ValueEnum` derives on `GraphFormat` / `GraphDepsDirection`. Invalid values are now caught at parse time with clap's standard "possible values" help text instead of a runtime `anyhow::bail!`. Existing integration test updated to match the new clap rejection message. Validated locally: cargo build, cargo test (1896 unit tests + all integration suites pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/audit.md | 2 +- src/audit/findings.rs | 79 ++++++++++++++++++++++++++++++++++-- src/audit/model.rs | 2 +- src/audit/pipeline_graph.rs | 2 +- src/inspect/cli.rs | 3 +- src/inspect/graph_deps.rs | 3 +- src/inspect/trace.rs | 24 ++++++----- src/main.rs | 25 +++--------- tests/inspect_integration.rs | 6 ++- 9 files changed, 106 insertions(+), 40 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index 89fdca1b..7e52944e 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -117,7 +117,7 @@ After the standard analyzers run, `audit` looks for top level) and resolves its `source` path relative to the current working directory. If that markdown source exists locally, the command rebuilds the typed IR with the same public summary shape emitted by `ado-aw inspect --json` -and stores it under `pipeline_graph.graph`. The audit embeds the full +and stores it under `pipeline_graph.summary`. The audit embeds the full `PipelineSummary` rather than a reduced subset so audit, inspect, graph, and trace consumers share one schema. diff --git a/src/audit/findings.rs b/src/audit/findings.rs index 4fab2eff..827fd442 100644 --- a/src/audit/findings.rs +++ b/src/audit/findings.rs @@ -374,6 +374,27 @@ fn add_downstream_impact_findings( continue; } + // Filter to downstream jobs that actually skipped (or were + // absent from the timeline, which also signals an expected + // skip). Jobs with bypass conditions like `always()` would + // still appear in `job.downstream_jobs` because that field is + // populated from typed-IR edges; without this gate we would + // emit "Downstream jobs skipped" findings even for cleanup + // jobs that successfully ran through the failure. + let any_actually_skipped = job.downstream_jobs.iter().any(|downstream_id| { + audit + .jobs + .iter() + .find(|candidate| candidate.matches_ir_id(downstream_id)) + .map(is_skipped_or_cancelled) + // Absent from runtime timeline → typed-IR expected it + // to skip after the upstream failure. + .unwrap_or(true) + }); + if !any_actually_skipped { + continue; + } + let downstream = job .downstream_jobs .iter() @@ -394,7 +415,12 @@ fn add_downstream_impact_findings( Finding { category: String::from("pipeline_graph"), severity: Severity::Medium, - title: format!("Downstream jobs skipped due to {} failure", job.name), + // Title intentionally says "potentially impacted" rather + // than "skipped": even with the `any_actually_skipped` + // gate above, some downstream jobs in this set may have + // bypassed the failure (e.g. via `always()`). The + // description embeds the real per-job classification. + title: format!("Downstream jobs potentially impacted by {} failure", job.name), description: format!( "The typed pipeline graph shows downstream impact from {}: {}.", job.name, downstream @@ -412,7 +438,7 @@ fn add_downstream_impact_findings( job.name ), reason: format!( - "{} failed, which skipped {} downstream job(s).", + "{} failed, which impacted {} downstream job(s).", job.name, job.downstream_jobs.len() ), @@ -422,6 +448,14 @@ fn add_downstream_impact_findings( } } +fn is_skipped_or_cancelled(job: &JobData) -> bool { + let result = job.result.as_deref().unwrap_or_default(); + result.eq_ignore_ascii_case("skipped") + || result.eq_ignore_ascii_case("canceled") + || result.eq_ignore_ascii_case("cancelled") + || job.status.eq_ignore_ascii_case("skipped") +} + fn push_finding(findings: &mut Vec, finding: Finding) { if !findings.contains(&finding) { findings.push(finding); @@ -743,7 +777,8 @@ mod tests { derive_findings(&mut audit); - let finding = finding_by_title(&audit, "Downstream jobs skipped due to Agent failure"); + let finding = + finding_by_title(&audit, "Downstream jobs potentially impacted by Agent failure"); assert_eq!(finding.severity, Severity::Medium); assert!(finding.description.contains("Detection: skipped")); assert!( @@ -753,6 +788,44 @@ mod tests { ); } + #[test] + fn downstream_impact_rule_suppresses_when_all_downstream_jobs_ran_via_bypass() { + // Regression: previously the rule fired whenever an upstream + // job failed and had any IR-derived downstream — even when + // every downstream job successfully ran via an always() + // bypass. The "skipped" wording was then a lie. With the + // any_actually_skipped gate the finding is suppressed. + let mut audit = AuditData { + jobs: vec![ + JobData { + name: String::from("Agent"), + status: String::from("completed"), + result: Some(String::from("failed")), + downstream_jobs: vec![String::from("Cleanup")], + ..Default::default() + }, + JobData { + name: String::from("Cleanup"), + status: String::from("completed"), + result: Some(String::from("succeeded")), + ..Default::default() + }, + ], + ..Default::default() + }; + + derive_findings(&mut audit); + + assert!( + !audit + .key_findings + .iter() + .any(|f| f.title.contains("Downstream jobs potentially impacted")), + "must not emit downstream-impact finding when every downstream succeeded, got {:?}", + audit.key_findings + ); + } + #[test] fn combined_findings_are_appended_and_preserved_across_passes() { let mut audit = AuditData { diff --git a/src/audit/model.rs b/src/audit/model.rs index 5c469fa5..07b96931 100644 --- a/src/audit/model.rs +++ b/src/audit/model.rs @@ -312,7 +312,7 @@ pub struct PipelineGraphSection { #[serde(default, skip_serializing_if = "String::is_empty")] pub source_path: String, /// Full public pipeline summary, matching `ado-aw inspect --json`. - pub graph: PipelineSummary, + pub summary: PipelineSummary, } /// Job-level status information for one stage in the build timeline. diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index 07646bda..c200039d 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -65,7 +65,7 @@ pub async fn populate_pipeline_graph(audit: &mut AuditData, run_dir: &Path) -> R populate_job_edges(audit, &summary); audit.pipeline_graph = Some(PipelineGraphSection { source_path: resolved_source_path.display().to_string(), - graph: summary, + summary, }); Ok(()) } diff --git a/src/inspect/cli.rs b/src/inspect/cli.rs index deb3ff0d..e330eeda 100644 --- a/src/inspect/cli.rs +++ b/src/inspect/cli.rs @@ -52,7 +52,8 @@ pub async fn build_inspect(source: &Path) -> Result { } /// Output format selector for `ado-aw graph`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +#[clap(rename_all = "lower")] pub enum GraphFormat { Text, Json, diff --git a/src/inspect/graph_deps.rs b/src/inspect/graph_deps.rs index ab49a480..62b2615c 100644 --- a/src/inspect/graph_deps.rs +++ b/src/inspect/graph_deps.rs @@ -17,7 +17,8 @@ use crate::compile::ir::summary::{ }; /// Traversal direction for `ado-aw graph deps`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +#[clap(rename_all = "lower")] pub enum GraphDepsDirection { /// Walk producer-side dependencies. Upstream, diff --git a/src/inspect/trace.rs b/src/inspect/trace.rs index 00f95232..22a7f335 100644 --- a/src/inspect/trace.rs +++ b/src/inspect/trace.rs @@ -195,9 +195,9 @@ fn render_step_dependencies(label: &str, steps: &[StepDependency], out: &mut Str } fn build_step_report(audit: &AuditData, step_id: &str) -> Option { - let graph = audit.pipeline_graph.as_ref()?; - let location = graph - .graph + let pipeline_graph = audit.pipeline_graph.as_ref()?; + let location = pipeline_graph + .summary .graph .step_locations .iter() @@ -218,11 +218,15 @@ fn build_step_report(audit: &AuditData, step_id: &str) -> Option(audit: &'a AuditData, ir_job_id: &str) -> Option<&'a Job } fn stage_for_job(audit: &AuditData, runtime_job: &JobData) -> Option { - let graph = audit.pipeline_graph.as_ref()?; - graph - .graph + let pipeline_graph = audit.pipeline_graph.as_ref()?; + pipeline_graph + .summary .all_jobs() .find(|job| { crate::audit::pipeline_graph::timeline_name_matches_job( diff --git a/src/main.rs b/src/main.rs index 1e36741b..fb14830c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -166,8 +166,8 @@ enum GraphCmd { /// Path to the agent markdown source. source: PathBuf, /// Output format: `text` (default), `json`, or `dot` (Graphviz). - #[arg(long, default_value = "text")] - format: String, + #[arg(long, value_enum, default_value_t = inspect::GraphFormat::Text)] + format: inspect::GraphFormat, }, /// Traverse dependencies for one named step. Deps { @@ -176,8 +176,8 @@ enum GraphCmd { /// Step id to traverse from. step: String, /// Traversal direction: `upstream` (default) or `downstream`. - #[arg(long, default_value = "upstream")] - direction: String, + #[arg(long, value_enum, default_value_t = inspect::GraphDepsDirection::Upstream)] + direction: inspect::GraphDepsDirection, /// Emit machine-readable JSON. #[arg(long)] json: bool, @@ -1391,17 +1391,9 @@ async fn main() -> Result<()> { } Commands::Graph { subcommand } => match subcommand { GraphCmd::Dump { source, format } => { - let fmt = match format.as_str() { - "text" => inspect::GraphFormat::Text, - "json" => inspect::GraphFormat::Json, - "dot" => inspect::GraphFormat::Dot, - other => anyhow::bail!( - "unknown --format '{other}' (expected one of: text, json, dot)" - ), - }; inspect::dispatch_graph(inspect::GraphOptions { source: &source, - format: fmt, + format, }) .await?; } @@ -1411,13 +1403,6 @@ async fn main() -> Result<()> { direction, json, } => { - let direction = match direction.as_str() { - "upstream" => inspect::GraphDepsDirection::Upstream, - "downstream" => inspect::GraphDepsDirection::Downstream, - other => anyhow::bail!( - "unknown --direction '{other}' (expected one of: upstream, downstream)" - ), - }; inspect::dispatch_graph_deps(inspect::GraphDepsOptions { source: &source, step: &step, diff --git a/tests/inspect_integration.rs b/tests/inspect_integration.rs index 1d4e7d68..6f15909c 100644 --- a/tests/inspect_integration.rs +++ b/tests/inspect_integration.rs @@ -109,8 +109,10 @@ fn graph_rejects_unknown_format() { .expect("run ado-aw graph dump --format yaml"); assert!(!out.status.success(), "unknown format should fail"); let stderr = String::from_utf8_lossy(&out.stderr); + // Clap value-enum validation emits "invalid value 'yaml' for + // '--format ': ... [possible values: text, json, dot]". assert!( - stderr.contains("unknown --format"), - "expected unknown-format error, got:\n{stderr}" + stderr.contains("invalid value 'yaml'") && stderr.contains("--format"), + "expected clap value-enum rejection for --format, got:\n{stderr}" ); } From 544c42d88cf622d4f95dabfc7a48d74c66ab11d8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 15 Jun 2026 12:24:05 +0100 Subject: [PATCH 15/17] refactor(compile,audit,mcp-author,inspect): extract shared source_path_guard + reachable_edges + fix corrupt aw_info abort Bugs: - audit::pipeline_graph::populate_pipeline_graph: a corrupt aw_info.json (read error or malformed JSON) previously aborted the entire audit via `transpose()?`. Now downgraded to a recorded warning, matching the documented "warn and continue" contract that already applies to resolve_source_path / missing-source failures. New regression test writes `{not valid json` and asserts the populate call succeeds with the expected warning. Security: - mcp_author::source_path: the previous implementation built PathBuf::from the trimmed input directly, so `..\\workflow.md` on Linux became a single Normal component and slipped past the ParentDir traversal check. The shared guard now normalises platform separators before the component check, closing the bypass. A backslash-traversal regression test in src/mcp_author/tests.rs pins the fix. Refactors (deduplicate audit + mcp-author validation logic): - New `src/compile/source_path_guard.rs` exposes `validate_workflow_source_path(source: &str) -> Result` with the full security contract: trim + separator normalisation, `.md` extension check, `..`/`~` rejection, and symlink-target re-check on absolute paths. Six dedicated tests (including a unix-only symlink-bypass test) cover every guard. - audit::pipeline_graph::resolve_source_path now delegates to the shared guard; the local `normalize_source_path` and `has_md_extension` helpers were removed. - mcp_author::source_path now delegates to the shared guard and wraps the anyhow error as `McpError::invalid_params`. Suggestion: shared BFS over edge lists. - `inspect::graph_deps::reachable_edges` is now `pub` and accepts a direction. `inspect::whatif::reachable_edges` collapsed to a one-line downstream-direction wrapper that calls the shared helper, removing the BTreeMap/VecDeque duplication. Unused imports trimmed from `whatif.rs`. Validated locally: cargo build, cargo test (1904 unit tests + every integration suite pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/audit/pipeline_graph.rs | 154 ++++++++------------- src/compile/mod.rs | 1 + src/compile/source_path_guard.rs | 222 +++++++++++++++++++++++++++++++ src/inspect/graph_deps.rs | 8 +- src/inspect/whatif.rs | 30 +---- src/mcp_author/mod.rs | 83 ++---------- src/mcp_author/tests.rs | 16 ++- 7 files changed, 321 insertions(+), 193 deletions(-) create mode 100644 src/compile/source_path_guard.rs diff --git a/src/audit/pipeline_graph.rs b/src/audit/pipeline_graph.rs index c200039d..cebf0552 100644 --- a/src/audit/pipeline_graph.rs +++ b/src/audit/pipeline_graph.rs @@ -13,8 +13,21 @@ use crate::compile::ir::summary::{JobSummary, PipelineSummary}; /// emitted by the Agent job. Missing local sources are common when auditing an /// arbitrary build, so absence is recorded as a warning rather than an error. pub async fn populate_pipeline_graph(audit: &mut AuditData, run_dir: &Path) -> Result<()> { - let source = match read_source_from_aw_info(run_dir).await.transpose()? { - Some(source) if !source.trim().is_empty() => Some(source), + let source = match read_source_from_aw_info(run_dir).await { + Some(Ok(value)) if !value.trim().is_empty() => Some(value), + Some(Err(err)) => { + // Previously `transpose()?` propagated this as a hard + // error and aborted the audit. A corrupt aw_info.json + // from a bad run is a realistic scenario; downgrade to + // the same warn-and-continue path documented for + // resolve_source_path failures below. + record_warning( + audit, + "audit::pipeline_graph", + format!("failed to read aw_info.json: {err:#}; skipping IR graph correlation"), + ); + return Ok(()); + } _ => audit .overview .aw_info @@ -155,102 +168,15 @@ async fn read_source_from_aw_info(run_dir: &Path) -> Option> { /// Resolve the `source` string taken from a downloaded `aw_info.json` /// into an on-disk path. /// -/// **Security**: this value is part of the audited build's artifact -/// payload and must be treated as untrusted. A malicious or prompt- -/// injected build could carry `"source": "/home/user/.ssh/id_rsa"`, -/// and although [`crate::compile::build_pipeline_ir`] would fail to -/// parse it the file would still be read. We mitigate by: -/// -/// - Requiring the path to end with `.md` (the only valid agentic -/// workflow source extension), which closes the -/// arbitrary-file-read vector against keys, `/etc/passwd`, etc. -/// - Rejecting relative paths that contain `..` components or a -/// leading `~` (no directory traversal, no shell-style expansion). -/// - Allowing absolute `.md` paths because legitimate compiled- -/// elsewhere workflows commonly carry an absolute `source`. For -/// absolute paths that exist on disk we additionally canonicalize -/// and **re-check the extension on the resolved target**, which -/// rejects a symlink-bypass such as `/tmp/foo.md` → `/etc/passwd` -/// where the link itself satisfies the lexical `.md` check but -/// points to an unrelated file. -/// -/// ## Residual risk -/// -/// The `.md` extension check is the **primary gate** for absolute -/// paths. A crafted `aw_info.json` carrying -/// `"source": "/home/user/something.md"` will still reach -/// `build_pipeline_ir`, which opens and reads the file. Because that -/// function is read-only and fails gracefully on non-front-matter -/// markdown, the practical blast radius is an unexpected parse error -/// surfaced in the audit warnings — not code execution or -/// credential exfiltration. **Do not weaken or remove the `.md` -/// extension check or the symlink-target re-check** without also -/// adding a containment check (e.g. canonicalize + prefix-against- -/// cwd) at the same level; without them any future maintainer would -/// silently re-open the arbitrary-file-read vector via either a -/// non-markdown extension or a `.md` symlink targeting an arbitrary -/// file. +/// Delegates the whole security contract to +/// [`crate::compile::source_path_guard::validate_workflow_source_path`], +/// which both this entry point and the mcp-author server share. +/// See that module-level doc for the full list of mitigations. async fn resolve_source_path(source: &str) -> Result { - let normalized = normalize_source_path(source); - let path = PathBuf::from(&normalized); - - if !has_md_extension(&path) { - anyhow::bail!( - "refusing source path '{}' from audited build artifact: only `.md` files are valid agentic workflow sources", - normalized - ); - } - - if path.is_absolute() { - // If the file exists, resolve symlinks and re-check the - // extension on the actual target. Closes the - // `/tmp/foo.md → /etc/passwd` symlink-bypass vector. We - // intentionally tolerate `canonicalize` failing (the path may - // not exist locally — the caller upstream emits a warning in - // that case) and only enforce the re-check when the link - // resolved successfully. - if let Ok(canonical) = tokio::fs::canonicalize(&path).await - && !has_md_extension(&canonical) - { - anyhow::bail!( - "refusing source path '{}' from audited build artifact: symlink resolves to non-`.md` target '{}'", - normalized, - canonical.display() - ); - } - return Ok(path); - } - - if path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - || normalized.starts_with('~') - { - anyhow::bail!( - "refusing suspicious relative source path '{}' from audited build artifact", - normalized - ); - } - - let cwd = tokio::fs::canonicalize(".") + let validated = crate::compile::source_path_guard::validate_workflow_source_path(source) .await - .context("Could not resolve current directory")?; - Ok(cwd.join(path)) -} - -fn has_md_extension(path: &Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) -} - -fn normalize_source_path(source: &str) -> String { - let trimmed = source.trim(); - if std::path::MAIN_SEPARATOR == '/' { - trimmed.replace('\\', "/") - } else { - trimmed.replace('/', "\\") - } + .with_context(|| "validate aw_info.json source string from audited build artifact")?; + Ok(validated.path) } async fn find_artifact_dir(run_dir: &Path, prefix: &str) -> Option { @@ -463,4 +389,40 @@ mod tests { audit.warnings ); } + + #[tokio::test] + async fn populate_pipeline_graph_records_warning_on_corrupt_aw_info_json() { + // Regression: previously `read_source_from_aw_info`'s + // Some(Err(_)) was propagated via `transpose()?` and aborted + // the entire audit. A corrupt aw_info.json from a bad run is + // a realistic scenario; it must degrade to a warning. + let temp_dir = tempfile::tempdir().expect("tempdir"); + let run_dir = temp_dir.path().join("build-77"); + let staging_dir = run_dir.join("agent_outputs_77").join("staging"); + tokio::fs::create_dir_all(&staging_dir) + .await + .expect("create staging"); + tokio::fs::write(staging_dir.join("aw_info.json"), b"{not valid json") + .await + .expect("write malformed aw_info"); + + let mut audit = AuditData::default(); + populate_pipeline_graph(&mut audit, &run_dir) + .await + .expect("populate graph must not bail on corrupt aw_info.json"); + + assert!( + audit.pipeline_graph.is_none(), + "corrupt aw_info.json must not populate pipeline_graph" + ); + assert!( + audit + .warnings + .iter() + .any(|w| w.source == "audit::pipeline_graph" + && w.message.contains("failed to read aw_info.json")), + "expected a warning recording the read failure, got {:?}", + audit.warnings + ); + } } diff --git a/src/compile/mod.rs b/src/compile/mod.rs index a994bcb7..baafd694 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -21,6 +21,7 @@ mod job_ir; mod onees; mod onees_ir; pub(crate) mod pr_filters; +pub mod source_path_guard; mod stage; mod stage_ir; mod standalone; diff --git a/src/compile/source_path_guard.rs b/src/compile/source_path_guard.rs new file mode 100644 index 00000000..d49ee2fa --- /dev/null +++ b/src/compile/source_path_guard.rs @@ -0,0 +1,222 @@ +//! Validation for caller-supplied workflow source paths. +//! +//! Two entry points feed an untrusted string to +//! [`crate::compile::build_pipeline_ir`]: +//! +//! 1. `audit::pipeline_graph` — `aw_info.json::source` from an +//! audited build's artifact payload (the build itself may have +//! been prompt-injected). +//! 2. `mcp_author` — `source_path` MCP tool parameters supplied by +//! an IDE/Copilot Chat agent that may be processing untrusted +//! content (PR descriptions, issue comments, fetched pages). +//! +//! Both sites need the same defence: refuse non-markdown paths, +//! refuse parent-directory traversal, refuse `~`-prefixed +//! shell-style expansion, refuse `.md` symlinks that resolve to +//! non-`.md` targets. This module centralises the guard so the two +//! call sites cannot drift apart. +//! +//! See the function-level doc on [`validate_workflow_source_path`] +//! for the full security contract. +//! +//! **Do not weaken any of the listed guards** without simultaneously +//! adding a stronger containment check (e.g. canonicalize + +//! prefix-against-cwd). Every existing audit and MCP entry point +//! relies on this function as the primary gate against arbitrary +//! file reads. + +use std::path::{Component, Path, PathBuf}; + +use anyhow::Result; + +/// Outcome of validating a caller-supplied workflow source path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedSourcePath { + /// The validated path. Absolute paths are returned as-is (after + /// symlink target re-check); relative paths are returned joined + /// to the canonicalized current working directory. + pub path: PathBuf, + /// The trimmed + separator-normalised form of the original + /// input string. Suitable for embedding in user-facing error + /// messages without leaking trailing whitespace. + pub normalized: String, +} + +/// Validate a caller-supplied workflow source path string. +/// +/// **Security**: the input is untrusted. Mitigations applied (in +/// order): +/// +/// 1. Trim whitespace and normalise platform path separators — +/// `\\` → `/` on Unix, `/` → `\\` on Windows. This step prevents +/// a Linux caller from smuggling `..\\workflow.md` past the +/// `ParentDir` check, since `PathBuf::from` would otherwise +/// treat the whole string as one `Normal` component on Unix. +/// 2. Require a `.md` extension — the only valid agentic workflow +/// source extension. Closes the arbitrary-file-read vector +/// against keys, `/etc/passwd`, etc. +/// 3. For absolute paths, canonicalise and **re-check the +/// extension on the resolved target** so a `foo.md → /etc/passwd` +/// symlink does not satisfy the lexical check. Canonicalisation +/// failures are tolerated (file may not exist locally — the +/// caller upstream surfaces a clean read error in that case). +/// 4. For relative paths, reject `..` components and a leading +/// `~` (no directory traversal, no shell-style expansion), then +/// join to the canonicalised current working directory. +pub async fn validate_workflow_source_path(source: &str) -> Result { + let normalized = normalize_separators(source.trim()); + let path = PathBuf::from(&normalized); + + if !has_md_extension(&path) { + anyhow::bail!( + "refusing source path '{normalized}': only `.md` files are valid agentic workflow sources" + ); + } + + if path.is_absolute() { + if let Ok(canonical) = tokio::fs::canonicalize(&path).await + && !has_md_extension(&canonical) + { + anyhow::bail!( + "refusing source path '{normalized}': symlink resolves to non-`.md` target '{}'", + canonical.display() + ); + } + return Ok(ValidatedSourcePath { + path, + normalized, + }); + } + + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + || normalized.starts_with('~') + { + anyhow::bail!("refusing suspicious relative source path '{normalized}'"); + } + + let cwd = tokio::fs::canonicalize(".") + .await + .map_err(|err| anyhow::anyhow!("could not resolve current directory: {err}"))?; + Ok(ValidatedSourcePath { + path: cwd.join(&path), + normalized, + }) +} + +/// Returns `true` when `path` carries a `.md` (case-insensitive) +/// extension. +fn has_md_extension(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) +} + +/// Normalise path separators so the platform-native `PathBuf` +/// machinery treats `..` and similar components consistently. +/// +/// `PathBuf::from("..\\foo.md")` on Unix produces a single +/// `Normal("..\\foo.md")` component, which would otherwise sneak +/// past the `ParentDir` check below. Mirrors the helper that used +/// to live inside `audit::pipeline_graph::normalize_source_path`. +fn normalize_separators(source: &str) -> String { + if std::path::MAIN_SEPARATOR == '/' { + source.replace('\\', "/") + } else { + source.replace('/', "\\") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn rejects_non_markdown_extension() { + let err = validate_workflow_source_path("/etc/passwd") + .await + .expect_err("non-md path must be rejected"); + assert!( + format!("{err}").contains("only `.md`"), + "expected non-md rejection message, got: {err}" + ); + } + + #[tokio::test] + async fn rejects_parent_traversal_with_unix_separators() { + let err = validate_workflow_source_path("../../../etc/passwd.md") + .await + .expect_err("`..` must be rejected"); + assert!( + format!("{err}").contains("suspicious relative source path"), + "expected traversal rejection message, got: {err}" + ); + } + + #[tokio::test] + async fn rejects_parent_traversal_with_backslash_separators() { + // Regression for the linux-side `..\\workflow.md` bypass: on + // Unix, `PathBuf::from("..\\workflow.md")` produces a single + // Normal component without the separator normalisation, so + // the `ParentDir` check would never fire. + let err = validate_workflow_source_path("..\\..\\workflow.md") + .await + .expect_err("backslash-encoded `..` must be rejected"); + assert!( + format!("{err}").contains("suspicious relative source path"), + "expected traversal rejection message, got: {err}" + ); + } + + #[tokio::test] + async fn rejects_tilde_prefix() { + let err = validate_workflow_source_path("~/secret.md") + .await + .expect_err("tilde prefix must be rejected"); + assert!( + format!("{err}").contains("suspicious relative source path"), + "expected tilde rejection message, got: {err}" + ); + } + + #[tokio::test] + async fn accepts_legitimate_relative_md() { + let result = validate_workflow_source_path("workflows/foo.md") + .await + .expect("plain relative .md path must be accepted"); + assert!(result.path.is_absolute()); + assert!(result.normalized.ends_with("foo.md")); + } + + #[tokio::test] + async fn accepts_absolute_markdown_path() { + let path = if cfg!(windows) { + r"C:\workflows\foo.md" + } else { + "/repo/workflows/foo.md" + }; + let result = validate_workflow_source_path(path) + .await + .expect("absolute `.md` paths must be accepted"); + assert!(result.path.is_absolute()); + } + + #[cfg(unix)] + #[tokio::test] + async fn rejects_md_symlink_to_non_md_target() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let target = temp_dir.path().join("binary.bin"); + tokio::fs::write(&target, b"x").await.unwrap(); + let link = temp_dir.path().join("evil.md"); + tokio::fs::symlink(&target, &link).await.unwrap(); + + let err = validate_workflow_source_path(link.to_str().unwrap()) + .await + .expect_err("symlink to non-md target must be rejected"); + assert!( + format!("{err}").contains("symlink resolves to non-`.md` target"), + "expected symlink rejection message, got: {err}" + ); + } +} diff --git a/src/inspect/graph_deps.rs b/src/inspect/graph_deps.rs index 62b2615c..1c32854b 100644 --- a/src/inspect/graph_deps.rs +++ b/src/inspect/graph_deps.rs @@ -229,7 +229,13 @@ fn transitive_jobs( .collect() } -fn reachable_edges( +/// BFS-walk a directed edge list from `start`, returning every node +/// reachable in the requested direction (transitive closure; +/// `start` itself is not included unless cyclically reachable). +/// +/// Shared with [`crate::inspect::whatif`] so the two failure +/// reachability tools cannot drift apart on traversal semantics. +pub fn reachable_edges( edges: &[EdgeEntry], start: &str, direction: GraphDepsDirection, diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 420fccf4..7c55fc96 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -5,7 +5,7 @@ //! strings to classify downstream jobs that would be skipped after a //! chosen job or step fails. -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::collections::BTreeSet; use std::error::Error; use std::fmt; @@ -13,6 +13,7 @@ use anyhow::{Result, anyhow}; use serde::Serialize; use crate::compile::ir::summary::{EdgeEntry, JobSummary, PipelineBodySummary, PipelineSummary}; +use crate::inspect::graph_deps; /// JSON report emitted by `ado-aw whatif --json`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -321,29 +322,10 @@ fn is_negated_call(normalized_condition: &str, call_idx: usize) -> bool { } fn reachable_edges(edges: &[EdgeEntry], start: &str) -> BTreeSet { - let mut downstream: BTreeMap> = BTreeMap::new(); - for edge in edges { - downstream - .entry(edge.producer.clone()) - .or_default() - .insert(edge.consumer.clone()); - } - - let mut seen = BTreeSet::new(); - let mut queue: VecDeque = downstream - .get(start) - .into_iter() - .flat_map(|next| next.iter().cloned()) - .collect(); - while let Some(node) = queue.pop_front() { - if !seen.insert(node.clone()) { - continue; - } - if let Some(next) = downstream.get(&node) { - queue.extend(next.iter().cloned()); - } - } - seen + // whatif always walks downstream (producer → consumers); the + // shared helper in graph_deps owns the BFS so the two failure + // reachability tools cannot drift apart on traversal semantics. + graph_deps::reachable_edges(edges, start, graph_deps::GraphDepsDirection::Downstream) } fn known_ids(summary: &PipelineSummary) -> Vec { diff --git a/src/mcp_author/mod.rs b/src/mcp_author/mod.rs index 442f7016..7e5ce6b7 100644 --- a/src/mcp_author/mod.rs +++ b/src/mcp_author/mod.rs @@ -385,77 +385,20 @@ pub async fn run_stdio() -> Result<()> { /// resolve it to a `PathBuf` suitable for passing to /// `crate::compile::build_pipeline_ir`. /// -/// **Security**: the mcp-author server runs in an IDE/Copilot Chat -/// context where the surrounding agent may be processing -/// untrusted content (PR descriptions, issue comments, fetched web -/// pages). A prompt-injected request such as -/// `inspect_workflow(source_path="../../.ssh/authorized_keys.md")` -/// would otherwise reach `build_pipeline_ir`, open the file, and -/// surface the path in the parse-error message. -/// -/// We apply the same guards as -/// [`crate::audit::pipeline_graph::resolve_source_path`]: -/// -/// - Require a `.md` extension (the only valid agentic workflow -/// source extension); closes the arbitrary-file-read vector -/// against keys, `/etc/passwd`, etc. -/// - Reject relative paths that contain `..` components or a leading -/// `~` (no directory traversal, no shell-style expansion). -/// - For absolute paths, canonicalize and re-check the extension on -/// the resolved target so a `foo.md → /etc/passwd` symlink does -/// not satisfy the lexical check. +/// Delegates to the shared +/// [`crate::compile::source_path_guard::validate_workflow_source_path`] +/// so the audit and mcp-author entry points cannot drift apart on +/// path validation. The shared guard rejects non-`.md` paths, +/// `..` components, `~` prefixes, and `.md` symlinks resolving to +/// non-`.md` targets (the latter via `canonicalize` re-check on +/// absolute paths), and **normalises platform separators first** so +/// a Linux caller cannot smuggle `..\\workflow.md` past the +/// traversal check. async fn source_path(path: &str) -> Result { - let trimmed = path.trim(); - let buf = PathBuf::from(trimmed); - - let has_md_extension = buf - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); - if !has_md_extension { - return Err(McpError::invalid_params( - format!( - "refusing source_path '{trimmed}': only `.md` files are valid agentic workflow sources" - ), - None, - )); - } - - if buf.is_absolute() { - // Resolve symlinks and re-check the extension on the target so a - // `foo.md` link pointing at an arbitrary file is rejected. We - // tolerate canonicalize failing (file may not exist locally — - // build_pipeline_ir will then surface a clean read error). - if let Ok(canonical) = tokio::fs::canonicalize(&buf).await { - let target_has_md = canonical - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); - if !target_has_md { - return Err(McpError::invalid_params( - format!( - "refusing source_path '{trimmed}': symlink resolves to non-`.md` target '{}'", - canonical.display() - ), - None, - )); - } - } - return Ok(buf); - } - - if buf - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - || trimmed.starts_with('~') - { - return Err(McpError::invalid_params( - format!("refusing suspicious relative source_path '{trimmed}'"), - None, - )); - } - - Ok(buf) + crate::compile::source_path_guard::validate_workflow_source_path(path) + .await + .map(|validated| validated.path) + .map_err(|err| McpError::invalid_params(format!("{err:#}"), None)) } fn parse_graph_dump_format(format: Option<&str>) -> Result { diff --git a/src/mcp_author/tests.rs b/src/mcp_author/tests.rs index b051590f..45a1a854 100644 --- a/src/mcp_author/tests.rs +++ b/src/mcp_author/tests.rs @@ -101,7 +101,19 @@ async fn source_path_rejects_parent_traversal() { .await .expect_err("parent traversal must be rejected"); assert!( - format!("{err}").contains("suspicious relative source_path"), + format!("{err}").contains("suspicious relative source path"), + "expected traversal rejection message, got: {err}" + ); +} + +#[tokio::test] +async fn source_path_rejects_backslash_parent_traversal() { + // Regression for the linux-side `..\\workflow.md` bypass. + let err = source_path("..\\..\\authorized_keys.md") + .await + .expect_err("backslash-encoded `..` must be rejected"); + assert!( + format!("{err}").contains("suspicious relative source path"), "expected traversal rejection message, got: {err}" ); } @@ -112,7 +124,7 @@ async fn source_path_rejects_tilde_prefix() { .await .expect_err("tilde prefix must be rejected"); assert!( - format!("{err}").contains("suspicious relative source_path"), + format!("{err}").contains("suspicious relative source path"), "expected tilde rejection message, got: {err}" ); } From 65a180d7a35a688ab6485e2b82db69035bf23117 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 15 Jun 2026 14:03:21 +0100 Subject: [PATCH 16/17] fix(compile,inspect): extend relative-path symlink guard + flesh out classify_condition limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (relative-path symlink target re-check): - compile::source_path_guard::validate_workflow_source_path previously only canonicalised + re-checked the `.md` extension on the **absolute-path** branch. A relative input such as `workflows/evil.md` that symlinked to `/etc/passwd` slipped past the lexical `.md` check; the eventual `canonicalize` inside `audit::pipeline_graph::populate_pipeline_graph` would then resolve to the arbitrary target, narrowing the documented contract. - The relative branch now mirrors the absolute-path guard: join to cwd, canonicalize, and re-check the resolved extension. Module doc updated to call this out. New unix-only regression test (rejects_relative_md_symlink_to_non_md_target) creates the bypass setup inside a tempdir, switches `set_current_dir` under a Mutex, and asserts the resolver refuses with the same symlink rejection message. Docs (inspect::whatif::classify_condition): - Expanded the "Coverage limitations" section to enumerate every known static-analyser blind spot so authors debugging a what-if result are not surprised: not(succeeded()) misclassification (already listed), scoped predicate forms like failed('Setup'), bare canceled(), variable / dependency-result comparisons, surviving template ${{ }} expressions, ignored boolean composition, multi-line not(...) wraps, step-level conditions, and string-literal false positives. Closes the reviewer's "tell me more" follow-up. No behaviour change — the classifier remains a conservative lower bound, with inspect::trace as the authoritative source for any classification disagreement. Validated locally: cargo build, cargo test (1904 unit tests + every integration suite pass — +1 on Unix from the new symlink test), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/source_path_guard.rs | 61 ++++++++++++++++++++++++++++++-- src/inspect/whatif.rs | 59 +++++++++++++++++++++++++----- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/compile/source_path_guard.rs b/src/compile/source_path_guard.rs index d49ee2fa..dff8baa8 100644 --- a/src/compile/source_path_guard.rs +++ b/src/compile/source_path_guard.rs @@ -62,7 +62,10 @@ pub struct ValidatedSourcePath { /// caller upstream surfaces a clean read error in that case). /// 4. For relative paths, reject `..` components and a leading /// `~` (no directory traversal, no shell-style expansion), then -/// join to the canonicalised current working directory. +/// join to the canonicalised current working directory. **Apply +/// the same symlink-target extension re-check** as for absolute +/// paths so a `workflows/evil.md` link to `/etc/passwd` cannot +/// sneak through after a downstream `canonicalize` resolves it. pub async fn validate_workflow_source_path(source: &str) -> Result { let normalized = normalize_separators(source.trim()); let path = PathBuf::from(&normalized); @@ -99,8 +102,25 @@ pub async fn validate_workflow_source_path(source: &str) -> Result = std::sync::Mutex::new(()); + let _guard = CWD_LOCK.lock().unwrap(); + let original_cwd = std::env::current_dir().expect("save cwd"); + std::env::set_current_dir(temp_dir.path()).expect("enter tempdir"); + + let result = validate_workflow_source_path("workflows/evil.md").await; + + std::env::set_current_dir(&original_cwd).expect("restore cwd"); + + let err = result.expect_err("relative symlink to non-md target must be rejected"); + assert!( + format!("{err}").contains("symlink resolves to non-`.md` target"), + "expected symlink rejection message, got: {err}" + ); + } } diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 7c55fc96..727bdf1f 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -239,21 +239,62 @@ fn reachable_downstream_jobs( /// /// The classifier is a best-effort static analyser over the rendered /// condition string, not a semantic ADO expression evaluator. Known -/// limitations: +/// limitations, in order of "most likely to surprise an author": /// -/// - **Variable-based conditions** such as -/// `eq(variables['Agent.JobStatus'], 'Failed')` or -/// `eq(dependencies.Setup.result, 'Failed')` are conservatively -/// reported as `Skipped`. Treat that result as a lower bound — a -/// job may still execute at runtime via a variable-based escape -/// hatch we cannot statically detect. +/// - **`not(succeeded())` is misclassified as `Skipped`**. The +/// classifier's bypass list contains only `always()`, `failed()`, +/// and `succeededOrFailed()`; `succeeded()` is not recognised, so +/// negating it does not flip the classification. In practice +/// `not(succeeded())` (run when the parent did **not** succeed — +/// typically a cleanup job) would execute after an upstream +/// failure but is conservatively reported as `Skipped`. Treat +/// that result as a lower bound for any cleanup job using this +/// form. +/// - **Scoped predicate forms are not recognised**. ADO accepts +/// arguments such as `failed('Setup')`, +/// `succeededOrFailed('Stage.Job')`, or `always('Stage1')` to +/// scope the predicate to specific upstream jobs/stages. The +/// classifier searches for the bare `failed()` / +/// `succeededorfailed()` / `always()` tokens (parens immediately +/// closed), so any argumented form drops through to `Skipped`. +/// - **`canceled()` is not recognised**. A condition of `canceled()` +/// alone classifies as `Skipped`. The common +/// `or(failed(), canceled())` form is still classified as +/// `RunsAnyway` because `failed()` is in the list, so this only +/// matters for cancellation-only bypass jobs. +/// - **Variable-based and dependency-result conditions** such as +/// `eq(variables['Agent.JobStatus'], 'Failed')`, +/// `eq(dependencies.Setup.result, 'Failed')`, or +/// `in(dependencies.Agent.result, 'Failed', 'Canceled')` are +/// conservatively reported as `Skipped`. Treat that result as a +/// lower bound — a job may still execute at runtime via a +/// variable-based escape hatch we cannot statically detect. +/// - **Templated `${{ }}` expressions** that survived compile-time +/// substitution (e.g. `eq('${{ parameters.runAnyway }}', 'true')`) +/// are opaque to the classifier and report `Skipped`. +/// - **Boolean composition is ignored**. `and(failed(), eq(...))` +/// classifies as `RunsAnyway` because the unnegated `failed()` +/// marker is enough — the `eq(...)` half is not evaluated for +/// short-circuit semantics. +/// - **Multi-line `not(...)` wraps** can defeat the negation +/// detector. The normaliser strips spaces but not tabs or +/// newlines, so `not\n(failed())` would not satisfy the `not(` +/// lookbehind and the marker would be treated as un-negated. +/// ADO emits compact single-line conditions in practice. +/// - **Step-level conditions are ignored**. `classify_condition` is +/// only called for job/stage `condition:` strings; a step inside a +/// job with its own bypass does not affect the job's +/// classification. /// - **String literals containing marker syntax** trigger a /// false-positive `RunsAnyway`: a condition like /// `eq(variables['result'], 'failed()')` would match the literal /// `failed()` substring even though the call is never invoked. ADO /// conditions are compiler-generated rather than raw user input, so -/// this is an accepted residual gap; the authoritative source -/// remains the live ADO pipeline run. +/// this is an accepted residual gap. +/// +/// The authoritative source for any classification disagreement +/// remains the live ADO pipeline run (or +/// [`crate::inspect::trace`] over a real build's timeline). fn classify_condition(condition: &Option) -> WhatIfClassification { let Some(condition) = condition else { return WhatIfClassification::Skipped; From 3e77e5aa3517d38dd8b23c3627367e7211a2dfb8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Mon, 15 Jun 2026 15:09:16 +0100 Subject: [PATCH 17/17] fix(inspect,compile,mcp-author): propagate stage condition to jobs + parent-dir guard on absolute paths + suggestion threshold + lint shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs: - inspect::whatif::reachable_downstream_jobs: jobs added via the stage-edge traversal branch are now classified by combining the containing stage's condition with the job's own. Previously only job.condition was inspected, so a `condition: always()` on a downstream cleanup stage was silently dropped on the floor and inner jobs (which default to succeeded()) were reported as Skipped. New `stronger_classification` + `find_stage` helpers lift the stage-level bypass through to its jobs. A stages-bodied regression test pins the new behaviour. Security: - compile::source_path_guard::validate_workflow_source_path: the ParentDir + `~` check was previously gated behind the relative- path branch, so an adversarial absolute path like `/workspace/../../home/runner/.env.md` slipped through unchecked even though the module's stated contract refuses parent- directory traversal unconditionally. Hoisted the check before the absolute/relative split; updated the error message to drop "relative" since the rejection now applies to both kinds. New `rejects_absolute_path_with_parent_dir_component` regression test; existing tests + mcp_author tests updated to match the new wording. Suggestions: - inspect::lint::report: renamed the inner `LintSummary` binding from `summary` to `tally` so it no longer shadows the `PipelineSummary` parameter; the struct field on `LintReport` is still `summary` (no public-API change). - inspect::whatif::closest and inspect::graph_deps::closest: added a Levenshtein distance threshold (`needle_len / 2 + 2`) so unrelated input like `whatif --fail xyzzy` no longer gets a noisy "did you mean" suggestion against whatever IR id happens to be lexicographically nearest. Single-typo cases like `Aget` → `Agent` still suggest. Two whatif regression tests (`closest_returns_none_for_unrelated_input`, `closest_suggests_single_typo_within_threshold`). Validated locally: cargo build, cargo test (1908 unit tests + every integration suite pass), cargo clippy --all-targets --all-features (clean). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/source_path_guard.rs | 56 ++++++++--- src/inspect/graph_deps.rs | 9 ++ src/inspect/lint.rs | 10 +- src/inspect/whatif.rs | 159 ++++++++++++++++++++++++++++++- src/mcp_author/tests.rs | 6 +- 5 files changed, 217 insertions(+), 23 deletions(-) diff --git a/src/compile/source_path_guard.rs b/src/compile/source_path_guard.rs index dff8baa8..252f73cf 100644 --- a/src/compile/source_path_guard.rs +++ b/src/compile/source_path_guard.rs @@ -76,6 +76,22 @@ pub async fn validate_workflow_source_path(source: &str) -> Result Result, job: &str) -> String { } fn closest<'a>(needle: &str, candidates: impl Iterator) -> Option { + // Same Levenshtein threshold as `inspect::whatif::closest`: + // suppress low-quality suggestions so an input like `xyzzy` + // does not get the lexicographically nearest match as its + // "did you mean" hint. Half the needle length + 2 keeps short + // single-typo cases (`Aget` → `Agent`) intact while rejecting + // genuinely unrelated input. + let needle_len = needle.chars().count(); + let max_distance = needle_len / 2 + 2; candidates .map(|candidate| (levenshtein(needle, candidate), candidate)) + .filter(|(distance, _)| *distance <= max_distance) .min_by_key(|(distance, candidate)| (*distance, (*candidate).to_string())) .map(|(_, candidate)| candidate.to_string()) } diff --git a/src/inspect/lint.rs b/src/inspect/lint.rs index d59012b9..79fcab34 100644 --- a/src/inspect/lint.rs +++ b/src/inspect/lint.rs @@ -73,8 +73,14 @@ pub fn lint(summary: &PipelineSummary) -> Vec { pub fn report(summary: &PipelineSummary) -> LintReport { let findings = lint(summary); - let summary = summarize_findings(&findings); - LintReport { findings, summary } + // Rename the local to avoid shadowing the `PipelineSummary` + // parameter with a `LintSummary` of the same name in the same + // scope; the struct field is still called `summary` below. + let tally = summarize_findings(&findings); + LintReport { + findings, + summary: tally, + } } pub fn summarize_findings(findings: &[LintFinding]) -> LintSummary { diff --git a/src/inspect/whatif.rs b/src/inspect/whatif.rs index 727bdf1f..8ad60afa 100644 --- a/src/inspect/whatif.rs +++ b/src/inspect/whatif.rs @@ -216,16 +216,57 @@ fn reachable_downstream_jobs( keys.into_iter() .filter_map(|(stage, job_id)| { - find_job(summary, &job_id).map(|job| DownstreamJob { - job: job.id.clone(), - stage: stage.or_else(|| job.stage.clone()), - classification: classify_condition(&job.condition), - condition: job.condition.clone(), + find_job(summary, &job_id).map(|job| { + let job_classification = classify_condition(&job.condition); + // Inherit the stage's bypass classification when the + // containing stage carries a `condition: always()` / + // `succeededOrFailed()` / similar. Without this the + // job branch alone reads as Skipped — wrong for the + // common cleanup-stage pattern where the stage + // bypasses failure but its inner jobs keep the + // default `succeeded()` condition. + let stage_classification = stage + .as_deref() + .and_then(|stage_id| find_stage(summary, stage_id)) + .map(|stage_summary| classify_condition(&stage_summary.condition)) + .unwrap_or(WhatIfClassification::Skipped); + let classification = stronger_classification(job_classification, stage_classification); + DownstreamJob { + job: job.id.clone(), + stage: stage.or_else(|| job.stage.clone()), + classification, + condition: job.condition.clone(), + } }) }) .collect() } +/// Return `RunsAnyway` if either side asserts the job runs after +/// upstream failure; otherwise `Skipped`. Used to lift a stage's +/// bypass classification through to its contained jobs. +fn stronger_classification( + a: WhatIfClassification, + b: WhatIfClassification, +) -> WhatIfClassification { + match (a, b) { + (WhatIfClassification::RunsAnyway, _) | (_, WhatIfClassification::RunsAnyway) => { + WhatIfClassification::RunsAnyway + } + _ => WhatIfClassification::Skipped, + } +} + +fn find_stage<'a>( + summary: &'a PipelineSummary, + stage_id: &str, +) -> Option<&'a crate::compile::ir::summary::StageSummary> { + match &summary.body { + PipelineBodySummary::Jobs { .. } => None, + PipelineBodySummary::Stages { stages } => stages.iter().find(|stage| stage.id == stage_id), + } +} + /// Classify a rendered ADO `condition:` string for what-if analysis. /// /// Returns [`WhatIfClassification::RunsAnyway`] if the condition @@ -407,8 +448,17 @@ fn qualified_job(stage: &Option, job: &str) -> String { } fn closest<'a>(needle: &str, candidates: impl Iterator) -> Option { + // Reject low-quality matches: a completely unrelated input like + // `xyzzy` should not get a suggestion just because some + // candidate happens to be lexicographically nearest. The + // threshold is half the needle length plus 2 so single typos in + // short ids (e.g. `Aget` → `Agent`) still suggest while genuinely + // unrelated inputs return `None`. + let needle_len = needle.chars().count(); + let max_distance = needle_len / 2 + 2; candidates .map(|candidate| (levenshtein(needle, candidate), candidate)) + .filter(|(distance, _)| *distance <= max_distance) .min_by_key(|(distance, candidate)| (*distance, (*candidate).to_string())) .map(|(_, candidate)| candidate.to_string()) } @@ -668,4 +718,103 @@ mod tests { // un-negated and the job classifies as RunsAnyway. assert_eq!(detection.classification, WhatIfClassification::RunsAnyway); } + + #[test] + fn closest_returns_none_for_unrelated_input() { + // Regression: without the Levenshtein threshold, an input + // like `xyzzy` would always be suggested the + // lexicographically nearest candidate. That's noise. + let candidates = ["Setup", "Agent", "Detection", "SafeOutputs"]; + assert_eq!( + closest("xyzzy", candidates.iter().copied()), + None, + "unrelated input must not get a 'did you mean' hint" + ); + } + + #[test] + fn closest_suggests_single_typo_within_threshold() { + let candidates = ["Setup", "Agent", "Detection", "SafeOutputs"]; + assert_eq!( + closest("Aget", candidates.iter().copied()), + Some("Agent".to_string()), + ); + } + + #[test] + fn stage_always_condition_propagates_to_inner_jobs_runs_anyway() { + // Regression: in a Stages-bodied pipeline, when a downstream + // stage carries `condition: always()` but its inner jobs + // keep the default `succeeded()`, the jobs should classify + // as RunsAnyway. Previously only `job.condition` was checked + // and the stage-level bypass was dropped on the floor. + use crate::compile::ir::summary::StageSummary; + let stage_a = StageSummary { + id: "BuildStage".to_string(), + display_name: "BuildStage".to_string(), + depends_on: Vec::new(), + condition: None, + jobs: vec![JobSummary { + id: "Build".to_string(), + stage: Some("BuildStage".to_string()), + display_name: "Build".to_string(), + depends_on: Vec::new(), + condition: None, + pool: PoolSummary::VmImage { + image: "ubuntu-latest".to_string(), + }, + steps: Vec::new(), + }], + }; + let stage_b = StageSummary { + id: "Cleanup".to_string(), + display_name: "Cleanup".to_string(), + depends_on: vec!["BuildStage".to_string()], + // Stage-level always() — common cleanup pattern. + condition: Some("always()".to_string()), + jobs: vec![JobSummary { + id: "CleanupJob".to_string(), + stage: Some("Cleanup".to_string()), + display_name: "CleanupJob".to_string(), + depends_on: Vec::new(), + // No job-level condition → defaults to succeeded() + // semantics on its own. + condition: None, + pool: PoolSummary::VmImage { + image: "ubuntu-latest".to_string(), + }, + steps: Vec::new(), + }], + }; + + let summary = PipelineSummary { + schema_version: 1, + name: "stages-test".to_string(), + shape: "1es".to_string(), + body: PipelineBodySummary::Stages { + stages: vec![stage_a, stage_b], + }, + graph: GraphSummary { + step_locations: Vec::new(), + job_edges: Vec::new(), + stage_edges: vec![EdgeEntry { + consumer: "Cleanup".to_string(), + producer: "BuildStage".to_string(), + }], + outputs_needing_is_output: Vec::new(), + }, + }; + + let report = analyze(&summary, "Build").expect("analyze Build failure"); + let cleanup = report + .downstream_jobs + .iter() + .find(|job| job.job == "CleanupJob") + .expect("CleanupJob must appear via stage-edge traversal"); + assert_eq!( + cleanup.classification, + WhatIfClassification::RunsAnyway, + "stage-level always() must propagate to inner jobs" + ); + } } diff --git a/src/mcp_author/tests.rs b/src/mcp_author/tests.rs index 45a1a854..4488a0d8 100644 --- a/src/mcp_author/tests.rs +++ b/src/mcp_author/tests.rs @@ -101,7 +101,7 @@ async fn source_path_rejects_parent_traversal() { .await .expect_err("parent traversal must be rejected"); assert!( - format!("{err}").contains("suspicious relative source path"), + format!("{err}").contains("parent-directory components"), "expected traversal rejection message, got: {err}" ); } @@ -113,7 +113,7 @@ async fn source_path_rejects_backslash_parent_traversal() { .await .expect_err("backslash-encoded `..` must be rejected"); assert!( - format!("{err}").contains("suspicious relative source path"), + format!("{err}").contains("parent-directory components"), "expected traversal rejection message, got: {err}" ); } @@ -124,7 +124,7 @@ async fn source_path_rejects_tilde_prefix() { .await .expect_err("tilde prefix must be rejected"); assert!( - format!("{err}").contains("suspicious relative source path"), + format!("{err}").contains("parent-directory components"), "expected tilde rejection message, got: {err}" ); }