# PortTracker Batch Operations Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

**Goal:** Add batch-update and batch-map subcommands to all PortTracker entity commands (feature, test, module, library) with shared filter infrastructure and dry-run-by-default safety.

**Architecture:** A shared `BatchFilters` static class provides reusable filter options (`--ids`, `--module`, `--status`), WHERE clause building, and the dry-run/execute pattern. Each entity command file gets two new subcommands that delegate filtering and execution to `BatchFilters`. The `Database` class gets an `ExecuteInTransaction` helper.

**Tech Stack:** .NET 10, System.CommandLine v3 preview, Microsoft.Data.Sqlite

**Design doc:** `docs/plans/2026-02-27-porttracker-batch-design.md`

---

### Task 0: Add ExecuteInTransaction to Database

**Files:**
- Modify: `tools/NatsNet.PortTracker/Data/Database.cs:73` (before Dispose)

**Step 1: Add the method**

Add this method to `Database.cs` before the `Dispose()` method (line 73):

```csharp
public int ExecuteInTransaction(string sql, params (string name, object? value)[] parameters)
{
    using var transaction = _connection.BeginTransaction();
    try
    {
        using var cmd = CreateCommand(sql);
        cmd.Transaction = transaction;
        foreach (var (name, value) in parameters)
            cmd.Parameters.AddWithValue(name, value ?? DBNull.Value);
        var affected = cmd.ExecuteNonQuery();
        transaction.Commit();
        return affected;
    }
    catch
    {
        transaction.Rollback();
        throw;
    }
}
```

**Step 2: Verify it compiles**

Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.

**Step 3: Commit**

```bash
git add tools/NatsNet.PortTracker/Data/Database.cs
git commit -m "feat(porttracker): add ExecuteInTransaction to Database"
```

---

### Task 1: Create BatchFilters shared infrastructure

**Files:**
- Create: `tools/NatsNet.PortTracker/Commands/BatchFilters.cs`

**Step 1: Create the file**

Create `tools/NatsNet.PortTracker/Commands/BatchFilters.cs` with this content:

```csharp
using System.CommandLine;
using NatsNet.PortTracker.Data;

namespace NatsNet.PortTracker.Commands;

public static class BatchFilters
{
    public static Option<string?> IdsOption() => new("--ids")
    {
        Description = "ID range: 100-200, 1,5,10, or mixed 1-5,10,20-25"
    };

    public static Option<int?> ModuleOption() => new("--module")
    {
        Description = "Filter by module ID"
    };

    public static Option<string?> StatusOption() => new("--status")
    {
        Description = "Filter by current status"
    };

    public static Option<bool> ExecuteOption() => new("--execute")
    {
        Description = "Actually apply changes (default is dry-run preview)",
        DefaultValueFactory = _ => false
    };

    public static void AddFilterOptions(Command cmd, bool includeModuleFilter)
    {
        cmd.Add(IdsOption());
        if (includeModuleFilter)
            cmd.Add(ModuleOption());
        cmd.Add(StatusOption());
        cmd.Add(ExecuteOption());
    }

    public static List<int> ParseIds(string? idsSpec)
    {
        if (string.IsNullOrWhiteSpace(idsSpec)) return [];

        var ids = new List<int>();
        foreach (var part in idsSpec.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
        {
            if (part.Contains('-'))
            {
                var range = part.Split('-', 2);
                if (int.TryParse(range[0], out var start) && int.TryParse(range[1], out var end))
                {
                    for (var i = start; i <= end; i++)
                        ids.Add(i);
                }
                else
                {
                    Console.WriteLine($"Warning: invalid range '{part}', skipping.");
                }
            }
            else if (int.TryParse(part, out var id))
            {
                ids.Add(id);
            }
            else
            {
                Console.WriteLine($"Warning: invalid ID '{part}', skipping.");
            }
        }
        return ids;
    }

    public static (string whereClause, List<(string name, object? value)> parameters) BuildWhereClause(
        string? idsSpec, int? moduleId, string? status, string idColumn = "id", string moduleColumn = "module_id")
    {
        var clauses = new List<string>();
        var parameters = new List<(string name, object? value)>();

        if (!string.IsNullOrWhiteSpace(idsSpec))
        {
            var ids = ParseIds(idsSpec);
            if (ids.Count > 0)
            {
                var placeholders = new List<string>();
                for (var i = 0; i < ids.Count; i++)
                {
                    placeholders.Add($"@id{i}");
                    parameters.Add(($"@id{i}", ids[i]));
                }
                clauses.Add($"{idColumn} IN ({string.Join(", ", placeholders)})");
            }
        }

        if (moduleId is not null)
        {
            clauses.Add($"{moduleColumn} = @moduleFilter");
            parameters.Add(("@moduleFilter", moduleId));
        }

        if (!string.IsNullOrWhiteSpace(status))
        {
            clauses.Add("status = @statusFilter");
            parameters.Add(("@statusFilter", status));
        }

        if (clauses.Count == 0)
            return ("", parameters);

        return (" WHERE " + string.Join(" AND ", clauses), parameters);
    }

    public static void PreviewOrExecute(
        Database db,
        string table,
        string displayColumns,
        string updateSetClause,
        List<(string name, object? value)> updateParams,
        string whereClause,
        List<(string name, object? value)> filterParams,
        bool execute)
    {
        // Count matching rows
        var countSql = $"SELECT COUNT(*) FROM {table}{whereClause}";
        var count = db.ExecuteScalar<long>(countSql, filterParams.ToArray());

        if (count == 0)
        {
            Console.WriteLine("No items match the specified filters.");
            return;
        }

        // Preview
        var previewSql = $"SELECT {displayColumns} FROM {table}{whereClause} ORDER BY id";
        var rows = db.Query(previewSql, filterParams.ToArray());

        if (!execute)
        {
            Console.WriteLine($"Would affect {count} items:");
            Console.WriteLine();
            PrintPreviewTable(rows);
            Console.WriteLine();
            Console.WriteLine("Add --execute to apply these changes.");
            return;
        }

        // Execute
        var allParams = new List<(string name, object? value)>();
        allParams.AddRange(updateParams);
        allParams.AddRange(filterParams);

        var updateSql = $"UPDATE {table} SET {updateSetClause}{whereClause}";
        var affected = db.ExecuteInTransaction(updateSql, allParams.ToArray());
        Console.WriteLine($"Updated {affected} items.");
    }

    private static void PrintPreviewTable(List<Dictionary<string, object?>> rows)
    {
        if (rows.Count == 0) return;

        var columns = rows[0].Keys.ToList();
        var widths = columns.Select(c => c.Length).ToList();

        foreach (var row in rows)
        {
            for (var i = 0; i < columns.Count; i++)
            {
                var val = row[columns[i]]?.ToString() ?? "";
                if (val.Length > widths[i]) widths[i] = Math.Min(val.Length, 40);
            }
        }

        // Header
        var header = string.Join(" ", columns.Select((c, i) => Truncate(c, widths[i]).PadRight(widths[i])));
        Console.WriteLine(header);
        Console.WriteLine(new string('-', header.Length));

        // Rows (cap at 50 for preview)
        var displayRows = rows.Take(50).ToList();
        foreach (var row in displayRows)
        {
            var line = string.Join(" ", columns.Select((c, i) =>
                Truncate(row[c]?.ToString() ?? "", widths[i]).PadRight(widths[i])));
            Console.WriteLine(line);
        }

        if (rows.Count > 50)
            Console.WriteLine($"  ... and {rows.Count - 50} more");
    }

    private static string Truncate(string s, int maxLen)
    {
        return s.Length <= maxLen ? s : s[..(maxLen - 2)] + "..";
    }
}
```

**Step 2: Verify it compiles**

Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.

**Step 3: Commit**

```bash
git add tools/NatsNet.PortTracker/Commands/BatchFilters.cs
git commit -m "feat(porttracker): add BatchFilters shared infrastructure"
```

---

### Task 2: Add batch commands to FeatureCommands

**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/FeatureCommands.cs:169-175`

**Step 1: Add batch-update and batch-map subcommands**

In `FeatureCommands.cs`, insert the batch commands before the `return featureCommand;` line (line 175). Add them after the existing `featureCommand.Add(naCmd);` at line 173.

Replace lines 169-175 with:

```csharp
        featureCommand.Add(listCmd);
        featureCommand.Add(showCmd);
        featureCommand.Add(updateCmd);
        featureCommand.Add(mapCmd);
        featureCommand.Add(naCmd);
        featureCommand.Add(CreateBatchUpdate(dbOption));
        featureCommand.Add(CreateBatchMap(dbOption));

        return featureCommand;
```

Then add these two static methods to the class (before the `Truncate` method at line 178):

```csharp
    private static Command CreateBatchUpdate(Option<string> dbOption)
    {
        var cmd = new Command("batch-update", "Bulk update feature status");
        var idsOpt = BatchFilters.IdsOption();
        var moduleOpt = BatchFilters.ModuleOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
        var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };

        cmd.Add(idsOpt);
        cmd.Add(moduleOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setStatus);
        cmd.Add(setNotes);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var module = parseResult.GetValue(moduleOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var newStatus = parseResult.GetValue(setStatus)!;
            var notes = parseResult.GetValue(setNotes);

            if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);

            var setClauses = new List<string> { "status = @newStatus" };
            var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
            if (notes is not null)
            {
                setClauses.Add("notes = @newNotes");
                updateParams.Add(("@newNotes", notes));
            }

            BatchFilters.PreviewOrExecute(db, "features",
                "id, name, status, module_id, notes",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }

    private static Command CreateBatchMap(Option<string> dbOption)
    {
        var cmd = new Command("batch-map", "Bulk map features to .NET methods");
        var idsOpt = BatchFilters.IdsOption();
        var moduleOpt = BatchFilters.ModuleOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setProject = new Option<string?>("--set-project") { Description = ".NET project" };
        var setClass = new Option<string?>("--set-class") { Description = ".NET class" };
        var setMethod = new Option<string?>("--set-method") { Description = ".NET method" };

        cmd.Add(idsOpt);
        cmd.Add(moduleOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setProject);
        cmd.Add(setClass);
        cmd.Add(setMethod);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var module = parseResult.GetValue(moduleOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var project = parseResult.GetValue(setProject);
            var cls = parseResult.GetValue(setClass);
            var method = parseResult.GetValue(setMethod);

            if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
                return;
            }
            if (project is null && cls is null && method is null)
            {
                Console.WriteLine("Error: at least one of --set-project, --set-class, --set-method is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);

            var setClauses = new List<string>();
            var updateParams = new List<(string, object?)>();
            if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
            if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
            if (method is not null) { setClauses.Add("dotnet_method = @setMethod"); updateParams.Add(("@setMethod", method)); }

            BatchFilters.PreviewOrExecute(db, "features",
                "id, name, status, dotnet_project, dotnet_class, dotnet_method",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }
```

**Step 2: Verify it compiles**

Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.

**Step 3: Smoke test dry-run**

Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --module 1 --status not_started --set-status deferred --db porting.db`
Expected: Preview output showing matching features (or "No items match").

**Step 4: Commit**

```bash
git add tools/NatsNet.PortTracker/Commands/FeatureCommands.cs
git commit -m "feat(porttracker): add feature batch-update and batch-map commands"
```

---

### Task 3: Add batch commands to TestCommands

**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/TestCommands.cs:130-135`

**Step 1: Add batch-update and batch-map subcommands**

In `TestCommands.cs`, replace lines 130-135 with:

```csharp
        testCommand.Add(listCmd);
        testCommand.Add(showCmd);
        testCommand.Add(updateCmd);
        testCommand.Add(mapCmd);
        testCommand.Add(CreateBatchUpdate(dbOption));
        testCommand.Add(CreateBatchMap(dbOption));

        return testCommand;
```

Then add these two static methods before the `Truncate` method (line 138):

```csharp
    private static Command CreateBatchUpdate(Option<string> dbOption)
    {
        var cmd = new Command("batch-update", "Bulk update test status");
        var idsOpt = BatchFilters.IdsOption();
        var moduleOpt = BatchFilters.ModuleOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
        var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };

        cmd.Add(idsOpt);
        cmd.Add(moduleOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setStatus);
        cmd.Add(setNotes);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var module = parseResult.GetValue(moduleOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var newStatus = parseResult.GetValue(setStatus)!;
            var notes = parseResult.GetValue(setNotes);

            if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);

            var setClauses = new List<string> { "status = @newStatus" };
            var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
            if (notes is not null)
            {
                setClauses.Add("notes = @newNotes");
                updateParams.Add(("@newNotes", notes));
            }

            BatchFilters.PreviewOrExecute(db, "unit_tests",
                "id, name, status, module_id, notes",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }

    private static Command CreateBatchMap(Option<string> dbOption)
    {
        var cmd = new Command("batch-map", "Bulk map tests to .NET test methods");
        var idsOpt = BatchFilters.IdsOption();
        var moduleOpt = BatchFilters.ModuleOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setProject = new Option<string?>("--set-project") { Description = ".NET test project" };
        var setClass = new Option<string?>("--set-class") { Description = ".NET test class" };
        var setMethod = new Option<string?>("--set-method") { Description = ".NET test method" };

        cmd.Add(idsOpt);
        cmd.Add(moduleOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setProject);
        cmd.Add(setClass);
        cmd.Add(setMethod);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var module = parseResult.GetValue(moduleOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var project = parseResult.GetValue(setProject);
            var cls = parseResult.GetValue(setClass);
            var method = parseResult.GetValue(setMethod);

            if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
                return;
            }
            if (project is null && cls is null && method is null)
            {
                Console.WriteLine("Error: at least one of --set-project, --set-class, --set-method is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);

            var setClauses = new List<string>();
            var updateParams = new List<(string, object?)>();
            if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
            if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
            if (method is not null) { setClauses.Add("dotnet_method = @setMethod"); updateParams.Add(("@setMethod", method)); }

            BatchFilters.PreviewOrExecute(db, "unit_tests",
                "id, name, status, dotnet_project, dotnet_class, dotnet_method",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }
```

**Step 2: Verify it compiles**

Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.

**Step 3: Smoke test dry-run**

Run: `dotnet run --project tools/NatsNet.PortTracker -- test batch-update --status not_started --set-status deferred --db porting.db`
Expected: Preview output showing matching tests (or "No items match").

**Step 4: Commit**

```bash
git add tools/NatsNet.PortTracker/Commands/TestCommands.cs
git commit -m "feat(porttracker): add test batch-update and batch-map commands"
```

---

### Task 4: Add batch commands to ModuleCommands

**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/ModuleCommands.cs:145-152`

**Step 1: Add batch-update and batch-map subcommands**

In `ModuleCommands.cs`, replace lines 145-152 with:

```csharp
        moduleCommand.Add(listCmd);
        moduleCommand.Add(showCmd);
        moduleCommand.Add(updateCmd);
        moduleCommand.Add(mapCmd);
        moduleCommand.Add(naCmd);
        moduleCommand.Add(CreateBatchUpdate(dbOption));
        moduleCommand.Add(CreateBatchMap(dbOption));

        return moduleCommand;
    }
```

Then add these two static methods before the closing `}` of the class:

```csharp
    private static Command CreateBatchUpdate(Option<string> dbOption)
    {
        var cmd = new Command("batch-update", "Bulk update module status");
        var idsOpt = BatchFilters.IdsOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
        var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };

        cmd.Add(idsOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setStatus);
        cmd.Add(setNotes);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var newStatus = parseResult.GetValue(setStatus)!;
            var notes = parseResult.GetValue(setNotes);

            if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);

            var setClauses = new List<string> { "status = @newStatus" };
            var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
            if (notes is not null)
            {
                setClauses.Add("notes = @newNotes");
                updateParams.Add(("@newNotes", notes));
            }

            BatchFilters.PreviewOrExecute(db, "modules",
                "id, name, status, notes",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }

    private static Command CreateBatchMap(Option<string> dbOption)
    {
        var cmd = new Command("batch-map", "Bulk map modules to .NET projects");
        var idsOpt = BatchFilters.IdsOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setProject = new Option<string?>("--set-project") { Description = ".NET project" };
        var setNamespace = new Option<string?>("--set-namespace") { Description = ".NET namespace" };
        var setClass = new Option<string?>("--set-class") { Description = ".NET class" };

        cmd.Add(idsOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setProject);
        cmd.Add(setNamespace);
        cmd.Add(setClass);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var project = parseResult.GetValue(setProject);
            var ns = parseResult.GetValue(setNamespace);
            var cls = parseResult.GetValue(setClass);

            if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
                return;
            }
            if (project is null && ns is null && cls is null)
            {
                Console.WriteLine("Error: at least one of --set-project, --set-namespace, --set-class is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);

            var setClauses = new List<string>();
            var updateParams = new List<(string, object?)>();
            if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
            if (ns is not null) { setClauses.Add("dotnet_namespace = @setNamespace"); updateParams.Add(("@setNamespace", ns)); }
            if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }

            BatchFilters.PreviewOrExecute(db, "modules",
                "id, name, status, dotnet_project, dotnet_namespace, dotnet_class",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }
```

**Step 2: Verify it compiles**

Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.

**Step 3: Commit**

```bash
git add tools/NatsNet.PortTracker/Commands/ModuleCommands.cs
git commit -m "feat(porttracker): add module batch-update and batch-map commands"
```

---

### Task 5: Add batch commands to LibraryCommands

**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/LibraryCommands.cs:86-91`

**Step 1: Add batch-update and batch-map subcommands**

In `LibraryCommands.cs`, replace lines 86-91 with:

```csharp
        libraryCommand.Add(listCmd);
        libraryCommand.Add(mapCmd);
        libraryCommand.Add(suggestCmd);
        libraryCommand.Add(CreateBatchUpdate(dbOption));
        libraryCommand.Add(CreateBatchMap(dbOption));

        return libraryCommand;
    }
```

Then add these two static methods before the `Truncate` method:

```csharp
    private static Command CreateBatchUpdate(Option<string> dbOption)
    {
        var cmd = new Command("batch-update", "Bulk update library status");
        var idsOpt = BatchFilters.IdsOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
        var setNotes = new Option<string?>("--set-notes") { Description = "Usage notes to set" };

        cmd.Add(idsOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setStatus);
        cmd.Add(setNotes);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var newStatus = parseResult.GetValue(setStatus)!;
            var notes = parseResult.GetValue(setNotes);

            if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);

            var setClauses = new List<string> { "status = @newStatus" };
            var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
            if (notes is not null)
            {
                setClauses.Add("dotnet_usage_notes = @newNotes");
                updateParams.Add(("@newNotes", notes));
            }

            BatchFilters.PreviewOrExecute(db, "library_mappings",
                "id, go_import_path, status, dotnet_usage_notes",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }

    private static Command CreateBatchMap(Option<string> dbOption)
    {
        var cmd = new Command("batch-map", "Bulk map libraries to .NET packages");
        var idsOpt = BatchFilters.IdsOption();
        var statusOpt = BatchFilters.StatusOption();
        var executeOpt = BatchFilters.ExecuteOption();
        var setPackage = new Option<string?>("--set-package") { Description = ".NET NuGet package" };
        var setNamespace = new Option<string?>("--set-namespace") { Description = ".NET namespace" };
        var setNotes = new Option<string?>("--set-notes") { Description = "Usage notes" };

        cmd.Add(idsOpt);
        cmd.Add(statusOpt);
        cmd.Add(executeOpt);
        cmd.Add(setPackage);
        cmd.Add(setNamespace);
        cmd.Add(setNotes);

        cmd.SetAction(parseResult =>
        {
            var dbPath = parseResult.GetValue(dbOption)!;
            var ids = parseResult.GetValue(idsOpt);
            var status = parseResult.GetValue(statusOpt);
            var execute = parseResult.GetValue(executeOpt);
            var package = parseResult.GetValue(setPackage);
            var ns = parseResult.GetValue(setNamespace);
            var notes = parseResult.GetValue(setNotes);

            if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
            {
                Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
                return;
            }
            if (package is null && ns is null && notes is null)
            {
                Console.WriteLine("Error: at least one of --set-package, --set-namespace, --set-notes is required.");
                return;
            }

            using var db = new Database(dbPath);
            var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);

            var setClauses = new List<string>();
            var updateParams = new List<(string, object?)>();
            if (package is not null) { setClauses.Add("dotnet_package = @setPackage"); updateParams.Add(("@setPackage", package)); }
            if (ns is not null) { setClauses.Add("dotnet_namespace = @setNamespace"); updateParams.Add(("@setNamespace", ns)); }
            if (notes is not null) { setClauses.Add("dotnet_usage_notes = @setNotes"); updateParams.Add(("@setNotes", notes)); }

            BatchFilters.PreviewOrExecute(db, "library_mappings",
                "id, go_import_path, status, dotnet_package, dotnet_namespace",
                string.Join(", ", setClauses), updateParams,
                whereClause, filterParams, execute);
        });

        return cmd;
    }
```

**Step 2: Verify it compiles**

Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.

**Step 3: Commit**

```bash
git add tools/NatsNet.PortTracker/Commands/LibraryCommands.cs
git commit -m "feat(porttracker): add library batch-update and batch-map commands"
```

---

### Task 6: End-to-end smoke test

**Files:** None — testing only.

**Step 1: Test feature batch-update dry-run**

Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --status deferred --set-status deferred --db porting.db`
Expected: Preview showing deferred features.

**Step 2: Test test batch-update dry-run**

Run: `dotnet run --project tools/NatsNet.PortTracker -- test batch-update --ids 1-5 --set-status verified --db porting.db`
Expected: Preview showing tests 1-5.

**Step 3: Test module batch-update dry-run**

Run: `dotnet run --project tools/NatsNet.PortTracker -- module batch-update --status verified --set-status verified --db porting.db`
Expected: Preview showing verified modules.

**Step 4: Test library batch-map dry-run**

Run: `dotnet run --project tools/NatsNet.PortTracker -- library batch-map --status mapped --set-package "test" --db porting.db`
Expected: Preview showing mapped libraries.

**Step 5: Test error cases**

Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --set-status deferred --db porting.db`
Expected: "Error: at least one filter (--ids, --module, --status) is required."

Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-map --ids 1-5 --db porting.db`
Expected: "Error: at least one of --set-project, --set-class, --set-method is required."

**Step 6: Test help output**

Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --help`
Expected: Shows all options with descriptions.

**Step 7: Final commit**

No code changes — this task is verification only. If any issues found, fix and commit with appropriate message.
