diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 135a1bf..50d8122 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -249,6 +249,15 @@ priorities. shared leaf identity. It rejects hostile structural depth before descent and leaves redirects, dynamic identities, unproved host command strings, and undiscovered executable expressions incomplete. +- [x] Promote representative existing constructs into the executable corpus + with exact v0.3 syntax and command-occurrence expectations. Bash and + PowerShell cases pin simple commands, list/pipeline precedence, group + scope, static and dynamic wrappers, redirects, hidden substitutions, + roles, ancestry, completeness, nullable decoded spans, compatibility + operators, and exact shared `Clause` identity. The strict DTO rejects + unknown fields and always requires unparseable projections to be empty. + The PowerShell manifest now owns all 310 entries and round-trips exactly; + explicit false/null assertions remain opt-in and generator-preserved. - [ ] Deliver paired Bash and PowerShell `$()` substitution slices for all locked executable value positions, including ordering, ancestry, shell-specific cwd propagation, literal/escaped boundaries, dynamic @@ -272,9 +281,6 @@ priorities. - Seed corpus entries from sanitized real-world dogfood logs (SPEC §14 workflow) — both shells. -- Synchronize PowerShell corpus manifest entries 282–309 before using the - generator as an all-corpus rewrite; until then, targeted manifest changes - must preserve the checked-in hand-authored tail. - Expand verb / cmdlet / alias tables as the corpus surfaces real commands. - Performance sanity check (~1 ms typical) with a tiny BenchmarkDotNet harness — only if anything in the daemon hot path complains. diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index c62736e..c2fb86d 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -1381,6 +1381,13 @@ The shared corpus DTO gains two optional fields: `Invoke-Expression`, an over-cap input (§11), or a recursion-depth overflow — because real `pwsh` parses the *outer* invocation without error. +The shared v0.3 `syntax` and `commands` expectations defined in `SPEC.md` §13 +apply unchanged. Selected PowerShell entries SHALL pin current-scope groups, +pipeline/list precedence, exact direct spans, null decoded-wrapper spans, +command roles, ancestry, completeness, and exact compatibility-leaf identity. +The manifest marks these entries explicitly so regeneration preserves the +structural assertions; entries without the marker retain the legacy schema. + ### Coverage targets for v0.2.0 | Category | Min | diff --git a/SPEC.md b/SPEC.md index 2d1d3dc..662f1e1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1964,6 +1964,26 @@ An entry may add an `elements` list to a clause to pin the complete `resolved`). The field is opt-in so older corpus entries remain readable; issue-specific provenance entries SHALL include it. +An entry may also add both of the following v0.3 structural expectations: + +- `syntax` is the complete `ParsedCommand.Syntax` tree flattened in preorder. + Each item records `kind`, `parentIndex`, the incoming ancestry `region` and + `childIndex`, exact-or-null `sourceStart` / `sourceLength`, and the + kind-specific `clauseIndex`, `groupKind`, or `listOperator`. `clauseIndex` + identifies the exact compatibility `Clause` instance owned by a + `SimpleCommand` node; it is not a copied value comparison. +- `commands` is the complete `ParsedCommand.Commands` projection in authored + order. Each item records its `clauseIndex`, `immediateRole`, `isComplete`, + and outermost-to-innermost `ancestry` frames. Each frame records + `ancestorKind`, `region`, `childIndex`, and exact-or-null source range. + +These fields are independently opt-in so legacy corpus entries retain their +v0.2 shape; structural acceptance cases normally provide both. When present, +the runner compares every node, relationship, range, occurrence, role, +completeness bit, ancestry frame, and `Clause` reference. Unknown JSON members +are rejected. An unparseable result always asserts empty `Clauses` and +`Commands`, even when those arrays are omitted from the JSON. + The corpus runner also lexes every direct, parseable input and verifies that each authored verb, argument, opaque region, and redirect token is covered by exactly-positioned clause-element provenance. This invariant applies even when diff --git a/TOOLING.md b/TOOLING.md index 70abd2f..626274d 100644 --- a/TOOLING.md +++ b/TOOLING.md @@ -49,7 +49,13 @@ and parser slice exists. The PII audit scans both corpus trees. The curated inputs live in `tools/PwshCorpusTool/CorpusManifest.cs`; the `expected` AST is generated from `PwshParser`, and `PwshOracleTests` -independently validates every input against real `pwsh`. +independently validates every input against real `pwsh`. The manifest owns +every checked-in PowerShell entry. `IncludeStructure` opts selected entries +into exact `syntax` / `commands` expectations, while +`IncludeOptionalAssertions` preserves explicit false/null security assertions +for cases that require them. A safe generator change MUST regenerate to a +temporary directory and produce an exact directory diff before rewriting the +checked-in corpus. ## Source Control and CI diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md index 3915855..c9302b5 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md @@ -15,6 +15,12 @@ of whether the command is top-level or nested. - **THEN** the authored `echo` command appears once - **THEN** its possible effective values are represented by analysis facts rather than three duplicated occurrences +#### Scenario: Executable corpus pins all public projections +- **WHEN** a selected Bash or PowerShell corpus entry is marked for structural verification +- **THEN** the corpus records the complete syntax tree and command-occurrence collection in authored order +- **THEN** every simple-command node and occurrence references the exact compatibility clause by index and object identity +- **THEN** roles, completeness, ancestry coordinates, and exact-or-null source ranges are compared without weakening legacy corpus entries that omit structural expectations + ### Requirement: Occurrences identify structural execution roles Each command occurrence SHALL identify its immediate structural execution role and SHALL retain compositional ancestry for analysis, diagnostics, and UI diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 5615170..c6d032a 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -30,7 +30,7 @@ - [x] 3.6 Adapt the existing Bash grammar to emit the structural model with no newly supported syntax. - [x] 3.7 Adapt the existing PowerShell grammar to emit the structural model with no newly supported syntax. - [x] 3.8 Add tests proving existing parser inputs retain their v0.2 leaf and compatibility results, except for explicitly promoted v0.3 fail-closed cases. -- [ ] 3.9 Add corpus expectations for syntax shape, occurrences, roles, and completeness for existing constructs. +- [x] 3.9 Add corpus expectations for syntax shape, occurrences, roles, and completeness for existing constructs. - [ ] 3.10 Implement Bash `$()` discovery in supported argument words, redirect values, iterables, and expanding heredoc bodies; retain literal/escaped spellings and fail closed on command-name substitutions, legacy backticks, or incomplete interiors. - [ ] 3.11 Implement PowerShell `$()` discovery in supported words, redirect values, foreach expressions, call-operator dynamic identities, standalone expression statements, double-quoted strings, and expandable here-strings; never invent invocation from standalone output, retain literal/escaped spellings, and fail closed on trailing command-style arguments, call-operator script blocks, or unsupported execution-bearing `@()` / `@{}` forms. - [ ] 3.12 Pin substitution parentage, authored sibling indices, innermost-first ordering, Bash-isolated versus PowerShell-current-scope state, unknown-state propagation, nesting/depth limits, and incomplete dynamic identities in direct tests. diff --git a/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs index e03a80d..9b57622 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs @@ -97,6 +97,12 @@ internal static void Equal( } } + if (actual.Commands.Count != 0 || actual.Clauses.Count != 0) + { + throw new XunitException( + prefix + "unparseable result retained command or compatibility projections"); + } + return; } @@ -113,8 +119,479 @@ internal static void Equal( { AssertClauseEqual(expectedClauses[i], actual.Clauses[i], $"{prefix}clauses[{i}]"); } + + if (expected.Syntax is not null) + { + AssertSyntaxEqual(expected.Syntax, actual, prefix); + } + + if (expected.Commands is not null) + { + AssertCommandsEqual(expected.Commands, actual, prefix); + } } + private static void AssertSyntaxEqual( + IReadOnlyList expected, + ParsedCommand actual, + string prefix) + { + ValidateExpectedSyntax(expected, prefix); + var flattened = new List(); + AppendSyntax( + actual.Syntax, + parentIndex: null, + CommandAncestryRegion.Unknown, + childIndex: null, + listOperator: null, + actual.Clauses, + flattened, + isRootBlock: true); + if (expected.Count != flattened.Count) + { + throw new XunitException( + prefix + $"syntax.count: expected={expected.Count}, actual={flattened.Count}"); + } + + for (var index = 0; index < expected.Count; index++) + { + var wanted = expected[index]; + var observed = flattened[index]; + if (wanted.Kind != observed.Kind || + wanted.ParentIndex != observed.ParentIndex || + wanted.Region != observed.Region || + wanted.ChildIndex != observed.ChildIndex || + wanted.SourceStart != observed.SourceStart || + wanted.SourceLength != observed.SourceLength || + wanted.ClauseIndex != observed.ClauseIndex || + wanted.GroupKind != observed.GroupKind || + wanted.ListOperator != observed.ListOperator) + { + throw new XunitException( + prefix + $"syntax[{index}]: expected={Summarize(wanted)}, " + + $"actual={Summarize(observed)}"); + } + + if (observed.Clause is not null && + (wanted.ClauseIndex is null || + wanted.ClauseIndex < 0 || + wanted.ClauseIndex >= actual.Clauses.Count || + !object.ReferenceEquals( + observed.Clause, + actual.Clauses[wanted.ClauseIndex.Value]))) + { + throw new XunitException( + prefix + $"syntax[{index}].clauseIndex does not reference the exact compatibility Clause"); + } + } + } + + private static void ValidateExpectedSyntax( + IReadOnlyList expected, + string prefix) + { + if (expected.Count == 0 || + expected[0].Kind != ShellSyntaxKind.Block || + expected[0].ParentIndex is not null || + expected[0].Region != CommandAncestryRegion.Unknown || + expected[0].ChildIndex is not null || + expected[0].ListOperator is not null) + { + throw new XunitException(prefix + "syntax[0] must be the root block"); + } + + for (var index = 0; index < expected.Count; index++) + { + var node = expected[index]; + if (node.Kind == ShellSyntaxKind.Unknown || + (node.Kind == ShellSyntaxKind.SimpleCommand) != node.ClauseIndex.HasValue || + (node.Kind == ShellSyntaxKind.Group) != node.GroupKind.HasValue) + { + throw new XunitException( + prefix + $"syntax[{index}] has an invalid kind-specific field"); + } + + if (node.SourceStart.HasValue != node.SourceLength.HasValue || + node.SourceStart < 0 || + node.SourceLength < 0 || + node.ChildIndex < 0) + { + throw new XunitException( + prefix + $"syntax[{index}] has an invalid coordinate"); + } + + if (index == 0) + { + continue; + } + + if (node.ParentIndex is null || + node.ParentIndex < 0 || + node.ParentIndex >= index) + { + throw new XunitException( + prefix + $"syntax[{index}].parentIndex must name an earlier node"); + } + + var parent = expected[node.ParentIndex.Value]; + var relationshipIsValid = parent.Kind switch + { + ShellSyntaxKind.Block => + node.Region == (node.ParentIndex == 0 + ? CommandAncestryRegion.Root + : CommandAncestryRegion.Statement) && + node.ChildIndex.HasValue, + ShellSyntaxKind.SimpleCommand => + node.Region == CommandAncestryRegion.Substitution && + node.ChildIndex.HasValue, + ShellSyntaxKind.Pipeline => + node.Region == CommandAncestryRegion.PipelineStage && + node.ChildIndex.HasValue, + ShellSyntaxKind.CommandList => + node.Region == CommandAncestryRegion.Statement && + node.ChildIndex.HasValue && + node.ListOperator.HasValue, + ShellSyntaxKind.Group => + node.Region == CommandAncestryRegion.GroupBody && + node.ChildIndex is null, + ShellSyntaxKind.ForEach => + (node.Region is CommandAncestryRegion.Iterator or + CommandAncestryRegion.LoopBody) && + node.ChildIndex is null, + ShellSyntaxKind.ConditionLoop => + (node.Region is CommandAncestryRegion.Condition or + CommandAncestryRegion.LoopBody) && + node.ChildIndex is null, + ShellSyntaxKind.Conditional => + node.Region == CommandAncestryRegion.Branch && + node.ChildIndex.HasValue, + ShellSyntaxKind.ConditionalBranch => + (node.Region is CommandAncestryRegion.Condition or + CommandAncestryRegion.Branch) && + node.ChildIndex is null, + ShellSyntaxKind.CommandSubstitution => + node.Region == CommandAncestryRegion.Substitution, + _ => false, + }; + if (!relationshipIsValid || + parent.Kind != ShellSyntaxKind.CommandList && node.ListOperator.HasValue) + { + throw new XunitException( + prefix + $"syntax[{index}] has an invalid parent relationship"); + } + } + } + + private static void AssertCommandsEqual( + IReadOnlyList expected, + ParsedCommand actual, + string prefix) + { + if (expected.Count != actual.Commands.Count) + { + throw new XunitException( + prefix + $"commands.count: expected={expected.Count}, actual={actual.Commands.Count}"); + } + + for (var index = 0; index < expected.Count; index++) + { + var wanted = expected[index]; + var observed = actual.Commands[index]; + if (wanted.ImmediateRole == CommandOccurrenceRole.Unknown || + observed.ImmediateRole == CommandOccurrenceRole.Unknown) + { + throw new XunitException( + prefix + $"commands[{index}].immediateRole must be known"); + } + + if (wanted.ClauseIndex < 0 || wanted.ClauseIndex >= actual.Clauses.Count) + { + throw new XunitException( + prefix + $"commands[{index}].clauseIndex is outside Clauses"); + } + + if (!object.ReferenceEquals( + observed.Clause, + actual.Clauses[wanted.ClauseIndex])) + { + throw new XunitException( + prefix + $"commands[{index}].clauseIndex does not reference the exact compatibility Clause"); + } + + if (wanted.ImmediateRole != observed.ImmediateRole || + wanted.IsComplete != observed.IsComplete) + { + throw new XunitException( + prefix + $"commands[{index}]: expected role={wanted.ImmediateRole}, complete={wanted.IsComplete}; " + + $"actual role={observed.ImmediateRole}, complete={observed.IsComplete}"); + } + + var expectedFrames = wanted.Ancestry ?? new List(); + if (expectedFrames.Count != observed.Ancestry.Count) + { + throw new XunitException( + prefix + $"commands[{index}].ancestry.count: " + + $"expected={expectedFrames.Count}, actual={observed.Ancestry.Count}"); + } + + for (var frameIndex = 0; frameIndex < expectedFrames.Count; frameIndex++) + { + var expectedFrame = expectedFrames[frameIndex]; + var actualFrame = observed.Ancestry[frameIndex]; + if (expectedFrame.AncestorKind == ShellSyntaxKind.Unknown || + actualFrame.AncestorKind == ShellSyntaxKind.Unknown || + expectedFrame.Region == CommandAncestryRegion.Unknown || + actualFrame.Region == CommandAncestryRegion.Unknown) + { + throw new XunitException( + prefix + $"commands[{index}].ancestry[{frameIndex}] contains an unknown enum"); + } + + if (expectedFrame.AncestorKind != actualFrame.AncestorKind || + expectedFrame.Region != actualFrame.Region || + expectedFrame.ChildIndex != actualFrame.ChildIndex || + expectedFrame.SourceStart != actualFrame.SourceStart || + expectedFrame.SourceLength != actualFrame.SourceLength) + { + throw new XunitException( + prefix + $"commands[{index}].ancestry[{frameIndex}] differs"); + } + } + } + } + + private static void AppendSyntax( + ShellSyntaxNode node, + int? parentIndex, + CommandAncestryRegion region, + int? childIndex, + CompoundOperator? listOperator, + IReadOnlyList clauses, + List nodes, + bool isRootBlock = false) + { + if (node.Kind == ShellSyntaxKind.Unknown) + { + throw new XunitException("Cannot flatten an unknown syntax node into corpus expectations"); + } + + var clause = (node as SimpleCommandSyntax)?.Clause; + int? clauseIndex = clause is null ? null : FindClauseIndex(clauses, clause); + var currentIndex = nodes.Count; + nodes.Add(new ActualSyntaxNode( + node.Kind, + parentIndex, + region, + childIndex, + node.SourceStart, + node.SourceLength, + clauseIndex, + (node as GroupSyntax)?.GroupKind, + listOperator, + clause)); + + switch (node) + { + case ShellBlockSyntax block: + var statementRegion = isRootBlock + ? CommandAncestryRegion.Root + : CommandAncestryRegion.Statement; + for (var index = 0; index < block.Statements.Count; index++) + { + AppendSyntax( + block.Statements[index], + currentIndex, + statementRegion, + index, + listOperator: null, + clauses, + nodes); + } + + break; + case SimpleCommandSyntax simple: + for (var index = 0; index < simple.Substitutions.Count; index++) + { + AppendSyntax( + simple.Substitutions[index], + currentIndex, + CommandAncestryRegion.Substitution, + index, + listOperator: null, + clauses, + nodes); + } + + break; + case PipelineSyntax pipeline: + for (var index = 0; index < pipeline.Stages.Count; index++) + { + AppendSyntax( + pipeline.Stages[index], + currentIndex, + CommandAncestryRegion.PipelineStage, + index, + listOperator: null, + clauses, + nodes); + } + + break; + case CommandListSyntax list: + for (var index = 0; index < list.Items.Count; index++) + { + AppendSyntax( + list.Items[index].Command, + currentIndex, + CommandAncestryRegion.Statement, + index, + list.Items[index].Operator, + clauses, + nodes); + } + + break; + case GroupSyntax group: + AppendSyntax( + group.Body, + currentIndex, + CommandAncestryRegion.GroupBody, + childIndex: null, + listOperator: null, + clauses, + nodes); + break; + case ForEachSyntax forEach: + AppendSyntax( + forEach.IteratorCommands, + currentIndex, + CommandAncestryRegion.Iterator, + childIndex: null, + listOperator: null, + clauses, + nodes); + AppendSyntax( + forEach.Body, + currentIndex, + CommandAncestryRegion.LoopBody, + childIndex: null, + listOperator: null, + clauses, + nodes); + break; + case ConditionLoopSyntax loop: + AppendSyntax( + loop.Condition, + currentIndex, + CommandAncestryRegion.Condition, + childIndex: null, + listOperator: null, + clauses, + nodes); + AppendSyntax( + loop.Body, + currentIndex, + CommandAncestryRegion.LoopBody, + childIndex: null, + listOperator: null, + clauses, + nodes); + break; + case ConditionalSyntax conditional: + for (var index = 0; index < conditional.Branches.Count; index++) + { + AppendSyntax( + conditional.Branches[index], + currentIndex, + CommandAncestryRegion.Branch, + index, + listOperator: null, + clauses, + nodes); + } + + if (conditional.Else is not null) + { + AppendSyntax( + conditional.Else, + currentIndex, + CommandAncestryRegion.Branch, + conditional.Branches.Count, + listOperator: null, + clauses, + nodes); + } + + break; + case ConditionalBranchSyntax branch: + AppendSyntax( + branch.Condition, + currentIndex, + CommandAncestryRegion.Condition, + childIndex: null, + listOperator: null, + clauses, + nodes); + AppendSyntax( + branch.Body, + currentIndex, + CommandAncestryRegion.Branch, + childIndex: null, + listOperator: null, + clauses, + nodes); + break; + case CommandSubstitutionSyntax substitution: + AppendSyntax( + substitution.Body, + currentIndex, + CommandAncestryRegion.Substitution, + childIndex, + listOperator: null, + clauses, + nodes); + break; + default: + throw new XunitException( + $"Cannot flatten unsupported syntax type {node.GetType().FullName} into corpus expectations"); + } + } + + private static int FindClauseIndex(IReadOnlyList clauses, Clause clause) + { + for (var index = 0; index < clauses.Count; index++) + { + if (object.ReferenceEquals(clauses[index], clause)) + { + return index; + } + } + + return -1; + } + + private static string Summarize(ExpectedSyntaxNode node) => + $"{{kind={node.Kind}, parent={node.ParentIndex}, region={node.Region}, " + + $"child={node.ChildIndex}, span={node.SourceStart}:{node.SourceLength}, " + + $"clause={node.ClauseIndex}, group={node.GroupKind}, listOp={node.ListOperator}}}"; + + private static string Summarize(ActualSyntaxNode node) => + $"{{kind={node.Kind}, parent={node.ParentIndex}, region={node.Region}, " + + $"child={node.ChildIndex}, span={node.SourceStart}:{node.SourceLength}, " + + $"clause={node.ClauseIndex}, group={node.GroupKind}, listOp={node.ListOperator}}}"; + + private sealed record ActualSyntaxNode( + ShellSyntaxKind Kind, + int? ParentIndex, + CommandAncestryRegion Region, + int? ChildIndex, + int? SourceStart, + int? SourceLength, + int? ClauseIndex, + ShellGroupKind? GroupKind, + CompoundOperator? ListOperator, + Clause? Clause); + private static void AssertClauseEqual(ExpectedClause expected, Clause actual, string path) { if (expected.Operator != actual.Operator) diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index e4b88fe..a6b4de3 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -364,6 +364,7 @@ public static IEnumerable CorpusEntries() PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, Converters = { new JsonStringEnumConverter() }, }; } @@ -396,6 +397,63 @@ public sealed record ExpectedParsedCommand public string? UnparseableReasonContains { get; init; } public List? Clauses { get; init; } + + /// + /// Optional pre-order structural projection. When present, every syntax + /// node, relationship, span, and simple-command clause identity is pinned. + /// + public List? Syntax { get; init; } + + /// + /// Optional executable-accounting projection. When present, command order, + /// role, ancestry, completeness, and compatibility-clause identity are pinned. + /// + public List? Commands { get; init; } +} + +public sealed record ExpectedSyntaxNode +{ + public ShellSyntaxKind Kind { get; init; } + + public int? ParentIndex { get; init; } + + public CommandAncestryRegion Region { get; init; } + + public int? ChildIndex { get; init; } + + public int? SourceStart { get; init; } + + public int? SourceLength { get; init; } + + public int? ClauseIndex { get; init; } + + public ShellGroupKind? GroupKind { get; init; } + + public CompoundOperator? ListOperator { get; init; } +} + +public sealed record ExpectedCommandOccurrence +{ + public int ClauseIndex { get; init; } + + public CommandOccurrenceRole ImmediateRole { get; init; } + + public bool IsComplete { get; init; } + + public List? Ancestry { get; init; } +} + +public sealed record ExpectedCommandAncestryFrame +{ + public ShellSyntaxKind AncestorKind { get; init; } + + public CommandAncestryRegion Region { get; init; } + + public int? ChildIndex { get; init; } + + public int? SourceStart { get; init; } + + public int? SourceLength { get; init; } } public sealed record ExpectedClause diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/01_simple_ls.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/01_simple_ls.json index 38103b3..2fe126a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/01_simple_ls.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/01_simple_ls.json @@ -12,6 +12,46 @@ "isSubshell": false, "isCommandStringWrapped": false } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 2, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 2, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 2 + } + ] + } ] }, "notes": "Default arity 1; no args." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/187_v03_hidden_substitution_incomplete.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/187_v03_hidden_substitution_incomplete.json new file mode 100644 index 0000000..845b6f9 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/187_v03_hidden_substitution_incomplete.json @@ -0,0 +1,34 @@ +{ + "name": "v0.3 hidden command substitution remains incomplete", + "input": "rm $(find /tmp)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["rm"], + "args": [ + { "raw": "$(find /tmp)", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + } + ], + "syntax": [ + { "kind": "Block", "parentIndex": null, "region": "Unknown", "childIndex": null, "sourceStart": 0, "sourceLength": 15, "clauseIndex": null, "groupKind": null, "listOperator": null }, + { "kind": "SimpleCommand", "parentIndex": 0, "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 15, "clauseIndex": 0, "groupKind": null, "listOperator": null } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 15 } + ] + } + ] + }, + "notes": "Until task 3.10 discovers the inner find command, the authored outer leaf stays visible but cannot authorize execution." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/188_v03_dynamic_wrapper_incomplete.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/188_v03_dynamic_wrapper_incomplete.json new file mode 100644 index 0000000..929fb04 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/188_v03_dynamic_wrapper_incomplete.json @@ -0,0 +1,35 @@ +{ + "name": "v0.3 dynamic bash command string remains incomplete", + "input": "bash -c \"$code\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["bash"], + "args": [ + { "raw": "-c", "kind": "Literal", "isPath": false }, + { "raw": "\"$code\"", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + } + ], + "syntax": [ + { "kind": "Block", "parentIndex": null, "region": "Unknown", "childIndex": null, "sourceStart": 0, "sourceLength": 15, "clauseIndex": null, "groupKind": null, "listOperator": null }, + { "kind": "SimpleCommand", "parentIndex": 0, "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 15, "clauseIndex": 0, "groupKind": null, "listOperator": null } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 15 } + ] + } + ] + }, + "notes": "An expanding command-string body cannot be decoded as one exact static program, so the direct host leaf remains incomplete." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/189_v03_mixed_list_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/189_v03_mixed_list_pipeline.json new file mode 100644 index 0000000..018fb1c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/189_v03_mixed_list_pipeline.json @@ -0,0 +1,91 @@ +{ + "name": "v0.3 mixed command list and pipeline", + "input": "printf x | grep x && echo ok; pwd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["printf", "x"], + "args": [], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + }, + { + "operator": "Pipe", + "verb": ["grep"], + "args": [{ "raw": "x", "kind": "Literal", "isPath": false }], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + }, + { + "operator": "AndIf", + "verb": ["echo", "ok"], + "args": [], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + }, + { + "operator": "Sequence", + "verb": ["pwd"], + "args": [], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + } + ], + "syntax": [ + { "kind": "Block", "parentIndex": null, "region": "Unknown", "childIndex": null, "sourceStart": 0, "sourceLength": 33, "clauseIndex": null, "groupKind": null, "listOperator": null }, + { "kind": "CommandList", "parentIndex": 0, "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 33, "clauseIndex": null, "groupKind": null, "listOperator": null }, + { "kind": "Pipeline", "parentIndex": 1, "region": "Statement", "childIndex": 0, "sourceStart": 0, "sourceLength": 17, "clauseIndex": null, "groupKind": null, "listOperator": "None" }, + { "kind": "SimpleCommand", "parentIndex": 2, "region": "PipelineStage", "childIndex": 0, "sourceStart": 0, "sourceLength": 8, "clauseIndex": 0, "groupKind": null, "listOperator": null }, + { "kind": "SimpleCommand", "parentIndex": 2, "region": "PipelineStage", "childIndex": 1, "sourceStart": 11, "sourceLength": 6, "clauseIndex": 1, "groupKind": null, "listOperator": null }, + { "kind": "SimpleCommand", "parentIndex": 1, "region": "Statement", "childIndex": 1, "sourceStart": 21, "sourceLength": 7, "clauseIndex": 2, "groupKind": null, "listOperator": "AndIf" }, + { "kind": "SimpleCommand", "parentIndex": 1, "region": "Statement", "childIndex": 2, "sourceStart": 30, "sourceLength": 3, "clauseIndex": 3, "groupKind": null, "listOperator": "Sequence" } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 33 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, "sourceLength": 33 }, + { "ancestorKind": "Pipeline", "region": "PipelineStage", "childIndex": 0, "sourceStart": 0, "sourceLength": 17 } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 33 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, "sourceLength": 33 }, + { "ancestorKind": "Pipeline", "region": "PipelineStage", "childIndex": 1, "sourceStart": 0, "sourceLength": 17 } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 33 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": 0, "sourceLength": 33 } + ] + }, + { + "clauseIndex": 3, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 33 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 2, "sourceStart": 0, "sourceLength": 33 } + ] + } + ] + }, + "notes": "Pins pipeline precedence inside a command list and the compatibility operators carried by each authored leaf." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json index 316ce99..b909149 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/42_pipe_then_redirect.json @@ -22,6 +22,96 @@ "isSubshell": false, "isCommandStringWrapped": false } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 22, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 22, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 4, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 7, + "sourceLength": 15, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 22 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 22 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 22 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 22 + } + ] + } ] } } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/83_subshell_with_pipe.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/83_subshell_with_pipe.json index 733199b..e0b5c95 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/83_subshell_with_pipe.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/83_subshell_with_pipe.json @@ -20,6 +20,98 @@ "isSubshell": true, "isCommandStringWrapped": false } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 7, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 7, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "GroupBody", + "childIndex": null, + "sourceStart": 1, + "sourceLength": 5, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": 1, + "sourceLength": 5, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 1, + "sourceLength": 1, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 5, + "sourceLength": 1, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 7 }, + { "ancestorKind": "Group", "region": "GroupBody", "childIndex": null, "sourceStart": 0, "sourceLength": 7 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 1, "sourceLength": 5 }, + { "ancestorKind": "Pipeline", "region": "PipelineStage", "childIndex": 0, "sourceStart": 1, "sourceLength": 5 } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 7 }, + { "ancestorKind": "Group", "region": "GroupBody", "childIndex": null, "sourceStart": 0, "sourceLength": 7 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 1, "sourceLength": 5 }, + { "ancestorKind": "Pipeline", "region": "PipelineStage", "childIndex": 1, "sourceStart": 1, "sourceLength": 5 } + ] + } ] }, "notes": "Pipe inside subshell — both clauses carry IsSubshell=true." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/95_bash_c_inner_compound.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/95_bash_c_inner_compound.json index 69947ce..8cf92f2 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/95_bash_c_inner_compound.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/95_bash_c_inner_compound.json @@ -28,6 +28,50 @@ "isSubshell": false, "isCommandStringWrapped": true } + ], + "syntax": [ + { "kind": "Block", "parentIndex": null, "region": "Unknown", "childIndex": null, "sourceStart": 0, "sourceLength": 21, "clauseIndex": null, "groupKind": null, "listOperator": null }, + { "kind": "Group", "parentIndex": 0, "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 21, "clauseIndex": null, "groupKind": "IsolatedScope", "listOperator": null }, + { "kind": "Block", "parentIndex": 1, "region": "GroupBody", "childIndex": null, "sourceStart": null, "sourceLength": null, "clauseIndex": null, "groupKind": null, "listOperator": null }, + { "kind": "CommandList", "parentIndex": 2, "region": "Statement", "childIndex": 0, "sourceStart": null, "sourceLength": null, "clauseIndex": null, "groupKind": null, "listOperator": null }, + { "kind": "SimpleCommand", "parentIndex": 3, "region": "Statement", "childIndex": 0, "sourceStart": null, "sourceLength": null, "clauseIndex": 0, "groupKind": null, "listOperator": "None" }, + { "kind": "SimpleCommand", "parentIndex": 3, "region": "Statement", "childIndex": 1, "sourceStart": null, "sourceLength": null, "clauseIndex": 1, "groupKind": null, "listOperator": "AndIf" }, + { "kind": "SimpleCommand", "parentIndex": 3, "region": "Statement", "childIndex": 2, "sourceStart": null, "sourceLength": null, "clauseIndex": 2, "groupKind": null, "listOperator": "AndIf" } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 21 }, + { "ancestorKind": "Group", "region": "GroupBody", "childIndex": null, "sourceStart": 0, "sourceLength": 21 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": null, "sourceLength": null }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": null, "sourceLength": null } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 21 }, + { "ancestorKind": "Group", "region": "GroupBody", "childIndex": null, "sourceStart": 0, "sourceLength": 21 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": null, "sourceLength": null }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": null, "sourceLength": null } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 21 }, + { "ancestorKind": "Group", "region": "GroupBody", "childIndex": null, "sourceStart": 0, "sourceLength": 21 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": null, "sourceLength": null }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 2, "sourceStart": null, "sourceLength": null } + ] + } ] }, "notes": "Inner compound surfaces as three IsBashCWrapped=true clauses." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/001_simple_get_date.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/001_simple_get_date.json index 4103e0d..7ad501e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/001_simple_get_date.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/001_simple_get_date.json @@ -12,6 +12,46 @@ "args": [], "redirects": [] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 8, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 8, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 8 + } + ] + } ] }, "notes": "Bare cmdlet, no args." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/066_compound_group_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/066_compound_group_pipeline.json index b2f7466..b497b7b 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/066_compound_group_pipeline.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/066_compound_group_pipeline.json @@ -30,6 +30,146 @@ "redirects": [], "isSubshell": true } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 26, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 26, + "clauseIndex": null, + "groupKind": "CurrentScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "GroupBody", + "childIndex": null, + "sourceStart": 1, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": 1, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 1, + "sourceLength": 10, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 14, + "sourceLength": 11, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 26 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 26 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 1, + "sourceLength": 24 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 1, + "sourceLength": 24 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 26 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 26 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 1, + "sourceLength": 24 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 1, + "sourceLength": 24 + } + ] + } ] }, "notes": "A group wrapping a pipeline; both clauses marked IsSubshell." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json index acb5788..281cf7c 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json @@ -17,6 +17,46 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 18, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 18, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 18 + } + ] + } ] }, "notes": "\u003E stdout redirect (truncate)." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/143_recursion_command_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/143_recursion_command_pipeline.json index 1e7a378..2316c91 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/143_recursion_command_pipeline.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/143_recursion_command_pipeline.json @@ -24,6 +24,146 @@ "redirects": [], "isCommandStringWrapped": true } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 24 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 24 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": null, + "sourceLength": null + } + ] + } ] }, "notes": "A pipeline inside -Command surfaces as two wrapped clauses." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json index 26bab92..96e2fce 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/165_dynamic_subexpression_arg.json @@ -18,6 +18,46 @@ ], "redirects": [] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 27, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 27, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 27 + } + ] + } ] }, "notes": "A $( ) subexpression argument is opaque DynamicSkip." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json index d1f8941..5afca7b 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/222_iex_variable_dynamic.json @@ -18,6 +18,46 @@ ], "redirects": [] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 23, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 23 + } + ] + } ] }, "notes": "A variable payload remains an Invoke-Expression clause with one DynamicSkip arg." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json index 22450f1..6dabc16 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/280_curl_data_mixed_literal_dynamic.json @@ -67,5 +67,5 @@ } ] }, - "notes": "All fragments are literal, so the curl @ prefix is removed without reinterpreting the quoted $HOME text." + "notes": "Resolver-sensitive mixed quoting safe-fails instead of expanding literal bytes." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json index 3079d33..7e031a9 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/281_curl_data_transformed_literal_dynamic.json @@ -67,5 +67,5 @@ } ] }, - "notes": "The quoted tilde remains literal after curl\u0027s @ marker is removed." + "notes": "Resolver-sensitive syntax exposed after curl\u0027s @ marker is removed still safe-fails." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json index 6d64f2f..4d27ea5 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/282_escaped_home_literal_path.json @@ -1,14 +1,26 @@ { "name": "Backtick escaped HOME remains a literal path", - "input": "Get-Content `$HOME", + "input": "Get-Content \u0060$HOME", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "`$HOME", "kind": "Literal", "isPath": true, "resolved": "C:/work/$HOME" }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0060$HOME", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/$HOME", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "The backtick escape removes interpolation eligibility without erasing the authored path value." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json index 0ad39be..b5c27dc 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/283_literalpath_abbreviation_wildcard.json @@ -1,17 +1,33 @@ { "name": "LiteralPath abbreviation suppresses wildcard semantics", - "input": "Get-Content -LiteralP \"*.txt\"", + "input": "Get-Content -LiteralP \u0022*.txt\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [ - { "raw": "-LiteralP", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "\"*.txt\"", "kind": "Literal", "isPath": true, "resolved": "C:/work/*.txt" } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "-LiteralP", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "\u0022*.txt\u0022", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/*.txt", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "Unambiguous parameter-prefix binding retains canonical LiteralPath identity." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json index 8411fe2..874a981 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/284_unknown_cmdlet_path_semantics.json @@ -3,15 +3,31 @@ "input": "Get-Foo -Path FileSystem::C:/safe", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Foo"], - "args": [ - { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "FileSystem::C:/safe", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__" } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Foo" + ], + "args": [ + { + "raw": "-Path", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "FileSystem::C:/safe", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "Only a closed-table known cmdlet or alias may activate provider and parameter semantics." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json index d7d15be..06d8bcf 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/285_null_sink_redirect.json @@ -1,14 +1,32 @@ { "name": "PowerShell null sink redirect remains non-file", - "input": "Write-Output ok > $null", + "input": "Write-Output ok \u003E $null", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Write-Output"], - "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], - "redirects": [{ "direction": "Out", "target": "$null", "isDynamicSkip": true }] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "ok", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [ + { + "direction": "Out", + "target": "$null", + "isDynamicSkip": true + } + ] + } + ] }, "notes": "The unescaped variable token is the PowerShell discard sink, not a filesystem target." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json index ef22657..32bb319 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/286_escaped_null_redirect.json @@ -1,14 +1,32 @@ { "name": "Escaped PowerShell null text is a literal file target", - "input": "Write-Output ok > `$null", + "input": "Write-Output ok \u003E \u0060$null", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Write-Output"], - "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], - "redirects": [{ "direction": "Out", "target": "C:/work/$null", "isDynamicSkip": false }] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "ok", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [ + { + "direction": "Out", + "target": "C:/work/$null", + "isDynamicSkip": false + } + ] + } + ] }, "notes": "Decoded text alone is insufficient: the backtick proves this is not the null sink." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json index b03b3af..8c0466c 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/287_native_quoted_wildcard.json @@ -1,14 +1,27 @@ { "name": "Quoted native wildcard is a literal path", - "input": "git add \"*.txt\"", + "input": "git add \u0022*.txt\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["git", "add"], - "args": [{ "raw": "\"*.txt\"", "kind": "Literal", "isPath": true, "resolved": "C:/work/*.txt" }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "git", + "add" + ], + "args": [ + { + "raw": "\u0022*.txt\u0022", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/*.txt", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "PowerShell native argument wildcard eligibility is quote-sensitive." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json index b0033b8..f21d1b5 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/288_cmdlet_path_wildcard.json @@ -1,17 +1,33 @@ { "name": "Cmdlet Path wildcard remains a pattern", - "input": "Get-Content -Path \"*.txt\"", + "input": "Get-Content -Path \u0022*.txt\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [ - { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "\"*.txt\"", "kind": "Glob", "isPath": true, "resolved": "__NULL__" } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "-Path", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "\u0022*.txt\u0022", + "kind": "Glob", + "isPath": true, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "Cmdlet Path applies wildcard semantics after quote removal, unlike native arguments." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json index dafd65e..e056fba 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/289_adjacent_escaped_redirect_target.json @@ -1,14 +1,32 @@ { "name": "Adjacent escaped PowerShell redirect fragments form one target", - "input": "Write-Output ok > `$HOME\".txt\"", + "input": "Write-Output ok \u003E \u0060$HOME\u0022.txt\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Write-Output"], - "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], - "redirects": [{ "direction": "Out", "target": "C:/work/$HOME.txt", "isDynamicSkip": false }] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "ok", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [ + { + "direction": "Out", + "target": "C:/work/$HOME.txt", + "isDynamicSkip": false + } + ] + } + ] }, "notes": "The redirect target is aggregated before provider-aware resolution." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json index 37766a1..01f9359 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/290_cmdlet_index_expression.json @@ -3,15 +3,31 @@ "input": "Get-Content -LiteralPath $HOME[0]", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [ - { "raw": "-LiteralPath", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "$HOME[0]", "kind": "DynamicSkip", "isPath": false } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "-LiteralPath", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "$HOME[0]", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "PowerShell binds this as an IndexExpressionAst, not HOME plus literal suffix text." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json index 42fd6a6..948c4da 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/291_cmdlet_colon_member_expression.json @@ -3,15 +3,31 @@ "input": "Get-Content -Path:$HOME.Length", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [ - { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "$HOME.Length", "kind": "DynamicSkip", "isPath": false } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "-Path", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "$HOME.Length", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "PowerShell binds the colon tail as a MemberExpressionAst and the parser does not evaluate it." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json index 23dc573..55bf069 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/292_native_member_spelling.json @@ -3,16 +3,38 @@ "input": "curl --output $HOME.Length https://example.invalid/api", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["curl"], - "args": [ - { "raw": "--output", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "$HOME.Length", "kind": "DynamicSkip", "isPath": false }, - { "raw": "https://example.invalid/api", "kind": "Literal", "isPath": false } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "curl" + ], + "args": [ + { + "raw": "--output", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "$HOME.Length", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + }, + { + "raw": "https://example.invalid/api", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "A bare variable at the start of a spaced native argument remains a PowerShell member expression; native command kind does not turn the suffix into literal text." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json index c4aaf0f..1bc77f1 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/293_native_inline_member_spelling.json @@ -3,16 +3,38 @@ "input": "curl --output=$HOME.Length https://example.invalid/api", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["curl"], - "args": [ - { "raw": "--output", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "$HOME.Length", "kind": "Tilde", "isPath": true, "resolved": "C:/Users/user.Length" }, - { "raw": "https://example.invalid/api", "kind": "Literal", "isPath": false } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "curl" + ], + "args": [ + { + "raw": "--output", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "$HOME.Length", + "kind": "Tilde", + "isPath": true, + "resolved": "C:/Users/user.Length", + "isFlag": false + }, + { + "raw": "https://example.invalid/api", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "The same authored fragment has native rather than cmdlet binding semantics." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json index 66fc60c..9728443 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/294_ambiguous_colon_parameter.json @@ -1,17 +1,33 @@ { "name": "Ambiguous colon parameter fails closed", - "input": "Get-Content -P:\"safe.txt\"", + "input": "Get-Content -P:\u0022safe.txt\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [ - { "raw": "-P", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "\"safe.txt\"", "kind": "DynamicSkip", "isPath": false } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "-P", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "\u0022safe.txt\u0022", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "-P matches multiple known parameters, so neither binding nor path mode is proved." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json index 9452adb..e89fdad 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/295_ambiguous_separated_parameter.json @@ -3,15 +3,31 @@ "input": "Get-Content -P safe.txt", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [ - { "raw": "-P", "kind": "DynamicSkip", "isPath": false, "isFlag": true }, - { "raw": "safe.txt", "kind": "DynamicSkip", "isPath": false } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "-P", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "safe.txt", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "The following token cannot safely be treated as either a bound value or a positional path." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json index 9a2c3bc..7334126 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/296_unproved_single_letter_psdrive.json @@ -3,12 +3,24 @@ "input": "Get-Content Z:\\x", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "Z:\\x", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "Z:\\x", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "A single-letter drive is not proved FileSystem merely from its spelling." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json index 930c24b..8747107 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/297_unproved_psdrive_redirect.json @@ -1,14 +1,32 @@ { "name": "Unproved PSDrive redirect fails closed", - "input": "Write-Output ok > Z:\\x", + "input": "Write-Output ok \u003E Z:\\x", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Write-Output"], - "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], - "redirects": [{ "direction": "Out", "target": "Z:\\x", "isDynamicSkip": true }] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "ok", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [ + { + "direction": "Out", + "target": "Z:\\x", + "isDynamicSkip": true + } + ] + } + ] }, "notes": "Redirect path semantics require a proved PSDrive provider mapping." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json index 05969b0..e75f340 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/298_drive_relative_path.json @@ -3,12 +3,24 @@ "input": "Get-Content C:relative.txt", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "C:relative.txt", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "C:relative.txt", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, - "notes": "C:relative.txt depends on PowerShell's per-drive current location, which parser options do not model." + "notes": "C:relative.txt depends on PowerShell\u0027s per-drive current location, which parser options do not model." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json index 0e012bc..3e0b29e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/299_drive_relative_redirect.json @@ -1,14 +1,32 @@ { "name": "Drive-relative redirect fails closed", - "input": "Write-Output ok > C:relative.txt", + "input": "Write-Output ok \u003E C:relative.txt", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Write-Output"], - "args": [{ "raw": "ok", "kind": "Literal", "isPath": false }], - "redirects": [{ "direction": "Out", "target": "C:relative.txt", "isDynamicSkip": true }] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "ok", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [ + { + "direction": "Out", + "target": "C:relative.txt", + "isDynamicSkip": true + } + ] + } + ] }, "notes": "A configured C drive does not prove its drive-relative current location." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json index ee45f41..648b717 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/300_quoted_member_suffix.json @@ -1,17 +1,33 @@ { "name": "Quoted member-looking suffix remains exact", - "input": "Get-Content -Path $HOME\".Length\"", + "input": "Get-Content -Path $HOME\u0022.Length\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [ - { "raw": "-Path", "kind": "Literal", "isPath": false, "isFlag": true }, - { "raw": "$HOME\".Length\"", "kind": "Tilde", "isPath": true, "resolved": "C:/Users/user.Length" } - ], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "-Path", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "$HOME\u0022.Length\u0022", + "kind": "Tilde", + "isPath": true, + "resolved": "C:/Users/user.Length", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "The quote boundary makes .Length literal text rather than a MemberExpressionAst." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json index f0f7c21..dc4a16e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/301_dynamic_command_identity.json @@ -3,13 +3,25 @@ "input": "Get-$(Write-Output Content) /etc/passwd", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-$(Write-Output Content)"], - "isDynamic": true, - "args": [{ "raw": "/etc/passwd", "kind": "Literal", "isPath": true, "resolved": "/etc/passwd" }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-$(Write-Output Content)" + ], + "isDynamic": true, + "args": [ + { + "raw": "/etc/passwd", + "kind": "Literal", + "isPath": true, + "resolved": "/etc/passwd", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "Adjacent aggregation preserves the opaque executable identity through VerbChain.IsDynamic." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json index 1787b24..83bf467 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/302_empty_path_value.json @@ -1,14 +1,26 @@ { "name": "Empty PowerShell path value fails closed", - "input": "Get-Content \"\"", + "input": "Get-Content \u0022\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "\"\"", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0022\u0022", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "An empty filesystem argument is not a resolvable static path." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json index 62c1262..58e101d 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/303_runtime_question_parameter_path.json @@ -1,14 +1,26 @@ { "name": "Runtime PowerShell status variable fails closed", - "input": "Get-Content \"$?\"", + "input": "Get-Content \u0022$?\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "\"$?\"", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0022$?\u0022", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "The special variable has a runtime value and cannot be reclassified as literal punctuation." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json index 3f74952..1599628 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/304_runtime_numeric_variable_path.json @@ -1,14 +1,26 @@ { "name": "Runtime PowerShell numeric variable fails closed", - "input": "Get-Content \"$1\"", + "input": "Get-Content \u0022$1\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "\"$1\"", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0022$1\u0022", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "Numeric variable identity is retained while its runtime path value remains unknown." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json index 26f7aca..336f0c0 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/305_runtime_unicode_variable_path.json @@ -1,14 +1,26 @@ { "name": "Runtime PowerShell Unicode variable fails closed", - "input": "Get-Content \"$é\"", + "input": "Get-Content \u0022$\u00E9\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "\"$é\"", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0022$\u00E9\u0022", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "Unicode variable names are recognized expansions rather than literal filenames." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json index 9cdd890..8b79a3c 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/306_unterminated_braced_interpolation.json @@ -1,10 +1,9 @@ { "name": "Unterminated PowerShell braced interpolation is unparseable", - "input": "Get-Content \"${HOME\"", + "input": "Get-Content \u0022${HOME\u0022", "expected": { "isUnparseable": true, - "unparseableReasonContains": "unbalanced '${'", - "clauses": [] + "unparseableReasonContains": "unbalanced \u0027${\u0027 variable interpolation" }, "notes": "PowerShell reports parser errors; no compatibility path is exposed." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json index 3a421e7..3e577dd 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/307_escaped_open_brace_literal_path.json @@ -1,14 +1,26 @@ { "name": "Escaped PowerShell interpolation start remains literal", - "input": "Get-Content \"`${HOME\"", + "input": "Get-Content \u0022\u0060${HOME\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "\"`${HOME\"", "kind": "Literal", "isPath": true, "resolved": "C:/work/${HOME" }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0022\u0060${HOME\u0022", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/${HOME", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "The backtick escapes the dollar, so the open brace remains exact literal data." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json index 6dc93f3..94394ec 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/308_runtime_scoped_variable_path.json @@ -1,14 +1,26 @@ { "name": "Runtime PowerShell scoped variable fails closed", - "input": "Get-Content \"$global:scoped\"", + "input": "Get-Content \u0022$global:scoped\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "\"$global:scoped\"", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0022$global:scoped\u0022", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "Scoped variable identity is retained while its runtime filesystem value remains unknown." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json index 423ccf6..c9c98ab 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/309_runtime_braced_variable_path.json @@ -1,14 +1,26 @@ { "name": "Runtime PowerShell braced variable fails closed", - "input": "Get-Content \"${braced-name}\"", + "input": "Get-Content \u0022${braced-name}\u0022", "expected": { "isUnparseable": false, - "clauses": [{ - "operator": "None", - "verb": ["Get-Content"], - "args": [{ "raw": "\"${braced-name}\"", "kind": "DynamicSkip", "isPath": false }], - "redirects": [] - }] + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Content" + ], + "args": [ + { + "raw": "\u0022${braced-name}\u0022", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [] + } + ] }, "notes": "A braced variable name is a recognized expansion rather than an exact literal filename." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/310_v03_mixed_list_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/310_v03_mixed_list_pipeline.json new file mode 100644 index 0000000..c66c786 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/310_v03_mixed_list_pipeline.json @@ -0,0 +1,228 @@ +{ + "name": "V03 mixed list pipeline", + "input": "gci | Select-Object Name \u0026\u0026 Get-Date; Get-Process", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "gci" + ], + "canonicalVerb": "Get-ChildItem", + "args": [], + "redirects": [] + }, + { + "operator": "Pipe", + "verb": [ + "Select-Object" + ], + "args": [ + { + "raw": "Name", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Process" + ], + "args": [], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 49, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 49, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Pipeline", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24, + "clauseIndex": null, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "SimpleCommand", + "parentIndex": 2, + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 3, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 2, + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 6, + "sourceLength": 18, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 28, + "sourceLength": 8, + "clauseIndex": 2, + "groupKind": null, + "listOperator": "AndIf" + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 2, + "sourceStart": 38, + "sourceLength": 11, + "clauseIndex": 3, + "groupKind": null, + "listOperator": "Sequence" + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 49 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 49 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 24 + } + ] + }, + { + "clauseIndex": 1, + "immediateRole": "PipelineStage", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 49 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 49 + }, + { + "ancestorKind": "Pipeline", + "region": "PipelineStage", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 24 + } + ] + }, + { + "clauseIndex": 2, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 49 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 49 + } + ] + }, + { + "clauseIndex": 3, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 49 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 2, + "sourceStart": 0, + "sourceLength": 49 + } + ] + } + ] + }, + "notes": "A pipeline followed by \u0026\u0026 and ; pins statement-versus-pipeline structure." +} diff --git a/tools/PwshCorpusTool/CorpusJson.cs b/tools/PwshCorpusTool/CorpusJson.cs index e768bbf..470316a 100644 --- a/tools/PwshCorpusTool/CorpusJson.cs +++ b/tools/PwshCorpusTool/CorpusJson.cs @@ -30,13 +30,19 @@ internal static string BuildEntry( ParsedCommand parsed, string notes, bool outOfScope, - bool includeElements) + bool includeElements, + bool includeStructure, + bool includeOptionalAssertions) { var obj = new JsonObject { ["name"] = name, ["input"] = input, - ["expected"] = BuildExpected(parsed, includeElements), + ["expected"] = BuildExpected( + parsed, + includeElements, + includeStructure, + includeOptionalAssertions), ["notes"] = notes, }; @@ -48,7 +54,11 @@ internal static string BuildEntry( return obj.ToJsonString(WriteOptions) + "\n"; } - private static JsonObject BuildExpected(ParsedCommand parsed, bool includeElements) + private static JsonObject BuildExpected( + ParsedCommand parsed, + bool includeElements, + bool includeStructure, + bool includeOptionalAssertions) { var expected = new JsonObject { ["isUnparseable"] = parsed.IsUnparseable }; if (parsed.IsUnparseable) @@ -60,14 +70,294 @@ private static JsonObject BuildExpected(ParsedCommand parsed, bool includeElemen var clauses = new JsonArray(); foreach (var clause in parsed.Clauses) { - clauses.Add(BuildClause(clause, includeElements)); + clauses.Add(BuildClause(clause, includeElements, includeOptionalAssertions)); } expected["clauses"] = clauses; + if (includeStructure) + { + expected["syntax"] = BuildSyntax(parsed); + expected["commands"] = BuildCommands(parsed); + } + return expected; } - private static JsonObject BuildClause(Clause clause, bool includeElements) + private static JsonArray BuildSyntax(ParsedCommand parsed) + { + var nodes = new JsonArray(); + AppendSyntax( + parsed.Syntax, + parentIndex: null, + CommandAncestryRegion.Unknown, + childIndex: null, + listOperator: null, + parsed, + nodes, + isRootBlock: true); + return nodes; + } + + private static void AppendSyntax( + ShellSyntaxNode node, + int? parentIndex, + CommandAncestryRegion region, + int? childIndex, + CompoundOperator? listOperator, + ParsedCommand parsed, + JsonArray nodes, + bool isRootBlock = false) + { + if (node.Kind == ShellSyntaxKind.Unknown) + { + throw new InvalidOperationException( + "Cannot generate corpus expectations for an unknown syntax node"); + } + + var currentIndex = nodes.Count; + var clause = (node as SimpleCommandSyntax)?.Clause; + var clauseIndex = clause is null ? (int?)null : FindClauseIndex(parsed, clause); + nodes.Add(new JsonObject + { + ["kind"] = node.Kind.ToString(), + ["parentIndex"] = JsonValue.Create(parentIndex), + ["region"] = region.ToString(), + ["childIndex"] = JsonValue.Create(childIndex), + ["sourceStart"] = JsonValue.Create(node.SourceStart), + ["sourceLength"] = JsonValue.Create(node.SourceLength), + ["clauseIndex"] = JsonValue.Create(clauseIndex), + ["groupKind"] = (node as GroupSyntax)?.GroupKind.ToString(), + ["listOperator"] = listOperator?.ToString(), + }); + + switch (node) + { + case ShellBlockSyntax block: + var statementRegion = isRootBlock + ? CommandAncestryRegion.Root + : CommandAncestryRegion.Statement; + for (var index = 0; index < block.Statements.Count; index++) + { + AppendSyntax( + block.Statements[index], + currentIndex, + statementRegion, + index, + listOperator: null, + parsed, + nodes); + } + + break; + case SimpleCommandSyntax simple: + for (var index = 0; index < simple.Substitutions.Count; index++) + { + AppendSyntax( + simple.Substitutions[index], + currentIndex, + CommandAncestryRegion.Substitution, + index, + listOperator: null, + parsed, + nodes); + } + + break; + case PipelineSyntax pipeline: + for (var index = 0; index < pipeline.Stages.Count; index++) + { + AppendSyntax( + pipeline.Stages[index], + currentIndex, + CommandAncestryRegion.PipelineStage, + index, + listOperator: null, + parsed, + nodes); + } + + break; + case CommandListSyntax list: + for (var index = 0; index < list.Items.Count; index++) + { + AppendSyntax( + list.Items[index].Command, + currentIndex, + CommandAncestryRegion.Statement, + index, + list.Items[index].Operator, + parsed, + nodes); + } + + break; + case GroupSyntax group: + AppendSyntax( + group.Body, + currentIndex, + CommandAncestryRegion.GroupBody, + childIndex: null, + listOperator: null, + parsed, + nodes); + break; + case ForEachSyntax forEach: + AppendSyntax( + forEach.IteratorCommands, + currentIndex, + CommandAncestryRegion.Iterator, + childIndex: null, + listOperator: null, + parsed, + nodes); + AppendSyntax( + forEach.Body, + currentIndex, + CommandAncestryRegion.LoopBody, + childIndex: null, + listOperator: null, + parsed, + nodes); + break; + case ConditionLoopSyntax loop: + AppendSyntax( + loop.Condition, + currentIndex, + CommandAncestryRegion.Condition, + childIndex: null, + listOperator: null, + parsed, + nodes); + AppendSyntax( + loop.Body, + currentIndex, + CommandAncestryRegion.LoopBody, + childIndex: null, + listOperator: null, + parsed, + nodes); + break; + case ConditionalSyntax conditional: + for (var index = 0; index < conditional.Branches.Count; index++) + { + AppendSyntax( + conditional.Branches[index], + currentIndex, + CommandAncestryRegion.Branch, + index, + listOperator: null, + parsed, + nodes); + } + + if (conditional.Else is not null) + { + AppendSyntax( + conditional.Else, + currentIndex, + CommandAncestryRegion.Branch, + conditional.Branches.Count, + listOperator: null, + parsed, + nodes); + } + + break; + case ConditionalBranchSyntax branch: + AppendSyntax( + branch.Condition, + currentIndex, + CommandAncestryRegion.Condition, + childIndex: null, + listOperator: null, + parsed, + nodes); + AppendSyntax( + branch.Body, + currentIndex, + CommandAncestryRegion.Branch, + childIndex: null, + listOperator: null, + parsed, + nodes); + break; + case CommandSubstitutionSyntax substitution: + AppendSyntax( + substitution.Body, + currentIndex, + CommandAncestryRegion.Substitution, + childIndex, + listOperator: null, + parsed, + nodes); + break; + default: + throw new InvalidOperationException( + $"Cannot generate corpus expectations for syntax type {node.GetType().FullName}"); + } + } + + private static JsonArray BuildCommands(ParsedCommand parsed) + { + var commands = new JsonArray(); + foreach (var command in parsed.Commands) + { + if (command.ImmediateRole == CommandOccurrenceRole.Unknown) + { + throw new InvalidOperationException( + "Cannot generate corpus expectations for an unknown command role"); + } + + var ancestry = new JsonArray(); + foreach (var frame in command.Ancestry) + { + if (frame.AncestorKind == ShellSyntaxKind.Unknown || + frame.Region == CommandAncestryRegion.Unknown) + { + throw new InvalidOperationException( + "Cannot generate corpus expectations for unknown command ancestry"); + } + + ancestry.Add(new JsonObject + { + ["ancestorKind"] = frame.AncestorKind.ToString(), + ["region"] = frame.Region.ToString(), + ["childIndex"] = JsonValue.Create(frame.ChildIndex), + ["sourceStart"] = JsonValue.Create(frame.SourceStart), + ["sourceLength"] = JsonValue.Create(frame.SourceLength), + }); + } + + commands.Add(new JsonObject + { + ["clauseIndex"] = FindClauseIndex(parsed, command.Clause), + ["immediateRole"] = command.ImmediateRole.ToString(), + ["isComplete"] = command.IsComplete, + ["ancestry"] = ancestry, + }); + } + + return commands; + } + + private static int FindClauseIndex(ParsedCommand parsed, Clause clause) + { + for (var index = 0; index < parsed.Clauses.Count; index++) + { + if (object.ReferenceEquals(parsed.Clauses[index], clause)) + { + return index; + } + } + + throw new InvalidOperationException( + "Structural corpus generation found a Clause outside ParsedCommand.Clauses"); + } + + private static JsonObject BuildClause( + Clause clause, + bool includeElements, + bool includeOptionalAssertions) { var verb = new JsonArray(); foreach (var token in clause.Verb.Tokens) @@ -94,7 +384,7 @@ private static JsonObject BuildClause(Clause clause, bool includeElements) var args = new JsonArray(); foreach (var arg in clause.Args) { - args.Add(BuildArg(arg)); + args.Add(BuildArg(arg, includeOptionalAssertions)); } obj["args"] = args; @@ -102,7 +392,7 @@ private static JsonObject BuildClause(Clause clause, bool includeElements) var redirects = new JsonArray(); foreach (var redirect in clause.Redirects) { - redirects.Add(BuildRedirect(redirect)); + redirects.Add(BuildRedirect(redirect, includeOptionalAssertions)); } obj["redirects"] = redirects; @@ -131,7 +421,7 @@ private static JsonObject BuildClause(Clause clause, bool includeElements) return obj; } - private static JsonObject BuildArg(Arg arg) + private static JsonObject BuildArg(Arg arg, bool includeOptionalAssertions) { var obj = new JsonObject { @@ -144,6 +434,15 @@ private static JsonObject BuildArg(Arg arg) { obj["resolved"] = arg.Resolved; } + else if (includeOptionalAssertions) + { + obj["resolved"] = "__NULL__"; + } + + if (includeOptionalAssertions) + { + obj["isFlag"] = arg.IsFlag; + } if (arg.IsCwdAttribution) { @@ -153,7 +452,9 @@ private static JsonObject BuildArg(Arg arg) return obj; } - private static JsonObject BuildRedirect(Redirect redirect) + private static JsonObject BuildRedirect( + Redirect redirect, + bool includeOptionalAssertions) { var obj = new JsonObject { @@ -165,6 +466,10 @@ private static JsonObject BuildRedirect(Redirect redirect) { obj["isDynamicSkip"] = true; } + else if (includeOptionalAssertions) + { + obj["isDynamicSkip"] = false; + } return obj; } diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index ce8ccd7..5056122 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -23,13 +23,16 @@ internal sealed record ManifestEntry( string Notes, bool OutOfScope, ManifestTransform Transform, - bool IncludeElements = false) + bool IncludeElements = false, + bool IncludeStructure = false, + bool IncludeOptionalAssertions = false, + string? DisplayName = null) { - /// Human-readable corpus entry name, derived from the slug. + /// Explicit display name when supplied; otherwise derived from the slug. public string Name => - Slug.Length == 0 + DisplayName ?? (Slug.Length == 0 ? Slug - : char.ToUpperInvariant(Slug[0]) + Slug.Substring(1).Replace('_', ' '); + : char.ToUpperInvariant(Slug[0]) + Slug.Substring(1).Replace('_', ' ')); /// The actual command string, after applying . public string ResolveInput() => Transform switch @@ -57,6 +60,19 @@ private static ManifestEntry E(string slug, string input, string notes) => private static ManifestEntry P(string slug, string input, string notes) => new(slug, input, notes, false, ManifestTransform.None, IncludeElements: true); + private static ManifestEntry S(string slug, string input, string notes) => + new(slug, input, notes, false, ManifestTransform.None, IncludeStructure: true); + + private static ManifestEntry A(string slug, string name, string input, string notes) => + new( + slug, + input, + notes, + false, + ManifestTransform.None, + IncludeOptionalAssertions: true, + DisplayName: name); + private static ManifestEntry Oos(string slug, string input, string notes) => new(slug, input, notes, true, ManifestTransform.None); @@ -82,7 +98,7 @@ private static string NestIex(string inner, int depth) internal static IReadOnlyList All() => new List { // ---- Simple cmdlet (§13: ≥10) ---- - E("simple_get_date", "Get-Date", "Bare cmdlet, no args."), + S("simple_get_date", "Get-Date", "Bare cmdlet, no args."), E("simple_get_childitem", "Get-ChildItem", "Bare cmdlet, no args."), E("simple_get_location", "Get-Location", "Bare cmdlet, no args."), E("simple_get_process", "Get-Process", "Bare cmdlet, no args."), @@ -180,7 +196,7 @@ private static string NestIex(string inner, int depth) E("compound_mixed_operators", "Get-Date && Get-Location || Get-Process", "Mixed && / || chain."), E("compound_group", "(Get-ChildItem)", "A parenthesized pipeline marks IsSubshell."), - E("compound_group_pipeline", "(gci C:\\tmp | Remove-Item)", + S("compound_group_pipeline", "(gci C:\\tmp | Remove-Item)", "A group wrapping a pipeline; both clauses marked IsSubshell."), E("compound_newline", "Get-Date\nGet-Location", "A bare newline is a statement separator."), @@ -311,7 +327,7 @@ private static string NestIex(string inner, int depth) "-Name is a path leaf for New-Item (§7.1)."), // ---- Redirect (§13: ≥10) ---- - E("redirect_out", "Get-Date > out.txt", "> stdout redirect (truncate)."), + S("redirect_out", "Get-Date > out.txt", "> stdout redirect (truncate)."), E("redirect_append", "Get-Date >> log.txt", ">> stdout append."), E("redirect_err", "Get-Process 2> err.txt", "2> stderr redirect."), E("redirect_err_append", "Get-Process 2>> err.txt", "2>> stderr append."), @@ -330,7 +346,7 @@ private static string NestIex(string inner, int depth) "pwsh -Command with a quoted-string payload; inner clause surfaces wrapped."), E("recursion_short_c", "pwsh -c \"Get-Date\"", "The short -c flag is recognized as -Command."), - E("recursion_command_pipeline", "pwsh -Command \"gci | rm\"", + S("recursion_command_pipeline", "pwsh -Command \"gci | rm\"", "A pipeline inside -Command surfaces as two wrapped clauses."), E("recursion_command_scriptblock", "pwsh -Command { Get-Date }", "pwsh -Command with a script-block payload."), @@ -377,7 +393,7 @@ private static string NestIex(string inner, int depth) "An $env: reference in a path slot resolves to DynamicSkip."), E("dynamic_variable_path_arg", "Remove-Item $targetPath", "A $var in a path slot is DynamicSkip."), - E("dynamic_subexpression_arg", "Get-Content $(Get-Location)", + S("dynamic_subexpression_arg", "Get-Content $(Get-Location)", "A $( ) subexpression argument is opaque DynamicSkip."), E("dynamic_array_subexpression", "Write-Output @(1, 2, 3)", "An @( ) array subexpression argument is opaque DynamicSkip."), @@ -502,7 +518,7 @@ private static string NestIex(string inner, int depth) "The full cmdlet name recurses into one static literal payload."), E("iex_command_parameter_static", "Invoke-Expression -Command 'Get-Process'", "The exact -Command parameter binds one static payload."), - E("iex_variable_dynamic", "Invoke-Expression $code", + S("iex_variable_dynamic", "Invoke-Expression $code", "A variable payload remains an Invoke-Expression clause with one DynamicSkip arg."), E("iex_interpolated_dynamic", "iex \"Remove-$noun C:\\x\"", "An interpolated payload remains opaque and dynamic."), @@ -642,5 +658,67 @@ private static string NestIex(string inner, int depth) P("curl_data_transformed_literal_dynamic", "curl --data='@~'\"/secret.json\" https://example.invalid/api", "Resolver-sensitive syntax exposed after curl's @ marker is removed still safe-fails."), + + // ---- Resolver and interpolation hardening follow-up ---- + A("escaped_home_literal_path", "Backtick escaped HOME remains a literal path", "Get-Content `$HOME", + "The backtick escape removes interpolation eligibility without erasing the authored path value."), + A("literalpath_abbreviation_wildcard", "LiteralPath abbreviation suppresses wildcard semantics", "Get-Content -LiteralP \"*.txt\"", + "Unambiguous parameter-prefix binding retains canonical LiteralPath identity."), + A("unknown_cmdlet_path_semantics", "Cmdlet shape alone does not prove Path semantics", "Get-Foo -Path FileSystem::C:/safe", + "Only a closed-table known cmdlet or alias may activate provider and parameter semantics."), + A("null_sink_redirect", "PowerShell null sink redirect remains non-file", "Write-Output ok > $null", + "The unescaped variable token is the PowerShell discard sink, not a filesystem target."), + A("escaped_null_redirect", "Escaped PowerShell null text is a literal file target", "Write-Output ok > `$null", + "Decoded text alone is insufficient: the backtick proves this is not the null sink."), + A("native_quoted_wildcard", "Quoted native wildcard is a literal path", "git add \"*.txt\"", + "PowerShell native argument wildcard eligibility is quote-sensitive."), + A("cmdlet_path_wildcard", "Cmdlet Path wildcard remains a pattern", "Get-Content -Path \"*.txt\"", + "Cmdlet Path applies wildcard semantics after quote removal, unlike native arguments."), + A("adjacent_escaped_redirect_target", "Adjacent escaped PowerShell redirect fragments form one target", "Write-Output ok > `$HOME\".txt\"", + "The redirect target is aggregated before provider-aware resolution."), + A("cmdlet_index_expression", "Cmdlet index expression fails closed", "Get-Content -LiteralPath $HOME[0]", + "PowerShell binds this as an IndexExpressionAst, not HOME plus literal suffix text."), + A("cmdlet_colon_member_expression", "Cmdlet colon member expression fails closed", "Get-Content -Path:$HOME.Length", + "PowerShell binds the colon tail as a MemberExpressionAst and the parser does not evaluate it."), + A("native_member_spelling", "Spaced native member expression fails closed", "curl --output $HOME.Length https://example.invalid/api", + "A bare variable at the start of a spaced native argument remains a PowerShell member expression; native command kind does not turn the suffix into literal text."), + A("native_inline_member_spelling", "Native inline member-looking spelling is literal suffix text", "curl --output=$HOME.Length https://example.invalid/api", + "The same authored fragment has native rather than cmdlet binding semantics."), + A("ambiguous_colon_parameter", "Ambiguous colon parameter fails closed", "Get-Content -P:\"safe.txt\"", + "-P matches multiple known parameters, so neither binding nor path mode is proved."), + A("ambiguous_separated_parameter", "Ambiguous separated parameter fails closed", "Get-Content -P safe.txt", + "The following token cannot safely be treated as either a bound value or a positional path."), + A("unproved_single_letter_psdrive", "Unproved single-letter PSDrive fails closed", "Get-Content Z:\\x", + "A single-letter drive is not proved FileSystem merely from its spelling."), + A("unproved_psdrive_redirect", "Unproved PSDrive redirect fails closed", "Write-Output ok > Z:\\x", + "Redirect path semantics require a proved PSDrive provider mapping."), + A("drive_relative_path", "Drive-relative path fails closed", "Get-Content C:relative.txt", + "C:relative.txt depends on PowerShell's per-drive current location, which parser options do not model."), + A("drive_relative_redirect", "Drive-relative redirect fails closed", "Write-Output ok > C:relative.txt", + "A configured C drive does not prove its drive-relative current location."), + A("quoted_member_suffix", "Quoted member-looking suffix remains exact", "Get-Content -Path $HOME\".Length\"", + "The quote boundary makes .Length literal text rather than a MemberExpressionAst."), + A("dynamic_command_identity", "Opaque PowerShell command identity remains dynamic", "Get-$(Write-Output Content) /etc/passwd", + "Adjacent aggregation preserves the opaque executable identity through VerbChain.IsDynamic."), + A("empty_path_value", "Empty PowerShell path value fails closed", "Get-Content \"\"", + "An empty filesystem argument is not a resolvable static path."), + A("runtime_question_parameter_path", "Runtime PowerShell status variable fails closed", "Get-Content \"$?\"", + "The special variable has a runtime value and cannot be reclassified as literal punctuation."), + A("runtime_numeric_variable_path", "Runtime PowerShell numeric variable fails closed", "Get-Content \"$1\"", + "Numeric variable identity is retained while its runtime path value remains unknown."), + A("runtime_unicode_variable_path", "Runtime PowerShell Unicode variable fails closed", "Get-Content \"$é\"", + "Unicode variable names are recognized expansions rather than literal filenames."), + A("unterminated_braced_interpolation", "Unterminated PowerShell braced interpolation is unparseable", "Get-Content \"${HOME\"", + "PowerShell reports parser errors; no compatibility path is exposed."), + A("escaped_open_brace_literal_path", "Escaped PowerShell interpolation start remains literal", "Get-Content \"`${HOME\"", + "The backtick escapes the dollar, so the open brace remains exact literal data."), + A("runtime_scoped_variable_path", "Runtime PowerShell scoped variable fails closed", "Get-Content \"$global:scoped\"", + "Scoped variable identity is retained while its runtime filesystem value remains unknown."), + A("runtime_braced_variable_path", "Runtime PowerShell braced variable fails closed", "Get-Content \"${braced-name}\"", + "A braced variable name is a recognized expansion rather than an exact literal filename."), + + S("v03_mixed_list_pipeline", + "gci | Select-Object Name && Get-Date; Get-Process", + "A pipeline followed by && and ; pins statement-versus-pipeline structure."), }; } diff --git a/tools/PwshCorpusTool/Program.cs b/tools/PwshCorpusTool/Program.cs index c6f19f4..7286e55 100644 --- a/tools/PwshCorpusTool/Program.cs +++ b/tools/PwshCorpusTool/Program.cs @@ -60,7 +60,14 @@ int Generate(string outputDir) var input = entry.ResolveInput(); var parsed = parser.Parse(input); var json = CorpusJson.BuildEntry( - entry.Name, input, parsed, entry.Notes, entry.OutOfScope, entry.IncludeElements); + entry.Name, + input, + parsed, + entry.Notes, + entry.OutOfScope, + entry.IncludeElements, + entry.IncludeStructure, + entry.IncludeOptionalAssertions); var fileName = $"{index:D3}_{entry.Slug}.json"; File.WriteAllText(Path.Combine(outputDir, fileName), json); index++; @@ -81,7 +88,14 @@ int Check(string command) var parsed = parser.Parse(command); Console.WriteLine("---- parser expected AST ----"); Console.WriteLine(CorpusJson.BuildEntry( - "check", command, parsed, "ad-hoc check", parsed.IsUnparseable, includeElements: true)); + "check", + command, + parsed, + "ad-hoc check", + parsed.IsUnparseable, + includeElements: true, + includeStructure: true, + includeOptionalAssertions: true)); Console.WriteLine("---- real pwsh oracle ----"); var counts = PwshOracle.CountParseErrors(new[] { command });