diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 5644a35..45849f3 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -522,6 +522,14 @@ priorities. equality, hashing, `ToString()`, serialization, and `Clauses` migration behavior in the guide and release notes; direct the README quick start to `Commands` and the full guide. +- [x] Close the v0.3 public-API compatibility gate. Existing reflection + snapshots pin the exact exported types, members, enum ordering, + reference nullability, defaults, parser constructors and entry points, + and fixed limits against the shared and PowerShell specifications. + Additional tests pin generated equality and `ToString()` participation + plus equal-record hash consistency, demonstrate that default JSON is not + a polymorphic round-trip contract, and make every policy-sensitive + unknown numeric enum value detectable so consumers can reject it. - [ ] Build on the delivered bounded Bash heredoc grammar and quoted-delimiter adjacency by exposing public body/delimiter/expansion/completeness facts, then add a separately tested Bash `<<<` here-string redirect slice. diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index f697f7a..bd6339d 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -250,11 +250,20 @@ ## 11. Verification and Release -- [ ] 11.1 Add public API default-value, equality, serialization, and unknown-enum compatibility tests. +- [x] 11.1 Add public API default-value, equality, serialization, and unknown-enum compatibility tests. + - `V03PublicApiSnapshotTests` pins every additive record default and enum + zero value, proves `Syntax` and `Commands` participate in generated record + equality and `ToString()` plus equal-record hash consistency, demonstrates + that default JSON is not a polymorphic round-trip contract, and makes every + policy-sensitive unknown numeric enum value detectable for consumer rejection. - [ ] 11.2 Assert every supported executable region appears exactly once and every unsupported executable region makes the result unparseable. - [x] 11.3 Run the complete Bash and PowerShell corpus suites plus the PII audit. - [x] 11.4 Run `dotnet build -c Release`, `dotnet test -c Release`, `dotnet pack -c Release`, and header verification. -- [ ] 11.5 Validate the public API field-for-field against the synchronized shared and PowerShell specifications. +- [x] 11.5 Validate the public API field-for-field against the synchronized shared and PowerShell specifications. + - `PublicApiSnapshotTests` and `V03PublicApiSnapshotTests` enumerate the exact + exported namespace, type family, exact property sets, parser constructors + and entry points, enum ordering, reference nullability, defaults, and fixed + limits synchronized into `SPEC.md` and `SPEC.POWERSHELL.md`. - [ ] 11.6 Validate Netclaw's ordinary-command, redirect, bounded-loop, and unknown-value approval matrices against the prerelease package. - [x] 11.7 Update release notes and remove Netclaw's temporary descriptor workaround only after explicit redirect integration is live. - The `0.3.0-alpha` release notes document the explicit redirect model. The diff --git a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs index adbee47..cd844c0 100644 --- a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs @@ -36,6 +36,8 @@ public void IShellParser_has_expected_shape() var parameters = parse.GetParameters(); Assert.Single(parameters); Assert.Equal(typeof(string), parameters[0].ParameterType); + AssertReferenceNullability(parse.ReturnParameter, NullabilityState.NotNull); + AssertReferenceNullability(parameters[0], NullabilityState.NotNull); } // -------- BashParser -------- @@ -61,6 +63,14 @@ public void BashParser_has_expected_shape() var parse = t.GetMethod(nameof(BashParser.Parse), new[] { typeof(string) }); Assert.NotNull(parse); Assert.Equal(typeof(ParsedCommand), parse!.ReturnType); + Assert.Equal( + new[] { nameof(BashParser.Parse) }, + DeclaredPublicMethodNames(t)); + AssertReferenceNullability(parse.ReturnParameter, NullabilityState.NotNull); + AssertReferenceNullability( + Assert.Single(parse.GetParameters()), + NullabilityState.NotNull); + AssertReferenceNullability(withOptions[0], NullabilityState.NotNull); } [Fact] @@ -123,6 +133,14 @@ public void PwshParser_has_expected_shape() var parse = t.GetMethod(nameof(PwshParser.Parse), new[] { typeof(string) }); Assert.NotNull(parse); Assert.Equal(typeof(ParsedCommand), parse!.ReturnType); + Assert.Equal( + new[] { nameof(PwshParser.Parse) }, + DeclaredPublicMethodNames(t)); + AssertReferenceNullability(parse.ReturnParameter, NullabilityState.NotNull); + AssertReferenceNullability( + Assert.Single(parse.GetParameters()), + NullabilityState.NotNull); + AssertReferenceNullability(withOptions[0], NullabilityState.NotNull); } [Fact] @@ -304,6 +322,15 @@ public void Clause_has_expected_shape() AssertInitProperty(t, "Elements", typeof(IReadOnlyList)); AssertInitProperty(t, "IsSubshell", typeof(bool)); AssertInitProperty(t, "IsCommandStringWrapped", typeof(bool)); + AssertDeclaredPropertyNames( + t, + "Args", + "Elements", + "IsCommandStringWrapped", + "IsSubshell", + "Operator", + "Redirects", + "Verb"); var instance = new Clause(); Assert.Equal(CompoundOperator.None, instance.Operator); @@ -336,6 +363,18 @@ public void ClauseElement_has_expected_shape() AssertInitProperty(t, "IsFlag", typeof(bool)); AssertInitProperty(t, "IsPath", typeof(bool)); AssertInitProperty(t, "Resolved", typeof(string), nullable: true); + AssertDeclaredPropertyNames( + t, + "IsFlag", + "IsPath", + "Kind", + "PrecedingVerbElementCount", + "Raw", + "Resolved", + "Role", + "SourceLength", + "SourceStart", + "Value"); var instance = new ClauseElement(); Assert.Equal("", instance.Raw); @@ -370,6 +409,8 @@ public void VerbChain_has_expected_shape() Assert.Equal(typeof(string), joined!.PropertyType); Assert.True(joined.CanRead); Assert.False(joined.CanWrite); + AssertReferenceNullability(joined, NullabilityState.NotNull); + AssertDeclaredPropertyNames(t, "CanonicalVerb", "IsDynamic", "Joined", "Tokens"); var instance = new VerbChain { Tokens = new[] { "git", "push" } }; Assert.Equal("git push", instance.Joined); @@ -403,6 +444,14 @@ public void Arg_has_expected_shape() Assert.Equal(typeof(bool), isFlag!.PropertyType); Assert.True(isFlag.CanRead); Assert.False(isFlag.CanWrite); + AssertDeclaredPropertyNames( + t, + "IsCwdAttribution", + "IsFlag", + "IsPath", + "Kind", + "Raw", + "Resolved"); Assert.True(new Arg { Raw = "-f" }.IsFlag); Assert.True(new Arg { Raw = "--force" }.IsFlag); @@ -434,6 +483,7 @@ public void Redirect_has_expected_shape() AssertInitProperty(t, "Direction", typeof(RedirectDirection)); AssertInitProperty(t, "Target", typeof(string)); AssertInitProperty(t, "IsDynamicSkip", typeof(bool)); + AssertDeclaredPropertyNames(t, "Direction", "IsDynamicSkip", "Target"); var instance = new Redirect(); Assert.Equal(RedirectDirection.In, instance.Direction); @@ -589,6 +639,22 @@ public void Library_assembly_exports_no_other_namespaces() private static IEnumerable DeclaredInstanceProps(Type t) => t.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + private static string[] DeclaredPublicMethodNames(Type type) => + type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Select(method => method.Name) + .OrderBy(name => name) + .ToArray(); + + private static void AssertDeclaredPropertyNames(Type type, params string[] expected) + { + Assert.Equal( + expected.OrderBy(name => name), + DeclaredInstanceProps(type) + .Where(property => property.Name != "EqualityContract") + .Select(property => property.Name) + .OrderBy(name => name)); + } + private static void AssertIsRecord(Type t) { // Records emit a compiler-generated $ method and an @@ -620,8 +686,46 @@ private static void AssertInitProperty(Type t, string name, Type expectedType, b var modreqs = setter!.ReturnParameter.GetRequiredCustomModifiers(); Assert.Contains(modreqs, m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit"); - // Suppress unused-parameter warning; nullability metadata isn't queried at runtime - // without NullabilityInfoContext (net6+) and our purpose here is shape, not nullability. - _ = nullable; + if (!prop.PropertyType.IsValueType) + { + var expectedNullability = nullable + ? NullabilityState.Nullable + : NullabilityState.NotNull; + AssertReferenceNullability(prop, expectedNullability); + } + } + + private static void AssertReferenceNullability( + PropertyInfo property, + NullabilityState expected) + { + var info = new NullabilityInfoContext().Create(property); + Assert.Equal(expected, info.ReadState); + if (property.CanWrite) + { + Assert.Equal(expected, info.WriteState); + } + + AssertGenericArgumentsNotNull(property.Name, info); + } + + private static void AssertReferenceNullability( + ParameterInfo parameter, + NullabilityState expected) + { + var info = new NullabilityInfoContext().Create(parameter); + Assert.Equal(expected, info.ReadState); + AssertGenericArgumentsNotNull(parameter.Name ?? "return", info); + } + + private static void AssertGenericArgumentsNotNull( + string member, + NullabilityInfo info) + { + foreach (var argument in info.GenericTypeArguments) + { + Assert.Equal(NullabilityState.NotNull, argument.ReadState); + AssertGenericArgumentsNotNull(member, argument); + } } } diff --git a/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs index 513d80b..59967e2 100644 --- a/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text.Json; using Xunit; namespace ShellSyntaxTree.Tests; @@ -461,6 +462,121 @@ public void Analysis_limits_are_static_get_only_and_match_the_locked_values() } } + [Fact] + public void Parsed_command_new_members_participate_in_generated_record_behavior() + { + var clauses = Array.Empty(); + var commands = Array.Empty(); + var syntax = new ShellBlockSyntax(); + var original = new ParsedCommand + { + Source = "echo ok", + Syntax = syntax, + Commands = commands, + Clauses = clauses, + }; + var equalCopy = original with { }; + + Assert.Equal(original, equalCopy); + Assert.Equal(original.GetHashCode(), equalCopy.GetHashCode()); + Assert.Contains($"{nameof(ParsedCommand.Syntax)} =", original.ToString()); + Assert.Contains($"{nameof(ParsedCommand.Commands)} =", original.ToString()); + + Assert.NotEqual( + original, + original with + { + Syntax = syntax with { SourceStart = 0, SourceLength = 7 }, + }); + Assert.NotEqual( + original, + original with + { + Commands = new[] { new CommandOccurrence() }, + }); + } + + [Fact] + public void Default_json_is_not_a_polymorphic_parser_result_round_trip_contract() + { + var clause = new Clause + { + Verb = new VerbChain { Tokens = new[] { "echo" } }, + }; + var parsed = new ParsedCommand + { + Source = "echo ok", + Syntax = new ShellBlockSyntax + { + Statements = new ShellSyntaxNode[] + { + new SimpleCommandSyntax { Clause = clause }, + }, + }, + Commands = new[] + { + new CommandOccurrence + { + Clause = clause, + ImmediateRole = CommandOccurrenceRole.Ordinary, + IsComplete = true, + }, + }, + Clauses = new[] { clause }, + }; + + var json = JsonSerializer.Serialize(parsed); + + Assert.Contains($"\"{nameof(ParsedCommand.Syntax)}\"", json); + Assert.Contains($"\"{nameof(ParsedCommand.Commands)}\"", json); + Assert.Contains($"\"{nameof(ParsedCommand.Clauses)}\"", json); + Assert.Throws( + () => JsonSerializer.Deserialize(json)); + var syntaxTypes = typeof(ShellSyntaxNode).Assembly + .GetExportedTypes() + .Where(type => typeof(ShellSyntaxNode).IsAssignableFrom(type)); + Assert.All( + syntaxTypes, + type => Assert.DoesNotContain( + type.CustomAttributes, + attribute => attribute.AttributeType.Namespace == + "System.Text.Json.Serialization")); + Assert.DoesNotContain( + typeof(ShellSyntaxNode).Assembly.GetReferencedAssemblies(), + assembly => assembly.Name == "System.Text.Json"); + } + + [Fact] + public void Unknown_numeric_enum_values_remain_detectable_for_consumer_rejection() + { + const int unknownValue = 999; + var policySensitiveEnums = new[] + { + typeof(BashInitialStateMode), + typeof(PwshInitialStateMode), + typeof(ShellSyntaxKind), + typeof(ShellGroupKind), + typeof(ConditionLoopKind), + typeof(ExecutionRegionOrigin), + typeof(ExecutionRegionPhase), + typeof(ExecutionRegionTiming), + typeof(ExecutionRegionCardinality), + typeof(CommandOccurrenceRole), + typeof(CommandAncestryRegion), + typeof(ShellValueDomainKind), + typeof(HereDocumentExpansionMode), + typeof(RedirectSourceKind), + typeof(RedirectOperation), + }; + + foreach (var enumType in policySensitiveEnums) + { + var value = Enum.ToObject(enumType, unknownValue); + Assert.Equal(unknownValue, Convert.ToInt32(value)); + Assert.False(Enum.IsDefined(enumType, value)); + } + } + private static void AssertNode( ShellSyntaxNode instance, ShellSyntaxKind expectedKind,