From 2350e2bb297e31f93399022bd51d24d264dead88 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:07:34 +0800 Subject: [PATCH 01/26] docs: design read-only IDE snapshot protocol --- .../2026-08-05-mcpp-ide-snapshot-design.md | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md diff --git a/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md b/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md new file mode 100644 index 00000000..8af109f4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md @@ -0,0 +1,276 @@ +# Read-only IDE Snapshot Design + +**Status:** Approved for M0 implementation + +**Date:** 2026-08-05 + +## Problem + +Editor integrations currently infer mcpp workspace state from human build output, +`compile_commands.json`, and filesystem conventions. Those heuristics cannot tell +whether a compilation database is current, which workspace member it belongs to, +or whether generating it would install dependencies, run `build.mcpp`, or write +project state. With C++ modules, a wrong or incomplete CDB makes clangd select the +wrong standard library, SDK, module files, or header. + +mcpp already owns the eventual source of truth in `BuildPlan`, but the only path +that constructs it is `prepare_build()`. That function also resolves and installs +toolchains and dependencies, materializes generated files, compiles and runs +`build.mcpp`, and writes lock/build artifacts. It is not a metadata API. + +## Decision + +Introduce a versioned IDE-specific protocol, starting with one one-shot command: + +```text +mcpp ide snapshot --format json [selectors] +``` + +M0 is a strictly read-only workspace inspection. It does not claim to produce the +final BuildPlan. It reports declared workspace/package/target data, the selected +configuration, the expected CDB location, artifact availability, and structured +diagnostics. A later milestone will add explicit `mcpp ide prepare --format +ndjson` to materialize authoritative CDB/PCM/generated artifacts. + +The protocol is owned by mcpp core. The VS Code extension will consume it later; +the extension must not define a second project model or parse human output. + +## Alternatives Considered + +### Wrap `prepare_build()` with JSON + +Rejected. It would make a command presented as inspection install packages, +download toolchains, execute project code, and write lock/build artifacts. Adding +booleans around individual writes would still leave a large, fragile matrix of +implicit side effects. + +### Publish `BuildPlan` directly + +Rejected. `BuildPlan` is an internal execution model whose fields and invariants +may change with the Ninja backend. Serializing it directly would turn internal +refactors into protocol breaks and would expose paths that are not valid until +materialization succeeds. + +### Implement a daemon, JSON-RPC service, or LSP now + +Rejected for V1. One-shot CLI calls are sufficient for workspace open/config +change events, are easy to cancel, and avoid lifecycle and protocol negotiation +complexity. A long-running transport can be added after measured demand. + +## M0 Architecture + +The implementation has four boundaries: + +1. `mcpp.ide.model` defines the public in-memory DTO. It does not import + `mcpp.build.plan`. +2. `mcpp.ide.inspect` performs filesystem and manifest reads only. It locates the + workspace, parses members, applies existing member-selection rules, and probes + whether each selected member has `compile_commands.json`. +3. `mcpp.ide.snapshot` converts the DTO to a single deterministic JSON document + and computes an opaque content-derived snapshot ID. +4. `mcpp.cli.cmd_ide` translates CLI selectors into an inspection request, writes + exactly one JSON document to stdout, and maps protocol state to an exit code. + +`mcpp.cli` remains a thin router. `prepare_build()`, dependency resolution, +toolchain resolution, Ninja generation, and CDB writing are unchanged in M0. + +## Request Selectors + +M0 accepts and echoes these selectors: + +```text +--package, -p +--workspace +--profile +--target +--features +--cap +--include-dev-dependencies +--format json +``` + +Selectors that require final build resolution are echoed but not interpreted +beyond member selection. This is explicit in the capability list and artifact +state. `--format` defaults to `json`; any other value returns a structured +`MCPP_IDE_UNSUPPORTED_FORMAT` diagnostic. + +Member selection follows existing command semantics: + +- `-p` selects exactly the matching member by relative path or basename. +- `--workspace` selects every declared member and the root package when present. +- A virtual workspace with no selector selects all declared members. +- A rooted workspace with no selector selects the root package. +- A non-workspace project selects its root package. + +The snapshot always lists all successfully parsed workspace members, even when a +selector narrows `selectedMembers`. + +## Public JSON Contract + +The top-level object contains: + +```json +{ + "schemaVersion": 1, + "kind": "mcpp.ide.snapshot", + "snapshotId": "fnv1a64:0123456789abcdef", + "state": "partial", + "mcpp": { + "version": "2026.8.5.2", + "protocol": {"min": 1, "max": 1}, + "capabilities": [ + "workspace-inspection", + "manifest-diagnostics", + "compile-commands-location" + ] + }, + "request": { + "root": "/absolute/request/path", + "selectors": { + "package": null, + "workspace": false, + "profile": null, + "target": null, + "features": [], + "capabilities": [], + "includeDevDependencies": false + }, + "mode": "read-only" + }, + "workspace": { + "root": "/absolute/workspace/root", + "manifest": "/absolute/workspace/root/mcpp.toml", + "members": [], + "selectedMembers": [] + }, + "artifacts": { + "state": "partial", + "compileCommands": [] + }, + "diagnostics": [] +} +``` + +Clients must ignore unknown fields. `schemaVersion` changes only for incompatible +field or semantic changes. Additive optional fields remain schema 1. Capability +strings state which portions of the larger IDE design this binary implements. + +Each member contains `name`, `version`, `root`, `manifest`, and declared targets. +Each target contains `name`, `kind`, and optional `main`. Target kinds are stable +wire strings: `library`, `binary`, `shared-library`, and `test-binary`. + +Each CDB artifact contains `member`, absolute `path`, and `state`. M0 states are: + +- `missing`: no CDB exists. +- `stale`: a CDB exists, but M0 has no mcpp-published freshness metadata. + +M0 never reports a CDB as `ready`. + +## Snapshot State + +The top-level and artifact state machine is deliberately conservative: + +- `partial`: manifests were inspected, but every selected CDB is missing. +- `stale`: at least one selected CDB exists but none can be freshness-verified. +- `unavailable`: no usable workspace snapshot can be formed because the root + manifest is missing/invalid or the requested member is invalid. +- `ready`: reserved for a later `ide prepare` publication and never emitted by M0. + +Valid partial/stale snapshots exit 0. Unavailable snapshots exit 3. Human CLI +usage errors such as bare `mcpp ide` retain exit 2. + +## Diagnostics + +Diagnostics have stable fields: + +```json +{ + "code": "MCPP_IDE_MANIFEST_INVALID", + "severity": "error", + "message": "expected value", + "source": "mcpp", + "path": "/project/mcpp.toml", + "range": { + "start": {"line": 4, "column": 9}, + "end": {"line": 4, "column": 9} + } +} +``` + +`path` and `range` are optional. Manifest parser locations are one-based and stay +one-based on the wire in schema 1. M0 defines these codes: + +- `MCPP_IDE_MANIFEST_NOT_FOUND` +- `MCPP_IDE_MANIFEST_INVALID` +- `MCPP_IDE_MEMBER_MANIFEST_MISSING` +- `MCPP_IDE_MEMBER_MANIFEST_INVALID` +- `MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND` +- `MCPP_IDE_ARTIFACTS_MISSING` +- `MCPP_IDE_ARTIFACTS_UNVERIFIED` +- `MCPP_IDE_UNSUPPORTED_FORMAT` + +Member manifest failures are diagnostics attached to the workspace. They become +fatal only when the failed member is selected or when no selected member remains. + +## Snapshot Identity + +The snapshot ID is opaque to clients. M0 serializes a canonical JSON value with +`snapshotId` omitted, then computes FNV-1a 64 and prefixes it with `fnv1a64:`. +This is an identity/checksum, not a security primitive. Absolute paths and input +content represented by the DTO participate, while wall-clock time does not. +Identical inspection results therefore produce identical IDs and fixtures. + +## Read-only Guarantee + +`snapshot` may perform only these operations: + +- Resolve absolute/canonical paths. +- Search ancestors for `mcpp.toml`. +- Read root/member manifests. +- Test existence and type of known manifest/CDB paths. + +It must not: + +- Initialize or write `MCPP_HOME`. +- Create `.mcpp`, `.xlings.json`, `target`, CDB, lock, or snapshot files. +- Refresh indices or access the network. +- Resolve/install toolchains or dependencies. +- Materialize generated files. +- Compile or execute `build.mcpp`. +- Run a compiler, scanner, Ninja, or user target. + +The implementation therefore must not import or call `prepare_build()`, config +initialization, xlings, fetcher, toolchain detection, or build execution modules. + +## Testing + +Unit tests construct temporary project trees and call `inspect_workspace()` and +JSON serialization directly. They cover single packages, rooted/virtual +workspaces, member selectors, malformed manifests with locations, missing member +manifests, deterministic snapshot IDs, target kind mapping, and partial/stale +artifact states. + +E2E tests invoke the fresh mcpp binary. They parse stdout with Python when +available and have portable grep fallbacks. They assert: + +- stdout is one JSON document on success and unavailable failure. +- no human status or ANSI text appears on stdout. +- invalid format/member/manifest paths produce stable diagnostics and exits. +- a virtual workspace selects all members by default. +- an existing CDB is stale, not ready. +- before/after project and isolated `MCPP_HOME` trees are byte/path identical. + +The full unit/integration suite and focused E2E must run with the fresh binary +produced by `mcpp build`. + +## Deferred Milestones + +`mcpp ide prepare` will add explicit, authorized mutation: toolchain/dependency +resolution, `build.mcpp`, required generated sources, CDB, and module PCM. It must +publish artifacts atomically and must not compile ordinary implementation objects +or link final targets. + +Later snapshots may adapt a resolved plan into versioned toolchain, compile-unit, +module graph, build action, and BMI DTOs. They must remain adapters over internal +models, not serialized internal structs. Package search/install will use a +separate package protocol. From 9933126ed7f941f319505ea020e11f4adb88273a Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:11:29 +0800 Subject: [PATCH 02/26] docs: plan read-only IDE snapshot implementation --- .../plans/2026-08-05-mcpp-ide-snapshot.md | 608 ++++++++++++++++++ .../2026-08-05-mcpp-ide-snapshot-design.md | 1 + 2 files changed, 609 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md diff --git a/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md b/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md new file mode 100644 index 00000000..91acf8ec --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md @@ -0,0 +1,608 @@ +# Read-only IDE Snapshot Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a versioned, one-shot `mcpp ide snapshot --format json` command that inspects mcpp workspaces without installing, building, executing project code, or writing artifacts. + +**Architecture:** Define an IDE-owned public DTO, populate it through a new read-only workspace inspector, and serialize it through a deterministic JSON adapter. Keep CLI parsing in `mcpp.cli.cmd_ide`; do not import `mcpp.build.prepare`, toolchain resolution, xlings, config initialization, or build execution anywhere below the IDE command. + +**Tech Stack:** C++23 named modules, `std::expected`/`std::filesystem`, existing `mcpp.manifest` and `mcpp.project` read paths, vendored nlohmann JSON module, GoogleTest, portable Bash E2E, self-hosted `mcpp build`/fresh-binary `mcpp test`. + +--- + +## File Map + +| File | Responsibility | +| --- | --- | +| `src/ide/model.cppm` | Stable IDE DTOs and wire-enum name helpers; no build-plan dependency | +| `src/ide/inspect.cppm` | Read-only root/workspace/member discovery, manifest diagnostics, selection, and CDB existence probes | +| `src/ide/snapshot.cppm` | DTO-to-JSON adapter and deterministic snapshot ID | +| `src/cli/cmd_ide.cppm` | CLI selector parsing, one-document stdout, format/error exit mapping | +| `src/cli.cppm` | Import/register `ide snapshot`, help entry, known-command list | +| `tests/unit/test_ide_snapshot.cpp` | DTO, inspection, selector, diagnostic, artifact, serialization, and identity tests | +| `tests/fixtures/ide/snapshot-v1.json` | Schema-1 golden fixture consumed by unit tests | +| `tests/e2e/185_ide_snapshot.sh` | Fresh-binary CLI contract and workspace/error cases | +| `tests/e2e/186_ide_snapshot_read_only.sh` | Before/after project and isolated-home zero-side-effect proof | +| `docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md` | Approved protocol and side-effect contract | + +### Task 1: Define the Public IDE DTO and Protocol Fixture + +**Files:** +- Create: `src/ide/model.cppm` +- Create: `tests/fixtures/ide/snapshot-v1.json` +- Create: `tests/unit/test_ide_snapshot.cpp` + +- [ ] **Step 1: Add a failing DTO/wire-name test** + +Create `tests/unit/test_ide_snapshot.cpp` with the fixture helper and initial enum contract: + +```cpp +#include + +import std; +import mcpp.ide.model; + +TEST(IdeSnapshotModel, WireNamesAreStable) { + using namespace mcpp::ide; + EXPECT_EQ(wire_name(SnapshotState::Partial), "partial"); + EXPECT_EQ(wire_name(SnapshotState::Stale), "stale"); + EXPECT_EQ(wire_name(SnapshotState::Unavailable), "unavailable"); + EXPECT_EQ(wire_name(SnapshotState::Ready), "ready"); + EXPECT_EQ(wire_name(ArtifactState::Missing), "missing"); + EXPECT_EQ(wire_name(ArtifactState::Stale), "stale"); + EXPECT_EQ(wire_name(Severity::Warning), "warning"); + EXPECT_EQ(wire_name(Severity::Error), "error"); +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +mcpp test ide_snapshot +``` + +Expected: build failure because module `mcpp.ide.model` does not exist. + +- [ ] **Step 3: Implement the DTO module** + +Create `src/ide/model.cppm` with these exported types and no imports outside `std`: + +```cpp +export module mcpp.ide.model; + +import std; + +export namespace mcpp::ide { + +enum class SnapshotState { Partial, Stale, Unavailable, Ready }; +enum class ArtifactState { Missing, Stale }; +enum class Severity { Warning, Error }; + +std::string_view wire_name(SnapshotState value); +std::string_view wire_name(ArtifactState value); +std::string_view wire_name(Severity value); + +struct Position { + std::size_t line = 0; + std::size_t column = 0; +}; + +struct Range { + Position start; + Position end; +}; + +struct Diagnostic { + std::string code; + Severity severity = Severity::Error; + std::string message; + std::filesystem::path path; + std::optional range; +}; + +struct Selectors { + std::optional package; + bool workspace = false; + std::optional profile; + std::optional target; + std::vector features; + std::vector capabilities; + bool includeDevDependencies = false; +}; + +struct InspectRequest { + std::filesystem::path start; + Selectors selectors; +}; + +struct DeclaredTarget { + std::string name; + std::string kind; + std::optional main; +}; + +struct WorkspaceMember { + std::string name; + std::string version; + std::string workspacePath; + std::filesystem::path root; + std::filesystem::path manifest; + std::vector targets; +}; + +struct CompileCommandsArtifact { + std::string member; + std::filesystem::path path; + ArtifactState state = ArtifactState::Missing; +}; + +struct WorkspaceInspection { + SnapshotState state = SnapshotState::Unavailable; + InspectRequest request; + std::filesystem::path workspaceRoot; + std::filesystem::path workspaceManifest; + std::vector members; + std::vector selectedMembers; + std::vector compileCommands; + std::vector diagnostics; +}; + +} // namespace mcpp::ide +``` + +Define each `wire_name` as an exhaustive switch returning the strings pinned by +the test. No default branch is needed; return an empty string after the switch to +satisfy compilers that do not treat an exhaustive enum switch as terminating. + +- [ ] **Step 4: Add the schema-1 golden fixture** + +Create `tests/fixtures/ide/snapshot-v1.json` as a complete, valid example. Use +fixed `/workspace/app` paths, `snapshotId` `fnv1a64:0000000000000000`, one binary +target, one missing CDB, state `partial`, and one `MCPP_IDE_ARTIFACTS_MISSING` +warning. Include every required top-level object from the design spec. + +- [ ] **Step 5: Run the focused test and verify GREEN** + +Run `mcpp test ide_snapshot`. + +Expected: `IdeSnapshotModel.WireNamesAreStable` passes. + +- [ ] **Step 6: Commit the model and fixture** + +```bash +git add src/ide/model.cppm tests/unit/test_ide_snapshot.cpp tests/fixtures/ide/snapshot-v1.json +git commit -m "feat(ide): define snapshot protocol model" +``` + +### Task 2: Implement Read-only Workspace Inspection + +**Files:** +- Create: `src/ide/inspect.cppm` +- Modify: `tests/unit/test_ide_snapshot.cpp` + +- [ ] **Step 1: Add a temporary-project test helper** + +Add a local RAII helper to `tests/unit/test_ide_snapshot.cpp`: + +```cpp +namespace { +struct TempProject { + std::filesystem::path root = std::filesystem::temp_directory_path() / + std::format("mcpp-ide-{}", std::chrono::steady_clock::now() + .time_since_epoch().count()); + TempProject() { std::filesystem::create_directories(root); } + ~TempProject() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + void write(std::filesystem::path relative, std::string_view content) { + auto path = root / relative; + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path) << content; + } +}; +} // namespace +``` + +- [ ] **Step 2: Add failing inspection tests** + +Import `mcpp.ide.inspect` and add tests with these exact assertions: + +```cpp +TEST(IdeSnapshotInspect, SinglePackageIsPartialWithoutCdb) { + TempProject p; + p.write("mcpp.toml", "[package]\nname=\"app\"\nversion=\"1.2.3\"\n"); + auto result = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(result.state, mcpp::ide::SnapshotState::Partial); + ASSERT_EQ(result.members.size(), 1u); + EXPECT_EQ(result.members[0].name, "app"); + EXPECT_EQ(result.selectedMembers, std::vector{"app"}); + ASSERT_EQ(result.compileCommands.size(), 1u); + EXPECT_EQ(result.compileCommands[0].state, mcpp::ide::ArtifactState::Missing); + ASSERT_EQ(result.diagnostics.size(), 1u); + EXPECT_EQ(result.diagnostics[0].code, "MCPP_IDE_ARTIFACTS_MISSING"); +} + +TEST(IdeSnapshotInspect, ExistingCdbIsStaleNeverReady) { + TempProject p; + p.write("mcpp.toml", "[package]\nname=\"app\"\nversion=\"1.0.0\"\n"); + p.write("compile_commands.json", "[]\n"); + auto result = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(result.state, mcpp::ide::SnapshotState::Stale); + EXPECT_EQ(result.compileCommands[0].state, mcpp::ide::ArtifactState::Stale); + EXPECT_EQ(result.diagnostics[0].code, "MCPP_IDE_ARTIFACTS_UNVERIFIED"); +} +``` + +Also add tests for: + +- no ancestor `mcpp.toml` -> unavailable + `MCPP_IDE_MANIFEST_NOT_FOUND`; +- malformed root manifest -> unavailable + parser path/range; +- virtual workspace no selector -> all valid members selected; +- rooted workspace no selector -> root selected; +- invocation from member directory -> current member selected; +- `selectors.package` matches basename and relative workspace path; +- unknown package -> unavailable + `MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND`; +- missing/invalid unselected member -> diagnostic but valid selected member survives; +- missing/invalid selected member -> unavailable. + +- [ ] **Step 3: Run focused tests and verify RED** + +Run `mcpp test ide_snapshot`. + +Expected: build failure because `mcpp.ide.inspect` and `inspect_workspace` do not +exist. + +- [ ] **Step 4: Implement `mcpp.ide.inspect`** + +Create `src/ide/inspect.cppm`: + +```cpp +export module mcpp.ide.inspect; + +import std; +import mcpp.ide.model; +import mcpp.manifest; +import mcpp.project; + +export namespace mcpp::ide { +WorkspaceInspection inspect_workspace(InspectRequest request); +} +``` + +Implementation order: + +1. Normalize `request.start` to an absolute path without creating it. +2. Call `project::find_manifest_root`; on miss, return unavailable with + `MCPP_IDE_MANIFEST_NOT_FOUND`. +3. Load the discovered manifest. Convert `ManifestError` into a diagnostic that + preserves `file`, `line`, and `column`. +4. If the discovered package is a member, use `find_workspace_root` to locate and + load its workspace manifest while retaining the member as the implicit + selection. +5. Add the rooted workspace package as `workspacePath = "."` when present, then + load each declared member manifest in declaration order. +6. Convert manifest targets using stable target-kind strings and absolute paths. +7. Apply package/workspace/implicit selection rules from the design spec. +8. Probe only `/compile_commands.json` with `exists` and + `is_regular_file`; never open or validate the CDB in M0. +9. Emit one aggregate missing or unverified artifact warning and derive top-level + partial/stale/unavailable state conservatively. + +Use `std::error_code` filesystem overloads where available so permission errors +become diagnostics instead of exceptions. Do not call `prepare_build`, config, +xlings, toolchain, dependency, scanner, or build modules. + +- [ ] **Step 5: Run focused tests and verify GREEN** + +Run `mcpp test ide_snapshot`. + +Expected: every `IdeSnapshotInspect.*` test passes. + +- [ ] **Step 6: Commit the inspector** + +```bash +git add src/ide/inspect.cppm tests/unit/test_ide_snapshot.cpp +git commit -m "feat(ide): inspect workspaces without side effects" +``` + +### Task 3: Serialize the Versioned Snapshot + +**Files:** +- Create: `src/ide/snapshot.cppm` +- Modify: `tests/unit/test_ide_snapshot.cpp` +- Modify: `tests/fixtures/ide/snapshot-v1.json` + +- [ ] **Step 1: Add failing serializer tests** + +Import `mcpp.ide.snapshot` and `mcpp.libs.json`. Add tests that: + +- call `snapshot_json(inspection)` and parse the result as exactly one JSON value; +- assert `schemaVersion == 1`, `kind == "mcpp.ide.snapshot"`, version/protocol, + capability strings, request selectors, members, selected members, artifacts, + diagnostics, and one-based range; +- call twice and assert byte-identical output and identical `snapshotId`; +- modify a selected member and assert the ID changes; +- load `tests/fixtures/ide/snapshot-v1.json`, replace its sentinel snapshot ID + with the computed ID, and compare the parsed JSON values. + +Use a direct fixture path rooted at `std::filesystem::current_path()` because +fresh-binary `mcpp test` runs tests from the project root. + +- [ ] **Step 2: Run focused tests and verify RED** + +Run `mcpp test ide_snapshot`. + +Expected: build failure because module `mcpp.ide.snapshot` does not exist. + +- [ ] **Step 3: Implement JSON serialization and identity** + +Create `src/ide/snapshot.cppm` with: + +```cpp +export module mcpp.ide.snapshot; + +import std; +import mcpp.ide.model; +import mcpp.libs.json; +import mcpp.version; + +export namespace mcpp::ide { +std::string snapshot_json(const WorkspaceInspection& inspection); +} +``` + +Build a `nlohmann::ordered_json` object in protocol field order. Represent absent +selectors as JSON null and preserve selector list order. Omit `path`/`range` from +diagnostics when unavailable. Serialize once without `snapshotId`, hash its +compact `dump()` bytes with local FNV-1a 64, insert +`std::format("fnv1a64:{:016x}", hash)`, and return `dump(2) + "\n"`. + +The serializer must not read the filesystem or environment. It consumes only the +DTO and `mcpp::MCPP_VERSION`. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run `mcpp test ide_snapshot`. + +Expected: fixture, deterministic identity, and full field tests pass. + +- [ ] **Step 5: Commit the serializer** + +```bash +git add src/ide/snapshot.cppm tests/unit/test_ide_snapshot.cpp tests/fixtures/ide/snapshot-v1.json +git commit -m "feat(ide): serialize versioned workspace snapshots" +``` + +### Task 4: Add `mcpp ide snapshot` CLI Routing + +**Files:** +- Create: `src/cli/cmd_ide.cppm` +- Modify: `src/cli.cppm` +- Modify: `tests/e2e/01_help_and_version.sh` +- Create: `tests/e2e/185_ide_snapshot.sh` + +- [ ] **Step 1: Add failing help and CLI E2E assertions** + +Update `tests/e2e/01_help_and_version.sh` to require `mcpp ide snapshot` in help. + +Create `tests/e2e/185_ide_snapshot.sh` with `# requires:` on line 2. It must: + +1. Create a minimal package in `mktemp -d` without calling `mcpp new`. +2. Run `"$MCPP" ide snapshot --format json`, capture stdout and stderr + separately, and require exit 0. +3. Parse stdout with Python when present and assert one object with schema 1, + kind, partial state, member `app`, selected member `app`, and missing CDB. +4. Require empty stderr and reject ANSI escape bytes in stdout. +5. Add `compile_commands.json`, rerun, and assert stale rather than ready. +6. Build a virtual two-member workspace and assert default all-member selection, + `-p one` selection, and `--workspace` selection. +7. Assert unknown `-p`, invalid manifest, and `--format yaml` each return 3 and + one JSON document containing the expected diagnostic code. + +- [ ] **Step 2: Build and run the focused E2E to verify RED** + +Run: + +```bash +mcpp build +FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) +MCPP="$FRESH_MCPP" bash tests/e2e/185_ide_snapshot.sh +``` + +Expected: unknown command `ide` or missing help entry. + +- [ ] **Step 3: Implement the CLI command module** + +Create `src/cli/cmd_ide.cppm`: + +```cpp +module; +#include + +export module mcpp.cli.cmd_ide; + +import std; +import mcpplibs.cmdline; +import mcpp.ide.inspect; +import mcpp.ide.model; +import mcpp.ide.snapshot; + +export namespace mcpp::cli { +int cmd_ide_snapshot(const mcpplibs::cmdline::ParsedArgs& parsed); +} +``` + +Parse CSV selector values with a local helper that drops empty items but preserves +order. Default `start` to `current_path()`. If `--format` is absent or `json`, run +the inspector; otherwise construct an unavailable inspection with +`MCPP_IDE_UNSUPPORTED_FORMAT`. Print exactly `snapshot_json(result)` to stdout +with `std::print`. Return 3 only for `Unavailable`, otherwise 0. + +- [ ] **Step 4: Register the nested command** + +In `src/cli.cppm`: + +- import `mcpp.cli.cmd_ide`; +- add `mcpp ide snapshot` to canonical help; +- add an `ide` app with nested `snapshot` and all selectors from the design; +- route through existing `dispatch_sub("ide", ..., {{"snapshot", + cmd_ide_snapshot}})`; +- add `ide` to the known-command array and update the array extent. + +Do not add CLI business logic to `src/cli.cppm`. + +- [ ] **Step 5: Rebuild and run focused tests/E2E to verify GREEN** + +Run: + +```bash +mcpp build +FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) +"$FRESH_MCPP" test ide_snapshot +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +MCPP="$FRESH_MCPP" bash tests/e2e/185_ide_snapshot.sh +``` + +Expected: focused unit tests and both E2E scripts pass. + +- [ ] **Step 6: Commit the CLI** + +```bash +git add src/cli/cmd_ide.cppm src/cli.cppm tests/e2e/01_help_and_version.sh tests/e2e/185_ide_snapshot.sh +git commit -m "feat(cli): add read-only ide snapshot command" +``` + +### Task 5: Prove the Zero-side-effect Contract + +**Files:** +- Create: `tests/e2e/186_ide_snapshot_read_only.sh` +- Modify: `tests/unit/test_ide_snapshot.cpp` + +- [ ] **Step 1: Add the failing side-effect E2E** + +Create `tests/e2e/186_ide_snapshot_read_only.sh` with `# requires:` on line 2. The +script must create a project containing: + +- a remote-looking version dependency; +- `[generated_files]` output that would be written by a build; +- `build.mcpp` that writes a sentinel if executed; +- a toolchain declaration whose payload is absent from the isolated home. + +Set `MCPP_HOME` to an empty sibling directory. Record sorted file paths and SHA-256 +hashes for both project and home before invocation. Run snapshot in default mode +and again with target/profile/features/cap/include-dev selectors. Record the same +inventories afterward and require byte equality. + +Explicitly assert absence of `.mcpp`, `.xlings.json`, `mcpp.lock`, `target`, +`compile_commands.json`, generated output, `build.mcpp` sentinel, and every file +under isolated `MCPP_HOME`. + +- [ ] **Step 2: Run the E2E before any correction** + +Run with the current fresh binary: + +```bash +FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) +MCPP="$FRESH_MCPP" bash tests/e2e/186_ide_snapshot_read_only.sh +``` + +Expected: pass if the architecture boundary is intact. If it fails, treat any new +file, network/install output, or build sentinel as a release-blocking defect and +remove the offending dependency/import/call from the IDE path. + +- [ ] **Step 3: Add an in-process no-write unit assertion** + +Add a test that snapshots a recursive inventory of the temporary project before +and after `inspect_workspace()` and compares paths, file sizes, and modification +times. This gives a fast local guard even when the E2E hash utility varies by OS. + +- [ ] **Step 4: Run focused unit and side-effect E2E** + +Run `mcpp test ide_snapshot`, then the fresh-binary E2E command above. + +Expected: both pass; no project/home additions or modifications. + +- [ ] **Step 5: Commit the side-effect proof** + +```bash +git add tests/unit/test_ide_snapshot.cpp tests/e2e/186_ide_snapshot_read_only.sh +git commit -m "test(ide): prove snapshot is read only" +``` + +### Task 6: Full Verification and Contribution Handoff + +**Files:** +- Modify only files required by failures attributable to this branch + +- [ ] **Step 1: Check formatting and scope** + +Run: + +```bash +git diff origin/main --check +git status --short +git diff origin/main --stat +``` + +Expected: no whitespace errors and only the files in this plan. + +- [ ] **Step 2: Self-host a fresh binary** + +Run `mcpp build`. + +Expected: successful release-profile self-host build. Locate the binary without +hardcoding the host triple: + +```bash +find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 -print +``` + +- [ ] **Step 3: Run the full unit/integration suite with the fresh binary** + +```bash +FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) +"$FRESH_MCPP" test +``` + +Expected: all test binaries pass with zero failures. + +- [ ] **Step 4: Run focused E2E with the fresh binary** + +```bash +MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh +MCPP="$FRESH_MCPP" bash tests/e2e/185_ide_snapshot.sh +MCPP="$FRESH_MCPP" bash tests/e2e/186_ide_snapshot_read_only.sh +``` + +Expected: all three scripts print `OK` and exit 0. + +- [ ] **Step 5: Inspect the protocol manually** + +Run: + +```bash +"$FRESH_MCPP" ide snapshot --format json +``` + +Expected: one JSON object, schema 1, state `stale` for this already-built +worktree, and no human status text on stdout. + +- [ ] **Step 6: Review commits and working tree** + +```bash +git log --oneline origin/main..HEAD +git status --short --branch +``` + +Expected: focused design/model/inspection/serialization/CLI/test commits and a +clean worktree. + +- [ ] **Step 7: Prepare the external contribution handoff** + +The repository contribution guide requires a GitHub Issue and PR, but publishing +external state is outside the current local-only authorization. Report the local +branch, commit list, verification evidence, and the exact remaining Issue/PR +actions. Do not push or create remote objects without explicit authorization. diff --git a/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md b/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md index 8af109f4..92e9c41e 100644 --- a/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md +++ b/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md @@ -100,6 +100,7 @@ Member selection follows existing command semantics: - `--workspace` selects every declared member and the root package when present. - A virtual workspace with no selector selects all declared members. - A rooted workspace with no selector selects the root package. +- An invocation from a member directory with no selector selects that member. - A non-workspace project selects its root package. The snapshot always lists all successfully parsed workspace members, even when a From 210a31b485d46bf30e9fb4e3af783adc12e6edfd Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:16:04 +0800 Subject: [PATCH 03/26] feat(ide): define snapshot protocol model --- src/ide/model.cppm | 115 ++++++++++++++++++++++++++++ tests/fixtures/ide/snapshot-v1.json | 71 +++++++++++++++++ tests/unit/test_ide_snapshot.cpp | 16 ++++ 3 files changed, 202 insertions(+) create mode 100644 src/ide/model.cppm create mode 100644 tests/fixtures/ide/snapshot-v1.json create mode 100644 tests/unit/test_ide_snapshot.cpp diff --git a/src/ide/model.cppm b/src/ide/model.cppm new file mode 100644 index 00000000..e38ce10b --- /dev/null +++ b/src/ide/model.cppm @@ -0,0 +1,115 @@ +export module mcpp.ide.model; + +import std; + +export namespace mcpp::ide { + +enum class SnapshotState { + Partial, + Stale, + Unavailable, + Ready, +}; + +enum class ArtifactState { + Missing, + Stale, +}; + +enum class Severity { + Warning, + Error, +}; + +std::string_view wire_name(SnapshotState state) { + switch (state) { + case SnapshotState::Partial: return "partial"; + case SnapshotState::Stale: return "stale"; + case SnapshotState::Unavailable: return "unavailable"; + case SnapshotState::Ready: return "ready"; + } + return {}; +} + +std::string_view wire_name(ArtifactState state) { + switch (state) { + case ArtifactState::Missing: return "missing"; + case ArtifactState::Stale: return "stale"; + } + return {}; +} + +std::string_view wire_name(Severity severity) { + switch (severity) { + case Severity::Warning: return "warning"; + case Severity::Error: return "error"; + } + return {}; +} + +struct Position { + std::size_t line = 0; + std::size_t column = 0; +}; + +struct Range { + Position start; + Position end; +}; + +struct Diagnostic { + std::string code; + Severity severity = Severity::Error; + std::string message; + std::filesystem::path path; + std::optional range; +}; + +struct Selectors { + std::optional package; + bool workspace = false; + std::optional profile; + std::optional target; + std::vector features; + std::vector capabilities; + bool includeDevDependencies = false; +}; + +struct InspectRequest { + std::filesystem::path start; + Selectors selectors; +}; + +struct DeclaredTarget { + std::string name; + std::string kind; + std::optional main; +}; + +struct WorkspaceMember { + std::string name; + std::string version; + std::string workspacePath; + std::filesystem::path root; + std::filesystem::path manifest; + std::vector targets; +}; + +struct CompileCommandsArtifact { + std::string member; + std::filesystem::path path; + ArtifactState state = ArtifactState::Missing; +}; + +struct WorkspaceInspection { + SnapshotState state = SnapshotState::Unavailable; + InspectRequest request; + std::filesystem::path workspaceRoot; + std::filesystem::path workspaceManifest; + std::vector members; + std::vector selectedMembers; + std::vector compileCommands; + std::vector diagnostics; +}; + +} // namespace mcpp::ide diff --git a/tests/fixtures/ide/snapshot-v1.json b/tests/fixtures/ide/snapshot-v1.json new file mode 100644 index 00000000..f8d56d4d --- /dev/null +++ b/tests/fixtures/ide/snapshot-v1.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "kind": "mcpp.ide.snapshot", + "snapshotId": "fnv1a64:0000000000000000", + "state": "partial", + "mcppVersion": "2026.8.5.2", + "protocol": { + "min": 1, + "max": 1 + }, + "capabilities": [ + "workspace-inspection", + "manifest-diagnostics", + "compile-commands-location" + ], + "request": { + "start": "/workspace/app", + "mode": "read-only", + "selectors": { + "package": null, + "workspace": false, + "profile": null, + "target": null, + "features": [], + "capabilities": [], + "includeDevDependencies": false + } + }, + "workspace": { + "root": "/workspace/app", + "manifest": "/workspace/app/mcpp.toml", + "members": [ + { + "name": "app", + "version": "1.0.0", + "workspacePath": ".", + "root": "/workspace/app", + "manifest": "/workspace/app/mcpp.toml", + "targets": [ + { + "name": "app", + "kind": "binary", + "main": "src/main.cpp" + } + ] + } + ], + "selectedMembers": [ + "app" + ] + }, + "artifacts": { + "state": "partial", + "compileCommands": [ + { + "member": "app", + "path": "/workspace/app/compile_commands.json", + "state": "missing" + } + ] + }, + "diagnostics": [ + { + "code": "MCPP_IDE_ARTIFACTS_MISSING", + "severity": "warning", + "message": "Compile commands are unavailable for one or more selected members.", + "path": "/workspace/app/mcpp.toml", + "range": null + } + ] +} diff --git a/tests/unit/test_ide_snapshot.cpp b/tests/unit/test_ide_snapshot.cpp new file mode 100644 index 00000000..bd14bc0a --- /dev/null +++ b/tests/unit/test_ide_snapshot.cpp @@ -0,0 +1,16 @@ +#include + +import std; +import mcpp.ide.model; + +TEST(IdeSnapshotModel, WireNamesAreStable) { + using namespace mcpp::ide; + EXPECT_EQ(wire_name(SnapshotState::Partial), "partial"); + EXPECT_EQ(wire_name(SnapshotState::Stale), "stale"); + EXPECT_EQ(wire_name(SnapshotState::Unavailable), "unavailable"); + EXPECT_EQ(wire_name(SnapshotState::Ready), "ready"); + EXPECT_EQ(wire_name(ArtifactState::Missing), "missing"); + EXPECT_EQ(wire_name(ArtifactState::Stale), "stale"); + EXPECT_EQ(wire_name(Severity::Warning), "warning"); + EXPECT_EQ(wire_name(Severity::Error), "error"); +} From 3885a63b531912a3e2bceab3b92517004d9723a4 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:23:49 +0800 Subject: [PATCH 04/26] fix(ide): align snapshot fixture contract --- tests/fixtures/ide/snapshot-v1.json | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/tests/fixtures/ide/snapshot-v1.json b/tests/fixtures/ide/snapshot-v1.json index f8d56d4d..a3de315e 100644 --- a/tests/fixtures/ide/snapshot-v1.json +++ b/tests/fixtures/ide/snapshot-v1.json @@ -3,18 +3,20 @@ "kind": "mcpp.ide.snapshot", "snapshotId": "fnv1a64:0000000000000000", "state": "partial", - "mcppVersion": "2026.8.5.2", - "protocol": { - "min": 1, - "max": 1 + "mcpp": { + "version": "2026.8.5.2", + "protocol": { + "min": 1, + "max": 1 + }, + "capabilities": [ + "workspace-inspection", + "manifest-diagnostics", + "compile-commands-location" + ] }, - "capabilities": [ - "workspace-inspection", - "manifest-diagnostics", - "compile-commands-location" - ], "request": { - "start": "/workspace/app", + "root": "/workspace/app", "mode": "read-only", "selectors": { "package": null, @@ -63,9 +65,8 @@ { "code": "MCPP_IDE_ARTIFACTS_MISSING", "severity": "warning", - "message": "Compile commands are unavailable for one or more selected members.", - "path": "/workspace/app/mcpp.toml", - "range": null + "source": "mcpp", + "message": "Compile commands are unavailable for one or more selected members." } ] } From 9759acfb88d6e6b07850674682941eaedc35368e Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:38:37 +0800 Subject: [PATCH 05/26] feat(ide): inspect workspaces without side effects --- src/ide/inspect.cppm | 268 +++++++++++++++++++++++++++++++ tests/unit/test_ide_snapshot.cpp | 221 +++++++++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 src/ide/inspect.cppm diff --git a/src/ide/inspect.cppm b/src/ide/inspect.cppm new file mode 100644 index 00000000..ca64d25e --- /dev/null +++ b/src/ide/inspect.cppm @@ -0,0 +1,268 @@ +export module mcpp.ide.inspect; + +import std; +import mcpp.ide.model; +import mcpp.manifest; +import mcpp.project; + +namespace mcpp::ide { + +namespace { + +struct Candidate { + std::string name; + std::string workspacePath; + std::filesystem::path root; + std::filesystem::path manifest; + std::optional member; + std::optional error; +}; + +std::filesystem::path absolute_path(std::filesystem::path path) { + std::error_code ec; + auto result = std::filesystem::absolute(path, ec); + return ec ? path : result; +} + +Diagnostic manifest_diagnostic(std::string code, Severity severity, + std::string message, + const std::filesystem::path& path, + std::optional range = std::nullopt) { + return Diagnostic{std::move(code), severity, std::move(message), path, + std::move(range)}; +} + +std::optional error_range(const mcpp::manifest::ManifestError& error) { + if (!error.line) return std::nullopt; + return Range{{error.line, error.column}, {error.line, error.column}}; +} + +WorkspaceMember describe_member(const mcpp::manifest::Manifest& manifest, + std::string workspacePath, + const std::filesystem::path& root) { + WorkspaceMember result; + result.name = manifest.package.name; + result.version = manifest.package.version; + result.workspacePath = std::move(workspacePath); + result.root = root; + result.manifest = manifest.sourcePath; + for (const auto& target : manifest.targets) { + DeclaredTarget out; + out.name = target.name; + if (target.kind == mcpp::manifest::Target::Library) { + out.kind = "library"; + } else if (target.kind == mcpp::manifest::Target::SharedLibrary) { + out.kind = "shared-library"; + } else if (target.kind == mcpp::manifest::Target::TestBinary) { + out.kind = "test-binary"; + if (!target.main.empty()) out.main = target.main; + } else { + out.kind = "binary"; + if (!target.main.empty()) out.main = target.main; + } + result.targets.push_back(std::move(out)); + } + return result; +} + +bool matches_selector(const Candidate& candidate, std::string_view selector, + const std::filesystem::path& workspaceRoot) { + if (candidate.name == selector || candidate.workspacePath == selector) + return true; + if (const auto dot = candidate.name.rfind('.'); + dot != std::string::npos && candidate.name.substr(dot + 1) == selector) + return true; + if (candidate.root.filename() == selector) + return true; + std::error_code ec; + auto relative = std::filesystem::relative(candidate.root, workspaceRoot, ec); + return !ec && relative.generic_string() == selector; +} + +bool matches_workspace_path(const Candidate& candidate, std::string_view selector, + const std::filesystem::path& workspaceRoot) { + if (candidate.workspacePath == selector) return true; + std::error_code ec; + auto relative = std::filesystem::relative(candidate.root, workspaceRoot, ec); + return !ec && relative.generic_string() == selector; +} + +} // namespace +} // namespace mcpp::ide + +export namespace mcpp::ide { + +WorkspaceInspection inspect_workspace(InspectRequest request) { + WorkspaceInspection result; + result.request = request; + result.request.start = absolute_path(std::move(result.request.start)); + + auto startRoot = mcpp::project::find_manifest_root(result.request.start); + if (!startRoot) { + result.workspaceRoot = result.request.start; + result.workspaceManifest = result.workspaceRoot / "mcpp.toml"; + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_MANIFEST_NOT_FOUND", Severity::Error, + "could not locate mcpp.toml", result.workspaceManifest)); + return result; + } + *startRoot = absolute_path(*startRoot); + + auto startManifest = mcpp::manifest::load(*startRoot / "mcpp.toml"); + if (!startManifest) { + result.workspaceRoot = *startRoot; + result.workspaceManifest = *startRoot / "mcpp.toml"; + const auto& error = startManifest.error(); + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_MANIFEST_INVALID", Severity::Error, error.message, + error.file, error_range(error))); + return result; + } + + std::filesystem::path workspaceRoot = *startRoot; + std::optional workspaceManifest = *startManifest; + std::filesystem::path currentMemberRoot; + if (!startManifest->workspace.present) { + auto found = mcpp::project::find_workspace_root(*startRoot); + if (!found.empty()) { + workspaceRoot = absolute_path(found); + currentMemberRoot = *startRoot; + auto loaded = mcpp::manifest::load(workspaceRoot / "mcpp.toml"); + if (loaded) workspaceManifest = std::move(*loaded); + } + } + + result.workspaceRoot = workspaceRoot; + result.workspaceManifest = workspaceRoot / "mcpp.toml"; + + std::vector candidates; + auto add_manifest = [&](const mcpp::manifest::Manifest& manifest, + std::string workspacePath, + const std::filesystem::path& root) { + auto member = describe_member(manifest, std::move(workspacePath), root); + candidates.push_back(Candidate{member.name, member.workspacePath, root, + member.manifest, std::move(member), std::nullopt}); + }; + + if (workspaceManifest->workspace.present && !workspaceManifest->package.name.empty()) + add_manifest(*workspaceManifest, ".", workspaceRoot); + if (!workspaceManifest->workspace.present) + add_manifest(*workspaceManifest, ".", workspaceRoot); + + const auto& declared = workspaceManifest->workspace.members; + for (const auto& workspacePath : declared) { + const auto memberRoot = workspaceRoot / workspacePath; + const auto manifestPath = memberRoot / "mcpp.toml"; + auto loaded = mcpp::manifest::load(manifestPath); + if (loaded) { + add_manifest(*loaded, workspacePath, memberRoot); + continue; + } + std::error_code ec; + const bool exists = std::filesystem::exists(manifestPath, ec); + const auto code = ec ? "MCPP_IDE_MEMBER_MANIFEST_UNREADABLE" + : exists ? "MCPP_IDE_MEMBER_MANIFEST_INVALID" + : "MCPP_IDE_MEMBER_MANIFEST_MISSING"; + const auto message = ec ? std::format("could not inspect member mcpp.toml: {}", + ec.message()) + : exists ? loaded.error().message + : "member mcpp.toml not found"; + const auto range = exists && !ec ? error_range(loaded.error()) : std::nullopt; + candidates.push_back(Candidate{ + manifestPath.filename().string(), workspacePath, memberRoot, manifestPath, + std::nullopt, + manifest_diagnostic(code, Severity::Error, message, manifestPath, range)}); + } + + std::vector selected; + auto select = [&](std::size_t index) { selected.push_back(index); }; + if (result.request.selectors.package) { + for (std::size_t i = 0; i < candidates.size(); ++i) { + if (matches_workspace_path(candidates[i], *result.request.selectors.package, + workspaceRoot)) { + select(i); + break; + } + } + for (std::size_t i = 0; i < candidates.size(); ++i) { + if (matches_selector(candidates[i], *result.request.selectors.package, + workspaceRoot) && selected.empty()) + select(i); + } + if (selected.empty()) { + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND", Severity::Error, + std::format("workspace member '{}' not found", + *result.request.selectors.package), + result.workspaceManifest)); + return result; + } + } else if (result.request.selectors.workspace + || (workspaceManifest->workspace.present && currentMemberRoot.empty() + && workspaceManifest->package.name.empty())) { + for (std::size_t i = 0; i < candidates.size(); ++i) select(i); + } else if (workspaceManifest->workspace.present) { + if (!currentMemberRoot.empty()) { + for (std::size_t i = 0; i < candidates.size(); ++i) + if (candidates[i].root == currentMemberRoot) select(i); + } else if (!workspaceManifest->package.name.empty()) { + for (std::size_t i = 0; i < candidates.size(); ++i) + if (candidates[i].workspacePath == ".") select(i); + } + } else { + if (!candidates.empty()) select(0); + } + + std::set selectedSet(selected.begin(), selected.end()); + bool selectedError = false; + for (std::size_t i = 0; i < candidates.size(); ++i) { + auto& candidate = candidates[i]; + if (candidate.member) { + result.members.push_back(*candidate.member); + } else { + result.diagnostics.push_back(*candidate.error); + } + if (!candidate.member && selectedSet.contains(i)) + selectedError = true; + } + if (selected.empty()) { + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND", Severity::Error, + "no workspace member selected", result.workspaceManifest)); + return result; + } + if (selectedError) return result; + + bool hasStale = false; + bool hasMissing = false; + bool hasUnreadable = false; + for (auto index : selected) { + const auto& candidate = candidates[index]; + if (!candidate.member) continue; + result.selectedMembers.push_back(candidate.name); + const auto path = candidate.root / "compile_commands.json"; + std::error_code ec; + const bool regular = std::filesystem::is_regular_file(path, ec); + const auto state = regular ? ArtifactState::Stale : ArtifactState::Missing; + result.compileCommands.push_back({candidate.name, path, state}); + hasStale = hasStale || regular; + hasMissing = hasMissing || !regular; + hasUnreadable = hasUnreadable || static_cast(ec); + } + if (hasMissing) result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_ARTIFACTS_MISSING", Severity::Warning, + "compile_commands.json is missing for one or more selected members", + result.workspaceManifest)); + if (hasStale) result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_ARTIFACTS_UNVERIFIED", Severity::Warning, + "compile_commands.json exists but was not verified", + result.workspaceManifest)); + if (hasUnreadable) result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_ARTIFACTS_UNREADABLE", Severity::Warning, + "could not inspect compile_commands.json for one or more selected members", + result.workspaceManifest)); + result.state = hasStale ? SnapshotState::Stale : SnapshotState::Partial; + return result; +} + +} // namespace mcpp::ide diff --git a/tests/unit/test_ide_snapshot.cpp b/tests/unit/test_ide_snapshot.cpp index bd14bc0a..3829aa21 100644 --- a/tests/unit/test_ide_snapshot.cpp +++ b/tests/unit/test_ide_snapshot.cpp @@ -2,6 +2,69 @@ import std; import mcpp.ide.model; +import mcpp.ide.inspect; +import mcpp.manifest; + +namespace { + +struct TempProject { + std::filesystem::path root; + + TempProject() { + root = std::filesystem::temp_directory_path() + / std::format("mcpp_ide_{}", std::chrono::steady_clock::now() + .time_since_epoch().count()); + std::filesystem::create_directories(root); + } + + ~TempProject() { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + + void write(std::string_view relative, std::string_view content) const { + auto path = root / relative; + std::filesystem::create_directories(path.parent_path()); + std::ofstream(path) << content; + } +}; + +const mcpp::ide::Diagnostic* diagnostic(const mcpp::ide::WorkspaceInspection& snapshot, + std::string_view code) { + for (const auto& item : snapshot.diagnostics) + if (item.code == code) return &item; + return nullptr; +} + +using Inventory = std::map>; + +Inventory inventory(const std::filesystem::path& root) { + Inventory result; + std::error_code ec; + std::filesystem::recursive_directory_iterator it( + root, std::filesystem::directory_options::skip_permission_denied, ec); + std::filesystem::recursive_directory_iterator end; + for (; it != end; it.increment(ec)) { + if (ec) { ec.clear(); continue; } + const auto relative = std::filesystem::relative(it->path(), root, ec); + if (ec) { ec.clear(); continue; } + const auto size = std::filesystem::is_regular_file(it->path(), ec) + ? std::filesystem::file_size(it->path(), ec) : 0; + ec.clear(); + const auto stamp = std::filesystem::last_write_time(it->path(), ec); + if (ec) { ec.clear(); continue; } + result.emplace(relative, std::tuple{size, stamp}); + } + return result; +} + +std::string package_manifest(std::string_view name = "app", + std::string_view version = "1.2.3") { + return std::format("[package]\nname=\"{}\"\nversion=\"{}\"\n", name, version); +} + +} // namespace TEST(IdeSnapshotModel, WireNamesAreStable) { using namespace mcpp::ide; @@ -14,3 +77,161 @@ TEST(IdeSnapshotModel, WireNamesAreStable) { EXPECT_EQ(wire_name(Severity::Warning), "warning"); EXPECT_EQ(wire_name(Severity::Error), "error"); } + +TEST(IdeSnapshotInspect, SinglePackageIsPartialWithoutCdb) { + TempProject p; + p.write("mcpp.toml", package_manifest()); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + ASSERT_EQ(snapshot.state, mcpp::ide::SnapshotState::Partial); + ASSERT_EQ(snapshot.members.size(), 1u); + EXPECT_EQ(snapshot.members[0].name, "app"); + ASSERT_EQ(snapshot.selectedMembers, std::vector{"app"}); + ASSERT_EQ(snapshot.compileCommands.size(), 1u); + EXPECT_EQ(snapshot.compileCommands[0].state, mcpp::ide::ArtifactState::Missing); + ASSERT_NE(diagnostic(snapshot, "MCPP_IDE_ARTIFACTS_MISSING"), nullptr); +} + +TEST(IdeSnapshotInspect, ExistingCdbIsStaleNeverReady) { + TempProject p; + p.write("mcpp.toml", package_manifest()); + p.write("compile_commands.json", "[]\n"); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Stale); + ASSERT_EQ(snapshot.compileCommands.size(), 1u); + EXPECT_EQ(snapshot.compileCommands[0].state, mcpp::ide::ArtifactState::Stale); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_ARTIFACTS_UNVERIFIED"), nullptr); +} + +TEST(IdeSnapshotInspect, NoManifestIsUnavailable) { + TempProject p; + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_MANIFEST_NOT_FOUND"), nullptr); +} + +TEST(IdeSnapshotInspect, InvalidManifestPreservesLocation) { + TempProject p; + p.write("mcpp.toml", "[package\nname=\"app\"\n"); + auto expected = mcpp::manifest::load(p.root / "mcpp.toml"); + ASSERT_FALSE(expected.has_value()); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + auto* item = diagnostic(snapshot, "MCPP_IDE_MANIFEST_INVALID"); + ASSERT_NE(item, nullptr); + ASSERT_TRUE(item->range.has_value()); + EXPECT_EQ(item->path, expected.error().file); + EXPECT_EQ(item->range->start.line, expected.error().line); + EXPECT_EQ(item->range->start.column, expected.error().column); +} + +TEST(IdeSnapshotInspect, VirtualWorkspaceSelectsAllMembers) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"one\",\"two\"]\n"); + p.write("one/mcpp.toml", package_manifest("one", "1.0.0")); + p.write("two/mcpp.toml", package_manifest("two", "2.0.0")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Partial); + ASSERT_EQ(snapshot.members.size(), 2u); + EXPECT_EQ(snapshot.members[0].workspacePath, "one"); + EXPECT_EQ(snapshot.members[1].workspacePath, "two"); + EXPECT_EQ(snapshot.selectedMembers, (std::vector{"one", "two"})); +} + +TEST(IdeSnapshotInspect, RootedWorkspaceSelectsRoot) { + TempProject p; + p.write("mcpp.toml", package_manifest("root") + "[workspace]\nmembers=[\"member\"]\n"); + p.write("member/mcpp.toml", package_manifest("member")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + ASSERT_EQ(snapshot.members.size(), 2u); + EXPECT_EQ(snapshot.members[0].workspacePath, "."); + EXPECT_EQ(snapshot.selectedMembers, std::vector{"root"}); +} + +TEST(IdeSnapshotInspect, MemberDirectorySelectsCurrentMember) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"member\"]\n"); + p.write("member/mcpp.toml", package_manifest("member")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root / "member"}); + EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); +} + +TEST(IdeSnapshotInspect, PackageSelectorMatchesBasenameAndPath) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"libs/one\",\"apps/two\"]\n"); + p.write("libs/one/mcpp.toml", package_manifest("first")); + p.write("apps/two/mcpp.toml", package_manifest("second")); + auto by_name = mcpp::ide::inspect_workspace({.start = p.root, + .selectors = {.package = "two"}}); + EXPECT_EQ(by_name.selectedMembers, std::vector{"second"}); + auto by_path = mcpp::ide::inspect_workspace({.start = p.root, + .selectors = {.package = "libs/one"}}); + EXPECT_EQ(by_path.selectedMembers, std::vector{"first"}); +} + +TEST(IdeSnapshotInspect, PackageSelectorPathWinsOverNameCollision) { + TempProject p; + p.write("mcpp.toml", package_manifest("app") + "[workspace]\nmembers=[\"app\"]\n"); + p.write("app/mcpp.toml", package_manifest("member")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root, + .selectors = {.package = "app"}}); + EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); +} + +TEST(IdeSnapshotInspect, UnknownPackageUnavailable) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"one\"]\n"); + p.write("one/mcpp.toml", package_manifest("one")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root, + .selectors = {.package = "missing"}}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND"), nullptr); +} + +TEST(IdeSnapshotInspect, UnselectedMemberErrorIsDiagnosticOnly) { + TempProject p; + p.write("mcpp.toml", package_manifest("root") + "[workspace]\nmembers=[\"member\"]\n"); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.selectedMembers, std::vector{"root"}); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_MEMBER_MANIFEST_MISSING"), nullptr); + EXPECT_NE(snapshot.state, mcpp::ide::SnapshotState::Unavailable); +} + +TEST(IdeSnapshotInspect, SelectedMemberErrorIsUnavailable) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"member\"]\n"); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root, + .selectors = {.package = "member"}}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_MEMBER_MANIFEST_MISSING"), nullptr); +} + +TEST(IdeSnapshotInspect, TargetKindsAndMain) { + TempProject p; + p.write("mcpp.toml", package_manifest() + + "[targets.library]\nkind=\"lib\"\n" + "[targets.app]\nkind=\"bin\"\nmain=\"src/main.cpp\"\n" + "[targets.shared]\nkind=\"shared\"\n" + "[targets.test]\nkind=\"bin\"\nmain=\"tests/main.cpp\"\n"); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + ASSERT_EQ(snapshot.members.size(), 1u); + ASSERT_EQ(snapshot.members[0].targets.size(), 4u); + EXPECT_EQ(snapshot.members[0].targets[0].name, "app"); + EXPECT_EQ(snapshot.members[0].targets[0].kind, "binary"); + EXPECT_EQ(snapshot.members[0].targets[0].main, std::optional("src/main.cpp")); + EXPECT_EQ(snapshot.members[0].targets[1].name, "library"); + EXPECT_EQ(snapshot.members[0].targets[1].kind, "library"); + EXPECT_EQ(snapshot.members[0].targets[2].kind, "shared-library"); + EXPECT_EQ(snapshot.members[0].targets[3].kind, "binary"); + EXPECT_EQ(snapshot.members[0].targets[3].main, std::optional("tests/main.cpp")); +} + +TEST(IdeSnapshotInspect, ReadOnlyInventory) { + TempProject p; + p.write("mcpp.toml", package_manifest()); + p.write("src/module.cppm", "export module app;\n"); + const auto before = inventory(p.root); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + const auto after = inventory(p.root); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Partial); + EXPECT_EQ(before, after); +} From 1ad07f9dcdb967977a218004e922c708edc0bf98 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:48:34 +0800 Subject: [PATCH 06/26] fix(ide): restrict workspace package selector aliases --- src/ide/inspect.cppm | 3 --- tests/unit/test_ide_snapshot.cpp | 10 ++++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ide/inspect.cppm b/src/ide/inspect.cppm index ca64d25e..8befa8fd 100644 --- a/src/ide/inspect.cppm +++ b/src/ide/inspect.cppm @@ -69,9 +69,6 @@ bool matches_selector(const Candidate& candidate, std::string_view selector, const std::filesystem::path& workspaceRoot) { if (candidate.name == selector || candidate.workspacePath == selector) return true; - if (const auto dot = candidate.name.rfind('.'); - dot != std::string::npos && candidate.name.substr(dot + 1) == selector) - return true; if (candidate.root.filename() == selector) return true; std::error_code ec; diff --git a/tests/unit/test_ide_snapshot.cpp b/tests/unit/test_ide_snapshot.cpp index 3829aa21..84a60387 100644 --- a/tests/unit/test_ide_snapshot.cpp +++ b/tests/unit/test_ide_snapshot.cpp @@ -177,6 +177,16 @@ TEST(IdeSnapshotInspect, PackageSelectorPathWinsOverNameCollision) { EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); } +TEST(IdeSnapshotInspect, PackageSelectorDoesNotUseDottedTailAlias) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"libs/special\"]\n"); + p.write("libs/special/mcpp.toml", package_manifest("acme.foo")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root, + .selectors = {.package = "foo"}}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND"), nullptr); +} + TEST(IdeSnapshotInspect, UnknownPackageUnavailable) { TempProject p; p.write("mcpp.toml", "[workspace]\nmembers=[\"one\"]\n"); From 5336e9fb505d02b823ca9521350c6d1ccf391800 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:11:15 +0800 Subject: [PATCH 07/26] fix(ide): harden workspace inspection paths --- src/ide/inspect.cppm | 157 +++++++++++++++++++++++-------- tests/unit/test_ide_snapshot.cpp | 41 ++++++++ 2 files changed, 161 insertions(+), 37 deletions(-) diff --git a/src/ide/inspect.cppm b/src/ide/inspect.cppm index 8befa8fd..b6426947 100644 --- a/src/ide/inspect.cppm +++ b/src/ide/inspect.cppm @@ -21,7 +21,7 @@ struct Candidate { std::filesystem::path absolute_path(std::filesystem::path path) { std::error_code ec; auto result = std::filesystem::absolute(path, ec); - return ec ? path : result; + return (ec ? path : result).lexically_normal(); } Diagnostic manifest_diagnostic(std::string code, Severity severity, @@ -37,6 +37,89 @@ std::optional error_range(const mcpp::manifest::ManifestError& error) { return Range{{error.line, error.column}, {error.line, error.column}}; } +std::filesystem::path normalized_path(const std::filesystem::path& path) { + return absolute_path(path); +} + +std::filesystem::path physical_path(const std::filesystem::path& path) { + auto absolute = normalized_path(path); + std::error_code ec; + auto normalized = std::filesystem::weakly_canonical(absolute, ec); + return ec ? absolute.lexically_normal() : normalized; +} + +bool same_directory(const std::filesystem::path& left, + const std::filesystem::path& right) { + std::error_code ec; + const bool equivalent = std::filesystem::equivalent(left, right, ec); + if (!ec) return equivalent; + return physical_path(left) == physical_path(right); +} + +struct ManifestRootSearch { + std::optional root; + std::optional error; +}; + +ManifestRootSearch find_manifest_root_read_only(std::filesystem::path start) { + auto current = normalized_path(start); + while (true) { + const auto manifest = current / "mcpp.toml"; + std::error_code ec; + const bool exists = std::filesystem::exists(manifest, ec); + if (ec) { + return {{}, manifest_diagnostic( + "MCPP_IDE_MANIFEST_INVALID", Severity::Error, + std::format("could not inspect mcpp.toml: {}", ec.message()), manifest)}; + } + if (exists) return {current, {}}; + const auto parent = current.parent_path(); + if (parent == current) return {}; + current = parent; + } +} + +struct WorkspaceSearch { + std::optional root; + std::optional manifest; + std::optional error; +}; + +WorkspaceSearch find_workspace_read_only(const std::filesystem::path& memberRoot) { + auto current = normalized_path(memberRoot).parent_path(); + while (true) { + const auto manifestPath = current / "mcpp.toml"; + std::error_code ec; + const bool exists = std::filesystem::exists(manifestPath, ec); + if (ec) { + return {{}, {}, manifest_diagnostic( + "MCPP_IDE_MANIFEST_INVALID", Severity::Error, + std::format("could not inspect ancestor mcpp.toml: {}", ec.message()), + manifestPath)}; + } + if (exists) { + auto loaded = mcpp::manifest::load(manifestPath); + if (!loaded) { + const auto& error = loaded.error(); + return {{}, {}, manifest_diagnostic( + "MCPP_IDE_MANIFEST_INVALID", Severity::Error, error.message, + error.file, error_range(error))}; + } + if (loaded->workspace.present) { + for (const auto& declared : loaded->workspace.members) { + const auto candidate = normalized_path( + (current / declared).lexically_normal()); + if (same_directory(candidate, memberRoot)) + return {current, std::move(*loaded), {}}; + } + } + } + const auto parent = current.parent_path(); + if (parent == current) return {}; + current = parent; + } +} + WorkspaceMember describe_member(const mcpp::manifest::Manifest& manifest, std::string workspacePath, const std::filesystem::path& root) { @@ -66,22 +149,17 @@ WorkspaceMember describe_member(const mcpp::manifest::Manifest& manifest, } bool matches_selector(const Candidate& candidate, std::string_view selector, - const std::filesystem::path& workspaceRoot) { - if (candidate.name == selector || candidate.workspacePath == selector) + const std::filesystem::path&) { + if (candidate.workspacePath == selector) return true; if (candidate.root.filename() == selector) return true; - std::error_code ec; - auto relative = std::filesystem::relative(candidate.root, workspaceRoot, ec); - return !ec && relative.generic_string() == selector; + return false; } bool matches_workspace_path(const Candidate& candidate, std::string_view selector, - const std::filesystem::path& workspaceRoot) { - if (candidate.workspacePath == selector) return true; - std::error_code ec; - auto relative = std::filesystem::relative(candidate.root, workspaceRoot, ec); - return !ec && relative.generic_string() == selector; + const std::filesystem::path&) { + return candidate.workspacePath == selector; } } // namespace @@ -92,10 +170,16 @@ export namespace mcpp::ide { WorkspaceInspection inspect_workspace(InspectRequest request) { WorkspaceInspection result; result.request = request; - result.request.start = absolute_path(std::move(result.request.start)); + result.request.start = normalized_path(result.request.start); - auto startRoot = mcpp::project::find_manifest_root(result.request.start); - if (!startRoot) { + auto rootSearch = find_manifest_root_read_only(result.request.start); + if (rootSearch.error) { + result.workspaceRoot = rootSearch.error->path.parent_path(); + result.workspaceManifest = rootSearch.error->path; + result.diagnostics.push_back(std::move(*rootSearch.error)); + return result; + } + if (!rootSearch.root) { result.workspaceRoot = result.request.start; result.workspaceManifest = result.workspaceRoot / "mcpp.toml"; result.diagnostics.push_back(manifest_diagnostic( @@ -103,12 +187,12 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { "could not locate mcpp.toml", result.workspaceManifest)); return result; } - *startRoot = absolute_path(*startRoot); + auto startRoot = normalized_path(*rootSearch.root); - auto startManifest = mcpp::manifest::load(*startRoot / "mcpp.toml"); + auto startManifest = mcpp::manifest::load(startRoot / "mcpp.toml"); if (!startManifest) { - result.workspaceRoot = *startRoot; - result.workspaceManifest = *startRoot / "mcpp.toml"; + result.workspaceRoot = startRoot; + result.workspaceManifest = startRoot / "mcpp.toml"; const auto& error = startManifest.error(); result.diagnostics.push_back(manifest_diagnostic( "MCPP_IDE_MANIFEST_INVALID", Severity::Error, error.message, @@ -116,16 +200,21 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { return result; } - std::filesystem::path workspaceRoot = *startRoot; + std::filesystem::path workspaceRoot = startRoot; std::optional workspaceManifest = *startManifest; std::filesystem::path currentMemberRoot; if (!startManifest->workspace.present) { - auto found = mcpp::project::find_workspace_root(*startRoot); - if (!found.empty()) { - workspaceRoot = absolute_path(found); - currentMemberRoot = *startRoot; - auto loaded = mcpp::manifest::load(workspaceRoot / "mcpp.toml"); - if (loaded) workspaceManifest = std::move(*loaded); + auto found = find_workspace_read_only(startRoot); + if (found.error) { + result.workspaceRoot = found.error->path.parent_path(); + result.workspaceManifest = found.error->path; + result.diagnostics.push_back(std::move(*found.error)); + return result; + } + if (found.root && found.manifest) { + workspaceRoot = normalized_path(*found.root); + currentMemberRoot = startRoot; + workspaceManifest = std::move(*found.manifest); } } @@ -148,7 +237,8 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { const auto& declared = workspaceManifest->workspace.members; for (const auto& workspacePath : declared) { - const auto memberRoot = workspaceRoot / workspacePath; + const auto memberRoot = normalized_path( + (workspaceRoot / workspacePath).lexically_normal()); const auto manifestPath = memberRoot / "mcpp.toml"; auto loaded = mcpp::manifest::load(manifestPath); if (loaded) { @@ -157,9 +247,8 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { } std::error_code ec; const bool exists = std::filesystem::exists(manifestPath, ec); - const auto code = ec ? "MCPP_IDE_MEMBER_MANIFEST_UNREADABLE" - : exists ? "MCPP_IDE_MEMBER_MANIFEST_INVALID" - : "MCPP_IDE_MEMBER_MANIFEST_MISSING"; + const auto code = exists || ec ? "MCPP_IDE_MEMBER_MANIFEST_INVALID" + : "MCPP_IDE_MEMBER_MANIFEST_MISSING"; const auto message = ec ? std::format("could not inspect member mcpp.toml: {}", ec.message()) : exists ? loaded.error().message @@ -201,7 +290,7 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { } else if (workspaceManifest->workspace.present) { if (!currentMemberRoot.empty()) { for (std::size_t i = 0; i < candidates.size(); ++i) - if (candidates[i].root == currentMemberRoot) select(i); + if (same_directory(candidates[i].root, currentMemberRoot)) select(i); } else if (!workspaceManifest->package.name.empty()) { for (std::size_t i = 0; i < candidates.size(); ++i) if (candidates[i].workspacePath == ".") select(i); @@ -232,7 +321,6 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { bool hasStale = false; bool hasMissing = false; - bool hasUnreadable = false; for (auto index : selected) { const auto& candidate = candidates[index]; if (!candidate.member) continue; @@ -244,20 +332,15 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { result.compileCommands.push_back({candidate.name, path, state}); hasStale = hasStale || regular; hasMissing = hasMissing || !regular; - hasUnreadable = hasUnreadable || static_cast(ec); } if (hasMissing) result.diagnostics.push_back(manifest_diagnostic( "MCPP_IDE_ARTIFACTS_MISSING", Severity::Warning, - "compile_commands.json is missing for one or more selected members", + "compile_commands.json is missing or could not be inspected for one or more selected members", result.workspaceManifest)); if (hasStale) result.diagnostics.push_back(manifest_diagnostic( "MCPP_IDE_ARTIFACTS_UNVERIFIED", Severity::Warning, "compile_commands.json exists but was not verified", result.workspaceManifest)); - if (hasUnreadable) result.diagnostics.push_back(manifest_diagnostic( - "MCPP_IDE_ARTIFACTS_UNREADABLE", Severity::Warning, - "could not inspect compile_commands.json for one or more selected members", - result.workspaceManifest)); result.state = hasStale ? SnapshotState::Stale : SnapshotState::Partial; return result; } diff --git a/tests/unit/test_ide_snapshot.cpp b/tests/unit/test_ide_snapshot.cpp index 84a60387..153e400d 100644 --- a/tests/unit/test_ide_snapshot.cpp +++ b/tests/unit/test_ide_snapshot.cpp @@ -124,6 +124,19 @@ TEST(IdeSnapshotInspect, InvalidManifestPreservesLocation) { EXPECT_EQ(item->range->start.column, expected.error().column); } +TEST(IdeSnapshotInspect, InvalidAncestorManifestIsUnavailableFromMember) { + TempProject p; + p.write("mcpp.toml", "[workspace\nmembers=[\"member\"]\n"); + p.write("member/mcpp.toml", package_manifest("member")); + auto expected = mcpp::manifest::load(p.root / "mcpp.toml"); + ASSERT_FALSE(expected.has_value()); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root / "member"}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + auto* item = diagnostic(snapshot, "MCPP_IDE_MANIFEST_INVALID"); + ASSERT_NE(item, nullptr); + EXPECT_EQ(item->path, expected.error().file); +} + TEST(IdeSnapshotInspect, VirtualWorkspaceSelectsAllMembers) { TempProject p; p.write("mcpp.toml", "[workspace]\nmembers=[\"one\",\"two\"]\n"); @@ -155,6 +168,24 @@ TEST(IdeSnapshotInspect, MemberDirectorySelectsCurrentMember) { EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); } +TEST(IdeSnapshotInspect, DotMemberPathSelectsCurrentMember) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"./member\"]\n"); + p.write("member/mcpp.toml", package_manifest("member")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root / "member"}); + EXPECT_EQ(snapshot.workspaceRoot, p.root); + EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); +} + +TEST(IdeSnapshotInspect, ParentTraversalMemberPathSelectsCurrentMember) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"group/../member\"]\n"); + p.write("member/mcpp.toml", package_manifest("member")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root / "member"}); + EXPECT_EQ(snapshot.workspaceRoot, p.root); + EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); +} + TEST(IdeSnapshotInspect, PackageSelectorMatchesBasenameAndPath) { TempProject p; p.write("mcpp.toml", "[workspace]\nmembers=[\"libs/one\",\"apps/two\"]\n"); @@ -187,6 +218,16 @@ TEST(IdeSnapshotInspect, PackageSelectorDoesNotUseDottedTailAlias) { EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND"), nullptr); } +TEST(IdeSnapshotInspect, PackageSelectorDoesNotUseManifestPackageName) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"libs/special\"]\n"); + p.write("libs/special/mcpp.toml", package_manifest("acme.foo")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root, + .selectors = {.package = "acme.foo"}}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND"), nullptr); +} + TEST(IdeSnapshotInspect, UnknownPackageUnavailable) { TempProject p; p.write("mcpp.toml", "[workspace]\nmembers=[\"one\"]\n"); From 68e1bbb6924a4c0f84901bb9a294b91cc2eeacfe Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:35:14 +0800 Subject: [PATCH 08/26] fix(ide): contain workspace inspection paths --- .../2026-08-05-mcpp-ide-snapshot-design.md | 6 +- src/ide/inspect.cppm | 65 +++++++++++++-- tests/unit/test_ide_snapshot.cpp | 83 ++++++++++++++++--- 3 files changed, 134 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md b/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md index 92e9c41e..69653dbd 100644 --- a/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md +++ b/docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md @@ -174,7 +174,8 @@ The top-level and artifact state machine is deliberately conservative: - `partial`: manifests were inspected, but every selected CDB is missing. - `stale`: at least one selected CDB exists but none can be freshness-verified. - `unavailable`: no usable workspace snapshot can be formed because the root - manifest is missing/invalid or the requested member is invalid. + manifest is missing/invalid, the requested member is invalid, or a selected + artifact path cannot be inspected safely. - `ready`: reserved for a later `ide prepare` publication and never emitted by M0. Valid partial/stale snapshots exit 0. Unavailable snapshots exit 3. Human CLI @@ -208,10 +209,13 @@ one-based on the wire in schema 1. M0 defines these codes: - `MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND` - `MCPP_IDE_ARTIFACTS_MISSING` - `MCPP_IDE_ARTIFACTS_UNVERIFIED` +- `MCPP_IDE_ARTIFACTS_UNAVAILABLE` - `MCPP_IDE_UNSUPPORTED_FORMAT` Member manifest failures are diagnostics attached to the workspace. They become fatal only when the failed member is selected or when no selected member remains. +Artifact probe failures use `MCPP_IDE_ARTIFACTS_UNAVAILABLE` and make the +snapshot unavailable instead of being misreported as a missing CDB. ## Snapshot Identity diff --git a/src/ide/inspect.cppm b/src/ide/inspect.cppm index b6426947..ca0aa827 100644 --- a/src/ide/inspect.cppm +++ b/src/ide/inspect.cppm @@ -16,6 +16,7 @@ struct Candidate { std::filesystem::path manifest; std::optional member; std::optional error; + std::vector workspaceAliases; }; std::filesystem::path absolute_path(std::filesystem::path path) { @@ -48,6 +49,15 @@ std::filesystem::path physical_path(const std::filesystem::path& path) { return ec ? absolute.lexically_normal() : normalized; } +bool is_within_workspace(const std::filesystem::path& path, + const std::filesystem::path& workspaceRoot) { + const auto relative = physical_path(path).lexically_relative( + physical_path(workspaceRoot)); + if (relative.empty() || relative.is_absolute()) return false; + const auto first = relative.begin(); + return first == relative.end() || *first != ".."; +} + bool same_directory(const std::filesystem::path& left, const std::filesystem::path& right) { std::error_code ec; @@ -150,8 +160,8 @@ WorkspaceMember describe_member(const mcpp::manifest::Manifest& manifest, bool matches_selector(const Candidate& candidate, std::string_view selector, const std::filesystem::path&) { - if (candidate.workspacePath == selector) - return true; + if (std::ranges::find(candidate.workspaceAliases, selector) + != candidate.workspaceAliases.end()) return true; if (candidate.root.filename() == selector) return true; return false; @@ -159,7 +169,8 @@ bool matches_selector(const Candidate& candidate, std::string_view selector, bool matches_workspace_path(const Candidate& candidate, std::string_view selector, const std::filesystem::path&) { - return candidate.workspacePath == selector; + return std::ranges::find(candidate.workspaceAliases, selector) + != candidate.workspaceAliases.end(); } } // namespace @@ -226,8 +237,10 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { std::string workspacePath, const std::filesystem::path& root) { auto member = describe_member(manifest, std::move(workspacePath), root); + const auto alias = member.workspacePath; candidates.push_back(Candidate{member.name, member.workspacePath, root, - member.manifest, std::move(member), std::nullopt}); + member.manifest, std::move(member), std::nullopt, + {alias}}); }; if (workspaceManifest->workspace.present && !workspaceManifest->package.name.empty()) @@ -240,6 +253,23 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { const auto memberRoot = normalized_path( (workspaceRoot / workspacePath).lexically_normal()); const auto manifestPath = memberRoot / "mcpp.toml"; + if (!is_within_workspace(memberRoot, workspaceRoot)) { + candidates.push_back(Candidate{ + memberRoot.filename().string(), workspacePath, memberRoot, manifestPath, + std::nullopt, + manifest_diagnostic( + "MCPP_IDE_MEMBER_MANIFEST_INVALID", Severity::Error, + "workspace member path escapes the workspace root", manifestPath), + {workspacePath}}); + continue; + } + auto duplicate = std::ranges::find_if(candidates, [&](const Candidate& candidate) { + return same_directory(candidate.root, memberRoot); + }); + if (duplicate != candidates.end()) { + duplicate->workspaceAliases.push_back(workspacePath); + continue; + } auto loaded = mcpp::manifest::load(manifestPath); if (loaded) { add_manifest(*loaded, workspacePath, memberRoot); @@ -257,7 +287,8 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { candidates.push_back(Candidate{ manifestPath.filename().string(), workspacePath, memberRoot, manifestPath, std::nullopt, - manifest_diagnostic(code, Severity::Error, message, manifestPath, range)}); + manifest_diagnostic(code, Severity::Error, message, manifestPath, range), + {workspacePath}}); } std::vector selected; @@ -321,13 +352,34 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { bool hasStale = false; bool hasMissing = false; + bool artifactProbeError = false; for (auto index : selected) { const auto& candidate = candidates[index]; if (!candidate.member) continue; result.selectedMembers.push_back(candidate.name); const auto path = candidate.root / "compile_commands.json"; std::error_code ec; - const bool regular = std::filesystem::is_regular_file(path, ec); + const bool exists = std::filesystem::exists(path, ec); + if (ec) { + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_ARTIFACTS_UNAVAILABLE", Severity::Error, + std::format("could not inspect compile_commands.json: {}", ec.message()), + path)); + artifactProbeError = true; + continue; + } + bool regular = false; + if (exists) { + regular = std::filesystem::is_regular_file(path, ec); + if (ec) { + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_ARTIFACTS_UNAVAILABLE", Severity::Error, + std::format("could not inspect compile_commands.json: {}", ec.message()), + path)); + artifactProbeError = true; + continue; + } + } const auto state = regular ? ArtifactState::Stale : ArtifactState::Missing; result.compileCommands.push_back({candidate.name, path, state}); hasStale = hasStale || regular; @@ -341,6 +393,7 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { "MCPP_IDE_ARTIFACTS_UNVERIFIED", Severity::Warning, "compile_commands.json exists but was not verified", result.workspaceManifest)); + if (artifactProbeError) return result; result.state = hasStale ? SnapshotState::Stale : SnapshotState::Partial; return result; } diff --git a/tests/unit/test_ide_snapshot.cpp b/tests/unit/test_ide_snapshot.cpp index 153e400d..5e517d72 100644 --- a/tests/unit/test_ide_snapshot.cpp +++ b/tests/unit/test_ide_snapshot.cpp @@ -37,26 +37,34 @@ const mcpp::ide::Diagnostic* diagnostic(const mcpp::ide::WorkspaceInspection& sn } using Inventory = std::map>; + std::tuple>; -Inventory inventory(const std::filesystem::path& root) { +std::optional inventory(const std::filesystem::path& root) { Inventory result; std::error_code ec; - std::filesystem::recursive_directory_iterator it( - root, std::filesystem::directory_options::skip_permission_denied, ec); + std::filesystem::recursive_directory_iterator it(root, ec); + if (ec) return std::nullopt; std::filesystem::recursive_directory_iterator end; for (; it != end; it.increment(ec)) { - if (ec) { ec.clear(); continue; } + if (ec) return std::nullopt; const auto relative = std::filesystem::relative(it->path(), root, ec); - if (ec) { ec.clear(); continue; } - const auto size = std::filesystem::is_regular_file(it->path(), ec) - ? std::filesystem::file_size(it->path(), ec) : 0; - ec.clear(); + if (ec) return std::nullopt; + const bool regular = std::filesystem::is_regular_file(it->path(), ec); + if (ec) return std::nullopt; + const auto size = regular ? std::filesystem::file_size(it->path(), ec) : 0; + if (ec) return std::nullopt; const auto stamp = std::filesystem::last_write_time(it->path(), ec); - if (ec) { ec.clear(); continue; } - result.emplace(relative, std::tuple{size, stamp}); + if (ec) return std::nullopt; + std::string contents; + if (regular) { + std::ifstream input(it->path(), std::ios::binary); + if (!input) return std::nullopt; + contents.assign(std::istreambuf_iterator(input), {}); + } + result.emplace(relative, std::tuple{size, stamp, std::move(contents)}); } - return result; + return std::make_optional(std::move(result)); } std::string package_manifest(std::string_view name = "app", @@ -186,6 +194,53 @@ TEST(IdeSnapshotInspect, ParentTraversalMemberPathSelectsCurrentMember) { EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); } +TEST(IdeSnapshotInspect, RejectsMemberOutsideWorkspaceRoot) { + TempProject p; + TempProject outside; + p.write("mcpp.toml", std::format("[workspace]\nmembers=[\"../{}\"]\n", + outside.root.filename().string())); + outside.write("mcpp.toml", package_manifest("outside")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_TRUE(snapshot.members.empty()); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_MEMBER_MANIFEST_INVALID"), nullptr); +} + +TEST(IdeSnapshotInspect, RejectsSymlinkedMemberOutsideWorkspaceRoot) { + TempProject p; + TempProject outside; + p.write("mcpp.toml", "[workspace]\nmembers=[\"linked\"]\n"); + outside.write("mcpp.toml", package_manifest("outside")); + std::error_code ec; + std::filesystem::create_directory_symlink(outside.root, p.root / "linked", ec); + if (ec) GTEST_SKIP() << "directory symlink unavailable: " << ec.message(); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_TRUE(snapshot.members.empty()); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_MEMBER_MANIFEST_INVALID"), nullptr); +} + +TEST(IdeSnapshotInspect, DeduplicatesEquivalentMemberPaths) { + TempProject p; + p.write("mcpp.toml", "[workspace]\nmembers=[\"member\",\"group/../member\"]\n"); + p.write("member/mcpp.toml", package_manifest("member")); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + ASSERT_EQ(snapshot.members.size(), 1u); + EXPECT_EQ(snapshot.selectedMembers, std::vector{"member"}); + ASSERT_EQ(snapshot.compileCommands.size(), 1u); +} + +TEST(IdeSnapshotInspect, ArtifactProbeErrorIsUnavailable) { + TempProject p; + p.write("mcpp.toml", package_manifest()); + std::error_code ec; + std::filesystem::create_symlink("compile_commands.json", p.root / "compile_commands.json", ec); + if (ec) GTEST_SKIP() << "symlink unavailable: " << ec.message(); + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Unavailable); + EXPECT_NE(diagnostic(snapshot, "MCPP_IDE_ARTIFACTS_UNAVAILABLE"), nullptr); +} + TEST(IdeSnapshotInspect, PackageSelectorMatchesBasenameAndPath) { TempProject p; p.write("mcpp.toml", "[workspace]\nmembers=[\"libs/one\",\"apps/two\"]\n"); @@ -281,8 +336,10 @@ TEST(IdeSnapshotInspect, ReadOnlyInventory) { p.write("mcpp.toml", package_manifest()); p.write("src/module.cppm", "export module app;\n"); const auto before = inventory(p.root); + ASSERT_TRUE(before.has_value()); auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); const auto after = inventory(p.root); + ASSERT_TRUE(after.has_value()); EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Partial); - EXPECT_EQ(before, after); + EXPECT_EQ(*before, *after); } From 1b6b6a15e1cc7cc60917b54641b6c29b8abbfb60 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:47:17 +0800 Subject: [PATCH 09/26] feat(ide): serialize versioned workspace snapshots --- src/ide/snapshot.cppm | 164 +++++++++++++++++++++++++++++++ tests/unit/test_ide_snapshot.cpp | 104 ++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 src/ide/snapshot.cppm diff --git a/src/ide/snapshot.cppm b/src/ide/snapshot.cppm new file mode 100644 index 00000000..a79421b0 --- /dev/null +++ b/src/ide/snapshot.cppm @@ -0,0 +1,164 @@ +export module mcpp.ide.snapshot; + +import std; +import mcpp.ide.model; +import mcpp.libs.json; +import mcpp.version; + +namespace mcpp::ide { + +namespace { + +using Json = nlohmann::ordered_json; + +Json optional_string(const std::optional& value) { + return value ? Json(*value) : Json(nullptr); +} + +Json string_array(const std::vector& values) { + auto result = Json::array(); + for (const auto& value : values) result.push_back(value); + return result; +} + +Json selectors_json(const Selectors& selectors) { + auto result = Json::object(); + result["package"] = optional_string(selectors.package); + result["workspace"] = selectors.workspace; + result["profile"] = optional_string(selectors.profile); + result["target"] = optional_string(selectors.target); + result["features"] = string_array(selectors.features); + result["capabilities"] = string_array(selectors.capabilities); + result["includeDevDependencies"] = selectors.includeDevDependencies; + return result; +} + +Json targets_json(const std::vector& targets) { + auto result = Json::array(); + for (const auto& target : targets) { + auto item = Json::object(); + item["name"] = target.name; + item["kind"] = target.kind; + if (target.main) item["main"] = *target.main; + result.push_back(std::move(item)); + } + return result; +} + +Json members_json(const std::vector& members) { + auto result = Json::array(); + for (const auto& member : members) { + auto item = Json::object(); + item["name"] = member.name; + item["version"] = member.version; + item["workspacePath"] = member.workspacePath; + item["root"] = member.root.generic_string(); + item["manifest"] = member.manifest.generic_string(); + item["targets"] = targets_json(member.targets); + result.push_back(std::move(item)); + } + return result; +} + +Json artifacts_json(const WorkspaceInspection& inspection) { + auto compileCommands = Json::array(); + for (const auto& artifact : inspection.compileCommands) { + auto item = Json::object(); + item["member"] = artifact.member; + item["path"] = artifact.path.generic_string(); + item["state"] = std::string(wire_name(artifact.state)); + compileCommands.push_back(std::move(item)); + } + auto result = Json::object(); + result["state"] = std::string(wire_name(inspection.state)); + result["compileCommands"] = std::move(compileCommands); + return result; +} + +Json position_json(const Position& position) { + auto result = Json::object(); + result["line"] = position.line; + result["column"] = position.column; + return result; +} + +Json diagnostics_json(const std::vector& diagnostics) { + auto result = Json::array(); + for (const auto& diagnostic : diagnostics) { + auto item = Json::object(); + item["code"] = diagnostic.code; + item["severity"] = std::string(wire_name(diagnostic.severity)); + item["message"] = diagnostic.message; + item["source"] = "mcpp"; + if (!diagnostic.path.empty()) item["path"] = diagnostic.path.generic_string(); + if (diagnostic.range) { + auto range = Json::object(); + range["start"] = position_json(diagnostic.range->start); + range["end"] = position_json(diagnostic.range->end); + item["range"] = std::move(range); + } + result.push_back(std::move(item)); + } + return result; +} + +Json document_json(const WorkspaceInspection& inspection, + std::optional snapshotId) { + auto document = Json::object(); + document["schemaVersion"] = 1; + document["kind"] = "mcpp.ide.snapshot"; + if (snapshotId) document["snapshotId"] = *snapshotId; + document["state"] = std::string(wire_name(inspection.state)); + + auto protocol = Json::object(); + protocol["min"] = 1; + protocol["max"] = 1; + auto mcpp = Json::object(); + mcpp["version"] = std::string(mcpp::MCPP_VERSION); + mcpp["protocol"] = std::move(protocol); + mcpp["capabilities"] = Json::array({ + "workspace-inspection", + "manifest-diagnostics", + "compile-commands-location", + }); + document["mcpp"] = std::move(mcpp); + + auto request = Json::object(); + request["root"] = inspection.request.start.generic_string(); + request["selectors"] = selectors_json(inspection.request.selectors); + request["mode"] = "read-only"; + document["request"] = std::move(request); + + auto workspace = Json::object(); + workspace["root"] = inspection.workspaceRoot.generic_string(); + workspace["manifest"] = inspection.workspaceManifest.generic_string(); + workspace["members"] = members_json(inspection.members); + workspace["selectedMembers"] = string_array(inspection.selectedMembers); + document["workspace"] = std::move(workspace); + document["artifacts"] = artifacts_json(inspection); + document["diagnostics"] = diagnostics_json(inspection.diagnostics); + return document; +} + +std::uint64_t fnv1a64(std::string_view bytes) { + std::uint64_t hash = 14695981039346656037ull; + for (const unsigned char byte : bytes) { + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +} // namespace + +} // namespace mcpp::ide + +export namespace mcpp::ide { + +std::string snapshot_json(const WorkspaceInspection& inspection) { + const auto canonical = document_json(inspection, std::nullopt).dump(); + const auto id = std::format("fnv1a64:{:016x}", fnv1a64(canonical)); + return document_json(inspection, id).dump(2) + "\n"; +} + +} // namespace mcpp::ide diff --git a/tests/unit/test_ide_snapshot.cpp b/tests/unit/test_ide_snapshot.cpp index 5e517d72..867b8542 100644 --- a/tests/unit/test_ide_snapshot.cpp +++ b/tests/unit/test_ide_snapshot.cpp @@ -3,6 +3,8 @@ import std; import mcpp.ide.model; import mcpp.ide.inspect; +import mcpp.ide.snapshot; +import mcpp.libs.json; import mcpp.manifest; namespace { @@ -72,6 +74,36 @@ std::string package_manifest(std::string_view name = "app", return std::format("[package]\nname=\"{}\"\nversion=\"{}\"\n", name, version); } +mcpp::ide::WorkspaceInspection fixture_inspection() { + using namespace mcpp::ide; + WorkspaceInspection snapshot; + snapshot.state = SnapshotState::Partial; + snapshot.request.start = "/workspace/app"; + snapshot.workspaceRoot = "/workspace/app"; + snapshot.workspaceManifest = "/workspace/app/mcpp.toml"; + snapshot.members.push_back(WorkspaceMember{ + .name = "app", + .version = "1.0.0", + .workspacePath = ".", + .root = "/workspace/app", + .manifest = "/workspace/app/mcpp.toml", + .targets = {DeclaredTarget{ + .name = "app", .kind = "binary", .main = "src/main.cpp"}}, + }); + snapshot.selectedMembers = {"app"}; + snapshot.compileCommands.push_back({ + .member = "app", + .path = "/workspace/app/compile_commands.json", + .state = ArtifactState::Missing, + }); + snapshot.diagnostics.push_back({ + .code = "MCPP_IDE_ARTIFACTS_MISSING", + .severity = Severity::Warning, + .message = "Compile commands are unavailable for one or more selected members.", + }); + return snapshot; +} + } // namespace TEST(IdeSnapshotModel, WireNamesAreStable) { @@ -343,3 +375,75 @@ TEST(IdeSnapshotInspect, ReadOnlyInventory) { EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Partial); EXPECT_EQ(*before, *after); } + +TEST(IdeSnapshotSerialize, EmitsCompleteSchemaOneDocument) { + auto inspection = fixture_inspection(); + inspection.request.selectors = { + .package = "app", + .workspace = true, + .profile = "debug", + .target = "aarch64-apple-darwin", + .features = {"tls", "metrics"}, + .capabilities = {"ssl=openssl", "zlib=zlib"}, + .includeDevDependencies = true, + }; + inspection.diagnostics.push_back({ + .code = "MCPP_IDE_MANIFEST_INVALID", + .severity = mcpp::ide::Severity::Error, + .message = "expected value", + .path = "/workspace/app/mcpp.toml", + .range = mcpp::ide::Range{{4, 9}, {4, 9}}, + }); + + const auto text = mcpp::ide::snapshot_json(inspection); + auto json = nlohmann::json::parse(text); + EXPECT_EQ(json["schemaVersion"], 1); + EXPECT_EQ(json["kind"], "mcpp.ide.snapshot"); + EXPECT_EQ(json["state"], "partial"); + EXPECT_EQ(json["mcpp"]["version"], "2026.8.5.2"); + EXPECT_EQ(json["mcpp"]["protocol"], nlohmann::json({{"min", 1}, {"max", 1}})); + EXPECT_EQ(json["mcpp"]["capabilities"], nlohmann::json({ + "workspace-inspection", "manifest-diagnostics", "compile-commands-location"})); + EXPECT_EQ(json["request"]["root"], "/workspace/app"); + EXPECT_EQ(json["request"]["mode"], "read-only"); + EXPECT_EQ(json["request"]["selectors"]["package"], "app"); + EXPECT_TRUE(json["request"]["selectors"]["workspace"]); + EXPECT_EQ(json["request"]["selectors"]["profile"], "debug"); + EXPECT_EQ(json["request"]["selectors"]["target"], "aarch64-apple-darwin"); + EXPECT_EQ(json["request"]["selectors"]["features"], + nlohmann::json({"tls", "metrics"})); + EXPECT_EQ(json["request"]["selectors"]["capabilities"], + nlohmann::json({"ssl=openssl", "zlib=zlib"})); + EXPECT_TRUE(json["request"]["selectors"]["includeDevDependencies"]); + EXPECT_EQ(json["workspace"]["members"][0]["targets"][0]["kind"], "binary"); + EXPECT_EQ(json["workspace"]["selectedMembers"], nlohmann::json({"app"})); + EXPECT_EQ(json["artifacts"]["state"], "partial"); + EXPECT_EQ(json["artifacts"]["compileCommands"][0]["state"], "missing"); + EXPECT_EQ(json["diagnostics"][1]["source"], "mcpp"); + EXPECT_EQ(json["diagnostics"][1]["range"]["start"]["line"], 4); + EXPECT_EQ(json["diagnostics"][1]["range"]["start"]["column"], 9); +} + +TEST(IdeSnapshotSerialize, IsDeterministicAndContentAddressed) { + auto inspection = fixture_inspection(); + const auto first = mcpp::ide::snapshot_json(inspection); + const auto second = mcpp::ide::snapshot_json(inspection); + EXPECT_EQ(first, second); + auto firstJson = nlohmann::json::parse(first); + EXPECT_TRUE(firstJson["snapshotId"].get().starts_with("fnv1a64:")); + + inspection.members[0].version = "1.0.1"; + auto changed = nlohmann::json::parse(mcpp::ide::snapshot_json(inspection)); + EXPECT_NE(firstJson["snapshotId"], changed["snapshotId"]); +} + +TEST(IdeSnapshotSerialize, MatchesSchemaOneFixture) { + const auto actualText = mcpp::ide::snapshot_json(fixture_inspection()); + auto actual = nlohmann::json::parse(actualText); + std::ifstream input(std::filesystem::current_path() + / "tests/fixtures/ide/snapshot-v1.json"); + ASSERT_TRUE(input.good()); + auto expected = nlohmann::json::parse(input); + expected["snapshotId"] = actual["snapshotId"]; + EXPECT_EQ(actual, expected); +} From 4e998792299baf78fde81b5ef787a7bc788cf754 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:23:45 +0800 Subject: [PATCH 10/26] feat(cli): add read-only ide snapshot command --- .../plans/2026-08-05-mcpp-ide-snapshot.md | 26 ++-- src/cli.cppm | 31 ++++- src/cli/cmd_ide.cppm | 78 ++++++++++++ tests/e2e/01_help_and_version.sh | 1 + tests/e2e/190_ide_snapshot.sh | 117 ++++++++++++++++++ 5 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 src/cli/cmd_ide.cppm create mode 100644 tests/e2e/190_ide_snapshot.sh diff --git a/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md b/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md index 91acf8ec..83ade515 100644 --- a/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md +++ b/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md @@ -21,8 +21,8 @@ | `src/cli.cppm` | Import/register `ide snapshot`, help entry, known-command list | | `tests/unit/test_ide_snapshot.cpp` | DTO, inspection, selector, diagnostic, artifact, serialization, and identity tests | | `tests/fixtures/ide/snapshot-v1.json` | Schema-1 golden fixture consumed by unit tests | -| `tests/e2e/185_ide_snapshot.sh` | Fresh-binary CLI contract and workspace/error cases | -| `tests/e2e/186_ide_snapshot_read_only.sh` | Before/after project and isolated-home zero-side-effect proof | +| `tests/e2e/190_ide_snapshot.sh` | Fresh-binary CLI contract and workspace/error cases | +| `tests/e2e/191_ide_snapshot_read_only.sh` | Before/after project and isolated-home zero-side-effect proof | | `docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md` | Approved protocol and side-effect contract | ### Task 1: Define the Public IDE DTO and Protocol Fixture @@ -382,13 +382,13 @@ git commit -m "feat(ide): serialize versioned workspace snapshots" - Create: `src/cli/cmd_ide.cppm` - Modify: `src/cli.cppm` - Modify: `tests/e2e/01_help_and_version.sh` -- Create: `tests/e2e/185_ide_snapshot.sh` +- Create: `tests/e2e/190_ide_snapshot.sh` - [ ] **Step 1: Add failing help and CLI E2E assertions** Update `tests/e2e/01_help_and_version.sh` to require `mcpp ide snapshot` in help. -Create `tests/e2e/185_ide_snapshot.sh` with `# requires:` on line 2. It must: +Create `tests/e2e/190_ide_snapshot.sh` with `# requires:` on line 2. It must: 1. Create a minimal package in `mktemp -d` without calling `mcpp new`. 2. Run `"$MCPP" ide snapshot --format json`, capture stdout and stderr @@ -409,7 +409,7 @@ Run: ```bash mcpp build FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) -MCPP="$FRESH_MCPP" bash tests/e2e/185_ide_snapshot.sh +MCPP="$FRESH_MCPP" bash tests/e2e/190_ide_snapshot.sh ``` Expected: unknown command `ide` or missing help entry. @@ -463,7 +463,7 @@ mcpp build FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) "$FRESH_MCPP" test ide_snapshot MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh -MCPP="$FRESH_MCPP" bash tests/e2e/185_ide_snapshot.sh +MCPP="$FRESH_MCPP" bash tests/e2e/190_ide_snapshot.sh ``` Expected: focused unit tests and both E2E scripts pass. @@ -471,19 +471,19 @@ Expected: focused unit tests and both E2E scripts pass. - [ ] **Step 6: Commit the CLI** ```bash -git add src/cli/cmd_ide.cppm src/cli.cppm tests/e2e/01_help_and_version.sh tests/e2e/185_ide_snapshot.sh +git add src/cli/cmd_ide.cppm src/cli.cppm tests/e2e/01_help_and_version.sh tests/e2e/190_ide_snapshot.sh git commit -m "feat(cli): add read-only ide snapshot command" ``` ### Task 5: Prove the Zero-side-effect Contract **Files:** -- Create: `tests/e2e/186_ide_snapshot_read_only.sh` +- Create: `tests/e2e/191_ide_snapshot_read_only.sh` - Modify: `tests/unit/test_ide_snapshot.cpp` - [ ] **Step 1: Add the failing side-effect E2E** -Create `tests/e2e/186_ide_snapshot_read_only.sh` with `# requires:` on line 2. The +Create `tests/e2e/191_ide_snapshot_read_only.sh` with `# requires:` on line 2. The script must create a project containing: - a remote-looking version dependency; @@ -506,7 +506,7 @@ Run with the current fresh binary: ```bash FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) -MCPP="$FRESH_MCPP" bash tests/e2e/186_ide_snapshot_read_only.sh +MCPP="$FRESH_MCPP" bash tests/e2e/191_ide_snapshot_read_only.sh ``` Expected: pass if the architecture boundary is intact. If it fails, treat any new @@ -528,7 +528,7 @@ Expected: both pass; no project/home additions or modifications. - [ ] **Step 5: Commit the side-effect proof** ```bash -git add tests/unit/test_ide_snapshot.cpp tests/e2e/186_ide_snapshot_read_only.sh +git add tests/unit/test_ide_snapshot.cpp tests/e2e/191_ide_snapshot_read_only.sh git commit -m "test(ide): prove snapshot is read only" ``` @@ -573,8 +573,8 @@ Expected: all test binaries pass with zero failures. ```bash MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh -MCPP="$FRESH_MCPP" bash tests/e2e/185_ide_snapshot.sh -MCPP="$FRESH_MCPP" bash tests/e2e/186_ide_snapshot_read_only.sh +MCPP="$FRESH_MCPP" bash tests/e2e/190_ide_snapshot.sh +MCPP="$FRESH_MCPP" bash tests/e2e/191_ide_snapshot_read_only.sh ``` Expected: all three scripts print `OK` and exit 0. diff --git a/src/cli.cppm b/src/cli.cppm index 5e6c6c10..1504bd68 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -20,6 +20,7 @@ import std; import mcpplibs.cmdline; import mcpp.cli.cmd_build; import mcpp.cli.cmd_cache; +import mcpp.cli.cmd_ide; import mcpp.cli.cmd_new; import mcpp.cli.cmd_publish; import mcpp.cli.cmd_xpkg; @@ -64,6 +65,7 @@ void print_usage() { std::println(" mcpp pack [--mode ] Build + bundle a tarball (m: system|vendored|self-contained|static)"); std::println(" mcpp emit xpkg [-V VER] [-o FILE] Generate xpkg Lua entry"); std::println(" mcpp xpkg parse [--json] Validate an xpkg descriptor (resolver grammar)"); + std::println(" mcpp ide snapshot [selectors] Inspect workspace state for IDE clients"); std::println(""); std::println("Resource management:"); std::println(" mcpp toolchain install|list|default Manage mcpp's private toolchains"); @@ -384,6 +386,31 @@ int run(int argc, char** argv) { return dispatch_sub("xpkg", p, {{"parse", cmd_xpkg_parse}}); }))) + // ─── IDE protocol (read-only snapshot) ────────────────────────── + .subcommand(cl::App("ide") + .description("Machine-readable IDE integration protocol") + .subcommand(cl::App("snapshot") + .description("Inspect workspace state without building or writing artifacts") + .option(cl::Option("package").short_name('p').takes_value().value_name("MEMBER") + .help("Select one workspace member by relative path or basename")) + .option(cl::Option("workspace") + .help("Select every workspace member")) + .option(cl::Option("profile").takes_value().value_name("NAME") + .help("Echo the requested build profile")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") + .help("Echo the requested target triple")) + .option(cl::Option("features").takes_value().value_name("LIST") + .help("Echo comma-separated feature selectors")) + .option(cl::Option("cap").takes_value().value_name("LIST") + .help("Echo comma-separated capability provider pins")) + .option(cl::Option("include-dev-dependencies") + .help("Echo dev-dependency inclusion")) + .option(cl::Option("format").takes_value().value_name("FORMAT") + .help("Output format (json)"))) + .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { + return dispatch_sub("ide", p, {{"snapshot", cmd_ide_snapshot}}); + }))) + // ─── resource management ─────────────────────────────────────── .subcommand(cl::App("toolchain") .description("Install / list / select / remove C++ toolchains") @@ -596,11 +623,11 @@ int run(int argc, char** argv) { { std::string_view first = argv[1]; if (!first.starts_with('-')) { - static constexpr std::array known = { + static constexpr std::array known = { "new", "build", "run", "test", "clean", "add", "remove", "update", "search", "publish", "pack", "emit", "xpkg", "toolchain", "cache", "index", "self", "explain", - "version", "dyndep", "why", "resolve", "stage", + "version", "dyndep", "why", "resolve", "stage", "ide", }; bool ok = false; for (auto k : known) if (k == first) { ok = true; break; } diff --git a/src/cli/cmd_ide.cppm b/src/cli/cmd_ide.cppm new file mode 100644 index 00000000..aeffcca1 --- /dev/null +++ b/src/cli/cmd_ide.cppm @@ -0,0 +1,78 @@ +module; +#include + +export module mcpp.cli.cmd_ide; + +import std; +import mcpplibs.cmdline; +import mcpp.ide.inspect; +import mcpp.ide.model; +import mcpp.ide.snapshot; + +namespace mcpp::cli { + +namespace { + +std::vector split_csv(std::string_view value) { + std::vector result; + std::size_t start = 0; + while (start <= value.size()) { + const auto end = value.find(',', start); + const auto item = value.substr( + start, end == std::string_view::npos ? value.size() - start : end - start); + if (!item.empty()) result.emplace_back(item); + if (end == std::string_view::npos) break; + start = end + 1; + } + return result; +} + +mcpp::ide::Selectors selectors_from(const mcpplibs::cmdline::ParsedArgs& parsed) { + mcpp::ide::Selectors selectors; + if (auto value = parsed.value("package")) selectors.package = *value; + selectors.workspace = parsed.is_flag_set("workspace"); + if (auto value = parsed.value("profile")) selectors.profile = *value; + if (auto value = parsed.value("target")) selectors.target = *value; + if (auto value = parsed.value("features")) selectors.features = split_csv(*value); + if (auto value = parsed.value("cap")) selectors.capabilities = split_csv(*value); + selectors.includeDevDependencies = parsed.is_flag_set("include-dev-dependencies"); + return selectors; +} + +} // namespace + +export int cmd_ide_snapshot(const mcpplibs::cmdline::ParsedArgs& parsed) { + std::error_code ec; + auto start = std::filesystem::current_path(ec); + if (ec) { + start = "."; + ec.clear(); + } + auto absolute = std::filesystem::absolute(start, ec); + if (!ec) start = std::move(absolute); + start = start.lexically_normal(); + + mcpp::ide::InspectRequest request{ + .start = std::move(start), + .selectors = selectors_from(parsed), + }; + const auto format = parsed.value("format").value_or("json"); + mcpp::ide::WorkspaceInspection inspection; + if (format == "json") { + inspection = mcpp::ide::inspect_workspace(std::move(request)); + } else { + inspection.request = std::move(request); + inspection.workspaceRoot = inspection.request.start; + inspection.workspaceManifest = inspection.workspaceRoot / "mcpp.toml"; + inspection.diagnostics.push_back({ + .code = "MCPP_IDE_UNSUPPORTED_FORMAT", + .severity = mcpp::ide::Severity::Error, + .message = std::format("unsupported IDE snapshot format '{}'; expected json", + format), + }); + } + std::print("{}", mcpp::ide::snapshot_json(inspection)); + return inspection.state == mcpp::ide::SnapshotState::Unavailable ? 3 : 0; +} + +} // namespace mcpp::cli diff --git a/tests/e2e/01_help_and_version.sh b/tests/e2e/01_help_and_version.sh index abc81620..00f1b633 100755 --- a/tests/e2e/01_help_and_version.sh +++ b/tests/e2e/01_help_and_version.sh @@ -19,6 +19,7 @@ out=$("$MCPP" --help) [[ "$out" == *"Usage:"* ]] || { echo "--help missing 'Usage:' section"; exit 1; } [[ "$out" == *"mcpp new"* ]] || { echo "--help missing 'mcpp new'"; exit 1; } [[ "$out" == *"mcpp build"* ]] || { echo "--help missing 'mcpp build'"; exit 1; } +[[ "$out" == *"mcpp ide snapshot"* ]] || { echo "--help missing 'mcpp ide snapshot'"; exit 1; } # Unknown command exit code (127) — must capture rc explicitly under `set -e` rc=0 diff --git a/tests/e2e/190_ide_snapshot.sh b/tests/e2e/190_ide_snapshot.sh new file mode 100644 index 00000000..825c8053 --- /dev/null +++ b/tests/e2e/190_ide_snapshot.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# requires: +# Verify the read-only IDE snapshot CLI contract. +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +assert_json() { + local file=$1 mode=$2 + if command -v python3 >/dev/null 2>&1; then + python3 - "$file" "$mode" <<'PY' +import json +import sys + +path, mode = sys.argv[1:] +raw = open(path, encoding="utf-8").read() +decoder = json.JSONDecoder() +value, end = decoder.raw_decode(raw.lstrip()) +assert not raw.lstrip()[end:].strip(), "stdout contains more than one JSON value" +assert isinstance(value, dict) +assert value["schemaVersion"] == 1 +assert value["kind"] == "mcpp.ide.snapshot" + +if mode == "partial": + assert value["state"] == "partial" + assert [member["name"] for member in value["workspace"]["members"]] == ["app"] + assert value["workspace"]["selectedMembers"] == ["app"] + assert value["artifacts"]["compileCommands"][0]["state"] == "missing" +elif mode == "stale": + assert value["state"] == "stale" + assert value["artifacts"]["compileCommands"][0]["state"] == "stale" + assert value["state"] != "ready" +elif mode == "all": + assert value["workspace"]["selectedMembers"] == ["one", "two"] +elif mode == "one": + assert value["workspace"]["selectedMembers"] == ["one"] +else: + expected = { + "unknown": "MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND", + "invalid": "MCPP_IDE_MANIFEST_INVALID", + "format": "MCPP_IDE_UNSUPPORTED_FORMAT", + }[mode] + assert value["state"] == "unavailable" + assert expected in [item["code"] for item in value["diagnostics"]] +PY + return + fi + + grep -q '"schemaVersion": 1' "$file" + grep -q '"kind": "mcpp.ide.snapshot"' "$file" + case "$mode" in + partial) grep -q '"state": "partial"' "$file"; grep -q '"state": "missing"' "$file" ;; + stale) grep -q '"state": "stale"' "$file"; ! grep -q '"state": "ready"' "$file" ;; + all) grep -q '"one"' "$file"; grep -q '"two"' "$file" ;; + one) grep -q '"one"' "$file" ;; + unknown) grep -q 'MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND' "$file" ;; + invalid) grep -q 'MCPP_IDE_MANIFEST_INVALID' "$file" ;; + format) grep -q 'MCPP_IDE_UNSUPPORTED_FORMAT' "$file" ;; + esac +} + +run_success() { + local dir=$1 mode=$2 + shift 2 + (cd "$dir" && "$MCPP" ide snapshot "$@") >"$TMP/out.json" 2>"$TMP/err.log" + [[ ! -s "$TMP/err.log" ]] || { cat "$TMP/err.log"; exit 1; } + ! LC_ALL=C grep -q $'\033' "$TMP/out.json" || { echo "ANSI bytes on stdout"; exit 1; } + assert_json "$TMP/out.json" "$mode" +} + +run_failure() { + local dir=$1 mode=$2 + shift 2 + local rc=0 + (cd "$dir" && "$MCPP" ide snapshot "$@") >"$TMP/out.json" 2>"$TMP/err.log" || rc=$? + [[ $rc -eq 3 ]] || { cat "$TMP/err.log"; echo "expected exit 3, got $rc"; exit 1; } + [[ ! -s "$TMP/err.log" ]] || { cat "$TMP/err.log"; exit 1; } + assert_json "$TMP/out.json" "$mode" +} + +mkdir -p "$TMP/app" +cat >"$TMP/app/mcpp.toml" <<'EOF' +[package] +name = "app" +version = "1.0.0" +EOF +run_success "$TMP/app" partial --format json +printf '[]\n' >"$TMP/app/compile_commands.json" +run_success "$TMP/app" stale + +mkdir -p "$TMP/workspace/one" "$TMP/workspace/two" +cat >"$TMP/workspace/mcpp.toml" <<'EOF' +[workspace] +members = ["one", "two"] +EOF +cat >"$TMP/workspace/one/mcpp.toml" <<'EOF' +[package] +name = "one" +version = "1.0.0" +EOF +cat >"$TMP/workspace/two/mcpp.toml" <<'EOF' +[package] +name = "two" +version = "1.0.0" +EOF +run_success "$TMP/workspace" all +run_success "$TMP/workspace" one -p one +run_success "$TMP/workspace" all --workspace +run_failure "$TMP/workspace" unknown -p missing + +mkdir -p "$TMP/invalid" +printf '[package\nname = "bad"\n' >"$TMP/invalid/mcpp.toml" +run_failure "$TMP/invalid" invalid +run_failure "$TMP/app" format --format yaml + +echo "OK" From fee82fe0ae105a82099428e8aa4ba5f663311e4e Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:37:53 +0800 Subject: [PATCH 11/26] test(ide): prove snapshot is read only --- tests/e2e/191_ide_snapshot_read_only.sh | 149 ++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100755 tests/e2e/191_ide_snapshot_read_only.sh diff --git a/tests/e2e/191_ide_snapshot_read_only.sh b/tests/e2e/191_ide_snapshot_read_only.sh new file mode 100755 index 00000000..d3c693ca --- /dev/null +++ b/tests/e2e/191_ide_snapshot_read_only.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# requires: +# Prove `ide snapshot` leaves both the project and an isolated MCPP_HOME untouched. +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +PROJECT="$TMP/project" +IDE_HOME="$TMP/mcpp-home" +mkdir -p "$PROJECT/src" "$IDE_HOME" + +cat >"$PROJECT/mcpp.toml" <<'EOF' +[package] +name = "read_only_probe" +version = "1.0.0" + +[dependencies] +"missing.remote" = "99.99.99" + +[generated_files] +"src/generated.cpp" = "int generated_value() { return 42; }\n" + +[toolchain] +linux = "llvm@99.99.99" +macosx = "llvm@99.99.99" +windows = "llvm@99.99.99" +EOF + +cat >"$PROJECT/build.mcpp" <<'EOF' +#include +int main() { + std::ofstream("build-mcpp-ran") << "snapshot executed build.mcpp\n"; + return 0; +} +EOF + +cat >"$PROJECT/src/main.cpp" <<'EOF' +int main() { return 0; } +EOF + +inventory() { + local root=$1 output=$2 + if command -v python3 >/dev/null 2>&1; then + python3 - "$root" >"$output" <<'PY' +import hashlib +import os +import stat +import sys + +root = os.path.abspath(sys.argv[1]) +records = [("D", ".", "-")] +for current, directories, files in os.walk(root, followlinks=False): + directories.sort() + files.sort() + for name in directories + files: + path = os.path.join(current, name) + relative = os.path.relpath(path, root).replace(os.sep, "/") + info = os.lstat(path) + if stat.S_ISLNK(info.st_mode): + records.append(("L", relative, os.readlink(path))) + elif stat.S_ISDIR(info.st_mode): + records.append(("D", relative, "-")) + elif stat.S_ISREG(info.st_mode): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(65536), b""): + digest.update(block) + records.append(("F", relative, digest.hexdigest())) + else: + records.append(("O", relative, str(info.st_mode))) +for record in sorted(records, key=lambda item: item[1]): + print("\t".join(record)) +PY + return + fi + + if ! command -v sha256sum >/dev/null 2>&1 \ + && ! command -v shasum >/dev/null 2>&1; then + echo "python3, sha256sum, or shasum is required" >&2 + return 1 + fi + ( + cd "$root" + find . -print | LC_ALL=C sort | while IFS= read -r path; do + if [[ -L "$path" ]]; then + printf 'L\t%s\t%s\n' "$path" "$(readlink "$path")" + elif [[ -d "$path" ]]; then + printf 'D\t%s\t-\n' "$path" + elif [[ -f "$path" ]]; then + if command -v sha256sum >/dev/null 2>&1; then + digest=$(sha256sum "$path" | awk '{print $1}') + else + digest=$(shasum -a 256 "$path" | awk '{print $1}') + fi + printf 'F\t%s\t%s\n' "$path" "$digest" + else + printf 'O\t%s\t-\n' "$path" + fi + done + ) >"$output" +} + +inventory "$PROJECT" "$TMP/project.before" +inventory "$IDE_HOME" "$TMP/home.before" + +(cd "$PROJECT" && MCPP_HOME="$IDE_HOME" "$MCPP" ide snapshot --format json) \ + >"$TMP/default.json" 2>"$TMP/default.err" +(cd "$PROJECT" && MCPP_HOME="$IDE_HOME" "$MCPP" ide snapshot \ + --target imaginary-unknown-none --profile dev --features alpha,beta \ + --cap ssl=missing --include-dev-dependencies) \ + >"$TMP/selectors.json" 2>"$TMP/selectors.err" + +[[ ! -s "$TMP/default.err" ]] || { cat "$TMP/default.err"; exit 1; } +[[ ! -s "$TMP/selectors.err" ]] || { cat "$TMP/selectors.err"; exit 1; } +grep -q '"kind": "mcpp.ide.snapshot"' "$TMP/default.json" +grep -q '"state": "partial"' "$TMP/default.json" +grep -q '"target": "imaginary-unknown-none"' "$TMP/selectors.json" +grep -q '"includeDevDependencies": true' "$TMP/selectors.json" + +inventory "$PROJECT" "$TMP/project.after" +inventory "$IDE_HOME" "$TMP/home.after" +cmp -s "$TMP/project.before" "$TMP/project.after" || { + diff -u "$TMP/project.before" "$TMP/project.after" || true + echo "project tree changed" >&2 + exit 1 +} +cmp -s "$TMP/home.before" "$TMP/home.after" || { + diff -u "$TMP/home.before" "$TMP/home.after" || true + echo "isolated MCPP_HOME changed" >&2 + exit 1 +} + +for path in \ + "$PROJECT/.mcpp" \ + "$PROJECT/.xlings.json" \ + "$PROJECT/mcpp.lock" \ + "$PROJECT/target" \ + "$PROJECT/compile_commands.json" \ + "$PROJECT/src/generated.cpp" \ + "$PROJECT/build-mcpp-ran"; do + [[ ! -e "$path" ]] || { echo "unexpected artifact: $path" >&2; exit 1; } +done +[[ -z "$(find "$IDE_HOME" -mindepth 1 -print -quit)" ]] || { + echo "isolated MCPP_HOME is not empty" >&2 + find "$IDE_HOME" -mindepth 1 -print >&2 + exit 1 +} + +echo "OK" From c58bdf45704aeef72e48f8ce72d3a3f980613d94 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:38:47 +0800 Subject: [PATCH 12/26] docs(ide): define universal project model and early CDB plan --- .../2026-08-06-mcpp-universal-ide-model.md | 489 ++++++++++++++++++ ...6-08-06-mcpp-universal-ide-model-design.md | 380 ++++++++++++++ 2 files changed, 869 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md create mode 100644 docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md diff --git a/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md b/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md new file mode 100644 index 00000000..286026b1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md @@ -0,0 +1,489 @@ +# mcpp 通用 IDE 工程模型实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 mcpp 在普通源码编译前发布带有真实工程模型和正确编译参数的 CDB,并在后台准备模块/生成产物,让 VS Code 和其他客户端不再从 CDB 或人类日志反推工程语义。 + +**Architecture:** 保留 M0 `inspect` 作为只读声明入口,新增显式 `configure` 和 `prepare`。`configure` 复用 mcpp 的解析链路,在 BuildPlan 和 CompileFlags 形成后先发布 fresh-only CDB;`prepare` 使用最小 Ninja goals 生成 generated outputs、std/工程 BMI,再发布 ready snapshot。协议采用 mcpp 自有版本化 JSON/NDJSON,公共模型预留到 BSP 的映射,但第一版不实现 daemon 或 BSP server。 + +**Tech Stack:** C++23 named modules、现有 `std::expected`/`std::filesystem`、nlohmann JSON module、Ninja backend、GoogleTest、portable Bash E2E、VS Code Extension Host、TypeScript、官方 clangd。 + +--- + +## 文件地图 + +| 文件 | 责任 | +| --- | --- | +| `src/ide/model.cppm` | schema 1 的 project/configuration/target/dependency/toolchain/artifact/diagnostic DTO | +| `src/ide/snapshot.cppm` | inspection/snapshot JSON、ID 和兼容字段 | +| `src/ide/events.cppm` | NDJSON event envelope、sequence、phase/status 和错误事件 | +| `src/ide/publish.cppm` | reply/index/current/last-known-good 原子发布 | +| `src/ide/inspect.cppm` | M0 只读声明和已发布状态读取 | +| `src/ide/configure.cppm` | resolution + BuildPlan + fresh CDB + configured snapshot | +| `src/ide/prepare.cppm` | configure 复用、artifact goals、ready snapshot | +| `src/build/resolution.cppm` | 从 `prepare_build()` 提取 resolved packages、edges、features 和 provenance | +| `src/build/artifact_layout.cppm` | BMI/std/generated/action 路径及 IDE goal 计算 | +| `src/build/compile_commands.cppm` | fresh-only CDB 发射;保留 legacy merge API | +| `src/build/backend.cppm` | emit/build 分离及跳过 link preflight 的 options | +| `src/build/ninja_backend.cppm` | build.ninja/CDB emission、显式 goal 执行和结果记录 | +| `src/build/prepare.cppm` | 解析流程拆分为 resolve、plan、materialize、execute 可复用阶段 | +| `src/toolchain/stdmod.cppm` | 纯 std artifact 描述与实际 ensure/build 分离 | +| `src/cli/cmd_ide.cppm` | inspect/configure/prepare CLI 路由和 selectors | +| `src/cli.cppm` | help、命令注册和 snapshot compatibility alias | +| `tests/unit/test_ide_protocol.cpp` | DTO、ID、event、publisher、schema fixture | +| `tests/unit/test_ide_configure.cpp` | CDB 提前发布和 no-ordinary-build 契约 | +| `tests/unit/test_ide_prepare.cpp` | minimal goals、artifact state 和回退 | +| `tests/e2e/192_ide_configure.sh` | fresh CDB、错误源码、事件顺序和零链接证明 | +| `tests/e2e/193_ide_prepare.sh` | generated/BMI、last-known-good、取消和多配置 | +| `/Users/cltx/projects/mcpp/mcpp-vscode/src/ideProtocol.ts` | 插件协议进程、NDJSON parser、snapshot client | +| `/Users/cltx/projects/mcpp/mcpp-vscode/src/ideWorkflow.ts` | configure/prepare 生命周期和状态机 | +| `/Users/cltx/projects/mcpp/mcpp-vscode/src/extension.ts` | 打开工程、watcher、clangd 配置和 UI 生命周期 | +| `/Users/cltx/projects/mcpp/mcpp-vscode/src/cliController.ts` | 保留 build/test/run,增加 IDE operation execution | +| `/Users/cltx/projects/mcpp/mcpp-vscode/test/ideProtocol.test.ts` | parser、version/capability/fallback tests | +| `/Users/cltx/projects/mcpp/mcpp-vscode/test/ideWorkflow.test.ts` | configured/ready/stale/cancel/late-event tests | +| `/Users/cltx/projects/mcpp/mcpp-vscode/test/extension-host/` | 真实 VS Code + fake/real mcpp integration fixtures | + +## 任务 1:锁定 schema 1 和 operation model + +**Files:** +- Modify: `src/ide/model.cppm` +- Modify: `src/ide/snapshot.cppm` +- Create: `src/ide/events.cppm` +- Create: `tests/unit/test_ide_protocol.cpp` +- Create: `tests/fixtures/ide/project-v1.json` +- Create: `tests/fixtures/ide/configured-v1.json` +- Create: `tests/fixtures/ide/events-v1.ndjson` + +- [ ] **Step 1: 写失败测试固定 ID、phase、state 和 event wire names** + +测试必须锁定:`declared`、`configured`、`ready`、`stale`、`unavailable`,以及 `operation-started`、`progress`、`diagnostic`、`snapshot-published`、`operation-finished`。测试输入使用固定绝对路径 `/workspace/app` 和固定 selector,断言同一输入生成相同 `projectId`/`configurationId`,改变 toolchain fingerprint 后只改变 `snapshotId`。 + +- [ ] **Step 2: 运行 focused test 确认 RED** + +运行: + +```bash +mcpp test ide_protocol +``` + +预期:失败,因为 phase/state/event DTO 尚未导出。 + +- [ ] **Step 3: 实现最小公共 DTO 和 canonical serialization** + +新增导出类型:`ProjectId`、`ConfigurationId`、`SnapshotId`、`IdePhase`、`ArtifactStatus`、`IdeEventType`、`OperationStatus`、`ConfigurationSelectors`、`ToolchainIdentity`、`TargetModel`、`DependencyModel`、`ArtifactModel`、`IdeSnapshot`、`IdeEvent`。ID 计算函数必须接收 canonical JSON/text,而不是内部对象地址或 wall-clock time。 + +- [ ] **Step 4: 写 fixture parser 和 additive-field test** + +用 nlohmann JSON 读取 fixture,断言未知字段不影响解析,缺少必需 `schemaVersion/kind/projectId/configurationId` 时返回稳定 `MCPP_IDE_SCHEMA_INVALID`。NDJSON parser 必须逐行处理,空行跳过,非对象行和重复/倒退 `seq` 返回错误。 + +- [ ] **Step 5: 运行 focused test 确认 GREEN 并提交** + +运行 `mcpp test ide_protocol`,预期所有 schema/ID/event 测试通过。 + +提交: + +```bash +git add src/ide/model.cppm src/ide/snapshot.cppm src/ide/events.cppm tests/unit/test_ide_protocol.cpp tests/fixtures/ide +git commit -m "feat(ide): define versioned project and operation protocol" +``` + +## 任务 2:提取 resolved project model + +**Files:** +- Create: `src/build/resolution.cppm` +- Modify: `src/build/prepare.cppm` +- Modify: `src/build/plan.cppm` +- Modify: `src/modgraph/graph.cppm` +- Create: `tests/unit/test_build_resolution.cpp` + +- [ ] **Step 1: 为 resolved package/edge/feature/provenance 写失败测试** + +构造两个临时 path packages 和一个 feature-gated dependency,调用新的 resolution API,断言返回 member/package IDs、dependency edge visibility、requested/default features、effective target/profile 和 source provenance。测试不得调用 Ninja,也不得创建最终 binary。 + +- [ ] **Step 2: 运行 `mcpp test build_resolution` 确认 RED** + +预期:模块不存在或 API 未导出。 + +- [ ] **Step 3: 从 `prepare_build()` 提取可复用 resolution 结果** + +把当前局部 `DependencyEdge`、resolved `packages`、activated features、toolchain selection、generated/action outputs 和 workspace selection 放进公共结果对象。保持 `BuildContext` 对 build/test 的字段兼容;新增字段只记录来源,不改变现有 build 语义。 + +- [ ] **Step 4: 将 BuildPlan 投影输入改为 resolved result** + +让 `make_plan()` 继续接收内部构建参数,但由 resolution 结果统一提供 packages/edges/topological order。不要让 `mcpp.ide` 直接 import `mcpp.build.plan` 的内部 DTO。 + +- [ ] **Step 5: 运行 focused tests 和现有 build-plan tests** + +运行: + +```bash +mcpp test build_resolution +mcpp test plan +``` + +预期:新测试通过,现有 plan 测试无行为变化。 + +- [ ] **Step 6: 提交** + +```bash +git add src/build/resolution.cppm src/build/prepare.cppm src/build/plan.cppm src/modgraph/graph.cppm tests/unit/test_build_resolution.cpp +git commit -m "refactor(build): expose resolved project model for IDE clients" +``` + +## 任务 3:分离 artifact layout 和 std BMI 描述 + +**Files:** +- Create: `src/build/artifact_layout.cppm` +- Modify: `src/build/ninja_backend.cppm` +- Modify: `src/toolchain/stdmod.cppm` +- Modify: `src/build/plan.cppm` +- Create: `tests/unit/test_ide_artifact_layout.cpp` + +- [ ] **Step 1: 写失败测试锁定路径和 goal 集合** + +给定含一个普通 `.cpp`、一个 module interface、一个 `import std` 的 BuildPlan,断言: + +```text +configured CDB entries = all supported source units +IDE goals = generated outputs + std BMI + module BMI/interface object +IDE goals do not contain ordinary implementation object or LinkUnit output +``` + +测试还要断言两个不同 configuration/fingerprint 不共享 BMI 路径。 + +- [ ] **Step 2: 运行 focused test 确认 RED** + +运行 `mcpp test ide_artifact_layout`,预期 artifact layout API 不存在。 + +- [ ] **Step 3: 提取 Ninja 私有 BMI/output path 函数** + +将 `bmi_path()`、staged std/std.compat 路径、source action outputs 和 object-to-source mapping 收敛到 `mcpp.build.artifact_layout`。Ninja 只消费该模块,不再私自重新计算路径。 + +- [ ] **Step 4: 把 `stdmod::ensure_built` 拆成 describe/materialize** + +新增纯函数返回 std identity、cacheDir、bmi/object/compat 路径和 metadata 输入;`ensure_built()` 调用该描述并负责实际编译。configure 只调用 describe,prepare 才调用 materialize/ensure。 + +- [ ] **Step 5: 运行 focused test 和 std BMI regression tests** + +运行 `mcpp test ide_artifact_layout` 以及现有 `stdmod`/BMI cache tests,确认路径和缓存命中语义保持一致。 + +- [ ] **Step 6: 提交** + +```bash +git add src/build/artifact_layout.cppm src/build/ninja_backend.cppm src/toolchain/stdmod.cppm src/build/plan.cppm tests/unit/test_ide_artifact_layout.cpp +git commit -m "refactor(build): centralize IDE artifact layout and goals" +``` + +## 任务 4:让 CDB 支持 fresh-only 和提前发布 + +**Files:** +- Modify: `src/build/compile_commands.cppm` +- Modify: `src/build/ninja_backend.cppm` +- Modify: `src/build/backend.cppm` +- Modify: `src/build/execute.cppm` +- Create: `tests/unit/test_compile_commands_fresh.cpp` + +- [ ] **Step 1: 写失败测试证明 fresh CDB 不保留历史条目** + +用一个旧 CDB 包含 `/old/test.cpp`,fresh plan 只包含 `/new/main.cpp`,调用 fresh emitter,断言输出只有 `/new/main.cpp`。单独调用旧 `merge_compile_commands()` 的测试保持原行为,证明 legacy build/test compatibility 未被删除。 + +- [ ] **Step 2: 运行 focused test 确认 RED** + +运行 `mcpp test compile_commands_fresh`,预期 fresh API 不存在。 + +- [ ] **Step 3: 增加 `emit_build_files` 和 `execute_goals` backend 边界** + +`NinjaBackend` 提供两个阶段: + +```cpp +std::expected +emit_build_files(const BuildPlan&, const EmitOptions&); + +std::expected +execute_goals(const BuildPlan&, const BuildOptions&); +``` + +`emit_build_files` 写 build.ninja、fresh CDB 和 generated action metadata;`execute_goals` 才执行 hermetic link preflight(当 goal 集合包含 link output 时)和 Ninja。`dryRun` 不再承担 IDE API 的全部语义。 + +- [ ] **Step 4: 让普通 `mcpp build` 复用同一 emission** + +保持默认 build 的 root CDB compatibility projection 和历史 merge,但实际 fresh content 必须先由同一个 emitter 生成,避免 IDE CDB 与 build CDB 参数漂移。 + +- [ ] **Step 5: 运行 CDB、backend 和现有 build tests** + +运行 `mcpp test compile_commands_fresh`、`mcpp test backend` 和现有 CDB e2e。预期普通 build 行为不变,fresh emitter 无历史污染。 + +- [ ] **Step 6: 提交** + +```bash +git add src/build/compile_commands.cppm src/build/ninja_backend.cppm src/build/backend.cppm src/build/execute.cppm tests/unit/test_compile_commands_fresh.cpp +git commit -m "feat(build): publish fresh compile databases before execution" +``` + +## 任务 5:实现 snapshot publisher 和 configured/ready index + +**Files:** +- Create: `src/ide/publish.cppm` +- Modify: `src/ide/snapshot.cppm` +- Modify: `src/ide/model.cppm` +- Create: `tests/unit/test_ide_publish.cpp` +- Create: `tests/fixtures/ide/publisher-layout.txt` + +- [ ] **Step 1: 写失败测试覆盖原子发布和回退** + +测试在临时 work root 中发布 configured snapshot,再发布 ready snapshot,断言 index 只引用完整 JSON;模拟失败/取消时 index、current 和 last-known-good 不变,临时文件不会被客户端看到。 + +- [ ] **Step 2: 运行 `mcpp test ide_publish` 确认 RED** + +- [ ] **Step 3: 实现 publisher API** + +导出以下 API: + +```cpp +std::expected +publish_snapshot(const SnapshotDocument&, const PublishOptions&); + +std::expected +publish_last_known_good(const ConfigurationId&, const SnapshotId&); + +std::expected, PublishError> +read_current_snapshot(const ConfigurationId&, const std::filesystem::path&); +``` + +publisher 负责 temp file、flush/close、JSON parse validation、rename、index 引用和摘要校验;不负责解析 manifest 或执行工具。 + +- [ ] **Step 4: 实现 configured/ready 与 stale 计算** + +configured CDB 发布后可成为 `current`,只有 ready 才更新 `last-known-good`。输入摘要变化而旧 snapshot 仍存在时返回 `stale`,不要删除旧回复文件。 + +- [ ] **Step 5: 运行 publisher tests 并提交** + +```bash +mcpp test ide_publish +git add src/ide/publish.cppm src/ide/snapshot.cppm src/ide/model.cppm tests/unit/test_ide_publish.cpp tests/fixtures/ide/publisher-layout.txt +git commit -m "feat(ide): atomically publish configured and ready snapshots" +``` + +## 任务 6:实现 `mcpp ide configure` + +**Files:** +- Create: `src/ide/configure.cppm` +- Modify: `src/cli/cmd_ide.cppm` +- Modify: `src/cli.cppm` +- Modify: `src/ide/model.cppm` +- Create: `tests/unit/test_ide_configure.cpp` +- Create: `tests/e2e/192_ide_configure.sh` + +- [ ] **Step 1: 写失败测试锁定事件顺序** + +使用临时工程和 fake toolchain,断言 stdout 事件顺序为 `operation-started -> progress(resolve) -> snapshot-published(configured) -> operation-finished`。在 `snapshot-published` 事件之后检查 CDB 已存在;测试记录 fake compiler 尚未收到普通项目 TU 编译调用。 + +- [ ] **Step 2: 运行 focused test 确认 RED** + +运行 `mcpp test ide_configure`,预期 configure module/CLI 不存在。 + +- [ ] **Step 3: 实现 configure pipeline** + +configure 按以下顺序调用: + +```text +selectors -> inspect/resolve workspace + -> resolve_project + -> describe toolchain/std artifacts + -> build.mcpp/generated resolution + -> module graph + BuildPlan + -> compute_flags + -> emit fresh CDB + -> validate entries and paths + -> publish configured snapshot +``` + +任何普通项目 compile goal 或 link output 都不得在该命令中执行。不可避免的 host helper/build.mcpp 执行必须以 progress/diagnostic event 标记。 + +- [ ] **Step 4: 实现 CLI NDJSON 输出和退出码** + +stdout 只写合法 NDJSON;人类日志进入 stderr/output channel。成功 configured 返回 0;解析失败、权限失败或无 CDB 且无 last-known-good 返回 3;取消返回 130;CLI 参数错误返回 2。 + +- [ ] **Step 5: 写 E2E:错误源码仍产生 CDB** + +fixture 包含语法错误的 `src/main.cpp`。运行 configure,解析 configured event,检查 CDB entry 的 compiler/directory/arguments;断言最终 binary 和普通 `.o` 不存在。使用一个不会触发网络安装的显式 system toolchain fixture,避免测试依赖外部 index。 + +- [ ] **Step 6: 运行 focused unit/e2e 并提交** + +```bash +mcpp test ide_configure +bash tests/e2e/192_ide_configure.sh +git add src/ide/configure.cppm src/cli/cmd_ide.cppm src/cli.cppm src/ide/model.cppm tests/unit/test_ide_configure.cpp tests/e2e/192_ide_configure.sh +git commit -m "feat(ide): add configure command with early CDB publication" +``` + +## 任务 7:实现 `mcpp ide prepare` 的最小 goals + +**Files:** +- Create: `src/ide/prepare.cppm` +- Modify: `src/build/artifact_layout.cppm` +- Modify: `src/build/backend.cppm` +- Modify: `src/build/ninja_backend.cppm` +- Create: `tests/unit/test_ide_prepare.cpp` +- Create: `tests/e2e/193_ide_prepare.sh` + +- [ ] **Step 1: 写失败测试固定 goal 过滤** + +给定普通 `.cpp`、module interface、generated action 和 binary target,断言 prepare goals 只包含 generated output、std/BMI 和 module interface object/BMI;binary output 和普通 implementation object 不在 goals 中。 + +- [ ] **Step 2: 运行 focused test 确认 RED** + +运行 `mcpp test ide_prepare`,预期 prepare goal selector 不存在。 + +- [ ] **Step 3: 实现 `ide_artifact_goals(plan)`** + +从 `artifact_layout` 返回去重、稳定排序的相对 Ninja outputs。goal 计算必须依据 `CompileUnit::providesModule`、std artifact descriptor 和 `BuildPlan::actions`,不得通过字符串匹配 `build.ninja`。 + +- [ ] **Step 4: 跳过不必要的 link preflight** + +当 `BuildOptions.ninjaTargets` 不包含 `LinkUnit::output` 时,Ninja backend 不运行 hermetic link preflight,也不把 final link 产物记录为 produced artifact。 + +- [ ] **Step 5: 发布 ready 或保留 configured** + +prepare 成功后验证每个 artifact 的 existence、regular-file/type、toolchain fingerprint 和 expected producer;不满足时发布结构化 diagnostic,configured snapshot 仍可使用,last-known-good 不更新。 + +- [ ] **Step 6: E2E 验证 generated/BMI/无最终链接** + +fixture 需要一个 generated header 或 source、一个 module interface 和一个 binary target。运行 prepare,断言 generated file、std/module BMI 存在,普通 implementation `.o` 和 final binary 不存在;允许 host helper、host tool 和 module interface object 存在。 + +- [ ] **Step 7: 运行测试并提交** + +```bash +mcpp test ide_prepare +bash tests/e2e/193_ide_prepare.sh +git add src/ide/prepare.cppm src/build/artifact_layout.cppm src/build/backend.cppm src/build/ninja_backend.cppm tests/unit/test_ide_prepare.cpp tests/e2e/193_ide_prepare.sh +git commit -m "feat(ide): prepare generated files and module artifacts without linking" +``` + +## 任务 8:移除插件侧工程反推,接入协议客户端 + +**Files:** +- Create: `/Users/cltx/projects/mcpp/mcpp-vscode/src/ideProtocol.ts` +- Create: `/Users/cltx/projects/mcpp/mcpp-vscode/src/ideWorkflow.ts` +- Modify: `/Users/cltx/projects/mcpp/mcpp-vscode/src/extension.ts` +- Modify: `/Users/cltx/projects/mcpp/mcpp-vscode/src/workflow.ts` +- Modify: `/Users/cltx/projects/mcpp/mcpp-vscode/src/cliController.ts` +- Modify: `/Users/cltx/projects/mcpp/mcpp-vscode/src/analysis.ts` +- Modify: `/Users/cltx/projects/mcpp/mcpp-vscode/package.json` +- Create: `/Users/cltx/projects/mcpp/mcpp-vscode/test/ideProtocol.test.ts` +- Create: `/Users/cltx/projects/mcpp/mcpp-vscode/test/ideWorkflow.test.ts` + +- [ ] **Step 1: 写 fake NDJSON client 测试** + +输入 configured、ready、stale、diagnostic、cancelled 和 legacy events,断言 parser 拒绝错误 schema/倒退 seq,workflow 正确更新 per-project/per-configuration state,并丢弃旧 operation 的迟到事件。 + +- [ ] **Step 2: 实现 argv-array protocol client** + +使用 `child_process.spawn` 的 argv 数组,cwd 为 selected project root。stdout 逐行解析,stderr 进入现有 output channel;提供 `AbortSignal`,结束时返回 exit code 和 last-known-good ID。不要用正则解析任何 mcpp 人类输出。 + +- [ ] **Step 3: 接入首次打开和 manifest watcher** + +受信任 workspace:`inspect -> configure -> apply configured CDB -> continue prepare -> apply ready`。未受信任 workspace:只读 index/CDB,不执行 mcpp、compiler 或 clangd。manifest/selector/profile/target 变化时按 `projectId + configurationId` 取消旧 operation。 + +- [ ] **Step 4: 用 snapshot toolchain 配置 clangd** + +`configureClangd` 接收 snapshot 的 compiler path、clangd path/identity、compileCommands directory、module capability 和 workspace scope。原有 `analyzeCompilationDatabase()` 仅保留为 CDB syntax/sanity check 和 legacy fallback,不再选择工程 capability。 + +- [ ] **Step 5: 替换 toolchain list 正则路径** + +新增 mcpp protocol capability 可用时,工具链 QuickPick 读取 snapshot 的 `toolchain`/`configurations`;旧 mcpp 才调用 `toolchain list` 和现有 parser。测试覆盖版本协商和旧版 fallback。 + +- [ ] **Step 6: 更新命令和设置** + +新增 `mcpp.configureIde`、`mcpp.prepareIde` 命令;把“刷新编译数据库”改为 configure,并保留旧命令别名。状态栏显示 `declared/configured/ready/stale/unavailable`,不要把 build exit code 当作 CDB availability。 + +- [ ] **Step 7: 运行插件单元测试并提交** + +在插件仓库运行: + +```bash +npm test +git add src/ideProtocol.ts src/ideWorkflow.ts src/extension.ts src/workflow.ts src/cliController.ts src/analysis.ts package.json test/ideProtocol.test.ts test/ideWorkflow.test.ts +git commit -m "feat: consume mcpp IDE snapshots and early CDB events" +``` + +## 任务 9:真实 VS Code/LLVM 发布门槛 + +**Files:** +- Create: `/Users/cltx/projects/mcpp/mcpp-vscode/test/extension-host/ide-fixture/` +- Create: `/Users/cltx/projects/mcpp/mcpp-vscode/test/extension-host/ide-e2e.ts` +- Modify: `/Users/cltx/projects/mcpp/mcpp-vscode/package.json` +- Modify: `tests/e2e/192_ide_configure.sh` +- Modify: `tests/e2e/193_ide_prepare.sh` +- Create: `docs/ide/ide-client-contract.md` + +- [ ] **Step 1: 建立真实 extension host runner** + +使用 VS Code Extension Development Host、真实编译后的 mcpp binary 和显式匹配 LLVM/clangd。fake process 仅覆盖 parser/workflow 单测,不能作为发布证据。 + +- [ ] **Step 2: 验证提前 CDB** + +fixture 源码包含错误模板、缺失符号和同名 `util.h`。在 configure event 后立即打开文件,断言 clangd 使用 snapshot CDB,诊断路径和 include 路径来自项目,不是 Apple SDK fallback。 + +- [ ] **Step 3: 验证模块两阶段行为** + +先断言 configured 阶段普通文件补全/跳转可用,module import 显示 pending;prepare 完成后重新检查跨模块补全、definition/reference 和 PCM producer identity。 + +- [ ] **Step 4: 验证 UChat 和多根 workspace** + +用 `/Users/cltx/ppp/UChat/server` 选择不同 member/target,验证 configurationId、CDB 和 generated sources 不串用。多根 A/B 切换时只让活动工程更新窗口级 clangd。 + +- [ ] **Step 5: 验证失败回退、信任和取消** + +分别制造网络/依赖失败、build.mcpp 失败、module BMI 失败、取消和旧 mcpp。断言 last-known-good 保持、迟到事件被忽略、未受信任 workspace 不执行外部程序、legacy 状态可见。 + +- [ ] **Step 6: 运行完整发布验证** + +运行: + +```bash +mcpp test +npm test +npm run package +``` + +再在真实 VS Code/LLVM 矩阵运行 extension-host E2E,记录 clangd revision、compiler revision、CDB path、snapshotId 和 artifact state。 + +- [ ] **Step 7: 提交文档和发布门槛** + +```bash +git add docs/ide/ide-client-contract.md tests/e2e/192_ide_configure.sh tests/e2e/193_ide_prepare.sh +git commit -m "docs(ide): document client contract and release gates" +``` + +## 任务 10:后续 BSP adapter 评估 + +**Files:** +- Create: `docs/ide/bsp-mapping.md` +- Create: `src/ide/bsp_adapter.cppm`(仅在有第二个真实客户端需求后) +- Create: `tests/unit/test_ide_bsp_mapping.cpp`(与 adapter 同步) + +- [ ] **Step 1: 用 schema 1 建立映射表** + +固定 `workspace -> workspace/buildTargets`、target/source/dependencies、test/run、diagnostics 和 C++ compile options/artifacts 的映射,标记 mcpp 特有字段如何放入 extension namespace。 + +- [ ] **Step 2: 只有真实客户端需求成立时才实现 JSON-RPC transport** + +adapter 必须调用同一 `project_model/configure/prepare` API,不得在 BSP 层重新解析 manifest、CDB 或 toolchain。没有第二个客户端时只交付映射文档,不增加 daemon 维护成本。 + +- [ ] **Step 3: 运行 mapping tests 并记录兼容性** + +schema 增加字段时先更新 mcpp snapshot contract,再更新 BSP adapter;不能让 BSP 的未批准变化反向破坏 mcpp schema。 + +## 交付顺序和停止条件 + +第一期交付范围是任务 1-7:mcpp 能在普通源码编译前发布 configured CDB,并能按最小 goals 准备模块产物。第二期是任务 8-9:VS Code 消费协议并通过真实 clangd/Extension Host。任务 10 延后到至少有一个非 VS Code 客户端或明确的 BSP 集成需求。 + +每期都必须满足: + +- focused unit tests 通过; +- 真实 CLI E2E 通过; +- `git diff --check` 通过; +- 没有把历史 CDB 条目、单代表性 CDB 推断或人类 CLI 正则重新引入新主链路; +- 文档中的 capability、state、side-effect 和回退承诺与测试一致。 + diff --git a/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md new file mode 100644 index 00000000..f0387965 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md @@ -0,0 +1,380 @@ +# mcpp 通用 IDE 工程模型与提前 CDB 设计 + +**状态:** 提案,供实施前审阅 + +**日期:** 2026-08-06 + +## 1. 目标与结论 + +mcpp IDE 集成的目标不是让 VS Code 猜出更多 CDB 信息,而是让 mcpp 成为工程模型、配置解析和 IDE 构建产物的权威提供者。VS Code、clangd、测试面板、调试器和其他 IDE 客户端消费同一份版本化模型。 + +本方案采用三阶段生命周期: + +```text +mcpp ide inspect + -> 只读读取声明模型和已发布状态 + +mcpp ide configure + -> 解析 workspace、依赖、feature、toolchain、build.mcpp 和 source + -> BuildPlan 成功后立即发布新 CDB + -> 不编译普通实现单元,不链接最终程序/库 + +mcpp ide prepare + -> 复用 configure 结果 + -> 生成必要的 generated outputs、std BMI 和工程模块 BMI + -> 发布 ready 快照 +``` + +关键决策是:CDB 不需要等待最终编译成功。当前 Ninja 后端已经在启动 Ninja 之前由 `BuildPlan + CompileFlags` 写出 CDB(见 `src/build/ninja_backend.cppm` 的 `NinjaBackend::build`)。缺少的是独立的、可观察的 configure 生命周期,以及 CDB 的 provenance、freshness 和 snapshot 所有权。 + +普通 C++ 语法错误通常不会阻止当前默认的正则模块扫描,因此 configure 可以在源码编译前产出带有正确 compiler、include、macro、标准、sysroot 和 module search path 的 CDB。依赖解析、工具链解析、`build.mcpp`、generated files 或模块图验证失败时,mcpp 不伪造完整 CDB,而是保留 last-known-good 并返回结构化诊断。 + +## 2. 现状证据与问题边界 + +### 2.1 CDB 不是编译正确性的证明 + +clangd 需要的是每个文件的编译上下文,不是该文件已经成功编译。clangd 官方说明中,CDB 提供的是 compiler argv、工作目录、语言标准、宏和 include 路径;这些参数决定解析上下文,但不代表源文件无语法错误。 + +mcpp 当前的链路是: + +```text +manifest/workspace/selector + -> dependency/toolchain resolution + -> generated_files 和 build.mcpp + -> module scan/validate + -> std BMI preparation + -> BuildPlan + -> Ninja emit + compile_commands.json + -> Ninja execution +``` + +`src/build/compile_commands.cppm` 已经可以只根据 `BuildPlan + CompileFlags` 生成 CDB,不需要运行 Ninja。`src/build/ninja_backend.cppm` 先写 `build.ninja` 和 CDB,再执行 hermetic link preflight 和 Ninja。因此“只有成功 build 后才有 CDB”不是当前源码事实;实际问题是: + +- 插件通过 `mcpp build` 任务间接触发 CDB 生成; +- 插件没有收到“配置已完成、CDB 已发布”的稳定事件; +- CDB 目前会合并历史条目,不能代表单一 configuration; +- CDB 没有绑定 `configurationId`、toolchain fingerprint、生成来源和 PCM 状态; +- `prepare_build()` 在 BuildPlan 之前可能安装依赖、运行 `build.mcpp`、物化 generated files、构建 host tools 或 std BMI;失败时没有独立的部分结果协议; +- 插件从一个代表性 CDB 条目反推整个工程和模块能力。 + +### 2.2 必须区分三个状态 + +| 状态 | 可用数据 | clangd 行为 | 产生条件 | +| --- | --- | --- | --- | +| `declared` | manifest、workspace、声明的 targets/features | 不执行 mcpp;可显示工程诊断 | `inspect` 成功 | +| `configured` | resolved model、toolchain、fresh CDB、预期 artifact 路径 | 立即可解析普通 C++;依赖模块可能报告 artifact pending | BuildPlan 和 CDB 发射成功 | +| `ready` | configured 数据加上 generated outputs、std/工程 BMI 和匹配指纹 | 允许跨模块补全、跳转和语义诊断 | `prepare` 的要求全部满足 | + +`configured` 不是编译失败,也不是降级 CDB。它是一个明确的 IDE 可用状态。`ready` 只表示 IDE 所需的产物已满足,不表示最终链接或运行时测试成功。 + +## 3. 设计目标 + +### 3.1 必须实现 + +- mcpp 原生 workspace/member/target/profile/feature/dependency/toolchain 的机器可读模型。 +- CDB 在 BuildPlan 完成后、普通源码编译前发布。 +- CDB 与 configuration、toolchain、target triple、stdlib、C++ standard、PCM producer 和 generated files 绑定。 +- CDB 使用 snapshot-local、无历史合并的内容;兼容旧客户端时再单独复制到项目根。 +- configure 和 prepare 输出版本化 JSON/NDJSON,支持进度、诊断、取消和失败回退。 +- 允许插件在 CDB 发布事件后立即配置 clangd,并在 ready 事件后刷新模块状态。 +- 为其他 IDE、测试/调试工具和 CI 保留同一数据模型入口。 +- 对 LLVM、GCC、MSVC、外部 CMake/CDB-only 项目明确 capability,而不是猜测完整模块支持。 + +### 3.2 不在第一版承诺 + +- 自研 C++ language server 或替代 clangd。 +- 一开始实现常驻 daemon、LSP 或完整 JSON-RPC 服务。 +- 让 configure 在无依赖、无 toolchain 或 `build.mcpp` 失败时猜出“正确”的完整 CDB。 +- 承诺 GCC `.gcm`、MSVC `.ifc` 能被 clangd 读取。 +- 解析所有 CMake/Meson/Bazel 工程的完整语义。外部工程先提供 CDB-only fallback。 +- 把 Cargo、CMake 或 BSP 的内部对象直接作为 mcpp wire DTO。 + +## 4. 方案比较 + +### 4.1 方案 A:继续让插件监听根 CDB + +这是当前行为的低成本延伸。插件在 `compile_commands.json` 出现或变化时重新配置 clangd,构建失败但 CDB 存在时保留配置。 + +优点是改动少,且当前 watcher 已能观察构建过程中的 CDB 写入。缺点是没有 configuration 选择、CDB provenance、依赖图、PCM ownership、取消和跨客户端契约;插件仍需从单个 CDB 条目和人类 CLI 输出推断工程。 + +结论:保留为旧 mcpp fallback,不作为通用架构。 + +### 4.2 方案 B:直接实现 BSP server + +BSP 的目标是减少 IDE 与 build tool 的两两集成,提供 workspace/build targets/sources/dependencies/tasks,并以 JSON-RPC 管理生命周期。它与 LSP 可以组合,概念上适合 mcpp。 + +但 BSP 当前规范明确标注为未批准标准,核心结构仍可能变化;C++ 扩展需要额外协议,PCM freshness、CDB ownership、`build.mcpp` 副作用和 mcpp 特有 capability 仍需自定义。直接实现 BSP 会把传输、daemon 生命周期和工程模型同时引入,无法快速改善现有插件。 + +结论:不作为第一条 wire protocol;将公共模型设计成可映射到 BSP,未来按实际客户端需求提供 BSP adapter。 + +### 4.3 方案 C:mcpp 版本化快照 + one-shot CLI,未来适配 BSP(推荐) + +核心协议是 mcpp 自有的稳定 JSON/NDJSON CLI,采用 Cargo metadata 的版本化/增量兼容原则和 CMake File API 的 query/reply、版本化 object kind、不可变回复文件思路。VS Code 先消费 CLI;需要时再由一个薄适配器映射到 BSP。 + +优点是直接复用现有 `mcpp` 进程模型、测试体系、BuildPlan 和 CDB 生成逻辑,能先解决 clangd,再逐步接入测试/运行/调试;缺点是 mcpp 需要维护自己的 schema,并且未来 BSP 适配需要额外映射。 + +结论:采用方案 C。 + +## 5. 命令和生命周期契约 + +### 5.1 命令 + +```text +mcpp ide inspect --format json [selectors] +mcpp ide configure --format ndjson [selectors] +mcpp ide prepare --format ndjson [--configuration-id ] [selectors] +``` + +当前未发布的 `mcpp ide snapshot --format json` 保留为 `inspect` 的兼容别名,输出中将 `kind` 标为 `mcpp.ide.inspection`,避免把声明检查误称为已发布构建快照。 + +selectors 与现有 build/test 保持一致:`--package`、`--workspace`、`--profile`、`--target`、`--features`、`--cap`、`--include-dev-dependencies`。解析后的 selector 必须在 wire 中回显。 + +### 5.2 `inspect` + +`inspect` 只读读取 manifest、workspace members、声明 target、已发布 snapshot index 和现有 CDB 状态。它不得初始化 `MCPP_HOME`、刷新 index、安装依赖、运行工具、编译 `build.mcpp` 或写项目文件。 + +它可以输出声明模型和 last-known-good 的摘要,但不能把现有根 CDB 直接标为 `ready`。没有 mcpp 发布 metadata 的根 CDB 状态为 `legacy/unknown`。 + +### 5.3 `configure` + +`configure` 是显式授权的配置操作,允许: + +- 解析和安装所需依赖、toolchain; +- 物化 `[generated_files]`; +- 编译/运行 `build.mcpp` 和必要的 host tools; +- 扫描并验证 module graph; +- 构造 resolved model、toolchain identity、BuildPlan 和 CompileFlags; +- 在任何普通项目 TU 或最终链接开始前发布 CDB。 + +`configure` 不请求普通实现 `.o`、最终库或最终程序。若当前 toolchain 的 `import std` 需要预编译 BMI,configure 只记录可确定的预期路径和 `pending` 状态;std/工程 BMI 的实际物化属于 `prepare`。如现有 `ensure_built()` 同时负责路径计算和编译,先提取纯路径/metadata 函数,再保留 `ensure_built()` 作为 prepare 阶段实现。 + +configure 的成功条件是“resolved BuildPlan 和 snapshot-local CDB 发射并发布成功”,不是源码编译成功。 + +### 5.4 `prepare` + +`prepare` 复用或重新执行 configure,然后只执行 IDE 必需的 goals: + +- generated action/source outputs; +- std/std.compat BMI; +- 提供模块的 CompileUnit 对应 BMI; +- 模块接口编译器无法分离出的接口对象。 + +它不请求普通实现 TU 的对象,也不请求 `LinkUnit::output`。host helper、`build.mcpp` 和请求的 host tool 可能仍需编译、链接和运行;这些副作用必须在事件和 manifest 中明确列出。 + +## 6. Wire model + +### 6.1 顶层对象 + +```json +{ + "schemaVersion": 1, + "kind": "mcpp.ide.snapshot", + "projectId": "path-sha256:...", + "configurationId": "config-sha256:...", + "snapshotId": "snapshot-sha256:...", + "phase": "configured", + "state": "configured", + "mcpp": { + "version": "2026.8.5.2", + "protocol": {"min": 1, "max": 1}, + "capabilities": ["workspace-model", "compile-commands", "module-artifacts"] + }, + "request": {"selectors": {}, "mode": "configure"}, + "workspace": {}, + "configurations": [], + "targets": [], + "dependencies": [], + "toolchain": {}, + "artifacts": {}, + "diagnostics": [] +} +``` + +客户端必须忽略未知字段。`schemaVersion` 只在不兼容字段或语义变化时增加;新增字段和新增枚举值遵循 Cargo metadata 的兼容规则。路径必须是绝对路径,wire 中使用 `/` 分隔符。 + +### 6.2 ID 和 freshness + +- `projectId`:由物理 workspace root 的规范化绝对路径计算,不能把 manifest 内容变化混入,以保持同一工程的 UI 状态。 +- `configurationId`:由 selector、effective profile、target triple、feature 集、offline/cache 模式和显式 toolchain 选择的规范化值计算。它区分配置,但不代表该配置当前已解析成功。 +- `snapshotId`:由 schema、resolved model、toolchain identity、CDB 内容摘要、module graph、artifact metadata 和输入指纹计算。它随 manifest、lockfile、toolchain、CDB 或产物变化。 +- 工具链 fingerprint 继续复用 `src/toolchain/fingerprint.cppm` 的 BMI safety 语义,但 wire 另列 compiler、driver identity、target triple、stdlib、standard 和来源。 + +### 6.3 工程和配置对象 + +`workspace` 描述 root manifest、members 和 member selection。`targets` 为每个 target 提供稳定 ID、member、kind、sources、generated sources、entry main、test/run/debug capability。`dependencies` 描述 resolved package identity、source/provenance、feature activation 和有向 edge;不能从 CDB 路径推断。 + +`toolchain` 描述 compiler family/path/version/driver identity、clangd compatibility、target triple、stdlib/sysroot、module dialect 和来源。插件不再从 `mcpp toolchain list` 人类输出正则推断这些字段。 + +### 6.4 Artifacts + +每个 artifact 包含: + +```json +{ + "kind": "compile-commands", + "owner": "target-id", + "path": "/project/.mcpp/ide/replies/compile_commands-.json", + "state": "ready", + "producer": "mcpp", + "fingerprint": "...", + "inputs": ["configurationId", "toolchainFingerprint"], + "lastKnownGood": false +} +``` + +artifact 状态包括 `pending`、`ready`、`stale`、`missing`、`unavailable`。CDB 必须由当前 resolved plan 单独发射,禁止复用 `write_compile_commands()` 的历史 merge 语义作为权威 IDE CDB。兼容旧 clangd 的根 `compile_commands.json` 是一个原子复制的 compatibility projection,不是 snapshot source of truth。 + +### 6.5 NDJSON 事件 + +`configure` 和 `prepare` 的 stdout 只输出一条 JSON 一行,格式如下: + +```json +{"schemaVersion":1,"type":"operation-started","operationId":"...","configurationId":"..."} +{"schemaVersion":1,"type":"progress","operationId":"...","phase":"resolve","completed":2,"total":5} +{"schemaVersion":1,"type":"diagnostic","operationId":"...","diagnostic":{}} +{"schemaVersion":1,"type":"snapshot-published","operationId":"...","phase":"configured","snapshotId":"...","manifest":"...","compileCommands":"..."} +{"schemaVersion":1,"type":"operation-finished","operationId":"...","status":"success","phase":"configured"} +``` + +事件包含单调递增 `seq`。失败、取消和超时也必须有 `operation-finished`,并携带 `status`、`diagnosticCodes` 和 `lastKnownGoodSnapshotId`。未受信任 workspace 不执行这些命令。 + +## 7. Snapshot 发布和早期 CDB + +### 7.1 发布目录 + +使用 mcpp 自己管理的目录,不依赖项目根写入: + +```text +/.mcpp/ide/ + configurations// + current.json + last-known-good.json + replies/ + index-.json + snapshot-.json + compile_commands-.json + artifacts// + std/ + modules/ + generated/ +``` + +回复文件写入临时文件后关闭并校验,再使用同目录 rename 发布。`index-.json` 只引用完整回复文件;客户端先读 index,再按引用读取对象。这借鉴 CMake File API 的 reply index 和不可变 reply 文件,避免读到半个 JSON。 + +### 7.2 CDB 发布时序 + +```text +resolve inputs + -> BuildPlan + -> compute_flags + -> emit fresh CDB to replies/compile_commands-.json + -> validate JSON entries and paths + -> publish configured snapshot/index + -> emit snapshot-published + -> optional prepare goals +``` + +插件收到 `snapshot-published(phase=configured)` 后立即将 clangd 的 compile-commands directory 指向该 CDB 所在目录,或使用 `--compile-commands-dir` 指向 compatibility projection。此时普通文件的语义解析不依赖最终 build 成功;import module 的文件可以暂时显示 `pending`,直到 prepare 发布 ready snapshot。 + +### 7.3 回退 + +- configure 失败且存在 last-known-good:不替换当前 clangd 数据;发布失败事件,返回 last-known-good ID。 +- configure 失败且没有 last-known-good:插件保持语法高亮,显示结构化错误,不执行 CDB 猜测。 +- prepare 失败:configured snapshot 仍然可用;last-known-good 只有在全部要求满足时才更新。 +- 取消或进程崩溃:不更新 index,不把半成品标为 ready;临时目录由下一次操作清理。 + +项目目录中的 `generated_files` 和任意 `build.mcpp` 代码执行无法纳入 mcpp 事务回滚。协议只能保证 mcpp 自己管理的 snapshot/reply/index 原子发布,并在诊断中准确区分“项目生成文件已写入”和“IDE 快照未发布”。 + +## 8. 后端和核心模块边界 + +需要从当前内部模型提取以下公共边界,而不是序列化 `BuildPlan`: + +| 模块 | 职责 | +| --- | --- | +| `mcpp.build.resolution` | 返回 resolved packages、edges、features、provenance、effective toolchain 以及 generated/action 结果 | +| `mcpp.build.artifact_layout` | 统一计算 object、BMI、std、generated 和 action 输出路径及 IDE goals | +| `mcpp.build.compile_commands` | 提供 fresh-only CDB 发射;保留历史 merge 仅给旧 build/test 工作流 | +| `mcpp.build.backend` | 分离 emit build files 与 execute goals;IDE goals 不触发 link preflight | +| `mcpp.ide.model` | 稳定 wire DTO,不导入 build-plan 内部类型 | +| `mcpp.ide.inspect` | M0 只读声明和已发布状态读取 | +| `mcpp.ide.configure` | 运行解析、生成 CDB 和 configured snapshot | +| `mcpp.ide.prepare` | 运行最小 artifact goals、校验并发布 ready snapshot | +| `mcpp.ide.events` | 统一 NDJSON envelope、进度、诊断、取消和完成状态 | +| `mcpp.ide.publish` | 临时文件、摘要校验、index/current/last-known-good 原子发布 | + +`prepare_build()` 当前将 `packages` 和 `DependencyEdge` 保留为局部类型,返回的 `BuildContext` 丢失了完整依赖图。第一阶段应提取 resolution DTO 或返回对象;不得在插件中复刻这部分逻辑。 + +## 9. 客户端边界 + +VS Code 侧保留:Workspace Trust、QuickPick/member/target/profile 选择、Task/进度/取消、clangd 扩展配置和重启、状态栏、输出频道、watcher、singleflight、迟到事件抑制、旧 mcpp CDB fallback。 + +VS Code 侧删除或降级:单代表性 CDB 条目推断全工程 capability、路径猜测 package/target、正则解析 `mcpp toolchain list`。这些字段从 snapshot 的 `targets`、`toolchain`、`artifacts` 和 `diagnostics` 获取。 + +首次打开受信任工程时: + +1. `inspect` 显示声明和 last-known-good 状态; +2. 若无当前 configured snapshot,启动 `configure`; +3. 收到 configured CDB 事件后立即配置 clangd; +4. 后台启动或继续 `prepare`; +5. ready 事件到达后仅刷新需要的 module arguments/status; +6. 失败时保留当前可用 CDB,并显示可操作诊断。 + +多根 workspace 按 `projectId + configurationId` 隔离状态;由于官方 clangd 扩展通常是窗口级配置,只有活动工程更新窗口级 clangd,后台工程只维护快照和状态。 + +## 10. 通用协议对照 + +### Cargo metadata + +Cargo 的可借鉴点是 `cargo metadata --format-version 1`:它输出 workspace members、packages、targets、features、resolved dependency graph、target directory 和 schema version;同一 format version 允许新增字段和 enum 值,消费者不依赖 opaque ID 的内部表示。mcpp 应复制这些兼容原则,但把 C++ toolchain、compile units、modules 和 artifact freshness 纳入自己的模型。 + +Cargo 的 `cargo check --message-format=json` 对应 mcpp 的 configure/prepare 事件:它让 IDE 获得结构化诊断和进度,而不是解析人类日志。Cargo 的统一 rustc/toolchain 环境比 C++ 简单,mcpp 必须以 capability 和 provenance 表达 GCC/MSVC/Clang 差异。 + +### CMake File API + +CMake File API 的 query/reply、object kind 独立版本、reply index、不可变回复文件和客户端私有查询目录很适合 mcpp 的 snapshot 发布。mcpp 不应复制 CMake 的 schema,而应采用同样的“客户端先读 index,再跟随引用”的一致性策略。 + +### Build Server Protocol + +BSP 的 build server/IDE client 边界和 JSON-RPC 生命周期值得作为未来 adapter 的目标。BSP 当前规范仍明确标注为未批准标准,C++ 扩展还需要自定义 compile options 和 artifact 能力,因此第一版不把 BSP 作为 mcpp 核心 wire。公共 mcpp model 的 workspace、target、sources、dependencies、test/run 能力应保持可映射;需要外部 BSP 客户端时增加独立 `mcpp ide bsp` adapter,而不是改变 snapshot schema。 + +## 11. 错误、信任和安全 + +稳定诊断必须包含 `code`、severity、message、source、path/range、phase 和可选 hint。`mcpp.diag::Record` 可作为来源,但不能直接作为 wire DTO,因为它当前没有稳定 code/path/range 且会立即渲染。 + +configure/prepare 运行任意项目代码和外部工具,只有 workspace trusted 才允许。所有外部 argv 使用结构化数组,不拼 shell 字符串。artifact 路径必须限制在 mcpp 管理的 work root、toolchain store 或明确的 dependency root;manifest 成员路径继续执行 workspace containment 和 symlink 策略。 + +取消需要 operation ID、generation token 和进程组/Job Object 语义。当前 `capture_exec_deadline` 的 POSIX `SIGKILL` 和 Windows best-effort deadline 不能直接宣称支持真实取消;第一阶段先实现插件杀进程树后的迟到事件丢弃,再补平台一致的 process cancellation。 + +## 12. 兼容和迁移 + +- 新 mcpp:提供 `inspect/configure/prepare` 和 schema 1;`snapshot` 是兼容 alias。 +- 旧 mcpp:插件探测不到 `mcpp.ide` capability 时,回退到 nearest manifest + root CDB,并在状态中标记 `legacy/partial`。 +- 旧插件:如果只会读根 CDB,mcpp 可在选定默认 configuration 下原子生成根 `compile_commands.json` compatibility projection。 +- 外部 CDB-only 工程:插件继续支持 clangd 原生行为;mcpp 不声称提供 workspace model 或 modules capability。 +- schema 的协议版本、对象 kind 版本、capability 字符串和诊断 code 必须在 mcpp 与插件测试中共同锁定。 + +## 13. 验收标准 + +### mcpp 核心 + +1. `configure` 在普通源文件包含明显语法错误时仍能发布有效 CDB;CDB 的 compiler、directory、include、macro、standard 和 sysroot 与 BuildPlan 相同。 +2. CDB 发布事件出现在任何普通项目 TU 的 Ninja compile edge 之前。 +3. configure 不产生普通实现对象,也不链接最终 binary/library。 +4. `prepare` 只生成声明的 generated outputs、std/工程 BMI 和不可分离的模块接口对象;不生成普通实现对象或最终链接产物。 +5. CDB 是 fresh-only;不同 configuration 不会通过历史 merge 互相污染。 +6. 修改 manifest、lock、selector、toolchain 或 source fingerprint 后旧 snapshot 为 stale;失败不覆盖 last-known-good。 +7. 取消、超时、权限拒绝、依赖失败和 build.mcpp 失败分别有稳定诊断和结束事件。 + +### VS Code 与真实 clangd + +1. 无 CDB 工程打开后,configure 发布事件会在完整 build 结束前配置 clangd。 +2. UChat 多 member/target 选择能让 tests 中的同名 `util.h` 解析到项目 CDB,而不是 macOS SDK。 +3. LLVM 22 CDB 不会因为 PATH 中存在 Apple Clang 而被插件选为 compiler/clangd identity。 +4. configured 阶段普通文件可以补全、悬停和跳转;ready 阶段模块 import 的补全和跨模块跳转可用。 +5. configure/prepare 失败时仍保留可用的 last-known-good clangd 状态。 +6. 多根 workspace、未受信任 workspace、取消和迟到事件都有测试覆盖。 + +### 证据边界 + +Node 单元测试和 `clangd --check` 不能替代真实 Extension Development Host。发布前必须用真实 VS Code、真实 mcpp binary、匹配 LLVM/clangd、模块工程、build.mcpp、测试源码和多根 workspace 运行 E2E。 + From e8dbd2c31cff5367fd9f475845609c1cfe98c502 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:48:19 +0800 Subject: [PATCH 13/26] feat(ide): define configuration and event protocol --- src/ide/events.cppm | 66 ++++++++++++++++++++++++++++++++ src/ide/model.cppm | 65 +++++++++++++++++++++++++++++++ tests/unit/test_ide_protocol.cpp | 41 ++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/ide/events.cppm create mode 100644 tests/unit/test_ide_protocol.cpp diff --git a/src/ide/events.cppm b/src/ide/events.cppm new file mode 100644 index 00000000..9e04301a --- /dev/null +++ b/src/ide/events.cppm @@ -0,0 +1,66 @@ +export module mcpp.ide.events; + +import std; +import mcpp.libs.json; + +export namespace mcpp::ide { + +enum class IdeEventType { + OperationStarted, + Progress, + Diagnostic, + SnapshotPublished, + OperationFinished, +}; + +std::string_view wire_name(IdeEventType type) { + switch (type) { + case IdeEventType::OperationStarted: return "operation-started"; + case IdeEventType::Progress: return "progress"; + case IdeEventType::Diagnostic: return "diagnostic"; + case IdeEventType::SnapshotPublished: return "snapshot-published"; + case IdeEventType::OperationFinished: return "operation-finished"; + } + return {}; +} + +struct IdeEvent { + std::uint64_t seq = 0; + IdeEventType type = IdeEventType::Progress; +}; + +class NdjsonEventParser { +public: + std::expected, std::string> consume(std::string_view line) { + if (line.find_first_not_of(" \t\r\n") == std::string_view::npos) + return std::optional{}; + + auto json = nlohmann::json::parse(line, nullptr, /*allow_exceptions=*/false); + if (json.is_discarded() || !json.is_object() + || !json.contains("seq") || !json["seq"].is_number_unsigned() + || !json.contains("type") || !json["type"].is_string()) { + return std::unexpected("MCPP_IDE_EVENT_INVALID"); + } + + const auto seq = json["seq"].get(); + if (seq <= lastSeq_) + return std::unexpected("MCPP_IDE_EVENT_SEQUENCE_INVALID"); + + const auto name = json["type"].get(); + std::optional type; + if (name == "operation-started") type = IdeEventType::OperationStarted; + else if (name == "progress") type = IdeEventType::Progress; + else if (name == "diagnostic") type = IdeEventType::Diagnostic; + else if (name == "snapshot-published") type = IdeEventType::SnapshotPublished; + else if (name == "operation-finished") type = IdeEventType::OperationFinished; + else return std::unexpected("MCPP_IDE_EVENT_TYPE_UNKNOWN"); + + lastSeq_ = seq; + return std::optional(IdeEvent{.seq = seq, .type = *type}); + } + +private: + std::uint64_t lastSeq_ = 0; +}; + +} // namespace mcpp::ide diff --git a/src/ide/model.cppm b/src/ide/model.cppm index e38ce10b..0cad452e 100644 --- a/src/ide/model.cppm +++ b/src/ide/model.cppm @@ -4,6 +4,25 @@ import std; export namespace mcpp::ide { +enum class IdePhase { + Declared, + Configured, + Ready, + Stale, + Unavailable, +}; + +std::string_view wire_name(IdePhase phase) { + switch (phase) { + case IdePhase::Declared: return "declared"; + case IdePhase::Configured: return "configured"; + case IdePhase::Ready: return "ready"; + case IdePhase::Stale: return "stale"; + case IdePhase::Unavailable: return "unavailable"; + } + return {}; +} + enum class SnapshotState { Partial, Stale, @@ -75,6 +94,52 @@ struct Selectors { bool includeDevDependencies = false; }; +// IDE 配置标识只包含用户选择和有效工具链,不包含时间戳或构建结果。 +struct ConfigurationSelectors { + std::optional package; + bool workspace = false; + std::string profile; + std::string target; + std::vector features; + std::vector capabilities; + bool includeDevDependencies = false; +}; + +std::string configuration_id(const std::filesystem::path& workspaceRoot, + ConfigurationSelectors selectors, + std::string_view toolchainFingerprint) { + auto normalize = [](std::vector& values) { + std::ranges::sort(values); + values.erase(std::unique(values.begin(), values.end()), values.end()); + }; + normalize(selectors.features); + normalize(selectors.capabilities); + + std::string canonical; + auto append = [&](std::string_view key, std::string_view value) { + canonical += key; + canonical.push_back('='); + canonical += value; + canonical.push_back('\x1f'); + }; + append("root", workspaceRoot.lexically_normal().generic_string()); + append("package", selectors.package.value_or("")); + append("workspace", selectors.workspace ? "1" : "0"); + append("profile", selectors.profile); + append("target", selectors.target); + for (const auto& feature : selectors.features) append("feature", feature); + for (const auto& capability : selectors.capabilities) append("capability", capability); + append("dev", selectors.includeDevDependencies ? "1" : "0"); + append("toolchain", toolchainFingerprint); + + std::uint64_t hash = 14695981039346656037ull; + for (const unsigned char byte : canonical) { + hash ^= byte; + hash *= 1099511628211ull; + } + return std::format("config-fnv1a64:{:016x}", hash); +} + struct InspectRequest { std::filesystem::path start; Selectors selectors; diff --git a/tests/unit/test_ide_protocol.cpp b/tests/unit/test_ide_protocol.cpp new file mode 100644 index 00000000..07908522 --- /dev/null +++ b/tests/unit/test_ide_protocol.cpp @@ -0,0 +1,41 @@ +#include + +import std; +import mcpp.ide.events; +import mcpp.ide.model; + +TEST(IdeProtocol, WireNamesCoverLifecycleStates) { + using namespace mcpp::ide; + EXPECT_EQ(wire_name(IdePhase::Declared), "declared"); + EXPECT_EQ(wire_name(IdePhase::Configured), "configured"); + EXPECT_EQ(wire_name(IdePhase::Ready), "ready"); + EXPECT_EQ(wire_name(IdePhase::Stale), "stale"); + EXPECT_EQ(wire_name(IdePhase::Unavailable), "unavailable"); +} + +TEST(IdeProtocol, ConfigurationIdIsStableAndSelectorSensitive) { + using namespace mcpp::ide; + ConfigurationSelectors selectors; + selectors.profile = "dev"; + selectors.target = "aarch64-apple-darwin"; + selectors.features = {"tls", "metrics"}; + + const auto first = configuration_id("/workspace/app", selectors, "llvm-22"); + const auto second = configuration_id("/workspace/app", selectors, "llvm-22"); + EXPECT_EQ(first, second); + EXPECT_TRUE(first.starts_with("config-fnv1a64:")); + + selectors.features.push_back("gui"); + EXPECT_NE(first, configuration_id("/workspace/app", selectors, "llvm-22")); +} + +TEST(IdeProtocol, NdjsonRejectsSequenceRegression) { + using namespace mcpp::ide; + NdjsonEventParser parser; + ASSERT_TRUE(parser.consume(R"({"seq":1,"type":"operation-started"})").has_value()); + ASSERT_TRUE(parser.consume(R"({"seq":2,"type":"progress"})").has_value()); + auto result = parser.consume(R"({"seq":2,"type":"diagnostic"})"); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), "MCPP_IDE_EVENT_SEQUENCE_INVALID"); +} + From 0c9c2e45a622486045a4db0726201dccd1c936fc Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:48:56 +0800 Subject: [PATCH 14/26] feat(build): add fresh compile database publication --- src/build/compile_commands.cppm | 62 ++++++++++++++++++++++++++++ tests/unit/test_compile_commands.cpp | 23 +++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index f2438d3a..e1a8a24e 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -43,6 +43,10 @@ std::string merge_compile_commands( // Write compile_commands.json to the project root. void write_compile_commands(const BuildPlan& plan, const CompileFlags& flags); +// Publish one configuration's CDB without merging historical build/test entries. +std::expected +write_fresh_compile_commands(const std::filesystem::path& path, std::string_view content); + } // namespace mcpp::build namespace mcpp::build { @@ -231,4 +235,62 @@ void write_compile_commands(const BuildPlan& plan, const CompileFlags& flags) { os << content; } +std::expected +write_fresh_compile_commands(const std::filesystem::path& path, std::string_view content) { + auto json = nlohmann::json::parse(content, nullptr, /*allow_exceptions=*/false); + if (json.is_discarded() || !json.is_array()) + return std::unexpected("compile commands must be a JSON array"); + + const auto normalized = json.dump(2) + "\n"; + { + std::ifstream existing(path, std::ios::binary); + if (existing) { + std::string bytes(std::istreambuf_iterator(existing), {}); + if (bytes == normalized) return {}; + } + } + + std::error_code ec; + if (!path.parent_path().empty()) { + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + return std::unexpected(std::format( + "cannot create compile commands directory '{}': {}", + path.parent_path().string(), ec.message())); + } + } + + // 先完成临时文件,再切换可见路径,避免 clangd 读到半截 JSON。 + auto temp = path; + temp += std::format(".tmp-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + { + std::ofstream output(temp, std::ios::binary | std::ios::trunc); + if (!output) + return std::unexpected(std::format("cannot write '{}'", temp.string())); + output << normalized; + output.flush(); + if (!output) { + output.close(); + std::filesystem::remove(temp, ec); + return std::unexpected(std::format("cannot flush '{}'", temp.string())); + } + } + + std::filesystem::rename(temp, path, ec); + if (ec) { + // Windows rename 不覆盖已有文件;此分支保持结果完整,但替换窗口不是原子的。 + std::error_code removeEc; + std::filesystem::remove(path, removeEc); + ec.clear(); + std::filesystem::rename(temp, path, ec); + } + if (ec) { + std::filesystem::remove(temp, ec); + return std::unexpected(std::format("cannot publish '{}': {}", + path.string(), ec.message())); + } + return {}; +} + } // namespace mcpp::build diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index b894106a..a258eb84 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -2,6 +2,7 @@ import std; import mcpp.build.compile_commands; +import mcpp.libs.json; using namespace mcpp::build; @@ -92,3 +93,25 @@ TEST(CompileCommandsMerge, MalformedExistingFallsBackToFresh) { EXPECT_NE(merged.find("src/main.cpp"), std::string::npos) << merged; EXPECT_NE(merged.find("-O2"), std::string::npos) << merged; } + +TEST(CompileCommandsFresh, DoesNotMergeHistoricalEntries) { + // IDE CDB 必须只描述当前 configuration,不能继承上一轮 test/build 的文件。 + const auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_cdb_fresh_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root); + const auto path = root / "compile_commands.json"; + std::ofstream(path) << cdb({entry("/p/tests/old.cpp", "-OLD")}); + + const auto fresh = cdb({entry("/p/src/main.cpp", "-FRESH")}); + auto result = write_fresh_compile_commands(path, fresh); + ASSERT_TRUE(result.has_value()); + std::ifstream input(path); + auto json = nlohmann::json::parse(input); + ASSERT_EQ(json.size(), 1u); + EXPECT_EQ(json[0]["file"], "/p/src/main.cpp"); + EXPECT_EQ(json, nlohmann::json::parse(fresh)); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} From 54faf3e8fb29c1891df1b8283e1414111a9e2ae4 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:09:03 +0800 Subject: [PATCH 15/26] feat(ide): publish compile database before build --- README.md | 2 + src/build/prepare.cppm | 26 ++-- src/cli.cppm | 23 +++- src/cli/cmd_ide.cppm | 30 +++++ src/ide/configure.cppm | 189 +++++++++++++++++++++++++++ src/ide/model.cppm | 4 + src/toolchain/stdmod.cppm | 153 ++++++++++++---------- tests/e2e/192_ide_configure.sh | 123 +++++++++++++++++ tests/unit/test_ide_configure.cpp | 35 +++++ tests/unit/test_ide_protocol.cpp | 6 +- tests/unit/test_toolchain_stdmod.cpp | 18 +++ 11 files changed, 532 insertions(+), 77 deletions(-) create mode 100644 src/ide/configure.cppm create mode 100755 tests/e2e/192_ide_configure.sh create mode 100644 tests/unit/test_ide_configure.cpp diff --git a/README.md b/README.md index 98b8e55b..aa82921b 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ import mcpplibs.cmdline; - Fingerprinted BMI cache: hashed by compiler/flags/standard library, shared across projects - Ninja backend: auto-generated build.ninja, parallel compilation - compile_commands.json generated automatically (ready for clangd / ccls) +- `mcpp ide configure` publishes a fresh CDB before ordinary TU compilation, + so clangd can analyze a project whose source currently has build errors - First-class C support: `.c` files auto-detected, mixed C/C++ projects - User-defined cflags / cxxflags / ldflags / c_standard diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index a622ac8b..f0bfc743 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -843,6 +843,9 @@ export struct BuildOverrides { bool strict = false; // --strict: schema warnings become errors std::string capabilities; // --cap blas=openblas,lapack=mkl (provider pins) std::string cache_mode; // --cache global|local|off ("" = unset) + // IDE configure 只需要准确的编译参数和预期 BMI 路径;实际 std BMI + // 由 ide prepare/build 物化,避免 CDB 发布等待一次编译。 + bool materialize_std_modules = true; }; // ── git dependency helpers ────────────────────────────────────────────────── @@ -4743,10 +4746,14 @@ prepare_build(bool print_fingerprint, if (f.enabled && !f.flags.empty()) dst += std::format(" ({})", f.flags); if (!f.enabled) dst += std::format(" ({})", f.reason); } - std::println("c++fly on {}: {}; enabled: {}; skipped: {}", - tc->label(), stdFlagAndDialect, - enabled.empty() ? "(none)" : enabled, - skipped.empty() ? "(none)" : skipped); + // IDE configure 的 stdout 是 NDJSON;普通 build 仍保留这条 + // 人类可读摘要,但 quiet 模式必须完全静默,避免污染协议。 + if (!mcpp::ui::is_quiet()) { + std::println("c++fly on {}: {}; enabled: {}; skipped: {}", + tc->label(), stdFlagAndDialect, + enabled.empty() ? "(none)" : enabled, + skipped.empty() ? "(none)" : skipped); + } } for (auto& f : mcpp::toolchain::cppfly::effective_dialect_flags( *tc, m->cppStandard.experimental, @@ -4850,10 +4857,13 @@ prepare_build(bool print_fingerprint, // a std BMI built without it structurally lacks std::meta). Both // pieces were already in the fingerprint; this fixes the COMMAND // construction the fingerprint promised (stdFlagAndDialect above). - auto sm = mcpp::toolchain::ensure_built( - *tc, m->package.standard, stdFlagAndDialect, - mcpp::platform::macos::deployment_target( - m->buildConfig.macosDeploymentTarget)); + const auto deploymentTarget = mcpp::platform::macos::deployment_target( + m->buildConfig.macosDeploymentTarget); + auto sm = overrides.materialize_std_modules + ? mcpp::toolchain::ensure_built( + *tc, m->package.standard, stdFlagAndDialect, deploymentTarget) + : mcpp::toolchain::describe_std_module( + *tc, m->package.standard, stdFlagAndDialect, deploymentTarget); if (!sm) return std::unexpected(sm.error().message); stdBmiPath = sm->bmiPath; stdObjectPath = sm->objectPath; diff --git a/src/cli.cppm b/src/cli.cppm index 1504bd68..babe01ed 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -66,6 +66,7 @@ void print_usage() { std::println(" mcpp emit xpkg [-V VER] [-o FILE] Generate xpkg Lua entry"); std::println(" mcpp xpkg parse [--json] Validate an xpkg descriptor (resolver grammar)"); std::println(" mcpp ide snapshot [selectors] Inspect workspace state for IDE clients"); + std::println(" mcpp ide configure [selectors] Resolve and publish a fresh IDE CDB"); std::println(""); std::println("Resource management:"); std::println(" mcpp toolchain install|list|default Manage mcpp's private toolchains"); @@ -407,8 +408,28 @@ int run(int argc, char** argv) { .help("Echo dev-dependency inclusion")) .option(cl::Option("format").takes_value().value_name("FORMAT") .help("Output format (json)"))) + .subcommand(cl::App("configure") + .description("Resolve the project and publish a fresh compile database") + .option(cl::Option("package").short_name('p').takes_value().value_name("MEMBER") + .help("Select one workspace member")) + .option(cl::Option("workspace").help("Reserved for workspace fan-out")) + .option(cl::Option("profile").takes_value().value_name("NAME") + .help("Build profile")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") + .help("Target triple")) + .option(cl::Option("features").takes_value().value_name("LIST") + .help("Comma-separated features")) + .option(cl::Option("cap").takes_value().value_name("LIST") + .help("Comma-separated capability pins")) + .option(cl::Option("include-dev-dependencies") + .help("Include development dependencies")) + .option(cl::Option("format").takes_value().value_name("FORMAT") + .help("Output format (ndjson)"))) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("ide", p, {{"snapshot", cmd_ide_snapshot}}); + return dispatch_sub("ide", p, { + {"snapshot", cmd_ide_snapshot}, + {"configure", cmd_ide_configure}, + }); }))) // ─── resource management ─────────────────────────────────────── diff --git a/src/cli/cmd_ide.cppm b/src/cli/cmd_ide.cppm index aeffcca1..628a1cce 100644 --- a/src/cli/cmd_ide.cppm +++ b/src/cli/cmd_ide.cppm @@ -5,6 +5,7 @@ export module mcpp.cli.cmd_ide; import std; import mcpplibs.cmdline; +import mcpp.ide.configure; import mcpp.ide.inspect; import mcpp.ide.model; import mcpp.ide.snapshot; @@ -75,4 +76,33 @@ export int cmd_ide_snapshot(const mcpplibs::cmdline::ParsedArgs& parsed) { return inspection.state == mcpp::ide::SnapshotState::Unavailable ? 3 : 0; } +export int cmd_ide_configure(const mcpplibs::cmdline::ParsedArgs& parsed) { + const auto format = parsed.value("format").value_or("ndjson"); + if (format != "ndjson") { + // IDE 客户端依赖逐行 JSON;拒绝未知格式,避免先执行 prepare + // 再发现无法解析,或在错误请求下意外发布兼容 CDB。 + std::println(stderr, "error: unsupported IDE configure format '{}'; expected ndjson", + format); + return 2; + } + std::error_code ec; + auto start = std::filesystem::current_path(ec); + if (ec) start = "."; + auto absolute = std::filesystem::absolute(start, ec); + if (!ec) start = std::move(absolute); + + mcpp::ide::ConfigureRequest request{ + .start = start.lexically_normal(), + .selectors = selectors_from(parsed), + }; + const auto result = mcpp::ide::configure_project(request); + if (!result) { + std::println("{}", mcpp::ide::configure_error_event(result.error())); + return 3; + } + for (const auto& line : mcpp::ide::configure_events(*result)) + std::println("{}", line); + return 0; +} + } // namespace mcpp::cli diff --git a/src/ide/configure.cppm b/src/ide/configure.cppm new file mode 100644 index 00000000..493fdd2f --- /dev/null +++ b/src/ide/configure.cppm @@ -0,0 +1,189 @@ +// mcpp.ide.configure — resolve a build plan and publish its CDB before Ninja. +// 该模块只负责 IDE 配置阶段,不驱动普通源文件编译或最终链接。 +module; +#include +export module mcpp.ide.configure; +import std; +import mcpp.build.compile_commands; +import mcpp.build.flags; +import mcpp.build.prepare; +import mcpp.ide.model; +import mcpp.libs.json; +import mcpp.project; +import mcpp.toolchain.fingerprint; +import mcpp.ui; + +namespace mcpp::ide { +namespace { +using Json = nlohmann::ordered_json; + +// configure 需要暂时屏蔽 prepare_build 的人类状态输出;用 RAII 保证 +// 解析失败或异常返回时也恢复调用者的 quiet 状态。 +struct QuietGuard { + const bool previous = mcpp::ui::is_quiet(); + + QuietGuard() { mcpp::ui::set_quiet(true); } + ~QuietGuard() { mcpp::ui::set_quiet(previous); } +}; + +std::string join(const std::vector& values) { + std::string result; + for (const auto& value : values) { + if (!result.empty()) result.push_back(','); + result += value; + } + return result; +} + +std::string toolchain_fingerprint(const mcpp::toolchain::Fingerprint& fingerprint) { + // Build fingerprint 还包含 flags、依赖和 BMI 状态;这些变化应只使 + // snapshotId 变化,不能让同一个工具链配置在 IDE 中产生新配置项。 + std::string identity; + for (std::size_t i = 0; i < 5; ++i) { + if (i != 0) identity.push_back('\x1f'); + identity += fingerprint.parts[i]; + } + return "toolchain-fnv1a64:" + mcpp::toolchain::hash_string(identity); +} + +ConfigurationSelectors configuration_selectors(const Selectors& selectors) { + return { + .package = selectors.package, .workspace = selectors.workspace, + .profile = selectors.profile.value_or(""), .target = selectors.target.value_or(""), + .features = selectors.features, .capabilities = selectors.capabilities, + .includeDevDependencies = selectors.includeDevDependencies, + }; +} + +Json event(std::uint64_t seq, std::string_view type) { + Json value = Json::object(); + value["schemaVersion"] = 1; value["seq"] = seq; value["type"] = type; + return value; +} +} // namespace + +export struct ConfigureRequest { + std::filesystem::path start; + Selectors selectors; +}; + +export struct ConfigureResult { + std::string projectId; + std::string configurationId; + std::string snapshotId; + std::filesystem::path projectRoot; + std::filesystem::path compileCommands; + std::filesystem::path compatibilityCompileCommands; + std::string toolchain; + std::string toolchainFingerprint; + std::size_t compileCommandCount = 0; + std::filesystem::path stdModule; + std::string stdModuleState; +}; + +export std::expected +configure_project(const ConfigureRequest& request) { + auto start = request.start.empty() ? std::filesystem::current_path() : request.start; + auto root = mcpp::project::find_manifest_root(start); + if (!root) return std::unexpected("no mcpp.toml found in current directory or any parent"); + if (request.selectors.workspace) { + return std::unexpected( + "workspace configure fan-out is not available yet; select one member with --package"); + } + + mcpp::build::BuildOverrides overrides; + overrides.project_root = *root; + if (request.selectors.package) overrides.package_filter = *request.selectors.package; + if (request.selectors.profile) overrides.profile = *request.selectors.profile; + if (request.selectors.target) overrides.target_triple = *request.selectors.target; + overrides.features = join(request.selectors.features); + overrides.capabilities = join(request.selectors.capabilities); + overrides.materialize_std_modules = false; + + // configure 的结构化 stdout 不能混入普通进度;mcpp 自身写入的项目文件 + // 仍保持原有 prepare_build 语义,但状态行转为诊断/事件。 + QuietGuard quiet; + auto context = mcpp::build::prepare_build( + /*print_fingerprint=*/false, request.selectors.includeDevDependencies, {}, + std::move(overrides)); + if (!context) return std::unexpected(context.error()); + + const auto configSelectors = configuration_selectors(request.selectors); + auto resolvedSelectors = configSelectors; + resolvedSelectors.profile = context->profile; + resolvedSelectors.target = context->tc.targetTriple; + resolvedSelectors.cacheMode = std::string( + mcpp::build::cache_mode_name(context->cacheMode)); + resolvedSelectors.cppStandard = context->manifest.package.standard; + const auto toolchainFingerprint = toolchain_fingerprint(context->fp); + const auto configId = configuration_id( + context->projectRoot, resolvedSelectors, toolchainFingerprint); + const auto projectId = std::format("project-fnv1a64:{}", + mcpp::toolchain::hash_string(context->projectRoot.generic_string())); + auto flags = mcpp::build::compute_flags(context->plan); + const auto content = mcpp::build::emit_compile_commands(context->plan, flags); + auto parsed = nlohmann::json::parse(content, nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded() || !parsed.is_array()) + return std::unexpected("mcpp produced an invalid compile database"); + + const auto ideRoot = context->projectRoot / ".mcpp" / "ide" / "replies"; + const auto snapshotCdb = ideRoot / std::format("compile_commands-{}.json", configId); + if (auto written = mcpp::build::write_fresh_compile_commands(snapshotCdb, content); !written) + return std::unexpected(written.error()); + const auto compatibilityCdb = context->projectRoot / "compile_commands.json"; + if (auto written = mcpp::build::write_fresh_compile_commands(compatibilityCdb, content); !written) + return std::unexpected(written.error()); + + const auto snapshotId = std::format("snapshot-fnv1a64:{}", + mcpp::toolchain::hash_string(configId + "\x1f" + content)); + return ConfigureResult{ + .projectId = projectId, .configurationId = configId, .snapshotId = snapshotId, + .projectRoot = context->projectRoot, .compileCommands = snapshotCdb, + .compatibilityCompileCommands = compatibilityCdb, .toolchain = context->tc.label(), + .toolchainFingerprint = toolchainFingerprint, .compileCommandCount = parsed.size(), + .stdModule = context->plan.stdBmiPath, + .stdModuleState = context->plan.stdBmiPath.empty() + ? "not-required" + : (std::filesystem::is_regular_file(context->plan.stdBmiPath) ? "ready" : "pending"), + }; +} + +export std::vector configure_events(const ConfigureResult& result) { + std::vector lines; + auto started = event(1, "operation-started"); + started["operation"] = "configure"; started["configurationId"] = result.configurationId; + lines.push_back(started.dump()); + auto published = event(2, "snapshot-published"); + published["phase"] = "configured"; published["state"] = "configured"; + published["projectId"] = result.projectId; published["configurationId"] = result.configurationId; + published["snapshotId"] = result.snapshotId; + published["compileCommands"] = result.compileCommands.generic_string(); + published["compatibilityCompileCommands"] = result.compatibilityCompileCommands.generic_string(); + published["compileCommandCount"] = result.compileCommandCount; + published["toolchain"] = result.toolchain; + published["toolchainFingerprint"] = result.toolchainFingerprint; + if (!result.stdModule.empty()) { + auto stdModule = Json::object(); + stdModule["kind"] = "std-module"; + stdModule["path"] = result.stdModule.generic_string(); + stdModule["state"] = result.stdModuleState; + published["stdModule"] = std::move(stdModule); + } + lines.push_back(published.dump()); + auto finished = event(3, "operation-finished"); + finished["operation"] = "configure"; finished["status"] = "success"; + finished["phase"] = "configured"; finished["configurationId"] = result.configurationId; + lines.push_back(finished.dump()); + return lines; +} + +export std::string configure_error_event(std::string_view message) { + auto value = event(1, "diagnostic"); + value["diagnostic"] = Json::object({ + {"code", "MCPP_IDE_CONFIGURE_FAILED"}, {"severity", "error"}, + {"source", "mcpp"}, {"message", message}, + }); + return value.dump(); +} + +} // namespace mcpp::ide diff --git a/src/ide/model.cppm b/src/ide/model.cppm index 0cad452e..65afb381 100644 --- a/src/ide/model.cppm +++ b/src/ide/model.cppm @@ -103,6 +103,8 @@ struct ConfigurationSelectors { std::vector features; std::vector capabilities; bool includeDevDependencies = false; + std::string cacheMode = "global"; + std::string cppStandard = "c++23"; }; std::string configuration_id(const std::filesystem::path& workspaceRoot, @@ -130,6 +132,8 @@ std::string configuration_id(const std::filesystem::path& workspaceRoot, for (const auto& feature : selectors.features) append("feature", feature); for (const auto& capability : selectors.capabilities) append("capability", capability); append("dev", selectors.includeDevDependencies ? "1" : "0"); + append("cache", selectors.cacheMode); + append("standard", selectors.cppStandard); append("toolchain", toolchainFingerprint); std::uint64_t hash = 14695981039346656037ull; diff --git a/src/toolchain/stdmod.cppm b/src/toolchain/stdmod.cppm index 47e3878e..cce9f217 100644 --- a/src/toolchain/stdmod.cppm +++ b/src/toolchain/stdmod.cppm @@ -56,6 +56,14 @@ struct StdModError { std::string message; }; std::filesystem::path default_cache_root(); +// 只计算 std/std.compat 的身份和预期路径,不创建目录或调用编译器。 +std::expected describe_std_module( + const Toolchain& tc, + std::string_view cpp_standard, + std::string_view cpp_standard_flag, + std::string_view macos_deployment_target = {}, + const std::filesystem::path& cache_root = default_cache_root()); + // Build std module if not already cached. Returns paths to BMI + object. // `macos_deployment_target` is the RESOLVED value from // platform::macos::deployment_target() — it must match what flags.cppm @@ -211,12 +219,21 @@ std::filesystem::path default_cache_root() { return mcpp::home::cache_root(); } -std::expected ensure_built( - const Toolchain& tc, - std::string_view cpp_standard, - std::string_view cpp_standard_flag, - std::string_view macos_deployment_target, - const std::filesystem::path& cache_root) +namespace { + +struct StdModuleDescription { + StdModule module; + std::vector stdCommands; + std::vector compatCommands; + nlohmann::json metadata; +}; + +std::expected describe_impl( + const Toolchain& tc, + std::string_view cpp_standard, + std::string_view cpp_standard_flag, + std::string_view macos_deployment_target, + const std::filesystem::path& cache_root) { if (tc.stdModuleSource.empty()) { return std::unexpected(StdModError{ @@ -224,49 +241,17 @@ std::expected ensure_built( } const bool isMsvc = tc.compiler == CompilerId::MSVC; - StdModule sm; - - // Build sysroot + include flags for std module precompilation, derived - // from the shared toolchain link model (same resolver as flags.cppm — - // identical flags also keep the std_build_commands cache key honest). - // Std module precompilation only needs compile flags (no linker flags), - // so --no-default-config is safe here on all platforms. const PathEscape shellEsc = [](const std::filesystem::path& p) { return std::format("'{}'", p.string()); }; - // The shared producer (mcpp.toolchain.hostflags) — the same assembly - // flags.cppm and the build.mcpp host compile use. This block used to - // hand-write the clang cfg bypass, which is how it could drift from the - // other two. HostFlagOptions hopt; hopt.cfgBypass = HostFlagOptions::CfgBypass::Always; hopt.clangStdlibSelect = true; - std::string sysroot_flag = - render_tokens(host_compile_tokens(tc, hopt, shellEsc)); - - // Deployment target appended here rather than passed to the producer - // ONLY to keep this command string byte-identical to what earlier - // releases emitted: the string is part of the std cache identity - // (std_build_commands feeds the cache directory name), so reordering a - // flag would invalidate every user's std BMIs for no behavioural gain. - // The VALUE still comes from the one resolver - // (platform::macos::deployment_target) that flags.cppm and - // build_program.cppm read — only its position is local. + std::string sysrootFlag = render_tokens(host_compile_tokens(tc, hopt, shellEsc)); if (!macos_deployment_target.empty()) { - sysroot_flag += std::format(" -mmacosx-version-min={}", - macos_deployment_target); + sysrootFlag += std::format(" -mmacosx-version-min={}", macos_deployment_target); } - // All three providers expose the same command-sequence shape (A5 backend - // surface normalization) — no per-compiler arity branching here. - // - // Everything downstream of the cache directory is derived here, because the - // directory NAME is derived from the metadata and the metadata contains the - // build commands, which contain the directory. Resolving that circularity - // by hand: run this once against a placeholder directory to obtain the - // identity (that is what "normalized" means — the commands then name a - // literal placeholder segment instead of a key-specific path), hash it, - // then run it again against the real directory to get what goes on disk. struct Derived { std::filesystem::path bmiPath; std::filesystem::path objectPath; @@ -277,18 +262,16 @@ std::expected ensure_built( auto derive = [&](const std::filesystem::path& cacheDir) { Derived d; d.bmiPath = isMsvc ? mcpp::toolchain::msvc::std_bmi_path(cacheDir) - : is_clang(tc) - ? mcpp::toolchain::clang::std_bmi_path(cacheDir) - : mcpp::toolchain::gcc::std_bmi_path(cacheDir); + : is_clang(tc) ? mcpp::toolchain::clang::std_bmi_path(cacheDir) + : mcpp::toolchain::gcc::std_bmi_path(cacheDir); d.objectPath = cacheDir / (isMsvc ? "std.obj" : "std.o"); - d.stdCommands = - isMsvc ? mcpp::toolchain::msvc::std_module_build_commands( - tc, cacheDir, cpp_standard_flag) - : is_clang(tc) - ? mcpp::toolchain::clang::std_module_build_commands( - tc, cacheDir, d.bmiPath, sysroot_flag, cpp_standard_flag) - : mcpp::toolchain::gcc::std_module_build_commands( - tc, cacheDir, sysroot_flag, cpp_standard_flag); + d.stdCommands = isMsvc + ? mcpp::toolchain::msvc::std_module_build_commands(tc, cacheDir, cpp_standard_flag) + : is_clang(tc) + ? mcpp::toolchain::clang::std_module_build_commands( + tc, cacheDir, d.bmiPath, sysrootFlag, cpp_standard_flag) + : mcpp::toolchain::gcc::std_module_build_commands( + tc, cacheDir, sysrootFlag, cpp_standard_flag); if (!tc.stdCompatSource.empty()) { if (isMsvc) { d.compatCommands = mcpp::toolchain::msvc::std_compat_build_commands( @@ -296,7 +279,7 @@ std::expected ensure_built( } else if (is_clang(tc)) { auto compatBmi = mcpp::toolchain::clang::std_compat_bmi_path(cacheDir); d.compatCommands = mcpp::toolchain::clang::std_compat_build_commands( - tc, cacheDir, compatBmi, d.bmiPath, sysroot_flag, cpp_standard_flag); + tc, cacheDir, compatBmi, d.bmiPath, sysrootFlag, cpp_standard_flag); } } d.metadata = metadata_for(tc, cpp_standard, cpp_standard_flag, @@ -306,15 +289,56 @@ std::expected ensure_built( const auto stdRoot = cache_root / "std"; auto normalized = derive(stdRoot / kStdKeyPlaceholder).metadata; - auto identity = std_identity_key(normalized); - sm.cacheDir = stdRoot / identity; - - auto derived = derive(sm.cacheDir); - sm.bmiPath = derived.bmiPath; - sm.objectPath = derived.objectPath; - const auto& stdCommands = derived.stdCommands; - const auto& compatCommands = derived.compatCommands; - const auto& metadata = derived.metadata; + auto cacheDir = stdRoot / std_identity_key(normalized); + auto derived = derive(cacheDir); + StdModule module{ + .cacheDir = cacheDir, + .bmiPath = derived.bmiPath, + .objectPath = derived.objectPath, + }; + if (!derived.compatCommands.empty()) { + module.compatBmiPath = isMsvc + ? mcpp::toolchain::msvc::std_compat_bmi_path(cacheDir) + : mcpp::toolchain::clang::std_compat_bmi_path(cacheDir); + module.compatObjectPath = cacheDir / (isMsvc ? "std.compat.obj" : "std.compat.o"); + } + return StdModuleDescription{ + .module = std::move(module), + .stdCommands = std::move(derived.stdCommands), + .compatCommands = std::move(derived.compatCommands), + .metadata = std::move(derived.metadata), + }; +} + +} // namespace + +std::expected describe_std_module( + const Toolchain& tc, + std::string_view cpp_standard, + std::string_view cpp_standard_flag, + std::string_view macos_deployment_target, + const std::filesystem::path& cache_root) +{ + auto description = describe_impl(tc, cpp_standard, cpp_standard_flag, + macos_deployment_target, cache_root); + if (!description) return std::unexpected(description.error()); + return std::move(description->module); +} + +std::expected ensure_built( + const Toolchain& tc, + std::string_view cpp_standard, + std::string_view cpp_standard_flag, + std::string_view macos_deployment_target, + const std::filesystem::path& cache_root) +{ + auto description = describe_impl(tc, cpp_standard, cpp_standard_flag, + macos_deployment_target, cache_root); + if (!description) return std::unexpected(description.error()); + auto sm = std::move(description->module); + const auto& stdCommands = description->stdCommands; + const auto& compatCommands = description->compatCommands; + const auto& metadata = description->metadata; auto metaPath = metadata_path(sm.cacheDir); bool std_cached = std::filesystem::exists(sm.bmiPath) && std::filesystem::exists(sm.objectPath) @@ -340,17 +364,12 @@ std::expected ensure_built( // Build std.compat after std (std.compat imports std; Clang + MSVC). if (!compatCommands.empty()) { - auto compatBmi = isMsvc - ? mcpp::toolchain::msvc::std_compat_bmi_path(sm.cacheDir) - : mcpp::toolchain::clang::std_compat_bmi_path(sm.cacheDir); - if (rebuiltStd || !std::filesystem::exists(compatBmi) + if (rebuiltStd || !std::filesystem::exists(sm.compatBmiPath) || !metadata_matches(metaPath, metadata)) { if (auto out = run_commands(compatCommands, tc); !out) { return std::unexpected(out.error()); } } - sm.compatBmiPath = compatBmi; - sm.compatObjectPath = sm.cacheDir / (isMsvc ? "std.compat.obj" : "std.compat.o"); } if (auto r = write_metadata(metaPath, metadata); !r) { diff --git a/tests/e2e/192_ide_configure.sh b/tests/e2e/192_ide_configure.sh new file mode 100755 index 00000000..71b88d01 --- /dev/null +++ b/tests/e2e/192_ide_configure.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# requires: +# Syntax errors must not prevent IDE configure from publishing a fresh CDB. +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +PROJECT="$TMP/app" +mkdir -p "$PROJECT/src" + +cat >"$PROJECT/mcpp.toml" <<'EOF' +[package] +name = "ide-broken-source" +version = "1.0.0" +standard = "c++fly" +EOF + +# 故意保留语法错误:configure 只能解析工程,不能要求普通 TU 编译成功。 +cat >"$PROJECT/src/main.cpp" <<'EOF' +int main( { + return missing_symbol; +} +EOF + +set +e +(cd "$PROJECT" && "$MCPP" ide configure --format json) \ + >"$TMP/invalid-format.out" 2>"$TMP/invalid-format.err" +invalid_format_rc=$? +set -e +if [ "$invalid_format_rc" -ne 2 ]; then + echo "FAIL: unsupported IDE format returned $invalid_format_rc, expected 2" >&2 + cat "$TMP/invalid-format.out" >&2 + cat "$TMP/invalid-format.err" >&2 + exit 1 +fi +if [ -e "$PROJECT/compile_commands.json" ]; then + echo "FAIL: unsupported IDE format published a CDB" >&2 + exit 1 +fi + +if ! (cd "$PROJECT" && "$MCPP" ide configure --format ndjson) \ + >"$TMP/events.ndjson" 2>"$TMP/stderr.log"; then + cat "$TMP/stderr.log" >&2 + cat "$TMP/events.ndjson" >&2 + exit 1 +fi + +python3 - "$TMP/events.ndjson" "$PROJECT" <<'PY' +import json +import os +import sys + +event_path, project = sys.argv[1:] +events = [json.loads(line) for line in open(event_path, encoding="utf-8") if line.strip()] +assert [event["type"] for event in events] == [ + "operation-started", "snapshot-published", "operation-finished" +] +published = events[1] +assert published["phase"] == "configured" +assert published["state"] == "configured" +assert published["compileCommandCount"] >= 1 + +snapshot_cdb = published["compileCommands"] +compat_cdb = published["compatibilityCompileCommands"] +assert os.path.isfile(snapshot_cdb), snapshot_cdb +assert os.path.realpath(compat_cdb) == os.path.realpath( + os.path.join(project, "compile_commands.json") +), (compat_cdb, project) +assert os.path.isfile(compat_cdb), compat_cdb + +cdb = json.load(open(snapshot_cdb, encoding="utf-8")) +entry = next(item for item in cdb if item["file"].endswith("/src/main.cpp")) +assert os.path.realpath(entry["directory"]) == os.path.realpath(project), ( + entry["directory"], project +) +assert entry["arguments"][0].endswith("clang++"), entry["arguments"][0] +assert any(arg.startswith("-std=c++") for arg in entry["arguments"]), entry["arguments"] +PY + +cat >>"$PROJECT/mcpp.toml" <<'EOF' + +[build] +cxxflags = ["-DIDE_SNAPSHOT_CHANGED=1"] +EOF + +if ! (cd "$PROJECT" && "$MCPP" ide configure --format ndjson) \ + >"$TMP/events-changed.ndjson" 2>"$TMP/stderr-changed.log"; then + cat "$TMP/stderr-changed.log" >&2 + cat "$TMP/events-changed.ndjson" >&2 + exit 1 +fi + +python3 - "$TMP/events.ndjson" "$TMP/events-changed.ndjson" <<'PY' +import json +import sys + +before_path, after_path = sys.argv[1:] + +def published(path): + events = [json.loads(line) for line in open(path, encoding="utf-8") if line.strip()] + return next(event for event in events if event["type"] == "snapshot-published") + +before = published(before_path) +after = published(after_path) +assert before["configurationId"] == after["configurationId"], (before, after) +assert before["toolchainFingerprint"] == after["toolchainFingerprint"], (before, after) +assert before["snapshotId"] != after["snapshotId"], (before, after) + +cdb = json.load(open(after["compileCommands"], encoding="utf-8")) +entry = next(item for item in cdb if item["file"].endswith("/src/main.cpp")) +assert "-DIDE_SNAPSHOT_CHANGED=1" in entry["arguments"], entry["arguments"] +PY + +if find "$PROJECT/target" -type f \( -name '*.o' -o -name '*.obj' \) -print -quit 2>/dev/null | grep -q .; then + echo "FAIL: ide configure compiled an ordinary object" >&2 + exit 1 +fi +if find "$PROJECT/target" -type f -path '*/bin/*' -print -quit 2>/dev/null | grep -q .; then + echo "FAIL: ide configure linked a final target" >&2 + exit 1 +fi + +echo "OK" diff --git a/tests/unit/test_ide_configure.cpp b/tests/unit/test_ide_configure.cpp new file mode 100644 index 00000000..e6b52054 --- /dev/null +++ b/tests/unit/test_ide_configure.cpp @@ -0,0 +1,35 @@ +#include + +import std; +import mcpp.ide.configure; +import mcpp.ide.model; +import mcpp.libs.json; + +TEST(IdeConfigure, EmitsConfiguredEventsWithCdbBeforeBuild) { + mcpp::ide::ConfigureResult result{ + .projectId = "project-fnv1a64:abcd", + .configurationId = "config-fnv1a64:1234", + .snapshotId = "snapshot-fnv1a64:5678", + .projectRoot = "/workspace/app", + .compileCommands = "/workspace/app/.mcpp/ide/replies/compile_commands.json", + .compatibilityCompileCommands = "/workspace/app/compile_commands.json", + .toolchain = "llvm@22", + .toolchainFingerprint = "fp", + .compileCommandCount = 2, + .stdModule = "/cache/std/pcm.cache/std.pcm", + .stdModuleState = "pending", + }; + + auto events = mcpp::ide::configure_events(result); + ASSERT_EQ(events.size(), 3u); + auto started = nlohmann::json::parse(events[0]); + auto published = nlohmann::json::parse(events[1]); + auto finished = nlohmann::json::parse(events[2]); + EXPECT_EQ(started["type"], "operation-started"); + EXPECT_EQ(published["type"], "snapshot-published"); + EXPECT_EQ(published["phase"], "configured"); + EXPECT_EQ(published["compileCommandCount"], 2); + EXPECT_EQ(published["stdModule"]["state"], "pending"); + EXPECT_EQ(finished["type"], "operation-finished"); + EXPECT_EQ(finished["status"], "success"); +} diff --git a/tests/unit/test_ide_protocol.cpp b/tests/unit/test_ide_protocol.cpp index 07908522..1179c941 100644 --- a/tests/unit/test_ide_protocol.cpp +++ b/tests/unit/test_ide_protocol.cpp @@ -27,6 +27,11 @@ TEST(IdeProtocol, ConfigurationIdIsStableAndSelectorSensitive) { selectors.features.push_back("gui"); EXPECT_NE(first, configuration_id("/workspace/app", selectors, "llvm-22")); + + selectors.features.pop_back(); + const auto cxx23 = configuration_id("/workspace/app", selectors, "llvm-22"); + selectors.cppStandard = "c++20"; + EXPECT_NE(cxx23, configuration_id("/workspace/app", selectors, "llvm-22")); } TEST(IdeProtocol, NdjsonRejectsSequenceRegression) { @@ -38,4 +43,3 @@ TEST(IdeProtocol, NdjsonRejectsSequenceRegression) { ASSERT_FALSE(result.has_value()); EXPECT_EQ(result.error(), "MCPP_IDE_EVENT_SEQUENCE_INVALID"); } - diff --git a/tests/unit/test_toolchain_stdmod.cpp b/tests/unit/test_toolchain_stdmod.cpp index dc337b40..76ce39aa 100644 --- a/tests/unit/test_toolchain_stdmod.cpp +++ b/tests/unit/test_toolchain_stdmod.cpp @@ -4,6 +4,7 @@ import std; import mcpp.toolchain.clang; import mcpp.toolchain.gcc; import mcpp.toolchain.model; +import mcpp.toolchain.stdmod; using namespace mcpp::toolchain; @@ -70,3 +71,20 @@ TEST(ToolchainStdmod, ClangStdCompatCommandsUseRequestedStandard) { EXPECT_EQ(cmd.find("-std=c++23"), std::string::npos) << cmd; } } + +TEST(ToolchainStdmod, DescribeComputesArtifactsWithoutCreatingCache) { + auto tc = clang_toolchain(); + tc.driverIdent = "clang version 22.1.8"; + const auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_std_describe_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + std::error_code ec; + std::filesystem::remove_all(root, ec); + + auto described = describe_std_module(tc, "c++23", "-std=c++23", "", root); + ASSERT_TRUE(described.has_value()) << described.error().message; + EXPECT_EQ(described->cacheDir.parent_path(), root / "std"); + EXPECT_EQ(described->bmiPath.parent_path(), described->cacheDir / "pcm.cache"); + EXPECT_EQ(described->objectPath, described->cacheDir / "std.o"); + EXPECT_FALSE(std::filesystem::exists(root)); +} From 1e8f1f43b7176fbec6088ef3124602ee546cc6f4 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:37:53 +0800 Subject: [PATCH 16/26] fix(ide): stage module prerequisites before publishing cdb --- src/build/prepare.cppm | 10 +-- src/ide/configure.cppm | 49 ++++++++++++-- tests/e2e/192_ide_configure.sh | 20 ++++++ tests/unit/test_ide_configure.cpp | 107 ++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 12 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index f0bfc743..949e300c 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -843,9 +843,6 @@ export struct BuildOverrides { bool strict = false; // --strict: schema warnings become errors std::string capabilities; // --cap blas=openblas,lapack=mkl (provider pins) std::string cache_mode; // --cache global|local|off ("" = unset) - // IDE configure 只需要准确的编译参数和预期 BMI 路径;实际 std BMI - // 由 ide prepare/build 物化,避免 CDB 发布等待一次编译。 - bool materialize_std_modules = true; }; // ── git dependency helpers ────────────────────────────────────────────────── @@ -4859,11 +4856,8 @@ prepare_build(bool print_fingerprint, // construction the fingerprint promised (stdFlagAndDialect above). const auto deploymentTarget = mcpp::platform::macos::deployment_target( m->buildConfig.macosDeploymentTarget); - auto sm = overrides.materialize_std_modules - ? mcpp::toolchain::ensure_built( - *tc, m->package.standard, stdFlagAndDialect, deploymentTarget) - : mcpp::toolchain::describe_std_module( - *tc, m->package.standard, stdFlagAndDialect, deploymentTarget); + auto sm = mcpp::toolchain::ensure_built( + *tc, m->package.standard, stdFlagAndDialect, deploymentTarget); if (!sm) return std::unexpected(sm.error().message); stdBmiPath = sm->bmiPath; stdObjectPath = sm->objectPath; diff --git a/src/ide/configure.cppm b/src/ide/configure.cppm index 493fdd2f..73da5748 100644 --- a/src/ide/configure.cppm +++ b/src/ide/configure.cppm @@ -6,11 +6,14 @@ export module mcpp.ide.configure; import std; import mcpp.build.compile_commands; import mcpp.build.flags; +import mcpp.build.plan; import mcpp.build.prepare; +import mcpp.build.stage; import mcpp.ide.model; import mcpp.libs.json; import mcpp.project; import mcpp.toolchain.fingerprint; +import mcpp.toolchain.model; import mcpp.ui; namespace mcpp::ide { @@ -81,6 +84,37 @@ export struct ConfigureResult { std::string stdModuleState; }; +export std::expected +stage_cached_module_prerequisites(const mcpp::build::BuildPlan& plan) { + const auto traits = mcpp::toolchain::bmi_traits(plan.toolchain); + const auto bmiDir = plan.outputDir / traits.bmiDir; + + auto stage = [&](const std::filesystem::path& source) + -> std::expected { + if (source.empty()) return {}; + // Keep this destination spelling identical to ninja_backend's BMI + // output: the CDB is published only after every referenced prerequisite + // is present at the path clangd will open. + auto staged = mcpp::build::stage::stage_file(source, bmiDir / source.filename()); + if (!staged) return std::unexpected(staged.error().message); + return {}; + }; + + // CDB 引用构建目录中的标准库 BMI;configure 不运行 Ninja,因此 + // 必须在发布前主动物化这两个前置产物。 + if (auto result = stage(plan.stdBmiPath); !result) return result; + if (auto result = stage(plan.stdCompatBmiPath); !result) return result; + + // 全局依赖缓存命中时只 stage BMI,不复制对象文件。clangd 只需要 + // 模块语义产物,普通编译和链接仍由后续 build/test 负责。 + for (const auto& unit : plan.compileUnits) { + if (!unit.servedFromCache || !unit.providesModule || unit.cachedBmi.empty()) + continue; + if (auto result = stage(unit.cachedBmi); !result) return result; + } + return {}; +} + export std::expected configure_project(const ConfigureRequest& request) { auto start = request.start.empty() ? std::filesystem::current_path() : request.start; @@ -98,7 +132,6 @@ configure_project(const ConfigureRequest& request) { if (request.selectors.target) overrides.target_triple = *request.selectors.target; overrides.features = join(request.selectors.features); overrides.capabilities = join(request.selectors.capabilities); - overrides.materialize_std_modules = false; // configure 的结构化 stdout 不能混入普通进度;mcpp 自身写入的项目文件 // 仍保持原有 prepare_build 语义,但状态行转为诊断/事件。 @@ -107,6 +140,10 @@ configure_project(const ConfigureRequest& request) { /*print_fingerprint=*/false, request.selectors.includeDevDependencies, {}, std::move(overrides)); if (!context) return std::unexpected(context.error()); + // A failed stage must leave the previous CDB untouched and must not emit a + // snapshot that clangd cannot load. + if (auto staged = stage_cached_module_prerequisites(context->plan); !staged) + return std::unexpected(staged.error()); const auto configSelectors = configuration_selectors(request.selectors); auto resolvedSelectors = configSelectors; @@ -136,15 +173,19 @@ configure_project(const ConfigureRequest& request) { const auto snapshotId = std::format("snapshot-fnv1a64:{}", mcpp::toolchain::hash_string(configId + "\x1f" + content)); + const auto traits = mcpp::toolchain::bmi_traits(context->plan.toolchain); + const auto stagedStdModule = context->plan.stdBmiPath.empty() + ? std::filesystem::path{} + : context->plan.outputDir / traits.bmiDir / context->plan.stdBmiPath.filename(); return ConfigureResult{ .projectId = projectId, .configurationId = configId, .snapshotId = snapshotId, .projectRoot = context->projectRoot, .compileCommands = snapshotCdb, .compatibilityCompileCommands = compatibilityCdb, .toolchain = context->tc.label(), .toolchainFingerprint = toolchainFingerprint, .compileCommandCount = parsed.size(), - .stdModule = context->plan.stdBmiPath, - .stdModuleState = context->plan.stdBmiPath.empty() + .stdModule = stagedStdModule, + .stdModuleState = stagedStdModule.empty() ? "not-required" - : (std::filesystem::is_regular_file(context->plan.stdBmiPath) ? "ready" : "pending"), + : (std::filesystem::is_regular_file(stagedStdModule) ? "ready" : "pending"), }; } diff --git a/tests/e2e/192_ide_configure.sh b/tests/e2e/192_ide_configure.sh index 71b88d01..b776e84d 100755 --- a/tests/e2e/192_ide_configure.sh +++ b/tests/e2e/192_ide_configure.sh @@ -17,6 +17,8 @@ EOF # 故意保留语法错误:configure 只能解析工程,不能要求普通 TU 编译成功。 cat >"$PROJECT/src/main.cpp" <<'EOF' +import std; + int main( { return missing_symbol; } @@ -59,6 +61,9 @@ published = events[1] assert published["phase"] == "configured" assert published["state"] == "configured" assert published["compileCommandCount"] >= 1 +std_module = published["stdModule"] +assert std_module["state"] == "ready", std_module +assert os.path.isfile(std_module["path"]), std_module snapshot_cdb = published["compileCommands"] compat_cdb = published["compatibilityCompileCommands"] @@ -75,6 +80,21 @@ assert os.path.realpath(entry["directory"]) == os.path.realpath(project), ( ) assert entry["arguments"][0].endswith("clang++"), entry["arguments"][0] assert any(arg.startswith("-std=c++") for arg in entry["arguments"]), entry["arguments"] +std_bmi = next( + arg.removeprefix("-fmodule-file=std=") + for arg in entry["arguments"] + if arg.startswith("-fmodule-file=std=") +) +std_compat_bmi = next( + arg.removeprefix("-fmodule-file=std.compat=") + for arg in entry["arguments"] + if arg.startswith("-fmodule-file=std.compat=") +) +assert os.path.realpath(std_bmi) == os.path.realpath(std_module["path"]), ( + std_bmi, std_module +) +assert os.path.isfile(std_bmi), std_bmi +assert os.path.isfile(std_compat_bmi), std_compat_bmi PY cat >>"$PROJECT/mcpp.toml" <<'EOF' diff --git a/tests/unit/test_ide_configure.cpp b/tests/unit/test_ide_configure.cpp index e6b52054..0aef8f60 100644 --- a/tests/unit/test_ide_configure.cpp +++ b/tests/unit/test_ide_configure.cpp @@ -1,9 +1,21 @@ #include import std; +import mcpp.build.plan; import mcpp.ide.configure; import mcpp.ide.model; import mcpp.libs.json; +import mcpp.toolchain.model; + +namespace { + +std::filesystem::path temp_root() { + return std::filesystem::temp_directory_path() + / std::format("mcpp_ide_configure_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); +} + +} // namespace TEST(IdeConfigure, EmitsConfiguredEventsWithCdbBeforeBuild) { mcpp::ide::ConfigureResult result{ @@ -33,3 +45,98 @@ TEST(IdeConfigure, EmitsConfiguredEventsWithCdbBeforeBuild) { EXPECT_EQ(finished["type"], "operation-finished"); EXPECT_EQ(finished["status"], "success"); } + +TEST(IdeConfigure, StagesCachedModulePrerequisitesForPublishedCdb) { + const auto root = temp_root(); + const auto cache = root / "cache"; + std::filesystem::create_directories(cache); + std::ofstream(cache / "std.pcm") << "std"; + std::ofstream(cache / "std.compat.pcm") << "std.compat"; + std::ofstream(cache / "dep.pcm") << "dep"; + + mcpp::build::BuildPlan plan; + plan.outputDir = root / "target"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.stdBmiPath = cache / "std.pcm"; + plan.stdCompatBmiPath = cache / "std.compat.pcm"; + plan.compileUnits.push_back({ + .source = "/deps/dep.cppm", + .object = "obj/dep.m.o", + .providesModule = "dep", + .servedFromCache = true, + .cachedBmi = cache / "dep.pcm", + }); + + auto staged = mcpp::ide::stage_cached_module_prerequisites(plan); + ASSERT_TRUE(staged.has_value()) << staged.error(); + EXPECT_TRUE(std::filesystem::is_regular_file( + plan.outputDir / "pcm.cache" / "std.pcm")); + EXPECT_TRUE(std::filesystem::is_regular_file( + plan.outputDir / "pcm.cache" / "std.compat.pcm")); + EXPECT_TRUE(std::filesystem::is_regular_file( + plan.outputDir / "pcm.cache" / "dep.pcm")); + EXPECT_FALSE(std::filesystem::exists(plan.outputDir / "obj")); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, UsesToolchainSpecificBmiDirectories) { + const auto root = temp_root(); + const auto cache = root / "cache"; + std::filesystem::create_directories(cache); + std::ofstream(cache / "dep.pcm") << "dep"; + std::ofstream(cache / "dep.gcm") << "dep"; + std::ofstream(cache / "dep.ifc") << "dep"; + + struct Case { + mcpp::toolchain::CompilerId compiler; + std::string_view bmiDir; + std::string_view extension; + }; + for (const auto& test : { + Case{mcpp::toolchain::CompilerId::Clang, "pcm.cache", ".pcm"}, + Case{mcpp::toolchain::CompilerId::GCC, "gcm.cache", ".gcm"}, + Case{mcpp::toolchain::CompilerId::MSVC, "ifc.cache", ".ifc"}, + }) { + mcpp::build::BuildPlan plan; + plan.outputDir = root / std::string(test.bmiDir); + plan.toolchain.compiler = test.compiler; + plan.compileUnits.push_back({ + .source = "/deps/dep.cppm", + .object = "obj/dep.m.o", + .providesModule = "dep", + .servedFromCache = true, + .cachedBmi = cache / ("dep" + std::string(test.extension)), + }); + + auto staged = mcpp::ide::stage_cached_module_prerequisites(plan); + ASSERT_TRUE(staged.has_value()) << staged.error(); + EXPECT_TRUE(std::filesystem::is_regular_file( + plan.outputDir / test.bmiDir / ("dep" + std::string(test.extension)))); + } + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, RefusesToPublishWhenCachedModuleIsMissing) { + const auto root = temp_root(); + mcpp::build::BuildPlan plan; + plan.outputDir = root / "target"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.compileUnits.push_back({ + .source = "/deps/dep.cppm", + .object = "obj/dep.m.o", + .providesModule = "dep", + .servedFromCache = true, + .cachedBmi = root / "missing" / "dep.pcm", + }); + + auto staged = mcpp::ide::stage_cached_module_prerequisites(plan); + ASSERT_FALSE(staged.has_value()); + EXPECT_FALSE(std::filesystem::exists(plan.outputDir)); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} From 67587e3d670f00076006ab733e1669ba0ba71120 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:21:18 +0800 Subject: [PATCH 17/26] test(ide): gate llvm configure e2e and track release version --- tests/e2e/192_ide_configure.sh | 16 ++++++++++++++-- tests/unit/test_ide_snapshot.cpp | 5 ++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/e2e/192_ide_configure.sh b/tests/e2e/192_ide_configure.sh index b776e84d..e7499555 100755 --- a/tests/e2e/192_ide_configure.sh +++ b/tests/e2e/192_ide_configure.sh @@ -1,18 +1,30 @@ #!/usr/bin/env bash -# requires: +# requires: import-std-libcxx # Syntax errors must not prevent IDE configure from publishing a fresh CDB. set -euo pipefail +OS="$(uname -s)" +if [[ "$OS" == MINGW* || "$OS" == MSYS* || "$OS" == CYGWIN* ]]; then + echo "SKIP: libc++ std modules are unavailable on Windows" + exit 0 +fi + +source "$(dirname "$0")/_llvm_env.sh" + TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT PROJECT="$TMP/app" mkdir -p "$PROJECT/src" -cat >"$PROJECT/mcpp.toml" <<'EOF' +cat >"$PROJECT/mcpp.toml" < Date: Thu, 6 Aug 2026 20:43:51 +0800 Subject: [PATCH 18/26] docs(ide): record configure validation boundaries --- .../plans/2026-08-06-mcpp-universal-ide-model.md | 9 +++++++-- .../specs/2026-08-06-mcpp-universal-ide-model-design.md | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md b/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md index 286026b1..c536eb33 100644 --- a/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md +++ b/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md @@ -308,7 +308,13 @@ stdout 只写合法 NDJSON;人类日志进入 stderr/output channel。成功 c fixture 包含语法错误的 `src/main.cpp`。运行 configure,解析 configured event,检查 CDB entry 的 compiler/directory/arguments;断言最终 binary 和普通 `.o` 不存在。使用一个不会触发网络安装的显式 system toolchain fixture,避免测试依赖外部 index。 -- [ ] **Step 6: 运行 focused unit/e2e 并提交** +- [ ] **Step 6: 补齐失败回退和 cached BMI 工具链矩阵** + +失败回退测试先写入带 marker 的旧根 CDB,再通过真实 `configure_project()` 制造 cached BMI staging 失败,断言命令返回结构化诊断、没有发布新 snapshot,并且旧根 CDB 字节不变。任务 5 的 publish 层完成后,再把同一故障注入扩展为 current/last-known-good 均不变的断言。 + +cached BMI 测试分别在可用的 Clang、GCC、MSVC capability 下预热一个含依赖模块的全局缓存,删除工程 target 后运行 `ide configure`,断言 CDB 引用的 `.pcm`、`.gcm` 或 `.ifc` 已存在,且目标路径与 Ninja 消费的 `artifact_layout` 完全相同。该矩阵验证 IDE staging 和核心后端共享契约,不宣称 clangd 能读取 GCC `.gcm` 或 MSVC `.ifc`。 + +- [ ] **Step 7: 运行 focused unit/e2e 并提交** ```bash mcpp test ide_configure @@ -486,4 +492,3 @@ schema 增加字段时先更新 mcpp snapshot contract,再更新 BSP adapter - `git diff --check` 通过; - 没有把历史 CDB 条目、单代表性 CDB 推断或人类 CLI 正则重新引入新主链路; - 文档中的 capability、state、side-effect 和回退承诺与测试一致。 - diff --git a/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md index f0387965..67efc9fc 100644 --- a/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md +++ b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md @@ -378,3 +378,11 @@ configure/prepare 运行任意项目代码和外部工具,只有 workspace tru Node 单元测试和 `clangd --check` 不能替代真实 Extension Development Host。发布前必须用真实 VS Code、真实 mcpp binary、匹配 LLVM/clangd、模块工程、build.mcpp、测试源码和多根 workspace 运行 E2E。 +### 当前实现的验证边界 + +截至 2026-08-06,早期 CDB 实现还有两个 IDE 路径的测试缺口,不能据此推断核心 Ninja 构建后端存在回归: + +1. 尚未通过真实 `configure_project()` 流程制造 staging 失败,并断言已有根 CDB 保持不变。普通 `mcpp build/test` 继续使用既有 `write_compile_commands()` 和 Ninja 执行路径,不经过 IDE 的 fresh CDB 发布与回退逻辑。完整 last-known-good 状态仍属于后续 snapshot publish 层,不应把它与当前测试缺口混为已实现能力。 +2. cached dependency BMI 的发布前 staging 已有 helper 单测和 macOS/Clang E2E,但尚缺真实 GCC `.gcm`、MSVC `.ifc` 缓存命中工程。核心 Ninja 后端仍通过自己的 `stage_file` edges 物化缓存产物;此缺口的直接风险是 IDE CDB 引用了未物化或路径漂移的 BMI,而不是普通构建无法完成。 + +第二项仍跨越 `BuildPlan::cachedBmi`、`bmi_traits()`、`stage_file()` 和 Ninja BMI 命名等共享契约。应按任务 3 将目标路径收敛到 `mcpp.build.artifact_layout`,由 Ninja 与 IDE 共同消费,再补真实工具链矩阵。GCC/MSVC E2E 只证明路径和产物契约,不代表 clangd 能消费 `.gcm` 或 `.ifc`;该能力仍按工具链 capability 明确表达。 From b2d092bf04ba124b0d6661de6010870aa799465c Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:33:06 +0800 Subject: [PATCH 19/26] fix(ide): keep configure events machine-readable --- .../plans/2026-08-05-mcpp-ide-snapshot.md | 26 ++-- .../2026-08-06-mcpp-universal-ide-model.md | 22 +-- ...6-08-06-mcpp-universal-ide-model-design.md | 24 ++- src/build/compile_commands.cppm | 13 +- src/build/prepare.cppm | 8 +- src/cli/cmd_ide.cppm | 13 +- src/ide/configure.cppm | 88 +++++++++-- src/ide/events.cppm | 42 +++++- src/xlings.cppm | 20 +-- ...90_ide_snapshot.sh => 196_ide_snapshot.sh} | 0 ..._only.sh => 197_ide_snapshot_read_only.sh} | 0 ..._ide_configure.sh => 198_ide_configure.sh} | 142 +++++++++++++++++- tests/unit/test_compile_commands.cpp | 36 +++++ tests/unit/test_ide_configure.cpp | 106 ++++++++++++- tests/unit/test_ide_protocol.cpp | 73 ++++++++- tests/unit/test_pm_index_refresh.cpp | 25 +++ 16 files changed, 568 insertions(+), 70 deletions(-) rename tests/e2e/{190_ide_snapshot.sh => 196_ide_snapshot.sh} (100%) mode change 100644 => 100755 rename tests/e2e/{191_ide_snapshot_read_only.sh => 197_ide_snapshot_read_only.sh} (100%) rename tests/e2e/{192_ide_configure.sh => 198_ide_configure.sh} (50%) diff --git a/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md b/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md index 83ade515..b3ab1140 100644 --- a/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md +++ b/docs/superpowers/plans/2026-08-05-mcpp-ide-snapshot.md @@ -21,8 +21,8 @@ | `src/cli.cppm` | Import/register `ide snapshot`, help entry, known-command list | | `tests/unit/test_ide_snapshot.cpp` | DTO, inspection, selector, diagnostic, artifact, serialization, and identity tests | | `tests/fixtures/ide/snapshot-v1.json` | Schema-1 golden fixture consumed by unit tests | -| `tests/e2e/190_ide_snapshot.sh` | Fresh-binary CLI contract and workspace/error cases | -| `tests/e2e/191_ide_snapshot_read_only.sh` | Before/after project and isolated-home zero-side-effect proof | +| `tests/e2e/196_ide_snapshot.sh` | Fresh-binary CLI contract and workspace/error cases | +| `tests/e2e/197_ide_snapshot_read_only.sh` | Before/after project and isolated-home zero-side-effect proof | | `docs/superpowers/specs/2026-08-05-mcpp-ide-snapshot-design.md` | Approved protocol and side-effect contract | ### Task 1: Define the Public IDE DTO and Protocol Fixture @@ -382,13 +382,13 @@ git commit -m "feat(ide): serialize versioned workspace snapshots" - Create: `src/cli/cmd_ide.cppm` - Modify: `src/cli.cppm` - Modify: `tests/e2e/01_help_and_version.sh` -- Create: `tests/e2e/190_ide_snapshot.sh` +- Create: `tests/e2e/196_ide_snapshot.sh` - [ ] **Step 1: Add failing help and CLI E2E assertions** Update `tests/e2e/01_help_and_version.sh` to require `mcpp ide snapshot` in help. -Create `tests/e2e/190_ide_snapshot.sh` with `# requires:` on line 2. It must: +Create `tests/e2e/196_ide_snapshot.sh` with `# requires:` on line 2. It must: 1. Create a minimal package in `mktemp -d` without calling `mcpp new`. 2. Run `"$MCPP" ide snapshot --format json`, capture stdout and stderr @@ -409,7 +409,7 @@ Run: ```bash mcpp build FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) -MCPP="$FRESH_MCPP" bash tests/e2e/190_ide_snapshot.sh +MCPP="$FRESH_MCPP" bash tests/e2e/196_ide_snapshot.sh ``` Expected: unknown command `ide` or missing help entry. @@ -463,7 +463,7 @@ mcpp build FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) "$FRESH_MCPP" test ide_snapshot MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh -MCPP="$FRESH_MCPP" bash tests/e2e/190_ide_snapshot.sh +MCPP="$FRESH_MCPP" bash tests/e2e/196_ide_snapshot.sh ``` Expected: focused unit tests and both E2E scripts pass. @@ -471,19 +471,19 @@ Expected: focused unit tests and both E2E scripts pass. - [ ] **Step 6: Commit the CLI** ```bash -git add src/cli/cmd_ide.cppm src/cli.cppm tests/e2e/01_help_and_version.sh tests/e2e/190_ide_snapshot.sh +git add src/cli/cmd_ide.cppm src/cli.cppm tests/e2e/01_help_and_version.sh tests/e2e/196_ide_snapshot.sh git commit -m "feat(cli): add read-only ide snapshot command" ``` ### Task 5: Prove the Zero-side-effect Contract **Files:** -- Create: `tests/e2e/191_ide_snapshot_read_only.sh` +- Create: `tests/e2e/197_ide_snapshot_read_only.sh` - Modify: `tests/unit/test_ide_snapshot.cpp` - [ ] **Step 1: Add the failing side-effect E2E** -Create `tests/e2e/191_ide_snapshot_read_only.sh` with `# requires:` on line 2. The +Create `tests/e2e/197_ide_snapshot_read_only.sh` with `# requires:` on line 2. The script must create a project containing: - a remote-looking version dependency; @@ -506,7 +506,7 @@ Run with the current fresh binary: ```bash FRESH_MCPP=$(find "$PWD/target" -type f -path '*/bin/mcpp' -perm -111 | head -1) -MCPP="$FRESH_MCPP" bash tests/e2e/191_ide_snapshot_read_only.sh +MCPP="$FRESH_MCPP" bash tests/e2e/197_ide_snapshot_read_only.sh ``` Expected: pass if the architecture boundary is intact. If it fails, treat any new @@ -528,7 +528,7 @@ Expected: both pass; no project/home additions or modifications. - [ ] **Step 5: Commit the side-effect proof** ```bash -git add tests/unit/test_ide_snapshot.cpp tests/e2e/191_ide_snapshot_read_only.sh +git add tests/unit/test_ide_snapshot.cpp tests/e2e/197_ide_snapshot_read_only.sh git commit -m "test(ide): prove snapshot is read only" ``` @@ -573,8 +573,8 @@ Expected: all test binaries pass with zero failures. ```bash MCPP="$FRESH_MCPP" bash tests/e2e/01_help_and_version.sh -MCPP="$FRESH_MCPP" bash tests/e2e/190_ide_snapshot.sh -MCPP="$FRESH_MCPP" bash tests/e2e/191_ide_snapshot_read_only.sh +MCPP="$FRESH_MCPP" bash tests/e2e/196_ide_snapshot.sh +MCPP="$FRESH_MCPP" bash tests/e2e/197_ide_snapshot_read_only.sh ``` Expected: all three scripts print `OK` and exit 0. diff --git a/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md b/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md index c536eb33..98404fcc 100644 --- a/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md +++ b/docs/superpowers/plans/2026-08-06-mcpp-universal-ide-model.md @@ -33,8 +33,8 @@ | `tests/unit/test_ide_protocol.cpp` | DTO、ID、event、publisher、schema fixture | | `tests/unit/test_ide_configure.cpp` | CDB 提前发布和 no-ordinary-build 契约 | | `tests/unit/test_ide_prepare.cpp` | minimal goals、artifact state 和回退 | -| `tests/e2e/192_ide_configure.sh` | fresh CDB、错误源码、事件顺序和零链接证明 | -| `tests/e2e/193_ide_prepare.sh` | generated/BMI、last-known-good、取消和多配置 | +| `tests/e2e/198_ide_configure.sh` | fresh CDB、错误源码、事件顺序和零链接证明 | +| `tests/e2e/199_ide_prepare.sh` | generated/BMI、last-known-good、取消和多配置 | | `/Users/cltx/projects/mcpp/mcpp-vscode/src/ideProtocol.ts` | 插件协议进程、NDJSON parser、snapshot client | | `/Users/cltx/projects/mcpp/mcpp-vscode/src/ideWorkflow.ts` | configure/prepare 生命周期和状态机 | | `/Users/cltx/projects/mcpp/mcpp-vscode/src/extension.ts` | 打开工程、watcher、clangd 配置和 UI 生命周期 | @@ -272,7 +272,7 @@ git commit -m "feat(ide): atomically publish configured and ready snapshots" - Modify: `src/cli.cppm` - Modify: `src/ide/model.cppm` - Create: `tests/unit/test_ide_configure.cpp` -- Create: `tests/e2e/192_ide_configure.sh` +- Create: `tests/e2e/198_ide_configure.sh` - [ ] **Step 1: 写失败测试锁定事件顺序** @@ -318,8 +318,8 @@ cached BMI 测试分别在可用的 Clang、GCC、MSVC capability 下预热一 ```bash mcpp test ide_configure -bash tests/e2e/192_ide_configure.sh -git add src/ide/configure.cppm src/cli/cmd_ide.cppm src/cli.cppm src/ide/model.cppm tests/unit/test_ide_configure.cpp tests/e2e/192_ide_configure.sh +bash tests/e2e/198_ide_configure.sh +git add src/ide/configure.cppm src/cli/cmd_ide.cppm src/cli.cppm src/ide/model.cppm tests/unit/test_ide_configure.cpp tests/e2e/198_ide_configure.sh git commit -m "feat(ide): add configure command with early CDB publication" ``` @@ -331,7 +331,7 @@ git commit -m "feat(ide): add configure command with early CDB publication" - Modify: `src/build/backend.cppm` - Modify: `src/build/ninja_backend.cppm` - Create: `tests/unit/test_ide_prepare.cpp` -- Create: `tests/e2e/193_ide_prepare.sh` +- Create: `tests/e2e/199_ide_prepare.sh` - [ ] **Step 1: 写失败测试固定 goal 过滤** @@ -361,8 +361,8 @@ fixture 需要一个 generated header 或 source、一个 module interface 和 ```bash mcpp test ide_prepare -bash tests/e2e/193_ide_prepare.sh -git add src/ide/prepare.cppm src/build/artifact_layout.cppm src/build/backend.cppm src/build/ninja_backend.cppm tests/unit/test_ide_prepare.cpp tests/e2e/193_ide_prepare.sh +bash tests/e2e/199_ide_prepare.sh +git add src/ide/prepare.cppm src/build/artifact_layout.cppm src/build/backend.cppm src/build/ninja_backend.cppm tests/unit/test_ide_prepare.cpp tests/e2e/199_ide_prepare.sh git commit -m "feat(ide): prepare generated files and module artifacts without linking" ``` @@ -419,8 +419,8 @@ git commit -m "feat: consume mcpp IDE snapshots and early CDB events" - Create: `/Users/cltx/projects/mcpp/mcpp-vscode/test/extension-host/ide-fixture/` - Create: `/Users/cltx/projects/mcpp/mcpp-vscode/test/extension-host/ide-e2e.ts` - Modify: `/Users/cltx/projects/mcpp/mcpp-vscode/package.json` -- Modify: `tests/e2e/192_ide_configure.sh` -- Modify: `tests/e2e/193_ide_prepare.sh` +- Modify: `tests/e2e/198_ide_configure.sh` +- Modify: `tests/e2e/199_ide_prepare.sh` - Create: `docs/ide/ide-client-contract.md` - [ ] **Step 1: 建立真实 extension host runner** @@ -458,7 +458,7 @@ npm run package - [ ] **Step 7: 提交文档和发布门槛** ```bash -git add docs/ide/ide-client-contract.md tests/e2e/192_ide_configure.sh tests/e2e/193_ide_prepare.sh +git add docs/ide/ide-client-contract.md tests/e2e/198_ide_configure.sh tests/e2e/199_ide_prepare.sh git commit -m "docs(ide): document client contract and release gates" ``` diff --git a/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md index 67efc9fc..0faf518a 100644 --- a/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md +++ b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md @@ -226,14 +226,14 @@ artifact 状态包括 `pending`、`ready`、`stale`、`missing`、`unavailable` ### 6.5 NDJSON 事件 -`configure` 和 `prepare` 的 stdout 只输出一条 JSON 一行,格式如下: +`configure` 和 `prepare` 的 stdout 每行只输出一条 JSON,格式如下: ```json -{"schemaVersion":1,"type":"operation-started","operationId":"...","configurationId":"..."} -{"schemaVersion":1,"type":"progress","operationId":"...","phase":"resolve","completed":2,"total":5} -{"schemaVersion":1,"type":"diagnostic","operationId":"...","diagnostic":{}} -{"schemaVersion":1,"type":"snapshot-published","operationId":"...","phase":"configured","snapshotId":"...","manifest":"...","compileCommands":"..."} -{"schemaVersion":1,"type":"operation-finished","operationId":"...","status":"success","phase":"configured"} +{"schemaVersion":1,"seq":1,"type":"operation-started","operationId":"...","operation":"configure"} +{"schemaVersion":1,"seq":2,"type":"progress","operationId":"...","phase":"resolve","completed":2,"total":5} +{"schemaVersion":1,"seq":3,"type":"diagnostic","operationId":"...","diagnostic":{}} +{"schemaVersion":1,"seq":4,"type":"snapshot-published","operationId":"...","phase":"configured","snapshotId":"...","manifest":"...","compileCommands":"..."} +{"schemaVersion":1,"seq":5,"type":"operation-finished","operationId":"...","operation":"configure","status":"success","phase":"configured"} ``` 事件包含单调递增 `seq`。失败、取消和超时也必须有 `operation-finished`,并携带 `status`、`diagnosticCodes` 和 `lastKnownGoodSnapshotId`。未受信任 workspace 不执行这些命令。 @@ -386,3 +386,15 @@ Node 单元测试和 `clangd --check` 不能替代真实 Extension Development H 2. cached dependency BMI 的发布前 staging 已有 helper 单测和 macOS/Clang E2E,但尚缺真实 GCC `.gcm`、MSVC `.ifc` 缓存命中工程。核心 Ninja 后端仍通过自己的 `stage_file` edges 物化缓存产物;此缺口的直接风险是 IDE CDB 引用了未物化或路径漂移的 BMI,而不是普通构建无法完成。 第二项仍跨越 `BuildPlan::cachedBmi`、`bmi_traits()`、`stage_file()` 和 Ninja BMI 命名等共享契约。应按任务 3 将目标路径收敛到 `mcpp.build.artifact_layout`,由 Ninja 与 IDE 共同消费,再补真实工具链矩阵。GCC/MSVC E2E 只证明路径和产物契约,不代表 clangd 能消费 `.gcm` 或 `.ifc`;该能力仍按工具链 capability 明确表达。 + +当前 `configure` 已在解析前发送带 `operationId` 的 `operation-started`,并在成功或 +失败的全部后续事件中沿用该 ID;失败序列为 +`diagnostic -> operation-finished(status=failed)`。解析前尚无 resolved +`configurationId`,因此早期 started 事件不携带该字段,客户端应以 `operationId` +关联整个生命周期。阶段化 `progress`、取消、迟到事件过滤,以及 +index/current/last-known-good 发布仍属于后续 publish/prepare 任务。现阶段不能据此 +宣称完整的长任务管理和失败回退协议已经完成。 + +当前 `configured_snapshot_id` 已绑定 configuration、BuildContext fingerprint 和 +CDB 内容;完整 resolved module graph、artifact metadata 和输入 provenance 尚未 +进入 snapshot DTO/ID,需随后续模型与 publish 层一并补齐。 diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index e1a8a24e..beacfe02 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -278,17 +278,22 @@ write_fresh_compile_commands(const std::filesystem::path& path, std::string_view } std::filesystem::rename(temp, path, ec); - if (ec) { - // Windows rename 不覆盖已有文件;此分支保持结果完整,但替换窗口不是原子的。 + std::error_code typeEc; + const bool replaceableFile = std::filesystem::is_regular_file(path, typeEc); + if (ec && replaceableFile && !typeEc) { + // Windows rename 不覆盖已有普通文件;只对文件执行 remove + rename, + // 不能把同名目录等其他对象当成可替换的旧 CDB。 std::error_code removeEc; std::filesystem::remove(path, removeEc); ec.clear(); std::filesystem::rename(temp, path, ec); } if (ec) { - std::filesystem::remove(temp, ec); + const auto publishError = ec; + std::error_code cleanupEc; + std::filesystem::remove(temp, cleanupEc); return std::unexpected(std::format("cannot publish '{}': {}", - path.string(), ec.message())); + path.string(), publishError.message())); } return {}; } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 949e300c..e2aae3d3 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1149,7 +1149,9 @@ prepare_build(bool print_fingerprint, bool bootstrap_checked = false; auto get_cfg = [&](bool requireBootstrap = true) -> std::expected { if (!cfg_opt) { - auto c = mcpp::config::load_or_init(/*quiet=*/false, + // IDE configure/JSON build 已通过 ui 设置静默;这里必须沿用该 + // 状态,否则 fresh MCPP_HOME 的 xlings bootstrap 文本会污染协议 stdout。 + auto c = mcpp::config::load_or_init(/*quiet=*/mcpp::ui::is_quiet(), mcpp::fetcher::make_bootstrap_progress_callback()); if (!c) return std::unexpected(c.error().message); cfg_opt = std::move(*c); @@ -4418,8 +4420,10 @@ prepare_build(bool print_fingerprint, // ninja's own progress and command echoes, which is right // for a normal build and wrong when the question is "what // did the inner build actually do". + // 结构化命令的 stdout 必须保持机器可读;quiet 模式仍会在 + // 失败结果中携带诊断,但不能把成功的 Ninja 日志插入 NDJSON。 if (const char* v = std::getenv("MCPP_TOOL_BUILD_VERBOSE"); - v && *v && std::string_view(v) != "0") + !mcpp::ui::is_quiet() && v && *v && std::string_view(v) != "0") bopt.verbose = true; auto br = be->build(subCtx->plan, bopt); if (!br) { diff --git a/src/cli/cmd_ide.cppm b/src/cli/cmd_ide.cppm index 628a1cce..6f19acd2 100644 --- a/src/cli/cmd_ide.cppm +++ b/src/cli/cmd_ide.cppm @@ -95,12 +95,21 @@ export int cmd_ide_configure(const mcpplibs::cmdline::ParsedArgs& parsed) { .start = start.lexically_normal(), .selectors = selectors_from(parsed), }; + + // 先发布生命周期起点并立即 flush;prepare 可能触发依赖解析或 + // build.mcpp host tool,客户端不应在这段耗时期间失去状态。 + const auto operationId = mcpp::ide::new_operation_id(); + std::println("{}", mcpp::ide::configure_started_event(operationId)); + std::fflush(stdout); const auto result = mcpp::ide::configure_project(request); if (!result) { - std::println("{}", mcpp::ide::configure_error_event(result.error())); + for (const auto& line : mcpp::ide::configure_failure_events( + result.error(), operationId, 2)) + std::println("{}", line); return 3; } - for (const auto& line : mcpp::ide::configure_events(*result)) + for (const auto& line : mcpp::ide::configure_events(*result, operationId, 2, + /*includeStarted=*/false)) std::println("{}", line); return 0; } diff --git a/src/ide/configure.cppm b/src/ide/configure.cppm index 73da5748..89377f2b 100644 --- a/src/ide/configure.cppm +++ b/src/ide/configure.cppm @@ -58,9 +58,10 @@ ConfigurationSelectors configuration_selectors(const Selectors& selectors) { }; } -Json event(std::uint64_t seq, std::string_view type) { +Json event(std::uint64_t seq, std::string_view type, std::string_view operationId) { Json value = Json::object(); value["schemaVersion"] = 1; value["seq"] = seq; value["type"] = type; + value["operationId"] = operationId; return value; } } // namespace @@ -84,10 +85,36 @@ export struct ConfigureResult { std::string stdModuleState; }; +// configured snapshot 的身份同时绑定配置、当前 resolved build fingerprint +// 和 CDB 内容;精简 toolchain fingerprint 只用于 configurationId,不能替代 +// 这里的 BuildContext::fp。 +export std::string configured_snapshot_id(std::string_view configurationId, + std::string_view buildFingerprint, + std::string_view cdbHash) { + return std::format("snapshot-fnv1a64:{}", + mcpp::toolchain::hash_string(std::string(configurationId) + "\x1f" + + std::string(buildFingerprint) + "\x1f" + + std::string(cdbHash))); +} + +export std::string new_operation_id() { + static std::atomic sequence = 0; + const auto serial = sequence.fetch_add(1, std::memory_order_relaxed); + const auto wall = std::chrono::system_clock::now().time_since_epoch().count(); + const auto monotonic = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto entropy = std::random_device{}(); + return std::format("operation-fnv1a64:{}", mcpp::toolchain::hash_string( + std::format("{}:{}:{}:{}", wall, monotonic, entropy, serial))); +} + export std::expected stage_cached_module_prerequisites(const mcpp::build::BuildPlan& plan) { const auto traits = mcpp::toolchain::bmi_traits(plan.toolchain); const auto bmiDir = plan.outputDir / traits.bmiDir; + // BMI 来源由工具链 fingerprint 和缓存 key 约束;与 Ninja 的 + // --verify size 一致可避免 clangd 映射目标文件时再次打开它比较内容。 + const mcpp::build::stage::StageOptions stageOptions{ + .verify = mcpp::build::stage::Verify::Size}; auto stage = [&](const std::filesystem::path& source) -> std::expected { @@ -95,7 +122,8 @@ stage_cached_module_prerequisites(const mcpp::build::BuildPlan& plan) { // Keep this destination spelling identical to ninja_backend's BMI // output: the CDB is published only after every referenced prerequisite // is present at the path clangd will open. - auto staged = mcpp::build::stage::stage_file(source, bmiDir / source.filename()); + auto staged = mcpp::build::stage::stage_file( + source, bmiDir / source.filename(), stageOptions); if (!staged) return std::unexpected(staged.error().message); return {}; }; @@ -164,15 +192,18 @@ configure_project(const ConfigureRequest& request) { return std::unexpected("mcpp produced an invalid compile database"); const auto ideRoot = context->projectRoot / ".mcpp" / "ide" / "replies"; - const auto snapshotCdb = ideRoot / std::format("compile_commands-{}.json", configId); + // Reply 文件按 CDB 内容寻址:配置保持不变而 flags/source 变化时,旧 + // reply 仍可被 last-known-good 客户端读取;同时 hash 只含十六进制字符, + // 不会把 configId 中的 ':' 带入 Windows 文件名。 + const auto cdbHash = mcpp::toolchain::hash_string(content); + const auto snapshotCdb = ideRoot / std::format("compile_commands-{}.json", cdbHash); if (auto written = mcpp::build::write_fresh_compile_commands(snapshotCdb, content); !written) return std::unexpected(written.error()); const auto compatibilityCdb = context->projectRoot / "compile_commands.json"; if (auto written = mcpp::build::write_fresh_compile_commands(compatibilityCdb, content); !written) return std::unexpected(written.error()); - const auto snapshotId = std::format("snapshot-fnv1a64:{}", - mcpp::toolchain::hash_string(configId + "\x1f" + content)); + const auto snapshotId = configured_snapshot_id(configId, context->fp.hex, cdbHash); const auto traits = mcpp::toolchain::bmi_traits(context->plan.toolchain); const auto stagedStdModule = context->plan.stdBmiPath.empty() ? std::filesystem::path{} @@ -189,12 +220,28 @@ configure_project(const ConfigureRequest& request) { }; } -export std::vector configure_events(const ConfigureResult& result) { +export std::string configure_started_event( + std::string_view operationId, std::string_view configurationId = {}) { + auto started = event(1, "operation-started", operationId); + started["operation"] = "configure"; + if (!configurationId.empty()) started["configurationId"] = configurationId; + return started.dump(); +} + +export std::vector configure_events( + const ConfigureResult& result, + std::string_view operationId, + std::uint64_t firstSeq = 1, + bool includeStarted = true) { std::vector lines; - auto started = event(1, "operation-started"); - started["operation"] = "configure"; started["configurationId"] = result.configurationId; - lines.push_back(started.dump()); - auto published = event(2, "snapshot-published"); + auto seq = firstSeq; + if (includeStarted) { + auto started = event(seq++, "operation-started", operationId); + started["operation"] = "configure"; + started["configurationId"] = result.configurationId; + lines.push_back(started.dump()); + } + auto published = event(seq++, "snapshot-published", operationId); published["phase"] = "configured"; published["state"] = "configured"; published["projectId"] = result.projectId; published["configurationId"] = result.configurationId; published["snapshotId"] = result.snapshotId; @@ -211,20 +258,31 @@ export std::vector configure_events(const ConfigureResult& result) published["stdModule"] = std::move(stdModule); } lines.push_back(published.dump()); - auto finished = event(3, "operation-finished"); + auto finished = event(seq, "operation-finished", operationId); finished["operation"] = "configure"; finished["status"] = "success"; finished["phase"] = "configured"; finished["configurationId"] = result.configurationId; lines.push_back(finished.dump()); return lines; } -export std::string configure_error_event(std::string_view message) { - auto value = event(1, "diagnostic"); - value["diagnostic"] = Json::object({ +export std::vector configure_failure_events( + std::string_view message, + std::string_view operationId, + std::uint64_t firstSeq = 1) { + std::vector lines; + auto diagnostic = event(firstSeq, "diagnostic", operationId); + diagnostic["diagnostic"] = Json::object({ {"code", "MCPP_IDE_CONFIGURE_FAILED"}, {"severity", "error"}, {"source", "mcpp"}, {"message", message}, }); - return value.dump(); + lines.push_back(diagnostic.dump()); + + auto finished = event(firstSeq + 1, "operation-finished", operationId); + finished["operation"] = "configure"; + finished["status"] = "failed"; + finished["diagnosticCodes"] = Json::array({"MCPP_IDE_CONFIGURE_FAILED"}); + lines.push_back(finished.dump()); + return lines; } } // namespace mcpp::ide diff --git a/src/ide/events.cppm b/src/ide/events.cppm index 9e04301a..1919fde6 100644 --- a/src/ide/events.cppm +++ b/src/ide/events.cppm @@ -27,6 +27,7 @@ std::string_view wire_name(IdeEventType type) { struct IdeEvent { std::uint64_t seq = 0; IdeEventType type = IdeEventType::Progress; + std::string operationId; }; class NdjsonEventParser { @@ -36,11 +37,18 @@ public: return std::optional{}; auto json = nlohmann::json::parse(line, nullptr, /*allow_exceptions=*/false); + // schema 和 operationId 是事件关联契约的一部分;解析器不能把缺字段的 + // 行降级成合法事件,否则客户端无法隔离并发或迟到的操作。 if (json.is_discarded() || !json.is_object() + || !json.contains("schemaVersion") || !json["schemaVersion"].is_number_unsigned() + || json["schemaVersion"].get() != 1 || !json.contains("seq") || !json["seq"].is_number_unsigned() || !json.contains("type") || !json["type"].is_string()) { return std::unexpected("MCPP_IDE_EVENT_INVALID"); } + if (!json.contains("operationId") || !json["operationId"].is_string() + || json["operationId"].get_ref().empty()) + return std::unexpected("MCPP_IDE_EVENT_INVALID"); const auto seq = json["seq"].get(); if (seq <= lastSeq_) @@ -55,8 +63,40 @@ public: else if (name == "operation-finished") type = IdeEventType::OperationFinished; else return std::unexpected("MCPP_IDE_EVENT_TYPE_UNKNOWN"); + const auto has_string = [&](std::string_view key) { + return json.contains(key) && json[key].is_string() + && !json[key].get_ref().empty(); + }; + const auto has_unsigned = [&](std::string_view key) { + return json.contains(key) && json[key].is_number_unsigned(); + }; + // 已知 type 还必须满足自己的 payload 契约;只验证 envelope 会让 + // 截断或字段拼写错误的事件悄悄进入客户端状态机。 + const bool payloadValid = [&] { + switch (*type) { + case IdeEventType::OperationStarted: + return has_string("operation"); + case IdeEventType::Progress: + return has_string("phase") && has_unsigned("completed") + && has_unsigned("total"); + case IdeEventType::Diagnostic: + return json.contains("diagnostic") && json["diagnostic"].is_object(); + case IdeEventType::SnapshotPublished: + return has_string("phase") && has_string("snapshotId") + && has_string("compileCommands"); + case IdeEventType::OperationFinished: + return has_string("operation") && has_string("status"); + } + return false; + }(); + if (!payloadValid) return std::unexpected("MCPP_IDE_EVENT_INVALID"); + lastSeq_ = seq; - return std::optional(IdeEvent{.seq = seq, .type = *type}); + return std::optional(IdeEvent{ + .seq = seq, + .type = *type, + .operationId = json["operationId"].get(), + }); } private: diff --git a/src/xlings.cppm b/src/xlings.cppm index 873b50a1..2fe757ef 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -1449,20 +1449,20 @@ int update_index(const Env& env, bool quiet) { // Report ONLY when the guard had to act. The common path — refresh keeps // the index readable — must stay silent, or the notice becomes noise that // users learn to skip past, which is the same as not printing it. - for (auto& dir : out.rolledBack) { - print_status("Kept", std::format( - "previous index for `{}` — the refreshed one requires a newer mcpp", - dir.filename().string())); - if (!quiet) { + if (!quiet) { + for (auto& dir : out.rolledBack) { + print_status("Kept", std::format( + "previous index for `{}` — the refreshed one requires a newer mcpp", + dir.filename().string())); std::println(" Your build continues to work with the packages " "it already describes."); std::println(" Upgrade to pick up newer packages: xlings update mcpp"); } - } - for (auto& dir : out.recovered) { - print_status("Restored", std::format( - "index `{}` from a local snapshot this mcpp can read", - dir.filename().string())); + for (auto& dir : out.recovered) { + print_status("Restored", std::format( + "index `{}` from a local snapshot this mcpp can read", + dir.filename().string())); + } } for (auto& dir : out.stillUnusable) { mcpp::log::verbose("index", std::format( diff --git a/tests/e2e/190_ide_snapshot.sh b/tests/e2e/196_ide_snapshot.sh old mode 100644 new mode 100755 similarity index 100% rename from tests/e2e/190_ide_snapshot.sh rename to tests/e2e/196_ide_snapshot.sh diff --git a/tests/e2e/191_ide_snapshot_read_only.sh b/tests/e2e/197_ide_snapshot_read_only.sh similarity index 100% rename from tests/e2e/191_ide_snapshot_read_only.sh rename to tests/e2e/197_ide_snapshot_read_only.sh diff --git a/tests/e2e/192_ide_configure.sh b/tests/e2e/198_ide_configure.sh similarity index 50% rename from tests/e2e/192_ide_configure.sh rename to tests/e2e/198_ide_configure.sh index e7499555..0f0a8c32 100755 --- a/tests/e2e/192_ide_configure.sh +++ b/tests/e2e/198_ide_configure.sh @@ -14,6 +14,8 @@ source "$(dirname "$0")/_llvm_env.sh" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT PROJECT="$TMP/app" +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" mkdir -p "$PROJECT/src" cat >"$PROJECT/mcpp.toml" </dev/null | grep -q .; then +# 用一个确定耗时的 build.mcpp 证明 started 在 prepare 完成前已经 flush。 +# 若只在进程退出后读取整个文件,事件即使被错误地移到末尾也会假通过。 +cat >"$PROJECT/build.mcpp" <<'EOF' +import std; + +int main() { + std::this_thread::sleep_for(std::chrono::seconds(2)); + return 0; +} +EOF + +LIVE_FIFO="$TMP/live-events.fifo" +mkfifo "$LIVE_FIFO" +( + cd "$PROJECT" + "$MCPP" ide configure --format ndjson >"$LIVE_FIFO" 2>"$TMP/live-stderr.log" +) & +live_pid=$! +exec 3<"$LIVE_FIFO" +if ! IFS= read -r -t 10 first_event <&3; then + echo "FAIL: operation-started was not visible while configure was running" >&2 + kill "$live_pid" 2>/dev/null || true + wait "$live_pid" 2>/dev/null || true + cat "$TMP/live-stderr.log" >&2 + exit 1 +fi +if ! kill -0 "$live_pid" 2>/dev/null; then + echo "FAIL: first event became visible only after configure exited" >&2 + wait "$live_pid" 2>/dev/null || true + exit 1 +fi +printf '%s\n' "$first_event" >"$TMP/live-events.ndjson" +cat <&3 >>"$TMP/live-events.ndjson" +exec 3<&- +if ! wait "$live_pid"; then + cat "$TMP/live-stderr.log" >&2 + cat "$TMP/live-events.ndjson" >&2 + exit 1 +fi +python3 - "$TMP/live-events.ndjson" <<'PY' +import json +import sys + +events = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +assert [event["type"] for event in events] == [ + "operation-started", "snapshot-published", "operation-finished" +], events +assert len({event["operationId"] for event in events}) == 1, events +PY + +# 真实 CLI 失败路径必须闭合生命周期,不能只输出 diagnostic 后退出。 +NO_MANIFEST="$TMP/no-manifest" +mkdir -p "$NO_MANIFEST" +set +e +(cd "$NO_MANIFEST" && "$MCPP" ide configure --format ndjson) \ + >"$TMP/failure-events.ndjson" 2>"$TMP/failure-stderr.log" +failure_rc=$? +set -e +if [ "$failure_rc" -ne 3 ]; then + echo "FAIL: failed configure returned $failure_rc, expected 3" >&2 + cat "$TMP/failure-events.ndjson" >&2 + cat "$TMP/failure-stderr.log" >&2 + exit 1 +fi +python3 - "$TMP/failure-events.ndjson" <<'PY' +import json +import sys + +events = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +assert [event["type"] for event in events] == [ + "operation-started", "diagnostic", "operation-finished" +], events +assert [event["seq"] for event in events] == [1, 2, 3], events +assert len({event["operationId"] for event in events}) == 1, events +assert events[1]["diagnostic"]["code"] == "MCPP_IDE_CONFIGURE_FAILED", events +assert events[2]["status"] == "failed", events +PY + +# `df4f75e` 允许依赖在 prepare 中构建 host tool。即使用户请求该子构建的 +# verbose 输出,IDE stdout 仍必须保持纯 NDJSON,详细日志只能留给人类命令。 +TOOL_PACKAGE="$TMP/tool-package" +VERBOSE_PROJECT="$TMP/verbose-project" +mkdir -p "$TOOL_PACKAGE/src" "$VERBOSE_PROJECT/src" +cat >"$TOOL_PACKAGE/mcpp.toml" <<'EOF' +[package] +name = "ide-tool-package" +version = "1.0.0" + +[targets.codegen] +kind = "bin" +main = "src/codegen.cpp" +EOF +cat >"$TOOL_PACKAGE/src/codegen.cpp" <<'EOF' +int main() { return 0; } +EOF +cat >"$VERBOSE_PROJECT/mcpp.toml" <"$VERBOSE_PROJECT/src/main.cpp" <<'EOF' +int main() { return 0; } +EOF +if ! (cd "$VERBOSE_PROJECT" && MCPP_TOOL_BUILD_VERBOSE=1 \ + "$MCPP" ide configure --format ndjson) \ + >"$TMP/verbose-events.ndjson" 2>"$TMP/verbose-stderr.log"; then + cat "$TMP/verbose-stderr.log" >&2 + cat "$TMP/verbose-events.ndjson" >&2 + exit 1 +fi +python3 - "$TMP/verbose-events.ndjson" <<'PY' +import json +import sys + +events = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +assert [event["type"] for event in events] == [ + "operation-started", "snapshot-published", "operation-finished" +], events +PY + +if find "$PROJECT/target" -type f \( -name '*.o' -o -name '*.obj' \) \ + ! -path '*/.build-mcpp/*' -print -quit 2>/dev/null | grep -q .; then echo "FAIL: ide configure compiled an ordinary object" >&2 exit 1 fi -if find "$PROJECT/target" -type f -path '*/bin/*' -print -quit 2>/dev/null | grep -q .; then +if find "$PROJECT/target" -type f -path '*/bin/*' ! -path '*/.build-mcpp/*' \ + -print -quit 2>/dev/null | grep -q .; then echo "FAIL: ide configure linked a final target" >&2 exit 1 fi diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index a258eb84..e7a13ed0 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -115,3 +115,39 @@ TEST(CompileCommandsFresh, DoesNotMergeHistoricalEntries) { std::error_code ec; std::filesystem::remove_all(root, ec); } + +TEST(CompileCommandsFresh, PreservesPublishErrorAfterTempCleanup) { + const auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_cdb_publish_error_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + const auto path = root / "compile_commands.json"; + std::filesystem::create_directories(path); + std::ofstream(path / "keep") << "keep"; + + // 非空目录既不能被 CDB 临时文件覆盖,也不能被 fallback remove 删除。 + auto result = write_fresh_compile_commands( + path, cdb({entry("/p/src/main.cpp", "-FRESH")})); + ASSERT_FALSE(result.has_value()); + EXPECT_FALSE(result.error().ends_with(": " + std::error_code{}.message())) + << result.error(); + EXPECT_TRUE(std::filesystem::is_regular_file(path / "keep")); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(CompileCommandsFresh, RefusesToReplaceDirectoryAtPublishPath) { + const auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_cdb_publish_directory_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + const auto path = root / "compile_commands.json"; + std::filesystem::create_directories(path); + + auto result = write_fresh_compile_commands( + path, cdb({entry("/p/src/main.cpp", "-FRESH")})); + EXPECT_FALSE(result.has_value()); + EXPECT_TRUE(std::filesystem::is_directory(path)); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} diff --git a/tests/unit/test_ide_configure.cpp b/tests/unit/test_ide_configure.cpp index 0aef8f60..261ebea4 100644 --- a/tests/unit/test_ide_configure.cpp +++ b/tests/unit/test_ide_configure.cpp @@ -32,7 +32,7 @@ TEST(IdeConfigure, EmitsConfiguredEventsWithCdbBeforeBuild) { .stdModuleState = "pending", }; - auto events = mcpp::ide::configure_events(result); + auto events = mcpp::ide::configure_events(result, "operation-fnv1a64:test"); ASSERT_EQ(events.size(), 3u); auto started = nlohmann::json::parse(events[0]); auto published = nlohmann::json::parse(events[1]); @@ -46,6 +46,81 @@ TEST(IdeConfigure, EmitsConfiguredEventsWithCdbBeforeBuild) { EXPECT_EQ(finished["status"], "success"); } +TEST(IdeConfigure, ContinuesLifecycleAfterEarlyStartedEvent) { + mcpp::ide::ConfigureResult result{ + .projectId = "project-fnv1a64:abcd", + .configurationId = "config-fnv1a64:1234", + .snapshotId = "snapshot-fnv1a64:5678", + }; + + auto events = mcpp::ide::configure_events(result, "operation-fnv1a64:test", + /*firstSeq=*/2, + /*includeStarted=*/false); + ASSERT_EQ(events.size(), 2u); + auto published = nlohmann::json::parse(events[0]); + auto finished = nlohmann::json::parse(events[1]); + EXPECT_EQ(published["seq"], 2); + EXPECT_EQ(published["type"], "snapshot-published"); + EXPECT_EQ(finished["seq"], 3); + EXPECT_EQ(finished["type"], "operation-finished"); +} + +TEST(IdeConfigure, CorrelatesEveryLifecycleEventWithOperationId) { + constexpr std::string_view operationId = "operation-fnv1a64:abcd"; + mcpp::ide::ConfigureResult result{ + .projectId = "project-fnv1a64:abcd", + .configurationId = "config-fnv1a64:1234", + .snapshotId = "snapshot-fnv1a64:5678", + }; + + auto started = nlohmann::json::parse( + mcpp::ide::configure_started_event(operationId)); + auto success = mcpp::ide::configure_events( + result, operationId, /*firstSeq=*/2, /*includeStarted=*/false); + auto failure = mcpp::ide::configure_failure_events( + "cannot resolve toolchain", operationId, /*firstSeq=*/2); + + EXPECT_EQ(started["operationId"], operationId); + for (const auto& line : success) + EXPECT_EQ(nlohmann::json::parse(line)["operationId"], operationId); + for (const auto& line : failure) + EXPECT_EQ(nlohmann::json::parse(line)["operationId"], operationId); +} + +TEST(IdeConfigure, GeneratesDistinctOperationIds) { + const auto first = mcpp::ide::new_operation_id(); + const auto second = mcpp::ide::new_operation_id(); + EXPECT_TRUE(first.starts_with("operation-fnv1a64:")); + EXPECT_NE(first, second); +} + +TEST(IdeConfigure, FinishesFailedLifecycleAfterDiagnostic) { + auto events = mcpp::ide::configure_failure_events("cannot resolve toolchain", + "operation-fnv1a64:test", + /*firstSeq=*/2); + ASSERT_EQ(events.size(), 2u); + auto diagnostic = nlohmann::json::parse(events[0]); + auto finished = nlohmann::json::parse(events[1]); + EXPECT_EQ(diagnostic["seq"], 2); + EXPECT_EQ(diagnostic["type"], "diagnostic"); + EXPECT_EQ(diagnostic["diagnostic"]["code"], "MCPP_IDE_CONFIGURE_FAILED"); + EXPECT_EQ(finished["seq"], 3); + EXPECT_EQ(finished["type"], "operation-finished"); + EXPECT_EQ(finished["status"], "failed"); + EXPECT_EQ(finished["diagnosticCodes"], + nlohmann::json::array({"MCPP_IDE_CONFIGURE_FAILED"})); +} + +TEST(IdeConfigure, SnapshotIdentityIncludesResolvedBuildFingerprint) { + const auto first = mcpp::ide::configured_snapshot_id( + "config-fnv1a64:1234", "build-fingerprint-a", "cdb-hash"); + EXPECT_TRUE(first.starts_with("snapshot-fnv1a64:")); + EXPECT_NE(first, mcpp::ide::configured_snapshot_id( + "config-fnv1a64:1234", "build-fingerprint-b", "cdb-hash")); + EXPECT_NE(first, mcpp::ide::configured_snapshot_id( + "config-fnv1a64:1234", "build-fingerprint-a", "other-cdb")); +} + TEST(IdeConfigure, StagesCachedModulePrerequisitesForPublishedCdb) { const auto root = temp_root(); const auto cache = root / "cache"; @@ -120,6 +195,35 @@ TEST(IdeConfigure, UsesToolchainSpecificBmiDirectories) { std::filesystem::remove_all(root, ec); } +TEST(IdeConfigure, KeepsFingerprintScopedStagedFileWhenSizeMatches) { + const auto root = temp_root(); + const auto source = root / "cache" / "dep.pcm"; + const auto destination = root / "target" / "pcm.cache" / "dep.pcm"; + std::filesystem::create_directories(source.parent_path()); + std::filesystem::create_directories(destination.parent_path()); + std::ofstream(source) << "new!"; + std::ofstream(destination) << "old!"; + + mcpp::build::BuildPlan plan; + plan.outputDir = root / "target"; + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.compileUnits.push_back({ + .source = "/deps/dep.cppm", + .object = "obj/dep.m.o", + .providesModule = "dep", + .servedFromCache = true, + .cachedBmi = source, + }); + + auto staged = mcpp::ide::stage_cached_module_prerequisites(plan); + ASSERT_TRUE(staged.has_value()) << staged.error(); + std::ifstream input(destination); + EXPECT_EQ(std::string(std::istreambuf_iterator(input), {}), "old!"); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + TEST(IdeConfigure, RefusesToPublishWhenCachedModuleIsMissing) { const auto root = temp_root(); mcpp::build::BuildPlan plan; diff --git a/tests/unit/test_ide_protocol.cpp b/tests/unit/test_ide_protocol.cpp index 1179c941..d7d71521 100644 --- a/tests/unit/test_ide_protocol.cpp +++ b/tests/unit/test_ide_protocol.cpp @@ -37,9 +37,76 @@ TEST(IdeProtocol, ConfigurationIdIsStableAndSelectorSensitive) { TEST(IdeProtocol, NdjsonRejectsSequenceRegression) { using namespace mcpp::ide; NdjsonEventParser parser; - ASSERT_TRUE(parser.consume(R"({"seq":1,"type":"operation-started"})").has_value()); - ASSERT_TRUE(parser.consume(R"({"seq":2,"type":"progress"})").has_value()); - auto result = parser.consume(R"({"seq":2,"type":"diagnostic"})"); + ASSERT_TRUE(parser.consume( + R"({"schemaVersion":1,"seq":1,"type":"operation-started","operationId":"operation:1","operation":"configure"})") + .has_value()); + ASSERT_TRUE(parser.consume( + R"({"schemaVersion":1,"seq":2,"type":"progress","operationId":"operation:1","phase":"resolve","completed":1,"total":2})") + .has_value()); + auto result = parser.consume( + R"({"schemaVersion":1,"seq":2,"type":"diagnostic","operationId":"operation:1"})"); ASSERT_FALSE(result.has_value()); EXPECT_EQ(result.error(), "MCPP_IDE_EVENT_SEQUENCE_INVALID"); } + +TEST(IdeProtocol, NdjsonRequiresAndPreservesOperationIdentity) { + using namespace mcpp::ide; + NdjsonEventParser parser; + + auto missingSchema = parser.consume( + R"({"seq":1,"type":"operation-started","operationId":"operation:1"})"); + ASSERT_FALSE(missingSchema.has_value()); + EXPECT_EQ(missingSchema.error(), "MCPP_IDE_EVENT_INVALID"); + + auto missingOperation = parser.consume( + R"({"schemaVersion":1,"seq":1,"type":"operation-started"})"); + ASSERT_FALSE(missingOperation.has_value()); + EXPECT_EQ(missingOperation.error(), "MCPP_IDE_EVENT_INVALID"); + + auto valid = parser.consume( + R"({"schemaVersion":1,"seq":1,"type":"operation-started","operationId":"operation:1","operation":"configure"})"); + ASSERT_TRUE(valid.has_value()); + ASSERT_TRUE(valid->has_value()); + EXPECT_EQ(valid->value().operationId, "operation:1"); +} + +TEST(IdeProtocol, NdjsonValidatesPayloadRequiredByEventType) { + using namespace mcpp::ide; + const auto expect_invalid = [](std::string_view line) { + NdjsonEventParser parser; + auto result = parser.consume(line); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), "MCPP_IDE_EVENT_INVALID"); + }; + + expect_invalid( + R"({"schemaVersion":1,"seq":1,"type":"operation-started","operationId":"operation:1"})"); + expect_invalid( + R"({"schemaVersion":1,"seq":1,"type":"progress","operationId":"operation:1"})"); + expect_invalid( + R"({"schemaVersion":1,"seq":1,"type":"diagnostic","operationId":"operation:1"})"); + expect_invalid( + R"({"schemaVersion":1,"seq":1,"type":"snapshot-published","operationId":"operation:1"})"); + expect_invalid( + R"({"schemaVersion":1,"seq":1,"type":"operation-finished","operationId":"operation:1"})"); +} + +TEST(IdeProtocol, NdjsonAcceptsCompletePayloadForEveryEventType) { + using namespace mcpp::ide; + NdjsonEventParser parser; + const std::array lines = { + R"({"schemaVersion":1,"seq":1,"type":"operation-started","operationId":"operation:1","operation":"configure"})", + R"({"schemaVersion":1,"seq":2,"type":"progress","operationId":"operation:1","phase":"resolve","completed":2,"total":5})", + R"({"schemaVersion":1,"seq":3,"type":"diagnostic","operationId":"operation:1","diagnostic":{}})", + R"({"schemaVersion":1,"seq":4,"type":"snapshot-published","operationId":"operation:1","phase":"configured","snapshotId":"snapshot:1","compileCommands":"/tmp/compile_commands.json"})", + R"({"schemaVersion":1,"seq":5,"type":"operation-finished","operationId":"operation:1","operation":"configure","status":"success"})", + }; + + for (std::uint64_t seq = 1; seq <= lines.size(); ++seq) { + auto result = parser.consume(lines[seq - 1]); + ASSERT_TRUE(result.has_value()) << result.error(); + ASSERT_TRUE(result->has_value()); + EXPECT_EQ(result->value().seq, seq); + EXPECT_EQ(result->value().operationId, "operation:1"); + } +} diff --git a/tests/unit/test_pm_index_refresh.cpp b/tests/unit/test_pm_index_refresh.cpp index a4a912a9..da789e35 100644 --- a/tests/unit/test_pm_index_refresh.cpp +++ b/tests/unit/test_pm_index_refresh.cpp @@ -19,8 +19,10 @@ import mcpp.config; import mcpp.pm.dep_spec; import mcpp.pm.index_refresh; import mcpp.pm.index_route; +import mcpp.pm.index_snapshot; import mcpp.pm.index_spec; import mcpp.platform.axis; +import mcpp.platform.env; import mcpp.xlings; namespace { @@ -164,6 +166,29 @@ TEST(PmIndexRefresh, OfflineDoesNotMisreportAResolvableDependency) { RefreshReason::None); } +TEST(PmIndexRefresh, QuietIndexRecoveryDoesNotWriteToStdout) { + FakeRegistry reg("quiet-recovery"); + const auto idx = reg.index_dir(); + auto write_index = [&](std::string_view floor, std::string_view marker) { + std::ofstream(idx / "index.toml") + << std::format("[index]\nspec = \"1\"\nmin_mcpp = \"{}\"\n", floor); + std::ofstream(idx / ".xlings-index-version") << marker; + std::ofstream(idx / "pkgs" / "z" / "zlib.lua") << "-- " << marker << "\n"; + }; + + write_index("0.0.85", "known-good"); + ASSERT_TRUE(mcpp::pm::index_snapshot::archive(reg.home / "data", idx)); + write_index("9999.9.9.9", "too-new"); + + mcpp::platform::env::ScopedEnv offline("MCPP_OFFLINE", "1"); + testing::internal::CaptureStdout(); + const auto rc = mcpp::xlings::update_index(reg.env(), /*quiet=*/true); + const auto output = testing::internal::GetCapturedStdout(); + + EXPECT_EQ(rc, 0); + EXPECT_TRUE(output.empty()) << output; +} + TEST(PmIndexRefresh, AutoRefreshOptOutSuppressesAGenuineMiss) { FakeRegistry reg("optout"); reg.mark_refreshed(std::chrono::hours{99}); From 9ae6d9f973a4a237f2e0341266e522f53d2dd037 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:21:10 +0800 Subject: [PATCH 20/26] fix(ide): make configure publication portable --- src/build/compile_commands.cppm | 6 +- tests/e2e/199_ide_configure_windows.sh | 83 ++++++++++++++++++++++++++ tests/unit/test_ide_configure.cpp | 10 +++- 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100755 tests/e2e/199_ide_configure_windows.sh diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index beacfe02..990834b6 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -242,7 +242,11 @@ write_fresh_compile_commands(const std::filesystem::path& path, std::string_view return std::unexpected("compile commands must be a JSON array"); const auto normalized = json.dump(2) + "\n"; - { + std::error_code existingTypeEc; + // 只有普通文件才能进入内容比较;Linux 上把目录交给 ifstream 读取会抛异常, + // 而不是稳定地设置 failbit,导致 IDE configure 无法返回结构化失败事件。 + if (std::filesystem::is_regular_file(path, existingTypeEc) + && !existingTypeEc) { std::ifstream existing(path, std::ios::binary); if (existing) { std::string bytes(std::istreambuf_iterator(existing), {}); diff --git a/tests/e2e/199_ide_configure_windows.sh b/tests/e2e/199_ide_configure_windows.sh new file mode 100755 index 00000000..825dac92 --- /dev/null +++ b/tests/e2e/199_ide_configure_windows.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# requires: windows msvc +# Windows 不依赖 libc++ std modules,也必须能在源码语法错误时发布可用 CDB。 +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +PROJECT="$TMP/app" +mkdir -p "$PROJECT/src" + +cat >"$PROJECT/mcpp.toml" <<'EOF' +[package] +name = "ide-windows-broken-source" +version = "1.0.0" + +[toolchain] +windows = "msvc@system" +EOF + +cat >"$PROJECT/src/main.cpp" <<'EOF' +int main( { + return missing_symbol; +} +EOF + +configure() { + local output=$1 + if ! (cd "$PROJECT" && "$MCPP" ide configure --format ndjson) \ + >"$output" 2>"$output.stderr"; then + cat "$output.stderr" >&2 + cat "$output" >&2 + return 1 + fi +} + +configure "$TMP/first.ndjson" + +# 第二次发布必须替换根目录兼容 CDB,同时保留按内容寻址的旧快照。 +cat >>"$PROJECT/mcpp.toml" <<'EOF' + +[build] +cxxflags = ["/DIDE_WINDOWS_CHANGED=1"] +EOF +configure "$TMP/second.ndjson" + +python3 - "$TMP/first.ndjson" "$TMP/second.ndjson" "$PROJECT" <<'PY' +import json +import os +import sys + +first_path, second_path, project = sys.argv[1:] + + +def read_events(path): + with open(path, encoding="utf-8") as stream: + events = [json.loads(line) for line in stream if line.strip()] + assert [event["type"] for event in events] == [ + "operation-started", "snapshot-published", "operation-finished" + ], events + assert [event["seq"] for event in events] == [1, 2, 3], events + assert len({event["operationId"] for event in events}) == 1, events + return events[1] + + +first = read_events(first_path) +second = read_events(second_path) +assert first["configurationId"] == second["configurationId"], (first, second) +assert first["snapshotId"] != second["snapshotId"], (first, second) +assert first["compileCommands"] != second["compileCommands"], (first, second) +assert os.path.isfile(first["compileCommands"]), first +assert os.path.isfile(second["compileCommands"]), second + +compatibility_cdb = os.path.join(project, "compile_commands.json") +assert os.path.realpath(second["compatibilityCompileCommands"]) == os.path.realpath( + compatibility_cdb +), second +with open(compatibility_cdb, encoding="utf-8") as stream: + cdb = json.load(stream) +entry = next(item for item in cdb if item["file"].replace("\\", "/").endswith("/src/main.cpp")) +assert "/DIDE_WINDOWS_CHANGED=1" in entry["arguments"], entry +PY + +echo "PASS: Windows IDE configure publishes and replaces CDB before compilation" diff --git a/tests/unit/test_ide_configure.cpp b/tests/unit/test_ide_configure.cpp index 261ebea4..12595afe 100644 --- a/tests/unit/test_ide_configure.cpp +++ b/tests/unit/test_ide_configure.cpp @@ -80,11 +80,15 @@ TEST(IdeConfigure, CorrelatesEveryLifecycleEventWithOperationId) { auto failure = mcpp::ide::configure_failure_events( "cannot resolve toolchain", operationId, /*firstSeq=*/2); - EXPECT_EQ(started["operationId"], operationId); + // 显式提取字符串,避免 Windows Clang 在 json 与 string_view 间选择 + // operator== 时产生二义性。 + EXPECT_EQ(started["operationId"].get(), std::string(operationId)); for (const auto& line : success) - EXPECT_EQ(nlohmann::json::parse(line)["operationId"], operationId); + EXPECT_EQ(nlohmann::json::parse(line)["operationId"].get(), + std::string(operationId)); for (const auto& line : failure) - EXPECT_EQ(nlohmann::json::parse(line)["operationId"], operationId); + EXPECT_EQ(nlohmann::json::parse(line)["operationId"].get(), + std::string(operationId)); } TEST(IdeConfigure, GeneratesDistinctOperationIds) { From 458c1a0afffe011237e0ec5a7aaa70dffafbc490 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:07 +0800 Subject: [PATCH 21/26] chore(release): bootstrap pin -> 2026.8.6.3 --- .xlings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.xlings.json b/.xlings.json index 2a35c39e..c793d60f 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.8.6.2" + "mcpp": "2026.8.6.3" } } From bd3843dec91ae4029b46abe2eb104454005a1a1d Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:03:57 +0800 Subject: [PATCH 22/26] fix(ide): harden configured snapshot publication --- README.md | 4 +- ...6-08-06-mcpp-universal-ide-model-design.md | 49 ++- src/build/compile_commands.cppm | 15 +- src/build/execute.cppm | 70 +--- src/build/test_targets.cppm | 74 ++++ src/cli/cmd_ide.cppm | 4 +- src/ide/configure.cppm | 54 ++- src/ide/inspect.cppm | 34 +- src/ide/model.cppm | 37 +- src/ide/publish.cppm | 326 ++++++++++++++++++ src/ide/snapshot.cppm | 3 + src/platform/fs.cppm | 22 ++ src/xlings.cppm | 28 +- tests/e2e/198_ide_configure.sh | 146 +++++++- tests/unit/test_ide_configure.cpp | 240 +++++++++++++ tests/unit/test_ide_snapshot.cpp | 28 ++ tests/unit/test_platform_fs.cpp | 63 ++++ tests/unit/test_pm_index_refresh.cpp | 5 +- 18 files changed, 1097 insertions(+), 105 deletions(-) create mode 100644 src/build/test_targets.cppm create mode 100644 src/ide/publish.cppm create mode 100644 tests/unit/test_platform_fs.cpp diff --git a/README.md b/README.md index aa82921b..ef1e3c96 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,9 @@ import mcpplibs.cmdline; - Fingerprinted BMI cache: hashed by compiler/flags/standard library, shared across projects - Ninja backend: auto-generated build.ninja, parallel compilation - compile_commands.json generated automatically (ready for clangd / ccls) -- `mcpp ide configure` publishes a fresh CDB before ordinary TU compilation, +- `mcpp ide configure` publishes a fresh CDB for ordinary and `tests/**/*.cpp` + translation units (including test dev-dependency flags) before compilation, + and persists the configured snapshot so clangd can analyze a project whose source currently has build errors - First-class C support: `.c` files auto-detected, mixed C/C++ projects - User-defined cflags / cxxflags / ldflags / c_standard diff --git a/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md index 0faf518a..b1a46303 100644 --- a/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md +++ b/docs/superpowers/specs/2026-08-06-mcpp-universal-ide-model-design.md @@ -261,6 +261,28 @@ artifact 状态包括 `pending`、`ready`、`stale`、`missing`、`unavailable` 回复文件写入临时文件后关闭并校验,再使用同目录 rename 发布。`index-.json` 只引用完整回复文件;客户端先读 index,再按引用读取对象。这借鉴 CMake File API 的 reply index 和不可变 reply 文件,避免读到半个 JSON。 +截至 2026-08-07,configured 阶段先实现以下可恢复子集: + +```text +/.mcpp/ide/ + current.json + replies/ + snapshot-.json + compile_commands-.json +``` + +`current.json` 是最后一个原子切换点,记录 `projectId`、`configurationId`、 +`snapshotId`、toolchain 和内容寻址 CDB。`ide snapshot` 会读取并验证该记录;无 +mcpp metadata 的根 CDB 仍为 `stale/unverified`。按 configuration 分目录的 index、 +ready 和 last-known-good 仍由后续 prepare/publish 阶段实现。 + +configured publisher 会在替换根 compatibility CDB 前保存旧内容;若随后 +`current.json` 发布失败,则尝试原子恢复旧根 CDB。内容寻址 reply 可以保留为不可见文件。 +根 CDB 与 `current.json` 是两个独立路径,操作系统不能为它们提供跨文件事务;如果外部 +进程在回滚期间持续占用文件,发布结果会返回复合错误,调用方必须把它视为需要人工恢复的 +不一致状态。发布过程持有 `.mcpp/ide/.lock`,多个 configure 可以并行解析,但不能交错 +切换 mutable 指针。 + ### 7.2 CDB 发布时序 ```text @@ -380,20 +402,33 @@ Node 单元测试和 `clangd --check` 不能替代真实 Extension Development H ### 当前实现的验证边界 -截至 2026-08-06,早期 CDB 实现还有两个 IDE 路径的测试缺口,不能据此推断核心 Ninja 构建后端存在回归: +截至 2026-08-07,早期 CDB 实现仍有一个工具链矩阵缺口,不能据此推断核心 Ninja 构建后端存在回归: + +1. cached dependency BMI 的发布前 staging 已有 helper 单测和 macOS/Clang E2E,但尚缺真实 GCC `.gcm`、MSVC `.ifc` 缓存命中工程。核心 Ninja 后端仍通过自己的 `stage_file` edges 物化缓存产物;此缺口的直接风险是 IDE CDB 引用了未物化或路径漂移的 BMI,而不是普通构建无法完成。 -1. 尚未通过真实 `configure_project()` 流程制造 staging 失败,并断言已有根 CDB 保持不变。普通 `mcpp build/test` 继续使用既有 `write_compile_commands()` 和 Ninja 执行路径,不经过 IDE 的 fresh CDB 发布与回退逻辑。完整 last-known-good 状态仍属于后续 snapshot publish 层,不应把它与当前测试缺口混为已实现能力。 -2. cached dependency BMI 的发布前 staging 已有 helper 单测和 macOS/Clang E2E,但尚缺真实 GCC `.gcm`、MSVC `.ifc` 缓存命中工程。核心 Ninja 后端仍通过自己的 `stage_file` edges 物化缓存产物;此缺口的直接风险是 IDE CDB 引用了未物化或路径漂移的 BMI,而不是普通构建无法完成。 +该缺口仍跨越 `BuildPlan::cachedBmi`、`bmi_traits()`、`stage_file()` 和 Ninja BMI 命名等共享契约。应按任务 3 将目标路径收敛到 `mcpp.build.artifact_layout`,由 Ninja 与 IDE 共同消费,再补真实工具链矩阵。GCC/MSVC E2E 只证明路径和产物契约,不代表 clangd 能消费 `.gcm` 或 `.ifc`;该能力仍按工具链 capability 明确表达。 -第二项仍跨越 `BuildPlan::cachedBmi`、`bmi_traits()`、`stage_file()` 和 Ninja BMI 命名等共享契约。应按任务 3 将目标路径收敛到 `mcpp.build.artifact_layout`,由 Ninja 与 IDE 共同消费,再补真实工具链矩阵。GCC/MSVC E2E 只证明路径和产物契约,不代表 clangd 能消费 `.gcm` 或 `.ifc`;该能力仍按工具链 capability 明确表达。 +真实 CLI E2E 已覆盖 `build.mcpp` 失败,并逐字节断言已有根 CDB 和 +`current.json` 不变;configure 还会把选中 member 的 `tests/**/*.cpp` 合成为与 +`mcpp test` 相同的 targets,并在发现测试时自动启用 dev-dependencies,使测试 CDB +包含实际测试构建所需的 include/define。workspace selector 与 member 目录调用使用物理 +workspace root 和规范化 member path,产生相同的 `projectId + configurationId`。 当前 `configure` 已在解析前发送带 `operationId` 的 `operation-started`,并在成功或 失败的全部后续事件中沿用该 ID;失败序列为 `diagnostic -> operation-finished(status=failed)`。解析前尚无 resolved `configurationId`,因此早期 started 事件不携带该字段,客户端应以 `operationId` -关联整个生命周期。阶段化 `progress`、取消、迟到事件过滤,以及 -index/current/last-known-good 发布仍属于后续 publish/prepare 任务。现阶段不能据此 -宣称完整的长任务管理和失败回退协议已经完成。 +关联整个生命周期。阶段化 `progress`、取消、迟到事件过滤,以及按 configuration +的 index、ready 和 last-known-good 发布仍属于后续 prepare 任务。configured +`current.json` 已原子发布,但现阶段仍不能据此宣称完整的长任务管理和失败回退协议 +已经完成。 + +`ide snapshot` 当前验证 metadata schema、工程内路径约束和 CDB 文件存在性,但不会 +在只读路径重新执行 resolved build fingerprint。manifest、lockfile、source 集或工具链 +输入变化后,插件必须触发一次 `ide configure` 才能得到新 snapshot;在此之前 +`configured` 仅表示“最后一次已发布 configured snapshot 仍可读取”,不表示所有输入 +已重新证明为 fresh。验收标准中的主动 stale 判定仍属于后续 configuration index/freshness +工作。 当前 `configured_snapshot_id` 已绑定 configuration、BuildContext fingerprint 和 CDB 内容;完整 resolved module graph、artifact metadata 和输入 provenance 尚未 diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index 990834b6..ebb306e8 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -18,6 +18,7 @@ import std; import mcpp.build.plan; import mcpp.build.flags; import mcpp.libs.json; +import mcpp.platform.fs; export namespace mcpp::build { @@ -281,17 +282,9 @@ write_fresh_compile_commands(const std::filesystem::path& path, std::string_view } } - std::filesystem::rename(temp, path, ec); - std::error_code typeEc; - const bool replaceableFile = std::filesystem::is_regular_file(path, typeEc); - if (ec && replaceableFile && !typeEc) { - // Windows rename 不覆盖已有普通文件;只对文件执行 remove + rename, - // 不能把同名目录等其他对象当成可替换的旧 CDB。 - std::error_code removeEc; - std::filesystem::remove(path, removeEc); - ec.clear(); - std::filesystem::rename(temp, path, ec); - } + // POSIX 使用 rename,Windows 使用 MoveFileEx(REPLACE_EXISTING);两者 + // 都不会先删除旧文件,因此失败时 last-known-good CDB 仍然存在。 + mcpp::platform::fs::replace_file(temp, path, ec); if (ec) { const auto publishError = ec; std::error_code cleanupEc; diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 6a0e9b8c..1adc4a23 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -11,6 +11,7 @@ export module mcpp.build.execute; import std; import mcpp.build.build_program; // #359 glob inputs the mtime sweep cannot see import mcpp.build.prepare; +import mcpp.build.test_targets; import mcpp.diag; import mcpp.build.plan; import mcpp.build.backend; @@ -930,72 +931,17 @@ export int run_tests(std::span passthrough, return 2; } - // Workspace scoping: discovery must run against the MEMBER, not the - // workspace root — otherwise `tests/**/*.cpp` globs every member's tests - // together (two `tests/main.cpp` → "duplicate test name 'main'"). When a - // member is selected (via -p, threaded as package_filter), glob from its - // dir; prepare_build below resolves the SAME member, so the two agree. - // (--workspace fans out over members at the cmd layer, one call per member.) - auto testRoot = *root; - if (auto rm = mcpp::manifest::load(*root / "mcpp.toml"); rm) { - auto member = mcpp::project::resolve_member_dir(*rm, *root, overrides.package_filter); - if (!member) { mcpp::ui::error(member.error()); return 2; } - if (!member->empty()) testRoot = *member; - } - - // 1. Discover test files (scoped to the member/package). - auto testFiles = mcpp::modgraph::expand_glob(testRoot, "tests/**/*.cpp"); - if (testFiles.empty()) { + // 测试命令和 IDE configure 共用同一发现规则,避免 CDB 与真实测试 + // 编译在 member 选择、名称和 `[build].flags` 上发生漂移。 + auto discovery = discover_test_targets(*root, overrides.package_filter); + if (!discovery) { mcpp::ui::error(discovery.error()); return 2; } + auto testRoot = std::move(discovery->root); + auto testTargets = std::move(discovery->targets); + if (testTargets.empty()) { std::println("no tests found in tests/"); return 0; } - // [build].flags globs also cover tests: a glob names files — whether they - // are scanned sources or test TUs is orthogonal. Matched entries ride the - // per-target flag channel (issue #131) on the synthesized test target. - // (Feature-folded entries are prepare-time state; tests take the base - // [build].flags — sufficient for per-test compile options.) - struct TestGlobFlags { - mcpp::manifest::GlobFlags gf; - std::set files; - }; - std::vector testGlobFlags; - if (auto mm = mcpp::manifest::load(testRoot / "mcpp.toml")) { - for (auto const& gf : mm->buildConfig.globFlags) { - auto hits = mcpp::modgraph::expand_glob(testRoot, gf.glob); - testGlobFlags.push_back({gf, {hits.begin(), hits.end()}}); - } - } - - // 2. Synthesize a Target for each test file. - // Name = path relative to tests/, extension dropped, '/' separators — - // so tests/00-a/0.cpp and tests/01-b/0.cpp coexist as '00-a/0' and - // '01-b/0' (stems alone would collide). Flat layouts keep their old - // names ('tests/smoke.cpp' → 'smoke'). - std::vector testTargets; - std::set seenNames; - for (auto& f : testFiles) { - auto rel = std::filesystem::relative(f, testRoot / "tests"); - auto name = rel.replace_extension("").generic_string(); - if (!seenNames.insert(name).second) { - mcpp::ui::error(std::format( - "duplicate test name '{}' (two test files map to the same name)", name)); - return 2; - } - mcpp::manifest::Target t; - t.name = name; - t.kind = mcpp::manifest::Target::TestBinary; - // Relative to the member/package root prepare_build will operate on. - t.main = std::filesystem::relative(f, testRoot).string(); - for (auto const& tgf : testGlobFlags) { - if (!tgf.files.contains(f)) continue; - for (auto const& d : tgf.gf.defines) t.defines.push_back(d); - for (auto const& fl : tgf.gf.cflags) t.cflags.push_back(fl); - for (auto const& fl : tgf.gf.cxxflags) t.cxxflags.push_back(fl); - } - testTargets.push_back(std::move(t)); - } - // --list: enumerate (filtered) tests and stop — no toolchain resolution, // no build. Names/paths come straight from discovery, so this also works // on tests that do not currently compile. diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm new file mode 100644 index 00000000..a810be2f --- /dev/null +++ b/src/build/test_targets.cppm @@ -0,0 +1,74 @@ +// mcpp.build.test_targets — shared tests/**/*.cpp discovery for test and IDE. +export module mcpp.build.test_targets; + +import std; +import mcpp.manifest; +import mcpp.modgraph.scanner; +import mcpp.project; + +export namespace mcpp::build { + +struct TestTargetDiscovery { + std::filesystem::path root; + std::vector targets; +}; + +std::expected +discover_test_targets(const std::filesystem::path& manifestRoot, + std::string_view packageFilter = {}) { + auto rootManifest = mcpp::manifest::load(manifestRoot / "mcpp.toml"); + if (!rootManifest) return std::unexpected(rootManifest.error().format()); + + auto member = mcpp::project::resolve_member_dir( + *rootManifest, manifestRoot, packageFilter); + if (!member) return std::unexpected(member.error()); + const auto testRoot = member->empty() ? manifestRoot : *member; + + auto manifest = member->empty() + ? std::move(rootManifest) + : mcpp::manifest::load(testRoot / "mcpp.toml"); + if (!manifest) return std::unexpected(manifest.error().format()); + + const auto testFiles = mcpp::modgraph::expand_glob(testRoot, "tests/**/*.cpp"); + if (testFiles.empty()) return TestTargetDiscovery{.root = testRoot}; + + // `[build].flags` 对测试源同样生效,必须与 `mcpp test` 的编译参数一致。 + struct TestGlobFlags { + mcpp::manifest::GlobFlags flags; + std::set files; + }; + std::vector globFlags; + for (const auto& flags : manifest->buildConfig.globFlags) { + auto files = mcpp::modgraph::expand_glob(testRoot, flags.glob); + globFlags.push_back({flags, {files.begin(), files.end()}}); + } + + TestTargetDiscovery result{.root = testRoot}; + std::set seenNames; + for (const auto& file : testFiles) { + auto relative = std::filesystem::relative(file, testRoot / "tests"); + auto name = relative.replace_extension("").generic_string(); + if (!seenNames.insert(name).second) { + return std::unexpected(std::format( + "duplicate test name '{}' (two test files map to the same name)", name)); + } + + mcpp::manifest::Target target; + target.name = std::move(name); + target.kind = mcpp::manifest::Target::TestBinary; + target.main = std::filesystem::relative(file, testRoot).string(); + for (const auto& matched : globFlags) { + if (!matched.files.contains(file)) continue; + target.defines.insert(target.defines.end(), + matched.flags.defines.begin(), matched.flags.defines.end()); + target.cflags.insert(target.cflags.end(), + matched.flags.cflags.begin(), matched.flags.cflags.end()); + target.cxxflags.insert(target.cxxflags.end(), + matched.flags.cxxflags.begin(), matched.flags.cxxflags.end()); + } + result.targets.push_back(std::move(target)); + } + return result; +} + +} // namespace mcpp::build diff --git a/src/cli/cmd_ide.cppm b/src/cli/cmd_ide.cppm index 6f19acd2..9e2e58c4 100644 --- a/src/cli/cmd_ide.cppm +++ b/src/cli/cmd_ide.cppm @@ -101,7 +101,9 @@ export int cmd_ide_configure(const mcpplibs::cmdline::ParsedArgs& parsed) { const auto operationId = mcpp::ide::new_operation_id(); std::println("{}", mcpp::ide::configure_started_event(operationId)); std::fflush(stdout); - const auto result = mcpp::ide::configure_project(request); + const auto result = mcpp::ide::run_configure_safely([&] { + return mcpp::ide::configure_project(request); + }); if (!result) { for (const auto& line : mcpp::ide::configure_failure_events( result.error(), operationId, 2)) diff --git a/src/ide/configure.cppm b/src/ide/configure.cppm index 89377f2b..4933b9cc 100644 --- a/src/ide/configure.cppm +++ b/src/ide/configure.cppm @@ -9,7 +9,9 @@ import mcpp.build.flags; import mcpp.build.plan; import mcpp.build.prepare; import mcpp.build.stage; +import mcpp.build.test_targets; import mcpp.ide.model; +import mcpp.ide.publish; import mcpp.libs.json; import mcpp.project; import mcpp.toolchain.fingerprint; @@ -85,6 +87,19 @@ export struct ConfigureResult { std::string stdModuleState; }; +export std::expected +run_configure_safely(const std::function< + std::expected()>& operation) { + try { + return operation(); + } catch (const std::exception& error) { + return std::unexpected(std::format( + "IDE configure raised an exception: {}", error.what())); + } catch (...) { + return std::unexpected("IDE configure raised an unknown exception"); + } +} + // configured snapshot 的身份同时绑定配置、当前 resolved build fingerprint // 和 CDB 内容;精简 toolchain fingerprint 只用于 configurationId,不能替代 // 这里的 BuildContext::fp。 @@ -164,8 +179,16 @@ configure_project(const ConfigureRequest& request) { // configure 的结构化 stdout 不能混入普通进度;mcpp 自身写入的项目文件 // 仍保持原有 prepare_build 语义,但状态行转为诊断/事件。 QuietGuard quiet; + auto tests = mcpp::build::discover_test_targets( + *root, request.selectors.package.value_or("")); + if (!tests) return std::unexpected(tests.error()); + // 测试 target 的真实构建语义包含 dev-dependencies;只把测试源放进 CDB + // 却遗漏其依赖 include/define,clangd 仍会把测试文件解析成错误配置。 + const bool includeDevDependencies = request.selectors.includeDevDependencies + || !tests->targets.empty(); auto context = mcpp::build::prepare_build( - /*print_fingerprint=*/false, request.selectors.includeDevDependencies, {}, + /*print_fingerprint=*/false, includeDevDependencies, + std::move(tests->targets), std::move(overrides)); if (!context) return std::unexpected(context.error()); // A failed stage must leave the previous CDB untouched and must not emit a @@ -180,11 +203,16 @@ configure_project(const ConfigureRequest& request) { resolvedSelectors.cacheMode = std::string( mcpp::build::cache_mode_name(context->cacheMode)); resolvedSelectors.cppStandard = context->manifest.package.standard; + resolvedSelectors.includeDevDependencies = includeDevDependencies; + auto workspaceRoot = mcpp::project::find_workspace_root(context->projectRoot); + if (workspaceRoot.empty()) workspaceRoot = context->projectRoot; + workspaceRoot = physical_project_root(workspaceRoot); + resolvedSelectors.package = canonical_member_selector( + workspaceRoot, context->projectRoot); const auto toolchainFingerprint = toolchain_fingerprint(context->fp); const auto configId = configuration_id( - context->projectRoot, resolvedSelectors, toolchainFingerprint); - const auto projectId = std::format("project-fnv1a64:{}", - mcpp::toolchain::hash_string(context->projectRoot.generic_string())); + workspaceRoot, resolvedSelectors, toolchainFingerprint); + const auto projectId = project_id(workspaceRoot); auto flags = mcpp::build::compute_flags(context->plan); const auto content = mcpp::build::emit_compile_commands(context->plan, flags); auto parsed = nlohmann::json::parse(content, nullptr, /*allow_exceptions=*/false); @@ -200,15 +228,13 @@ configure_project(const ConfigureRequest& request) { if (auto written = mcpp::build::write_fresh_compile_commands(snapshotCdb, content); !written) return std::unexpected(written.error()); const auto compatibilityCdb = context->projectRoot / "compile_commands.json"; - if (auto written = mcpp::build::write_fresh_compile_commands(compatibilityCdb, content); !written) - return std::unexpected(written.error()); const auto snapshotId = configured_snapshot_id(configId, context->fp.hex, cdbHash); const auto traits = mcpp::toolchain::bmi_traits(context->plan.toolchain); const auto stagedStdModule = context->plan.stdBmiPath.empty() ? std::filesystem::path{} : context->plan.outputDir / traits.bmiDir / context->plan.stdBmiPath.filename(); - return ConfigureResult{ + ConfigureResult result{ .projectId = projectId, .configurationId = configId, .snapshotId = snapshotId, .projectRoot = context->projectRoot, .compileCommands = snapshotCdb, .compatibilityCompileCommands = compatibilityCdb, .toolchain = context->tc.label(), @@ -218,6 +244,20 @@ configure_project(const ConfigureRequest& request) { ? "not-required" : (std::filesystem::is_regular_file(stagedStdModule) ? "ready" : "pending"), }; + auto published = publish_configured_snapshot(context->projectRoot, { + .projectId = result.projectId, + .configurationId = result.configurationId, + .snapshotId = result.snapshotId, + .phase = "configured", + .projectRoot = result.projectRoot, + .compileCommands = result.compileCommands, + .compatibilityCompileCommands = result.compatibilityCompileCommands, + .toolchain = result.toolchain, + .toolchainFingerprint = result.toolchainFingerprint, + .compileCommandCount = result.compileCommandCount, + }, content); + if (!published) return std::unexpected(published.error()); + return result; } export std::string configure_started_event( diff --git a/src/ide/inspect.cppm b/src/ide/inspect.cppm index ca0aa827..33038ae3 100644 --- a/src/ide/inspect.cppm +++ b/src/ide/inspect.cppm @@ -2,6 +2,7 @@ export module mcpp.ide.inspect; import std; import mcpp.ide.model; +import mcpp.ide.publish; import mcpp.manifest; import mcpp.project; @@ -352,11 +353,39 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { bool hasStale = false; bool hasMissing = false; + bool hasConfigured = false; bool artifactProbeError = false; for (auto index : selected) { const auto& candidate = candidates[index]; if (!candidate.member) continue; result.selectedMembers.push_back(candidate.name); + + auto current = read_current_snapshot(candidate.root); + if (!current) { + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_SNAPSHOT_INVALID", Severity::Warning, + current.error(), candidate.root / ".mcpp" / "ide" / "current.json")); + } else if (current->has_value()) { + const auto& published = **current; + std::error_code publishedEc; + const bool publishedCdb = std::filesystem::is_regular_file( + published.compileCommands, publishedEc); + if (!publishedEc && publishedCdb + && physical_project_root(published.projectRoot) + == physical_project_root(candidate.root)) { + result.compileCommands.push_back({ + candidate.name, published.compileCommands, + ArtifactState::Configured, published.snapshotId, + published.configurationId}); + hasConfigured = true; + continue; + } + result.diagnostics.push_back(manifest_diagnostic( + "MCPP_IDE_SNAPSHOT_STALE", Severity::Warning, + "published IDE snapshot no longer references a usable CDB", + candidate.root / ".mcpp" / "ide" / "current.json")); + } + const auto path = candidate.root / "compile_commands.json"; std::error_code ec; const bool exists = std::filesystem::exists(path, ec); @@ -394,7 +423,10 @@ WorkspaceInspection inspect_workspace(InspectRequest request) { "compile_commands.json exists but was not verified", result.workspaceManifest)); if (artifactProbeError) return result; - result.state = hasStale ? SnapshotState::Stale : SnapshotState::Partial; + if (hasMissing) result.state = SnapshotState::Partial; + else if (hasStale) result.state = SnapshotState::Stale; + else if (hasConfigured) result.state = SnapshotState::Configured; + else result.state = SnapshotState::Partial; return result; } diff --git a/src/ide/model.cppm b/src/ide/model.cppm index 65afb381..69b1fe83 100644 --- a/src/ide/model.cppm +++ b/src/ide/model.cppm @@ -25,6 +25,7 @@ std::string_view wire_name(IdePhase phase) { enum class SnapshotState { Partial, + Configured, Stale, Unavailable, Ready, @@ -32,6 +33,7 @@ enum class SnapshotState { enum class ArtifactState { Missing, + Configured, Stale, }; @@ -43,6 +45,7 @@ enum class Severity { std::string_view wire_name(SnapshotState state) { switch (state) { case SnapshotState::Partial: return "partial"; + case SnapshotState::Configured: return "configured"; case SnapshotState::Stale: return "stale"; case SnapshotState::Unavailable: return "unavailable"; case SnapshotState::Ready: return "ready"; @@ -53,6 +56,7 @@ std::string_view wire_name(SnapshotState state) { std::string_view wire_name(ArtifactState state) { switch (state) { case ArtifactState::Missing: return "missing"; + case ArtifactState::Configured: return "configured"; case ArtifactState::Stale: return "stale"; } return {}; @@ -107,6 +111,35 @@ struct ConfigurationSelectors { std::string cppStandard = "c++23"; }; +std::filesystem::path physical_project_root(const std::filesystem::path& root) { + std::error_code ec; + auto absolute = std::filesystem::absolute(root, ec); + if (ec) absolute = root; + auto physical = std::filesystem::weakly_canonical(absolute, ec); + return (ec ? absolute : physical).lexically_normal(); +} + +std::optional +canonical_member_selector(const std::filesystem::path& workspaceRoot, + const std::filesystem::path& projectRoot) { + const auto workspace = physical_project_root(workspaceRoot); + const auto project = physical_project_root(projectRoot); + if (project == workspace) return std::nullopt; + const auto relative = project.lexically_relative(workspace); + if (relative.empty() || relative.is_absolute() || *relative.begin() == "..") + return std::nullopt; + return relative.generic_string(); +} + +std::string project_id(const std::filesystem::path& workspaceRoot) { + std::uint64_t hash = 14695981039346656037ull; + for (const unsigned char byte : physical_project_root(workspaceRoot).generic_string()) { + hash ^= byte; + hash *= 1099511628211ull; + } + return std::format("project-fnv1a64:{:016x}", hash); +} + std::string configuration_id(const std::filesystem::path& workspaceRoot, ConfigurationSelectors selectors, std::string_view toolchainFingerprint) { @@ -124,7 +157,7 @@ std::string configuration_id(const std::filesystem::path& workspaceRoot, canonical += value; canonical.push_back('\x1f'); }; - append("root", workspaceRoot.lexically_normal().generic_string()); + append("root", physical_project_root(workspaceRoot).generic_string()); append("package", selectors.package.value_or("")); append("workspace", selectors.workspace ? "1" : "0"); append("profile", selectors.profile); @@ -168,6 +201,8 @@ struct CompileCommandsArtifact { std::string member; std::filesystem::path path; ArtifactState state = ArtifactState::Missing; + std::optional snapshotId; + std::optional configurationId; }; struct WorkspaceInspection { diff --git a/src/ide/publish.cppm b/src/ide/publish.cppm new file mode 100644 index 00000000..573a2ec2 --- /dev/null +++ b/src/ide/publish.cppm @@ -0,0 +1,326 @@ +// mcpp.ide.publish — atomically persist configured IDE snapshot metadata. +export module mcpp.ide.publish; + +import std; +import mcpp.build.compile_commands; +import mcpp.ide.model; +import mcpp.libs.json; +import mcpp.platform.fs; + +export namespace mcpp::ide { + +struct PublishedSnapshot { + std::string projectId; + std::string configurationId; + std::string snapshotId; + std::string phase = "configured"; + std::filesystem::path projectRoot; + std::filesystem::path compileCommands; + std::filesystem::path compatibilityCompileCommands; + std::string toolchain; + std::string toolchainFingerprint; + std::size_t compileCommandCount = 0; +}; + +namespace detail { + +using Json = nlohmann::ordered_json; + +Json snapshot_json(const PublishedSnapshot& snapshot) { + auto json = Json::object(); + json["schemaVersion"] = 1; + json["kind"] = "mcpp.ide.configured-snapshot"; + json["phase"] = snapshot.phase; + json["projectId"] = snapshot.projectId; + json["configurationId"] = snapshot.configurationId; + json["snapshotId"] = snapshot.snapshotId; + json["projectRoot"] = snapshot.projectRoot.generic_string(); + json["compileCommands"] = snapshot.compileCommands.generic_string(); + json["compatibilityCompileCommands"] = + snapshot.compatibilityCompileCommands.generic_string(); + json["compileCommandCount"] = snapshot.compileCommandCount; + json["toolchain"] = snapshot.toolchain; + json["toolchainFingerprint"] = snapshot.toolchainFingerprint; + return json; +} + +std::uint64_t fnv1a64(std::string_view bytes) { + std::uint64_t hash = 14695981039346656037ull; + for (const unsigned char byte : bytes) { + hash ^= byte; + hash *= 1099511628211ull; + } + return hash; +} + +std::expected +write_bytes_atomically(const std::filesystem::path& path, + std::string_view content, + std::string_view description) { + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) return std::unexpected(std::format( + "cannot create {} directory '{}': {}", description, + path.parent_path().string(), ec.message())); + + auto temp = path; + temp += std::format(".tmp-{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + { + std::ofstream output(temp, std::ios::binary | std::ios::trunc); + if (!output) return std::unexpected(std::format( + "cannot write {} '{}'", description, temp.string())); + output << content; + output.flush(); + if (!output) { + output.close(); + std::filesystem::remove(temp, ec); + return std::unexpected(std::format( + "cannot flush {} '{}'", description, temp.string())); + } + } + + mcpp::platform::fs::replace_file(temp, path, ec); + if (ec) { + const auto publishError = ec; + std::filesystem::remove(temp, ec); + return std::unexpected(std::format( + "cannot publish {} '{}': {}", description, + path.string(), publishError.message())); + } + return {}; +} + +std::expected +write_json_atomically(const std::filesystem::path& path, std::string_view content) { + auto parsed = nlohmann::json::parse(content, nullptr, /*allow_exceptions=*/false); + if (parsed.is_discarded() || !parsed.is_object()) + return std::unexpected("IDE snapshot metadata must be a JSON object"); + return write_bytes_atomically(path, content, "IDE snapshot metadata"); +} + +std::expected, std::string> +read_optional_file(const std::filesystem::path& path) { + std::error_code ec; + const bool exists = std::filesystem::exists(path, ec); + if (ec) return std::unexpected(std::format( + "cannot inspect compatibility CDB '{}': {}", path.string(), ec.message())); + if (!exists) return std::optional{}; + if (!std::filesystem::is_regular_file(path, ec) || ec) { + return std::unexpected(std::format( + "compatibility CDB is not a regular file: '{}'", path.string())); + } + + std::ifstream input(path, std::ios::binary); + if (!input) return std::unexpected(std::format( + "cannot read compatibility CDB '{}'", path.string())); + std::string content(std::istreambuf_iterator(input), {}); + if (input.bad()) return std::unexpected(std::format( + "cannot finish reading compatibility CDB '{}'", path.string())); + return std::optional(std::move(content)); +} + +std::expected +restore_optional_file(const std::filesystem::path& path, + const std::optional& previous) { + if (previous) return write_bytes_atomically( + path, *previous, "previous compatibility CDB"); + + std::error_code ec; + std::filesystem::remove(path, ec); + if (ec) return std::unexpected(std::format( + "cannot remove newly published compatibility CDB '{}': {}", + path.string(), ec.message())); + return {}; +} + +std::expected parse_snapshot(const Json& json) { + const auto string_field = [&](std::string_view key) + -> std::expected { + if (!json.contains(key) || !json[key].is_string() + || json[key].get_ref().empty()) { + return std::unexpected(std::format( + "IDE snapshot metadata has invalid '{}'", key)); + } + return json[key].get(); + }; + + if (!json.is_object() + || !json.contains("schemaVersion") || !json["schemaVersion"].is_number_integer() + || json["schemaVersion"].get() != 1 + || !json.contains("kind") || !json["kind"].is_string() + || json["kind"].get_ref() + != "mcpp.ide.configured-snapshot") { + return std::unexpected("unsupported IDE snapshot metadata"); + } + auto projectId = string_field("projectId"); + auto configurationId = string_field("configurationId"); + auto snapshotId = string_field("snapshotId"); + auto phase = string_field("phase"); + auto projectRoot = string_field("projectRoot"); + auto compileCommands = string_field("compileCommands"); + auto compatibility = string_field("compatibilityCompileCommands"); + auto toolchain = string_field("toolchain"); + auto toolchainFingerprint = string_field("toolchainFingerprint"); + if (!projectId) return std::unexpected(projectId.error()); + if (!configurationId) return std::unexpected(configurationId.error()); + if (!snapshotId) return std::unexpected(snapshotId.error()); + if (!phase) return std::unexpected(phase.error()); + if (!projectRoot) return std::unexpected(projectRoot.error()); + if (!compileCommands) return std::unexpected(compileCommands.error()); + if (!compatibility) return std::unexpected(compatibility.error()); + if (!toolchain) return std::unexpected(toolchain.error()); + if (!toolchainFingerprint) return std::unexpected(toolchainFingerprint.error()); + + std::size_t compileCommandCount = 0; + if (json.contains("compileCommandCount")) { + if (!json["compileCommandCount"].is_number_unsigned()) + return std::unexpected( + "IDE snapshot metadata has invalid 'compileCommandCount'"); + compileCommandCount = json["compileCommandCount"].get(); + } + + return PublishedSnapshot{ + .projectId = std::move(*projectId), + .configurationId = std::move(*configurationId), + .snapshotId = std::move(*snapshotId), + .phase = std::move(*phase), + .projectRoot = std::filesystem::path(std::move(*projectRoot)), + .compileCommands = std::filesystem::path(std::move(*compileCommands)), + .compatibilityCompileCommands = std::filesystem::path(std::move(*compatibility)), + .toolchain = std::move(*toolchain), + .toolchainFingerprint = std::move(*toolchainFingerprint), + .compileCommandCount = compileCommandCount, + }; +} + +std::expected +validate_snapshot_paths(const std::filesystem::path& projectRoot, + const PublishedSnapshot& snapshot) { + const auto physicalRoot = physical_project_root(projectRoot); + if (snapshot.projectRoot.empty() || !snapshot.projectRoot.is_absolute() + || physical_project_root(snapshot.projectRoot) != physicalRoot) { + return std::unexpected( + "configured snapshot project root does not match publication root"); + } + if (snapshot.compileCommands.empty() || !snapshot.compileCommands.is_absolute()) { + return std::unexpected( + "configured snapshot CDB path must be absolute"); + } + if (snapshot.compatibilityCompileCommands.empty() + || !snapshot.compatibilityCompileCommands.is_absolute() + || physical_project_root(snapshot.compatibilityCompileCommands) + != physicalRoot / "compile_commands.json") { + return std::unexpected( + "configured snapshot compatibility CDB does not belong to project root"); + } + + // current.json 不是可信输入;读取时重做与发布时相同的物理路径约束, + // 防止损坏或被修改的 metadata 把 IDE 引向工程外的 CDB。 + const auto repliesRoot = physicalRoot / ".mcpp" / "ide" / "replies"; + const auto physicalCdb = physical_project_root(snapshot.compileCommands); + const auto relativeCdb = physicalCdb.lexically_relative(repliesRoot); + if (relativeCdb.empty() || relativeCdb.is_absolute() + || *relativeCdb.begin() == "..") { + return std::unexpected(std::format( + "configured snapshot CDB escapes IDE replies directory: '{}'", + snapshot.compileCommands.string())); + } + return {}; +} + +} // namespace detail + +std::expected +publish_current_snapshot(const std::filesystem::path& projectRoot, + const PublishedSnapshot& snapshot) { + const auto physicalRoot = physical_project_root(projectRoot); + if (auto valid = detail::validate_snapshot_paths(physicalRoot, snapshot); !valid) + return valid; + + const auto physicalCdb = physical_project_root(snapshot.compileCommands); + std::error_code ec; + if (!std::filesystem::is_regular_file(physicalCdb, ec) || ec) { + return std::unexpected(std::format( + "configured snapshot CDB is unavailable: '{}'", + snapshot.compileCommands.string())); + } + + const auto content = detail::snapshot_json(snapshot).dump(2) + "\n"; + const auto ideRoot = physicalRoot / ".mcpp" / "ide"; + const auto reply = ideRoot / "replies" / std::format( + "snapshot-{:016x}.json", detail::fnv1a64(content)); + if (auto result = detail::write_json_atomically(reply, content); !result) + return result; + + // current.json 是最后一个切换点;此前任何失败都只留下不可见的 reply。 + return detail::write_json_atomically(ideRoot / "current.json", content); +} + +std::expected +publish_configured_snapshot(const std::filesystem::path& projectRoot, + const PublishedSnapshot& snapshot, + std::string_view compileCommandsContent) { + const auto physicalRoot = physical_project_root(projectRoot); + if (auto valid = detail::validate_snapshot_paths(physicalRoot, snapshot); !valid) + return valid; + + // 同一工程的两个 configure 可以并行完成解析,但 mutable 指针只能串行 + // 切换;否则旧内容备份和失败回滚可能覆盖另一个进程刚发布的配置。 + auto publicationLock = mcpp::platform::fs::FileLock::try_acquire( + physicalRoot / ".mcpp" / "ide"); + if (!publicationLock) { + return std::unexpected( + "another IDE snapshot publication is already in progress"); + } + + auto previous = detail::read_optional_file(snapshot.compatibilityCompileCommands); + if (!previous) return std::unexpected(previous.error()); + + if (auto compatibility = mcpp::build::write_fresh_compile_commands( + snapshot.compatibilityCompileCommands, compileCommandsContent); + !compatibility) { + return compatibility; + } + + if (auto current = publish_current_snapshot(projectRoot, snapshot); !current) { + // current.json 是客户端的最终切换点。若它发布失败,根 CDB 也必须 + // 回到调用前状态,避免命令失败却让 clangd 悄悄切到新配置。 + auto restored = detail::restore_optional_file( + snapshot.compatibilityCompileCommands, *previous); + if (!restored) return std::unexpected(std::format( + "{}; additionally failed to restore compatibility CDB: {}", + current.error(), restored.error())); + return current; + } + return {}; +} + +std::expected, std::string> +read_current_snapshot(const std::filesystem::path& projectRoot) { + const auto path = projectRoot / ".mcpp" / "ide" / "current.json"; + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + if (ec) return std::unexpected(std::format( + "cannot inspect IDE snapshot metadata '{}': {}", path.string(), ec.message())); + return std::optional{}; + } + if (!std::filesystem::is_regular_file(path, ec) || ec) { + return std::unexpected(std::format( + "IDE snapshot metadata is not a regular file: '{}'", path.string())); + } + + std::ifstream input(path, std::ios::binary); + if (!input) return std::unexpected(std::format( + "cannot read IDE snapshot metadata '{}'", path.string())); + auto json = detail::Json::parse(input, nullptr, /*allow_exceptions=*/false); + if (json.is_discarded()) return std::unexpected(std::format( + "IDE snapshot metadata is invalid JSON: '{}'", path.string())); + auto parsed = detail::parse_snapshot(json); + if (!parsed) return std::unexpected(parsed.error()); + if (auto valid = detail::validate_snapshot_paths(projectRoot, *parsed); !valid) + return std::unexpected(valid.error()); + return std::optional(std::move(*parsed)); +} + +} // namespace mcpp::ide diff --git a/src/ide/snapshot.cppm b/src/ide/snapshot.cppm index a79421b0..f34845f3 100644 --- a/src/ide/snapshot.cppm +++ b/src/ide/snapshot.cppm @@ -67,6 +67,9 @@ Json artifacts_json(const WorkspaceInspection& inspection) { item["member"] = artifact.member; item["path"] = artifact.path.generic_string(); item["state"] = std::string(wire_name(artifact.state)); + if (artifact.snapshotId) item["snapshotId"] = *artifact.snapshotId; + if (artifact.configurationId) + item["configurationId"] = *artifact.configurationId; compileCommands.push_back(std::move(item)); } auto result = Json::object(); diff --git a/src/platform/fs.cppm b/src/platform/fs.cppm index c0afa2da..d2728a86 100644 --- a/src/platform/fs.cppm +++ b/src/platform/fs.cppm @@ -80,6 +80,12 @@ private: #endif }; +// 不先删除旧目标就完成替换。Windows 的 std::filesystem::rename 不能覆盖 +// 已有文件,因此使用 MoveFileEx 保证发布失败时保留 last-known-good 内容。 +void replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec) noexcept; + } // namespace mcpp::platform::fs // ─── Implementation ────────────────────────────────────────────────────── @@ -222,4 +228,20 @@ FileLock& FileLock::operator=(FileLock&& o) noexcept { #endif +void replace_file(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::error_code& ec) noexcept { + ec.clear(); +#if defined(_WIN32) + // Windows 上 path::value_type 就是 wchar_t;直接使用原生缓冲区,避免 + // noexcept 函数中的 wstring 临时分配抛出异常并触发 terminate。 + if (!MoveFileExW(source.c_str(), destination.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + ec = std::error_code(static_cast(GetLastError()), std::system_category()); + } +#else + std::filesystem::rename(source, destination, ec); +#endif +} + } // namespace mcpp::platform::fs diff --git a/src/xlings.cppm b/src/xlings.cppm index 2fe757ef..153b8cc4 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -1449,19 +1449,29 @@ int update_index(const Env& env, bool quiet) { // Report ONLY when the guard had to act. The common path — refresh keeps // the index readable — must stay silent, or the notice becomes noise that // users learn to skip past, which is the same as not printing it. - if (!quiet) { - for (auto& dir : out.rolledBack) { - print_status("Kept", std::format( - "previous index for `{}` — the refreshed one requires a newer mcpp", - dir.filename().string())); + for (auto& dir : out.rolledBack) { + const auto message = std::format( + "previous index for `{}` — the refreshed one requires a newer mcpp", + dir.filename().string()); + if (quiet) { + // 结构化 IDE stdout 只能包含 NDJSON;安全事件仍必须可见,故在 + // quiet 模式转到 stderr,而不是改变回滚通知本身的语义。 + std::println(stderr, "Kept {}", message); + } else { + print_status("Kept", message); std::println(" Your build continues to work with the packages " "it already describes."); std::println(" Upgrade to pick up newer packages: xlings update mcpp"); } - for (auto& dir : out.recovered) { - print_status("Restored", std::format( - "index `{}` from a local snapshot this mcpp can read", - dir.filename().string())); + } + for (auto& dir : out.recovered) { + const auto message = std::format( + "index `{}` from a local snapshot this mcpp can read", + dir.filename().string()); + if (quiet) { + std::println(stderr, "Restored {}", message); + } else { + print_status("Restored", message); } } for (auto& dir : out.stillUnusable) { diff --git a/tests/e2e/198_ide_configure.sh b/tests/e2e/198_ide_configure.sh index 0f0a8c32..0919ddab 100755 --- a/tests/e2e/198_ide_configure.sh +++ b/tests/e2e/198_ide_configure.sh @@ -14,9 +14,23 @@ source "$(dirname "$0")/_llvm_env.sh" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT PROJECT="$TMP/app" +DEV_PACKAGE="$TMP/test-helper" export MCPP_HOME="$TMP/mcpp-home" source "$(dirname "$0")/_inherit_toolchain.sh" -mkdir -p "$PROJECT/src" +mkdir -p "$PROJECT/src" "$PROJECT/include" "$PROJECT/tests" "$DEV_PACKAGE/include" + +cat >"$DEV_PACKAGE/mcpp.toml" <<'EOF' +[package] +name = "test-helper" +version = "1.0.0" + +[build] +include_dirs = ["include"] +EOF +cat >"$DEV_PACKAGE/include/test_helper.h" <<'EOF' +#pragma once +inline int test_helper_value() { return 1; } +EOF cat >"$PROJECT/mcpp.toml" <"$PROJECT/include/util.h" <<'EOF' +#pragma once +inline bool is_valid(int value) { return value > 0; } +EOF + +cat >"$PROJECT/tests/testisvalid.cpp" <<'EOF' +#include "util.h" +#include "test_helper.h" +int main() { return is_valid(test_helper_value()) ? 0 : 1; } +EOF + set +e (cd "$PROJECT" && "$MCPP" ide configure --format json) \ >"$TMP/invalid-format.out" 2>"$TMP/invalid-format.err" @@ -77,7 +108,7 @@ assert next(iter(operation_ids)).startswith("operation-fnv1a64:"), events published = events[1] assert published["phase"] == "configured" assert published["state"] == "configured" -assert published["compileCommandCount"] >= 1 +assert published["compileCommandCount"] >= 2 std_module = published["stdModule"] assert std_module["state"] == "ready", std_module assert os.path.isfile(std_module["path"]), std_module @@ -92,11 +123,24 @@ assert os.path.isfile(compat_cdb), compat_cdb cdb = json.load(open(snapshot_cdb, encoding="utf-8")) entry = next(item for item in cdb if item["file"].endswith("/src/main.cpp")) +test_entry = next(item for item in cdb if item["file"].endswith("/tests/testisvalid.cpp")) assert os.path.realpath(entry["directory"]) == os.path.realpath(project), ( entry["directory"], project ) assert entry["arguments"][0].endswith("clang++"), entry["arguments"][0] assert any(arg.startswith("-std=c++") for arg in entry["arguments"]), entry["arguments"] +assert any( + arg.startswith("-I") and os.path.realpath(arg[2:]) == os.path.realpath( + os.path.join(project, "include") + ) + for arg in test_entry["arguments"] +), test_entry["arguments"] +assert any( + arg.startswith("-I") and os.path.realpath(arg[2:]) == os.path.realpath( + os.path.join(os.path.dirname(project), "test-helper", "include") + ) + for arg in test_entry["arguments"] +), test_entry["arguments"] std_bmi = next( arg.removeprefix("-fmodule-file=std=") for arg in entry["arguments"] @@ -114,9 +158,73 @@ assert os.path.isfile(std_bmi), std_bmi assert os.path.isfile(std_compat_bmi), std_compat_bmi PY -cat >>"$PROJECT/mcpp.toml" <<'EOF' +(cd "$PROJECT" && "$MCPP" ide snapshot --format json) >"$TMP/snapshot.json" +python3 - "$TMP/snapshot.json" <<'PY' +import json +import sys -[build] +snapshot = json.load(open(sys.argv[1], encoding="utf-8")) +assert snapshot["state"] == "configured", snapshot +artifact = snapshot["artifacts"]["compileCommands"][0] +assert artifact["state"] == "configured", artifact +assert artifact["snapshotId"].startswith("snapshot-fnv1a64:"), artifact +PY + +# 从 workspace selector 和 member 目录配置同一工程,ID 必须规范化一致。 +WORKSPACE="$TMP/workspace" +MEMBER="$WORKSPACE/libs/server" +mkdir -p "$MEMBER/src" +cat >"$WORKSPACE/mcpp.toml" <"$MEMBER/mcpp.toml" <<'EOF' +[package] +name = "workspace-server" +version = "1.0.0" +EOF +cat >"$MEMBER/src/main.cpp" <<'EOF' +int main() { return 0; } +EOF +(cd "$WORKSPACE" && "$MCPP" ide configure -p libs/server --format ndjson) \ + >"$TMP/workspace-configure.ndjson" +(cd "$MEMBER" && "$MCPP" ide configure --format ndjson) \ + >"$TMP/member-configure.ndjson" +python3 - "$TMP/workspace-configure.ndjson" "$TMP/member-configure.ndjson" "$WORKSPACE" <<'PY' +import json +import os +import sys + + +def published(path): + events = [json.loads(line) for line in open(path, encoding="utf-8") if line.strip()] + return next(event for event in events if event["type"] == "snapshot-published") + + +def project_id(path): + value = 0xCBF29CE484222325 + for byte in os.path.realpath(path).encode(): + value ^= byte + value = (value * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF + return f"project-fnv1a64:{value:016x}" + + +from_workspace = published(sys.argv[1]) +from_member = published(sys.argv[2]) +assert from_workspace["projectId"] == project_id(sys.argv[3]), from_workspace +assert from_workspace["projectId"] == from_member["projectId"], ( + from_workspace, from_member +) +assert from_workspace["configurationId"] == from_member["configurationId"], ( + from_workspace, from_member +) +PY + +cat >>"$PROJECT/mcpp.toml" <<'EOF' cxxflags = ["-DIDE_SNAPSHOT_CHANGED=1"] EOF @@ -279,6 +387,36 @@ assert [event["type"] for event in events] == [ ], events PY +# 真实 prepare 失败不能替换根 CDB 或已发布的 current snapshot。 +cp "$PROJECT/compile_commands.json" "$TMP/last-good-cdb.json" +cp "$PROJECT/.mcpp/ide/current.json" "$TMP/last-good-current.json" +cat >"$PROJECT/build.mcpp" <<'EOF' +int main() { return 9; } +EOF +set +e +(cd "$PROJECT" && "$MCPP" ide configure --format ndjson) \ + >"$TMP/build-mcpp-failure.ndjson" 2>"$TMP/build-mcpp-failure.err" +build_mcpp_failure_rc=$? +set -e +if [ "$build_mcpp_failure_rc" -ne 3 ]; then + echo "FAIL: build.mcpp configure failure returned $build_mcpp_failure_rc" >&2 + cat "$TMP/build-mcpp-failure.ndjson" >&2 + cat "$TMP/build-mcpp-failure.err" >&2 + exit 1 +fi +cmp "$TMP/last-good-cdb.json" "$PROJECT/compile_commands.json" +cmp "$TMP/last-good-current.json" "$PROJECT/.mcpp/ide/current.json" +python3 - "$TMP/build-mcpp-failure.ndjson" <<'PY' +import json +import sys + +events = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()] +assert [event["type"] for event in events] == [ + "operation-started", "diagnostic", "operation-finished" +], events +assert events[-1]["status"] == "failed", events +PY + if find "$PROJECT/target" -type f \( -name '*.o' -o -name '*.obj' \) \ ! -path '*/.build-mcpp/*' -print -quit 2>/dev/null | grep -q .; then echo "FAIL: ide configure compiled an ordinary object" >&2 diff --git a/tests/unit/test_ide_configure.cpp b/tests/unit/test_ide_configure.cpp index 12595afe..8e52d261 100644 --- a/tests/unit/test_ide_configure.cpp +++ b/tests/unit/test_ide_configure.cpp @@ -4,7 +4,9 @@ import std; import mcpp.build.plan; import mcpp.ide.configure; import mcpp.ide.model; +import mcpp.ide.publish; import mcpp.libs.json; +import mcpp.platform.fs; import mcpp.toolchain.model; namespace { @@ -248,3 +250,241 @@ TEST(IdeConfigure, RefusesToPublishWhenCachedModuleIsMissing) { std::error_code ec; std::filesystem::remove_all(root, ec); } + +TEST(IdeConfigure, CanonicalizesWorkspaceAndMemberIdentity) { + const auto root = temp_root(); + const auto workspace = root / "workspace"; + const auto member = workspace / "libs" / "server"; + std::filesystem::create_directories(member); + + const auto normalized = mcpp::ide::physical_project_root(workspace / "libs" / ".."); + EXPECT_EQ(normalized, mcpp::ide::physical_project_root(workspace)); + EXPECT_EQ(mcpp::ide::canonical_member_selector(workspace, member), + std::optional("libs/server")); + EXPECT_EQ(mcpp::ide::canonical_member_selector(workspace, workspace), std::nullopt); + EXPECT_EQ(mcpp::ide::project_id(workspace / "libs" / ".."), + mcpp::ide::project_id(workspace)); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, ConvertsThrownExceptionToExpectedFailure) { + auto result = mcpp::ide::run_configure_safely([]() + -> std::expected { + throw std::runtime_error("synthetic configure failure"); + }); + + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().find("synthetic configure failure"), std::string::npos); +} + +TEST(IdeConfigure, PublishesRecoverableConfiguredSnapshot) { + const auto root = temp_root(); + std::filesystem::create_directories(root / ".mcpp" / "ide" / "replies"); + const auto cdb = root / ".mcpp" / "ide" / "replies" / "compile_commands-a.json"; + std::ofstream(cdb) << "[]\n"; + + mcpp::ide::PublishedSnapshot snapshot{ + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-1", + .phase = "configured", + .projectRoot = root, + .compileCommands = cdb, + .compatibilityCompileCommands = root / "compile_commands.json", + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }; + auto published = mcpp::ide::publish_current_snapshot(root, snapshot); + ASSERT_TRUE(published.has_value()) << published.error(); + + auto loaded = mcpp::ide::read_current_snapshot(root); + ASSERT_TRUE(loaded.has_value()) << loaded.error(); + ASSERT_TRUE(loaded->has_value()); + EXPECT_EQ((*loaded)->snapshotId, snapshot.snapshotId); + EXPECT_EQ((*loaded)->configurationId, snapshot.configurationId); + EXPECT_EQ((*loaded)->compileCommands, cdb); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, FailedPublicationKeepsCurrentSnapshot) { + const auto root = temp_root(); + std::filesystem::create_directories(root / ".mcpp" / "ide" / "replies"); + const auto oldCdb = root / ".mcpp" / "ide" / "replies" / "compile_commands-old.json"; + std::ofstream(oldCdb) << "[]\n"; + auto first = mcpp::ide::publish_current_snapshot(root, { + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-old", + .phase = "configured", + .projectRoot = root, + .compileCommands = oldCdb, + .compatibilityCompileCommands = root / "compile_commands.json", + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }); + ASSERT_TRUE(first.has_value()) << first.error(); + + auto failed = mcpp::ide::publish_current_snapshot(root, { + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-new", + .phase = "configured", + .projectRoot = root, + .compileCommands = root / "missing.json", + .compatibilityCompileCommands = root / "compile_commands.json", + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }); + ASSERT_FALSE(failed.has_value()); + + auto current = mcpp::ide::read_current_snapshot(root); + ASSERT_TRUE(current.has_value()) << current.error(); + ASSERT_TRUE(current->has_value()); + EXPECT_EQ((*current)->snapshotId, "snapshot-old"); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, MetadataPublicationFailureRestoresCompatibilityCdb) { + const auto root = temp_root(); + const auto replies = root / ".mcpp" / "ide" / "replies"; + std::filesystem::create_directories(replies); + const auto snapshotCdb = replies / "compile_commands-new.json"; + const auto compatibilityCdb = root / "compile_commands.json"; + const std::string oldContent = R"([{"file":"old.cpp"}])"; + const std::string newContent = R"([{"file":"new.cpp"}])"; + std::ofstream(snapshotCdb) << newContent; + std::ofstream(compatibilityCdb) << oldContent; + + // 用目录占据最终 metadata 路径,跨平台稳定制造第二个切换点失败。 + std::filesystem::create_directories(root / ".mcpp" / "ide" / "current.json"); + auto published = mcpp::ide::publish_configured_snapshot(root, { + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-new", + .phase = "configured", + .projectRoot = root, + .compileCommands = snapshotCdb, + .compatibilityCompileCommands = compatibilityCdb, + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }, newContent); + + ASSERT_FALSE(published.has_value()); + std::ifstream restored(compatibilityCdb, std::ios::binary); + EXPECT_EQ(std::string(std::istreambuf_iterator(restored), {}), oldContent); + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, ConcurrentPublicationCannotChangeCompatibilityCdb) { + const auto root = temp_root(); + const auto replies = root / ".mcpp" / "ide" / "replies"; + std::filesystem::create_directories(replies); + const auto snapshotCdb = replies / "compile_commands-new.json"; + const auto compatibilityCdb = root / "compile_commands.json"; + const std::string oldContent = R"([{"file":"old.cpp"}])"; + const std::string newContent = R"([{"file":"new.cpp"}])"; + std::ofstream(snapshotCdb) << newContent; + std::ofstream(compatibilityCdb) << oldContent; + auto held = mcpp::platform::fs::FileLock::try_acquire(root / ".mcpp" / "ide"); + ASSERT_TRUE(held.has_value()); + + auto published = mcpp::ide::publish_configured_snapshot(root, { + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-new", + .phase = "configured", + .projectRoot = root, + .compileCommands = snapshotCdb, + .compatibilityCompileCommands = compatibilityCdb, + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }, newContent); + + ASSERT_FALSE(published.has_value()); + EXPECT_NE(published.error().find("already in progress"), std::string::npos); + std::ifstream unchanged(compatibilityCdb, std::ios::binary); + EXPECT_EQ(std::string(std::istreambuf_iterator(unchanged), {}), oldContent); + held.reset(); + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, RejectsSnapshotCdbOutsideProject) { + const auto root = temp_root(); + const auto outside = root.parent_path() / std::format( + "mcpp_outside_cdb_{}.json", + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root); + std::ofstream(outside) << "[]\n"; + + auto published = mcpp::ide::publish_current_snapshot(root, { + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-1", + .phase = "configured", + .projectRoot = root, + .compileCommands = outside, + .compatibilityCompileCommands = root / "compile_commands.json", + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }); + + EXPECT_FALSE(published.has_value()); + EXPECT_FALSE(std::filesystem::exists(root / ".mcpp" / "ide" / "current.json")); + std::error_code ec; + std::filesystem::remove(outside, ec); + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, InvalidCurrentMetadataReturnsError) { + const auto root = temp_root(); + std::filesystem::create_directories(root / ".mcpp" / "ide"); + std::ofstream(root / ".mcpp" / "ide" / "current.json") + << R"({"schemaVersion":"wrong","kind":17})"; + + auto current = mcpp::ide::read_current_snapshot(root); + + EXPECT_FALSE(current.has_value()); + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + +TEST(IdeConfigure, CurrentMetadataCannotReferenceCdbOutsideProject) { + const auto root = temp_root(); + const auto outside = root.parent_path() / std::format( + "mcpp_external_current_cdb_{}.json", + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root / ".mcpp" / "ide"); + std::ofstream(outside) << "[]\n"; + std::ofstream(root / ".mcpp" / "ide" / "current.json") + << nlohmann::ordered_json{ + {"schemaVersion", 1}, + {"kind", "mcpp.ide.configured-snapshot"}, + {"phase", "configured"}, + {"projectId", "project-1"}, + {"configurationId", "config-1"}, + {"snapshotId", "snapshot-1"}, + {"projectRoot", root.generic_string()}, + {"compileCommands", outside.generic_string()}, + {"compatibilityCompileCommands", + (root / "compile_commands.json").generic_string()}, + {"compileCommandCount", 0}, + {"toolchain", "llvm@22"}, + {"toolchainFingerprint", "toolchain-1"}, + }.dump(); + + auto current = mcpp::ide::read_current_snapshot(root); + + EXPECT_FALSE(current.has_value()); + if (!current) EXPECT_NE(current.error().find("escapes"), std::string::npos); + std::error_code ec; + std::filesystem::remove(outside, ec); + std::filesystem::remove_all(root, ec); +} diff --git a/tests/unit/test_ide_snapshot.cpp b/tests/unit/test_ide_snapshot.cpp index 76e6c5ce..62727f69 100644 --- a/tests/unit/test_ide_snapshot.cpp +++ b/tests/unit/test_ide_snapshot.cpp @@ -3,6 +3,7 @@ import std; import mcpp.ide.model; import mcpp.ide.inspect; +import mcpp.ide.publish; import mcpp.ide.snapshot; import mcpp.libs.json; import mcpp.manifest; @@ -377,6 +378,33 @@ TEST(IdeSnapshotInspect, ReadOnlyInventory) { EXPECT_EQ(*before, *after); } +TEST(IdeSnapshotInspect, RestoresPublishedConfiguredArtifact) { + TempProject p; + p.write("mcpp.toml", package_manifest()); + p.write("compile_commands.json", "[]\n"); + p.write(".mcpp/ide/replies/compile_commands-a.json", "[]\n"); + auto published = mcpp::ide::publish_current_snapshot(p.root, { + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-1", + .phase = "configured", + .projectRoot = p.root, + .compileCommands = p.root / ".mcpp/ide/replies/compile_commands-a.json", + .compatibilityCompileCommands = p.root / "compile_commands.json", + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }); + ASSERT_TRUE(published.has_value()) << published.error(); + + auto snapshot = mcpp::ide::inspect_workspace({.start = p.root}); + + EXPECT_EQ(snapshot.state, mcpp::ide::SnapshotState::Configured); + ASSERT_EQ(snapshot.compileCommands.size(), 1u); + EXPECT_EQ(snapshot.compileCommands[0].state, mcpp::ide::ArtifactState::Configured); + EXPECT_EQ(snapshot.compileCommands[0].snapshotId, + std::optional("snapshot-1")); +} + TEST(IdeSnapshotSerialize, EmitsCompleteSchemaOneDocument) { auto inspection = fixture_inspection(); inspection.request.selectors = { diff --git a/tests/unit/test_platform_fs.cpp b/tests/unit/test_platform_fs.cpp new file mode 100644 index 00000000..2fc0c98f --- /dev/null +++ b/tests/unit/test_platform_fs.cpp @@ -0,0 +1,63 @@ +#if defined(_WIN32) +#include +#endif + +#include + +import std; +import mcpp.platform.fs; + +namespace { + +std::string read_text(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), {}}; +} + +} // namespace + +TEST(PlatformFs, ReplaceFileOverwritesDestination) { + const auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_replace_file_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root); + const auto source = root / "source.tmp"; + const auto destination = root / "current.json"; + std::ofstream(source) << "new"; + std::ofstream(destination) << "old"; + + std::error_code ec; + mcpp::platform::fs::replace_file(source, destination, ec); + + EXPECT_FALSE(ec) << ec.message(); + EXPECT_EQ(read_text(destination), "new"); + EXPECT_FALSE(std::filesystem::exists(source)); + std::filesystem::remove_all(root, ec); +} + +#if defined(_WIN32) +TEST(PlatformFs, FailedReplacePreservesDestination) { + const auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_replace_locked_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root); + const auto source = root / "source.tmp"; + const auto destination = root / "current.json"; + std::ofstream(source) << "new"; + std::ofstream(destination) << "old"; + + // 禁止 FILE_SHARE_DELETE,稳定制造 Windows 原子替换失败。 + HANDLE locked = CreateFileW(destination.wstring().c_str(), GENERIC_READ, + FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + ASSERT_NE(locked, INVALID_HANDLE_VALUE); + + std::error_code ec; + mcpp::platform::fs::replace_file(source, destination, ec); + CloseHandle(locked); + + EXPECT_TRUE(ec); + EXPECT_EQ(read_text(destination), "old"); + EXPECT_EQ(read_text(source), "new"); + std::filesystem::remove_all(root, ec); +} +#endif diff --git a/tests/unit/test_pm_index_refresh.cpp b/tests/unit/test_pm_index_refresh.cpp index da789e35..9e8b2eb6 100644 --- a/tests/unit/test_pm_index_refresh.cpp +++ b/tests/unit/test_pm_index_refresh.cpp @@ -166,7 +166,7 @@ TEST(PmIndexRefresh, OfflineDoesNotMisreportAResolvableDependency) { RefreshReason::None); } -TEST(PmIndexRefresh, QuietIndexRecoveryDoesNotWriteToStdout) { +TEST(PmIndexRefresh, QuietIndexRecoveryKeepsSafetyNoticeOffProtocolStdout) { FakeRegistry reg("quiet-recovery"); const auto idx = reg.index_dir(); auto write_index = [&](std::string_view floor, std::string_view marker) { @@ -182,11 +182,14 @@ TEST(PmIndexRefresh, QuietIndexRecoveryDoesNotWriteToStdout) { mcpp::platform::env::ScopedEnv offline("MCPP_OFFLINE", "1"); testing::internal::CaptureStdout(); + testing::internal::CaptureStderr(); const auto rc = mcpp::xlings::update_index(reg.env(), /*quiet=*/true); const auto output = testing::internal::GetCapturedStdout(); + const auto errors = testing::internal::GetCapturedStderr(); EXPECT_EQ(rc, 0); EXPECT_TRUE(output.empty()) << output; + EXPECT_NE(errors.find("Restored"), std::string::npos) << errors; } TEST(PmIndexRefresh, AutoRefreshOptOutSuppressesAGenuineMiss) { From 50daf5095cc1a5faa9afce2c9258c516f28031c8 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:48:42 +0800 Subject: [PATCH 23/26] fix(test): preserve best-effort test inventory --- src/build/test_targets.cppm | 33 +++++++++++++++++++------------- tests/unit/test_test_options.cpp | 25 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index a810be2f..51441aa7 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -17,17 +17,20 @@ std::expected discover_test_targets(const std::filesystem::path& manifestRoot, std::string_view packageFilter = {}) { auto rootManifest = mcpp::manifest::load(manifestRoot / "mcpp.toml"); - if (!rootManifest) return std::unexpected(rootManifest.error().format()); + auto testRoot = manifestRoot; + std::optional manifest; + if (rootManifest) { + auto member = mcpp::project::resolve_member_dir( + *rootManifest, manifestRoot, packageFilter); + if (!member) return std::unexpected(member.error()); + testRoot = member->empty() ? manifestRoot : *member; - auto member = mcpp::project::resolve_member_dir( - *rootManifest, manifestRoot, packageFilter); - if (!member) return std::unexpected(member.error()); - const auto testRoot = member->empty() ? manifestRoot : *member; - - auto manifest = member->empty() - ? std::move(rootManifest) - : mcpp::manifest::load(testRoot / "mcpp.toml"); - if (!manifest) return std::unexpected(manifest.error().format()); + if (member->empty()) { + manifest.emplace(std::move(*rootManifest)); + } else if (auto loaded = mcpp::manifest::load(testRoot / "mcpp.toml")) { + manifest.emplace(std::move(*loaded)); + } + } const auto testFiles = mcpp::modgraph::expand_glob(testRoot, "tests/**/*.cpp"); if (testFiles.empty()) return TestTargetDiscovery{.root = testRoot}; @@ -38,9 +41,13 @@ discover_test_targets(const std::filesystem::path& manifestRoot, std::set files; }; std::vector globFlags; - for (const auto& flags : manifest->buildConfig.globFlags) { - auto files = mcpp::modgraph::expand_glob(testRoot, flags.glob); - globFlags.push_back({flags, {files.begin(), files.end()}}); + // inventory 可在损坏源码/manifest 上工作;只有成功解析时才附加 + // `[build].flags`,真正 build/configure 随后仍由 prepare_build 严格校验。 + if (manifest) { + for (const auto& flags : manifest->buildConfig.globFlags) { + auto files = mcpp::modgraph::expand_glob(testRoot, flags.glob); + globFlags.push_back({flags, {files.begin(), files.end()}}); + } } TestTargetDiscovery result{.root = testRoot}; diff --git a/tests/unit/test_test_options.cpp b/tests/unit/test_test_options.cpp index a87ee31f..8d17a3a2 100644 --- a/tests/unit/test_test_options.cpp +++ b/tests/unit/test_test_options.cpp @@ -3,6 +3,7 @@ import std; import mcpp.build.backend; import mcpp.build.execute; +import mcpp.build.test_targets; using namespace mcpp::build; @@ -68,3 +69,27 @@ TEST(TestRunSummary, DefaultsAreZeroAndPrintable) { EXPECT_EQ(s.elapsedMs, 0); EXPECT_FALSE(s.packageError); } + +// `mcpp test --list` 和“没有测试”路径不进入 prepare_build;发现阶段若把 +// manifest 解析改成强制成功,就会让原本无需构建的查询产生行为回归。 +TEST(TestTargetDiscovery, InvalidManifestStillAllowsBestEffortInventory) { + const auto root = std::filesystem::temp_directory_path() + / std::format("mcpp_test_discovery_{}", + std::chrono::steady_clock::now().time_since_epoch().count()); + std::filesystem::create_directories(root / "tests"); + std::ofstream(root / "mcpp.toml") << "[package\n"; + std::ofstream(root / "tests" / "smoke.cpp") << "this need not compile"; + + auto discovery = discover_test_targets(root); + EXPECT_TRUE(discovery.has_value()) + << (discovery ? "" : discovery.error()); + if (discovery) { + ASSERT_EQ(discovery->targets.size(), 1u); + EXPECT_EQ(discovery->targets.front().name, "smoke"); + EXPECT_EQ(discovery->targets.front().main, + (std::filesystem::path("tests") / "smoke.cpp").string()); + } + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} From 10c81429da2b34277a008ad3145516de62284d71 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:48:42 +0800 Subject: [PATCH 24/26] fix(ide): distinguish publication lock failures --- src/ide/publish.cppm | 9 +++++++- src/platform/fs.cppm | 38 +++++++++++++++++++++++++++---- tests/unit/test_ide_configure.cpp | 27 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/ide/publish.cppm b/src/ide/publish.cppm index 573a2ec2..eb29c1b6 100644 --- a/src/ide/publish.cppm +++ b/src/ide/publish.cppm @@ -267,9 +267,16 @@ publish_configured_snapshot(const std::filesystem::path& projectRoot, // 同一工程的两个 configure 可以并行完成解析,但 mutable 指针只能串行 // 切换;否则旧内容备份和失败回滚可能覆盖另一个进程刚发布的配置。 + const auto publicationDir = physicalRoot / ".mcpp" / "ide"; + std::error_code lockEc; auto publicationLock = mcpp::platform::fs::FileLock::try_acquire( - physicalRoot / ".mcpp" / "ide"); + publicationDir, lockEc); if (!publicationLock) { + if (lockEc) { + return std::unexpected(std::format( + "cannot acquire IDE publication lock '{}': {}", + (publicationDir / ".lock").string(), lockEc.message())); + } return std::unexpected( "another IDE snapshot publication is already in progress"); } diff --git a/src/platform/fs.cppm b/src/platform/fs.cppm index d2728a86..e97ba9c6 100644 --- a/src/platform/fs.cppm +++ b/src/platform/fs.cppm @@ -8,6 +8,7 @@ // 3. FileLock — RAII exclusive file lock (was in bmi_cache.cppm) module; +#include #include #include #if defined(_WIN32) @@ -62,6 +63,9 @@ public: // Try to acquire an exclusive lock on /.lock. // Returns nullopt if another process holds the lock. static std::optional try_acquire(const std::filesystem::path& dir); + // `ec` 为空表示锁竞争;非空表示目录、文件或系统锁操作失败。 + static std::optional try_acquire(const std::filesystem::path& dir, + std::error_code& ec); ~FileLock(); FileLock(FileLock&& o) noexcept; @@ -147,17 +151,30 @@ std::optional which(std::string_view binary_name) { #if defined(_WIN32) std::optional FileLock::try_acquire(const std::filesystem::path& dir) { - std::error_code ec; + std::error_code ignored; + return try_acquire(dir, ignored); +} + +std::optional FileLock::try_acquire(const std::filesystem::path& dir, + std::error_code& ec) { + ec.clear(); std::filesystem::create_directories(dir, ec); + if (ec) return std::nullopt; auto lockPath = dir / ".lock"; HANDLE h = CreateFileW(lockPath.wstring().c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); - if (h == INVALID_HANDLE_VALUE) return std::nullopt; + if (h == INVALID_HANDLE_VALUE) { + ec = std::error_code(static_cast(GetLastError()), std::system_category()); + return std::nullopt; + } OVERLAPPED ov = {}; if (!LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &ov)) { + const auto error = GetLastError(); CloseHandle(h); + if (error != ERROR_LOCK_VIOLATION) + ec = std::error_code(static_cast(error), std::system_category()); return std::nullopt; } return FileLock{h}; @@ -191,13 +208,26 @@ FileLock& FileLock::operator=(FileLock&& o) noexcept { #else // POSIX std::optional FileLock::try_acquire(const std::filesystem::path& dir) { - std::error_code ec; + std::error_code ignored; + return try_acquire(dir, ignored); +} + +std::optional FileLock::try_acquire(const std::filesystem::path& dir, + std::error_code& ec) { + ec.clear(); std::filesystem::create_directories(dir, ec); + if (ec) return std::nullopt; auto lockPath = dir / ".lock"; int fd = ::open(lockPath.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644); - if (fd < 0) return std::nullopt; + if (fd < 0) { + ec = std::error_code(errno, std::generic_category()); + return std::nullopt; + } if (::flock(fd, LOCK_EX | LOCK_NB) != 0) { + const auto error = errno; ::close(fd); + if (error != EWOULDBLOCK && error != EAGAIN) + ec = std::error_code(error, std::generic_category()); return std::nullopt; } return FileLock{fd}; diff --git a/tests/unit/test_ide_configure.cpp b/tests/unit/test_ide_configure.cpp index 8e52d261..194ba87f 100644 --- a/tests/unit/test_ide_configure.cpp +++ b/tests/unit/test_ide_configure.cpp @@ -416,6 +416,33 @@ TEST(IdeConfigure, ConcurrentPublicationCannotChangeCompatibilityCdb) { std::filesystem::remove_all(root, ec); } +TEST(IdeConfigure, PublicationIoFailureIsNotReportedAsLockContention) { + const auto root = temp_root(); + std::filesystem::create_directories(root / ".mcpp"); + // 用普通文件占据锁目录,跨平台稳定制造锁初始化 I/O 失败。 + std::ofstream(root / ".mcpp" / "ide") << "not a directory"; + + auto published = mcpp::ide::publish_configured_snapshot(root, { + .projectId = "project-1", + .configurationId = "config-1", + .snapshotId = "snapshot-new", + .phase = "configured", + .projectRoot = root, + .compileCommands = root / ".mcpp" / "ide" / "replies" / "cdb.json", + .compatibilityCompileCommands = root / "compile_commands.json", + .toolchain = "llvm@22", + .toolchainFingerprint = "toolchain-1", + }, "[]"); + + ASSERT_FALSE(published.has_value()); + EXPECT_NE(published.error().find("cannot acquire IDE publication lock"), + std::string::npos); + EXPECT_EQ(published.error().find("already in progress"), std::string::npos); + + std::error_code ec; + std::filesystem::remove_all(root, ec); +} + TEST(IdeConfigure, RejectsSnapshotCdbOutsideProject) { const auto root = temp_root(); const auto outside = root.parent_path() / std::format( From 0e519e0a1cf08529c84a24a13170afc3210d4e97 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:48:42 +0800 Subject: [PATCH 25/26] docs(ide): document protocol and client lifecycle --- docs/11-ide-protocol.md | 260 +++++++++++++++++++++++++++++++++++++ docs/README.md | 1 + docs/zh/11-ide-protocol.md | 225 ++++++++++++++++++++++++++++++++ docs/zh/README.md | 1 + 4 files changed, 487 insertions(+) create mode 100644 docs/11-ide-protocol.md create mode 100644 docs/zh/11-ide-protocol.md diff --git a/docs/11-ide-protocol.md b/docs/11-ide-protocol.md new file mode 100644 index 00000000..8faef9f2 --- /dev/null +++ b/docs/11-ide-protocol.md @@ -0,0 +1,260 @@ +# 11 - IDE Protocol + +This document describes the IDE protocol implemented by the current `mcpp` +binary. It is intended for IDE integrations, language-tool launchers and other +machine clients. The protocol is separate from the human-oriented CLI output; +clients must request and parse the documented format instead of scraping +progress text. + +## 1. Scope and compatibility + +The public commands are: + +```text +mcpp ide snapshot [selectors] +mcpp ide configure [selectors] +``` + +Both commands currently use schema version `1`. For a snapshot response, clients +should check `schemaVersion`, `kind` and the `mcpp.protocol` range; for configure +events, clients should validate `schemaVersion`, `type`, `seq` and +`operationId`. Unknown fields must be ignored. `events`, `model`, `inspect` and +`publish` are internal C++ modules, not public CLI subcommands. The future `prepare`, +configuration index, cancellation and progress protocol are not implemented. + +The project root is found by searching upward from the current directory for +`mcpp.toml`; the commands do not currently accept a project path argument. + +## 2. Selectors + +Both commands accept the following options: + +| Option | Meaning | +| --- | --- | +| `-p, --package MEMBER` | Select one member by workspace-relative path or directory basename. | +| `--workspace` | `snapshot`: inspect every member. `configure`: currently rejected. | +| `--profile NAME` | Request a build profile. | +| `--target TRIPLE` | Request a target triple. | +| `--features LIST` | Comma-separated feature selectors. | +| `--cap LIST` | Comma-separated capability-provider pins. | +| `--include-dev-dependencies` | Include development dependencies explicitly. | +| `--format FORMAT` | `snapshot` accepts `json`; `configure` accepts `ndjson`. | + +CSV selectors discard empty items but do not trim whitespace. There is no +`--offline` selector in this protocol. + +When `configure` discovers `tests/**/*.cpp`, it automatically enables +development dependencies even without `--include-dev-dependencies`. This makes +test translation units use the same include paths and defines as a real test +build. + +## 3. Read-only snapshot + +`mcpp ide snapshot` defaults to `--format json` and does not resolve +dependencies, write project files or publish a CDB. A valid inspection exits `0` for +`partial`, `stale` or `configured`; an unavailable inspection exits `3`. + +The top-level shape is: + +```json +{ + "schemaVersion": 1, + "kind": "mcpp.ide.snapshot", + "snapshotId": "fnv1a64:...", + "state": "partial", + "mcpp": { + "version": "2026.8.7.1", + "protocol": {"min": 1, "max": 1}, + "capabilities": [ + "workspace-inspection", + "manifest-diagnostics", + "compile-commands-location" + ] + }, + "request": {"root": ".", "mode": "read-only", "selectors": {}}, + "workspace": {"root": ".", "manifest": "...", "members": [], "selectedMembers": []}, + "artifacts": {"state": "partial", "compileCommands": []}, + "diagnostics": [] +} +``` + +`workspace.members[]` describes each parsed package with `name`, `version`, +`workspacePath`, `root`, `manifest` and `targets[]`. A target contains `name`, +`kind` (`library`, `shared-library`, `binary` or `test-binary`) and, when +available, `main`. + +Each `artifacts.compileCommands[]` entry contains `member`, `path`, `state` +(`missing`, `configured` or `stale`) and optional `snapshotId` and +`configurationId` values. + +The inspection state is aggregated as follows: + +```text +missing artifacts -> partial +unverified regular root CDB -> stale +readable configured snapshot and reply CDB -> configured +``` + +`ready` is reserved for a future artifact-prepared state and is not currently +produced. + +Diagnostics contain `code`, `severity`, `message`, `source: "mcpp"`, and may +include `path` and a zero-based `range` with `line` and `column` positions. +Known codes include `MCPP_IDE_MANIFEST_NOT_FOUND`, +`MCPP_IDE_MANIFEST_INVALID`, `MCPP_IDE_MEMBER_MANIFEST_MISSING`, +`MCPP_IDE_MEMBER_MANIFEST_INVALID`, `MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND`, +`MCPP_IDE_ARTIFACTS_MISSING`, `MCPP_IDE_ARTIFACTS_UNVERIFIED`, +`MCPP_IDE_ARTIFACTS_UNAVAILABLE`, `MCPP_IDE_SNAPSHOT_INVALID`, +`MCPP_IDE_SNAPSHOT_STALE` and `MCPP_IDE_UNSUPPORTED_FORMAT`. + +The snapshot inspection checks metadata, project-root containment and CDB +readability. It does not recompute the manifest, lockfile, source set, +selectors or toolchain fingerprint. Therefore `configured` means that the last +published configured snapshot remains readable, not that the current inputs +have been revalidated. Clients should run `configure` after those inputs change. + +## 4. Configure and NDJSON events + +`mcpp ide configure` defaults to `--format ndjson`. Every stdout line is one +JSON object. A successful invocation emits, in order: + +```text +operation-started +snapshot-published +operation-finished (success) +``` + +A failed invocation emits: + +```text +operation-started +diagnostic +operation-finished (failed) +``` + +All events have a common envelope: + +```json +{ + "schemaVersion": 1, + "seq": 2, + "type": "snapshot-published", + "operationId": "operation-fnv1a64:..." +} +``` + +`seq` starts at `1` and increases strictly. All events for one invocation use +the same `operationId`. + +The `operation-started` event identifies `operation: "configure"`. The +`snapshot-published` event includes `phase`, `state`, `projectId`, +`configurationId`, `snapshotId`, the content-addressed `compileCommands` path, +the compatibility CDB path, `compileCommandCount`, `toolchain` and +`toolchainFingerprint`. It may include: + +```json +"stdModule": { + "kind": "std-module", + "path": "...", + "state": "ready" +} +``` + +The successful `operation-finished` event includes `status: "success"`, +`operation`, `phase` and `configurationId`. A failure has a diagnostic event +with code `MCPP_IDE_CONFIGURE_FAILED`, followed by a finished event with +`status: "failed"` and `diagnosticCodes`. + +Configure returns `0` after publication, `2` for a format error (before the +operation starts), and `3` for manifest, resolution, selector, tool, staging, +publication or unexpected failures. Cancellation, timeout and exit code `130` +are not protocol features yet. + +## 5. IDs and published files + +The IDs have different scopes: + +- `projectId` identifies the physical workspace root. +- `configurationId` identifies normalized selectors, resolved profile/target, + cache mode, language standard and toolchain fingerprint. +- `snapshotId` identifies the configured CDB publication and its provenance. + The read-only snapshot's top-level `fnv1a64:*` is an inspection document ID; + it is not the same ID type as the configured `snapshot-*` value. + +After a successful configure, the relevant files are: + +```text +/.mcpp/ide/replies/compile_commands-.json +/.mcpp/ide/replies/snapshot-.json +/.mcpp/ide/current.json +/compile_commands.json +/.mcpp/ide/.lock +``` + +The reply CDB is the protocol source of truth. The root CDB is a compatibility +projection for existing clangd clients. `current.json` records +`schemaVersion`, `kind: "mcpp.ide.configured-snapshot"`, phase, IDs, project +root, both CDB paths, command count and toolchain identity. + +Each individual JSON file is written through a temporary file and replacement. +The root CDB and `current.json` are separate replacement points, not one +cross-file operating-system transaction. Normal publication errors attempt to +restore the previous root CDB; a process crash between the two replacements can +still leave a new CDB alongside old metadata. + +## 6. What configure does + +Configure resolves the project and produces a CDB from the resolved `BuildPlan` +and compile flags. It does not compile ordinary project translation units or +link the final executable. It may nevertheless parse dependencies and the +toolchain, prepare a `build.mcpp` host tool, create or update caches, discover +tests, and stage standard-library or cached dependency BMIs. + +The CDB includes ordinary sources and discovered test sources. Cached module +prerequisites are staged before the CDB is published. Uncached project or +dependency module BMIs are not fully built by this command, so module completion +may remain pending until a normal build or a future prepare command. Staging a +GCC `.gcm` or MSVC `.ifc` file does not promise that clangd can consume that +toolchain's module format. + +`mcpp ide configure --workspace` is currently rejected. Select one member with +`--package` and run configure separately for each member. + +## 7. Client lifecycle + +The following behavior belongs in the IDE client and does not require another +mcpp protocol command: + +1. Run `snapshot --format json` to discover members and existing artifacts. +2. For a workspace, use `workspace.members[].workspacePath` and run one + `configure --package ` operation per member. Do not pass + `--workspace` to configure. Serialize operations per member, or use bounded + concurrency across independent members. +3. Reconfigure after the root/member `mcpp.toml`, `mcpp.lock`, `build.mcpp`, + source-set/module declarations, selected profile/target/features/capabilities + or toolchain selection changes. Debounce file events and reconfigure after an + mcpp dependency or toolchain command completes. A plain source-content edit + that does not change the source set or module graph does not require a new CDB. +4. Validate `seq`, correlate events by `operationId`, and ignore events from a + superseded operation. The protocol has no cancellation yet, so the client + should not start overlapping configure operations for the same member. +5. Treat `snapshot-published` as the publication boundary. The reply CDB is the + protocol source of truth; a language server that requires a literal + `compile_commands.json` may use `compatibilityCompileCommands`, but only after + that event from the same operation. On startup or recovery, rerun configure + instead of trusting a root CDB merely because it exists. +6. If configure fails, keep the previous usable clangd configuration, surface + the structured diagnostic and mark it stale. A contention diagnostic may be + retried with bounded backoff; an I/O/path diagnostic should be shown to the + user instead of being retried as contention. +7. Do not interpret `configured` as full module readiness. Start clangd for the + available TUs, show module support as pending when required BMIs are absent, + and offer a normal `mcpp build` (or `mcpp test` for test-only preparation) when + the user requests complete module semantics. Reconfigure after that command + finishes. Do not start a full build silently during workspace discovery. +8. Keep stdout reserved for the requested JSON protocol. Human diagnostics may + appear on stderr; clients should not parse them as events. + +The protocol is intentionally one-shot today. Daemon mode, cancellation, +workspace fan-out, active freshness recomputation, a ready artifact snapshot and +last-known-good indexes remain future extensions. diff --git a/docs/README.md b/docs/README.md index 4d8637f0..1930e1b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [08 - Toolchain Internals](08-toolchain-internals.md) - [09 - Releasing mcpp](09-release.md) - [10 - Publishing a Library to mcpp-index](10-publishing-a-library.md) +- [11 - IDE Protocol](11-ide-protocol.md) ## Specifications diff --git a/docs/zh/11-ide-protocol.md b/docs/zh/11-ide-protocol.md new file mode 100644 index 00000000..3bf39514 --- /dev/null +++ b/docs/zh/11-ide-protocol.md @@ -0,0 +1,225 @@ +# 11 — IDE 协议 + +本文描述当前 `mcpp` 二进制已经实现的 IDE 协议,面向 IDE 插件、语言工具 +启动器及其他机器客户端。协议输出与面向人的 CLI 输出分离;客户端应请求并 +解析下面的格式,不要抓取进度文本。 + +## 1. 范围与兼容性 + +当前公开的命令只有: + +```text +mcpp ide snapshot [selectors] +mcpp ide configure [selectors] +``` + +两个命令当前都使用 schema 版本 `1`。对于 snapshot 响应,客户端应先检查 +`schemaVersion`、`kind` 和 `mcpp.protocol` 范围;对于 configure 事件,应验证 +`schemaVersion`、`type`、`seq` 和 `operationId`。未知字段必须忽略。 +`events`、`model`、`inspect`、`publish` 是内部 C++ module,不是 CLI 子命令。设计中的 `prepare`、 +配置索引、取消和进度协议尚未实现。 + +命令从当前目录向父目录查找 `mcpp.toml`,目前不接受项目路径参数。 + +## 2. Selector + +两个命令都支持以下选项: + +| 选项 | 含义 | +| --- | --- | +| `-p, --package MEMBER` | 按工作空间相对路径或目录 basename 选择一个成员。 | +| `--workspace` | `snapshot` 检查全部成员;`configure` 当前拒绝。 | +| `--profile NAME` | 请求构建 profile。 | +| `--target TRIPLE` | 请求 target triple。 | +| `--features LIST` | 逗号分隔的 feature selector。 | +| `--cap LIST` | 逗号分隔的 capability provider pin。 | +| `--include-dev-dependencies` | 显式纳入开发依赖。 | +| `--format FORMAT` | `snapshot` 只接受 `json`;`configure` 只接受 `ndjson`。 | + +CSV selector 会丢弃空项,但不会去除空格。当前没有 `--offline` selector。 + +`configure` 发现 `tests/**/*.cpp` 后,即使没有显式指定 +`--include-dev-dependencies`,也会自动启用开发依赖,保证测试 TU 使用与真实 +测试构建一致的 include 路径和宏定义。 + +## 3. 只读 snapshot + +`mcpp ide snapshot` 默认使用 `--format json`,不会解析依赖、安装依赖、写项目 +文件,也不会发布 CDB。`partial`、`stale`、`configured` 状态返回 `0`;不可用 +状态返回 `3`。 + +顶层结构如下: + +```json +{ + "schemaVersion": 1, + "kind": "mcpp.ide.snapshot", + "snapshotId": "fnv1a64:...", + "state": "partial", + "mcpp": { + "version": "2026.8.7.1", + "protocol": {"min": 1, "max": 1}, + "capabilities": ["workspace-inspection", "manifest-diagnostics", "compile-commands-location"] + }, + "request": {"root": ".", "mode": "read-only", "selectors": {}}, + "workspace": {"root": ".", "manifest": "...", "members": [], "selectedMembers": []}, + "artifacts": {"state": "partial", "compileCommands": []}, + "diagnostics": [] +} +``` + +`workspace.members[]` 描述已经成功解析的包,包含 `name`、`version`、 +`workspacePath`、`root`、`manifest` 和 `targets[]`。target 包含 `name`、 +`kind`(`library`、`shared-library`、`binary` 或 `test-binary`),以及可选 +的 `main`。 + +`artifacts.compileCommands[]` 包含 `member`、`path`、`state`(`missing`、 +`configured` 或 `stale`),以及可选的 `snapshotId` 和 `configurationId`。 + +状态聚合规则为: + +```text +缺少 artifact -> partial +存在但未经验证的根 CDB -> stale +current.json 有效且 reply CDB 可读 -> configured +``` + +`ready` 只是为未来“artifact 已准备好”状态预留,当前不会生成。 + +诊断包含 `code`、`severity`、`message`、`source: "mcpp"`,并可包含 `path` 和 +带 `line`、`column` 的零基 `range`。已知 code 包括: +`MCPP_IDE_MANIFEST_NOT_FOUND`、`MCPP_IDE_MANIFEST_INVALID`、 +`MCPP_IDE_MEMBER_MANIFEST_MISSING`、`MCPP_IDE_MEMBER_MANIFEST_INVALID`、 +`MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND`、`MCPP_IDE_ARTIFACTS_MISSING`、 +`MCPP_IDE_ARTIFACTS_UNVERIFIED`、`MCPP_IDE_ARTIFACTS_UNAVAILABLE`、 +`MCPP_IDE_SNAPSHOT_INVALID`、`MCPP_IDE_SNAPSHOT_STALE` 和 +`MCPP_IDE_UNSUPPORTED_FORMAT`。 + +只读检查会验证 metadata、项目根路径约束和 CDB 可读性,但不会重新计算 +manifest、lockfile、源码集合、selector 或工具链 fingerprint。因此 +`configured` 只表示“上一次发布的 configured snapshot 仍可读”,不表示当前输入 +已经重新验证。输入变化后插件应再次运行 `configure`。 + +## 4. Configure 与 NDJSON 事件 + +`mcpp ide configure` 默认使用 `--format ndjson`,stdout 的每一行都是一个独立 +JSON 对象。成功顺序固定为: + +```text +operation-started +snapshot-published +operation-finished (success) +``` + +失败顺序为: + +```text +operation-started +diagnostic +operation-finished (failed) +``` + +所有事件都包含以下 envelope: + +```json +{ + "schemaVersion": 1, + "seq": 2, + "type": "snapshot-published", + "operationId": "operation-fnv1a64:..." +} +``` + +`seq` 从 `1` 开始严格递增;同一次调用的所有事件共享 `operationId`。 + +`operation-started` 包含 `operation: "configure"`。`snapshot-published` 包含 +`phase`、`state`、`projectId`、`configurationId`、`snapshotId`、内容寻址的 +`compileCommands` 路径、兼容 CDB 路径、`compileCommandCount`、`toolchain` 和 +`toolchainFingerprint`,还可能包含: + +```json +"stdModule": { + "kind": "std-module", + "path": "...", + "state": "ready" +} +``` + +成功的 `operation-finished` 包含 `status: "success"`、`operation`、`phase` 和 +`configurationId`。失败会先发出 code 为 `MCPP_IDE_CONFIGURE_FAILED` 的诊断, +再发出 `status: "failed"` 和 `diagnosticCodes`。 + +Configure 成功发布返回 `0`;格式错误在 operation 开始前返回 `2`;manifest、 +解析、selector、工具、stage、发布或未预期异常返回 `3`。取消、超时和 `130` +退出语义尚未实现。 + +## 5. ID 与发布文件 + +三类 ID 的范围不同: + +- `projectId`:物理工作空间根目录。 +- `configurationId`:规范化 selector、解析后的 profile/target、cache mode、 + language standard 和工具链 fingerprint。 +- `snapshotId`:configured CDB 发布及其 provenance。只读 snapshot 顶层的 + `fnv1a64:*` 是 inspection 文档 ID,不是 configured `snapshot-*` ID。 + +成功 configure 后相关文件为: + +```text +/.mcpp/ide/replies/compile_commands-.json +/.mcpp/ide/replies/snapshot-.json +/.mcpp/ide/current.json +/compile_commands.json +/.mcpp/ide/.lock +``` + +reply CDB 是协议的事实来源;根目录 CDB 是供既有 clangd 使用的兼容投影。 +`current.json` 记录 `schemaVersion`、`kind: "mcpp.ide.configured-snapshot"`、 +phase、各 ID、项目根、两个 CDB 路径、命令数和工具链身份。 + +每个 JSON 文件都通过临时文件和替换写出。根 CDB 与 `current.json` 是两个独立 +替换点,不是一个跨文件操作系统事务。普通发布错误会尝试恢复旧根 CDB;如果 +进程恰好在两个替换点之间崩溃,仍可能短暂留下新 CDB 与旧 metadata 不一致。 + +## 6. Configure 做什么 + +Configure 从解析后的 `BuildPlan` 和 compile flags 生成 CDB,不编译普通项目 TU, +也不链接最终可执行文件。但它可能解析依赖和工具链、准备 `build.mcpp` 宿主 +工具、创建或更新缓存、发现测试,以及 stage 标准库或已命中的依赖 BMI。 + +CDB 包含普通源码和发现到的测试源码;发布前会 stage 已缓存的模块前置产物。未 +命中的工程或依赖模块 BMI 不会由此命令完整生成,因此模块补全可能要等普通 build +或未来的 prepare 命令。stage GCC `.gcm` 或 MSVC `.ifc` 不代表 clangd 能消费这些 +工具链的模块格式。 + +`mcpp ide configure --workspace` 当前明确拒绝。请使用 `--package`,逐个成员 +执行 configure。 + +## 7. 客户端生命周期 + +以下行为属于 IDE 插件职责,不需要继续增加 mcpp 协议命令: + +1. 先运行 `snapshot --format json`,发现成员和已有 artifact。 +2. 工作空间使用 `workspace.members[].workspacePath`,逐成员运行 + `configure --package `;不要把 `--workspace` 传给 configure。 + 同一成员内串行执行,不同成员最多使用有限并发。 +3. 根/成员 `mcpp.toml`、`mcpp.lock`、`build.mcpp`、源码集合或 module 声明、 + profile/target/features/capabilities selector、工具链选择发生变化时重新 configure。 + 文件事件应 debounce;插件执行完 mcpp 依赖或工具链命令后也应重新 configure。 + 不改变源码集合或 module graph 的普通源码内容编辑不需要重建 CDB。 +4. 校验 `seq`,用 `operationId` 关联事件,忽略已经被新操作取代的事件。协议尚无 + cancellation,因此插件不应为同一成员启动重叠的 configure。 +5. 把 `snapshot-published` 作为发布边界。reply CDB 是协议事实来源;如果语言服务 + 必须读取名为 `compile_commands.json` 的文件,可以使用 + `compatibilityCompileCommands`,但必须等同一次 operation 的该事件到达后再启用。 + 启动或崩溃恢复时应重新 configure,不能仅因根 CDB 存在就信任它。 +6. configure 失败时继续使用此前可用的 clangd 配置,展示结构化诊断并标为 stale。 + 锁竞争可以有限退避重试;I/O 或路径错误应直接提示用户,不能当成竞争反复重试。 +7. 不要把 `configured` 当成 module 已完全 ready。先让 clangd 分析可用 TU;缺少 + BMI 时显示 module pending,并在用户要求完整 module 语义时提供普通 + `mcpp build`(仅测试准备可使用 `mcpp test`),命令完成后重新 configure。 + 工作空间发现阶段不要静默启动完整构建。 +8. 将 stdout 视为协议专用通道;人的诊断可能写入 stderr,不要把它当成事件解析。 + +当前协议是一次性命令。daemon、取消、工作空间扇出、主动 freshness 重算、ready +artifact snapshot 和 last-known-good 索引都属于后续扩展。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 511a32c9..359b777e 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -13,3 +13,4 @@ - [08 - 工具链机制内幕](08-toolchain-internals.md) - [09 - 发布 mcpp](09-release.md) - [10 - 发布一个库到 mcpp-index](10-publishing-a-library.md) +- [11 - IDE 协议](11-ide-protocol.md) From 07bbc165a6e821d005e1d818834de1278c45c980 Mon Sep 17 00:00:00 2001 From: wellwei <96378453+wellwei@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:18:08 +0800 Subject: [PATCH 26/26] docs(ide): clarify client trust and protocol limits --- docs/11-ide-protocol.md | 38 +++++++++++++++++++++++--------------- docs/zh/11-ide-protocol.md | 27 +++++++++++++++++---------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/11-ide-protocol.md b/docs/11-ide-protocol.md index 8faef9f2..4f272950 100644 --- a/docs/11-ide-protocol.md +++ b/docs/11-ide-protocol.md @@ -92,14 +92,14 @@ The inspection state is aggregated as follows: ```text missing artifacts -> partial unverified regular root CDB -> stale -readable configured snapshot and reply CDB -> configured +valid configured metadata and an existing regular-file reply CDB -> configured ``` `ready` is reserved for a future artifact-prepared state and is not currently produced. Diagnostics contain `code`, `severity`, `message`, `source: "mcpp"`, and may -include `path` and a zero-based `range` with `line` and `column` positions. +include `path` and a one-based `range` with `line` and `column` positions. Known codes include `MCPP_IDE_MANIFEST_NOT_FOUND`, `MCPP_IDE_MANIFEST_INVALID`, `MCPP_IDE_MEMBER_MANIFEST_MISSING`, `MCPP_IDE_MEMBER_MANIFEST_INVALID`, `MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND`, @@ -107,11 +107,12 @@ Known codes include `MCPP_IDE_MANIFEST_NOT_FOUND`, `MCPP_IDE_ARTIFACTS_UNAVAILABLE`, `MCPP_IDE_SNAPSHOT_INVALID`, `MCPP_IDE_SNAPSHOT_STALE` and `MCPP_IDE_UNSUPPORTED_FORMAT`. -The snapshot inspection checks metadata, project-root containment and CDB -readability. It does not recompute the manifest, lockfile, source set, -selectors or toolchain fingerprint. Therefore `configured` means that the last -published configured snapshot remains readable, not that the current inputs -have been revalidated. Clients should run `configure` after those inputs change. +The snapshot inspection checks metadata, project-root containment and whether +the CDB path is still a regular file. It does not open or parse the CDB, nor +recompute the manifest, lockfile, source set, selectors or toolchain +fingerprint. Therefore `configured` only means that the last published metadata +still points to an existing regular file. Clients may validate the JSON before +handing it to a language server and should run `configure` after inputs change. ## 4. Configure and NDJSON events @@ -206,9 +207,13 @@ still leave a new CDB alongside old metadata. Configure resolves the project and produces a CDB from the resolved `BuildPlan` and compile flags. It does not compile ordinary project translation units or -link the final executable. It may nevertheless parse dependencies and the -toolchain, prepare a `build.mcpp` host tool, create or update caches, discover -tests, and stage standard-library or cached dependency BMIs. +link the final executable. It is not read-only: resolution can install +dependencies or toolchains, execute root-project and dependency `build.mcpp` +code, update `mcpp.lock`, write target resolution metadata, create or update +caches, discover tests, and stage standard-library or cached dependency BMIs. +An IDE must obtain workspace trust or equivalent explicit permission before it +runs configure for an untrusted project. Read-only discovery should use +`snapshot` instead. The CDB includes ordinary sources and discovered test sources. Cached module prerequisites are staged before the CDB is published. Uncached project or @@ -233,8 +238,10 @@ mcpp protocol command: 3. Reconfigure after the root/member `mcpp.toml`, `mcpp.lock`, `build.mcpp`, source-set/module declarations, selected profile/target/features/capabilities or toolchain selection changes. Debounce file events and reconfigure after an - mcpp dependency or toolchain command completes. A plain source-content edit - that does not change the source set or module graph does not require a new CDB. + mcpp dependency or toolchain command completes. Do not immediately retrigger + on `mcpp.lock`, target metadata or cache events produced by the configure + operation that is already in progress. A plain source-content edit that does + not change the source set or module graph does not require a new CDB. 4. Validate `seq`, correlate events by `operationId`, and ignore events from a superseded operation. The protocol has no cancellation yet, so the client should not start overlapping configure operations for the same member. @@ -244,9 +251,10 @@ mcpp protocol command: that event from the same operation. On startup or recovery, rerun configure instead of trusting a root CDB merely because it exists. 6. If configure fails, keep the previous usable clangd configuration, surface - the structured diagnostic and mark it stale. A contention diagnostic may be - retried with bounded backoff; an I/O/path diagnostic should be shown to the - user instead of being retried as contention. + the structured diagnostic and mark it stale. Version 1 reports configure + failures under the generic `MCPP_IDE_CONFIGURE_FAILED` code; clients must not + parse the human message to infer lock contention or I/O categories. Serialize + operations per member and expose a user-initiated retry for other failures. 7. Do not interpret `configured` as full module readiness. Start clangd for the available TUs, show module support as pending when required BMIs are absent, and offer a normal `mcpp build` (or `mcpp test` for test-only preparation) when diff --git a/docs/zh/11-ide-protocol.md b/docs/zh/11-ide-protocol.md index 3bf39514..102e306c 100644 --- a/docs/zh/11-ide-protocol.md +++ b/docs/zh/11-ide-protocol.md @@ -81,13 +81,13 @@ CSV selector 会丢弃空项,但不会去除空格。当前没有 `--offline` ```text 缺少 artifact -> partial 存在但未经验证的根 CDB -> stale -current.json 有效且 reply CDB 可读 -> configured +configured metadata 有效且 reply CDB 是仍存在的普通文件 -> configured ``` `ready` 只是为未来“artifact 已准备好”状态预留,当前不会生成。 诊断包含 `code`、`severity`、`message`、`source: "mcpp"`,并可包含 `path` 和 -带 `line`、`column` 的零基 `range`。已知 code 包括: +带 `line`、`column` 的一基 `range`。已知 code 包括: `MCPP_IDE_MANIFEST_NOT_FOUND`、`MCPP_IDE_MANIFEST_INVALID`、 `MCPP_IDE_MEMBER_MANIFEST_MISSING`、`MCPP_IDE_MEMBER_MANIFEST_INVALID`、 `MCPP_IDE_WORKSPACE_MEMBER_NOT_FOUND`、`MCPP_IDE_ARTIFACTS_MISSING`、 @@ -95,10 +95,11 @@ current.json 有效且 reply CDB 可读 -> configured `MCPP_IDE_SNAPSHOT_INVALID`、`MCPP_IDE_SNAPSHOT_STALE` 和 `MCPP_IDE_UNSUPPORTED_FORMAT`。 -只读检查会验证 metadata、项目根路径约束和 CDB 可读性,但不会重新计算 -manifest、lockfile、源码集合、selector 或工具链 fingerprint。因此 -`configured` 只表示“上一次发布的 configured snapshot 仍可读”,不表示当前输入 -已经重新验证。输入变化后插件应再次运行 `configure`。 +只读检查会验证 metadata、项目根路径约束,以及 CDB 路径仍为普通文件;它不会 +打开或解析 CDB,也不会重新计算 manifest、lockfile、源码集合、selector 或工具链 +fingerprint。因此 `configured` 只表示上一次 metadata 仍指向现存普通文件,不表示 +CDB 内容有效或当前输入已经重新验证。插件可以在交给语言服务前自行验证 JSON; +输入变化后应再次运行 `configure`。 ## 4. Configure 与 NDJSON 事件 @@ -184,8 +185,11 @@ phase、各 ID、项目根、两个 CDB 路径、命令数和工具链身份。 ## 6. Configure 做什么 Configure 从解析后的 `BuildPlan` 和 compile flags 生成 CDB,不编译普通项目 TU, -也不链接最终可执行文件。但它可能解析依赖和工具链、准备 `build.mcpp` 宿主 -工具、创建或更新缓存、发现测试,以及 stage 标准库或已命中的依赖 BMI。 +也不链接最终可执行文件,但它不是只读操作:解析过程可能安装依赖或工具链、执行 +根项目及依赖的 `build.mcpp` 代码、更新 `mcpp.lock`、写入 target resolution +metadata、创建或更新缓存、发现测试,以及 stage 标准库或已命中的依赖 BMI。 +IDE 对不可信工程运行 configure 前必须取得 workspace trust 或等价的明确授权; +未授权时只能运行只读的 `snapshot`。 CDB 包含普通源码和发现到的测试源码;发布前会 stage 已缓存的模块前置产物。未 命中的工程或依赖模块 BMI 不会由此命令完整生成,因此模块补全可能要等普通 build @@ -206,7 +210,8 @@ CDB 包含普通源码和发现到的测试源码;发布前会 stage 已缓存 3. 根/成员 `mcpp.toml`、`mcpp.lock`、`build.mcpp`、源码集合或 module 声明、 profile/target/features/capabilities selector、工具链选择发生变化时重新 configure。 文件事件应 debounce;插件执行完 mcpp 依赖或工具链命令后也应重新 configure。 - 不改变源码集合或 module graph 的普通源码内容编辑不需要重建 CDB。 + 不要因当前 configure 自己写出的 `mcpp.lock`、target metadata 或缓存事件立即再次 + 触发 configure。不改变源码集合或 module graph 的普通源码内容编辑不需要重建 CDB。 4. 校验 `seq`,用 `operationId` 关联事件,忽略已经被新操作取代的事件。协议尚无 cancellation,因此插件不应为同一成员启动重叠的 configure。 5. 把 `snapshot-published` 作为发布边界。reply CDB 是协议事实来源;如果语言服务 @@ -214,7 +219,9 @@ CDB 包含普通源码和发现到的测试源码;发布前会 stage 已缓存 `compatibilityCompileCommands`,但必须等同一次 operation 的该事件到达后再启用。 启动或崩溃恢复时应重新 configure,不能仅因根 CDB 存在就信任它。 6. configure 失败时继续使用此前可用的 clangd 配置,展示结构化诊断并标为 stale。 - 锁竞争可以有限退避重试;I/O 或路径错误应直接提示用户,不能当成竞争反复重试。 + schema 1 将 configure 失败统一报告为 `MCPP_IDE_CONFIGURE_FAILED`;插件不能解析 + 面向人的 message 来推断锁竞争或 I/O 分类。同一成员的操作应串行,其他失败由 + 用户主动重试。 7. 不要把 `configured` 当成 module 已完全 ready。先让 clangd 分析可用 TU;缺少 BMI 时显示 module pending,并在用户要求完整 module 语义时提供普通 `mcpp build`(仅测试准备可使用 `mcpp test`),命令完成后重新 configure。