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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions openspec/changes/v0-3-structured-shell-analysis/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 107 additions & 3 deletions tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -304,6 +322,15 @@ public void Clause_has_expected_shape()
AssertInitProperty(t, "Elements", typeof(IReadOnlyList<ClauseElement>));
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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -589,6 +639,22 @@ public void Library_assembly_exports_no_other_namespaces()
private static IEnumerable<PropertyInfo> 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 <Clone>$ method and an
Expand Down Expand Up @@ -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);
}
}
}
116 changes: 116 additions & 0 deletions tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using Xunit;

namespace ShellSyntaxTree.Tests;
Expand Down Expand Up @@ -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<Clause>();
var commands = Array.Empty<CommandOccurrence>();
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<NotSupportedException>(
() => JsonSerializer.Deserialize<ParsedCommand>(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,
Expand Down