Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

- Cargo/toolchain failures now surface as structured nxrust diagnostics
(DIAG-002). A missing `cargo`, an uninstalled toolchain channel or target, a
nightly-only invocation, or an unsafe `rust-toolchain` literal are classified
from the cargo/rustup spawn outcome and rendered through the `formatDiagnostic`
envelope with a stable slug code (`nxrust:cargo-not-found`,
`nxrust:toolchain-not-installed`, `nxrust:target-not-installed`,
`nxrust:nightly-required`, `nxrust:invalid-toolchain-literal`) and an
actionable `rustup ...` fix, instead of a bare shell error. Unknown cargo
output (rustc compile errors) still passes through untouched. `command:`
redaction now also covers `--token`-style flags. Codes are part of the public
contract (D-D5) → **minor** bump (D-008). See `docs/diagnostics.md`.
- New `@eddacraft/nxrust:cache-report` generator (alias `cache-info`) — a
read-only cache-observability report. For each inferred Rust crate it prints,
per nxrust target, the effective `inputs`, `outputs`, the environment-variable
Expand Down
41 changes: 41 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# nxrust diagnostics catalogue

Every plugin-detectable failure surfaces through the shared diagnostic envelope
(`formatDiagnostic`, APS module 14 / spec §6.14):

```
[nxrust] <what>
why: <why>
command: <command, if safe to print — secrets redacted>
fix: <fix>
```

Each diagnostic carries a stable, slug-based **code** with an `nxrust:` prefix
(D-D5). Codes are part of the public contract: renaming or removing one is a
major version bump; adding one is a minor bump (D-008). Secret-shaped values in
the `command:` field (`TOKEN`, `SECRET`, `KEY`, `PASSWORD`, and `--token`-style
flags) are redacted to `<redacted>`.

## Toolchain & cargo pre-flight family

These cover a missing `cargo`, an uninstalled toolchain channel or target, a
nightly-only invocation, and an unsafe toolchain literal. They are classified
from spawn errors and `rustup`/`cargo` stderr at the single process chokepoint
(`runWithDiagnostic` in `src/utils/diagnostics.ts`).

| Code | Trigger | Fix |
| ---------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `nxrust:cargo-not-found` | `cargo`/`rustup` not on PATH (`spawn ENOENT`) | Install the Rust toolchain via <https://rustup.rs>. |
| `nxrust:toolchain-not-installed` | rustup stderr `toolchain '<channel>' is not installed` | `rustup install <channel>` |
| `nxrust:target-not-installed` | stderr `the target \`<triple>\` must be installed` (and variants) | `rustup target add <triple>` |
| `nxrust:nightly-required` | a nightly-only feature used on a non-nightly channel | Add `[toolchain] channel = "nightly"` to `rust-toolchain.toml`, or pass `--toolchain=nightly`. |
| `nxrust:invalid-toolchain-literal` | a resolved channel literal fails the shell-safety pattern | Use a channel like `stable`, `nightly`, or `1.81.0`. |
| `nxrust:spawn-failed` | a spawn failed for an unclassified reason | Confirm the binary is installed and executable, then retry. |

> Unknown cargo output (rustc compile errors and the like) is **not** translated
> — cargo prints its own error inline and nxrust leaves it untouched (module 14
> Out-of-Scope). Only the plugin-detectable shapes above are wrapped.

Later DIAG slices append their codes here (workspace-shape, `cargo metadata`,
duplicate-package, release-publish, supply-chain, and the cross-language
`^build` seam warning surfaced by `nxrust doctor`).
68 changes: 55 additions & 13 deletions plans/modules/14-diagnostics.aps.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ Actionable error messages for cargo, toolchain, and tool-missing
failures. Every error names what failed, why it matters, the exact
command attempted, and the suggested fix.

| ID | Owner | Status |
| ---- | --------- | ------------------------------------------------------------- |
| DIAG | eddacraft | In Progress (DIAG-001 done — Anvil #2; CACHE-OBS-001 done — Anvil #7; both unreleased) |
| ID | Owner | Status |
| ---- | --------- | ------------------------------------------------------------------------------------- |
| DIAG | eddacraft | In Progress (DIAG-001 + DIAG-002 done; CACHE-OBS-001 done — Anvil #7; all unreleased) |

## Purpose

Expand Down Expand Up @@ -146,7 +146,7 @@ Promote individual Work Items to Ready when:
ADR-049 waits on `doctor`. First slice under D-011.)

- `src/utils/diagnostics.ts` — the shared `formatDiagnostic({ what, why,
command?, fix, severity? })` helper (spec §6.14 envelope) with secret
command?, fix, severity? })` helper (spec §6.14 envelope) with secret
redaction (`redactSecrets` strips `TOKEN`/`SECRET`/`KEY`/`PASSWORD` values
Comment on lines 148 to 150
from the `command:` field, D-D / module constraint).
- `@eddacraft/nxrust:doctor` generator (`src/generators/doctor/`) — read-only;
Expand All @@ -159,11 +159,49 @@ ADR-049 waits on `doctor`. First slice under D-011.)
that is the Nx entry point with graph access and there is no synthetic
`rust-workspace` project to host an executor yet (module 12, Proposed).

**Deferred to later DIAG slices** (not in DIAG-001): the rest of the §6.14
catalogue (cargo/toolchain/tool-missing pre-flight checks), the cache-config
warnings (`CARGO_TARGET_DIR` / lockfile / toolchain surprises), `--json-diagnostics`
for Nx Console, `explain affected` (ISS-004 #4, module 13), and routing
existing executor errors through `formatDiagnostic`.
**Deferred to later DIAG slices** (not in DIAG-001): the cache-config warnings
(`CARGO_TARGET_DIR` / lockfile / toolchain surprises), `--json-diagnostics` for
Nx Console, `explain affected` (ISS-004 #4, module 13), and the tool-missing
checks for the supply-chain/wasm tools (promote with modules 09/10). The
cargo/toolchain pre-flight family is now **DIAG-002** (below).

### DIAG-002 — Cargo/toolchain pre-flight diagnostic family

**Status: Done** (2026-06-24, unreleased — the next slice after DIAG-001, built
on its `formatDiagnostic` envelope. Routes cargo/rustup failures through the
structured envelope so a missing toolchain/target surfaces as an actionable
nxrust error instead of a raw shell failure.)

- `src/utils/diagnostics.ts` — extends the envelope with **slug-based diagnostic
codes** (`DIAGNOSTIC_CODES`, `DiagnosticCode`, D-D5), a `code` field on
`Diagnostic` plus a `CataloguedDiagnostic` type, and an
`NxrustDiagnosticError` that carries the code so callers branch on `.code`.
Builders for the family: `cargo-not-found`, `toolchain-not-installed`,
`target-not-installed`, `nightly-required`, `invalid-toolchain-literal`, and a
generic `spawn-failed`. Secret redaction extended to `--token`-style flags
(both `--token x` and `--token=x`).
- `runWithDiagnostic` classifier — maps spawn `ENOENT` on cargo/rustup to
`cargo-not-found` and classifies rustup/cargo stderr (toolchain/target
missing, nightly-only). Unknown cargo output passes through unchanged (module
14 Out-of-Scope — no translation of arbitrary cargo output).
- `src/utils/run-process.ts` — stderr is teed (live passthrough + 64 KB tail
capture) so the single cargo-invocation chokepoint can classify failures;
replaces the bare `console.error` (constraint: no `console.*` outside the
envelope). Settles once across `error`/`close`.
- `src/utils/rust-toolchain.ts` — `validateChannelLiteral` now throws
`NxrustDiagnosticError(invalidToolchainLiteral(...))` instead of a bare
`Error`, joining the catalogue while preserving the legacy wording.
- `docs/diagnostics.md` — catalogue stub documenting the six codes, triggers,
and fixes (seeds the consumer/Nx-Console reference).
- Tests: `diagnostics.spec.ts` (builders, codes, error, classifier, redaction),
`run-process.spec.ts` (integration: ENOENT, classified stderr, unknown
pass-through, success). Full suite 225 green; e2e confirms cargo's live
colourised output still surfaces through the teed-stderr path.

**Still deferred** (not in DIAG-002): the tool-missing checks for
`nextest`/`audit`/`deny`/`napi`/`wasm-pack` (promote with modules 09/10), the
workspace-shape / `cargo metadata` / duplicate-package pre-flight rows,
`--json-diagnostics`, and the `verboseDiagnostics` Info tier.

### CACHE-OBS-001 — `nxrust cache-report` cache-observability generator (ISS-004 #7)

Expand Down Expand Up @@ -217,13 +255,17 @@ the feature — note which codes it introduces._
- **D-D3:** UK English in plan and README, locale-neutral in user-facing
CLI output. _Accepted (inherits index constraint)._
- **D-D4:** No localisation in v0.x. _Accepted._
- **D-D5:** Diagnostic codes are **slug-based with an `nxrust:` prefix**
(`nxrust:cargo-not-found`), not numeric. Slugs are more discoverable in
logs and IDE output; the "do not rename" rule (D-D2: rename = major bump,
add = minor bump) supplies the stability numeric codes would otherwise
give. _Accepted 2026-06-24 — resolved via DIAG-002._

## Open Questions

- [ ] Should diagnostic codes be numeric (`NXRUST001`) or slug-based
(`cargo-not-found`)? Slug is more discoverable; numeric is more
stable across renames. Probably slug with a documented "do not
rename" rule.
- [x] Should diagnostic codes be numeric (`NXRUST001`) or slug-based
(`cargo-not-found`)? **Resolved (D-D5):** slug-based with an
`nxrust:` prefix and a documented "do not rename" rule.
- [ ] Should the catalogue live in `docs/diagnostics.md` or be generated
from code annotations? Generated is DRY but adds tooling; manual
Markdown is simpler. Manual for v0.2, generated later.
Expand Down
127 changes: 126 additions & 1 deletion src/utils/diagnostics.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatDiagnostic, redactSecrets } from "./diagnostics";
import {
DIAGNOSTIC_CODES,
NxrustDiagnosticError,
cargoNotFound,
formatDiagnostic,
invalidToolchainLiteral,
nightlyRequired,
redactSecrets,
runWithDiagnostic,
targetNotInstalled,
toolchainNotInstalled,
} from "./diagnostics";

describe("formatDiagnostic", () => {
it("renders the four-part envelope for an error", () => {
Expand Down Expand Up @@ -53,4 +64,118 @@ describe("redactSecrets", () => {
it("leaves a command with no secrets untouched", () => {
expect(redactSecrets("cargo test -p app")).toBe("cargo test -p app");
});

it("redacts secret-bearing flag values too", () => {
expect(redactSecrets("cargo publish --token sekret123 --dry-run")).toBe(
"cargo publish --token <redacted> --dry-run",
);
});

it("redacts the `--token=value` form as well", () => {
expect(redactSecrets("cargo publish --token=sekret123")).toBe(
"cargo publish --token=<redacted>",
);
});
});

describe("toolchain/cargo diagnostic builders", () => {
it("cargoNotFound carries the slug code and a rustup fix", () => {
const d = cargoNotFound("cargo build -p app");
expect(d.code).toBe(DIAGNOSTIC_CODES.cargoNotFound);
expect(d.code).toBe("nxrust:cargo-not-found");
expect(d.fix).toContain("https://rustup.rs");
expect(d.command).toBe("cargo build -p app");
});

it("toolchainNotInstalled quotes `rustup install <channel>`", () => {
const d = toolchainNotInstalled("nightly");
expect(d.code).toBe("nxrust:toolchain-not-installed");
expect(d.what).toContain("nightly");
expect(d.fix).toContain("rustup install nightly");
});

it("targetNotInstalled quotes `rustup target add <triple>`", () => {
const d = targetNotInstalled("x86_64-pc-windows-gnu");
expect(d.code).toBe("nxrust:target-not-installed");
expect(d.fix).toContain("rustup target add x86_64-pc-windows-gnu");
});

it("nightlyRequired offers both the toml and the --toolchain fix", () => {
const d = nightlyRequired();
expect(d.code).toBe("nxrust:nightly-required");
expect(d.fix).toContain('channel = "nightly"');
expect(d.fix).toContain("--toolchain=nightly");
});

it("invalidToolchainLiteral keeps the legacy `from <origin>` wording", () => {
const d = invalidToolchainLiteral("a;b", "projectJsonToolchain");
expect(d.code).toBe("nxrust:invalid-toolchain-literal");
expect(d.what).toContain("invalid toolchain literal from projectJsonToolchain");
expect(d.what).toContain('"a;b"');
});
});

describe("NxrustDiagnosticError", () => {
it("carries the code and formats the envelope as its message", () => {
const err = new NxrustDiagnosticError(toolchainNotInstalled("stable"));
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("nxrust:toolchain-not-installed");
expect(err.message).toBe(formatDiagnostic(err.diagnostic));
expect(err.message).toContain("[nxrust]");
});
});

describe("runWithDiagnostic", () => {
it("throws cargo-not-found on a spawn ENOENT for cargo", () => {
const error = Object.assign(new Error("spawn cargo ENOENT"), { code: "ENOENT" });
expect(() => runWithDiagnostic({ error, binary: "cargo" })).toThrow(NxrustDiagnosticError);
try {
runWithDiagnostic({ error, binary: "cargo" });
} catch (e) {
expect((e as NxrustDiagnosticError).code).toBe("nxrust:cargo-not-found");
}
});

it("classifies rustup 'toolchain not installed' stderr", () => {
expect(() =>
runWithDiagnostic({ stderr: "info: ...\nerror: toolchain 'nightly' is not installed" }),
).toThrow(/rustup install nightly/);
});

it("classifies a missing target stderr", () => {
let caught: NxrustDiagnosticError | undefined;
try {
runWithDiagnostic({
stderr: "error: the target `x86_64-pc-windows-gnu` must be installed",
});
} catch (e) {
caught = e as NxrustDiagnosticError;
}
expect(caught?.code).toBe("nxrust:target-not-installed");
expect(caught?.message).toContain("rustup target add x86_64-pc-windows-gnu");
});

it("classifies a nightly-required stderr", () => {
let caught: NxrustDiagnosticError | undefined;
try {
runWithDiagnostic({ stderr: "error: this feature requires nightly" });
} catch (e) {
caught = e as NxrustDiagnosticError;
}
expect(caught?.code).toBe("nxrust:nightly-required");
});

it("passes unknown cargo stderr through unchanged (no throw)", () => {
expect(() =>
runWithDiagnostic({ stderr: "error[E0382]: borrow of moved value: `x`" }),
).not.toThrow();
});

it("does not flag a stable `requires -Z` advisory as nightly-required", () => {
expect(() =>
runWithDiagnostic({
stderr: "warning: this requires -Zunstable-options; not available on stable",
}),
).not.toThrow();
});
});
Loading
Loading