From 3e4953f23676a0a132bc9ca4cbddbf007766f528 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 21:40:29 +0000 Subject: [PATCH 1/8] Normalize C# line endings to CRLF (editorconfig convention) CommandHistoryTests.cs, TerminalKeyboardControllerTests.cs, and TryEatArgumentTests.cs were the last LF-stored C# files (50 of 52 are CRLF per .editorconfig end_of_line=crlf). csharpier normalizes them on next touch anyway; sweep the stragglers explicitly so future logic commits carry no eol churn. No content changes. --- Tests/Runtime/CommandHistoryTests.cs | 719 ++++++++++-------- .../TerminalKeyboardControllerTests.cs | 402 +++++----- Tests/Runtime/TryEatArgumentTests.cs | 306 +++++--- 3 files changed, 807 insertions(+), 620 deletions(-) diff --git a/Tests/Runtime/CommandHistoryTests.cs b/Tests/Runtime/CommandHistoryTests.cs index 97d2f09b..0f11e492 100644 --- a/Tests/Runtime/CommandHistoryTests.cs +++ b/Tests/Runtime/CommandHistoryTests.cs @@ -1,336 +1,383 @@ -namespace WallstopStudios.DxCommandTerminal.Tests.Runtime -{ - using System.Collections; - using System.Linq; - using Backend; - using NUnit.Framework; - using UI; - using UnityEngine; - using UnityEngine.TestTools; - - public sealed class CommandHistoryTests - { - [TearDown] - public void TearDown() - { - if (TerminalUI.Instance != null) - { - Object.Destroy(TerminalUI.Instance.gameObject); - } - } - - [Test] - public void CountReflectsNumberOfEntries() - { - CommandHistory history = new(10); - Assert.AreEqual(0, history.Count, "New history should have count 0"); - - history.Push("command1", true, true); - Assert.AreEqual(1, history.Count, "Count should be 1 after one push"); - - history.Push("command2", true, true); - Assert.AreEqual(2, history.Count, "Count should be 2 after two pushes"); - - history.Push("command3", false, false); - Assert.AreEqual(3, history.Count, "Count should be 3 after three pushes"); - } - - [Test] - public void ClearEmptiesHistory() - { - CommandHistory history = new(10); - history.Push("command1", true, true); - history.Push("command2", true, true); - history.Push("command3", true, true); - - history.Clear(); - - Assert.AreEqual(0, history.Count, "Count should be 0 after clear"); - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(0, entries.Length, "GetHistory should return no entries after clear"); - } - - [Test] - public void ClearReturnsCorrectCount() - { - CommandHistory history = new(10); - Assert.AreEqual(0, history.Clear(), "Clear on empty history should return 0"); - - history.Push("command1", true, true); - history.Push("command2", true, true); - history.Push("command3", true, true); - - Assert.AreEqual(3, history.Clear(), "Clear should return the number of entries cleared"); - } - - [Test] - public void ClearOnEmptyHistoryReturnsZero() - { - CommandHistory history = new(10); - int count = history.Clear(); - Assert.AreEqual(0, count, "Clear on empty history should return 0"); - Assert.AreEqual(0, history.Count, "Count should remain 0 after clearing empty history"); - } - - [Test] - public void ClearResetsNavigationPosition() - { - CommandHistory history = new(10); - history.Push("command1", true, true); - history.Push("command2", true, true); - history.Push("command3", true, true); - - // Navigate to verify position is set - string prev = history.Previous(false); - Assert.AreEqual("command3", prev, "Previous should return last command before clear"); - - history.Clear(); - - // After clear, Previous and Next should return empty - Assert.AreEqual(string.Empty, history.Previous(false), "Previous should return empty after clear"); - Assert.AreEqual(string.Empty, history.Next(false), "Next should return empty after clear"); - } - - [Test] - public void PushAfterClearWorksCorrectly() - { - CommandHistory history = new(10); - history.Push("old1", true, true); - history.Push("old2", true, true); - - history.Clear(); - - history.Push("new1", true, true); - Assert.AreEqual(1, history.Count, "Count should be 1 after push following clear"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(1, entries.Length, "Should have exactly one entry after push following clear"); - Assert.AreEqual("new1", entries[0], "Entry should be the newly pushed command"); - } - - [Test] - public void NavigationAfterClearAndPushWorksCorrectly() - { - CommandHistory history = new(10); - history.Push("old1", true, true); - history.Push("old2", true, true); - - history.Clear(); - - history.Push("new1", true, true); - history.Push("new2", true, true); - - string prev = history.Previous(false); - Assert.AreEqual("new2", prev, "Previous should return last new command"); - - prev = history.Previous(false); - Assert.AreEqual("new1", prev, "Previous again should return first new command"); - - prev = history.Previous(false); - Assert.AreEqual(string.Empty, prev, "Previous beyond beginning should return empty"); - - string next = history.Next(false); - Assert.AreEqual("new1", next, "Next should return first new command"); - - next = history.Next(false); - Assert.AreEqual("new2", next, "Next should return second new command"); - - next = history.Next(false); - Assert.AreEqual(string.Empty, next, "Next beyond end should return empty"); - } - - [Test] - public void PushRejectsNullAndWhitespace() - { - CommandHistory history = new(10); - - Assert.IsFalse(history.Push(null, true, true), "Push should reject null"); - Assert.IsFalse(history.Push("", true, true), "Push should reject empty string"); - Assert.IsFalse(history.Push(" ", true, true), "Push should reject whitespace-only string"); - Assert.AreEqual(0, history.Count, "Count should remain 0 after rejected pushes"); - } - - [Test] - public void GetHistoryFiltersSuccess() - { - CommandHistory history = new(10); - history.Push("success1", true, true); - history.Push("failure1", false, true); - history.Push("success2", true, true); - - string[] successOnly = history.GetHistory(true, false).ToArray(); - Assert.AreEqual(2, successOnly.Length, "Should have 2 successful entries"); - Assert.IsTrue(successOnly.Contains("success1"), "Should contain success1"); - Assert.IsTrue(successOnly.Contains("success2"), "Should contain success2"); - } - - [Test] - public void GetHistoryFiltersErrorFree() - { - CommandHistory history = new(10); - history.Push("clean1", true, true); - history.Push("errored1", true, false); - history.Push("clean2", true, true); - - string[] errorFreeOnly = history.GetHistory(false, true).ToArray(); - Assert.AreEqual(2, errorFreeOnly.Length, "Should have 2 error-free entries"); - Assert.IsTrue(errorFreeOnly.Contains("clean1"), "Should contain clean1"); - Assert.IsTrue(errorFreeOnly.Contains("clean2"), "Should contain clean2"); - } - - [Test] - public void CountHandlesCyclicBufferWrap() - { - CommandHistory history = new(2); - history.Push("a", true, true); - history.Push("b", true, true); - Assert.AreEqual(2, history.Count, "Count should be 2 at capacity."); - history.Push("c", true, true); - Assert.AreEqual(2, history.Count, "Count should remain at capacity after wrap."); - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(2, entries.Length, "History should contain 2 entries after wrap."); - Assert.AreEqual("b", entries[0], "First entry should be 'b' after wrap."); - Assert.AreEqual("c", entries[1], "Second entry should be 'c' after wrap."); - } - - [Test] - public void NavigationAfterClearWithSkipSameCommands() - { - CommandHistory history = new(10); - history.Push("cmd1", true, true); - history.Push("cmd1", true, true); - history.Push("cmd2", true, true); - history.Clear(); - - Assert.AreEqual(string.Empty, history.Previous(true), "Previous with skip should return empty after Clear."); - Assert.AreEqual(string.Empty, history.Next(true), "Next with skip should return empty after Clear."); - - // Push duplicates after clear and navigate with skip - history.Push("cmd1", true, true); - history.Push("cmd1", true, true); - history.Push("cmd2", true, true); - - string prev1 = history.Previous(true); - Assert.AreEqual("cmd2", prev1, "Previous with skip should return 'cmd2'."); - string prev2 = history.Previous(true); - Assert.AreEqual("cmd1", prev2, "Previous with skip should skip duplicate and return 'cmd1'."); - } - - [UnityTest] - public IEnumerator ClearHistoryCommandResultsInEmptyHistory() - { - // clear-history uses AddToHistory = false on its RegisterCommand attribute, - // ensuring the command itself is not recorded in the history it just cleared. - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Run some commands to populate history - shell.RunCommand("log test1"); - shell.RunCommand("log test2"); - shell.RunCommand("log test3"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(3, entries.Length, "Should have 3 history entries before clear-history"); - - // Run clear-history - shell.RunCommand("clear-history"); - - // After fix: history should be completely empty - entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual( - 0, - entries.Length, - $"History should be empty after clear-history, but contained: {string.Join(", ", entries)}" - ); - Assert.AreEqual(0, history.Count, "History count should be 0 after clear-history"); - } - - [UnityTest] - public IEnumerator CommandsAfterClearHistoryWorkNormally() - { - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Populate, clear, then run more commands - shell.RunCommand("log before"); - shell.RunCommand("clear-history"); - - Assert.AreEqual(0, history.Count, "History should be empty after clear-history"); - - shell.RunCommand("log after1"); - shell.RunCommand("log after2"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(2, entries.Length, "Should have 2 entries after clear-history followed by 2 commands"); - Assert.IsTrue(entries.Contains("log after1"), "Should contain 'log after1'"); - Assert.IsTrue(entries.Contains("log after2"), "Should contain 'log after2'"); - } - - [UnityTest] - public IEnumerator CommandWithAddToHistoryFalseAndInvalidArgsDoesNotPushToHistory() - { - // clear-history has AddToHistory = false. When called with wrong args, - // it should still NOT be recorded in history. - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Run some commands to populate history - shell.RunCommand("log test1"); - shell.RunCommand("log test2"); - - string[] entriesBefore = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(2, entriesBefore.Length, "Should have 2 history entries before invalid clear-history"); - - // Run clear-history with an extra argument (should fail arg validation) - shell.RunCommand("clear-history somearg"); - - string[] entriesAfter = history.GetHistory(false, false).ToArray(); - Assert.IsFalse( - entriesAfter.Contains("clear-history somearg"), - "clear-history somearg should NOT appear in history when AddToHistory is false" - ); - Assert.AreEqual( - 2, - entriesAfter.Length, - $"History should still have 2 entries, but contained: {string.Join(", ", entriesAfter)}" - ); - Assert.IsTrue(entriesAfter.Contains("log test1"), "Should still contain 'log test1'"); - Assert.IsTrue(entriesAfter.Contains("log test2"), "Should still contain 'log test2'"); - } - - [UnityTest] - public IEnumerator CommandWithAddToHistoryTrueAndInvalidArgsPushesToHistory() - { - // set-theme has AddToHistory = true (default). When called with wrong args, - // it should still be recorded in history. - yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); - - CommandShell shell = Terminal.Shell; - Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); - CommandHistory history = Terminal.History; - Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); - - // Run set-theme with no arguments (it requires exactly 1) - shell.RunCommand("set-theme"); - - string[] entries = history.GetHistory(false, false).ToArray(); - Assert.AreEqual(1, entries.Length, "Should have 1 history entry after invalid set-theme"); - Assert.IsTrue( - entries.Contains("set-theme"), - "set-theme should appear in history when AddToHistory is true" - ); - } - } -} +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using System.Collections; + using System.Linq; + using Backend; + using NUnit.Framework; + using UI; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class CommandHistoryTests + { + [TearDown] + public void TearDown() + { + if (TerminalUI.Instance != null) + { + Object.Destroy(TerminalUI.Instance.gameObject); + } + } + + [Test] + public void CountReflectsNumberOfEntries() + { + CommandHistory history = new(10); + Assert.AreEqual(0, history.Count, "New history should have count 0"); + + history.Push("command1", true, true); + Assert.AreEqual(1, history.Count, "Count should be 1 after one push"); + + history.Push("command2", true, true); + Assert.AreEqual(2, history.Count, "Count should be 2 after two pushes"); + + history.Push("command3", false, false); + Assert.AreEqual(3, history.Count, "Count should be 3 after three pushes"); + } + + [Test] + public void ClearEmptiesHistory() + { + CommandHistory history = new(10); + history.Push("command1", true, true); + history.Push("command2", true, true); + history.Push("command3", true, true); + + history.Clear(); + + Assert.AreEqual(0, history.Count, "Count should be 0 after clear"); + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual(0, entries.Length, "GetHistory should return no entries after clear"); + } + + [Test] + public void ClearReturnsCorrectCount() + { + CommandHistory history = new(10); + Assert.AreEqual(0, history.Clear(), "Clear on empty history should return 0"); + + history.Push("command1", true, true); + history.Push("command2", true, true); + history.Push("command3", true, true); + + Assert.AreEqual( + 3, + history.Clear(), + "Clear should return the number of entries cleared" + ); + } + + [Test] + public void ClearOnEmptyHistoryReturnsZero() + { + CommandHistory history = new(10); + int count = history.Clear(); + Assert.AreEqual(0, count, "Clear on empty history should return 0"); + Assert.AreEqual(0, history.Count, "Count should remain 0 after clearing empty history"); + } + + [Test] + public void ClearResetsNavigationPosition() + { + CommandHistory history = new(10); + history.Push("command1", true, true); + history.Push("command2", true, true); + history.Push("command3", true, true); + + // Navigate to verify position is set + string prev = history.Previous(false); + Assert.AreEqual("command3", prev, "Previous should return last command before clear"); + + history.Clear(); + + // After clear, Previous and Next should return empty + Assert.AreEqual( + string.Empty, + history.Previous(false), + "Previous should return empty after clear" + ); + Assert.AreEqual( + string.Empty, + history.Next(false), + "Next should return empty after clear" + ); + } + + [Test] + public void PushAfterClearWorksCorrectly() + { + CommandHistory history = new(10); + history.Push("old1", true, true); + history.Push("old2", true, true); + + history.Clear(); + + history.Push("new1", true, true); + Assert.AreEqual(1, history.Count, "Count should be 1 after push following clear"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 1, + entries.Length, + "Should have exactly one entry after push following clear" + ); + Assert.AreEqual("new1", entries[0], "Entry should be the newly pushed command"); + } + + [Test] + public void NavigationAfterClearAndPushWorksCorrectly() + { + CommandHistory history = new(10); + history.Push("old1", true, true); + history.Push("old2", true, true); + + history.Clear(); + + history.Push("new1", true, true); + history.Push("new2", true, true); + + string prev = history.Previous(false); + Assert.AreEqual("new2", prev, "Previous should return last new command"); + + prev = history.Previous(false); + Assert.AreEqual("new1", prev, "Previous again should return first new command"); + + prev = history.Previous(false); + Assert.AreEqual(string.Empty, prev, "Previous beyond beginning should return empty"); + + string next = history.Next(false); + Assert.AreEqual("new1", next, "Next should return first new command"); + + next = history.Next(false); + Assert.AreEqual("new2", next, "Next should return second new command"); + + next = history.Next(false); + Assert.AreEqual(string.Empty, next, "Next beyond end should return empty"); + } + + [Test] + public void PushRejectsNullAndWhitespace() + { + CommandHistory history = new(10); + + Assert.IsFalse(history.Push(null, true, true), "Push should reject null"); + Assert.IsFalse(history.Push("", true, true), "Push should reject empty string"); + Assert.IsFalse( + history.Push(" ", true, true), + "Push should reject whitespace-only string" + ); + Assert.AreEqual(0, history.Count, "Count should remain 0 after rejected pushes"); + } + + [Test] + public void GetHistoryFiltersSuccess() + { + CommandHistory history = new(10); + history.Push("success1", true, true); + history.Push("failure1", false, true); + history.Push("success2", true, true); + + string[] successOnly = history.GetHistory(true, false).ToArray(); + Assert.AreEqual(2, successOnly.Length, "Should have 2 successful entries"); + Assert.IsTrue(successOnly.Contains("success1"), "Should contain success1"); + Assert.IsTrue(successOnly.Contains("success2"), "Should contain success2"); + } + + [Test] + public void GetHistoryFiltersErrorFree() + { + CommandHistory history = new(10); + history.Push("clean1", true, true); + history.Push("errored1", true, false); + history.Push("clean2", true, true); + + string[] errorFreeOnly = history.GetHistory(false, true).ToArray(); + Assert.AreEqual(2, errorFreeOnly.Length, "Should have 2 error-free entries"); + Assert.IsTrue(errorFreeOnly.Contains("clean1"), "Should contain clean1"); + Assert.IsTrue(errorFreeOnly.Contains("clean2"), "Should contain clean2"); + } + + [Test] + public void CountHandlesCyclicBufferWrap() + { + CommandHistory history = new(2); + history.Push("a", true, true); + history.Push("b", true, true); + Assert.AreEqual(2, history.Count, "Count should be 2 at capacity."); + history.Push("c", true, true); + Assert.AreEqual(2, history.Count, "Count should remain at capacity after wrap."); + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual(2, entries.Length, "History should contain 2 entries after wrap."); + Assert.AreEqual("b", entries[0], "First entry should be 'b' after wrap."); + Assert.AreEqual("c", entries[1], "Second entry should be 'c' after wrap."); + } + + [Test] + public void NavigationAfterClearWithSkipSameCommands() + { + CommandHistory history = new(10); + history.Push("cmd1", true, true); + history.Push("cmd1", true, true); + history.Push("cmd2", true, true); + history.Clear(); + + Assert.AreEqual( + string.Empty, + history.Previous(true), + "Previous with skip should return empty after Clear." + ); + Assert.AreEqual( + string.Empty, + history.Next(true), + "Next with skip should return empty after Clear." + ); + + // Push duplicates after clear and navigate with skip + history.Push("cmd1", true, true); + history.Push("cmd1", true, true); + history.Push("cmd2", true, true); + + string prev1 = history.Previous(true); + Assert.AreEqual("cmd2", prev1, "Previous with skip should return 'cmd2'."); + string prev2 = history.Previous(true); + Assert.AreEqual( + "cmd1", + prev2, + "Previous with skip should skip duplicate and return 'cmd1'." + ); + } + + [UnityTest] + public IEnumerator ClearHistoryCommandResultsInEmptyHistory() + { + // clear-history uses AddToHistory = false on its RegisterCommand attribute, + // ensuring the command itself is not recorded in the history it just cleared. + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Run some commands to populate history + shell.RunCommand("log test1"); + shell.RunCommand("log test2"); + shell.RunCommand("log test3"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 3, + entries.Length, + "Should have 3 history entries before clear-history" + ); + + // Run clear-history + shell.RunCommand("clear-history"); + + // After fix: history should be completely empty + entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 0, + entries.Length, + $"History should be empty after clear-history, but contained: {string.Join(", ", entries)}" + ); + Assert.AreEqual(0, history.Count, "History count should be 0 after clear-history"); + } + + [UnityTest] + public IEnumerator CommandsAfterClearHistoryWorkNormally() + { + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Populate, clear, then run more commands + shell.RunCommand("log before"); + shell.RunCommand("clear-history"); + + Assert.AreEqual(0, history.Count, "History should be empty after clear-history"); + + shell.RunCommand("log after1"); + shell.RunCommand("log after2"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 2, + entries.Length, + "Should have 2 entries after clear-history followed by 2 commands" + ); + Assert.IsTrue(entries.Contains("log after1"), "Should contain 'log after1'"); + Assert.IsTrue(entries.Contains("log after2"), "Should contain 'log after2'"); + } + + [UnityTest] + public IEnumerator CommandWithAddToHistoryFalseAndInvalidArgsDoesNotPushToHistory() + { + // clear-history has AddToHistory = false. When called with wrong args, + // it should still NOT be recorded in history. + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Run some commands to populate history + shell.RunCommand("log test1"); + shell.RunCommand("log test2"); + + string[] entriesBefore = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 2, + entriesBefore.Length, + "Should have 2 history entries before invalid clear-history" + ); + + // Run clear-history with an extra argument (should fail arg validation) + shell.RunCommand("clear-history somearg"); + + string[] entriesAfter = history.GetHistory(false, false).ToArray(); + Assert.IsFalse( + entriesAfter.Contains("clear-history somearg"), + "clear-history somearg should NOT appear in history when AddToHistory is false" + ); + Assert.AreEqual( + 2, + entriesAfter.Length, + $"History should still have 2 entries, but contained: {string.Join(", ", entriesAfter)}" + ); + Assert.IsTrue(entriesAfter.Contains("log test1"), "Should still contain 'log test1'"); + Assert.IsTrue(entriesAfter.Contains("log test2"), "Should still contain 'log test2'"); + } + + [UnityTest] + public IEnumerator CommandWithAddToHistoryTrueAndInvalidArgsPushesToHistory() + { + // set-theme has AddToHistory = true (default). When called with wrong args, + // it should still be recorded in history. + yield return TerminalTests.SpawnTerminal(resetStateOnInit: true); + + CommandShell shell = Terminal.Shell; + Assert.IsNotNull(shell, "Terminal.Shell should not be null after SpawnTerminal"); + CommandHistory history = Terminal.History; + Assert.IsNotNull(history, "Terminal.History should not be null after SpawnTerminal"); + + // Run set-theme with no arguments (it requires exactly 1) + shell.RunCommand("set-theme"); + + string[] entries = history.GetHistory(false, false).ToArray(); + Assert.AreEqual( + 1, + entries.Length, + "Should have 1 history entry after invalid set-theme" + ); + Assert.IsTrue( + entries.Contains("set-theme"), + "set-theme should appear in history when AddToHistory is true" + ); + } + } +} diff --git a/Tests/Runtime/TerminalKeyboardControllerTests.cs b/Tests/Runtime/TerminalKeyboardControllerTests.cs index 5730d2ef..7d397e74 100644 --- a/Tests/Runtime/TerminalKeyboardControllerTests.cs +++ b/Tests/Runtime/TerminalKeyboardControllerTests.cs @@ -1,191 +1,211 @@ -namespace WallstopStudios.DxCommandTerminal.Tests.Runtime -{ - using System; - using System.Collections; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - using Input; - using NUnit.Framework; - using UnityEngine; - using UnityEngine.TestTools; - - public sealed class TerminalKeyboardControllerTests - { - private readonly List _gameObjects = new(); - - private static TerminalControlTypes[] GetControlTypes() - { - FieldInfo controlTypesField = typeof(TerminalKeyboardController).GetField( - "ControlTypes", - BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy - ); - return (TerminalControlTypes[])controlTypesField.GetValue(null); - } - - private static FieldInfo GetControlOrderField() - { - return typeof(TerminalKeyboardController).GetField( - "_controlOrder", - BindingFlags.Instance | BindingFlags.NonPublic - ); - } - - private static MethodInfo GetVerifyMethod() - { - return typeof(TerminalKeyboardController).GetMethod( - "VerifyControlOrderIntegrity", - BindingFlags.Instance | BindingFlags.NonPublic - ); - } - - [TearDown] - public void TearDown() - { - foreach (GameObject go in _gameObjects) - { - if (go != null) - { - UnityEngine.Object.Destroy(go); - } - } - _gameObjects.Clear(); - } - - [Test] - public void ControlTypesContainsAllNonNoneEnumValues() - { - TerminalControlTypes[] expected = Enum.GetValues(typeof(TerminalControlTypes)) - .OfType() -#pragma warning disable CS0612 // Type or member is obsolete - .Except(new[] { TerminalControlTypes.None }) -#pragma warning restore CS0612 // Type or member is obsolete - .ToArray(); - - TerminalControlTypes[] actual = GetControlTypes(); - Assert.IsNotNull(actual, "ControlTypes should not be null"); - Assert.AreEqual( - expected.Length, - actual.Length, - $"ControlTypes length mismatch. Expected: [{string.Join(", ", expected)}], Actual: [{string.Join(", ", actual)}]" - ); - - foreach (TerminalControlTypes controlType in expected) - { - Assert.IsTrue( - actual.Contains(controlType), - $"ControlTypes is missing {controlType}. Contents: [{string.Join(", ", actual)}]" - ); - } - } - - [UnityTest] - public IEnumerator DefaultControlOrderProducesNoWarning() - { - // Default _controlOrder contains all TerminalControlTypes, so no warning should fire. - // Awake will log an error about missing TerminalUI -- expect that. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - go.AddComponent(); - yield return null; - - // LogAssert will fail the test if any unexpected warnings were emitted. - LogAssert.NoUnexpectedReceived(); - } - - [UnityTest] - public IEnumerator ControlOrderWithDuplicatesButAllTypesPresent_ProducesNoWarning() - { - // Regression test: if _controlOrder has duplicates but still covers all control types, - // VerifyControlOrderIntegrity should NOT produce a warning. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - TerminalKeyboardController controller = go.AddComponent(); - yield return null; - - // Now modify _controlOrder to have duplicates but still include all types, then invoke VerifyControlOrderIntegrity. - FieldInfo controlOrderField = GetControlOrderField(); - Assert.IsNotNull(controlOrderField, "_controlOrder field should exist on TerminalKeyboardController"); - - List orderWithDuplicates = GetControlTypes().ToList(); - // Add duplicates - orderWithDuplicates.Add(orderWithDuplicates[0]); - orderWithDuplicates.Add(orderWithDuplicates[1]); - controlOrderField.SetValue(controller, orderWithDuplicates); - - MethodInfo verifyMethod = GetVerifyMethod(); - Assert.IsNotNull(verifyMethod, "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController"); - - verifyMethod.Invoke(controller, null); - - // LogAssert will fail the test if any unexpected warnings were emitted. - LogAssert.NoUnexpectedReceived(); - } - - [UnityTest] - public IEnumerator ControlOrderMissingType_ProducesWarning() - { - // When _controlOrder is missing a control type, a warning should be emitted. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - TerminalKeyboardController controller = go.AddComponent(); - yield return null; - - FieldInfo controlOrderField = GetControlOrderField(); - Assert.IsNotNull(controlOrderField, "_controlOrder field should exist on TerminalKeyboardController"); - - // Remove the last type from the list - TerminalControlTypes[] allTypes = GetControlTypes(); - TerminalControlTypes removedType = allTypes[^1]; - List incompleteOrder = allTypes.Take(allTypes.Length - 1).ToList(); - controlOrderField.SetValue(controller, incompleteOrder); - - MethodInfo verifyMethod = GetVerifyMethod(); - Assert.IsNotNull(verifyMethod, "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController"); - - LogAssert.Expect( - LogType.Warning, - $"Control Order is missing the following controls: [{removedType}]. " - + "Input for these will not be handled. Is this intentional?" - ); - verifyMethod.Invoke(controller, null); - - LogAssert.NoUnexpectedReceived(); - } - - [UnityTest] - public IEnumerator EmptyControlOrder_ProducesWarningForAllTypes() - { - // When _controlOrder is empty, VerifyControlOrderIntegrity should warn about all missing types. - // Note: Awake fires during AddComponent with the default (full) control order, so - // we only modify _controlOrder afterward and invoke VerifyControlOrderIntegrity directly. - LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); - GameObject go = new("TerminalKeyboardControllerTest"); - _gameObjects.Add(go); - TerminalKeyboardController controller = go.AddComponent(); - yield return null; - - FieldInfo controlOrderField = GetControlOrderField(); - Assert.IsNotNull(controlOrderField, "_controlOrder field should exist on TerminalKeyboardController"); - controlOrderField.SetValue(controller, new List()); - - MethodInfo verifyMethod = GetVerifyMethod(); - Assert.IsNotNull(verifyMethod, "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController"); - - TerminalControlTypes[] allTypes = GetControlTypes(); - string expectedMissing = string.Join(", ", allTypes); - - LogAssert.Expect( - LogType.Warning, - $"Control Order is missing the following controls: [{expectedMissing}]. " - + "Input for these will not be handled. Is this intentional?" - ); - verifyMethod.Invoke(controller, null); - - LogAssert.NoUnexpectedReceived(); - } - } -} +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using Input; + using NUnit.Framework; + using UnityEngine; + using UnityEngine.TestTools; + + public sealed class TerminalKeyboardControllerTests + { + private readonly List _gameObjects = new(); + + private static TerminalControlTypes[] GetControlTypes() + { + FieldInfo controlTypesField = typeof(TerminalKeyboardController).GetField( + "ControlTypes", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy + ); + return (TerminalControlTypes[])controlTypesField.GetValue(null); + } + + private static FieldInfo GetControlOrderField() + { + return typeof(TerminalKeyboardController).GetField( + "_controlOrder", + BindingFlags.Instance | BindingFlags.NonPublic + ); + } + + private static MethodInfo GetVerifyMethod() + { + return typeof(TerminalKeyboardController).GetMethod( + "VerifyControlOrderIntegrity", + BindingFlags.Instance | BindingFlags.NonPublic + ); + } + + [TearDown] + public void TearDown() + { + foreach (GameObject go in _gameObjects) + { + if (go != null) + { + UnityEngine.Object.Destroy(go); + } + } + _gameObjects.Clear(); + } + + [Test] + public void ControlTypesContainsAllNonNoneEnumValues() + { + TerminalControlTypes[] expected = Enum.GetValues(typeof(TerminalControlTypes)) + .OfType() +#pragma warning disable CS0612 // Type or member is obsolete + .Except(new[] { TerminalControlTypes.None }) +#pragma warning restore CS0612 // Type or member is obsolete + .ToArray(); + + TerminalControlTypes[] actual = GetControlTypes(); + Assert.IsNotNull(actual, "ControlTypes should not be null"); + Assert.AreEqual( + expected.Length, + actual.Length, + $"ControlTypes length mismatch. Expected: [{string.Join(", ", expected)}], Actual: [{string.Join(", ", actual)}]" + ); + + foreach (TerminalControlTypes controlType in expected) + { + Assert.IsTrue( + actual.Contains(controlType), + $"ControlTypes is missing {controlType}. Contents: [{string.Join(", ", actual)}]" + ); + } + } + + [UnityTest] + public IEnumerator DefaultControlOrderProducesNoWarning() + { + // Default _controlOrder contains all TerminalControlTypes, so no warning should fire. + // Awake will log an error about missing TerminalUI -- expect that. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + go.AddComponent(); + yield return null; + + // LogAssert will fail the test if any unexpected warnings were emitted. + LogAssert.NoUnexpectedReceived(); + } + + [UnityTest] + public IEnumerator ControlOrderWithDuplicatesButAllTypesPresent_ProducesNoWarning() + { + // Regression test: if _controlOrder has duplicates but still covers all control types, + // VerifyControlOrderIntegrity should NOT produce a warning. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + TerminalKeyboardController controller = go.AddComponent(); + yield return null; + + // Now modify _controlOrder to have duplicates but still include all types, then invoke VerifyControlOrderIntegrity. + FieldInfo controlOrderField = GetControlOrderField(); + Assert.IsNotNull( + controlOrderField, + "_controlOrder field should exist on TerminalKeyboardController" + ); + + List orderWithDuplicates = GetControlTypes().ToList(); + // Add duplicates + orderWithDuplicates.Add(orderWithDuplicates[0]); + orderWithDuplicates.Add(orderWithDuplicates[1]); + controlOrderField.SetValue(controller, orderWithDuplicates); + + MethodInfo verifyMethod = GetVerifyMethod(); + Assert.IsNotNull( + verifyMethod, + "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController" + ); + + verifyMethod.Invoke(controller, null); + + // LogAssert will fail the test if any unexpected warnings were emitted. + LogAssert.NoUnexpectedReceived(); + } + + [UnityTest] + public IEnumerator ControlOrderMissingType_ProducesWarning() + { + // When _controlOrder is missing a control type, a warning should be emitted. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + TerminalKeyboardController controller = go.AddComponent(); + yield return null; + + FieldInfo controlOrderField = GetControlOrderField(); + Assert.IsNotNull( + controlOrderField, + "_controlOrder field should exist on TerminalKeyboardController" + ); + + // Remove the last type from the list + TerminalControlTypes[] allTypes = GetControlTypes(); + TerminalControlTypes removedType = allTypes[^1]; + List incompleteOrder = allTypes + .Take(allTypes.Length - 1) + .ToList(); + controlOrderField.SetValue(controller, incompleteOrder); + + MethodInfo verifyMethod = GetVerifyMethod(); + Assert.IsNotNull( + verifyMethod, + "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController" + ); + + LogAssert.Expect( + LogType.Warning, + $"Control Order is missing the following controls: [{removedType}]. " + + "Input for these will not be handled. Is this intentional?" + ); + verifyMethod.Invoke(controller, null); + + LogAssert.NoUnexpectedReceived(); + } + + [UnityTest] + public IEnumerator EmptyControlOrder_ProducesWarningForAllTypes() + { + // When _controlOrder is empty, VerifyControlOrderIntegrity should warn about all missing types. + // Note: Awake fires during AddComponent with the default (full) control order, so + // we only modify _controlOrder afterward and invoke VerifyControlOrderIntegrity directly. + LogAssert.Expect(LogType.Error, "Failed to find TerminalUI, Input will not work."); + GameObject go = new("TerminalKeyboardControllerTest"); + _gameObjects.Add(go); + TerminalKeyboardController controller = go.AddComponent(); + yield return null; + + FieldInfo controlOrderField = GetControlOrderField(); + Assert.IsNotNull( + controlOrderField, + "_controlOrder field should exist on TerminalKeyboardController" + ); + controlOrderField.SetValue(controller, new List()); + + MethodInfo verifyMethod = GetVerifyMethod(); + Assert.IsNotNull( + verifyMethod, + "VerifyControlOrderIntegrity method should exist on TerminalKeyboardController" + ); + + TerminalControlTypes[] allTypes = GetControlTypes(); + string expectedMissing = string.Join(", ", allTypes); + + LogAssert.Expect( + LogType.Warning, + $"Control Order is missing the following controls: [{expectedMissing}]. " + + "Input for these will not be handled. Is this intentional?" + ); + verifyMethod.Invoke(controller, null); + + LogAssert.NoUnexpectedReceived(); + } + } +} diff --git a/Tests/Runtime/TryEatArgumentTests.cs b/Tests/Runtime/TryEatArgumentTests.cs index 5a2dbc8f..9613a61f 100644 --- a/Tests/Runtime/TryEatArgumentTests.cs +++ b/Tests/Runtime/TryEatArgumentTests.cs @@ -1,93 +1,213 @@ -namespace WallstopStudios.DxCommandTerminal.Tests.Runtime -{ - using Backend; - using NUnit.Framework; - - public sealed class TryEatArgumentTests - { - [TestCase("", false, null, null, null, Description = "Empty string returns false")] - [TestCase(" ", false, null, null, null, Description = "Whitespace-only returns false")] - [TestCase("hello", true, "hello", null, null, Description = "Simple unquoted word")] - [TestCase("hello world", true, "hello", null, null, Description = "First word of multi-word input")] - [TestCase("\"quoted\"", true, "quoted", '"', '"', Description = "Double-quoted argument")] - [TestCase("'quoted'", true, "quoted", '\'', '\'', Description = "Single-quoted argument")] - [TestCase("\"unclosed", true, "unclosed", '"', null, Description = "Unclosed double quote consumes rest")] - [TestCase("'unclosed", true, "unclosed", '\'', null, Description = "Unclosed single quote consumes rest")] - [TestCase("\"\"", true, "", '"', '"', Description = "Empty double-quoted string")] - [TestCase("''", true, "", '\'', '\'', Description = "Empty single-quoted string")] - [TestCase("\"", true, "", '"', null, Description = "Lone double quote")] - [TestCase("'", true, "", '\'', null, Description = "Lone single quote")] - [TestCase("\"hello world\"", true, "hello world", '"', '"', Description = "Quoted string with space")] - [TestCase("'hello world'", true, "hello world", '\'', '\'', Description = "Single-quoted string with space")] - [TestCase("\"hello world\" rest", true, "hello world", '"', '"', Description = "Quoted with remainder")] - [TestCase("'hello' rest", true, "hello", '\'', '\'', Description = "Single-quoted with remainder")] - [TestCase(" hello", true, "hello", null, null, Description = "Leading whitespace trimmed")] - [TestCase(" \"hello\"", true, "hello", '"', '"', Description = "Leading whitespace before quote")] - [TestCase("'hello \"world\"'", true, "hello \"world\"", '\'', '\'', Description = "Double quotes inside single quotes")] - [TestCase("\"hello 'world'\"", true, "hello 'world'", '"', '"', Description = "Single quotes inside double quotes")] - [TestCase("hello world", true, "hello", null, null, Description = "Multiple consecutive spaces returns first word")] - [TestCase("hello\tworld", true, "hello\tworld", null, null, Description = "Tab character is not a space delimiter")] - [TestCase("\"quoted\"extra", true, "quoted", '"', '"', Description = "Text after closing quote stops at quote")] - public void ParsesCorrectly( - string input, - bool expectedResult, - string expectedContents, - char? expectedStartQuote, - char? expectedEndQuote - ) - { - string remaining = input; - bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); - - Assert.AreEqual(expectedResult, result, $"TryEatArgument return value mismatch for input: \"{input}\""); - - if (expectedResult) - { - Assert.AreEqual( - expectedContents, - arg.contents, - $"Contents mismatch for input: \"{input}\". Expected: \"{expectedContents}\", Got: \"{arg.contents}\"" - ); - Assert.AreEqual( - expectedStartQuote, - arg.startQuote, - $"Start quote mismatch for input: \"{input}\". Expected: '{expectedStartQuote}', Got: '{arg.startQuote}'" - ); - Assert.AreEqual( - expectedEndQuote, - arg.endQuote, - $"End quote mismatch for input: \"{input}\". Expected: '{expectedEndQuote}', Got: '{arg.endQuote}'" - ); - } - } - - [TestCase("hello world", "hello", "world", Description = "Remainder after unquoted word has space consumed")] - [TestCase("\"quoted\" rest", "quoted", " rest", Description = "Remainder after quoted arg preserves leading space")] - [TestCase("'unclosed arg", "unclosed arg", "", Description = "Unclosed quote consumes all")] - [TestCase("word", "word", "", Description = "Single word leaves empty remainder")] - [TestCase("hello world", "hello", " world", Description = "Multiple spaces leaves remaining spaces minus one")] - [TestCase("\"quoted\"extra", "quoted", "extra", Description = "Text after closing quote is remainder")] - [TestCase("\"unclosed with spaces", "unclosed with spaces", "", Description = "Unclosed quote with spaces consumes all")] - public void RemainingStringIsCorrect( - string input, - string expectedContents, - string expectedRemaining - ) - { - string remaining = input; - bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); - - Assert.IsTrue(result, $"Expected TryEatArgument to return true for input: \"{input}\""); - Assert.AreEqual( - expectedContents, - arg.contents, - $"Contents mismatch for input: \"{input}\"" - ); - Assert.AreEqual( - expectedRemaining, - remaining, - $"Remaining string mismatch for input: \"{input}\". Expected: \"{expectedRemaining}\", Got: \"{remaining}\"" - ); - } - } -} +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using Backend; + using NUnit.Framework; + + public sealed class TryEatArgumentTests + { + [TestCase("", false, null, null, null, Description = "Empty string returns false")] + [TestCase(" ", false, null, null, null, Description = "Whitespace-only returns false")] + [TestCase("hello", true, "hello", null, null, Description = "Simple unquoted word")] + [TestCase( + "hello world", + true, + "hello", + null, + null, + Description = "First word of multi-word input" + )] + [TestCase("\"quoted\"", true, "quoted", '"', '"', Description = "Double-quoted argument")] + [TestCase("'quoted'", true, "quoted", '\'', '\'', Description = "Single-quoted argument")] + [TestCase( + "\"unclosed", + true, + "unclosed", + '"', + null, + Description = "Unclosed double quote consumes rest" + )] + [TestCase( + "'unclosed", + true, + "unclosed", + '\'', + null, + Description = "Unclosed single quote consumes rest" + )] + [TestCase("\"\"", true, "", '"', '"', Description = "Empty double-quoted string")] + [TestCase("''", true, "", '\'', '\'', Description = "Empty single-quoted string")] + [TestCase("\"", true, "", '"', null, Description = "Lone double quote")] + [TestCase("'", true, "", '\'', null, Description = "Lone single quote")] + [TestCase( + "\"hello world\"", + true, + "hello world", + '"', + '"', + Description = "Quoted string with space" + )] + [TestCase( + "'hello world'", + true, + "hello world", + '\'', + '\'', + Description = "Single-quoted string with space" + )] + [TestCase( + "\"hello world\" rest", + true, + "hello world", + '"', + '"', + Description = "Quoted with remainder" + )] + [TestCase( + "'hello' rest", + true, + "hello", + '\'', + '\'', + Description = "Single-quoted with remainder" + )] + [TestCase(" hello", true, "hello", null, null, Description = "Leading whitespace trimmed")] + [TestCase( + " \"hello\"", + true, + "hello", + '"', + '"', + Description = "Leading whitespace before quote" + )] + [TestCase( + "'hello \"world\"'", + true, + "hello \"world\"", + '\'', + '\'', + Description = "Double quotes inside single quotes" + )] + [TestCase( + "\"hello 'world'\"", + true, + "hello 'world'", + '"', + '"', + Description = "Single quotes inside double quotes" + )] + [TestCase( + "hello world", + true, + "hello", + null, + null, + Description = "Multiple consecutive spaces returns first word" + )] + [TestCase( + "hello\tworld", + true, + "hello\tworld", + null, + null, + Description = "Tab character is not a space delimiter" + )] + [TestCase( + "\"quoted\"extra", + true, + "quoted", + '"', + '"', + Description = "Text after closing quote stops at quote" + )] + public void ParsesCorrectly( + string input, + bool expectedResult, + string expectedContents, + char? expectedStartQuote, + char? expectedEndQuote + ) + { + string remaining = input; + bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); + + Assert.AreEqual( + expectedResult, + result, + $"TryEatArgument return value mismatch for input: \"{input}\"" + ); + + if (expectedResult) + { + Assert.AreEqual( + expectedContents, + arg.contents, + $"Contents mismatch for input: \"{input}\". Expected: \"{expectedContents}\", Got: \"{arg.contents}\"" + ); + Assert.AreEqual( + expectedStartQuote, + arg.startQuote, + $"Start quote mismatch for input: \"{input}\". Expected: '{expectedStartQuote}', Got: '{arg.startQuote}'" + ); + Assert.AreEqual( + expectedEndQuote, + arg.endQuote, + $"End quote mismatch for input: \"{input}\". Expected: '{expectedEndQuote}', Got: '{arg.endQuote}'" + ); + } + } + + [TestCase( + "hello world", + "hello", + "world", + Description = "Remainder after unquoted word has space consumed" + )] + [TestCase( + "\"quoted\" rest", + "quoted", + " rest", + Description = "Remainder after quoted arg preserves leading space" + )] + [TestCase("'unclosed arg", "unclosed arg", "", Description = "Unclosed quote consumes all")] + [TestCase("word", "word", "", Description = "Single word leaves empty remainder")] + [TestCase( + "hello world", + "hello", + " world", + Description = "Multiple spaces leaves remaining spaces minus one" + )] + [TestCase( + "\"quoted\"extra", + "quoted", + "extra", + Description = "Text after closing quote is remainder" + )] + [TestCase( + "\"unclosed with spaces", + "unclosed with spaces", + "", + Description = "Unclosed quote with spaces consumes all" + )] + public void RemainingStringIsCorrect( + string input, + string expectedContents, + string expectedRemaining + ) + { + string remaining = input; + bool result = CommandShell.TryEatArgument(ref remaining, out CommandArg arg); + + Assert.IsTrue(result, $"Expected TryEatArgument to return true for input: \"{input}\""); + Assert.AreEqual( + expectedContents, + arg.contents, + $"Contents mismatch for input: \"{input}\"" + ); + Assert.AreEqual( + expectedRemaining, + remaining, + $"Remaining string mismatch for input: \"{input}\". Expected: \"{expectedRemaining}\", Got: \"{remaining}\"" + ); + } + } +} From b2cbc9db765e897ab0daa89a515e541064111ded Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 21:40:37 +0000 Subject: [PATCH 2/8] Better history de-duplication: direction-scoped traversal (#20) Next/Previous(skipSameCommands) now skip any entry already shown during the current traversal direction, so non-adjacent repeats are no longer re-displayed within one sweep (previously only adjacent runs were skipped). The seen set resets on Push, Clear, Resize, and direction flips, so sweeping back replays the passed entries like a fresh pass. skipSameCommands=false traversal is byte-for-byte unchanged. Data: red evidence - 2 new tests failed pre-change (16/18), full PlayMode suite 98/98 green post-change (96 baseline + 2 new), live Unity 6000.4.6f1 editor. --- .../CommandTerminal/Backend/CommandHistory.cs | 73 ++++++++++--------- Tests/Runtime/CommandHistoryTests.cs | 53 ++++++++++++++ 2 files changed, 90 insertions(+), 36 deletions(-) diff --git a/Runtime/CommandTerminal/Backend/CommandHistory.cs b/Runtime/CommandTerminal/Backend/CommandHistory.cs index 895a35d8..7925a6d3 100644 --- a/Runtime/CommandTerminal/Backend/CommandHistory.cs +++ b/Runtime/CommandTerminal/Backend/CommandHistory.cs @@ -13,6 +13,17 @@ public sealed class CommandHistory private readonly CyclicBuffer<(string text, bool? success, bool? errorFree)> _history; + /* + Commands already shown during the current traversal direction. Next + and Previous skip entries listed here, so neither adjacent runs nor + non-adjacent repeats get re-displayed in one sweep. The set resets + on Push, Clear, Resize, and whenever the traversal direction + flips, replaying the passed entries on the way back. + */ + private readonly HashSet _seenInDirection = new(StringComparer.OrdinalIgnoreCase); + + private int _direction; + private int _position; public CommandHistory(int capacity) @@ -31,6 +42,8 @@ public IEnumerable GetHistory(bool onlySuccess, bool onlyErrorFree) public void Resize(int newCapacity) { _history.Resize(newCapacity); + _seenInDirection.Clear(); + _direction = 0; } public bool Push(string commandString, bool? success, bool? errorFree) @@ -42,41 +55,35 @@ public bool Push(string commandString, bool? success, bool? errorFree) _history.Add((commandString, success, errorFree)); _position = _history.Count; + _seenInDirection.Clear(); + _direction = 0; return true; } public string Next(bool skipSameCommands) { - int initialPosition = _position; ++_position; + if (_direction != 1) + { + _seenInDirection.Clear(); + } + _direction = 1; while ( skipSameCommands - && 0 <= initialPosition - && initialPosition < _history.Count && 0 <= _position && _position < _history.Count + && _seenInDirection.Contains(_history[_position].text) ) { - if ( - string.Equals( - _history[initialPosition].text, - _history[_position].text, - StringComparison.OrdinalIgnoreCase - ) - ) - { - ++_position; - } - else - { - break; - } + ++_position; } if (0 <= _position && _position < _history.Count) { - return _history[_position].text; + string text = _history[_position].text; + _seenInDirection.Add(text); + return text; } _position = _history.Count; @@ -85,36 +92,28 @@ public string Next(bool skipSameCommands) public string Previous(bool skipSameCommands) { - int initialPosition = _position; --_position; + if (_direction != -1) + { + _seenInDirection.Clear(); + } + _direction = -1; while ( skipSameCommands - && 0 <= initialPosition - && initialPosition < _history.Count && 0 <= _position && _position < _history.Count + && _seenInDirection.Contains(_history[_position].text) ) { - if ( - string.Equals( - _history[initialPosition].text, - _history[_position].text, - StringComparison.OrdinalIgnoreCase - ) - ) - { - --_position; - } - else - { - break; - } + --_position; } if (0 <= _position && _position < _history.Count) { - return _history[_position].text; + string text = _history[_position].text; + _seenInDirection.Add(text); + return text; } _position = -1; @@ -126,6 +125,8 @@ public int Clear() int count = _history.Count; _history.Clear(); _position = 0; + _seenInDirection.Clear(); + _direction = 0; return count; } } diff --git a/Tests/Runtime/CommandHistoryTests.cs b/Tests/Runtime/CommandHistoryTests.cs index 0f11e492..c3cd4ca2 100644 --- a/Tests/Runtime/CommandHistoryTests.cs +++ b/Tests/Runtime/CommandHistoryTests.cs @@ -247,6 +247,59 @@ public void NavigationAfterClearWithSkipSameCommands() ); } + [Test] + public void PreviousSkipsNonAdjacentDuplicates() + { + CommandHistory history = new(10); + history.Push("a", true, true); + history.Push("b", true, true); + history.Push("a", true, true); + + Assert.AreEqual( + "a", + history.Previous(true), + "First Previous should return the newest entry" + ); + Assert.AreEqual("b", history.Previous(true), "Second Previous should return 'b'"); + Assert.AreEqual( + string.Empty, + history.Previous(true), + "Third Previous should skip the non-adjacent duplicate 'a' and return empty" + ); + } + + [Test] + public void NextSkipsNonAdjacentDuplicates() + { + CommandHistory history = new(10); + history.Push("a", true, true); + history.Push("b", true, true); + history.Push("x", true, true); + history.Push("b", true, true); + history.Push("c", true, true); + + Assert.AreEqual( + "c", + history.Previous(true), + "First Previous should return the newest entry" + ); + Assert.AreEqual("b", history.Previous(true), "Second Previous should return 'b'"); + Assert.AreEqual("x", history.Previous(true), "Third Previous should return 'x'"); + Assert.AreEqual( + "a", + history.Previous(true), + "Fourth Previous should skip duplicate 'b' and return 'a'" + ); + + Assert.AreEqual("b", history.Next(true), "Next should replay entries going forward"); + Assert.AreEqual("x", history.Next(true), "Second Next should return 'x'"); + Assert.AreEqual( + "c", + history.Next(true), + "Third Next should skip the non-adjacent duplicate 'b' and return 'c'" + ); + } + [UnityTest] public IEnumerator ClearHistoryCommandResultsInEmptyHistory() { From 9801de239f18b936098b6333e4ed37d7483d2d56 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 22:08:06 +0000 Subject: [PATCH 3/8] Relocate npm tooling to Unity-hidden tooling~/ (#39) The npm project (package.json scripts/devDependencies/engines) and the repo tooling under scripts/ now live under tooling~/ (tilde-suffixed), which Unity never imports. Opening the repository as a local package no longer imports node_modules or tooling scripts, and the 21 tracked scripts/**/*.meta files are gone (Unity-invisible paths must not carry metas; unity-meta-lint stays green at 1613 files, 0 problems). Root package.json keeps the UPM manifest fields plus delegating npm scripts (npm --prefix tooling~), so documented commands like npm run unity:mcp:probe and npm test are unchanged from the repo root. CI/devcontainer/linter/skills/docs updated to the new paths; the devcontainer node_modules cache mount now targets tooling~/node_modules. Path-resolution fixes: generate-skills-index/lint-* RepoRoot now walks two levels from the script (tooling~/scripts), unity-mcp REPO_ROOT and captureScriptSourcePath point at tooling~/scripts/mcp, and the env-and-capture test computes its repo root accordingly. Data: node --test 32/32 green under tooling~ and via root wrapper; llm-instructions lint, skill-sizes lint, unity-meta lint all pass; pwsh test suites 21+8+7 and ai-backends suite 106/106 green. --- .devcontainer/README.md | 10 +++---- .devcontainer/cache-contract.sh | 4 +-- .devcontainer/devcontainer.json | 4 +-- .devcontainer/post-create.sh | 5 ++-- .devcontainer/post-start.sh | 2 +- .editorconfig | 4 +-- .github/workflows/llm-instructions-lint.yml | 30 +++++++++---------- .github/workflows/tooling-tests.yml | 12 ++++---- .github/workflows/unity-meta-lint.yml | 2 +- .gitignore | 3 +- .llm/context.md | 22 +++++++------- .llm/skills/capture-unity-state/SKILL.md | 2 +- .llm/skills/create-unity-meta/SKILL.md | 2 +- .llm/skills/formatting-and-linting/SKILL.md | 2 +- .llm/skills/index.md | 6 ++-- .llm/skills/manage-skills/SKILL.md | 10 +++---- .llm/skills/unity-mcp/SKILL.md | 4 +-- .pre-commit-config.yaml | 6 ++-- package.json | 22 +++++--------- scripts.meta | 8 ----- scripts/generate-skills-index.ps1.meta | 7 ----- scripts/lint-llm-instructions.ps1.meta | 7 ----- scripts/lint-skill-sizes.ps1.meta | 7 ----- scripts/lint-unity-meta.ps1.meta | 7 ----- scripts/mcp.meta | 8 ----- .../mcp/DxTerminalStateCapture.cs.txt.meta | 7 ----- scripts/mcp/README.md.meta | 7 ----- scripts/mcp/__tests__.meta | 8 ----- .../__tests__/client-configs.test.mjs.meta | 7 ----- scripts/mcp/__tests__/dotenv.test.mjs.meta | 7 ----- .../__tests__/env-and-capture.test.mjs.meta | 7 ----- scripts/mcp/__tests__/ports.test.mjs.meta | 7 ----- scripts/mcp/unity-mcp.mjs.meta | 7 ----- scripts/tests.meta | 8 ----- scripts/tests/fixtures.meta | 8 ----- scripts/tests/fixtures/valid-skill.md.meta | 7 ----- scripts/tests/test-ai-backends.sh.meta | 7 ----- .../tests/test-generate-skills-index.ps1.meta | 7 ----- scripts/tests/test-helpers.ps1.meta | 7 ----- scripts/tests/test-lint-skill-sizes.ps1.meta | 7 ----- .../tests/test-llm-instructions-lint.ps1.meta | 7 ----- tooling~/package.json | 22 ++++++++++++++ .../scripts}/generate-skills-index.ps1 | 12 ++++---- .../scripts}/lint-llm-instructions.ps1 | 10 +++---- .../scripts}/lint-skill-sizes.ps1 | 8 ++--- .../scripts}/lint-unity-meta.ps1 | 6 ++-- .../mcp/DxTerminalStateCapture.cs.txt | 0 {scripts => tooling~/scripts}/mcp/README.md | 4 +-- .../mcp/__tests__/client-configs.test.mjs | 0 .../scripts}/mcp/__tests__/dotenv.test.mjs | 0 .../mcp/__tests__/env-and-capture.test.mjs | 2 +- .../scripts}/mcp/__tests__/ports.test.mjs | 0 .../scripts}/mcp/unity-mcp.mjs | 6 ++-- .../scripts}/tests/fixtures/valid-skill.md | 2 +- .../scripts}/tests/test-ai-backends.sh | 6 ++-- .../tests/test-generate-skills-index.ps1 | 4 +-- .../scripts}/tests/test-helpers.ps1 | 2 +- .../scripts}/tests/test-lint-skill-sizes.ps1 | 4 +-- .../tests/test-llm-instructions-lint.ps1 | 4 +-- 59 files changed, 132 insertions(+), 271 deletions(-) delete mode 100644 scripts.meta delete mode 100644 scripts/generate-skills-index.ps1.meta delete mode 100644 scripts/lint-llm-instructions.ps1.meta delete mode 100644 scripts/lint-skill-sizes.ps1.meta delete mode 100644 scripts/lint-unity-meta.ps1.meta delete mode 100644 scripts/mcp.meta delete mode 100644 scripts/mcp/DxTerminalStateCapture.cs.txt.meta delete mode 100644 scripts/mcp/README.md.meta delete mode 100644 scripts/mcp/__tests__.meta delete mode 100644 scripts/mcp/__tests__/client-configs.test.mjs.meta delete mode 100644 scripts/mcp/__tests__/dotenv.test.mjs.meta delete mode 100644 scripts/mcp/__tests__/env-and-capture.test.mjs.meta delete mode 100644 scripts/mcp/__tests__/ports.test.mjs.meta delete mode 100644 scripts/mcp/unity-mcp.mjs.meta delete mode 100644 scripts/tests.meta delete mode 100644 scripts/tests/fixtures.meta delete mode 100644 scripts/tests/fixtures/valid-skill.md.meta delete mode 100644 scripts/tests/test-ai-backends.sh.meta delete mode 100644 scripts/tests/test-generate-skills-index.ps1.meta delete mode 100644 scripts/tests/test-helpers.ps1.meta delete mode 100644 scripts/tests/test-lint-skill-sizes.ps1.meta delete mode 100644 scripts/tests/test-llm-instructions-lint.ps1.meta create mode 100644 tooling~/package.json rename {scripts => tooling~/scripts}/generate-skills-index.ps1 (93%) rename {scripts => tooling~/scripts}/lint-llm-instructions.ps1 (97%) rename {scripts => tooling~/scripts}/lint-skill-sizes.ps1 (92%) rename {scripts => tooling~/scripts}/lint-unity-meta.ps1 (96%) rename {scripts => tooling~/scripts}/mcp/DxTerminalStateCapture.cs.txt (100%) rename {scripts => tooling~/scripts}/mcp/README.md (94%) rename {scripts => tooling~/scripts}/mcp/__tests__/client-configs.test.mjs (100%) rename {scripts => tooling~/scripts}/mcp/__tests__/dotenv.test.mjs (100%) rename {scripts => tooling~/scripts}/mcp/__tests__/env-and-capture.test.mjs (98%) rename {scripts => tooling~/scripts}/mcp/__tests__/ports.test.mjs (100%) rename {scripts => tooling~/scripts}/mcp/unity-mcp.mjs (99%) rename {scripts => tooling~/scripts}/tests/fixtures/valid-skill.md (79%) rename {scripts => tooling~/scripts}/tests/test-ai-backends.sh (99%) rename {scripts => tooling~/scripts}/tests/test-generate-skills-index.ps1 (97%) rename {scripts => tooling~/scripts}/tests/test-helpers.ps1 (98%) rename {scripts => tooling~/scripts}/tests/test-lint-skill-sizes.ps1 (97%) rename {scripts => tooling~/scripts}/tests/test-llm-instructions-lint.ps1 (98%) diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 7060821d..e2d0f201 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -2,7 +2,7 @@ A fast, reliable VS Code devcontainer for this Unity UPM package. The Unity Editor stays on the **host** (licensing + GUI); the container ships every CLI tool, drives the -host editor through the [unity-mcp bridge](../scripts/mcp/README.md), and needs **zero +host editor through the [unity-mcp bridge](../tooling~/scripts/mcp/README.md), and needs **zero sudo** anywhere. ``` @@ -41,7 +41,7 @@ sudo** anywhere. per-project port, so multiple editors never collide): ```bash - npm install # host checkout of this repo (one time) + npm --prefix tooling~ install # host checkout of this repo (one time) npm run unity:mcp # bridge: host editor -> authenticated HTTP ``` @@ -143,7 +143,7 @@ Z.AI overrides: `CODEX_ZAI_MODEL`, `CODEX_ZAI_REASONING_EFFORT`, `CLAUDE_ZAI_*_M `ZAI_API_TIMEOUT_MS` (default 3000000, matching the GLM coding-plan guidance), `CLAUDE_ZAI_CONFIG_DIR`, `AI_BACKENDS_CONTAINER_MODE`, `CLAUDE_ZAI_SUBPROCESS_ENV_SCRUB`. The launchers are regression-tested -(`scripts/tests/test-ai-backends.sh`). +(`tooling~/scripts/tests/test-ai-backends.sh`). ### OpenRouter (API key, any model) @@ -181,7 +181,7 @@ from workspace watching; the workspace itself remains the package repository. ## Verify ```bash -npm test # node --test scripts/mcp/__tests__ -bash scripts/tests/test-ai-backends.sh # Z.AI launcher regression suite +npm test # node --test tooling~/scripts/mcp/__tests__ +bash tooling~/scripts/tests/test-ai-backends.sh # Z.AI launcher regression suite npm run unity:mcp:probe # host editor readiness (bridge running) ``` diff --git a/.devcontainer/cache-contract.sh b/.devcontainer/cache-contract.sh index 81e8a2cc..9a3af225 100644 --- a/.devcontainer/cache-contract.sh +++ b/.devcontainer/cache-contract.sh @@ -49,7 +49,7 @@ readonly CACHE_MOUNT_TARGETS=( "/home/vscode/.local/share/powershell" "/home/vscode/.cache/pip" "/home/vscode/.npm" - "${CACHE_WORKSPACE_ROOT}/node_modules" + "${CACHE_WORKSPACE_ROOT}/tooling~/node_modules" ) cache_contract_validate_shape() { @@ -106,7 +106,7 @@ cache_contract_repair_permissions() { for target in "${CACHE_MOUNT_TARGETS[@]}" "${HOME}/.local"; do cache_contract_repair_directory "$target" "$current_uid" "$current_gid" || return 1 done - for target in "${HOME}/.npmrc" "${CACHE_WORKSPACE_ROOT}/package-lock.json" \ + for target in "${HOME}/.npmrc" "${CACHE_WORKSPACE_ROOT}/tooling~/package-lock.json" \ "${CACHE_WORKSPACE_ROOT}/package.json"; do if [[ -f "$target" && ! -w "$target" ]]; then # Host bind mounts can be writable without supporting ownership changes. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c5a87572..174e4acd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ // Project: DxCommandTerminal | Unity UPM package (min 2021.3, host editor 6000.4) // The Unity Editor stays on the host; this container ships the .NET / Node / // PowerShell toolchain plus every agent CLI and MCP server, and drives the host -// editor through the unity-mcp bridge (scripts/mcp/unity-mcp.mjs). +// editor through the unity-mcp bridge (tooling~/tooling~/scripts/mcp/unity-mcp.mjs). // ============================================================================= { "name": "DxCommandTerminal Dev", @@ -41,7 +41,7 @@ "source=dxt-powershell-modules,target=/home/vscode/.local/share/powershell,type=volume", "source=dxt-python-cache,target=/home/vscode/.cache/pip,type=volume", "source=dxt-npm-cache,target=/home/vscode/.npm,type=volume", - "source=dxt-node-modules,target=${containerWorkspaceFolder}/node_modules,type=volume", + "source=dxt-node-modules,target=${containerWorkspaceFolder}/tooling~/node_modules,type=volume", "source=${localWorkspaceFolder}/../..,target=/unity-project,type=bind,consistency=cached" ], // --------------------------------------------------------------------------- diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index a6d9558b..0a79fd41 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -106,7 +106,7 @@ EOF } configure_agent_mcps() { - local configure=(node "${WORKSPACE_DIR}/scripts/mcp/unity-mcp.mjs" configure --offline) + local configure=(node "${WORKSPACE_DIR}/tooling~/tooling~/scripts/mcp/unity-mcp.mjs" configure --offline) if command -v flock >/dev/null 2>&1; then flock -w 180 "${MCP_CONFIGURE_LOCK}" "${configure[@]}" return @@ -228,7 +228,8 @@ main() { cd "${WORKSPACE_DIR}" run_optional "Restoring .NET local tools (CSharpier)" dotnet tool restore # npm install reuses the persistent modules tree; npm ci would remove it. - run_optional "Installing workspace npm dependencies" npm install --prefer-offline --no-audit --no-fund + # The npm project lives under tooling~/ so Unity never imports node_modules. + run_optional "Installing workspace npm dependencies" npm --prefix tooling~ install --prefer-offline --no-audit --no-fund run_optional "Configuring MCP servers for every agent front end" configure_agent_mcps run_optional "Installing Z.AI and OpenRouter agent launchers" \ bash "${SCRIPT_DIR}/ai-backends.sh" install diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index af1d287e..cab9ad2f 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -10,7 +10,7 @@ export NPM_CONFIG_PREFIX="${HOME}/.local" # Configuration is local and works with the image's baked dependencies before npm # install. Finish it before clients start, without probing a sleeping host. -mcp_script="${SCRIPT_DIR}/../scripts/mcp/unity-mcp.mjs" +mcp_script="${SCRIPT_DIR}/../tooling~/tooling~/scripts/mcp/unity-mcp.mjs" mcp_lock="${TMPDIR:-/tmp}/dxt-mcp-configure.lock" if command -v flock >/dev/null 2>&1; then flock -w 30 "${mcp_lock}" node "${mcp_script}" configure --offline diff --git a/.editorconfig b/.editorconfig index 768fc170..7774a4e0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -184,9 +184,9 @@ indent_size = 4 tab_width = 4 # LLM context and tooling: UTF-8 without BOM + LF for byte-stable cross-OS -# generation (enforced by scripts/lint-llm-instructions.ps1). Overrides the +# generation (enforced by tooling~/scripts/lint-llm-instructions.ps1). Overrides the # repo-wide BOM/CRLF defaults, which remain for C# assets. -[{.llm/**,scripts/**,.devcontainer/**,.github/copilot-instructions.md,AGENTS.md,CLAUDE.md,.cursorrules}] +[{.llm/**,tooling~/**,.devcontainer/**,.github/copilot-instructions.md,AGENTS.md,CLAUDE.md,.cursorrules}] charset = utf-8 end_of_line = lf insert_final_newline = true diff --git a/.github/workflows/llm-instructions-lint.yml b/.github/workflows/llm-instructions-lint.yml index da7b376b..a0fc2d95 100644 --- a/.github/workflows/llm-instructions-lint.yml +++ b/.github/workflows/llm-instructions-lint.yml @@ -11,10 +11,10 @@ on: - "CLAUDE.md" - ".cursorrules" - ".github/copilot-instructions.md" - - "scripts/generate-skills-index.ps1" - - "scripts/lint-llm-instructions.ps1" - - "scripts/lint-skill-sizes.ps1" - - "scripts/tests/**" + - "tooling~/scripts/generate-skills-index.ps1" + - "tooling~/scripts/lint-llm-instructions.ps1" + - "tooling~/scripts/lint-skill-sizes.ps1" + - "tooling~/scripts/tests/**" - ".github/workflows/llm-instructions-lint.yml" pull_request: paths: @@ -23,10 +23,10 @@ on: - "CLAUDE.md" - ".cursorrules" - ".github/copilot-instructions.md" - - "scripts/generate-skills-index.ps1" - - "scripts/lint-llm-instructions.ps1" - - "scripts/lint-skill-sizes.ps1" - - "scripts/tests/**" + - "tooling~/scripts/generate-skills-index.ps1" + - "tooling~/scripts/lint-llm-instructions.ps1" + - "tooling~/scripts/lint-skill-sizes.ps1" + - "tooling~/scripts/tests/**" - ".github/workflows/llm-instructions-lint.yml" permissions: @@ -53,23 +53,23 @@ jobs: - name: Lint LLM instructions shell: pwsh - run: ./scripts/lint-llm-instructions.ps1 -VerboseOutput + run: ./tooling~/scripts/lint-llm-instructions.ps1 -VerboseOutput - name: Check skill file sizes shell: pwsh - run: ./scripts/lint-skill-sizes.ps1 -VerboseOutput + run: ./tooling~/scripts/lint-skill-sizes.ps1 -VerboseOutput - name: Test LLM instructions lint shell: pwsh - run: ./scripts/tests/test-llm-instructions-lint.ps1 -VerboseOutput + run: ./tooling~/scripts/tests/test-llm-instructions-lint.ps1 -VerboseOutput - name: Test skill size linter shell: pwsh - run: ./scripts/tests/test-lint-skill-sizes.ps1 -VerboseOutput + run: ./tooling~/scripts/tests/test-lint-skill-sizes.ps1 -VerboseOutput - name: Test skills index generator shell: pwsh - run: ./scripts/tests/test-generate-skills-index.ps1 -VerboseOutput + run: ./tooling~/scripts/tests/test-generate-skills-index.ps1 -VerboseOutput - name: Validate skills index is up-to-date # Regenerate the file and fail if it differs from the committed bytes. @@ -77,11 +77,11 @@ jobs: # (untracked) index.md, unlike `git diff` which ignores untracked files. shell: pwsh run: | - ./scripts/generate-skills-index.ps1 + ./tooling~/scripts/generate-skills-index.ps1 if ($LASTEXITCODE) { exit $LASTEXITCODE } $status = git status --porcelain -- .llm/skills/index.md if ($status) { - Write-Host "::error::.llm/skills/index.md is out of date or uncommitted. Run: pwsh -NoProfile -File scripts/generate-skills-index.ps1 and commit it." + Write-Host "::error::.llm/skills/index.md is out of date or uncommitted. Run: pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 and commit it." git diff -- .llm/skills/index.md exit 1 } diff --git a/.github/workflows/tooling-tests.yml b/.github/workflows/tooling-tests.yml index 5fbe7102..dfba6834 100644 --- a/.github/workflows/tooling-tests.yml +++ b/.github/workflows/tooling-tests.yml @@ -7,12 +7,12 @@ on: - main paths: - "package.json" - - "scripts/**" + - "tooling~/**" - ".github/workflows/tooling-tests.yml" pull_request: paths: - "package.json" - - "scripts/**" + - "tooling~/**" - ".github/workflows/tooling-tests.yml" permissions: @@ -30,7 +30,9 @@ jobs: # # Installs with `npm install`: no lockfile is committed for this Unity # package (reviewer decision), so there is nothing for `npm ci` to pin - # and setup-node's npm cache cannot be keyed. + # and setup-node's npm cache cannot be keyed. The npm project lives under + # `tooling~/` (Unity-hidden via the tilde suffix, so editors never import + # node_modules); root package.json scripts delegate with `npm --prefix`. name: node-tests (${{ matrix.os }}) strategy: fail-fast: false @@ -47,7 +49,7 @@ jobs: node-version: 24 - name: Install dependencies - run: npm install --no-audit --no-fund + run: npm --prefix tooling~ install --no-audit --no-fund - name: Test tooling - run: npm test + run: npm --prefix tooling~ test diff --git a/.github/workflows/unity-meta-lint.yml b/.github/workflows/unity-meta-lint.yml index 42b2f04b..903df79f 100644 --- a/.github/workflows/unity-meta-lint.yml +++ b/.github/workflows/unity-meta-lint.yml @@ -27,4 +27,4 @@ jobs: - name: Lint Unity meta hygiene shell: pwsh - run: ./scripts/lint-unity-meta.ps1 + run: ./tooling~/scripts/lint-unity-meta.ps1 diff --git a/.gitignore b/.gitignore index cd4756cd..e16ad960 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ node_modules/ # npm lockfiles are intentionally not committed for Unity packages (reviewer # decision): the package ships no Node code, and a lockfile would churn with -# every dev environment. Install with `npm install` to work on the tooling. +# every dev environment. Install with `npm --prefix tooling~ install` to work on the tooling (the npm +# project lives under tooling~/ so Unity never imports node_modules). package-lock.json # Local credentials - never commit (preferred credential source; see .env.example) diff --git a/.llm/context.md b/.llm/context.md index 2bf06319..3f49f40e 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -56,16 +56,18 @@ Styles/ # USS/TSS stylesheets consumed by Terminal Tests/Runtime/ # PlayMode tests (asmdef: ...Tests.Runtime) Components/ # Test harness components (TestCommands, TerminalInputHandler) Media/ # Screenshots and demo GIFs -scripts/ # Repo tooling (PowerShell + Node; not shipped in the UPM artifact) - mcp/ # unity-mcp.mjs: Unity MCP bridge/probe/configure/capture + tests - tests/ # Script regression suites (pwsh + bash) +tooling~/ # Unity-hidden tooling (tilde-suffixed; not shipped in the UPM artifact) + package.json # npm manifest (devDependencies; root package.json delegates via --prefix) + scripts/ # Repo tooling (PowerShell + Node) + mcp/ # unity-mcp.mjs: Unity MCP bridge/probe/configure/capture + tests + tests/ # Script regression suites (pwsh + bash) .devcontainer/ # VS Code devcontainer (Dockerfile, lifecycle, Z.AI launchers) ``` -### Dev tooling (scripts/, .devcontainer/) +### Dev tooling (tooling~/scripts/, .devcontainer/) `npm run unity:mcp:probe|configure|bridge|capture` drives the host Unity editor -over the authenticated MCP bridge in `scripts/mcp/unity-mcp.mjs` (see the +over the authenticated MCP bridge in `tooling~/scripts/mcp/unity-mcp.mjs` (see the [unity-mcp](./skills/unity-mcp/SKILL.md) and [capture-unity-state](./skills/capture-unity-state/SKILL.md) skills). Credentials live in gitignored `.env.local` (see `.env.example`); agent MCP configs are @@ -76,8 +78,8 @@ generated, never hand-edited; `npm test` runs the Node tooling suite. ## Skills Reference See the generated [Skills Index](./skills/index.md). Regenerate it after adding or editing any -skill: `pwsh -NoProfile -File scripts/generate-skills-index.ps1` (validated by -`scripts/lint-llm-instructions.ps1`). +skill: `pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1` (validated by +`tooling~/scripts/lint-llm-instructions.ps1`). ### SKILL.md Contract @@ -164,13 +166,13 @@ frontmatter validity, index freshness, and pointer-file delegation; see 1. **Line limits**: every authored file under `.llm/` MUST stay at or below 300 lines; 270+ gets a critical warning. The generated `.llm/skills/index.md` is exempt (machine-written). - Enforced by `scripts/lint-skill-sizes.ps1` (pre-commit + CI + tests). + Enforced by `tooling~/scripts/lint-skill-sizes.ps1` (pre-commit + CI + tests). 2. **SKILL.md validity + index freshness + pointer delegation**: enforced by - `scripts/lint-llm-instructions.ps1` (pre-commit + CI + tests). + `tooling~/scripts/lint-llm-instructions.ps1` (pre-commit + CI + tests). 3. **Generated files are byte-stable**: UTF-8 without BOM, LF line endings, ordinal sorting, no timestamps. Never hand-edit `.llm/skills/index.md`. 4. **Encoding overrides**: `.editorconfig` forces UTF-8 (no BOM) + LF for `.llm/**` and - `scripts/**` regardless of the repo defaults for C# assets. + `tooling~/**` regardless of the repo defaults for C# assets. 5. `.editorconfig` charset/line-ending defaults for C# assets remain BOM/CRLF per repo convention; only the LLM-context paths above are overridden. diff --git a/.llm/skills/capture-unity-state/SKILL.md b/.llm/skills/capture-unity-state/SKILL.md index 3aa46a6d..3c65634b 100644 --- a/.llm/skills/capture-unity-state/SKILL.md +++ b/.llm/skills/capture-unity-state/SKILL.md @@ -18,7 +18,7 @@ manifest to complete. Artifacts land inside this package's `.artifacts/unity-sta passing from where you run capture. 2. Capture script compiled in the editor. Install it host-side with `npm run unity:mcp:install-capture -- --project ` (it copies - `scripts/mcp/DxTerminalStateCapture.cs.txt` to `/Assets/Editor/`, + `tooling~/scripts/mcp/DxTerminalStateCapture.cs.txt` to `/Assets/Editor/`, backing up any previous copy under `.artifacts/unity-state/backup/`). `capture` performs this install itself when the project directory is reachable locally (host runs, or the container's `/unity-project` bind mount). diff --git a/.llm/skills/create-unity-meta/SKILL.md b/.llm/skills/create-unity-meta/SKILL.md index 26e2d2ed..295dbd1c 100644 --- a/.llm/skills/create-unity-meta/SKILL.md +++ b/.llm/skills/create-unity-meta/SKILL.md @@ -53,7 +53,7 @@ TextScriptImporter: ## Verification -Run `pwsh -NoProfile -File scripts/lint-unity-meta.ps1` (also enforced by pre-commit and +Run `pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1` (also enforced by pre-commit and CI). It checks, over tracked files only: 1. Every tracked `.meta` has its target tracked (no orphan metas for gitignored or diff --git a/.llm/skills/formatting-and-linting/SKILL.md b/.llm/skills/formatting-and-linting/SKILL.md index c42d2a12..88e88944 100644 --- a/.llm/skills/formatting-and-linting/SKILL.md +++ b/.llm/skills/formatting-and-linting/SKILL.md @@ -33,7 +33,7 @@ on every commit and fails fast when tools are missing. - Modifier order: `public private protected internal file new static abstract virtual sealed readonly override extern unsafe volatile async required`. - Files: UTF-8 BOM + CRLF for C# assets (repo default). Do not "fix" line endings on untouched - files - that pollutes diffs. Exceptions: `.llm/**` and `scripts/**` are UTF-8 no BOM + LF + files - that pollutes diffs. Exceptions: `.llm/**` and `tooling~/scripts/**` are UTF-8 no BOM + LF (enforced by `.editorconfig` overrides and the LLM linters). ## Pre-commit hooks diff --git a/.llm/skills/index.md b/.llm/skills/index.md index f723da09..66d28fd1 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -1,5 +1,5 @@ - - + + # Skills Index @@ -11,7 +11,7 @@ Agent Skills ([SKILL.md format](https://agentskills.io)) for specific tasks. Inv | --- | --- | | [create-unity-meta](./create-unity-meta/SKILL.md) | Generate correct Unity .meta files for new files and folders in the DxCommandTerminal package, including which paths Unity ignores (dot-directories) and which importer blocks to expect. Use whenever creating, moving, renaming, or deleting any visible file or folder in the package. | | [formatting-and-linting](./formatting-and-linting/SKILL.md) | Run CSharpier and the repo's pre-commit enforcement for DxCommandTerminal (dotnet tools, formatting C# files, fixing format-check failures). Use when formatting code, adding pre-commit hooks, or when a commit is rejected by the csharpier or dotnet-tool-restore hooks. | -| [manage-skills](./manage-skills/SKILL.md) | Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. | +| [manage-skills](./manage-skills/SKILL.md) | Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when tooling~/scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. | ## Performance Skills diff --git a/.llm/skills/manage-skills/SKILL.md b/.llm/skills/manage-skills/SKILL.md index 0e21e8d2..42222b3b 100644 --- a/.llm/skills/manage-skills/SKILL.md +++ b/.llm/skills/manage-skills/SKILL.md @@ -1,6 +1,6 @@ --- name: manage-skills -description: Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. +description: Create, edit, or remove agentic skills (.llm/skills/*/SKILL.md), regenerate the skills index, or fix lint failures around SKILL.md frontmatter, line limits, or pointer-file delegation. Use when adding new skills, changing skill descriptions/categories, touching .llm/**, or when tooling~/scripts/lint-llm-instructions.ps1 or lint-skill-sizes.ps1 fail. metadata: category: Core --- @@ -15,7 +15,7 @@ metadata: skills/ index.md # GENERATED - never hand-edit /SKILL.md # One directory per skill (agentskills.io format) -scripts/ +tooling~/scripts/ generate-skills-index.ps1 # Index generator (deterministic) lint-llm-instructions.ps1 # Contract linter (-Fix regenerates the index) lint-skill-sizes.ps1 # Line-limit linter @@ -35,12 +35,12 @@ scripts/ 3. Body: instructions, examples, edge cases. Reference sibling files with relative paths from the skill root. Keep the file at or below 300 lines (`lint-skill-sizes.ps1`; 270+ warns). 4. Regenerate the index: - `pwsh -NoProfile -File scripts/generate-skills-index.ps1` + `pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1` 5. Run both linters; commit `.llm/skills/index.md` together with the skill. ```sh -pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 -Fix -pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 -VerboseOutput +pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 -Fix +pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 -VerboseOutput ``` ## Editing a skill diff --git a/.llm/skills/unity-mcp/SKILL.md b/.llm/skills/unity-mcp/SKILL.md index 6750e19e..923a2970 100644 --- a/.llm/skills/unity-mcp/SKILL.md +++ b/.llm/skills/unity-mcp/SKILL.md @@ -8,7 +8,7 @@ metadata: # Unity MCP Bridge and Agent Backends Unity runs on the host; agents (in the devcontainer or on the host) reach it through -`scripts/mcp/unity-mcp.mjs`. Full reference: `scripts/mcp/README.md`. +`tooling~/scripts/mcp/unity-mcp.mjs`. Full reference: `tooling~/scripts/mcp/README.md`. ## Commands @@ -35,7 +35,7 @@ automatically); `GET /healthz` is the only unauthenticated endpoint. `.env.local`; the same `unity-mcp` server name in each checkout then reaches that checkout's editor. - Keep the bridge alive under the host's service manager (launchd on macOS) if it - must survive terminal closure: host `node /scripts/mcp/unity-mcp.mjs bridge`. + must survive terminal closure: host `node /tooling~/scripts/mcp/unity-mcp.mjs bridge`. - Backend selection: `--backend cli` (default; Unity 6 CLI `unity mcp --project-path`) or `--backend relay` (legacy AI Assistant under `~/.unity/relay/`). If `unity list --project-path --format json` shows no Pipeline tools, use relay. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11702960..4dbb8293 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: description: CSharpier is an opinionated C# formatter inspired by Prettier. - id: llm-instructions-lint name: Lint .llm instructions (SKILL.md spec, index freshness, pointer delegation) - entry: pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 + entry: pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 language: system always_run: true pass_filenames: false @@ -35,7 +35,7 @@ repos: structure, and front-end pointer-file delegation to .llm/context.md. - id: llm-skill-sizes name: Enforce .llm line limits (300 hard, 270 critical) - entry: pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 + entry: pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 language: system always_run: true pass_filenames: false @@ -47,7 +47,7 @@ repos: below 300 lines (270+ is a critical warning); the generated skills index is exempt. - id: unity-meta-lint name: Lint Unity .meta hygiene (orphans, missing metas, guid uniqueness) - entry: pwsh -NoProfile -File scripts/lint-unity-meta.ps1 + entry: pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1 language: system always_run: true pass_filenames: false diff --git a/package.json b/package.json index 46ff847d..9c694e1a 100644 --- a/package.json +++ b/package.json @@ -26,22 +26,14 @@ "author": "wallstop studios (https://wallstopstudios.com)", "homepage": "https://github.com/wallstop/DxCommandTerminal/blob/master/README.md", "main": "README.md", - "engines": { - "node": ">=22.7.0" - }, "scripts": { - "test": "node --test \"scripts/mcp/__tests__/*.test.mjs\"", - "unity:mcp:probe": "node scripts/mcp/unity-mcp.mjs probe", - "unity:mcp:configure": "node scripts/mcp/unity-mcp.mjs configure", - "unity:mcp:bridge": "node scripts/mcp/unity-mcp.mjs bridge", - "unity:mcp:install-capture": "node scripts/mcp/unity-mcp.mjs install-capture", - "unity:capture": "node scripts/mcp/unity-mcp.mjs capture", - "ai:backends": "bash .devcontainer/ai-backends.sh" - }, - "devDependencies": { - "@modelcontextprotocol/sdk": "1.30.0", - "jsonc-parser": "3.3.1", - "smol-toml": "1.8.0" + "test": "npm --prefix tooling~ run test", + "unity:mcp:probe": "npm --prefix tooling~ run unity:mcp:probe", + "unity:mcp:configure": "npm --prefix tooling~ run unity:mcp:configure", + "unity:mcp:bridge": "npm --prefix tooling~ run unity:mcp:bridge", + "unity:mcp:install-capture": "npm --prefix tooling~ run unity:mcp:install-capture", + "unity:capture": "npm --prefix tooling~ run unity:capture", + "ai:backends": "npm --prefix tooling~ run ai:backends" } } diff --git a/scripts.meta b/scripts.meta deleted file mode 100644 index ee8ea9bc..00000000 --- a/scripts.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 3bbaaf40ed0e843608e67f60a6325b4e -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/generate-skills-index.ps1.meta b/scripts/generate-skills-index.ps1.meta deleted file mode 100644 index d67a4f0b..00000000 --- a/scripts/generate-skills-index.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 62da9b55915fc4a50b925f418da87827 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/lint-llm-instructions.ps1.meta b/scripts/lint-llm-instructions.ps1.meta deleted file mode 100644 index 22f24a0d..00000000 --- a/scripts/lint-llm-instructions.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b56eaf37a4399426d83290d1c94c8d31 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/lint-skill-sizes.ps1.meta b/scripts/lint-skill-sizes.ps1.meta deleted file mode 100644 index 1b28850c..00000000 --- a/scripts/lint-skill-sizes.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: e7e7539b705684e16830e648d06c5177 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/lint-unity-meta.ps1.meta b/scripts/lint-unity-meta.ps1.meta deleted file mode 100644 index 3037823e..00000000 --- a/scripts/lint-unity-meta.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 2d318c664e5d484da9510d6a9b3f06b4 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp.meta b/scripts/mcp.meta deleted file mode 100644 index 0f847bdf..00000000 --- a/scripts/mcp.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 500d903f64dc7643f8d39f0646e3f558 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/DxTerminalStateCapture.cs.txt.meta b/scripts/mcp/DxTerminalStateCapture.cs.txt.meta deleted file mode 100644 index 0ec8d4ca..00000000 --- a/scripts/mcp/DxTerminalStateCapture.cs.txt.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 5d379f410b53fde49576f988f8b93fd3 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/README.md.meta b/scripts/mcp/README.md.meta deleted file mode 100644 index 5ec2640e..00000000 --- a/scripts/mcp/README.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ddfac236c87aa7fd03b4af215e614672 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__.meta b/scripts/mcp/__tests__.meta deleted file mode 100644 index 25e7fa85..00000000 --- a/scripts/mcp/__tests__.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 60f1d96a90d7a49603b18c53eac4c473 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/client-configs.test.mjs.meta b/scripts/mcp/__tests__/client-configs.test.mjs.meta deleted file mode 100644 index 39861c6c..00000000 --- a/scripts/mcp/__tests__/client-configs.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 37313b5cfe5836f41f9291c5f1cd6c90 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/dotenv.test.mjs.meta b/scripts/mcp/__tests__/dotenv.test.mjs.meta deleted file mode 100644 index 73801986..00000000 --- a/scripts/mcp/__tests__/dotenv.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 40f44fe0a996236ab3eda9ccf978acc9 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/env-and-capture.test.mjs.meta b/scripts/mcp/__tests__/env-and-capture.test.mjs.meta deleted file mode 100644 index b9885c2d..00000000 --- a/scripts/mcp/__tests__/env-and-capture.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 2fca72540f450288772cb10ab6b4d41a -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/__tests__/ports.test.mjs.meta b/scripts/mcp/__tests__/ports.test.mjs.meta deleted file mode 100644 index 5f5ca8e0..00000000 --- a/scripts/mcp/__tests__/ports.test.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 8691d6fb510cbe1549d25cf0d79fe5bb -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/mcp/unity-mcp.mjs.meta b/scripts/mcp/unity-mcp.mjs.meta deleted file mode 100644 index 3c02c8b7..00000000 --- a/scripts/mcp/unity-mcp.mjs.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 7e65ba3e9ebace1af2b8f9e1bbc60cc5 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests.meta b/scripts/tests.meta deleted file mode 100644 index db80d82c..00000000 --- a/scripts/tests.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: b4609f220a8d54767ab633c4c48809df -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/fixtures.meta b/scripts/tests/fixtures.meta deleted file mode 100644 index 9c59b608..00000000 --- a/scripts/tests/fixtures.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: a41b2b4252cda40dc994a8d07a9a6cbc -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/fixtures/valid-skill.md.meta b/scripts/tests/fixtures/valid-skill.md.meta deleted file mode 100644 index e18c2659..00000000 --- a/scripts/tests/fixtures/valid-skill.md.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 18b8c5d9c96be4e849193ada00285bd5 -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-ai-backends.sh.meta b/scripts/tests/test-ai-backends.sh.meta deleted file mode 100644 index cad0f020..00000000 --- a/scripts/tests/test-ai-backends.sh.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: b31fe7463a33dc606b7b5dfafe57018c -TextScriptImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-generate-skills-index.ps1.meta b/scripts/tests/test-generate-skills-index.ps1.meta deleted file mode 100644 index c8ad1240..00000000 --- a/scripts/tests/test-generate-skills-index.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 4b129089b767e4b85aca8eca30e6df45 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-helpers.ps1.meta b/scripts/tests/test-helpers.ps1.meta deleted file mode 100644 index b34b8d1b..00000000 --- a/scripts/tests/test-helpers.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 222a76b64cb0944b8899e2e2d7cf4348 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-lint-skill-sizes.ps1.meta b/scripts/tests/test-lint-skill-sizes.ps1.meta deleted file mode 100644 index 6647d110..00000000 --- a/scripts/tests/test-lint-skill-sizes.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: ac1efb338cd1d4ae599444572bd3ee6c -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/scripts/tests/test-llm-instructions-lint.ps1.meta b/scripts/tests/test-llm-instructions-lint.ps1.meta deleted file mode 100644 index eee0b768..00000000 --- a/scripts/tests/test-llm-instructions-lint.ps1.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: e09b8c2a1e8544d25aa0b7b6133b1b1e -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/tooling~/package.json b/tooling~/package.json new file mode 100644 index 00000000..03c72929 --- /dev/null +++ b/tooling~/package.json @@ -0,0 +1,22 @@ +{ + "name": "dxcommandterminal-tooling", + "private": true, + "description": "npm tooling for the DxCommandTerminal repository. Lives under tooling~/ (tilde-suffixed) so Unity never imports it or its node_modules when the repository is used as a local package. The root UPM package.json delegates here via `npm --prefix tooling~`.", + "engines": { + "node": ">=22.7.0" + }, + "scripts": { + "test": "node --test \"scripts/mcp/__tests__/*.test.mjs\"", + "unity:mcp:probe": "node scripts/mcp/unity-mcp.mjs probe", + "unity:mcp:configure": "node scripts/mcp/unity-mcp.mjs configure", + "unity:mcp:bridge": "node scripts/mcp/unity-mcp.mjs bridge", + "unity:mcp:install-capture": "node scripts/mcp/unity-mcp.mjs install-capture", + "unity:capture": "node scripts/mcp/unity-mcp.mjs capture", + "ai:backends": "bash ../.devcontainer/ai-backends.sh" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "1.30.0", + "jsonc-parser": "3.3.1", + "smol-toml": "1.8.0" + } +} diff --git a/scripts/generate-skills-index.ps1 b/tooling~/scripts/generate-skills-index.ps1 similarity index 93% rename from scripts/generate-skills-index.ps1 rename to tooling~/scripts/generate-skills-index.ps1 index 573c5520..0d88291e 100644 --- a/scripts/generate-skills-index.ps1 +++ b/tooling~/scripts/generate-skills-index.ps1 @@ -22,7 +22,7 @@ - The file is written as UTF-8 WITHOUT a BOM and with LF line endings via [System.IO.File]::WriteAllText. - Descriptions MUST be single-line and ASCII; scripts/lint-llm-instructions.ps1 + Descriptions MUST be single-line and ASCII; tooling~/scripts/lint-llm-instructions.ps1 enforces this so stray em-dashes / smart quotes can never reintroduce drift. .PARAMETER OutputPath @@ -36,8 +36,8 @@ Emit progress messages. .EXAMPLE - pwsh -NoProfile -File scripts/generate-skills-index.ps1 - pwsh -NoProfile -File scripts/generate-skills-index.ps1 -Stdout + pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 + pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 -Stdout #> Param( [string]$OutputPath, @@ -50,7 +50,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.llm/skills' $indexFileName = 'index.md' @@ -178,8 +178,8 @@ function Get-SkillsIndexContent { $lf = "`n" $sb = New-Object System.Text.StringBuilder - [void]$sb.Append("$lf") - [void]$sb.Append("$lf") + [void]$sb.Append("$lf") + [void]$sb.Append("$lf") [void]$sb.Append($lf) [void]$sb.Append("# Skills Index$lf") [void]$sb.Append($lf) diff --git a/scripts/lint-llm-instructions.ps1 b/tooling~/scripts/lint-llm-instructions.ps1 similarity index 97% rename from scripts/lint-llm-instructions.ps1 rename to tooling~/scripts/lint-llm-instructions.ps1 index 3b3f0136..7c9c56e8 100644 --- a/scripts/lint-llm-instructions.ps1 +++ b/tooling~/scripts/lint-llm-instructions.ps1 @@ -29,8 +29,8 @@ Repository root. Defaults to the parent of this script's directory. .EXAMPLE - pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 - pwsh -NoProfile -File scripts/lint-llm-instructions.ps1 -Fix + pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 + pwsh -NoProfile -File tooling~/scripts/lint-llm-instructions.ps1 -Fix #> Param( [switch]$Fix, @@ -54,7 +54,7 @@ function Write-SuccessMsg($msg) { } if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.llm/skills' @@ -253,7 +253,7 @@ try { Write-SuccessMsg "Generated missing $indexFileName" } else { - Write-ErrorMsg "$indexFileName does not exist. Run: pwsh -NoProfile -File scripts/generate-skills-index.ps1" + Write-ErrorMsg "$indexFileName does not exist. Run: pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1" exit 1 } } @@ -286,7 +286,7 @@ try { $shown++ } } - Write-Host "Run: pwsh -NoProfile -File scripts/generate-skills-index.ps1 (or this lint with -Fix)" -ForegroundColor Cyan + Write-Host "Run: pwsh -NoProfile -File tooling~/scripts/generate-skills-index.ps1 (or this lint with -Fix)" -ForegroundColor Cyan $exitCode = 1 } } diff --git a/scripts/lint-skill-sizes.ps1 b/tooling~/scripts/lint-skill-sizes.ps1 similarity index 92% rename from scripts/lint-skill-sizes.ps1 rename to tooling~/scripts/lint-skill-sizes.ps1 index b590737e..f5f5c001 100644 --- a/scripts/lint-skill-sizes.ps1 +++ b/tooling~/scripts/lint-skill-sizes.ps1 @@ -10,7 +10,7 @@ - <=269 lines: OK The generated .llm/skills/index.md is exempt: it is machine-written by - scripts/generate-skills-index.ps1, not an authored file. + tooling~/scripts/generate-skills-index.ps1, not an authored file. Skill/context messages use [skill-sizes] / [context-size] prefixes. @@ -24,8 +24,8 @@ Repository root. Defaults to the parent of this script's directory. .EXAMPLE - pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 - pwsh -NoProfile -File scripts/lint-skill-sizes.ps1 -VerboseOutput + pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 + pwsh -NoProfile -File tooling~/scripts/lint-skill-sizes.ps1 -VerboseOutput #> Param( [switch]$VerboseOutput, @@ -49,7 +49,7 @@ function Write-SuccessMsg($msg, $prefix = "[skill-sizes]") { } if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } $llmDir = Join-Path -Path $RepoRoot -ChildPath '.llm' diff --git a/scripts/lint-unity-meta.ps1 b/tooling~/scripts/lint-unity-meta.ps1 similarity index 96% rename from scripts/lint-unity-meta.ps1 rename to tooling~/scripts/lint-unity-meta.ps1 index 98ec033c..6ebaa0fe 100644 --- a/scripts/lint-unity-meta.ps1 +++ b/tooling~/scripts/lint-unity-meta.ps1 @@ -27,8 +27,8 @@ Repository root. Defaults to the parent of this script's directory. .EXAMPLE - pwsh -NoProfile -File scripts/lint-unity-meta.ps1 - pwsh -NoProfile -File scripts/lint-unity-meta.ps1 -VerboseOutput + pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1 + pwsh -NoProfile -File tooling~/scripts/lint-unity-meta.ps1 -VerboseOutput #> Param( [switch]$VerboseOutput, @@ -47,7 +47,7 @@ function Write-SuccessMsg($msg) { } if (-not $RepoRoot) { - $RepoRoot = (Get-Item $PSScriptRoot).Parent.FullName + $RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName } Push-Location $RepoRoot diff --git a/scripts/mcp/DxTerminalStateCapture.cs.txt b/tooling~/scripts/mcp/DxTerminalStateCapture.cs.txt similarity index 100% rename from scripts/mcp/DxTerminalStateCapture.cs.txt rename to tooling~/scripts/mcp/DxTerminalStateCapture.cs.txt diff --git a/scripts/mcp/README.md b/tooling~/scripts/mcp/README.md similarity index 94% rename from scripts/mcp/README.md rename to tooling~/scripts/mcp/README.md index 5d4efcee..98db965e 100644 --- a/scripts/mcp/README.md +++ b/tooling~/scripts/mcp/README.md @@ -3,7 +3,7 @@ `unity-mcp.mjs` is the single entry point for agent access to the host Unity editor: ``` -node scripts/mcp/unity-mcp.mjs [options] +node tooling~/scripts/mcp/unity-mcp.mjs [options] ``` Unity stays on the host. The `bridge` command (host) exposes the editor through an @@ -71,5 +71,5 @@ Details: [capture-unity-state](../../.llm/skills/capture-unity-state/SKILL.md). ## Tests ```bash -npm test # node --test scripts/mcp/__tests__/ (dotenv, ports, configs, capture paths) +npm test # node --test tooling~/scripts/mcp/__tests__/ (dotenv, ports, configs, capture paths) ``` diff --git a/scripts/mcp/__tests__/client-configs.test.mjs b/tooling~/scripts/mcp/__tests__/client-configs.test.mjs similarity index 100% rename from scripts/mcp/__tests__/client-configs.test.mjs rename to tooling~/scripts/mcp/__tests__/client-configs.test.mjs diff --git a/scripts/mcp/__tests__/dotenv.test.mjs b/tooling~/scripts/mcp/__tests__/dotenv.test.mjs similarity index 100% rename from scripts/mcp/__tests__/dotenv.test.mjs rename to tooling~/scripts/mcp/__tests__/dotenv.test.mjs diff --git a/scripts/mcp/__tests__/env-and-capture.test.mjs b/tooling~/scripts/mcp/__tests__/env-and-capture.test.mjs similarity index 98% rename from scripts/mcp/__tests__/env-and-capture.test.mjs rename to tooling~/scripts/mcp/__tests__/env-and-capture.test.mjs index c14e77ff..40638ff6 100644 --- a/scripts/mcp/__tests__/env-and-capture.test.mjs +++ b/tooling~/scripts/mcp/__tests__/env-and-capture.test.mjs @@ -13,7 +13,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); test("github token aliases resolve in documented order with env beating file", () => { const env = (token) => (token ? { GITHUB_TOKEN: token } : {}); diff --git a/scripts/mcp/__tests__/ports.test.mjs b/tooling~/scripts/mcp/__tests__/ports.test.mjs similarity index 100% rename from scripts/mcp/__tests__/ports.test.mjs rename to tooling~/scripts/mcp/__tests__/ports.test.mjs diff --git a/scripts/mcp/unity-mcp.mjs b/tooling~/scripts/mcp/unity-mcp.mjs similarity index 99% rename from scripts/mcp/unity-mcp.mjs rename to tooling~/scripts/mcp/unity-mcp.mjs index 79324f4c..a40d06fa 100755 --- a/scripts/mcp/unity-mcp.mjs +++ b/tooling~/scripts/mcp/unity-mcp.mjs @@ -42,7 +42,7 @@ const [ "jsonc-parser" ].map(dependency) ); -export const REPO_ROOT = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); +export const REPO_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); export const GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/"; export const DEFAULTS = Object.freeze({ bindHost: "0.0.0.0", @@ -1478,7 +1478,7 @@ const CAPTURE_TYPE_PROBE = const CAPTURE_REFRESH_EXPRESSION = "UnityEditor.AssetDatabase.Refresh()"; export function captureScriptSourcePath(repoRoot = REPO_ROOT) { - return path.join(repoRoot, "scripts", "mcp", CAPTURE_SOURCE_NAME); + return path.join(repoRoot, "tooling~", "scripts", "mcp", CAPTURE_SOURCE_NAME); } export function captureInstallTarget(projectPath) { @@ -1724,7 +1724,7 @@ async function waitForEditorIdle(client, evalCall, deadline) { function usage() { return [ - "Usage: node scripts/mcp/unity-mcp.mjs [options]", + "Usage: node tooling~/scripts/mcp/unity-mcp.mjs [options]", "", " probe Discover Unity tools and check editor readiness.", " configure Configure agent MCP servers, discovering Unity unless --offline is set.", diff --git a/scripts/tests/fixtures/valid-skill.md b/tooling~/scripts/tests/fixtures/valid-skill.md similarity index 79% rename from scripts/tests/fixtures/valid-skill.md rename to tooling~/scripts/tests/fixtures/valid-skill.md index f0451af9..d87e4e63 100644 --- a/scripts/tests/fixtures/valid-skill.md +++ b/tooling~/scripts/tests/fixtures/valid-skill.md @@ -1,6 +1,6 @@ --- name: valid-skill -description: Valid fixture skill used by the automated linter tests. Use only inside scripts/tests fixtures. +description: Valid fixture skill used by the automated linter tests. Use only inside tooling~/scripts/tests fixtures. metadata: category: Feature --- diff --git a/scripts/tests/test-ai-backends.sh b/tooling~/scripts/tests/test-ai-backends.sh similarity index 99% rename from scripts/tests/test-ai-backends.sh rename to tooling~/scripts/tests/test-ai-backends.sh index 64adbb5e..f14d5f43 100755 --- a/scripts/tests/test-ai-backends.sh +++ b/tooling~/scripts/tests/test-ai-backends.sh @@ -19,7 +19,7 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BACKENDS="${SCRIPT_DIR}/../../.devcontainer/ai-backends.sh" +BACKENDS="${SCRIPT_DIR}/../../../.devcontainer/ai-backends.sh" WORK="$(mktemp -d)" trap 'rm -rf "${WORK}"' EXIT @@ -470,7 +470,7 @@ out="${WORK}/env-precedence.txt" assert_contains "${out}" "^export ZAI_API_KEY='env-zai-wins'\$" "env honors environment precedence" echo "== env-autoload: rc snippet exports .env.local credentials for native agents ==" -AUTOLOAD="${SCRIPT_DIR}/../../.devcontainer/env-autoload.sh" +AUTOLOAD="${SCRIPT_DIR}/../../../.devcontainer/env-autoload.sh" assert "env-autoload snippet exists" test -f "${AUTOLOAD}" mkdir -p "${WORK}/autoload-root/.devcontainer" cp "${BACKENDS}" "${WORK}/autoload-root/.devcontainer/ai-backends.sh" @@ -514,7 +514,7 @@ touch "${out}/.bashrc" "${out}/.profile" ( # Sourcing post-create.sh only defines functions and enables -euo pipefail # (main is guarded); relax the flags for the assertions below. - . "${SCRIPT_DIR}/../../.devcontainer/post-create.sh" + . "${SCRIPT_DIR}/../../../.devcontainer/post-create.sh" set +e +u HOME="${WORK}/pc-home"; export HOME ensure_env_local_autoload "${WORK}/autoload-root" diff --git a/scripts/tests/test-generate-skills-index.ps1 b/tooling~/scripts/tests/test-generate-skills-index.ps1 similarity index 97% rename from scripts/tests/test-generate-skills-index.ps1 rename to tooling~/scripts/tests/test-generate-skills-index.ps1 index 6ce4e4cd..eb873a16 100644 --- a/scripts/tests/test-generate-skills-index.ps1 +++ b/tooling~/scripts/tests/test-generate-skills-index.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Red-green tests for scripts/generate-skills-index.ps1. + Red-green tests for tooling~/scripts/generate-skills-index.ps1. .DESCRIPTION Asserts the generator's contract: cross-run byte determinism, ordinal @@ -8,7 +8,7 @@ parsing (quoted strings), and graceful skipping of malformed skills. .EXAMPLE - pwsh -NoProfile -File scripts/tests/test-generate-skills-index.ps1 + pwsh -NoProfile -File tooling~/scripts/tests/test-generate-skills-index.ps1 #> Param( [switch]$VerboseOutput diff --git a/scripts/tests/test-helpers.ps1 b/tooling~/scripts/tests/test-helpers.ps1 similarity index 98% rename from scripts/tests/test-helpers.ps1 rename to tooling~/scripts/tests/test-helpers.ps1 index 7bb76c1f..ec990538 100644 --- a/scripts/tests/test-helpers.ps1 +++ b/tooling~/scripts/tests/test-helpers.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Shared helpers for the pwsh test scripts under scripts/tests/. + Shared helpers for the pwsh test scripts under tooling~/scripts/tests/. .DESCRIPTION Minimal test harness: named test cases with automatic pass/fail tracking, diff --git a/scripts/tests/test-lint-skill-sizes.ps1 b/tooling~/scripts/tests/test-lint-skill-sizes.ps1 similarity index 97% rename from scripts/tests/test-lint-skill-sizes.ps1 rename to tooling~/scripts/tests/test-lint-skill-sizes.ps1 index bcdca656..1c9097db 100644 --- a/scripts/tests/test-lint-skill-sizes.ps1 +++ b/tooling~/scripts/tests/test-lint-skill-sizes.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Red-green tests for scripts/lint-skill-sizes.ps1. + Red-green tests for tooling~/scripts/lint-skill-sizes.ps1. .DESCRIPTION Builds isolated fixture repos and asserts boundary behavior of the line @@ -8,7 +8,7 @@ exemption, recursive coverage, and the -FailOnCritical switch. .EXAMPLE - pwsh -NoProfile -File scripts/tests/test-lint-skill-sizes.ps1 + pwsh -NoProfile -File tooling~/scripts/tests/test-lint-skill-sizes.ps1 #> Param( [switch]$VerboseOutput diff --git a/scripts/tests/test-llm-instructions-lint.ps1 b/tooling~/scripts/tests/test-llm-instructions-lint.ps1 similarity index 98% rename from scripts/tests/test-llm-instructions-lint.ps1 rename to tooling~/scripts/tests/test-llm-instructions-lint.ps1 index 1b9709f2..da50e8d0 100644 --- a/scripts/tests/test-llm-instructions-lint.ps1 +++ b/tooling~/scripts/tests/test-llm-instructions-lint.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Red-green tests for scripts/lint-llm-instructions.ps1. + Red-green tests for tooling~/scripts/lint-llm-instructions.ps1. .DESCRIPTION Builds isolated fixture repos and asserts both failure paths (red) and the @@ -9,7 +9,7 @@ pointer-file delegation. Also verifies -Fix repairs a stale index. .EXAMPLE - pwsh -NoProfile -File scripts/tests/test-llm-instructions-lint.ps1 + pwsh -NoProfile -File tooling~/scripts/tests/test-llm-instructions-lint.ps1 #> Param( [switch]$VerboseOutput From 3364dedff775b74909a4ceb17d6eb44720fdc59f Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 22:09:58 +0000 Subject: [PATCH 4/8] Pin GitHub Actions to commit SHAs (#39) actions/checkout and actions/setup-node are pinned to the exact v6 commit SHAs with version comments in all three workflows, per the supply-chain hardening candidate in #39. Dependabot's github-actions ecosystem updates SHA-pinned refs and preserves inline version comments; the policy is documented in dependabot.yml so future bumps keep the comment in sync. --- .github/dependabot.yml | 3 +++ .github/workflows/llm-instructions-lint.yml | 2 +- .github/workflows/tooling-tests.yml | 4 ++-- .github/workflows/unity-meta-lint.yml | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f006b8cc..7cc93879 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,3 +13,6 @@ updates: - wallstop reviewers: - wallstop + # Repo policy: all action refs are pinned to commit SHAs with a "# vX" + # version comment (issue #39). Dependabot updates SHA-pinned refs and + # preserves the comment; keep the comment in sync with each bump. diff --git a/.github/workflows/llm-instructions-lint.yml b/.github/workflows/llm-instructions-lint.yml index a0fc2d95..5edd5671 100644 --- a/.github/workflows/llm-instructions-lint.yml +++ b/.github/workflows/llm-instructions-lint.yml @@ -49,7 +49,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Lint LLM instructions shell: pwsh diff --git a/.github/workflows/tooling-tests.yml b/.github/workflows/tooling-tests.yml index dfba6834..332c26ed 100644 --- a/.github/workflows/tooling-tests.yml +++ b/.github/workflows/tooling-tests.yml @@ -41,10 +41,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 diff --git a/.github/workflows/unity-meta-lint.yml b/.github/workflows/unity-meta-lint.yml index 903df79f..55e7ce6e 100644 --- a/.github/workflows/unity-meta-lint.yml +++ b/.github/workflows/unity-meta-lint.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Lint Unity meta hygiene shell: pwsh From db5ac336ed86fe5ea24a5ccffa644e2689e4a0a9 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 22:12:48 +0000 Subject: [PATCH 5/8] Remove obsolete JetBrains.Rider discovery sentinel (#39) Since discovery filters to assemblies whose metadata references the terminal assembly (session-001, issue #36), Rider plugin assemblies are skipped before any type reflection and the IgnoredTypes sentinel can no longer fire for its designed target. The conservative fail-open scan path is unaffected. The filtered==legacy equivalence sweep (FilteredDiscoveryMatchesUnfilteredDiscovery, 5/5 green on live editor) continues to pin filter behavior. --- .../CommandTerminal/Backend/CommandShell.cs | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/Runtime/CommandTerminal/Backend/CommandShell.cs b/Runtime/CommandTerminal/Backend/CommandShell.cs index eb986f1d..38c036d4 100644 --- a/Runtime/CommandTerminal/Backend/CommandShell.cs +++ b/Runtime/CommandTerminal/Backend/CommandShell.cs @@ -13,8 +13,6 @@ namespace WallstopStudios.DxCommandTerminal.Backend public sealed class CommandShell { - private static readonly string[] IgnoredTypes = { "JetBrains.Rider" }; - public static readonly Lazy<( MethodInfo method, RegisterCommandAttribute attribute @@ -106,11 +104,6 @@ is not RegisterCommandAttribute attribute } catch (Exception e) { - if (ShouldIgnoreExceptionForType(type)) - { - continue; - } - Debug.LogError( $"Failed to resolve method {method.Name} of type {type.FullName} with exception {e}" ); @@ -119,11 +112,6 @@ is not RegisterCommandAttribute attribute } catch (Exception e) { - if (ShouldIgnoreExceptionForType(type)) - { - continue; - } - Debug.LogError( $"Failed to resolve methods for type {type.FullName} with exception {e}" ); @@ -185,19 +173,6 @@ public CommandShell(CommandHistory history) public bool HasErrors => 0 < _errorMessages.Count; - private static bool ShouldIgnoreExceptionForType(Type type) - { - foreach (string ignoredType in IgnoredTypes) - { - if (type.FullName?.IndexOf(ignoredType, StringComparison.OrdinalIgnoreCase) >= 0) - { - return true; - } - } - - return false; - } - // Internal for test coverage of the discovery filter (see // WallstopStudios.DxCommandTerminal.Tests.Runtime). internal static bool MayContainCommands(Assembly assembly, AssemblyName self) From 78e9476bf00b1eb6e36e79291b43d75872c74f99 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 22:22:32 +0000 Subject: [PATCH 6/8] Package-content validator for clean UPM installs (#22, T02) New tooling~/scripts/release/validate-package-contents.mjs packs the package exactly as npm/UPM consumers receive it (npm pack + tar listing, node stdlib only) and asserts: manifest identity matches package.json, required artifacts ship (README/LICENSE/CHANGELOG with metas, all three asmdefs), no tooling/repo-internal files leak (tooling~, .github, .devcontainer, dotfiles, doc.md, agent pointers), and every shipped file carries its .meta with no orphan metas (directory targets inferred from file paths since npm tarballs omit dir entries). Root package.json gains an npm files allowlist so npm pack ships only the Unity package content - before this change the tarball shipped tooling~/, .devcontainer/, .github/ and dotfiles (red evidence: 100+ content errors). Green: 1440 entries checked, all content checks pass. Wired as an always-on Unity-free package-content CI job. --- .github/workflows/tooling-tests.yml | 20 ++ package.json | 17 ++ tooling~/package.json | 1 + .../release/validate-package-contents.mjs | 175 ++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 tooling~/scripts/release/validate-package-contents.mjs diff --git a/.github/workflows/tooling-tests.yml b/.github/workflows/tooling-tests.yml index 332c26ed..5c85d7f4 100644 --- a/.github/workflows/tooling-tests.yml +++ b/.github/workflows/tooling-tests.yml @@ -53,3 +53,23 @@ jobs: - name: Test tooling run: npm --prefix tooling~ test + + package-content: + # Clean-install guard for the UPM artifact (issue #22, PLAN.md T02 + # "package-content validators"): packs the package exactly like npm/UPM + # consumers receive it and asserts required artifacts ship, tooling and + # repo-internal files do not, and every shipped file carries its .meta. + # Unity-free: node stdlib + npm pack + system tar only. + name: package-content + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + + - name: Validate package contents + run: npm --prefix tooling~ run package:validate diff --git a/package.json b/package.json index 9c694e1a..dd2231d4 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,25 @@ "author": "wallstop studios (https://wallstopstudios.com)", "homepage": "https://github.com/wallstop/DxCommandTerminal/blob/master/README.md", "main": "README.md", + "files": [ + "Runtime", + "Editor", + "Packs", + "Styles", + "Tests", + "Media", + "Fonts", + "CHANGELOG.md", + "CHANGELOG.md.meta", + "LICENSE", + "LICENSE.meta", + "README.md", + "README.md.meta", + "package.json.meta" + ], "scripts": { "test": "npm --prefix tooling~ run test", + "package:validate": "npm --prefix tooling~ run package:validate", "unity:mcp:probe": "npm --prefix tooling~ run unity:mcp:probe", "unity:mcp:configure": "npm --prefix tooling~ run unity:mcp:configure", "unity:mcp:bridge": "npm --prefix tooling~ run unity:mcp:bridge", diff --git a/tooling~/package.json b/tooling~/package.json index 03c72929..3a2bfae9 100644 --- a/tooling~/package.json +++ b/tooling~/package.json @@ -7,6 +7,7 @@ }, "scripts": { "test": "node --test \"scripts/mcp/__tests__/*.test.mjs\"", + "package:validate": "node scripts/release/validate-package-contents.mjs", "unity:mcp:probe": "node scripts/mcp/unity-mcp.mjs probe", "unity:mcp:configure": "node scripts/mcp/unity-mcp.mjs configure", "unity:mcp:bridge": "node scripts/mcp/unity-mcp.mjs bridge", diff --git a/tooling~/scripts/release/validate-package-contents.mjs b/tooling~/scripts/release/validate-package-contents.mjs new file mode 100644 index 00000000..1c0de0cf --- /dev/null +++ b/tooling~/scripts/release/validate-package-contents.mjs @@ -0,0 +1,175 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); + +const REQUIRED_FILES = [ + "README.md", + "README.md.meta", + "LICENSE", + "LICENSE.meta", + "CHANGELOG.md", + "CHANGELOG.md.meta", + "package.json.meta", + "Runtime/WallstopStudios.DxCommandTerminal.asmdef", + "Runtime/WallstopStudios.DxCommandTerminal.asmdef.meta", + "Editor/WallstopStudios.DxCommandTerminal.Editor.asmdef", + "Editor/WallstopStudios.DxCommandTerminal.Editor.asmdef.meta", + "Tests/Runtime/WallstopStudios.DxCommandTerminal.Tests.Runtime.asmdef", + "Tests/Runtime/WallstopStudios.DxCommandTerminal.Tests.Runtime.asmdef.meta" +]; + +const FORBIDDEN_EXACT = new Set([ + "AGENTS.md", + "CLAUDE.md", + "doc.md", + ".cursorrules", + ".editorconfig", + ".gitattributes", + ".gitignore", + ".dockerignore", + ".pre-commit-config.yaml", + ".env.example", + "package-lock.json", + ".DS_Store" +]); + +const FORBIDDEN_PREFIXES = [ + "tooling~", + "node_modules", + "scripts", + ".git", + ".github", + ".llm", + ".devcontainer", + ".artifacts", + "progress", + ".env", + ".vscode", + ".codex", + ".cursor", + ".copilot", + ".nanocoder" +]; + +const NPM = process.platform === "win32" ? "npm.cmd" : "npm"; + +function fail(message) { + console.error(`[package-validate] ERROR: ${message}`); + process.exitCode = 1; +} + +function pack() { + const destination = fs.mkdtempSync(path.join(os.tmpdir(), "dxt-pack-")); + const stdout = execFileSync(NPM, ["pack", "--pack-destination", destination], { + cwd: REPO_ROOT, + encoding: "utf8" + }); + return path.join(destination, stdout.trim().split(/\r?\n/).at(-1)); +} + +function listTarball(tarball) { + const stdout = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" }); + return stdout + .split(/\r?\n/) + .filter(Boolean) + .map((entry) => entry.replace(/^package\//, "")) + .filter((entry) => entry.length > 0 && !entry.endsWith("/")); +} + +function extractFile(tarball, entry) { + return execFileSync("tar", ["-xzf", tarball, "-O", `package/${entry}`], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024 + }); +} + +function main() { + const rootManifest = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8") + ); + const tarball = pack(); + const entries = listTarball(tarball); + const entrySet = new Set(entries); + let problems = 0; + + const check = (ok, message) => { + if (!ok) { + fail(message); + problems += 1; + } + }; + + const shippedManifest = JSON.parse(extractFile(tarball, "package.json")); + check( + shippedManifest.name === rootManifest.name && shippedManifest.version === rootManifest.version, + `shipped manifest identity mismatch: expected ${rootManifest.name}@${rootManifest.version}, ` + + `got ${shippedManifest.name}@${shippedManifest.version}` + ); + + for (const required of REQUIRED_FILES) { + check(entrySet.has(required), `required file missing from tarball: ${required}`); + } + + for (const entry of entries) { + if (FORBIDDEN_EXACT.has(entry)) { + fail(`non-shippable file in tarball: ${entry}`); + problems += 1; + continue; + } + if (FORBIDDEN_PREFIXES.some((prefix) => entry.startsWith(`${prefix}/`))) { + fail(`non-shippable path in tarball: ${entry}`); + problems += 1; + } + } + + const shippedFiles = entries.filter((entry) => !entry.endsWith("/")); + const impliedDirectories = new Set(); + for (const file of shippedFiles) { + let current = path.dirname(file); + while (current && current !== "." && !impliedDirectories.has(current)) { + impliedDirectories.add(current); + current = path.dirname(current); + } + } + + for (const file of shippedFiles) { + if (file.endsWith(".meta")) { + const target = file.slice(0, -".meta".length); + check( + entrySet.has(target) || impliedDirectories.has(target), + `orphan meta in tarball: ${file}` + ); + } else if (file !== "package.json") { + check(entrySet.has(`${file}.meta`), `shipped file without meta: ${file}`); + } + } + + for (const asmdef of REQUIRED_FILES.filter((candidate) => candidate.endsWith(".asmdef"))) { + let parsed; + try { + parsed = JSON.parse(extractFile(tarball, asmdef)); + } catch (error) { + check(false, `asmdef is not valid JSON: ${asmdef} (${error.message})`); + continue; + } + check( + typeof parsed.name === "string" && 0 < parsed.name.length, + `asmdef missing a name: ${asmdef}` + ); + } + + console.log( + `[package-validate] ${path.basename(tarball)}\n` + + `[package-validate] ${entries.length} entries checked; ` + + `${problems === 0 ? "all content checks passed" : `${problems} problem(s) found`}` + ); + if (0 < problems) { + process.exitCode = 1; + } +} + +main(); From 48ea2572d360a96bafca65e0e73a8a7ebbc963da Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 22:35:38 +0000 Subject: [PATCH 7/8] Fix Bugbot findings: doubled tooling path, Dockerfile deps, folder metas - post-start.sh/post-create.sh/devcontainer.json pointed at tooling~/tooling~/scripts/mcp/unity-mcp.mjs (a duplicated sed substitution); post-start runs under set -e so container start would abort. Paths corrected. - The image Dockerfile baked /opt/dxt-mcp deps from the root package.json, which no longer carries devDependencies after the tooling~ relocation; COPY now sources tooling~/package.json and the 3-dependency length guard still passes. .dockerignore gains tooling~/node_modules. - npm files allowlist now includes the top-level folder metas (Runtime.meta, Editor.meta, ...), which npm pack drops when only directories are allowlisted; the validator now also asserts every shipped directory carries its folder meta (1447 entries, all checks pass). --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 2 +- .devcontainer/post-create.sh | 2 +- .devcontainer/post-start.sh | 2 +- .dockerignore | 1 + package.json | 7 +++++++ tooling~/scripts/release/validate-package-contents.mjs | 7 +++++++ 7 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f49261fd..32ba6975 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -123,7 +123,7 @@ RUN npm install --global --prefix /usr/local \ # MCP configuration must work before the workspace volume has dependencies. # jq extracts exactly the devDependencies the MCP script needs; the length guard # fails the build when the manifest drops a required package. -COPY package.json /tmp/dxt-tooling-package.json +COPY tooling~/package.json /tmp/dxt-tooling-package.json RUN mkdir -p /opt/dxt-mcp \ && jq '{private: true, dependencies: (.devDependencies | with_entries(select(.key as $k | ["@modelcontextprotocol/sdk", "smol-toml", "jsonc-parser"] | index($k))))}' /tmp/dxt-tooling-package.json > /opt/dxt-mcp/package.json \ && test "$(jq '.dependencies | length' /opt/dxt-mcp/package.json)" -eq 3 \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 174e4acd..dd575fbe 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ // Project: DxCommandTerminal | Unity UPM package (min 2021.3, host editor 6000.4) // The Unity Editor stays on the host; this container ships the .NET / Node / // PowerShell toolchain plus every agent CLI and MCP server, and drives the host -// editor through the unity-mcp bridge (tooling~/tooling~/scripts/mcp/unity-mcp.mjs). +// editor through the unity-mcp bridge (tooling~/scripts/mcp/unity-mcp.mjs). // ============================================================================= { "name": "DxCommandTerminal Dev", diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 0a79fd41..bab51879 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -106,7 +106,7 @@ EOF } configure_agent_mcps() { - local configure=(node "${WORKSPACE_DIR}/tooling~/tooling~/scripts/mcp/unity-mcp.mjs" configure --offline) + local configure=(node "${WORKSPACE_DIR}/tooling~/scripts/mcp/unity-mcp.mjs" configure --offline) if command -v flock >/dev/null 2>&1; then flock -w 180 "${MCP_CONFIGURE_LOCK}" "${configure[@]}" return diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index cab9ad2f..88b6dae4 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -10,7 +10,7 @@ export NPM_CONFIG_PREFIX="${HOME}/.local" # Configuration is local and works with the image's baked dependencies before npm # install. Finish it before clients start, without probing a sleeping host. -mcp_script="${SCRIPT_DIR}/../tooling~/tooling~/scripts/mcp/unity-mcp.mjs" +mcp_script="${SCRIPT_DIR}/../tooling~/scripts/mcp/unity-mcp.mjs" mcp_lock="${TMPDIR:-/tmp}/dxt-mcp-configure.lock" if command -v flock >/dev/null 2>&1; then flock -w 30 "${mcp_lock}" node "${mcp_script}" configure --offline diff --git a/.dockerignore b/.dockerignore index 3089c7c2..292bcd5e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,3 +21,4 @@ Editor Tests .llm .qodo +tooling~/node_modules diff --git a/package.json b/package.json index dd2231d4..c5b23336 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,19 @@ "main": "README.md", "files": [ "Runtime", + "Runtime.meta", "Editor", + "Editor.meta", "Packs", + "Packs.meta", "Styles", + "Styles.meta", "Tests", + "Tests.meta", "Media", + "Media.meta", "Fonts", + "Fonts.meta", "CHANGELOG.md", "CHANGELOG.md.meta", "LICENSE", diff --git a/tooling~/scripts/release/validate-package-contents.mjs b/tooling~/scripts/release/validate-package-contents.mjs index 1c0de0cf..4aa22030 100644 --- a/tooling~/scripts/release/validate-package-contents.mjs +++ b/tooling~/scripts/release/validate-package-contents.mjs @@ -136,6 +136,13 @@ function main() { } } + for (const directory of impliedDirectories) { + check( + entrySet.has(`${directory}.meta`), + `shipped directory without its folder meta: ${directory}` + ); + } + for (const file of shippedFiles) { if (file.endsWith(".meta")) { const target = file.slice(0, -".meta".length); From abb8fc8be1a97b166dfda75f76576a3fc113b127 Mon Sep 17 00:00:00 2001 From: wallstop Date: Wed, 9 Sep 2026 22:58:18 +0000 Subject: [PATCH 8/8] Adversarial review fixes: flag forwarding, gitattributes, tarball set-equality - Root delegating npm scripts now end in a trailing '--' so user flags reach the inner script verbatim. Without it, the inner npm consumed flag names as its own config (verified: 'npm run unity:mcp:probe -- --project /p' delivered [] to the script; now forwards ['--project','/p']). - .gitattributes: scripts/** eol=lf rule updated to tooling~/** so Windows autocrlf=true checkouts keep tooling (incl. bash fixtures) LF. - Validator now compares tarball entries against git ls-files filtered by the allowlist: a tracked file missing from the tarball, or an untracked file shipping, both fail (previously only required files were pinned, so subtree loss would pass). - package.json trailing blank lines removed. --- .gitattributes | 2 +- package.json | 75 +++++++++---------- .../release/validate-package-contents.mjs | 32 ++++++++ 3 files changed, 67 insertions(+), 42 deletions(-) diff --git a/.gitattributes b/.gitattributes index 86f2c7f4..4de1f557 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,7 +2,7 @@ # Matches the .editorconfig overrides; keeps generated content and shell # scripts intact on CRLF-configured checkouts (autocrlf=true). .llm/** text eol=lf -scripts/** text eol=lf +tooling~/** text eol=lf AGENTS.md text eol=lf CLAUDE.md text eol=lf .cursorrules text eol=lf diff --git a/package.json b/package.json index c5b23336..bfda0824 100644 --- a/package.json +++ b/package.json @@ -25,45 +25,38 @@ }, "author": "wallstop studios (https://wallstopstudios.com)", "homepage": "https://github.com/wallstop/DxCommandTerminal/blob/master/README.md", - "main": "README.md", - "files": [ - "Runtime", - "Runtime.meta", - "Editor", - "Editor.meta", - "Packs", - "Packs.meta", - "Styles", - "Styles.meta", - "Tests", - "Tests.meta", - "Media", - "Media.meta", - "Fonts", - "Fonts.meta", - "CHANGELOG.md", - "CHANGELOG.md.meta", - "LICENSE", - "LICENSE.meta", - "README.md", - "README.md.meta", - "package.json.meta" - ], - "scripts": { - "test": "npm --prefix tooling~ run test", - "package:validate": "npm --prefix tooling~ run package:validate", - "unity:mcp:probe": "npm --prefix tooling~ run unity:mcp:probe", - "unity:mcp:configure": "npm --prefix tooling~ run unity:mcp:configure", - "unity:mcp:bridge": "npm --prefix tooling~ run unity:mcp:bridge", - "unity:mcp:install-capture": "npm --prefix tooling~ run unity:mcp:install-capture", - "unity:capture": "npm --prefix tooling~ run unity:capture", - "ai:backends": "npm --prefix tooling~ run ai:backends" - } + "main": "README.md", + "files": [ + "Runtime", + "Runtime.meta", + "Editor", + "Editor.meta", + "Packs", + "Packs.meta", + "Styles", + "Styles.meta", + "Tests", + "Tests.meta", + "Media", + "Media.meta", + "Fonts", + "Fonts.meta", + "CHANGELOG.md", + "CHANGELOG.md.meta", + "LICENSE", + "LICENSE.meta", + "README.md", + "README.md.meta", + "package.json.meta" + ], + "scripts": { + "test": "npm --prefix tooling~ run test --", + "package:validate": "npm --prefix tooling~ run package:validate --", + "unity:mcp:probe": "npm --prefix tooling~ run unity:mcp:probe --", + "unity:mcp:configure": "npm --prefix tooling~ run unity:mcp:configure --", + "unity:mcp:bridge": "npm --prefix tooling~ run unity:mcp:bridge --", + "unity:mcp:install-capture": "npm --prefix tooling~ run unity:mcp:install-capture --", + "unity:capture": "npm --prefix tooling~ run unity:capture --", + "ai:backends": "npm --prefix tooling~ run ai:backends --" + } } - - - - - - - diff --git a/tooling~/scripts/release/validate-package-contents.mjs b/tooling~/scripts/release/validate-package-contents.mjs index 4aa22030..ff2181b9 100644 --- a/tooling~/scripts/release/validate-package-contents.mjs +++ b/tooling~/scripts/release/validate-package-contents.mjs @@ -87,6 +87,11 @@ function extractFile(tarball, entry) { }); } +function trackedFiles() { + const stdout = execFileSync("git", ["ls-files"], { cwd: REPO_ROOT, encoding: "utf8" }); + return new Set(stdout.split(/\r?\n/).filter(Boolean)); +} + function main() { const rootManifest = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8") @@ -110,6 +115,33 @@ function main() { `got ${shippedManifest.name}@${shippedManifest.version}` ); + const allowlist = rootManifest.files; + check( + Array.isArray(allowlist) && 0 < allowlist.length, + "package.json must declare an npm files allowlist" + ); + if (Array.isArray(allowlist)) { + const tracked = trackedFiles(); + const expected = new Set(["package.json"]); + for (const entry of tracked) { + if (allowlist.some((root) => entry === root || entry.startsWith(`${root}/`))) { + expected.add(entry); + } + } + for (const missing of expected) { + if (!entrySet.has(missing)) { + fail(`tracked file missing from tarball: ${missing}`); + problems += 1; + } + } + for (const extra of entrySet) { + if (!expected.has(extra)) { + fail(`untracked or unallowed file shipped in tarball: ${extra}`); + problems += 1; + } + } + } + for (const required of REQUIRED_FILES) { check(entrySet.has(required), `required file missing from tarball: ${required}`); }