diff --git a/.editorconfig b/.editorconfig
index 00cca3d..9dfc746 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -23,6 +23,7 @@ tab_width = 2
# New line preferences
end_of_line = lf
insert_final_newline = true
+charset = utf-8
# ReSharper properties
resharper_html_attribute_indent = single_indent
@@ -32,7 +33,6 @@ resharper_check_namespace_highlighting= none
# Development files
[*.{cs,csx,cshtml,csproj,razor,sln,props,targets,json,yml,gitignore}]
-charset = "utf-8"
trim_trailing_whitespace = true
#### .NET Coding Conventions ####
@@ -291,6 +291,11 @@ dotnet_diagnostic.ca1308.severity = none
# ganda repo audit (https://github.com/TimeWarpEngineering/timewarp-ganda)
# ═══════════════════════════════════════════════════════════════════════════════
[ganda.audit]
+# This repo is the TimeWarp.SourceGenerators package. A root PackageReference
+# would consume the published package from the project that produces it.
+# TW0007 is implemented and tested here (task 028). Task 030.
+global-usings-analyzer.severity = off
+
# kebab-path-names prune (additive, basename match):
# .nuget-cache = local package cache (gitignored; package basenames are not ours)
# generated / .generated / build = generator/build outputs
diff --git a/.githooks/Directory.Build.props b/.githooks/Directory.Build.props
new file mode 100644
index 0000000..6c193e6
--- /dev/null
+++ b/.githooks/Directory.Build.props
@@ -0,0 +1,11 @@
+
+
+
+
+ net11.0
+
+
diff --git a/.githooks/post-checkout b/.githooks/post-checkout
new file mode 120000
index 0000000..79fc8cd
--- /dev/null
+++ b/.githooks/post-checkout
@@ -0,0 +1 @@
+post-checkout.cs
\ No newline at end of file
diff --git a/.githooks/post-checkout.cs b/.githooks/post-checkout.cs
new file mode 100755
index 0000000..9e4f06b
--- /dev/null
+++ b/.githooks/post-checkout.cs
@@ -0,0 +1,33 @@
+#!/usr/bin/env -S dotnet --
+#:package TimeWarp.Amuru
+#:package TimeWarp.Amuru.Tools
+#:property NoWarn=CA2007
+#:property RunAnalyzers=false
+
+// Dispatcher for branch checkouts only ($3 == 1): ganda repo attest.
+// Exit 0 always.
+using TimeWarp.Amuru;
+
+// git post-checkout
+// branch_flag is 1 for branch checkout, 0 for file checkout.
+if (args.Length < 3 || args[2] != "1")
+{
+ return 0;
+}
+
+string? root = Git.FindRoot();
+if (root is null)
+{
+ return 0;
+}
+
+if (!string.Equals(Environment.GetEnvironmentVariable("GANDA_ATTEST_HOOK"), "0", StringComparison.Ordinal))
+{
+ await Shell.Builder("ganda")
+ .WithArguments("repo", "attest")
+ .WithWorkingDirectory(root)
+ .WithNoValidation()
+ .RunAsync();
+}
+
+return 0;
diff --git a/.githooks/post-commit.cs b/.githooks/post-commit.cs
index a6f0920..41673a6 100755
--- a/.githooks/post-commit.cs
+++ b/.githooks/post-commit.cs
@@ -2,7 +2,10 @@
#:package TimeWarp.Amuru
#:package TimeWarp.Amuru.Tools
#:property NoWarn=CA2007
+#:property RunAnalyzers=false
+// Dispatcher: ganda repo attest.
+// Exit 0 always — commit already landed.
using TimeWarp.Amuru;
string? root = Git.FindRoot();
@@ -11,9 +14,13 @@
return 0;
}
-await Shell.Builder("ganda")
- .WithArguments("memsearch", "index-repo", "--background")
- .WithWorkingDirectory(root)
- .WithNoValidation()
- .RunAsync();
-return 0;
\ No newline at end of file
+if (!string.Equals(Environment.GetEnvironmentVariable("GANDA_ATTEST_HOOK"), "0", StringComparison.Ordinal))
+{
+ await Shell.Builder("ganda")
+ .WithArguments("repo", "attest")
+ .WithWorkingDirectory(root)
+ .WithNoValidation()
+ .RunAsync();
+}
+
+return 0;
diff --git a/.githooks/post-merge.cs b/.githooks/post-merge.cs
index a6f0920..2a1908a 100755
--- a/.githooks/post-merge.cs
+++ b/.githooks/post-merge.cs
@@ -2,7 +2,10 @@
#:package TimeWarp.Amuru
#:package TimeWarp.Amuru.Tools
#:property NoWarn=CA2007
+#:property RunAnalyzers=false
+// Dispatcher: ganda repo attest.
+// Exit 0 always — cannot undo an already-completed merge.
using TimeWarp.Amuru;
string? root = Git.FindRoot();
@@ -11,9 +14,13 @@
return 0;
}
-await Shell.Builder("ganda")
- .WithArguments("memsearch", "index-repo", "--background")
- .WithWorkingDirectory(root)
- .WithNoValidation()
- .RunAsync();
-return 0;
\ No newline at end of file
+if (!string.Equals(Environment.GetEnvironmentVariable("GANDA_ATTEST_HOOK"), "0", StringComparison.Ordinal))
+{
+ await Shell.Builder("ganda")
+ .WithArguments("repo", "attest")
+ .WithWorkingDirectory(root)
+ .WithNoValidation()
+ .RunAsync();
+}
+
+return 0;
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 120000
index 0000000..4f22eb4
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1 @@
+pre-commit.cs
\ No newline at end of file
diff --git a/.githooks/pre-commit.cs b/.githooks/pre-commit.cs
new file mode 100755
index 0000000..68958ac
--- /dev/null
+++ b/.githooks/pre-commit.cs
@@ -0,0 +1,34 @@
+#!/usr/bin/env -S dotnet --
+#:package TimeWarp.Amuru
+#:package TimeWarp.Amuru.Tools
+#:property NoWarn=CA2007;CA1849;RS0030
+#:property RunAnalyzers=false
+
+// Refuse commits while HEAD is master or main.
+// Escape hatch: git commit --no-verify
+using TimeWarp.Amuru;
+
+string? root = Git.FindRoot();
+if (root is null)
+{
+ return 0;
+}
+
+CommandOutput revParse = await Shell.Builder("git")
+ .WithArguments("rev-parse", "--abbrev-ref", "HEAD")
+ .WithWorkingDirectory(root)
+ .WithNoValidation()
+ .CaptureAsync();
+
+string branch = revParse.Stdout.Trim();
+if (branch is "master" or "main")
+{
+#pragma warning disable RS0030, CA1849 // hook runfile: stderr to git, no ITerminal host
+ Console.Error.WriteLine($"Refusing commit on '{branch}'.");
+ Console.Error.WriteLine("Do not commit on master/main — use a feature worktree/branch (e.g. ganda worktree / task branch).");
+ Console.Error.WriteLine("Escape hatch (intentional only): git commit --no-verify");
+#pragma warning restore RS0030, CA1849
+ return 1;
+}
+
+return 0;
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 120000
index 0000000..ab587a2
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1 @@
+pre-push.cs
\ No newline at end of file
diff --git a/.githooks/pre-push.cs b/.githooks/pre-push.cs
new file mode 100755
index 0000000..537ad61
--- /dev/null
+++ b/.githooks/pre-push.cs
@@ -0,0 +1,104 @@
+#!/usr/bin/env -S dotnet --
+#:package TimeWarp.Amuru
+#:package TimeWarp.Amuru.Tools
+#:property NoWarn=CA2007;CA1849;RS0030
+#:property RunAnalyzers=false
+
+// Refuse pushes that update home branches (master/main) while HEAD is master or main.
+// Allow other dests (feature/*, etc.) so origin-home can publish a missing --into ref.
+// Allow refs/tags/* (release tags) and refs/ganda/* (claims CAS) when every dest is one of those.
+// Mixed tag + branch batches stay refused while HEAD is home.
+// An empty dest list updates no branch. Git omits refs that are already up to date and
+// refs it has already rejected as non-fast-forward, and runs this hook on what remains.
+// Refusing that empty list hides the non-fast-forward error, so the shared claims push
+// (pr merge cleanup and worktree gc) cannot retry while HEAD is master.
+// A branch update lists its dest.
+// Refuse pushing a local task/* branch to a home branch from any HEAD (a task worktree that
+// still tracks origin/). Raw-sha sources (kanban publish merge commit) stay allowed.
+// Commits on master stay blocked by pre-commit. Escape hatch: git push --no-verify
+using TimeWarp.Amuru;
+
+string? root = Git.FindRoot();
+if (root is null)
+{
+ return 0;
+}
+
+// stdin: (one line per dest)
+List remoteRefs = [];
+List taskToHome = [];
+string? line;
+while ((line = Console.In.ReadLine()) is not null)
+{
+ if (string.IsNullOrWhiteSpace(line))
+ continue;
+
+ string[] parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length >= 3)
+ {
+ remoteRefs.Add(parts[2]);
+ if (parts[0].StartsWith("refs/heads/task/", StringComparison.Ordinal) && IsHomeBranchDest(parts[2]))
+ taskToHome.Add($"{parts[0]["refs/heads/".Length..]} -> {parts[2]["refs/heads/".Length..]}");
+ }
+}
+
+if (taskToHome.Count > 0)
+{
+#pragma warning disable RS0030, CA1849 // hook runfile: stderr to git, no ITerminal host
+ foreach (string push in taskToHome)
+ Console.Error.WriteLine($"Refusing push of task branch to home: {push}.");
+ Console.Error.WriteLine("Task branches publish to origin/ and land on home via PR.");
+ Console.Error.WriteLine("Fix tracking: ganda repo audit --fix --checks task-branch-upstream, then git push.");
+ Console.Error.WriteLine("Escape hatch (intentional only): git push --no-verify");
+#pragma warning restore RS0030, CA1849
+ return 1;
+}
+
+// Nothing in this push will update a ref. Allow it so git can report
+// "everything up-to-date" or the non-fast-forward rejection it already decided.
+if (remoteRefs.Count == 0)
+{
+ return 0;
+}
+
+if (remoteRefs.All(IsExemptDest))
+{
+ return 0;
+}
+
+CommandOutput revParse = await Shell.Builder("git")
+ .WithArguments("rev-parse", "--abbrev-ref", "HEAD")
+ .WithWorkingDirectory(root)
+ .WithNoValidation()
+ .CaptureAsync();
+
+string branch = revParse.Stdout.Trim();
+if (branch is "master" or "main")
+{
+ bool updatesHome = remoteRefs.Any(IsHomeBranchDest);
+ bool mixesTagAndBranch = remoteRefs.Any(IsTagDest) && remoteRefs.Any(IsBranchDest);
+ if (updatesHome || mixesTagAndBranch)
+ {
+#pragma warning disable RS0030, CA1849 // hook runfile: stderr to git, no ITerminal host
+ Console.Error.WriteLine($"Refusing push while HEAD is '{branch}'.");
+ Console.Error.WriteLine("Do not push from master/main — use a feature worktree/branch (e.g. ganda worktree / task branch).");
+ Console.Error.WriteLine("Escape hatch (intentional only): git push --no-verify");
+#pragma warning restore RS0030, CA1849
+ return 1;
+ }
+}
+
+return 0;
+
+static bool IsExemptDest(string remoteRef) =>
+ IsTagDest(remoteRef)
+ || remoteRef.StartsWith("refs/ganda/", StringComparison.Ordinal);
+
+static bool IsTagDest(string remoteRef) =>
+ remoteRef.StartsWith("refs/tags/", StringComparison.Ordinal);
+
+static bool IsBranchDest(string remoteRef) =>
+ remoteRef.StartsWith("refs/heads/", StringComparison.Ordinal);
+
+static bool IsHomeBranchDest(string remoteRef) =>
+ remoteRef is "refs/heads/master" or "refs/heads/main";
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index 8fbcab3..69077b3 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -14,7 +14,7 @@ on:
- 'source/Directory.Build.props'
- 'nuget.config'
pull_request:
- branches: [master]
+ branches: [master, 'feature/**']
paths:
- 'source/**'
- 'tests/**'
@@ -49,7 +49,11 @@ jobs:
fetch-depth: 0
- uses: actions/setup-dotnet@v5
with:
- dotnet-version: '10.0.x'
+ # Dev CLI targets net11 because the audit pin TimeWarp.Amuru 3.0.0-beta.1 is net11-only.
+ # Net10 stays installed so the test projects still build. Task 030.
+ dotnet-version: |
+ 10.0.x
+ 11.0.100-rc.3.26522.148
# Prefer NuGet Trusted Publishing (OIDC). Fall back to org API key when no trust
# policy exists yet for this package (common on first release under a new policy).
diff --git a/.gitignore b/.gitignore
index c553a41..e1a9645 100644
--- a/.gitignore
+++ b/.gitignore
@@ -317,3 +317,34 @@ __pycache__/
/.aider.tags.cache.v3/cache.db-wal
/.aider.input.history
.nuget-cache/
+
+# Routine journals, mission briefs, and progress logs beside kitchens (local; not product)
+*.journal.json
+stacked-task-set*.resume.json
+*.next.md
+*.progress.log
+
+# Review-round markers beside kitchens (local; not product)
+**/review/round-*/base.sha
+**/review/round-*/closed
+
+# Herdr pane-command oracle logs beside kitchens (local; not product)
+oracle-*.log
+
+# Task-work host progress lines beside kitchens (local; not product)
+task-work.progress.log
+
+# Mission heartbeat in the claim worktree (local; not product)
+mission.heartbeat
+
+# VS Code settings: Peacock rewrites this file; vscode-window-icon --fix writes it locally (not tracked)
+.vscode/settings.json
+
+# Per-user tool state (XDG .local/, e.g. gh device-id; local; not product)
+.local/
+# AOT dev CLI (self-install output; local; not product)
+bin/dev
+# AOT dev CLI debug companion (self-install output; local; not product)
+bin/dev.dbg
+# AOT dev CLI on Windows (self-install output; local; not product)
+bin/dev.exe
diff --git a/.memsearch.toml b/.memsearch.toml
deleted file mode 100644
index 00bcc0e..0000000
--- a/.memsearch.toml
+++ /dev/null
@@ -1,50 +0,0 @@
-# MemSearch repo knowledge index — TimeWarp convention
-#
-# SPEC (proposed extension)
-# -------------------------
-# memsearch's stock CLI ignores [index]; this file is honored by:
-# ganda memsearch index-repo
-# ganda hooks install memsearch (post-commit / post-merge runfiles)
-#
-# Purpose: index markdown knowledge in THIS repo only. One Milvus collection
-# per repo (auto-derived from repo path). Global DB file stays at
-# ~/.memsearch/milvus.db unless [milvus].uri is overridden below.
-#
-# [index].paths
-# Directory roots (relative to repo root). memsearch indexes all .md files
-# under each path recursively. Missing paths are skipped.
-#
-# [index].exclude_paths
-# Directory paths removed from the resolved path list before indexing.
-# Does not prune subdirectories when a parent path (e.g. "kanban/") is listed.
-#
-# [index].exclude_globs
-# Reserved — file-level globs (e.g. "**/task-template.md"). Not implemented in v1.
-#
-# [index].enabled
-# false disables hook indexing (conversation plugins still work).
-#
-# [milvus].collection
-# Empty = auto-derive ms__<8char_sha256> from repo root.
-#
-# Search this repo only:
-# memsearch search "query" -c --source-prefix "$(pwd)/kanban/"
-
-[index]
-enabled = true
-paths = [
- "kanban",
- "documentation",
- "okf",
- "adr",
- ".memsearch/memory",
-]
-exclude_paths = []
-exclude_globs = [
- "**/task-template.md",
- "**/overview.md",
-]
-
-[milvus]
-# uri = ".memsearch/milvus.db" # optional per-repo DB file
-collection = ""
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 3201e56..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "workbench.colorCustomizations": {
- "activityBar.activeBackground": "#82e5ee",
- "activityBar.background": "#82e5ee",
- "activityBar.foreground": "#15202b",
- "activityBar.inactiveForeground": "#15202b99",
- "activityBarBadge.background": "#e857dc",
- "activityBarBadge.foreground": "#15202b",
- "commandCenter.border": "#15202b99",
- "sash.hoverBorder": "#82e5ee",
- "statusBar.background": "#55dbe8",
- "statusBar.foreground": "#15202b",
- "statusBarItem.hoverBackground": "#28d1e2",
- "statusBarItem.remoteBackground": "#e43ad5",
- "statusBarItem.remoteForeground": "#15202b",
- "titleBar.activeBackground": "#55dbe8",
- "titleBar.activeForeground": "#15202b",
- "titleBar.inactiveBackground": "#55dbe899",
- "titleBar.inactiveForeground": "#15202b99",
- "activityBarTop.activeBackground": "#82e5ee",
- "activityBarTop.background": "#82e5ee",
- "activityBarTop.foreground": "#15202b",
- "activityBarTop.inactiveForeground": "#15202b99",
- "commandCenter.foreground": "#15202b",
- "statusBar.debuggingBackground": "#55dbe8",
- "statusBar.debuggingForeground": "#15202b"
- },
- "peacock.remoteColor": "#55dbe8",
- "cSpell.words": [
- "assemblyinfo",
- "buildtransitive",
- "contentfiles",
- "nugets"
- ],
- "window.title": "timewarp-source-generators · ${rootName}${separator}${activeEditorShort}",
- "timewarp.blurImagePath": "assets/timewarp-source-generators-avatar.svg"
-}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b7be34c..2808d7b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,10 +19,10 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/glossary.md b/glossary.md
new file mode 100644
index 0000000..e91d5fa
--- /dev/null
+++ b/glossary.md
@@ -0,0 +1,17 @@
+# Glossary
+
+Shared meanings for words this repository uses.
+
+When an agent or reviewer has to ask what a term or acronym means, or sees one used two ways, the definition goes into `glossary.md` in the same PR.
+
+## Terms
+
+| Term | Meaning |
+|------|---------|
+| _example_ | Replace this row with a repo-specific word and its one meaning. This example row is not a glossary entry. |
+
+## Acronyms
+
+| Acronym | Expansion |
+|---------|-----------|
+| _example_ | Replace this row with a repo-specific short form and what it expands to. This example row is not a glossary entry. |
diff --git a/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/disposition.md b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/disposition.md
new file mode 100644
index 0000000..9d93f20
--- /dev/null
+++ b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/disposition.md
@@ -0,0 +1,18 @@
+# Disposition — task 030
+
+**Date:** 2026-11-08
+**Outcome:** clean
+**Rounds:** 1
+**Final open count:** 0
+
+## Summary
+
+The round-1 general review (effort 3, general axis) raised no findings. The live gates passed in the claim worktree. `ganda repo audit` exits 0 with Passed 36, Failed 0, Skipped 3. `./bin/dev build` reported 0 warnings and 0 errors. `./bin/dev test` passed 11/11. `check-version` sees `TimeWarp.SourceGenerators`. Nuru and DevCli are at 4.0.0-beta.11. The markdown generator split is a pure move under the 500-line cap. `global-usings-analyzer` is off with the required reason comment, and there is no self-package reference.
+
+## Exception log (if accepted-exceptions)
+
+None.
+
+## Escalations
+
+- None. Follow-up after .NET 11 GA: change the CI SDK pin from `11.0.100-rc.3.26522.148` to `11.0.x`.
diff --git a/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/review-framework.md b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/review-framework.md
new file mode 100644
index 0000000..cbefe88
--- /dev/null
+++ b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/review-framework.md
@@ -0,0 +1,28 @@
+# Review framework
+
+## Budget (by-diff)
+
+- Lines changed: 1590
+- Effort: 3
+- TCB hits: none
+- Roster axes: general
+- Turn cap: 200 (--max-turns; cursor uncapped)
+
+## Scope
+
+**Date:** 2026-11-08
+**Host task:** kanban/in-progress/030-bring-ganda-repo-audit-clean/
+**Diff scope:** branch `task/030-bring-ganda-repo-audit-clean` (HEAD `36a03b1`) vs merge-base `c225708` with `origin/master`
+**Plan / brief:** task.md `## Goal (implementer restatement)` and `## Requirements`. Make `ganda repo audit` pass, move Nuru/DevCli to 4.0.0-beta.11, split `markdown-docs-generator.cs` under 500 lines, and turn `global-usings-analyzer` off with a reason comment
+**Effort:** 3 (by-diff budget); roster axis general only
+**Reviewer roster:** general
+**Session IDs:** review oracle Claude (claude-opus-5-5), ganda task work headless
+
+## Ground rules
+
+- Reviewers are read-only on product code; they write only under `review/round-N/`
+- Severity: bug | suggestion | nit — Status starts as open
+- Do not invent issues to fill space; zero issues is a valid outcome
+- Address the diff and surrounding call sites; re-verify falsifiable claims against the repo
+- Prior rounds are immutable; new work goes in `round-(N+1)/`
+- `ganda repo audit --fix` output (hook scaffolds, glossary template, package pins) is policy baseline, not a finding
diff --git a/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/round-1/general.md b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/round-1/general.md
new file mode 100644
index 0000000..72dae53
--- /dev/null
+++ b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/round-1/general.md
@@ -0,0 +1,34 @@
+# Round 1 — general
+**Date:** 2026-11-08
+**Scope reviewed:** `task/030-bring-ganda-repo-audit-clean` vs merge-base `c225708` (25 files, +919/−671)
+
+## Summary
+
+The branch commits the `ganda repo audit --fix` output (`0eac4eb`) and then hand-fixes the rest (`a936c58`). The hand fixes are: unquoted `charset = utf-8` under `[*]`, `global-usings-analyzer.severity = off` with the reason comment the task asked for, a Nuru 4 port of the dev CLI, net11 retargets for the Amuru 3 pin, and the markdown generator split. Risk is low. The generator split is a pure move. A whitespace-insensitive, sorted line comparison of the old file against the two new files shows only the new `partial` keyword, the Purpose/Design regions, and the second file's namespace and class wrapper.
+
+## Verification (live, this worktree)
+
+- `ganda repo audit`: exit 0, **Passed 36 | Failed 0 | Skipped 3**, "Repository passes all audit checks."
+- `./bin/dev build`: Build succeeded, 0 warnings, 0 errors.
+- `./bin/dev test`: 11/11 analyzer cases passed.
+- `./bin/dev --help` lists `release` and `check-version`.
+- `./bin/dev check-version` prints `Packages checked: TimeWarp.SourceGenerators`. It fails on the already-published `1.0.0-beta.11`, which is the release guard working as intended.
+- `Directory.Packages.props` pins `TimeWarp.Nuru` and `TimeWarp.Nuru.DevCli` at `4.0.0-beta.11`. The audit's `nuru` row passes.
+- `.editorconfig` has `charset = utf-8` at line 26 under `[*]`, and the quoted glob-scoped line is gone.
+- No `PackageReference Include="TimeWarp.SourceGenerators"` in root `Directory.Build.props` or `Directory.Packages.props`. The `TW0007.filename` line is kept.
+- `markdown-docs-generator.cs` is 136 lines and `markdown-docs-generator.markdown-to-xml.cs` is 409 lines. No `part-N` naming. The audit's `source-file-line-cap` passes.
+- `workflow.yml` `pull_request.branches` is `[master, 'feature/**']`.
+- `git ls-files '*.journal.json' .vscode/settings.json .memsearch.toml` is empty. The working copy `.vscode/settings.json` sets `peacock.color`.
+
+## Goal restatement vs task
+
+The restatement matches the task's Description and Requirements. It includes the 027 archive step and the stop before `kanban done` / `gh pr create`. No misread.
+
+## Issues
+
+None. The `#pragma warning disable RS0030, CA1849` blocks and `RunAnalyzers=false` in `.githooks/*.cs` come from the audit `memsearch-scaffold` baseline that `--fix` wrote. They are not suppressions added to make a check pass, so they are not findings under the audit-fix policy.
+
+## Notes (not findings)
+
+- CI pins the exact SDK `11.0.100-rc.3.26522.148` next to `10.0.x`. When .NET 11 reaches GA, switch that line to `11.0.x`. This is a follow-up after GA, not a defect now.
+- Task 029 (`UseTelemetry`) must land on top of the Nuru 4 pin, as task.md Notes already say.
diff --git a/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/round-1/merged.md b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/round-1/merged.md
new file mode 100644
index 0000000..4ea9cc3
--- /dev/null
+++ b/kanban/in-progress/030-bring-ganda-repo-audit-clean/review/round-1/merged.md
@@ -0,0 +1,19 @@
+# Round 1 — merged findings
+**Date:** 2026-11-08
+**Sources:** general
+
+## Counts
+
+| Severity | open | fixed | wontfix |
+|----------|------|-------|---------|
+| bug | 0 | 0 | 0 |
+| suggestion | 0 | 0 | 0 |
+| nit | 0 | 0 | 0 |
+
+## Issues
+
+None raised. See `general.md` for the live gate checks (audit exit 0, build, 11/11 tests, check-version, pin and line-cap checks).
+
+## Duplicates / conflicts
+
+- None (single reviewer).
diff --git a/kanban/in-progress/030-bring-ganda-repo-audit-clean/task.md b/kanban/in-progress/030-bring-ganda-repo-audit-clean/task.md
new file mode 100644
index 0000000..22b1a7d
--- /dev/null
+++ b/kanban/in-progress/030-bring-ganda-repo-audit-clean/task.md
@@ -0,0 +1,174 @@
+# Bring ganda repo audit clean
+
+## Goal (implementer restatement)
+
+`ganda repo audit` on this repo currently fails. The goal is a clean audit (exit 0, no Failed row) on the claimed task branch, with the product still building.
+
+Acceptance:
+
+- Re-read the audit at the start and treat that live table as the list, because pins move.
+- Apply `ganda repo audit --fix` for every fixable row except `global-usings-analyzer`, and commit that diff. Do not revert a major Nuru or DevCli bump.
+- For `global-usings-analyzer`, turn the check off in `[ganda.audit]` with the reason comment from the kitchen. Do not add a root `PackageReference` to `TimeWarp.SourceGenerators`, because this repo is that package.
+- Put unquoted `charset = utf-8` under `[*]`.
+- Split `markdown-docs-generator.cs` by responsibility so every file is at most 500 lines. No `part-N` partial names. Analyzer behavior stays.
+- Bump `TimeWarp.Nuru` and `TimeWarp.Nuru.DevCli` to the version the audit calls latest, and make `./bin/dev build` succeed. `dev --help` still shows `release`, and `dev check-version` still sees `TimeWarp.SourceGenerators`.
+- `pull_request.branches` includes `feature/**`.
+- After the journal ignore is in `.gitignore`, archive task 027 so it does not edit the same file.
+- Record `## Results` and a non-empty `### How to validate` (Smoke + Expect). Stop before `kanban done` and `gh pr create`.
+
+## Description
+
+`ganda repo audit` on `timewarp-source-generators` master fails.
+Snapshot from 2026-11-08 (`origin/master`, clean): **Passed 25, Failed 12, Skipped 3**, exit 1.
+Ten rows are Error. Two are Warning (`memsearch-scaffold`, `vscode-window-icon`). Both count as Failed.
+
+Bring the repo to a passing audit. Run `ganda repo audit --fix` for the rows it can fix, commit that result, then hand-fix the rest. A major package bump from `--fix` is the baseline (do not revert it). Downgrade a check under `[ganda.audit]` only when the check does not apply, with a comment that names the reason and this task id.
+
+Re-read `ganda repo audit` at the start. Pins and "latest" versions move. The list below is the 2026-11-08 snapshot.
+
+## Requirements
+
+- `ganda repo audit` exits 0. The results table has no Failed row.
+- `./bin/dev build` succeeds after the Nuru / DevCli pin move (0 errors).
+- Every `[ganda.audit]` downgrade has a comment with the reason and task 030.
+- Do not implement on `master`. Work stays in the claimed task worktree.
+
+### Fix with `ganda repo audit --fix`
+
+These rows reported Fixable YES. Commit the `--fix` output. Do not hand-revert it.
+
+- `glossary` — `glossary.md` is missing.
+- `kanban-porcelain-gitignore` — root `.gitignore` missing porcelain lines (`*.journal.json`, `*.next.md`, `*.progress.log`, `**/review/round-*/base.sha`, `**/review/round-*/closed`, `oracle-*.log`, `task-work.progress.log`, `mission.heartbeat`).
+- `required-gitignore-entries` — missing `.local/`, `bin/dev`, `bin/dev.dbg`, `bin/dev.exe`.
+- `routine-journals-gitignore` — same journal globs, plus `stacked-task-set*.resume.json`. One `*.journal.json` line covers the journal basenames. Do not commit journal contents. `git ls-files '*.journal.json'` must be empty.
+- `memsearch-scaffold` (Warning) — leftover `.memsearch.toml`; outdated `.githooks/post-commit.cs`, `post-merge.cs`, `post-checkout`, `pre-commit`, `pre-push`.
+- `vscode-settings-gitignore` — `.vscode/settings.json` is tracked. `--fix` runs `git rm --cached` and adds the ignore. Keep the working copy. Do not commit `settings.json`.
+- `vscode-window-icon` (Warning) — `.vscode/settings.json` does not set `peacock.color`. Set it in the working copy after the file is untracked.
+- `workflow-file` — `.github/workflows/workflow.yml` `pull_request.branches` is `[master]`. It must include `feature/**`.
+- `nuru` — `Directory.Packages.props` pins `TimeWarp.Nuru` and `TimeWarp.Nuru.DevCli` at `3.0.0-beta.76`. Audit latest on this date: **4.0.0-beta.11** (nuget.org). Bump **both** packages to the version the audit reports when the work starts. This is a 3.x → 4.x move. Update `tools/dev-cli` if the 4.x API no longer compiles. `dev --help` must still show `release`, and `dev check-version` must still see `TimeWarp.SourceGenerators`.
+
+### Hand fixes (`--fix` will not do these)
+
+- `editorconfig-charset` (Error, not fixable) — `charset` is `"utf-8"` under the `[*.{cs,...}]` glob. The check wants `charset = utf-8` (no quotes) under `[*]`.
+- `source-file-line-cap` (Error, not fixable) — `source/timewarp-source-generators/markdown-docs-generator.cs` is **532** lines. Cap is **500**. Split by responsibility. No partial-class `part-N` names. Behavior stays; analyzer tests still pass.
+- `global-usings-analyzer` (Error, fixable, **do not take the default fix**). The check looks for the string `TimeWarp.SourceGenerators` in root `Directory.Packages.props` and root `Directory.Build.props`. `--fix` would pin the NuGet package and add a root `PackageReference`. This repo **is** that package. A root reference would make the generator project consume the published package. Set:
+
+ ```ini
+ # This repo is the TimeWarp.SourceGenerators package. A root PackageReference
+ # would consume the published package from the project that produces it.
+ # TW0007 is implemented and tested here (task 028). Task 030.
+ global-usings-analyzer.severity = off
+ ```
+
+ Leave the existing `dotnet_diagnostic.TW0007.filename = global-usings.cs` line. Do not add a `PackageReference` to `TimeWarp.SourceGenerators`.
+
+## Checklist
+
+- [x] Re-run `ganda repo audit` and refresh this list if a row changed
+- [x] `ganda repo audit --fix` for the fixable rows, excluding a self-package pin (see `global-usings-analyzer`)
+- [x] Commit the `--fix` diff (`chore(audit): …`)
+- [x] `charset = utf-8` under `[*]`, quotes removed
+- [x] `global-usings-analyzer.severity = off` with the reason comment above
+- [x] Split `markdown-docs-generator.cs` under the 500 line cap
+- [x] Nuru and DevCli pins match the audit's latest; `dev build` passes
+- [x] `pull_request.branches` includes `feature/**`
+- [x] `ganda repo audit` exits 0 with no Failed row
+- [x] `## Results` and `### How to validate`
+- [x] Archive task 027 once this kitchen owns the journal ignore (one `.gitignore` edit)
+- [x] Stop after product work and Results. Do not `kanban done`. Do not `gh pr create`
+
+## Review
+
+- Round 1: Done
+
+## Notes
+
+- Predecessor: task **024** brought the audit clean on Nuru / DevCli **3.0.0-beta.76**. This task is the next drift.
+- Task **027** (`routine-journals-gitignore` only) was archived from origin-home on 2026-11-08 (`09a725d land kanban 027`) after this branch committed the journal ignore. Archive refuses on the 030 branch because that branch is not 027's task branch. The to-do copy remains on this branch; master owns the archived kitchen, and this branch did not edit that file.
+- Task **029** adds `UseTelemetry` to `tools/dev-cli/dev.cs` against the Nuru 3.x generator. Do not run 029 in parallel. This task owns the 4.x pin. 029 should land on top of that pin and re-check the `UseTelemetry` call against 4.x.
+- Claim on-create (2026-11-08) failed `dev` self-install: `MSB3030` could not copy `dev.xml` from the runfile publish output. `bin/dev` may be missing in a fresh worktree until self-install works on the new DevCli pin. The claim still stands.
+- Policy: `ganda repo audit --fix` output is the baseline. Commit it. The only downgrade allowed here is `global-usings-analyzer`, because this repo is the package the check wants consumers to reference.
+- Live re-read before `--fix` (this worktree, 2026-11-08): **Passed 27, Failed 10, Skipped 3**. `kanban-porcelain-gitignore` and `routine-journals-gitignore` already passed from the uncommitted gitignore lines. Nuru latest was still **4.0.0-beta.11**. `--fix` also pinned `TimeWarp.Amuru` and `TimeWarp.Amuru.Tools` to **3.0.0-beta.1** and `TimeWarp.Terminal` to **1.0.2**. Amuru 3 is net11.0 only, so the dev CLI and the git hooks target net11.0, and CI installs `11.0.100-rc.3.26522.148` beside `10.0.x`.
+
+### How to validate
+
+```bash
+ganda repo audit
+# Expect: exit 0, no Failed row
+
+grep -E 'TimeWarp\.Nuru' Directory.Packages.props
+# Expect: Nuru and DevCli at the version the audit called latest
+
+./bin/dev --help | grep release
+./bin/dev check-version
+./bin/dev build
+# Expect: build succeeds
+
+# charset
+grep -n 'charset' .editorconfig
+# Expect: charset = utf-8 under [*]
+
+# no self-package reference
+grep -n 'PackageReference Include="TimeWarp.SourceGenerators"' Directory.Build.props Directory.Packages.props
+# Expect: no PackageReference (PackageId in source/Directory.Build.props is the product id, and is fine)
+
+wc -l source/timewarp-source-generators/markdown-docs-generator.cs
+# Expect: the split files are each <= 500 lines; the old monolith is gone or under the cap
+```
+
+## Results
+
+`ganda repo audit` exits 0 with no Failed row (**Passed 36, Failed 0, Skipped 3**). `global-usings-analyzer` is off under `[ganda.audit]` with the reason comment, and there is no root `PackageReference` to `TimeWarp.SourceGenerators`.
+
+`--fix` is committed as `0eac4eb` (`chore(audit): apply ganda repo audit --fix`). That commit keeps the Amuru 3 and Terminal bumps. Hand fixes are `a936c58`.
+
+Nuru and DevCli are **4.0.0-beta.11**. Nuru 4 moved `ICommand`, `ICommandHandler`, and `Unit` to `TimeWarp.Mediator`, and handlers return `Task`. The dev CLI compiles on net11.0. `dotnet run --file tools/dev-cli/dev.cs -- --help` lists `release`. `check-version` prints `Packages checked: TimeWarp.SourceGenerators` and exits 1 because `1.0.0-beta.11` is already on NuGet. That exit is the release guard, not a missed package.
+
+`markdown-docs-generator.cs` is 136 lines. Markdown parsing lives in `markdown-docs-generator.markdown-to-xml.cs` (409 lines). `./bin/dev build` succeeded with 0 warnings. `./bin/dev test` passed the test console and 11/11 analyzer tests. The Nuru 4 dev CLI `build` command also succeeded.
+
+Task 027 is archived on master (`09a725d`). This walk did not run `ganda kanban done` or `gh pr create`.
+
+CI installs the net11 RC next to net10 so `dotnet run --file tools/dev-cli/dev.cs` can restore Amuru 3. .NET 11 was still `11.0.0-rc.3` on 2026-11-08.
+
+### Review disposition
+
+- **Disposition:** clean. Rounds: 1. Effort 3, roster: general.
+- **Final counts:** bug 0, suggestion 0, nit 0 (0 open, 0 fixed, 0 wontfix).
+- **Live checks by the reviewer:** `ganda repo audit` exit 0 (Passed 36, Failed 0, Skipped 3). `./bin/dev build` reported 0 warnings and 0 errors. `./bin/dev test` passed 11/11. `check-version` lists `TimeWarp.SourceGenerators`. The generator split is confirmed as a pure move.
+- **Follow-up (not a finding):** after .NET 11 GA, change the CI SDK line `11.0.100-rc.3.26522.148` to `11.0.x`.
+- **Paths:** `review/review-framework.md`, `review/round-1/general.md`, `review/round-1/merged.md`, `review/disposition.md`.
+
+### How to validate
+
+**Smoke**
+
+```bash
+ganda repo audit
+grep -E 'TimeWarp\.Nuru' Directory.Packages.props
+./bin/dev --help | grep release
+./bin/dev check-version
+./bin/dev build
+./bin/dev test
+grep -n 'charset' .editorconfig
+grep -n 'PackageReference Include="TimeWarp.SourceGenerators"' Directory.Build.props Directory.Packages.props || true
+wc -l source/timewarp-source-generators/markdown-docs-generator.cs source/timewarp-source-generators/markdown-docs-generator.markdown-to-xml.cs
+```
+
+**Expect**
+
+- `ganda repo audit` exits 0 and the table has no Failed row.
+- `TimeWarp.Nuru` and `TimeWarp.Nuru.DevCli` are `4.0.0-beta.11`.
+- Help text includes `release`. `check-version` lists `TimeWarp.SourceGenerators`.
+- `./bin/dev build` succeeds with 0 errors. `./bin/dev test` passes the analyzer cases.
+- `.editorconfig` has `charset = utf-8` under `[*]` and no quoted charset.
+- No `PackageReference` to `TimeWarp.SourceGenerators`.
+- Both markdown generator files are at most 500 lines.
+
+## Session
+
+- Created: grok `01a1bbd2-4185-7452-8535-389eb11a4f8f` (2026-11-08)
+- Trigger: Steve asked to create a task to fix `ganda repo audit` on timewarp-source-generators
+- Implementation: grok `01a1bbe3-2e77-79f0-ae81-48feae04ec9c` (2026-11-08)
+- Implement oracle: implemented by implementer-grok (grok, vendor xai, model grok-4.7, response model grok-4.7-build), session 01a1bbe3-2e77-79f0-ae81-48feae04ec9c, max-turns 500, Done — 2026-11-08T16:21:32Z
+- Review oracle: tw-implementation-review round 1, Claude (claude-opus-5-5), ganda task work headless, effort 3, roster general, turn cap 200. Disposition clean — 2026-11-08
+- Review oracle: review by reviewer-claude (claude, vendor anthropic, model claude-opus-5-5, response model claude-opus-5-5), session 96240dfd-27eb-43b9-ac1e-aea0e6bdce7d, max-turns 200, artifacts general.md, merged.md, disposition.md — 2026-11-08T16:24:11Z
diff --git a/kanban/to-do/030-bring-ganda-repo-audit-clean/task.md b/kanban/to-do/030-bring-ganda-repo-audit-clean/task.md
deleted file mode 100644
index 320c19f..0000000
--- a/kanban/to-do/030-bring-ganda-repo-audit-clean/task.md
+++ /dev/null
@@ -1,101 +0,0 @@
-# Bring ganda repo audit clean
-
-## Description
-
-`ganda repo audit` on `timewarp-source-generators` master fails.
-Snapshot from 2026-11-08 (`origin/master`, clean): **Passed 25, Failed 12, Skipped 3**, exit 1.
-Ten rows are Error. Two are Warning (`memsearch-scaffold`, `vscode-window-icon`). Both count as Failed.
-
-Bring the repo to a passing audit. Run `ganda repo audit --fix` for the rows it can fix, commit that result, then hand-fix the rest. A major package bump from `--fix` is the baseline (do not revert it). Downgrade a check under `[ganda.audit]` only when the check does not apply, with a comment that names the reason and this task id.
-
-Re-read `ganda repo audit` at the start. Pins and "latest" versions move. The list below is the 2026-11-08 snapshot.
-
-## Requirements
-
-- `ganda repo audit` exits 0. The results table has no Failed row.
-- `./bin/dev build` succeeds after the Nuru / DevCli pin move (0 errors).
-- Every `[ganda.audit]` downgrade has a comment with the reason and task 030.
-- Do not implement on `master`. Work stays in the claimed task worktree.
-
-### Fix with `ganda repo audit --fix`
-
-These rows reported Fixable YES. Commit the `--fix` output. Do not hand-revert it.
-
-- `glossary` — `glossary.md` is missing.
-- `kanban-porcelain-gitignore` — root `.gitignore` missing porcelain lines (`*.journal.json`, `*.next.md`, `*.progress.log`, `**/review/round-*/base.sha`, `**/review/round-*/closed`, `oracle-*.log`, `task-work.progress.log`, `mission.heartbeat`).
-- `required-gitignore-entries` — missing `.local/`, `bin/dev`, `bin/dev.dbg`, `bin/dev.exe`.
-- `routine-journals-gitignore` — same journal globs, plus `stacked-task-set*.resume.json`. One `*.journal.json` line covers the journal basenames. Do not commit journal contents. `git ls-files '*.journal.json'` must be empty.
-- `memsearch-scaffold` (Warning) — leftover `.memsearch.toml`; outdated `.githooks/post-commit.cs`, `post-merge.cs`, `post-checkout`, `pre-commit`, `pre-push`.
-- `vscode-settings-gitignore` — `.vscode/settings.json` is tracked. `--fix` runs `git rm --cached` and adds the ignore. Keep the working copy. Do not commit `settings.json`.
-- `vscode-window-icon` (Warning) — `.vscode/settings.json` does not set `peacock.color`. Set it in the working copy after the file is untracked.
-- `workflow-file` — `.github/workflows/workflow.yml` `pull_request.branches` is `[master]`. It must include `feature/**`.
-- `nuru` — `Directory.Packages.props` pins `TimeWarp.Nuru` and `TimeWarp.Nuru.DevCli` at `3.0.0-beta.76`. Audit latest on this date: **4.0.0-beta.11** (nuget.org). Bump **both** packages to the version the audit reports when the work starts. This is a 3.x → 4.x move. Update `tools/dev-cli` if the 4.x API no longer compiles. `dev --help` must still show `release`, and `dev check-version` must still see `TimeWarp.SourceGenerators`.
-
-### Hand fixes (`--fix` will not do these)
-
-- `editorconfig-charset` (Error, not fixable) — `charset` is `"utf-8"` under the `[*.{cs,...}]` glob. The check wants `charset = utf-8` (no quotes) under `[*]`.
-- `source-file-line-cap` (Error, not fixable) — `source/timewarp-source-generators/markdown-docs-generator.cs` is **532** lines. Cap is **500**. Split by responsibility. No partial-class `part-N` names. Behavior stays; analyzer tests still pass.
-- `global-usings-analyzer` (Error, fixable, **do not take the default fix**). The check looks for the string `TimeWarp.SourceGenerators` in root `Directory.Packages.props` and root `Directory.Build.props`. `--fix` would pin the NuGet package and add a root `PackageReference`. This repo **is** that package. A root reference would make the generator project consume the published package. Set:
-
- ```ini
- # This repo is the TimeWarp.SourceGenerators package. A root PackageReference
- # would consume the published package from the project that produces it.
- # TW0007 is implemented and tested here (task 028). Task 030.
- global-usings-analyzer.severity = off
- ```
-
- Leave the existing `dotnet_diagnostic.TW0007.filename = global-usings.cs` line. Do not add a `PackageReference` to `TimeWarp.SourceGenerators`.
-
-## Checklist
-
-- [ ] Re-run `ganda repo audit` and refresh this list if a row changed
-- [ ] `ganda repo audit --fix` for the fixable rows, excluding a self-package pin (see `global-usings-analyzer`)
-- [ ] Commit the `--fix` diff (`chore(audit): …`)
-- [ ] `charset = utf-8` under `[*]`, quotes removed
-- [ ] `global-usings-analyzer.severity = off` with the reason comment above
-- [ ] Split `markdown-docs-generator.cs` under the 500 line cap
-- [ ] Nuru and DevCli pins match the audit's latest; `dev build` passes
-- [ ] `pull_request.branches` includes `feature/**`
-- [ ] `ganda repo audit` exits 0 with no Failed row
-- [ ] `## Results` and `### How to validate`
-- [ ] Archive task 027 once this kitchen owns the journal ignore (one `.gitignore` edit)
-- [ ] Stop after product work and Results. Do not `kanban done`. Do not `gh pr create`
-
-## Notes
-
-- Predecessor: task **024** brought the audit clean on Nuru / DevCli **3.0.0-beta.76**. This task is the next drift.
-- Task **027** (`routine-journals-gitignore` only) is still `to-do`. Its requirements are the journal row above. Archive 027 when this task is claimed so two kitchens do not edit `.gitignore`.
-- Task **029** adds `UseTelemetry` to `tools/dev-cli/dev.cs` against the Nuru 3.x generator. Do not run 029 in parallel. This task owns the 4.x pin. 029 should land on top of that pin and re-check the `UseTelemetry` call against 4.x.
-- Claim on-create (2026-11-08) failed `dev` self-install: `MSB3030` could not copy `dev.xml` from the runfile publish output. `bin/dev` may be missing in a fresh worktree until self-install works on the new DevCli pin. The claim still stands.
-- Policy: `ganda repo audit --fix` output is the baseline. Commit it. The only downgrade allowed here is `global-usings-analyzer`, because this repo is the package the check wants consumers to reference.
-
-### How to validate
-
-```bash
-ganda repo audit
-# Expect: exit 0, no Failed row
-
-grep -E 'TimeWarp\.Nuru' Directory.Packages.props
-# Expect: Nuru and DevCli at the version the audit called latest
-
-./bin/dev --help | grep release
-./bin/dev check-version
-./bin/dev build
-# Expect: build succeeds
-
-# charset
-grep -n 'charset' .editorconfig
-# Expect: charset = utf-8 under [*]
-
-# no self-package reference
-grep -n 'PackageReference Include="TimeWarp.SourceGenerators"' Directory.Build.props Directory.Packages.props
-# Expect: no PackageReference (PackageId in source/Directory.Build.props is the product id, and is fine)
-
-wc -l source/timewarp-source-generators/markdown-docs-generator.cs
-# Expect: the split files are each <= 500 lines; the old monolith is gone or under the cap
-```
-
-## Session
-
-- Created: grok `01a1bbd2-4185-7452-8535-389eb11a4f8f` (2026-11-08)
-- Trigger: Steve asked to create a task to fix `ganda repo audit` on timewarp-source-generators
diff --git a/source/timewarp-source-generators/markdown-docs-generator.cs b/source/timewarp-source-generators/markdown-docs-generator.cs
index 9573184..8d36b37 100644
--- a/source/timewarp-source-generators/markdown-docs-generator.cs
+++ b/source/timewarp-source-generators/markdown-docs-generator.cs
@@ -1,532 +1,136 @@
-namespace TimeWarp.SourceGenerators;
-
-[Generator]
-public class MarkdownDocsGenerator : IIncrementalGenerator
-{
- // Regex pattern for checking if a file name is kebab-case
- private static readonly Regex KebabCasePattern = new(@"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", RegexOptions.Compiled);
- private static readonly DiagnosticDescriptor MarkdownDocsGeneratorLoadedDescriptor = new(
- id: "TW0003",
- title: "MarkdownDocs Generator Loaded",
- messageFormat: "The MarkdownDocs generator has been loaded and initialized",
- category: "SourceGenerator",
- DiagnosticSeverity.Info,
- isEnabledByDefault: true
- );
-
- public void Initialize(IncrementalGeneratorInitializationContext context)
- {
- // Report initialization diagnostic
- IncrementalValueProvider initializationTrigger = context.CompilationProvider
- .Select((compilation, _) => true);
-
- context.RegisterSourceOutput(initializationTrigger, (sourceContext, _) =>
- {
- sourceContext.ReportDiagnostic(
- Diagnostic.Create(MarkdownDocsGeneratorLoadedDescriptor, Location.None)
- );
- });
-
- // Find all class declarations in C# files
- IncrementalValuesProvider<(ClassDeclarationSyntax ClassDeclaration, string? Namespace, string FilePath)> classDeclarations =
- context.SyntaxProvider
- .CreateSyntaxProvider(
- predicate: (s, _) => s is ClassDeclarationSyntax,
- transform: (ctx, _) =>
- {
- var classDeclaration = (ClassDeclarationSyntax)ctx.Node;
- var namespaceDecl = classDeclaration.Ancestors().OfType().FirstOrDefault();
- var filePath = ctx.Node.SyntaxTree.FilePath;
- return (classDeclaration, namespaceDecl?.Name.ToString(), filePath);
- });
-
- // Find all .md files
- IncrementalValuesProvider markdownFiles = context.AdditionalTextsProvider
- .Where(file => file.Path.EndsWith(".md", StringComparison.OrdinalIgnoreCase));
-
- // Group class declarations by class name to avoid duplicates
- var groupedClasses = classDeclarations
- .Collect()
- .Select((classes, _) => classes
- .GroupBy(c => (c.ClassDeclaration.Identifier.Text, c.Namespace))
- .Select(g => g.First())
- .ToArray());
-
- // Combine into pairs
- var pairs = groupedClasses.Combine(markdownFiles.Collect());
-
- // Generate documentation for matching pairs
- context.RegisterSourceOutput(pairs, (sourceContext, pair) =>
- {
- var (classGroups, markdownTexts) = pair;
-
- foreach (var (classDeclaration, namespaceName, filePath) in classGroups)
- {
- var className = classDeclaration.Identifier.Text;
-
- // Extract source file name without extension
- var sourceFileName = Path.GetFileNameWithoutExtension(filePath);
-
- // Find matching markdown file using appropriate strategy
- AdditionalText? matchingMd = null;
-
- // If source file is kebab-case, try to match kebab-case markdown first
- if (IsKebabCase(sourceFileName))
- {
- matchingMd = markdownTexts.FirstOrDefault(md =>
- Path.GetFileNameWithoutExtension(md.Path).Equals(sourceFileName, StringComparison.OrdinalIgnoreCase));
- }
-
- // Fall back to class name match (for backward compatibility or PascalCase files)
- if (matchingMd == null)
- {
- matchingMd = markdownTexts.FirstOrDefault(md =>
- Path.GetFileNameWithoutExtension(md.Path).Equals(className, StringComparison.OrdinalIgnoreCase));
- }
-
- // Also try kebab-case of the class name (PascalCase source + kebab markdown)
- if (matchingMd == null)
- {
- string kebabClassName = ConvertToKebabCase(className);
- matchingMd = markdownTexts.FirstOrDefault(md =>
- Path.GetFileNameWithoutExtension(md.Path).Equals(kebabClassName, StringComparison.OrdinalIgnoreCase));
- }
-
- if (matchingMd != null)
- {
- var markdownContent = matchingMd.GetText()?.ToString() ?? string.Empty;
- var (classDocs, methodDocs, propertyDocs) = ConvertMarkdownToXmlDocs(markdownContent, classDeclaration);
-
- var sourceText = SourceText.From($@"// Auto-generated documentation for {className}
-#nullable enable
-
-{(namespaceName != null ? $"namespace {namespaceName};" : "")}
-
-{classDocs}
-public partial class {className}
-{{
-{propertyDocs}
-{methodDocs}
-}}
-", Encoding.UTF8);
-
- sourceContext.AddSource($"{className}.docs.g.cs", sourceText);
- }
- }
- });
- }
-
- private static (string ClassDocs, string MethodDocs, string PropertyDocs) ConvertMarkdownToXmlDocs(string markdownContent, ClassDeclarationSyntax classDeclaration)
- {
- var classBuilder = new StringBuilder();
- var methodBuilder = new StringBuilder();
- var propertyBuilder = new StringBuilder();
- var reader = new StringReader(markdownContent);
- string? line;
-
- // State tracking
- var currentSection = "";
- var contentBuilder = new StringBuilder();
- var inMethodSection = false;
- var inPropertySection = false;
- var currentMethod = "";
- var currentMethodDescription = "";
- var currentProperty = "";
- var currentPropertyDescription = "";
-
- // Get method signatures from the class declaration
- var methodSignatures = classDeclaration.Members
- .OfType()
- .ToDictionary(
- m => m.Identifier.Text,
- m => $"public partial {m.ReturnType} {m.Identifier}({string.Join(", ", m.ParameterList.Parameters.Select(p => $"{p.Type} {p.Identifier}{(p.Default != null ? " = " + p.Default.Value.ToString() : "")}"))})"
- );
-
- // Get property signatures from the class declaration (only partial properties)
- var propertySignatures = classDeclaration.Members
- .OfType()
- .Where(p => p.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
- .ToDictionary(
- p => p.Identifier.Text,
- p =>
- {
- var modifiers = string.Join(" ", p.Modifiers.Select(m => m.Text));
- var accessors = new List();
-
- if (p.AccessorList != null)
- {
- foreach (var accessor in p.AccessorList.Accessors)
- {
- if (accessor.Keyword.Text == "get")
- {
- // For non-nullable reference types, initialize with default
- var typeStr = p.Type?.ToString() ?? "";
- if (typeStr == "string" && !typeStr.EndsWith("?"))
- {
- accessors.Add("get => field ??= \"\"");
- }
- else
- {
- accessors.Add("get => field");
- }
- }
- else if (accessor.Keyword.Text == "set")
- {
- accessors.Add("set => field = value");
- }
- else if (accessor.Keyword.Text == "init")
- {
- accessors.Add("init => field = value");
- }
- }
- }
-
- var accessorString = string.Join("; ", accessors);
- return $"{modifiers} {p.Type} {p.Identifier} {{ {accessorString}; }}";
- }
- );
-
- while ((line = reader.ReadLine()) != null)
- {
- if (line.StartsWith("# ")) // Class name - skip
- continue;
-
- if (line.StartsWith("## "))
- {
- // Process previous section
- if (inMethodSection)
- ProcessMethodSection(methodBuilder, currentMethod, currentMethodDescription, contentBuilder.ToString().Trim(), methodSignatures);
- else if (inPropertySection)
- ProcessPropertySection(propertyBuilder, currentProperty, currentPropertyDescription, contentBuilder.ToString().Trim(), propertySignatures);
- else
- ProcessSection(classBuilder, currentSection, contentBuilder.ToString().Trim());
-
- // Start new section
- currentSection = line.Substring(3).Trim();
- inMethodSection = currentSection == "Methods";
- inPropertySection = currentSection == "Properties";
- contentBuilder.Clear();
- currentMethodDescription = "";
- currentPropertyDescription = "";
- continue;
- }
-
- if (line.StartsWith("### ") && inMethodSection)
- {
- // Process previous method if exists
- if (!string.IsNullOrEmpty(currentMethod))
- {
- ProcessMethodSection(methodBuilder, currentMethod, currentMethodDescription, contentBuilder.ToString().Trim(), methodSignatures);
- }
-
- // Start new method
- currentMethod = line.Substring(4).Trim();
- contentBuilder.Clear();
- currentMethodDescription = "";
- continue;
- }
-
- if (line.StartsWith("### ") && inPropertySection)
- {
- // Process previous property if exists
- if (!string.IsNullOrEmpty(currentProperty))
- {
- ProcessPropertySection(propertyBuilder, currentProperty, currentPropertyDescription, contentBuilder.ToString().Trim(), propertySignatures);
- }
-
- // Start new property
- currentProperty = line.Substring(4).Trim();
- contentBuilder.Clear();
- currentPropertyDescription = "";
- continue;
- }
-
- // If we haven't hit any section yet, this is the class summary
- if (string.IsNullOrEmpty(currentSection) && !string.IsNullOrWhiteSpace(line))
- {
- classBuilder.AppendLine("/// ");
- classBuilder.AppendLine($"/// {line.Trim()}");
- classBuilder.AppendLine("/// ");
- continue;
- }
-
- // Capture method description (text before any #### subsections)
- if (inMethodSection && !string.IsNullOrWhiteSpace(line) && !line.StartsWith("####") && string.IsNullOrEmpty(currentMethodDescription))
- {
- currentMethodDescription = line.Trim();
- continue;
- }
-
- // Capture property description (text before any #### subsections)
- if (inPropertySection && !string.IsNullOrWhiteSpace(line) && !line.StartsWith("####") && string.IsNullOrEmpty(currentPropertyDescription))
- {
- currentPropertyDescription = line.Trim();
- continue;
- }
-
- // Add content to current section
- if (!string.IsNullOrWhiteSpace(line))
- {
- contentBuilder.AppendLine(line);
- }
- }
-
- // Process the last section
- if (inMethodSection)
- ProcessMethodSection(methodBuilder, currentMethod, currentMethodDescription, contentBuilder.ToString().Trim(), methodSignatures);
- else if (inPropertySection)
- ProcessPropertySection(propertyBuilder, currentProperty, currentPropertyDescription, contentBuilder.ToString().Trim(), propertySignatures);
- else
- ProcessSection(classBuilder, currentSection, contentBuilder.ToString().Trim());
-
- return (classBuilder.ToString(), methodBuilder.ToString(), propertyBuilder.ToString());
- }
-
- private static void ProcessSection(StringBuilder builder, string section, string content)
- {
- if (string.IsNullOrEmpty(content))
- return;
-
- switch (section)
- {
- case "Remarks":
- builder.AppendLine("/// ");
- foreach (var line in content.Split('\n'))
- {
- builder.AppendLine($"/// {line.Trim()}");
- }
- builder.AppendLine("/// ");
- break;
-
- case "See Also":
- foreach (var line in content.Split('\n'))
- {
- if (string.IsNullOrWhiteSpace(line)) continue;
- var match = Regex.Match(line, @"\[(.*?)\]\((.*?)\)");
- if (match.Success)
- {
- var type = match.Groups[1].Value;
- // Convert angle brackets to curly braces for XML documentation
- var xmlType = type.Replace('<', '{').Replace('>', '}');
- builder.AppendLine($"/// ");
- }
- }
- break;
-
- case "Inheritance":
- if (content.Contains("@"))
- {
- var match = Regex.Match(content, @"@(\S+)");
- if (match.Success)
- {
- var type = match.Groups[1].Value;
- // Extract the type name and keep T as the generic parameter
- var baseType = type.Split('<')[0];
- builder.AppendLine($"/// ");
- }
- }
- break;
-
- case "References":
- foreach (var line in content.Split('\n'))
- {
- if (string.IsNullOrWhiteSpace(line)) continue;
- var match = Regex.Match(line, @"@(\S+)");
- if (match.Success)
- {
- var type = match.Groups[1].Value;
- // Handle fully qualified type names
- if (!type.Contains("."))
- {
- type = "System." + type;
- }
- builder.AppendLine($"/// ");
- }
- }
- break;
- }
- }
-
- private static void ProcessMethodSection(StringBuilder builder, string methodName, string description, string content, Dictionary methodSignatures)
- {
- if (string.IsNullOrEmpty(methodName))
- return;
-
- // Handle constructor specially
- if (methodName == "Constructor")
- methodName = methodSignatures.Keys.FirstOrDefault(k => k.Contains("ctor")) ?? "";
-
- if (!methodSignatures.ContainsKey(methodName))
- return;
-
- builder.AppendLine($" // Documentation for {methodName}");
-
- // Add method summary if available
- if (!string.IsNullOrEmpty(description))
- {
- builder.AppendLine(" /// ");
- builder.AppendLine($" /// {description}");
- builder.AppendLine(" /// ");
- }
-
- var reader = new StringReader(content);
- string? line;
- var currentSubSection = "";
- var subSectionContent = new StringBuilder();
-
- while ((line = reader.ReadLine()) != null)
- {
- if (line.StartsWith("#### "))
- {
- // Process previous subsection
- ProcessMethodSubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
-
- // Start new subsection
- currentSubSection = line.Substring(5).Trim();
- subSectionContent.Clear();
- continue;
- }
-
- // Add content to current subsection
- if (!string.IsNullOrWhiteSpace(line))
- {
- subSectionContent.AppendLine(line);
- }
- }
-
- // Process the last subsection
- ProcessMethodSubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
-
- // Add the method signature with a semicolon
- builder.AppendLine($" {methodSignatures[methodName]};");
- builder.AppendLine();
- }
-
- private static void ProcessMethodSubSection(StringBuilder builder, string subSection, string content)
- {
- if (string.IsNullOrEmpty(content))
- return;
-
- switch (subSection)
- {
- case "Parameters":
- foreach (var line in content.Split('\n'))
- {
- if (string.IsNullOrWhiteSpace(line)) continue;
- var match = Regex.Match(line, @"`(.*?)`\s*-\s*(.*)");
- if (match.Success)
- {
- builder.AppendLine($" /// {match.Groups[2].Value.Trim()}");
- }
- }
- break;
-
- case "Returns":
- builder.AppendLine(" /// ");
- foreach (var line in content.Split('\n'))
- {
- builder.AppendLine($" /// {line.Trim()}");
- }
- builder.AppendLine(" /// ");
- break;
-
- case "Exceptions":
- foreach (var line in content.Split('\n'))
- {
- if (string.IsNullOrWhiteSpace(line)) continue;
- var match = Regex.Match(line, @"`(.*?)`\s*-\s*(.*)");
- if (match.Success)
- {
- builder.AppendLine($" /// {match.Groups[2].Value.Trim()}");
- }
- }
- break;
- }
- }
-
- private static void ProcessPropertySection(StringBuilder builder, string propertyName, string description, string content, Dictionary propertySignatures)
- {
- if (string.IsNullOrEmpty(propertyName))
- return;
-
- if (!propertySignatures.ContainsKey(propertyName))
- return;
-
- builder.AppendLine($" // Documentation for {propertyName}");
-
- // Add property summary if available
- if (!string.IsNullOrEmpty(description))
- {
- builder.AppendLine(" /// ");
- builder.AppendLine($" /// {description}");
- builder.AppendLine(" /// ");
- }
-
- var reader = new StringReader(content);
- string? line;
- var currentSubSection = "";
- var subSectionContent = new StringBuilder();
-
- while ((line = reader.ReadLine()) != null)
- {
- if (line.StartsWith("#### "))
- {
- // Process previous subsection
- ProcessPropertySubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
-
- // Start new subsection
- currentSubSection = line.Substring(5).Trim();
- subSectionContent.Clear();
- continue;
- }
-
- // Add content to current subsection
- if (!string.IsNullOrWhiteSpace(line))
- {
- subSectionContent.AppendLine(line);
- }
- }
-
- // Process the last subsection
- ProcessPropertySubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
-
- // Add the property signature
- builder.AppendLine($" {propertySignatures[propertyName]}");
- builder.AppendLine();
- }
-
- private static void ProcessPropertySubSection(StringBuilder builder, string subSection, string content)
- {
- if (string.IsNullOrEmpty(content))
- return;
-
- switch (subSection)
- {
- case "Value":
- builder.AppendLine(" /// ");
- foreach (var line in content.Split('\n'))
- {
- builder.AppendLine($" /// {line.Trim()}");
- }
- builder.AppendLine(" /// ");
- break;
- }
- }
-
- private static string ConvertToKebabCase(string pascalCase)
- {
- if (string.IsNullOrEmpty(pascalCase))
- return pascalCase;
-
- // Insert hyphens before uppercase letters (except the first character)
- var kebabCase = Regex.Replace(pascalCase, "(? initializationTrigger = context.CompilationProvider
+ .Select((compilation, _) => true);
+
+ context.RegisterSourceOutput(initializationTrigger, (sourceContext, _) =>
+ {
+ sourceContext.ReportDiagnostic(
+ Diagnostic.Create(MarkdownDocsGeneratorLoadedDescriptor, Location.None)
+ );
+ });
+
+ // Find all class declarations in C# files
+ IncrementalValuesProvider<(ClassDeclarationSyntax ClassDeclaration, string? Namespace, string FilePath)> classDeclarations =
+ context.SyntaxProvider
+ .CreateSyntaxProvider(
+ predicate: (s, _) => s is ClassDeclarationSyntax,
+ transform: (ctx, _) =>
+ {
+ var classDeclaration = (ClassDeclarationSyntax)ctx.Node;
+ var namespaceDecl = classDeclaration.Ancestors().OfType().FirstOrDefault();
+ var filePath = ctx.Node.SyntaxTree.FilePath;
+ return (classDeclaration, namespaceDecl?.Name.ToString(), filePath);
+ });
+
+ // Find all .md files
+ IncrementalValuesProvider markdownFiles = context.AdditionalTextsProvider
+ .Where(file => file.Path.EndsWith(".md", StringComparison.OrdinalIgnoreCase));
+
+ // Group class declarations by class name to avoid duplicates
+ var groupedClasses = classDeclarations
+ .Collect()
+ .Select((classes, _) => classes
+ .GroupBy(c => (c.ClassDeclaration.Identifier.Text, c.Namespace))
+ .Select(g => g.First())
+ .ToArray());
+
+ // Combine into pairs
+ var pairs = groupedClasses.Combine(markdownFiles.Collect());
+
+ // Generate documentation for matching pairs
+ context.RegisterSourceOutput(pairs, (sourceContext, pair) =>
+ {
+ var (classGroups, markdownTexts) = pair;
+
+ foreach (var (classDeclaration, namespaceName, filePath) in classGroups)
+ {
+ var className = classDeclaration.Identifier.Text;
+
+ // Extract source file name without extension
+ var sourceFileName = Path.GetFileNameWithoutExtension(filePath);
+
+ // Find matching markdown file using appropriate strategy
+ AdditionalText? matchingMd = null;
+
+ // If source file is kebab-case, try to match kebab-case markdown first
+ if (IsKebabCase(sourceFileName))
+ {
+ matchingMd = markdownTexts.FirstOrDefault(md =>
+ Path.GetFileNameWithoutExtension(md.Path).Equals(sourceFileName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ // Fall back to class name match (for backward compatibility or PascalCase files)
+ if (matchingMd == null)
+ {
+ matchingMd = markdownTexts.FirstOrDefault(md =>
+ Path.GetFileNameWithoutExtension(md.Path).Equals(className, StringComparison.OrdinalIgnoreCase));
+ }
+
+ // Also try kebab-case of the class name (PascalCase source + kebab markdown)
+ if (matchingMd == null)
+ {
+ string kebabClassName = ConvertToKebabCase(className);
+ matchingMd = markdownTexts.FirstOrDefault(md =>
+ Path.GetFileNameWithoutExtension(md.Path).Equals(kebabClassName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (matchingMd != null)
+ {
+ var markdownContent = matchingMd.GetText()?.ToString() ?? string.Empty;
+ var (classDocs, methodDocs, propertyDocs) = ConvertMarkdownToXmlDocs(markdownContent, classDeclaration);
+
+ var sourceText = SourceText.From($@"// Auto-generated documentation for {className}
+#nullable enable
+
+{(namespaceName != null ? $"namespace {namespaceName};" : "")}
+
+{classDocs}
+public partial class {className}
+{{
+{propertyDocs}
+{methodDocs}
+}}
+", Encoding.UTF8);
+
+ sourceContext.AddSource($"{className}.docs.g.cs", sourceText);
+ }
+ }
+ });
+ }
+
+
+ private static string ConvertToKebabCase(string pascalCase)
+ {
+ if (string.IsNullOrEmpty(pascalCase))
+ return pascalCase;
+
+ // Insert hyphens before uppercase letters (except the first character)
+ var kebabCase = Regex.Replace(pascalCase, "(?()
+ .ToDictionary(
+ m => m.Identifier.Text,
+ m => $"public partial {m.ReturnType} {m.Identifier}({string.Join(", ", m.ParameterList.Parameters.Select(p => $"{p.Type} {p.Identifier}{(p.Default != null ? " = " + p.Default.Value.ToString() : "")}"))})"
+ );
+
+ // Get property signatures from the class declaration (only partial properties)
+ var propertySignatures = classDeclaration.Members
+ .OfType()
+ .Where(p => p.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
+ .ToDictionary(
+ p => p.Identifier.Text,
+ p =>
+ {
+ var modifiers = string.Join(" ", p.Modifiers.Select(m => m.Text));
+ var accessors = new List();
+
+ if (p.AccessorList != null)
+ {
+ foreach (var accessor in p.AccessorList.Accessors)
+ {
+ if (accessor.Keyword.Text == "get")
+ {
+ // For non-nullable reference types, initialize with default
+ var typeStr = p.Type?.ToString() ?? "";
+ if (typeStr == "string" && !typeStr.EndsWith("?"))
+ {
+ accessors.Add("get => field ??= \"\"");
+ }
+ else
+ {
+ accessors.Add("get => field");
+ }
+ }
+ else if (accessor.Keyword.Text == "set")
+ {
+ accessors.Add("set => field = value");
+ }
+ else if (accessor.Keyword.Text == "init")
+ {
+ accessors.Add("init => field = value");
+ }
+ }
+ }
+
+ var accessorString = string.Join("; ", accessors);
+ return $"{modifiers} {p.Type} {p.Identifier} {{ {accessorString}; }}";
+ }
+ );
+
+ while ((line = reader.ReadLine()) != null)
+ {
+ if (line.StartsWith("# ")) // Class name - skip
+ continue;
+
+ if (line.StartsWith("## "))
+ {
+ // Process previous section
+ if (inMethodSection)
+ ProcessMethodSection(methodBuilder, currentMethod, currentMethodDescription, contentBuilder.ToString().Trim(), methodSignatures);
+ else if (inPropertySection)
+ ProcessPropertySection(propertyBuilder, currentProperty, currentPropertyDescription, contentBuilder.ToString().Trim(), propertySignatures);
+ else
+ ProcessSection(classBuilder, currentSection, contentBuilder.ToString().Trim());
+
+ // Start new section
+ currentSection = line.Substring(3).Trim();
+ inMethodSection = currentSection == "Methods";
+ inPropertySection = currentSection == "Properties";
+ contentBuilder.Clear();
+ currentMethodDescription = "";
+ currentPropertyDescription = "";
+ continue;
+ }
+
+ if (line.StartsWith("### ") && inMethodSection)
+ {
+ // Process previous method if exists
+ if (!string.IsNullOrEmpty(currentMethod))
+ {
+ ProcessMethodSection(methodBuilder, currentMethod, currentMethodDescription, contentBuilder.ToString().Trim(), methodSignatures);
+ }
+
+ // Start new method
+ currentMethod = line.Substring(4).Trim();
+ contentBuilder.Clear();
+ currentMethodDescription = "";
+ continue;
+ }
+
+ if (line.StartsWith("### ") && inPropertySection)
+ {
+ // Process previous property if exists
+ if (!string.IsNullOrEmpty(currentProperty))
+ {
+ ProcessPropertySection(propertyBuilder, currentProperty, currentPropertyDescription, contentBuilder.ToString().Trim(), propertySignatures);
+ }
+
+ // Start new property
+ currentProperty = line.Substring(4).Trim();
+ contentBuilder.Clear();
+ currentPropertyDescription = "";
+ continue;
+ }
+
+ // If we haven't hit any section yet, this is the class summary
+ if (string.IsNullOrEmpty(currentSection) && !string.IsNullOrWhiteSpace(line))
+ {
+ classBuilder.AppendLine("/// ");
+ classBuilder.AppendLine($"/// {line.Trim()}");
+ classBuilder.AppendLine("/// ");
+ continue;
+ }
+
+ // Capture method description (text before any #### subsections)
+ if (inMethodSection && !string.IsNullOrWhiteSpace(line) && !line.StartsWith("####") && string.IsNullOrEmpty(currentMethodDescription))
+ {
+ currentMethodDescription = line.Trim();
+ continue;
+ }
+
+ // Capture property description (text before any #### subsections)
+ if (inPropertySection && !string.IsNullOrWhiteSpace(line) && !line.StartsWith("####") && string.IsNullOrEmpty(currentPropertyDescription))
+ {
+ currentPropertyDescription = line.Trim();
+ continue;
+ }
+
+ // Add content to current section
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ contentBuilder.AppendLine(line);
+ }
+ }
+
+ // Process the last section
+ if (inMethodSection)
+ ProcessMethodSection(methodBuilder, currentMethod, currentMethodDescription, contentBuilder.ToString().Trim(), methodSignatures);
+ else if (inPropertySection)
+ ProcessPropertySection(propertyBuilder, currentProperty, currentPropertyDescription, contentBuilder.ToString().Trim(), propertySignatures);
+ else
+ ProcessSection(classBuilder, currentSection, contentBuilder.ToString().Trim());
+
+ return (classBuilder.ToString(), methodBuilder.ToString(), propertyBuilder.ToString());
+ }
+
+ private static void ProcessSection(StringBuilder builder, string section, string content)
+ {
+ if (string.IsNullOrEmpty(content))
+ return;
+
+ switch (section)
+ {
+ case "Remarks":
+ builder.AppendLine("/// ");
+ foreach (var line in content.Split('\n'))
+ {
+ builder.AppendLine($"/// {line.Trim()}");
+ }
+ builder.AppendLine("/// ");
+ break;
+
+ case "See Also":
+ foreach (var line in content.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ var match = Regex.Match(line, @"\[(.*?)\]\((.*?)\)");
+ if (match.Success)
+ {
+ var type = match.Groups[1].Value;
+ // Convert angle brackets to curly braces for XML documentation
+ var xmlType = type.Replace('<', '{').Replace('>', '}');
+ builder.AppendLine($"/// ");
+ }
+ }
+ break;
+
+ case "Inheritance":
+ if (content.Contains("@"))
+ {
+ var match = Regex.Match(content, @"@(\S+)");
+ if (match.Success)
+ {
+ var type = match.Groups[1].Value;
+ // Extract the type name and keep T as the generic parameter
+ var baseType = type.Split('<')[0];
+ builder.AppendLine($"/// ");
+ }
+ }
+ break;
+
+ case "References":
+ foreach (var line in content.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ var match = Regex.Match(line, @"@(\S+)");
+ if (match.Success)
+ {
+ var type = match.Groups[1].Value;
+ // Handle fully qualified type names
+ if (!type.Contains("."))
+ {
+ type = "System." + type;
+ }
+ builder.AppendLine($"/// ");
+ }
+ }
+ break;
+ }
+ }
+
+ private static void ProcessMethodSection(StringBuilder builder, string methodName, string description, string content, Dictionary methodSignatures)
+ {
+ if (string.IsNullOrEmpty(methodName))
+ return;
+
+ // Handle constructor specially
+ if (methodName == "Constructor")
+ methodName = methodSignatures.Keys.FirstOrDefault(k => k.Contains("ctor")) ?? "";
+
+ if (!methodSignatures.ContainsKey(methodName))
+ return;
+
+ builder.AppendLine($" // Documentation for {methodName}");
+
+ // Add method summary if available
+ if (!string.IsNullOrEmpty(description))
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine($" /// {description}");
+ builder.AppendLine(" /// ");
+ }
+
+ var reader = new StringReader(content);
+ string? line;
+ var currentSubSection = "";
+ var subSectionContent = new StringBuilder();
+
+ while ((line = reader.ReadLine()) != null)
+ {
+ if (line.StartsWith("#### "))
+ {
+ // Process previous subsection
+ ProcessMethodSubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
+
+ // Start new subsection
+ currentSubSection = line.Substring(5).Trim();
+ subSectionContent.Clear();
+ continue;
+ }
+
+ // Add content to current subsection
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ subSectionContent.AppendLine(line);
+ }
+ }
+
+ // Process the last subsection
+ ProcessMethodSubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
+
+ // Add the method signature with a semicolon
+ builder.AppendLine($" {methodSignatures[methodName]};");
+ builder.AppendLine();
+ }
+
+ private static void ProcessMethodSubSection(StringBuilder builder, string subSection, string content)
+ {
+ if (string.IsNullOrEmpty(content))
+ return;
+
+ switch (subSection)
+ {
+ case "Parameters":
+ foreach (var line in content.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ var match = Regex.Match(line, @"`(.*?)`\s*-\s*(.*)");
+ if (match.Success)
+ {
+ builder.AppendLine($" /// {match.Groups[2].Value.Trim()}");
+ }
+ }
+ break;
+
+ case "Returns":
+ builder.AppendLine(" /// ");
+ foreach (var line in content.Split('\n'))
+ {
+ builder.AppendLine($" /// {line.Trim()}");
+ }
+ builder.AppendLine(" /// ");
+ break;
+
+ case "Exceptions":
+ foreach (var line in content.Split('\n'))
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+ var match = Regex.Match(line, @"`(.*?)`\s*-\s*(.*)");
+ if (match.Success)
+ {
+ builder.AppendLine($" /// {match.Groups[2].Value.Trim()}");
+ }
+ }
+ break;
+ }
+ }
+
+ private static void ProcessPropertySection(StringBuilder builder, string propertyName, string description, string content, Dictionary propertySignatures)
+ {
+ if (string.IsNullOrEmpty(propertyName))
+ return;
+
+ if (!propertySignatures.ContainsKey(propertyName))
+ return;
+
+ builder.AppendLine($" // Documentation for {propertyName}");
+
+ // Add property summary if available
+ if (!string.IsNullOrEmpty(description))
+ {
+ builder.AppendLine(" /// ");
+ builder.AppendLine($" /// {description}");
+ builder.AppendLine(" /// ");
+ }
+
+ var reader = new StringReader(content);
+ string? line;
+ var currentSubSection = "";
+ var subSectionContent = new StringBuilder();
+
+ while ((line = reader.ReadLine()) != null)
+ {
+ if (line.StartsWith("#### "))
+ {
+ // Process previous subsection
+ ProcessPropertySubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
+
+ // Start new subsection
+ currentSubSection = line.Substring(5).Trim();
+ subSectionContent.Clear();
+ continue;
+ }
+
+ // Add content to current subsection
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ subSectionContent.AppendLine(line);
+ }
+ }
+
+ // Process the last subsection
+ ProcessPropertySubSection(builder, currentSubSection, subSectionContent.ToString().Trim());
+
+ // Add the property signature
+ builder.AppendLine($" {propertySignatures[propertyName]}");
+ builder.AppendLine();
+ }
+
+ private static void ProcessPropertySubSection(StringBuilder builder, string subSection, string content)
+ {
+ if (string.IsNullOrEmpty(content))
+ return;
+
+ switch (subSection)
+ {
+ case "Value":
+ builder.AppendLine(" /// ");
+ foreach (var line in content.Split('\n'))
+ {
+ builder.AppendLine($" /// {line.Trim()}");
+ }
+ builder.AppendLine(" /// ");
+ break;
+ }
+ }
+}
diff --git a/tools/dev-cli/Directory.Build.props b/tools/dev-cli/Directory.Build.props
index 29f5420..99d3af3 100644
--- a/tools/dev-cli/Directory.Build.props
+++ b/tools/dev-cli/Directory.Build.props
@@ -2,6 +2,9 @@
+
+ net11.0
+
$(NoWarn);CA1031;CA1303;CA1508;CA1515;CA1849;CA2007;CA2016;CA2000;CA1034;CA5399;IDE0065;IL2026;IL2104;IL3050;IL3053;RCS1046;IDE0005;IDE0022;IDE0046;IDE0052;IDE0055;IDE0058;IDE0066;IDE0078;IDE0160;IDE0211;IDE0290
diff --git a/tools/dev-cli/endpoints/build-command.cs b/tools/dev-cli/endpoints/build-command.cs
index a20f772..171e843 100644
--- a/tools/dev-cli/endpoints/build-command.cs
+++ b/tools/dev-cli/endpoints/build-command.cs
@@ -31,17 +31,17 @@ public Handler(ITerminal terminal)
Terminal = terminal;
}
- public async ValueTask Handle(BuildCommand command, CancellationToken ct)
+ public async Task Handle(BuildCommand command, CancellationToken ct)
{
Command = command;
Ct = ct;
- if (!FindRepoRoot()) return Value;
- if (!await CleanAsync()) return Value;
- if (!await BuildAsync()) return Value;
+ if (!FindRepoRoot()) return Unit.Value;
+ if (!await CleanAsync()) return Unit.Value;
+ if (!await BuildAsync()) return Unit.Value;
Terminal.WriteLine("\nBuild completed successfully!".Green());
- return Value;
+ return Unit.Value;
}
private bool FindRepoRoot()
diff --git a/tools/dev-cli/endpoints/test-command.cs b/tools/dev-cli/endpoints/test-command.cs
index 96203b6..5b7aca8 100644
--- a/tools/dev-cli/endpoints/test-command.cs
+++ b/tools/dev-cli/endpoints/test-command.cs
@@ -34,17 +34,17 @@ public Handler(ITerminal terminal)
Terminal = terminal;
}
- public async ValueTask Handle(TestCommand command, CancellationToken ct)
+ public async Task Handle(TestCommand command, CancellationToken ct)
{
Command = command;
Ct = ct;
- if (!FindRepoRoot()) return Value;
- if (!await BuildTestProjectAsync()) return Value;
- if (!await RunTestProjectAsync()) return Value;
+ if (!FindRepoRoot()) return Unit.Value;
+ if (!await BuildTestProjectAsync()) return Unit.Value;
+ if (!await RunTestProjectAsync()) return Unit.Value;
Terminal.WriteLine("\nTests completed successfully!".Green());
- return Value;
+ return Unit.Value;
}
private bool FindRepoRoot()
diff --git a/tools/dev-cli/endpoints/verify-samples-command.cs b/tools/dev-cli/endpoints/verify-samples-command.cs
index c9c03f9..39a9f4d 100644
--- a/tools/dev-cli/endpoints/verify-samples-command.cs
+++ b/tools/dev-cli/endpoints/verify-samples-command.cs
@@ -19,12 +19,12 @@ public Handler(ITerminal terminal)
Terminal = terminal;
}
- public ValueTask Handle(VerifySamplesCommand command, CancellationToken ct)
+ public Task Handle(VerifySamplesCommand command, CancellationToken ct)
{
Terminal.WriteLine("Verifying samples...");
// TODO: Implement sample verification logic specific to this repo
Terminal.WriteLine("Samples verified successfully!");
- return ValueTask.FromResult(Value);
+ return Task.FromResult(Unit.Value);
}
}
}
diff --git a/tools/dev-cli/endpoints/workflow-command.cs b/tools/dev-cli/endpoints/workflow-command.cs
index 5ffd150..8f3b89b 100644
--- a/tools/dev-cli/endpoints/workflow-command.cs
+++ b/tools/dev-cli/endpoints/workflow-command.cs
@@ -46,11 +46,11 @@ INuGetPackageService nuGetPackageService
NuGetPackageService = nuGetPackageService;
}
- public async ValueTask Handle(WorkflowCommand command, CancellationToken ct)
+ public async Task Handle(WorkflowCommand command, CancellationToken ct)
{
Ct = ct;
- if (!FindRepoRoot()) return Value;
+ if (!FindRepoRoot()) return Unit.Value;
CiMode mode = DetermineMode(command.Mode);
Terminal.WriteLine($"Starting CI workflow (mode: {mode})...");
@@ -67,7 +67,7 @@ public async ValueTask Handle(WorkflowCommand command, CancellationToken c
if (Environment.ExitCode == 0)
Terminal.WriteLine("\nWorkflow completed successfully!".Green());
- return Value;
+ return Unit.Value;
}
private bool FindRepoRoot()
@@ -135,7 +135,7 @@ private async Task RunReleaseWorkflowAsync(string? apiKey)
await PushPackageAsync(version, apiKey);
}
- private async ValueTask CleanAsync()
+ private async Task CleanAsync()
{
Terminal.WriteLine("Cleaning repository artifacts...");
CleanResult result = await RepoCleanService.CleanAsync(Ct);
@@ -143,10 +143,10 @@ private async ValueTask CleanAsync()
(
$"Clean complete: {result.ObjDirectoriesDeleted} obj, {result.BinDirectoriesDeleted} bin, {result.RootBinFilesCleaned} root-bin items."
);
- return Value;
+ return Unit.Value;
}
- private async Task RunStepAsync(string title, Func> step)
+ private async Task RunStepAsync(string title, Func> step)
{
Terminal.WriteLine($"\n=== {title} ===");
await step();
diff --git a/tools/dev-cli/global-usings.cs b/tools/dev-cli/global-usings.cs
index 9052391..a05786d 100644
--- a/tools/dev-cli/global-usings.cs
+++ b/tools/dev-cli/global-usings.cs
@@ -1,6 +1,10 @@
#region Purpose
// Global usings for dev-cli
#endregion
+#region Design
+// ICommand, ICommandHandler, and Unit live in TimeWarp.Mediator as of Nuru 4.
+// A static using of Unit collides with Task inside the DevCli package sources, so handlers return Unit.Value.
+#endregion
global using System;
global using System.IO;
@@ -14,8 +18,8 @@
global using System.Xml.Linq;
global using System.Text.RegularExpressions;
+global using TimeWarp.Mediator;
global using TimeWarp.Nuru;
-global using static TimeWarp.Nuru.Unit;
global using TimeWarp.Amuru;
global using TimeWarp.Terminal;
global using DevCli;