Agents: AGENTS.md becomes a router to focused skills, with a staleness check (Refs #6439)

AGENTS.md drops from 16.8k to 3.7k characters: the hard rules, each naming the
check that enforces it, and a table of what to read for each kind of work.
The rest moves into eleven new skills; the five existing ones stay.

cargo xtask skills fails when AGENTS.md or a SKILL.md cites a path, an xtask
command or a skill that does not exist, when the router table and skills/
disagree, or when the router or a new skill grows past its size cap. It runs
in cargo xtask ci and the repository checks job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruck
2026-10-24 21:55:59 -04:00
parent 6224cbf526
commit 4a3e0772ae
18 changed files with 880 additions and 225 deletions
+4
View File
@@ -286,6 +286,10 @@ jobs:
cargo test -p xtask ui_containers --locked
- name: Sorted lists
run: cargo xtask lists
- name: Agent router and skills cite real files
run: |
cargo xtask skills
cargo test -p xtask skills --locked
- name: Design format pending changes
run: bash scripts/test-format-pending.sh
- name: Web app offline use and updates (headless Chrome)
+38 -213
View File
@@ -1,216 +1,41 @@
# SolidCraft — instructions for agents
# SolidCraft: instructions for agents
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
## Hard rules (what enforces each)
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
- Sorted lists (message catalogs, `mod` lines, languages) stay sorted: `cargo xtask lists`. Never edit generated files (`docs/features.md`, `THIRD-PARTY-LICENSES.txt`) in a PR.
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line (#6442).
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
- Show observable changes with real screenshots or a recording, captured yourself.
- Run `cargo xtask ci` before every commit. `cargo xtask skills` keeps this file and `skills/` honest.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
unless it lists the decision as the owner's.
`plan/` is gitignored (local only).
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
and calls through its function pointers), two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers), the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations) and the Windows-only
`apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D
mice, so no other device is sent anything).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Command icons are the original SVG masters in
`assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`.
**Before any icon, icon assignment or media edit, read `assets/AGENTS.md`,
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`.** Use the canonical
bases and named finished references there; never invent a parallel icon style. Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **One outer UI container**: all panels, floating sheets, menus, overlays and platform UI
windows use `ui_kit::Container` (directly or through `Panel`, `Window`, `Confirm`, `Menu`).
Declare the semantic `Surface`; workspace panels use its shared layout policy and chrome.
System/report windows use `ui_kit::Window`; its low-level host builder is kit-private.
Do not construct raw egui hosts, outer popup/window frames or shadows in feature modules.
`cargo xtask ui-containers` recursively checks source/examples in all current and future crates
and apps. It runs in the fast repository CI job and `cargo xtask ci`, with no caller allowances.
See `docs/ui-container-audit.md` for the inventory and legitimate content/native exceptions.
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
camera input (a test rejects it).
- **Application code is Rust only.** Units are millimetres and radians internally; Z is up.
Local asset authoring, lint and review tools use Python 3.11+ as documented in
`assets/AGENTS.md`; Python is not an application runtime dependency. The application exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Showing and following through on changes
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
issues and PRs must include actual descriptive screenshots or a short recording from a
small reproduction. This includes kernel, geometry, assembly and backend changes whose
effects can be shown in the app. Coding agents should run the app and capture this evidence
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
or result, preferably before and after, with brief reproduction steps, expected/actual
behavior and annotations that point out the relevant detail.
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
- Use a small public-safe example and check for private designs, paths, account details or
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
recording; do not commit generated screenshots or videos just to provide review evidence.
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
make its pending status clear at handoff; do not claim it merged without checking.
## Before you start: look at what is already open
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
work on the same issue, the same feature under another number, and PRs that change the files
you are about to change (`gh pr list ... --json number,title,files`).
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
PRs for one change can't both land, and one of them is wasted work and CI.
## Claim = a draft PR
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
a claim before you have a branch.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
## Working on X: read the skill first
| Working on | Read |
|---|---|
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
| A dialog, panel, camera, icon, colour | `skills/solidcraft-ui/SKILL.md` |
| Labels, messages, translations | `skills/solidcraft-ui-text/SKILL.md` |
| Saving something new in a design, import, export | `skills/solidcraft-file-formats/SKILL.md` |
| Preferences, a changed default | `skills/solidcraft-preferences/SKILL.md` |
| Writing or running tests | `skills/solidcraft-tests/SKILL.md` |
| Slow work, the window thread, wasm | `skills/solidcraft-performance/SKILL.md` |
| Releases, installers, packages | `skills/solidcraft-packaging/SKILL.md` |
| Running the app, control channel, MCP tools | `skills/solidcraft-agent-tools/SKILL.md` |
| Building a part over MCP | `skills/solidcraft-modeling/SKILL.md` |
| Changing an existing design | `skills/solidcraft-editing/SKILL.md` |
| Checking, porting, designs in git | `skills/solidcraft-checking/SKILL.md` |
| Assemblies, joints, motion | `skills/solidcraft-mechanisms/SKILL.md` |
+2
View File
@@ -1,5 +1,7 @@
# Contributing to SolidCraft
Agents: start from the router in [AGENTS.md](AGENTS.md), which points to a short skill in [`skills/`](skills/README.md) for each kind of work. This page stays the full human guide.
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
You sketch on a plane, constrain the sketch, turn profiles into solids, and
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
+14 -3
View File
@@ -6,11 +6,22 @@ description that tell the agent when to load it, and an optional supporting refe
The modelling documents are built into SolidCraft: its MCP server's `skill_reference` tool returns them, so an
agent without file access (such as the one in SolidCraft's own chat) can still read a skill's reference.
`solidcraft-contributing` is for repository work and is not embedded in that tool.
The contributor skills (everything except modeling, editing, checking and mechanisms) are for repository work and are not embedded in that tool. `AGENTS.md` routes to them; `cargo xtask skills` fails when its table and this folder disagree or a file, `cargo xtask` command or skill they name is gone.
| Skill | Loads when the agent |
|---|---|
| [`solidcraft-contributing`](solidcraft-contributing/SKILL.md) | helps someone set up a fork, choose and claim an issue, and submit a contribution for review |
| [`solidcraft-contributing`](solidcraft-contributing/SKILL.md) | helps someone set up a fork, choose and claim an issue, and submit a contribution for review; the rules every PR follows |
| [`solidcraft-ground-rules`](solidcraft-ground-rules/SKILL.md) | adds a dependency, asset or wording: independence, licences, attribution, the product name |
| [`solidcraft-crash-safety`](solidcraft-crash-safety/SKILL.md) | writes non-test Rust, handles hostile input or investigates a crash report |
| [`solidcraft-commands`](solidcraft-commands/SKILL.md) | adds a command or decides which crate code belongs in |
| [`solidcraft-ui`](solidcraft-ui/SKILL.md) | adds a dialog, panel, camera input, icon or colour |
| [`solidcraft-ui-text`](solidcraft-ui-text/SKILL.md) | adds a label, message or translation |
| [`solidcraft-file-formats`](solidcraft-file-formats/SKILL.md) | changes what a design saves, or touches import and export |
| [`solidcraft-preferences`](solidcraft-preferences/SKILL.md) | adds a setting or changes a default |
| [`solidcraft-tests`](solidcraft-tests/SKILL.md) | places or runs tests |
| [`solidcraft-performance`](solidcraft-performance/SKILL.md) | works on slow code, the window thread or the wasm build |
| [`solidcraft-packaging`](solidcraft-packaging/SKILL.md) | prepares a release or changes packages |
| [`solidcraft-agent-tools`](solidcraft-agent-tools/SKILL.md) | runs the app, drives the control channel or changes MCP tools |
| [`solidcraft-modeling`](solidcraft-modeling/SKILL.md) | builds a new part (enclosure, bracket, plate, flange, …) |
| [`solidcraft-editing`](solidcraft-editing/SKILL.md) | changes, fixes or extends an existing design |
| [`solidcraft-checking`](solidcraft-checking/SKILL.md) | builds, changes or ports a design, next to the two above: parameter ranges, every allowed value, exact STEP comparison, regression tests, designs kept in git |
@@ -45,7 +56,7 @@ foreach ($s in 'modeling','editing','checking','mechanisms') { Copy-Item -Recurs
- **One project only:** copy the folders into that project's `.claude/skills/` instead.
- **Other agents:** for agents that read the Agent Skills format, copy the folders into the skills directory their documentation names.
- **Updating:** copy the folders again after you update SolidCraft.
- **Contributing:** `solidcraft-contributing` needs no install. Tell your assistant: "I want to contribute to SolidCraft. Read https://github.com/tangency-labs/solidcraft/blob/main/skills/solidcraft-contributing/SKILL.md and help me get started."
- **Contributing:** the contributor skills need no install. Tell your assistant: "I want to contribute to SolidCraft. Read https://github.com/tangency-labs/solidcraft/blob/main/skills/solidcraft-contributing/SKILL.md and help me get started."
## Maintaining
+29
View File
@@ -0,0 +1,29 @@
---
name: solidcraft-agent-tools
description: "Use when running SolidCraft to see a change working, driving it through the control channel or CLI, or adding or changing an MCP tool, control method or bundled agent skill."
---
# Running the app, the control channel and MCP tools
## Run it and look at it
- `cargo run --release -p solidcraft -- --sample --control PORT` starts the sample design with the control channel. Pick a free port. With no display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Talk JSON lines to `127.0.0.1:PORT` (`docs/control-protocol.md`). The first request on a connection carries the launch token as `"token"`: read it from `~/.local/share/solidcraft/control/PORT.token`, or start the app with `SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that. Never paste the token into a PR or issue.
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}`, then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`, then read the PNG
- For UI work, look at the result. Without a window use `ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`, `solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Build with `CARGO_TARGET_DIR=<repo>/target`.
## MCP tools
The server is `solidcraft-cli mcp` (`crates/mcp/src/`, `docs/mcp.md`). Tools are defined in `crates/mcp/src/tools.rs`; keep `docs/mcp.md` in step, and keep tool annotations (read-only, destructive) honest.
- Anything the user can do is a command; add a command, not a new tool, unless the tool is about the session itself (open, save, screenshot, batch).
- Files: tools reach only the allowed folders (the design's folder, the recovery folder, the scratch folder, `--allow-path`). Do not widen this casually; paths are resolved before comparing, so `..` and links leaving the allowed set are refused, and on Windows UNC and device paths are refused.
- Replies stay small: `verbose: false`, no unfiltered command catalogue or topology dumps.
- The control channel is a local-only interface with a token. A new method needs an entry in `docs/control-protocol.md`; long work follows `skills/solidcraft-performance/SKILL.md`.
## Bundled agent skills
The modelling skills (`skills/solidcraft-modeling`, `-editing`, `-checking`, `-mechanisms`) are built into the MCP server for its `skill_reference` tool (`crates/mcp/src/skill_reference.rs`). The contributor skills are not.
- When a command, parameter name or behaviour those skills describe changes, update them in the same PR. `skills_name_only_real_tools_and_commands` in `crates/mcp/src/tests.rs` fails when a skill names a tool or command that is gone.
- Adding a bundled file means adding it to `skill_reference.rs` and to that test.
- `skills/README.md` explains the layout and the other tests; `cargo xtask skills` checks that AGENTS.md and every SKILL.md cite real paths, commands and skills.
+36
View File
@@ -0,0 +1,36 @@
---
name: solidcraft-commands
description: "Use when adding or changing a SolidCraft command or any user-visible behaviour, or when deciding which crate code belongs in. Covers CommandSpec, command ids, programmatic calls vs dialogs, crate layering and the generated feature list."
---
# Adding a command
User-visible behaviour is a command. The toolbar, dialogs, sketch tools, palette, scripts, CLI, control channel and MCP all reach the same commands through `Session::execute(id, params)`. The long form is `docs/book/src/dev/commands.md`.
## Steps
1. Add a `CommandSpec` to the right file in `crates/engine/src/cmd/` and implement `run` (`fn(&mut Session, &Value) -> Result<Value>`). Fields: id, label, toolbar tab and panel (`.at`), icon (`.icon`), shortcut (`.key`), params doc (`.params`), `enabled` (its error string is the disabled-button tooltip).
2. The id is our own: dotted, lower-case `<area>.<thing>[.<variant>]`, such as `solid.extrude` or `sketch.rectangle.two_point`. Never another product's id.
3. Validate inputs first (units, ranges, references) and return `BadParams` rather than building a broken feature. Parameters are hostile: see `skills/solidcraft-crash-safety/SKILL.md`.
4. Engine tests measure the result: volume, area, face/edge/vertex counts against values you can derive by hand. Where they go: `skills/solidcraft-tests/SKILL.md`.
5. Add the label to the English catalog as `cmd.<command id>` (`cargo xtask i18n --add-missing`); a test fails without it. See `skills/solidcraft-ui-text/SKILL.md`.
6. Run `cargo xtask features` to see the generated `docs/features.md` change, but do not commit it: `main` regenerates it after each merge.
7. If the command saves something new in a design, add a format file: `skills/solidcraft-file-formats/SKILL.md`.
8. If it needs a dialog or panel: `skills/solidcraft-ui/SKILL.md`.
## Programmatic calls never open dialogs
`engine.execute` runs the JSON form. Only toolbar clicks (`ui.start`) start interactive tools and dialogs, which then run commands. A dialog is a front-end form that builds the parameters.
A command that changes the design is undoable by default (the session snapshots the document first), and a failing command leaves the document unchanged.
## Layering
`cargo xtask layers` fails the build when a lower layer depends on a higher one:
L0 `geom` -> L1 `sketch`, `kernel`, `render` -> L2 `doc` -> L3 `io` -> L4 `engine` -> L5 `ui-egui` -> apps.
- Nothing below L5 depends on egui, eframe, winit, wgpu or rfd. The UI crate is swappable.
- Only `crates/kernel` may use `truck`.
- The UI is thin: panels read engine state and act through `app.run(id, params)` or `app.start(id)`.
- A new module's `mod` line goes into the existing sorted group, not a new group at the end (`cargo fmt` keeps each group sorted).
## Roadmap
`ROADMAP.md` tracks milestones; update it when a milestone task lands.
+52 -8
View File
@@ -5,12 +5,58 @@ description: Use when someone wants to contribute code or documentation to Solid
# Contribute to SolidCraft
Read [CONTRIBUTING.md](../../CONTRIBUTING.md), [AGENTS.md](../../AGENTS.md),
the [contributor book page](../../docs/book/src/dev/contributing.md), and the
[skills index](../README.md) first. Follow their current rules; use this skill to
Read [CONTRIBUTING.md](../../CONTRIBUTING.md), [AGENTS.md](../../AGENTS.md) (the router
to the other skills), the [contributor book page](../../docs/book/src/dev/contributing.md),
and the [skills index](../README.md) first. Follow their current rules; use this skill to
help a first-time contributor apply them. This skill concerns the repository,
not modelling a part through MCP.
## Rules for every PR
- **Look at what is open first.** Pull the whole list, drafts included:
`gh pr list -R tangency-labs/solidcraft --state open --limit 500` and
`gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for the same issue, the same
feature under another number, and PRs that change the files you will change
(`gh pr list ... --json number,title,files`). If someone has it, comment on their PR or issue
instead of opening a second one: two PRs for one change cannot both land.
- **Claim with a draft PR.** `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`; if none,
branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push, and open a
draft PR titled `<summary> (#N)`. The lowest-numbered open PR for an issue wins; close yours if it
lost. The bot assigns you and labels the issue `in progress`. Do not claim by editing files; comment
`/claim` only to stake a claim before you have a branch.
- **Push daily.** A draft with no pushes for 72 hours may be taken over: comment first, then open your
own claim (you may start from its branch). The bot's automatic cleanup deadlines in CONTRIBUTING.md
are separate.
- **One topic and one issue per PR.** Never commit `plan/`, build directories (`target*/`) or generated
reference data; check `git ls-files` before pushing. Run `cargo xtask ci` before every commit.
Commit after every feature arc that builds.
- **Merge flow.** The heavy CI jobs start when a maintainer adds `approved-for-ci`; approved PRs go
through the merge queue (`gh pr merge N -R tangency-labs/solidcraft --squash --auto`), which runs the
full suite on each group before it lands on `main`. Never force-push `main`.
- **Do not push only to catch up with `main`** (no merge of `main`, no Update branch, no rebase for
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in between.
When you push anyway, or GitHub reports a conflict, rebase onto the latest `main` and push your own
branch with `--force-with-lease`; never merge `main` into a PR. Resolve conflicts without discarding
others' work. Do not re-run CI on an unchanged commit: re-run only a job that runner trouble
cancelled, with "Re-run failed jobs".
- **Evidence.** For observable CAD, UI or model behaviour (kernel, geometry, assembly and backend
changes included), the issue or PR carries real screenshots or a short recording from a small
reproduction, preferably before and after, with steps and expected/actual behaviour. Capture it
yourself; do not ask a human for evidence you can produce. Diagrams do not replace it. Changes with
nothing observable need none. Use a public-safe example, check for private designs, paths and account
details, attach the media rather than committing it.
- **Follow through.** Check the PR's merge status throughout and before handoff. Report the state you
checked and any conflicts resolved. While the PR is open, keep monitoring it and say clearly that it
is pending; do not claim it merged without checking.
- **Shared build machine.** Check the load before a big build (`cut -d' ' -f1 /proc/loadavg`), wait
while it is over 24, and build with `nice`.
- **Maintainers' machines** keep a local, gitignored `plan/` folder: read `plan/STATUS.md` (current
milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md`
section. Kernel and library decisions are in `plan/adr/0001-geometry-kernel.md`. Follow the autonomous
operation protocol in `plan/execution-plan.md` section 6; do not stop to ask unless it lists the decision
as the owner's. Contributors without `plan/` can ignore this.
- **Update `ROADMAP.md`** when a milestone task lands.
## 1. Check the environment
Explain what you are checking and why, in everyday language. Run:
@@ -111,10 +157,8 @@ the duplicate. Do not try to claim by editing files.
## 5. Make and check one focused change
Explain the intended behaviour and follow AGENTS.md, including local instructions.
Never crash: return `Result` errors, validate hostile input with finite checks and
size caps, and avoid non-test panics. Preserve layering and route user-visible
behaviour through commands. Use only permitted clean-room references and assets.
Explain the intended behaviour, then pick the skill for the area from AGENTS.md's table and
follow it (never crash, commands, layering, clean-room rules and tests all live there).
Put synthetic regression tests in the existing test module for the affected area;
prove a bug test fails before the fix and passes after it.
@@ -132,7 +176,7 @@ On small environments, use targeted tests and report exactly what passed and wha
could not run; leave the full suite to CI rather than claiming it passed. Follow
[testing guidance](../../docs/book/src/dev/testing.md) for applicable checks.
Review the diff, keep one topic, and commit the finished change. Before an approved
push, follow AGENTS.md's rebase and conflict rules; do not push solely to catch up
push, follow "Rules for every PR" above; do not push solely to catch up
with `main`. Explain progress and ask before publishing each update.
## 6. Show the result
+30
View File
@@ -0,0 +1,30 @@
---
name: solidcraft-crash-safety
description: "Use when writing or reviewing non-test Rust in SolidCraft, handling design files, command parameters or control-channel input, calling the kernel, or investigating a crash report. A crash loses someone's work, so this outranks feature work."
---
# Never crash
People trust SolidCraft with their designs. This outranks feature work.
## In non-test code
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; `cargo xtask ci` runs clippy with `-D warnings`.
- No `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in `crates/threedx-client` (which deny it instead), have no safe API:
- macOS only: `crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time and calls through its function pointers), and two functions in `apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C method)
- Windows only: `apps/solidcraft/src/native_crash.rs` (the exception filter that records crashes inside native code such as graphics drivers), `apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit test of the window without OS decorations) and `apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D mice, so no other device is sent anything)
- Errors are `Result<T, E>` through the crate's error type and `?`. An unfinished feature returns an error ("not supported yet: ..."); it never panics.
- Input-derived numbers are hostile (command parameters, design files, control-channel input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- Everything that calls `truck` goes through `solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`. Only `crates/kernel` may use `truck`.
- Last resort: `Session::execute` runs every command under `catch_unwind`; an escaped panic restores the design and reports `EngineError::Internal`. Do not rely on it.
- Files are written with `crates/io/src/safe_write.rs` (temporary file, flush, one rename), so a crash leaves the old file or the new one.
## Proving a crash fix
- Every crash fix lands with a regression test. `hostile_params_never_panic` in `crates/engine/src/tests.rs` runs every command with junk parameters; a new command gets this for free.
- A crash report's `design.solidcraft` and `replay.json` are the test's starting point. Put the test in the area's existing test file (see `skills/solidcraft-tests/SKILL.md`).
## When someone says the app crashed
The desktop app writes every panic and every unclean exit to a local crash folder, with the design and the commands that reproduce it (`solidcraft_engine::crash`, `docs/crash-reports.md`). Look there first:
- `solidcraft-cli crashes`, `solidcraft-cli crashes show <id>`, `solidcraft-cli crashes replay <id>`
- the `diagnostics.crash_reports` command, or the MCP `crash_reports` tool
Crashes and data loss are taken as issues even while kernel work is paused (CONTRIBUTING.md, "Kernel work is paused").
+27
View File
@@ -0,0 +1,27 @@
---
name: solidcraft-file-formats
description: "Use when a SolidCraft change saves something new in a design (a field, a feature type, a kind of name) or changes what older designs mean, and when touching STEP, IGES, 3MF, STL, OBJ, DXF or URDF import and export."
---
# Design file format, import and export
## Design file format changes
A design says which format it is in (`"format": "solidcraft/N"`). A change that saves something new, or makes older designs mean something else, adds ONE file and edits nothing shared:
1. Create `crates/doc/src/format/pending/<what-it-is>.rs` (lowercase, dashes). Start it with a `//!` note: what is saved, what older designs get without it, and that the last release asks for an update instead of meeting it.
2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) -> crate::Result<()>` in the same file. Pending steps run in file-name order, after the older formats' steps.
3. Never touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another change's file, or any `v<N>/` folder. The build counts the pending files and writes the next number, so a release refuses a design that carries your change instead of losing it.
Two PRs that each add a change merge without a conflict. `crates/doc/src/format/pending/README.md` has an example. At release time the maintainer runs `scripts/fold-format-pending.sh`, which moves pending files into `v<N>/` for good; `scripts/test-format-pending.sh` runs in CI. Never add a file to a `v<N>/` folder afterwards. See `docs/releasing.md`.
## Import
- Every import (STEP, IGES, 3MF, STL, OBJ; inserted, dropped, opened, or run as `file.insert_step`, `file.insert_mesh` or `doc.open`) becomes a new component named after the file, with the Import feature inside it. Bodies never land loose.
- Imported files are untrusted: sizes, entity counts and nesting are capped, and every kernel call is guarded (`skills/solidcraft-crash-safety/SKILL.md`). What cannot be read becomes a warning on the feature.
- STEP is read by our own reader in `crates/kernel/src/step_in`, written from the public standards, with no third-party STEP code. The reader and what it supports: `docs/step-import.md`.
- Imported assemblies are rigid by default; Unlock Contents releases them.
- Kernel work (booleans, STEP/IGES import and export, tessellation) is paused except crashes and data loss: CONTRIBUTING.md, "Kernel work is paused".
## Export
- STEP, 3MF, STL, OBJ and URDF go through `file.export` and `file.save_mesh`; the MCP `export` tool calls them. URDF: `docs/urdf.md`. 3MF face tags: `docs/formats/3mf-face-tags.md`.
- Mesh and design readers and writers live in `crates/io/src/`. Write files through `crates/io/src/safe_write.rs`.
- Reference tests for exports never take their expected values from SolidCraft's own output (`skills/solidcraft-tests/SKILL.md`).
+34
View File
@@ -0,0 +1,34 @@
---
name: solidcraft-ground-rules
description: "Use before adding any dependency, icon, image, font, test reference data or product wording to SolidCraft, or when unsure whether something may be copied, read or named. Covers independence from other CAD products, GPL code, asset attribution and the product name."
---
# Independence, licences, assets and the name
The rules that cannot be broken in a PR. `AGENTS.md` lists them in one line each; the reasons are in CONTRIBUTING.md, "Rules that aren't negotiable".
## Independent
- Behaviour comes from public documentation, general CAD conventions and user feedback. File formats come from public specifications.
- Never copy another product's code, icons, artwork, help text, tooltips, templates or materials.
- Reference tests use analytic values (`tools/oracle-analytic/`) or open-source tools. Expected values never come from a commercial CAD product.
- Do not drive, script, automate or extract data from commercial CAD products (Fusion, SolidWorks, Onshape, ...) for this project: no test data, measurements, command lists, ids or files produced with them.
- Command ids are our own: dotted, lower-case `<area>.<thing>[.<variant>]`, never another product's id.
## No GPL, LGPL or AGPL code
- Do not read, copy or link OpenCASCADE, FreeCAD (including planegcs), SolveSpace, CadQuery/OCP, LibreCAD or QCAD. Do not even read their sources.
- `tools/freecad-oracle/` runs FreeCAD as a separate program through its public scripting API to compute reference values. Nothing from FreeCAD is linked, vendored or shipped. The sketch solver and the blend operations are our own.
- Permissive dependencies are fine: say which one you add and why in the PR. `THIRD-PARTY-LICENSES.txt` is regenerated on `main`; never edit it in a PR (`cargo xtask licences --check` only reports).
- Code adapted from other permissive projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md. Projects published by [storytold](https://github.com/storytold) can serve as references for implementation conventions.
## Assets: the most serious rule
- SolidCraft contains no iconography, images, fonts or artwork from commercial CAD products, ever. Screenshots of commercial CAD software are never committed.
- Every file asset is original or openly licensed and has a row in ATTRIBUTION.md. `cargo xtask assets` enforces it.
- Command icons are the original SVG masters in `assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`.
- Before any icon, icon assignment or media edit, read `assets/AGENTS.md`, `assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`. Use the canonical bases and named finished references there; never invent a parallel icon style. `cargo xtask icons` validates them.
## The name
- SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`, `.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables, settings and recovery folders, the MCP server name, docs, CI and packaging.
- The old name survives only in the compatibility shims listed in `docs/compatibility.md`. Do not add new uses of it.
- Public docs, web and Flatpak deployments use project Pages `/solidcraft/`. Do not add permanent old-name Pages aliases.
- Labels, parameter docs, messages and docs use the terms in the glossary (`docs/book/src/reference/glossary.md`): one term for each idea. A change that adds a new idea adds its term there.
- Units are millimetres and radians internally; Z is up.
+31
View File
@@ -0,0 +1,31 @@
---
name: solidcraft-packaging
description: "Use when preparing or shipping a SolidCraft release, changing installers or packages (AppImage, MSI, dmg, Flatpak, Snap, WinGet, Nix, FreeBSD, web), release asset names, signing or the Pages deployment."
---
# Packaging and releases
Development pushes are not application releases. Pushing to `main` or pushing a tag does not start a release. The full procedure is `docs/releasing.md`; follow it when asked to prepare or ship a release.
## Preparing
1. Choose one version for the whole batch from the actual changes (patch, minor, major, `-rc.N`). Do not infer it from commit prefixes. Moving to `1.0` is the owner's decision.
2. Update `[workspace.package] version` in `Cargo.toml`, then `cargo update --workspace --offline`; only our own crates' `version =` lines in `Cargo.lock` change.
3. Fold the pending design-format changes with `scripts/fold-format-pending.sh` (`skills/solidcraft-file-formats/SKILL.md`).
4. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`. Land the version commit on `main` through the normal review flow; never use `[skip ci]`.
## Building and publishing
- Advance the `release` branch with fast-forwards only (`git merge --ff-only`); never force-push. `.github/workflows/release.yml` builds every package and creates a draft GitHub Release.
- Verify the draft before publishing: the run succeeded for the intended SHA, every platform and architecture is present, `sha256sum --check SHA256SUMS.txt` passes, and the built CLI runs (`--version`, an example model).
- Only when shipping is authorised, publish the verified draft with `gh release edit`, then confirm its tag resolves to the recorded SHA and the downstream deployment ran. A request to prepare a release is not a request to publish.
- Never overwrite a published version, tag or asset; use a new version. Do not push the version tag yourself. Packages are unsigned unless `docs/releasing.md` says otherwise; do not claim they are signed.
- Nightlies are prereleases built by the same workflow; they never become latest or advance `release`.
## Names that must stay stable
- Assets are `solidcraft-<version>-...` and are also attached without the version (`solidcraft-windows-x64.msi`). The README links and the in-app update check (`crates/ui-egui/src/update.rs`) pick them by name; renaming one breaks both.
- The old product name survives only in the shims in `docs/compatibility.md`. Do not rewrite published assets, checksums, tags or historical notes.
- Documentation, the browser app and the Flatpak repository publish under project Pages `/solidcraft/`. Add no old-name Pages aliases.
## Where things live
- `packaging/` holds the per-platform scripts (`packaging/linux`, `packaging/windows`, `packaging/macos`, `packaging/freebsd`, `packaging/web`); `packaging/tests` checks them.
- Nix: `flake.nix` and `docs/nix.md`. Building: `docs/building.md`. Downloads: `docs/downloads.md`.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
+24
View File
@@ -0,0 +1,24 @@
---
name: solidcraft-performance
description: "Use when a SolidCraft change is slow, blocks the window, runs long work, or must build for the browser. Covers keeping the window thread free, measuring a speed-up, the wasm build and what may be JavaScript or Python."
---
# Performance, threads and wasm
## Do not block the window
- Work that can take long (a feature computation, a save, a file load, reading a picture) runs beside the window and is applied when done. Existing examples: `crates/ui-egui/src/save_job.rs`, `crates/ui-egui/src/file_load.rs`, and the agent path in `docs/control-protocol.md` ("Slow commands and the window").
- With the preference "Keep the window responsive while agents run commands" (`agent_off_thread`) on, a design change that takes longer than about 150 ms is evaluated ahead on a copy of the design, then run for real reusing that result. A person's edit made meanwhile goes first; Stop drops the computation and nothing is applied. Keep that contract when you add a long-running command.
- `file.export` and `file.save_mesh` change nothing in the design: they run on a copy and nothing waits behind them.
- Test it: `crates/ui-egui/src/apply_off_thread_tests.rs` drives a long feature through the harness; never depend on wall-clock timing in a test.
## Measure before you claim a speed-up
- Profile first. Time with `solidcraft-cli` from `main` and from your branch, alternating runs, after the load is low. On a shared machine check `cut -d' ' -f1 /proc/loadavg` and wait while it is over 24; build with `nice`.
- Report medians with the range, the machine, and the cases. The pattern to follow is `docs/perf.md`: what was slow, why, the result, and proof that nothing changed (same bodies, volumes, areas and triangle counts).
- Do not rewrite kernel code for speed: the kernel is being replaced, and kernel work is paused. Report a slow case with its file under the `kernel-perf` label (CONTRIBUTING.md, "Where help is wanted most").
- Pull requests run only the tests for what they touch; geometry-heavy tests run in the merge queue. Use `cargo xtask test` with the `ci` profile locally (`skills/solidcraft-tests/SKILL.md`).
## The browser build
- Everything below the apps must build for `wasm32-unknown-unknown`: `cargo xtask wasm` checks every library crate and the web app, and is part of `cargo xtask ci`. Do not add a dependency that cannot.
- Application code is Rust only. The browser requires two pieces of JavaScript: the service worker `apps/solidcraft-web/sw.js` and the small inline scripts in `apps/solidcraft-web/index.html`. Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- Python 3.11+ is allowed only for asset authoring, lint and review tools (`assets/AGENTS.md`), never as an application dependency.
- Design notes for the browser runtime: `docs/web-runtime.md`, `docs/browser-api.md`.
+25
View File
@@ -0,0 +1,25 @@
---
name: solidcraft-preferences
description: "Use when a SolidCraft change alters a default behaviour, an existing shortcut or the look users already have, or adds a setting in Preferences. Covers keeping the old behaviour behind a preference and the PR's 'Changes a default' line."
---
# Preferences and defaults
People rely on how SolidCraft behaves today. A change that makes users see or do something different out of the box is a change of a default.
## The rule
- Keep the old behaviour reachable. Add a preference whose old value gives the old behaviour, and test that it still does.
- Fill in the PR template's `Changes a default:` line (a PR check for it is in review as #6442):
- `Changes a default: no`
- `Changes a default: yes - <what changes>, old behaviour kept behind preference <name>`
## Adding a preference
Settings live in the `preference_settings!` table in `crates/ui-egui/src/prefs.rs`: the field, its default, the page whose reset restores it, an optional accepted range for the control channel, and the form control. Add one row there.
- A new setting that changes nothing by default needs no `yes` on the line; one that changes what users see by default does.
- Give it a label and help text through the catalogs (`skills/solidcraft-ui-text/SKILL.md`).
- A reset on its page restores the default; fields left out of every page keep their values when defaults are restored.
- Control channel: `ui.prefs {...}` reads and sets preferences by name (`docs/control-protocol.md`).
- Test it in a UI scenario or a `*_tests.rs` file next to the setting (`skills/solidcraft-tests/SKILL.md`).
## What counts as a default
Anything a user sees or does differently on first launch after the update: a changed shortcut, camera or navigation behaviour, unit, naming, theme or autosave behaviour, or a tool that appears unasked. A fix that makes the app do what it already claimed is not a default change; say so on the line.
+30
View File
@@ -0,0 +1,30 @@
---
name: solidcraft-tests
description: "Use when writing, placing or running tests in SolidCraft: a regression test for a bug, an engine test for a command, a UI scenario, a reference (oracle) test, goldens, or when CI fails a test you did not touch."
---
# Where tests go and how to run them
The long form is `docs/book/src/dev/testing.md`.
## Placement
- A regression test goes into the existing test file for its area (booleans, blends, shell, sweep, STEP, naming, ...), never a new file named after the bug (#3226).
- Engine tests sit next to the code or in `*_tests.rs` files; they measure the result (volume, area, face/edge/vertex counts) against a value you can derive.
- UI scenarios are JSON in `crates/ui-egui/tests/scenarios/*.json`; a new file is picked up automatically. Put `{"pending": "why"}` in a scenario that records a bug before it is fixed, and remove it in the fixing PR.
- Reference tests are `tests/oracle/<case>/recipe.json` with an `expected.json` beside it, run by `cargo xtask oracle`. Expected values are worked out in closed form (`tools/oracle-analytic/`) or computed by FreeCAD run as a separate program (`tools/freecad-oracle/`). A case that fails on a known bug names its issue (`"known_failure": "#123"`). Never change an expected value to match SolidCraft's output, and never take one from a commercial CAD product.
- Crash fixes: a regression test, started from the crash report's `design.solidcraft` and `replay.json` (`skills/solidcraft-crash-safety/SKILL.md`).
## A failing test needs its fix
A failing test without its fix cannot merge: it turns `main` red for everyone. Include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]` and remove that line in the PR that fixes it.
## Running
- `cargo xtask test` builds with the `ci` profile and runs every test binary at once; arguments after `--` go to each binary (`cargo xtask test -- shell`). Use the `ci` profile: it builds several times faster than `--release`, and debug assertions are off because truck's reject valid geometry. If a test fails only in a plain debug build, say so in the PR.
- `SOLIDCRAFT_TEST_JOBS=N` bounds concurrent commands on a shared machine.
- `cargo xtask ci` is the gate before a commit: fmt, assets, icons, licences, lists, layers, ui-containers, clippy `-D warnings`, tests and the wasm check.
- Every test runs in its own process: do not rely on state left by another test, or on wall-clock timing.
## Goldens, matrices and skipped tests
- A geometry change that moves the robustness goldens re-records them in the same PR (`ROBUSTNESS_GOLDENS_BLESS="why it changed"`, `docs/robustness.md`).
- The action matrix is a ratchet: record a newly passing action with `cargo xtask action-matrix --bless` (`docs/action-matrix.md`).
- Pull requests skip a few heavy tests, which run in the merge queue, nightly and on `main`. Check that validation after merging. The list is in `.config/nextest.toml` and the testing page.
- Mutation testing finds behaviour the suites miss: `docs/mutation-testing.md`.
+30
View File
@@ -0,0 +1,30 @@
---
name: solidcraft-ui-text
description: "Use when adding or changing any user-visible text in SolidCraft: a label, tooltip, message, command label or a new language. Covers the message catalogs in assets/locales, sorted lists, which locales a new key needs, UK English and terminology."
---
# UI text and translations
SolidCraft embeds its translations and works offline. The long form is `docs/localization.md`.
## Adding a message
1. Add the id and English text to `assets/locales/en.json`. Ids are stable: keep them when you change wording.
2. Use `app.tr("ui.some_id")` for static messages and `Locale::format` for whole sentences with named placeholders. Translate the whole sentence, punctuation included, so a translator can reorder arguments. Use stable ids directly for new messages, not the English-label adapter (`app.tr_label`), which exists for older tables.
3. Command labels live under `cmd.<command id>`; `cargo xtask i18n --add-missing` writes the ones that are missing into `en.json`.
4. Add the UK spelling to `assets/locales/en-GB.json` when the US spelling differs (Centre, Colour, Licence, ...). That file holds only the differing messages.
5. A new label needs `en`, `ja`, `zh`, `de` and `ko`. The other catalogs are partial and fall back to English; `cargo xtask i18n` reports their gaps without failing, and `every_complete_catalog_carries_every_english_message` fails a missing `ja` or `zh` entry.
6. Run `cargo xtask lists --fix` before committing, then `cargo xtask i18n`.
## Sorted catalogs
Catalogs are one message per line, sorted by id, with no repeated id. `cargo xtask lists` (part of `cargo xtask ci` and the Lists workflow) fails on an unsorted file. Add the message anywhere and let `--fix` place it. Two PRs that add neighbouring keys still collide on these files; rebase and run `--fix` again.
## Pending locale files
Not available yet: `main` has no folder of per-change locale files, and no PR adding one has merged. Until it does, edit the catalogs as above. When it lands, this section gets the new practice.
## Other sorted lists
- Languages (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id; a test checks it. Adding a language is an embedded catalog plus one registration there.
- `mod` lines: one per line, in the sorted group they belong to.
- `docs/features.md` and `THIRD-PARTY-LICENSES.txt` are generated on `main` after each merge: never edit or regenerate them in a PR.
## Wording
Use the terms in the glossary (`docs/book/src/reference/glossary.md`); words with several meanings (Construction, Flat, Round, Normal, Gap, Clearance) have one catalogue entry per meaning. A new idea adds its glossary term. Tests also require tabs, panels, menu labels and command labels to be in `en.json`.
+31
View File
@@ -0,0 +1,31 @@
---
name: solidcraft-ui
description: "Use when adding or changing a SolidCraft dialog, panel, menu, overlay, canvas, camera input, slider, icon or colour in crates/ui-egui. Covers the single UI container, the UI kit, navigation::handle, theme tokens, command icons and UI scenarios."
---
# Dialogs and UI
The UI is thin: it reads engine state and acts through `app.run(id, params)` or `app.start(id)`. A dialog is a form that builds the parameters of a command (`skills/solidcraft-commands/SKILL.md`). The full control list is `docs/ui-kit.md`.
## One outer container
- All panels, floating sheets, menus, overlays and platform UI windows use `ui_kit::Container`, directly or through `Panel`, `Window`, `Confirm` and `Menu`. Declare the semantic `Surface`; workspace panels use its shared layout policy and chrome.
- System and report windows use `ui_kit::Window`; its low-level host builder is kit-private.
- Do not construct raw egui hosts, outer popup or window frames, or shadows in feature modules.
- `cargo xtask ui-containers` checks source and examples in all current and future crates and apps, recursively. It runs in the fast repository CI job and in `cargo xtask ci`, with no caller allowances. The inventory and the legitimate content and native exceptions are in `docs/ui-container-audit.md`.
## Controls and colour
- Colours, typography, dimensions and spacing come from `theme::Tokens` (`crates/ui-egui/src/theme.rs`). No literal colours in feature code.
- Sliders only through `ui_kit::slider`. Form rows use `row`, `expr_row`, `text_row`, `choice_row` and `count_row` from `crate::ui_kit` (`crates/ui-egui/src/ui_kit/`).
- Confirmations use `ui_kit::Confirm`; the old `Prompt` element is gone.
## One set of camera controls
Every page or canvas with a camera (viewport, Render preview, a flat pattern or drawing page) calls `navigation::handle` (`crates/ui-egui/src/navigation.rs`) with its `NavCaps` (orbit off for flat 2D pages). Never hand-roll camera input from drags, the wheel or pinch; `hand_rolled_camera_input_is_rejected` fails if a page does.
## Icons
Command icons are original SVG masters compiled through `crates/ui-egui/src/icon_svg.rs`. Read `assets/AGENTS.md`, `assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md` first, add the ATTRIBUTION.md row, and run `cargo xtask icons` and `cargo xtask assets`. See `skills/solidcraft-ground-rules/SKILL.md`.
## Text
Every label, tooltip and message goes through the catalogs: `skills/solidcraft-ui-text/SKILL.md`. A new behaviour that is on by default: `skills/solidcraft-preferences/SKILL.md`.
## Look at the result
Run the app, drive it and read the screenshot: `skills/solidcraft-agent-tools/SKILL.md`. A UI scenario (`crates/ui-egui/tests/scenarios/*.json`) covers the clicks; see `skills/solidcraft-tests/SKILL.md`. Work that takes long must not block the window: `skills/solidcraft-performance/SKILL.md`.
+7 -1
View File
@@ -13,6 +13,7 @@ mod layers;
mod licences;
mod lists;
mod render_assets;
mod skills;
mod test;
mod test_nextest;
mod ui_containers;
@@ -57,6 +58,9 @@ commands:
leave it to the Generated files workflow on main
lists [--fix] check that files many changes add to are sorted one item per line (the
message catalogs in assets/locales); --fix sorts them
skills check that AGENTS.md and skills/*/SKILL.md cite files, `cargo xtask` commands and skills
that exist, that the router table lists exactly the skills there are, and that
AGENTS.md and new skills stay short
test [CARGO ARGS] [-- TEST ARGS]
build the tests with the ci profile, then run every test binary at once
(and the doc tests alongside); SOLIDCRAFT_TEST_JOBS=N bounds concurrent test
@@ -69,7 +73,7 @@ commands:
web-offline-test [--site DIR]
check in headless Chrome that the web app works offline and updates to a new
build (a stand-in app, or the web build in DIR such as dist/web)
ci fmt --check, assets, icons, licences --check, lists, layers, ui-containers, clippy -D warnings, then the tests
ci fmt --check, assets, icons, licences --check, lists, skills, layers, ui-containers, clippy -D warnings, then the tests
with the wasm check alongside, then web-offline-test when Chrome is installed
(stops at the first failing step)
";
@@ -92,6 +96,7 @@ fn main() -> ExitCode {
}
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
Some("skills") => skills::run(&root()),
Some("lists") => lists::run(&root(), rest.contains(&"--fix")),
Some("test") => {
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));
@@ -379,6 +384,7 @@ fn cmd_ci() -> Result<(), String> {
icons::run(&root())?;
licences::run(&root(), true)?;
lists::run(&root(), false)?;
skills::run(&root())?;
cmd_layers()?;
ui_containers::run(&root())?;
let mut c = cargo();
+436
View File
@@ -0,0 +1,436 @@
//! `cargo xtask skills`: keeps the contributor router (AGENTS.md) and the skills in `skills/`
//! honest, so agents are never sent to a file, command or skill that is gone.
//!
//! - Every path an AGENTS.md or SKILL.md names (in backticks, or as a Markdown link) exists.
//! Only paths that start at a top-level entry of the repository are checked; `plan/` (local
//! only) and `target*` are skipped, as is anything with a placeholder (`<N>`, `*`, `$SKILL`).
//! - Every `cargo xtask <name>` names a command of this tool.
//! - Every `skills/<name>` names a folder that exists.
//! - The router table in AGENTS.md lists exactly the skills that have a `skills/<name>/SKILL.md`,
//! and each SKILL.md has a front matter `name` equal to its folder and a `description`.
//! - AGENTS.md stays a router (at most [`ROUTER_MAX_CHARS`]) and a new skill stays short
//! (at most [`SKILL_MAX_CHARS`]); the skills in [`LONG_SKILLS`] predate the cap.
use std::collections::BTreeSet;
use std::path::Path;
pub const ROUTER_MAX_CHARS: usize = 4000;
pub const SKILL_MAX_CHARS: usize = 3600;
/// Skills written before the size cap (the modelling skills carry their command shapes).
const LONG_SKILLS: &[&str] =
&["solidcraft-checking", "solidcraft-contributing", "solidcraft-editing", "solidcraft-mechanisms", "solidcraft-modeling"];
const SKIPPED_ROOTS: &[&str] = &["plan", "target", "target-wasm"];
/// A file that is checked: its path from the repository root and its text.
pub struct Doc {
pub path: String,
pub text: String,
}
/// What the check needs to know about the repository.
pub struct Repo<'a> {
/// Whether a path from the repository root exists (file or folder).
pub exists: &'a dyn Fn(&str) -> bool,
/// The top-level entries of the repository.
pub roots: &'a [String],
/// The commands of this tool.
pub commands: &'a [String],
}
pub fn run(root: &Path) -> Result<(), String> {
let mut docs = Vec::new();
let read = |rel: &str| std::fs::read_to_string(root.join(rel)).map_err(|e| format!("{rel}: {e}"));
docs.push(Doc { path: "AGENTS.md".into(), text: read("AGENTS.md")? });
let mut names = Vec::new();
let dir = root.join("skills");
for entry in std::fs::read_dir(&dir).map_err(|e| format!("skills: {e}"))?.filter_map(Result::ok) {
if entry.path().join("SKILL.md").is_file() {
names.push(entry.file_name().to_string_lossy().to_string());
}
}
names.sort();
for name in &names {
let rel = format!("skills/{name}/SKILL.md");
docs.push(Doc { text: read(&rel)?, path: rel });
}
let roots: Vec<String> = std::fs::read_dir(root)
.map_err(|e| format!("{}: {e}", root.display()))?
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
let commands = xtask_commands(crate::USAGE);
let exists = |p: &str| root.join(p).exists();
let problems = check(&docs, &Repo { exists: &exists, roots: &roots, commands: &commands });
if problems.is_empty() {
println!("skills: AGENTS.md and {} skills are consistent", names.len());
Ok(())
} else {
Err(format!("{} problem(s) in AGENTS.md and skills/:\n {}", problems.len(), problems.join("\n ")))
}
}
/// The command names in the usage text: lines of the form ` name what it does`.
pub fn xtask_commands(usage: &str) -> Vec<String> {
usage
.lines()
.filter(|l| l.starts_with(" ") && !l.starts_with(" "))
.filter_map(|l| l.split_whitespace().next())
.filter(|w| w.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
.map(str::to_owned)
.collect()
}
pub fn check(docs: &[Doc], repo: &Repo) -> Vec<String> {
let mut problems = Vec::new();
let mut skill_dirs = BTreeSet::new();
for d in docs {
if let Some(name) = d.path.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md")) {
skill_dirs.insert(name.to_owned());
}
}
for d in docs {
for t in path_mentions(&d.text) {
if resolve(&d.path, &t, repo).is_some_and(|candidates| !candidates.iter().any(|c| (repo.exists)(c))) {
problems.push(format!("{}: `{t}` does not exist", d.path));
}
}
for name in command_mentions(&d.text) {
if !repo.commands.contains(&name) {
problems.push(format!("{}: `cargo xtask {name}` is not an xtask command", d.path));
}
}
for name in skill_mentions(&d.text) {
if !(repo.exists)(&format!("skills/{name}")) {
problems.push(format!("{}: `skills/{name}` does not exist", d.path));
}
}
let chars = d.text.chars().count();
if d.path == "AGENTS.md" {
if chars > ROUTER_MAX_CHARS {
problems.push(format!("AGENTS.md is {chars} characters; a router stays under {ROUTER_MAX_CHARS}: move detail into a skill"));
}
} else if let Some(name) = d.path.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md")) {
if chars > SKILL_MAX_CHARS && !LONG_SKILLS.contains(&name) {
problems.push(format!("{}: {chars} characters; a skill stays under {SKILL_MAX_CHARS}: split it", d.path));
}
match front_matter(&d.text) {
None => problems.push(format!("{}: no front matter with name and description", d.path)),
Some((n, desc)) => {
if n != name {
problems.push(format!("{}: front matter name `{n}` is not the folder name `{name}`", d.path));
}
if desc.trim().is_empty() {
problems.push(format!("{}: front matter has no description", d.path));
}
}
}
}
}
if let Some(router) = docs.iter().find(|d| d.path == "AGENTS.md") {
let routed = router_skills(&router.text);
for name in skill_dirs.difference(&routed) {
problems.push(format!("AGENTS.md: the router table does not list skills/{name}/SKILL.md"));
}
for name in routed.difference(&skill_dirs) {
problems.push(format!("AGENTS.md: the router table lists skills/{name}/SKILL.md, which has no SKILL.md"));
}
if routed.is_empty() {
problems.push("AGENTS.md: no router table (rows naming `skills/<name>/SKILL.md`)".into());
}
}
problems
}
/// The skills named in table rows (`| ... | `skills/<name>/SKILL.md` |`).
pub fn router_skills(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in text.lines().filter(|l| l.trim_start().starts_with('|')) {
for span in code_spans(line) {
if let Some(n) = span.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md")) {
out.insert(n.to_owned());
}
}
}
out
}
/// The `name` and `description` of a `---` front matter block.
pub fn front_matter(text: &str) -> Option<(String, String)> {
let rest = text.strip_prefix("---\n")?;
let block = rest.split("\n---").next()?;
let get =
|key: &str| block.lines().find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix(':'))).map(|v| v.trim().trim_matches('"').to_owned());
Some((get("name")?, get("description")?))
}
fn code_spans(line: &str) -> Vec<&str> {
line.split('`').skip(1).step_by(2).collect()
}
/// Backticked spans and Markdown link targets that look like paths.
pub fn path_mentions(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_fence = false;
for line in text.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
out.extend(code_spans(line).into_iter().map(str::to_owned));
let mut rest = line;
while let Some(i) = rest.find("](") {
let after = &rest[i + 2..];
if let Some(end) = after.find(')') {
out.push(after[..end].to_owned());
}
rest = after;
}
}
out
}
/// The repository path a mention stands for, or `None` when it is not a checkable path.
fn resolve(from: &str, mention: &str, repo: &Repo) -> Option<Vec<String>> {
let m = mention.trim();
if m.is_empty() || m.contains("://") || m.starts_with('#') || m.starts_with('~') || m.starts_with('/') || m.starts_with("mailto:") {
return None;
}
if m.chars().any(|c| matches!(c, '*' | '<' | '>' | '$' | '{' | '}' | '…' | ' ' | '|' | '(' | ')' | '=')) {
return None;
}
let m = m.split('#').next().unwrap_or(m);
// `path:12` and `path:12-30` point into a file.
let m = match m.rsplit_once(':') {
Some((p, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit() || c == '-') => p,
_ => m,
};
let m = m.trim_end_matches('/');
let dir = from.rsplit_once('/').map_or("", |(d, _)| d);
let from_file = normalize(&format!("{dir}/{m}"));
let candidates = if m.starts_with("./") || m.starts_with("../") {
vec![from_file?]
} else {
// A bare mention is from the repository root (or the file's folder, for a skill's
// own `scripts/`), and only when its first folder is one of the repository's.
if !m.contains('/') {
return None;
}
let first = m.split('/').next()?;
if SKIPPED_ROOTS.contains(&first) || first.starts_with("target") || !repo.roots.iter().any(|r| r == first) {
return None;
}
let mut v = vec![normalize(m)?];
v.extend(from_file);
v
};
let first = candidates[0].split('/').next()?;
if SKIPPED_ROOTS.contains(&first) || first.starts_with("target") || !repo.roots.iter().any(|r| r == first) {
return None;
}
Some(candidates)
}
fn normalize(path: &str) -> Option<String> {
let mut parts: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop()?;
}
s => parts.push(s),
}
}
Some(parts.join("/"))
}
/// `cargo xtask <name>` anywhere in the text.
pub fn command_mentions(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = text;
while let Some(i) = rest.find("cargo xtask ") {
let after = &rest[i + "cargo xtask ".len()..];
let name: String = after.chars().take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-').collect();
if !name.is_empty() {
out.push(name);
}
rest = after;
}
out
}
/// `skills/<name>` anywhere in the text (a placeholder such as `skills/<name>` is not a name).
pub fn skill_mentions(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = text;
while let Some(i) = rest.find("skills/") {
// Not the tail of another path such as `.claude/skills/` or `xskills/`.
let before = rest[..i].chars().next_back();
let after = &rest[i + "skills/".len()..];
let name: String = after.chars().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect();
let name = name.trim_end_matches('.').to_owned();
let own = before.is_none_or(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_' | '~')));
if own && !name.is_empty() {
out.push(name);
}
rest = after;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const USAGE: &str = "usage: cargo xtask <command>\n\ncommands:\n layers enforce\n lists [--fix] check\n ci all\n continued line\n";
fn doc(path: &str, text: &str) -> Doc {
Doc { path: path.into(), text: text.into() }
}
fn problems(docs: &[Doc], files: &[&str]) -> Vec<String> {
let files: Vec<String> = files.iter().map(|s| s.to_string()).collect();
let exists = |p: &str| files.iter().any(|f| f == p || f.starts_with(&format!("{p}/")));
let roots = vec!["AGENTS.md".to_string(), "crates".into(), "docs".into(), "skills".into(), "plan".into()];
let commands = xtask_commands(USAGE);
check(docs, &Repo { exists: &exists, roots: &roots, commands: &commands })
}
const SKILL: &str = "---\nname: solidcraft-a\ndescription: Use when a.\n---\n# A\n";
fn router(table: &str) -> Doc {
doc("AGENTS.md", &format!("# Router\n\n| Working on | Read |\n|---|---|\n{table}\n"))
}
#[test]
fn commands_come_from_the_usage_text() {
assert_eq!(xtask_commands(USAGE), ["layers", "lists", "ci"]);
}
#[test]
fn a_consistent_router_passes() {
let docs = [router("| a | `skills/solidcraft-a/SKILL.md` |"), doc("skills/solidcraft-a/SKILL.md", SKILL)];
assert_eq!(problems(&docs, &["skills/solidcraft-a/SKILL.md"]), Vec::<String>::new());
}
#[test]
fn a_missing_path_is_reported_and_placeholders_are_not() {
let text = "See `docs/gone.md`, `docs/here.md`, `crates/doc/src/format/v<N>/`, `crates/*/x.rs`, `plan/STATUS.md`, `~/.claude/skills/x`, `src/lib.rs`, `lib.rs`.";
let docs = [router("| a | `skills/solidcraft-a/SKILL.md` |"), doc("skills/solidcraft-a/SKILL.md", &format!("{SKILL}{text}"))];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md", "docs/here.md"]);
assert_eq!(p, ["skills/solidcraft-a/SKILL.md: `docs/gone.md` does not exist"]);
}
#[test]
fn links_resolve_from_the_file_and_fences_are_skipped() {
let text = "[ok](../../docs/here.md#top) [bad](../../docs/no.md) [web](https://x.io/docs/no.md)\n```\n`docs/in-fence.md`\n```\n";
let docs = [router("| a | `skills/solidcraft-a/SKILL.md` |"), doc("skills/solidcraft-a/SKILL.md", &format!("{SKILL}{text}"))];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md", "docs/here.md"]);
assert_eq!(p, ["skills/solidcraft-a/SKILL.md: `../../docs/no.md` does not exist"]);
}
#[test]
fn a_skills_own_relative_path_resolves_from_its_folder() {
let docs = [
router("| a | `skills/solidcraft-a/SKILL.md` |"),
doc("skills/solidcraft-a/SKILL.md", &format!("{SKILL}`docs/scripts/x.py` `docs/other/y.py`")),
];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md", "skills/solidcraft-a/docs/scripts/x.py"]);
assert_eq!(p, ["skills/solidcraft-a/SKILL.md: `docs/other/y.py` does not exist"]);
}
#[test]
fn a_line_number_suffix_is_ignored() {
let docs = [router("| a | `skills/solidcraft-a/SKILL.md` |"), doc("skills/solidcraft-a/SKILL.md", &format!("{SKILL}`docs/here.md:12-30`"))];
assert!(problems(&docs, &["skills/solidcraft-a/SKILL.md", "docs/here.md"]).is_empty());
}
#[test]
fn an_unknown_xtask_command_is_reported() {
let docs = [
router("| a | `skills/solidcraft-a/SKILL.md` |"),
doc("skills/solidcraft-a/SKILL.md", &format!("{SKILL}Run `cargo xtask lists --fix` then `cargo xtask nope`.")),
];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md"]);
assert_eq!(p, ["skills/solidcraft-a/SKILL.md: `cargo xtask nope` is not an xtask command"]);
}
#[test]
fn a_skill_that_does_not_exist_is_reported() {
let docs =
[router("| a | `skills/solidcraft-a/SKILL.md` |\n| b | `skills/solidcraft-b/SKILL.md` |"), doc("skills/solidcraft-a/SKILL.md", SKILL)];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md"]);
assert!(p.contains(&"AGENTS.md: `skills/solidcraft-b` does not exist".to_string()), "{p:?}");
assert!(p.contains(&"AGENTS.md: the router table lists skills/solidcraft-b/SKILL.md, which has no SKILL.md".to_string()), "{p:?}");
}
#[test]
fn a_skill_missing_from_the_table_is_reported() {
let docs = [
router("| a | `skills/solidcraft-a/SKILL.md` |"),
doc("skills/solidcraft-a/SKILL.md", SKILL),
doc("skills/solidcraft-c/SKILL.md", &SKILL.replace("-a", "-c")),
];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md", "skills/solidcraft-c/SKILL.md"]);
assert_eq!(p, ["AGENTS.md: the router table does not list skills/solidcraft-c/SKILL.md"]);
}
#[test]
fn prose_mentions_of_skills_outside_the_table_do_not_route() {
let docs = [doc("AGENTS.md", "Install into `~/.claude/skills/` or see skills/solidcraft-a.\n"), doc("skills/solidcraft-a/SKILL.md", SKILL)];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md"]);
assert_eq!(
p,
[
"AGENTS.md: the router table does not list skills/solidcraft-a/SKILL.md",
"AGENTS.md: no router table (rows naming `skills/<name>/SKILL.md`)"
]
);
}
#[test]
fn front_matter_must_match_the_folder() {
let docs =
[router("| a | `skills/solidcraft-a/SKILL.md` |"), doc("skills/solidcraft-a/SKILL.md", "---\nname: other\ndescription: \"\"\n---\n")];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md"]);
assert_eq!(
p,
[
"skills/solidcraft-a/SKILL.md: front matter name `other` is not the folder name `solidcraft-a`",
"skills/solidcraft-a/SKILL.md: front matter has no description"
]
);
assert_eq!(front_matter("no front matter"), None);
}
#[test]
fn sizes_are_capped_except_for_the_older_skills() {
let long = "x".repeat(SKILL_MAX_CHARS);
let docs = [
doc("AGENTS.md", &format!("| `skills/solidcraft-a/SKILL.md` |{}", "y".repeat(ROUTER_MAX_CHARS))),
doc("skills/solidcraft-a/SKILL.md", &format!("{SKILL}{long}")),
doc("skills/solidcraft-modeling/SKILL.md", &format!("{}{long}", SKILL.replace("-a", "-modeling"))),
];
let p = problems(&docs, &["skills/solidcraft-a/SKILL.md", "skills/solidcraft-modeling/SKILL.md"]);
assert!(p.iter().any(|m| m.starts_with("AGENTS.md is ")), "{p:?}");
assert!(p.iter().any(|m| m.starts_with("skills/solidcraft-a/SKILL.md: ") && m.contains("split it")), "{p:?}");
assert!(!p.iter().any(|m| m.contains("solidcraft-modeling/SKILL.md: ") && m.contains("split it")), "{p:?}");
}
#[test]
fn mentions_are_found() {
assert_eq!(command_mentions("run cargo xtask ci, cargo xtask lists --fix; `cargo xtask`"), ["ci", "lists"]);
assert_eq!(skill_mentions("skills/a-b/SKILL.md, ~/.claude/skills/x, skills/<name>, skills/README.md."), ["a-b", "README.md"]);
}
/// The real AGENTS.md and skills pass, so a broken citation fails `cargo test -p xtask`.
#[test]
fn this_repository_passes() {
if let Err(e) = run(&crate::root()) {
panic!("{e}");
}
}
}