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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/command-ref.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: command reference

# Regenerates skills/unity-pipeline/references/editor-commands.md from the com.unity.pipeline
# package sources on the public UPM registry. A bare runner with the .NET SDK is the whole
# requirement — no Unity Editor, no license, no `unity --json command` dump.
#
# Triggered by hand, or by the release-watch workflow, which polls the same versions endpoint.

on:
workflow_dispatch:
inputs:
version:
description: 'com.unity.pipeline version to document, or "latest"'
type: string
default: latest
workflow_call:
inputs:
version:
description: 'com.unity.pipeline version to document, or "latest"'
type: string
default: latest

permissions:
contents: read

jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

# Parsing regressions surface here, against the checked-in fixture package, before the
# generator is let near the real reference file.
- name: Verify the generator against its fixture package
run: tools/CommandRefGen/tests/run.sh

- name: Regenerate the command reference
env:
VERSION: ${{ inputs.version }}
run: |
set -o pipefail
dotnet run --project tools/CommandRefGen -- --version "$VERSION" 2>&1 | tee gen.log

- name: Diff summary
if: always()
run: |
{
echo '## Command reference'
echo
echo '```'
sed -n '/^diff against /,/commands)$/p' gen.log
echo '```'
echo
echo '### Warnings'
echo
echo '```'
grep '^warning:' gen.log || echo 'none'
echo '```'
} >>"$GITHUB_STEP_SUMMARY"
git diff --stat -- skills/

- uses: actions/upload-artifact@v4
with:
name: editor-commands
path: |
skills/unity-pipeline/references/editor-commands.md
gen.log
18 changes: 10 additions & 8 deletions skills/unity-pipeline/references/editor-commands.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<!--
The command entries below are GENERATED — do not hand-edit them. Regenerate with:
unity --json command --project-path <project> > cmds.json
node tools/gen-commands-md.js cmds.json out.md
Hand-maintained parts the generator does NOT produce (its own emitted preamble is
older than the one in this file): this comment, the preamble paragraphs, the
Contents section, and the "RuntimeOnly commands" section at the bottom.
Regeneration replaces the whole file — re-apply the hand-maintained parts after,
and update the version numbers in the preamble.
GENERATED FILE — do not hand-edit any part of it, including the preamble and the
Contents list. Regenerate from the com.unity.pipeline package sources with:
dotnet run --project tools/CommandRefGen -- --version latest
No Unity Editor and no `unity --json command` dump is involved. Field notes that are
not derivable from the package source (observed behaviour, links to other skill files)
live in tools/CommandRefGen/annotations.json — add them there, not here.

This copy still carries the content of the retired two-step pipeline (a live-editor dump
plus a hand-spliced RuntimeOnly section). The first CommandRefGen run replaces the whole
file, including these lines, and its diff summary reports what moved.
-->

# Editor command reference
Expand Down
2 changes: 2 additions & 0 deletions tools/CommandRefGen/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bin/
obj/
25 changes: 25 additions & 0 deletions tools/CommandRefGen/CommandRefGen.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>CommandRefGen</AssemblyName>
<RootNamespace>CommandRefGen</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Fixture sources under tests/ are parsed as data, never compiled into this project. -->
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>

<ItemGroup>
<Compile Include="src/**/*.cs" />
</ItemGroup>

<ItemGroup>
<!-- Roslyn is the only external dependency. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
</ItemGroup>

</Project>
97 changes: 97 additions & 0 deletions tools/CommandRefGen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# CommandRefGen

Generates [`skills/unity-pipeline/references/editor-commands.md`](../../skills/unity-pipeline/references/editor-commands.md)
from the `com.unity.pipeline` package sources. No Unity Editor, no license, no
`unity --json command` dump — a machine with the .NET 8 SDK is the whole requirement.

The package is public on the UPM registry with no auth, and its C# sources carry every
`[CliCommand]`/`[CliArg]` declaration, including the `RuntimeOnly` commands the live listing
filters out and the version-gated ones that only exist on newer Unity. The source is therefore
a strict superset of the live listing, and the only source that needs no running editor.

## Usage

```bash
# document the newest version in the registry, updating the reference file in place
dotnet run --project tools/CommandRefGen -- --version latest

# pin a version
dotnet run --project tools/CommandRefGen -- --version 0.4.0-exp.1

# CI drift check: exits 3 when the reference file is out of date
dotnet run --project tools/CommandRefGen -- --version latest --check
```

A version bump is a rerun with a different `--version`. There is no code to edit.

Every run prints a diff summary against the file already on disk — added, removed and changed
commands and arguments — plus warnings, to stderr. `--stdout` puts the document on stdout so
stderr stays a clean log.

Offline, when the registry is unreachable (filtered egress, an air-gapped runner):

```bash
dotnet run --project tools/CommandRefGen -- --tarball ./com.unity.pipeline-0.4.0-exp.1.tgz
dotnet run --project tools/CommandRefGen -- --source-dir ./unpacked/package
```

Both read the version from the package's `package.json`. `--help` lists every option.

## What it owns

The generator owns the output file end to end: the do-not-edit banner, the preamble, the
Contents list, every category section and the RuntimeOnly section. Nothing in it is
hand-maintained, which is the drift trap the old two-step pipeline (a dump plus a hand-spliced
RuntimeOnly section) existed to create.

Field notes that are true but not derivable from the source — behaviour observed in the field,
links to the other skill files — live in [`annotations.json`](annotations.json) and are merged
into the matching command's entry. That keeps hand-maintained prose in a file the generator
reads, never in the file it writes. A note whose command no longer exists is reported as unused.

## Rules it applies

- **Roslyn, not regexes.** Attributes span lines, descriptions are built by concatenation or
written as verbatim strings, and defaults live in two places.
- **Defaults.** The attribute's `DefaultValue` wins; otherwise the C# parameter default applies.
Well-known constants are folded, including the package's `float.MinValue` "leave Unity's own
value alone" sentinel, which renders as `-3.40282347e+38` exactly as the live listing does.
- **Required.** An explicit `Required` (or `IsRequired`) on `[CliArg]` decides it. Without one,
an argument with no default of any kind is required. The first run against a new package
version reports any argument whose required-ness moved, in the diff summary.
- **Descriptions are never shortened.** Newlines and whitespace runs collapse to single spaces
so a multi-line description cannot break the markdown list, and that is the only edit. An
emergency ceiling (`--max-description`, 1000 chars) exists so a runaway string cannot wreck
the file; hitting it prints a warning naming the command — it is never a silent cut.
- **Version gates.** A command compiled under `#if UNITY_6000_7_OR_NEWER` is marked with its
Unity floor instead of being presented as universally available. Non-version gates
(`#if ENABLE_INPUT_SYSTEM`) are reported as conditional. Each file is parsed twice — once with
every `#if` symbol defined, once with none — so `#else` and `#if !SYMBOL` branches are reached
too; a command declared in both branches is reported as ungated, because it exists either way.
- **`Tests/` is excluded.** The package's test assembly registers throwaway commands
(`log_editor`, `test_types`, `test_structured`) to prove registration works. A
`Commands/Tests/` directory is a different thing — that is where `run_tests` and friends live,
and it is kept.
- **Categories come from the source tree**, from the directory under `Commands/`. Display names
and section order live in `Categories.cs`; an unmapped directory still gets a section, with its
name humanized and a warning naming it, so the mapping is extended on purpose rather than by
accident.
- **Project-defined commands are out of scope.** The reference documents the package surface.

## Tests

```bash
tools/CommandRefGen/tests/run.sh # compare against the golden file
tools/CommandRefGen/tests/run.sh --update # accept the current output as the new golden
```

`tests/fixture-package/` is a small source tree that exercises each rule above — multi-line
attributes, both default sources, an explicit `Required` on an argument that has a default,
the `float.MinValue` sentinel, a verbatim multi-line description, a `#if` version gate, a
compound gate, a command declared in both branches of an `#if`, `RuntimeOnly`,
`MainThreadRequired = false`, a parameter with no `[CliArg]`, an unmapped category directory,
and a `Tests/` assembly whose commands must not appear. The script also asserts the warnings
the fixture is built to provoke, and both `--check` outcomes.

CI runs it before the generator touches the real reference file
([`.github/workflows/command-ref.yml`](../../.github/workflows/command-ref.yml)).
21 changes: 21 additions & 0 deletions tools/CommandRefGen/annotations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
// Field notes merged into the generated reference by tools/CommandRefGen.
//
// Everything a command's entry says is read out of the package source, except what is in
// here: behaviour observed in the field, and links to the other skill files. Keeping these
// notes out of the generated document is what lets the generator own that file end to end.
//
// Each key is a command name; the value is markdown appended after the command's
// description (and after any version-gate note the generator derives from `#if`).
// The generator warns about a key that matches no command in the package version it read.
"commands": {
"simulate_pointer":
"**Feeds a virtual device, not the OS cursor — UI raycasts work, but game code polling `Mouse.current` sees the virtual mouse; verify the click landed via its response, not via assumption.**",

"capture_runtime_element":
"Unlike the other RuntimeOnly commands it does not execute against the editor server; on an editor older than its Unity floor it fails with exit code 6, indistinguishably from an unknown command name.",

"quit":
"Against the editor this is the play-mode/app quit path — for shutting down the editor itself, prefer `eval EditorApplication.Exit(0)` (see [lifecycle-recovery.md](lifecycle-recovery.md))."
}
}
65 changes: 65 additions & 0 deletions tools/CommandRefGen/src/Annotations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Text.Json;

namespace CommandRefGen;

/// <summary>
/// Field notes that are true but not derivable from the package source — observed behaviour,
/// cross-references to other skill files. They live outside the generated document so the
/// generator can own that file end to end; the generator merges them into a command's entry.
/// </summary>
internal sealed class Annotations
{
private readonly Dictionary<string, string> _notes;

private Annotations(Dictionary<string, string> notes) => _notes = notes;

public static Annotations Empty { get; } = new([]);

/// <summary>The note appended after a command's description, or "" when there is none.</summary>
public string NoteFor(string command) => _notes.GetValueOrDefault(command, "");

/// <summary>Names that no longer match a command — stale notes to clean up.</summary>
public IEnumerable<string> Orphans(IEnumerable<string> commandNames)
{
var known = commandNames.ToHashSet(StringComparer.Ordinal);
return _notes.Keys.Where(k => !known.Contains(k)).OrderBy(k => k, StringComparer.Ordinal);
}

public static Annotations Load(string path)
{
if (!File.Exists(path))
{
Log.Info($"annotations: {path} not found, continuing without field notes");
return Empty;
}

using var doc = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
});

var notes = new Dictionary<string, string>(StringComparer.Ordinal);
if (doc.RootElement.TryGetProperty("commands", out var commands)
&& commands.ValueKind == JsonValueKind.Object)
{
foreach (var entry in commands.EnumerateObject())
{
var note = entry.Value.ValueKind switch
{
JsonValueKind.String => entry.Value.GetString(),
JsonValueKind.Object when entry.Value.TryGetProperty("note", out var n) => n.GetString(),
_ => null,
};

if (string.IsNullOrWhiteSpace(note))
Log.Warn($"annotations: '{entry.Name}' has no note text; ignoring it");
else
notes[entry.Name] = note.Trim();
}
}

Log.Info($"annotations: {notes.Count} field note(s) from {path}");
return new Annotations(notes);
}
}
Loading