diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index a70ab80..9020b7b 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -223,8 +223,19 @@ priorities.
and assembly-only closure mechanism. Until the projection passes land,
`Syntax` is an empty block and `Commands` is empty, so early use remains
fail-closed while v0.2 `Clauses` behavior is unchanged.
-- [ ] Add the structural and command-occurrence projections for the existing
- grammar before enabling any control-flow construct.
+- [x] Add the parser-owned structural projector and conservative compatibility
+ flattener. It walks every syntax shape in deterministic authored order,
+ assigns immediate roles and compositional ancestry, preserves the exact
+ `Clause` instance and its authored operator, joins parser-owned analysis
+ facts without mutating compatibility leaves, and discards every partial
+ projection on malformed, aliased, cyclic, or over-depth structure or
+ invalid joined facts. Direct tests pin ordering, branch and pipeline
+ precedence, ancestry coordinates, reference identity, span and enum
+ validity, value/redirect invariants, safe defaults, copied collections,
+ and the 16-container bound.
+- [ ] Adapt the existing Bash and PowerShell grammars to emit the structural
+ and command-occurrence projections before enabling any control-flow
+ construct.
- [ ] Deliver paired Bash and PowerShell `$()` substitution slices for all
locked executable value positions, including ordering, ancestry,
shell-specific cwd propagation, literal/escaped boundaries, dynamic
diff --git a/SPEC.md b/SPEC.md
index a08b599..e1d03f7 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -490,6 +490,30 @@ zero-based `ChildIndex` in the structural collection that owns the
`CommandSubstitutionSyntax`. For a simple command this is its `Substitutions`
collection; for an iterator it is the containing iterator-command collection.
+Each ancestry frame describes the relationship from its `AncestorKind` to the
+next node on the path. The root block uses `Root`; non-root blocks and command
+lists use `Statement`; pipelines use `PipelineStage`; groups use `GroupBody`;
+foreach nodes use `Iterator` or `LoopBody`; condition loops use `Condition` or
+`LoopBody`; conditionals use `Branch`; conditional-branch nodes use
+`Condition` or `Branch`; and command substitutions use `Substitution`.
+Repeated children use their zero-based authored index. The `else` child uses
+the branch count, placing it after every condition/body pair. Frame source
+ranges belong to the ancestor. Blocks, command lists, and groups retain the
+incoming immediate role; pipeline stages, iterator/body regions,
+condition/body regions, branches, and substitutions replace it with their
+nearer execution role.
+
+Projection accepts only a parser-owned tree: a syntax-node or `Clause`
+reference cannot appear at two authored positions, node and fragment spans are
+either both unavailable or a non-negative start/length pair, and structural
+enum values consumed by the projector must be known. Empty blocks remain
+valid, but empty pipelines, command lists, and conditionals are malformed.
+Joined value domains, effective-argument coordinates, cwd facts, redirect
+coordinates, redirect shapes, and heredoc facts must satisfy their contracts.
+Any violation discards the partial `Commands` and `Clauses` collections and
+makes the outer parse unparseable; it is never published as a complete
+occurrence.
+
`EffectiveArgument.ClauseElementIndex` is a stable authored coordinate. The
analysis never mutates a compatibility `Arg` to hold loop-specific values.
An occurrence can be structurally complete while one effective value remains
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 aee727e..8d680e2 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
@@ -25,6 +25,49 @@ their enum zero values. Ancestry SHALL be ordered outermost to innermost,
exclude the simple-command leaf, and retain child indices and exact-or-null
source ranges for correlation.
+Each frame SHALL describe the relationship from its ancestor to the next node
+on the path. The root block SHALL use `Root`; non-root blocks and command lists
+SHALL use `Statement`; pipelines SHALL use `PipelineStage`; groups SHALL use
+`GroupBody`; foreach nodes SHALL use `Iterator` or `LoopBody`; condition loops
+SHALL use `Condition` or `LoopBody`; conditionals SHALL use `Branch`;
+conditional-branch nodes SHALL use `Condition` or `Branch`; and substitutions
+SHALL use `Substitution`. Repeated children SHALL use their zero-based authored
+index, with an `else` child indexed after all conditional branches. Frame
+source ranges SHALL identify the ancestor. Blocks, command lists, and groups
+SHALL retain the incoming immediate role; a nearer pipeline, iterator, body,
+condition, branch, or substitution relation SHALL replace it.
+
+#### Scenario: Root and nested block coordinates are deterministic
+- **WHEN** a root statement contains a loop-body pipeline
+- **THEN** a stage occurrence has outer-to-inner `Root`, `LoopBody`,
+ `Statement`, and `PipelineStage` ancestry
+- **THEN** each repeated relation carries its authored child index
+
+### Requirement: Projection rejects malformed parser-owned structure and facts
+The projector SHALL accept only a tree with one syntax-node and one `Clause`
+reference per authored simple-command position. Node and source-fragment spans
+SHALL be both unavailable or a non-negative start/length pair. Structural enum
+values consumed by projection SHALL be known. Empty blocks MAY be valid, but
+empty pipelines, command lists, and conditionals SHALL be rejected.
+
+Value domains, cwd facts, effective-argument coordinates, redirect
+coordinates, redirect shapes, and heredoc facts SHALL satisfy their locked
+record invariants before projection succeeds. Any repeated identity, malformed
+shape, invalid coordinate, cycle, or depth overflow SHALL discard every
+partial command and compatibility result.
+
+#### Scenario: Shared leaf identity is not counted twice
+- **WHEN** an internal parser bug places one syntax leaf or `Clause` reference
+ at two authored positions
+- **THEN** projection fails instead of emitting two occurrences
+- **THEN** no partial command or compatibility projection is returned
+
+#### Scenario: Complete occurrence cannot contain invalid facts
+- **WHEN** parser-owned analysis supplies an invalid value domain, argument
+ coordinate, redirect shape, or unknown scope-affecting structural kind
+- **THEN** projection fails closed
+- **THEN** the occurrence is not published with `IsComplete=true`
+
#### Scenario: While condition and body roles
- **WHEN** Bash parses `while curl URL; do sleep 1; done`
- **THEN** `curl` is identified as a condition occurrence
diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
index 4cc8c91..853c955 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
@@ -25,8 +25,8 @@
- [x] 3.1 Add the locked public syntax-node hierarchy and defaults to the public API snapshot.
- [x] 3.2 Add the locked command-occurrence, role, ancestry, completeness, and analysis records to the public API snapshot.
- [x] 3.3 Add `ParsedCommand.Syntax` and `ParsedCommand.Commands` while retaining all v0.2 members.
-- [ ] 3.4 Build a library-owned traversal that emits each simple command occurrence exactly once in deterministic source order.
-- [ ] 3.5 Build the conservative `Clauses` compatibility flattener without inventing cross-structure compound operators.
+- [x] 3.4 Build a library-owned traversal that emits each simple command occurrence exactly once in deterministic source order.
+- [x] 3.5 Build the conservative `Clauses` compatibility flattener without inventing cross-structure compound operators.
- [ ] 3.6 Adapt the existing Bash grammar to emit the structural model with no newly supported syntax.
- [ ] 3.7 Adapt the existing PowerShell grammar to emit the structural model with no newly supported syntax.
- [ ] 3.8 Add tests proving existing parser inputs retain their v0.2 leaf and compatibility results.
diff --git a/src/ShellSyntaxTree/ShellSyntaxProjection.cs b/src/ShellSyntaxTree/ShellSyntaxProjection.cs
new file mode 100644
index 0000000..348e0bf
--- /dev/null
+++ b/src/ShellSyntaxTree/ShellSyntaxProjection.cs
@@ -0,0 +1,744 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+
+namespace ShellSyntaxTree;
+
+///
+/// Parser-owned analysis that is joined to a simple-command leaf while its
+/// structural projections are built.
+///
+internal sealed class CommandOccurrenceFacts
+{
+ internal IReadOnlyList EffectiveArguments { get; init; } =
+ Array.Empty();
+
+ internal ShellValueDomain WorkingDirectory { get; init; } = ShellValueDomain.Unknown;
+
+ internal IReadOnlyList Redirects { get; init; } =
+ Array.Empty();
+
+ internal bool IsComplete { get; init; }
+}
+
+/// One successful projection of a parser-owned syntax tree.
+internal sealed class ShellProjectionResult
+{
+ internal static ShellProjectionResult Empty { get; } = new();
+
+ internal IReadOnlyList Commands { get; init; } =
+ Array.Empty();
+
+ internal IReadOnlyList Clauses { get; init; } = Array.Empty();
+}
+
+///
+/// Owns executable-command discovery so security consumers never need to
+/// recursively match syntax-node types.
+///
+internal static class ShellSyntaxProjection
+{
+ internal static bool TryProject(
+ ShellBlockSyntax syntax,
+ out ShellProjectionResult result) =>
+ TryProject(syntax, _ => new CommandOccurrenceFacts(), out result);
+
+ internal static bool TryProject(
+ ShellBlockSyntax syntax,
+ Func factsFactory,
+ out ShellProjectionResult result)
+ {
+ if (syntax is null || factsFactory is null)
+ {
+ result = ShellProjectionResult.Empty;
+ return false;
+ }
+
+ var walker = new ProjectionWalker(factsFactory);
+ return walker.TryProject(syntax, out result);
+ }
+
+ private sealed class ProjectionWalker
+ {
+ private readonly Func _factsFactory;
+ private readonly List _ancestry = new();
+ private readonly List _commands = new();
+ private readonly List _clauses = new();
+ private readonly HashSet _visitedNodes =
+ new(NodeReferenceComparer.Instance);
+ private readonly HashSet _visitedClauses =
+ new(ClauseReferenceComparer.Instance);
+
+ internal ProjectionWalker(
+ Func factsFactory)
+ {
+ _factsFactory = factsFactory;
+ }
+
+ internal bool TryProject(
+ ShellBlockSyntax syntax,
+ out ShellProjectionResult result)
+ {
+ if (!TryVisit(
+ syntax,
+ CommandOccurrenceRole.Ordinary,
+ structuralDepth: 0,
+ isRoot: true))
+ {
+ result = ShellProjectionResult.Empty;
+ return false;
+ }
+
+ result = new ShellProjectionResult
+ {
+ Commands = _commands.ToArray(),
+ Clauses = _clauses.ToArray(),
+ };
+ return true;
+ }
+
+ private bool TryVisit(
+ ShellSyntaxNode node,
+ CommandOccurrenceRole role,
+ int structuralDepth,
+ bool isRoot = false,
+ int? substitutionChildIndex = null)
+ {
+ if (node is null ||
+ !IsValidSpan(node.SourceStart, node.SourceLength) ||
+ !_visitedNodes.Add(node))
+ {
+ return false;
+ }
+
+ var nextDepth = structuralDepth + (CountsTowardStructuralDepth(node) ? 1 : 0);
+ if (nextDepth > ShellAnalysisLimits.MaxStructuralNesting)
+ {
+ return false;
+ }
+
+ var succeeded = node switch
+ {
+ ShellBlockSyntax block => TryVisitBlock(block, role, nextDepth, isRoot),
+ SimpleCommandSyntax simple => TryVisitSimple(simple, role, nextDepth),
+ PipelineSyntax pipeline => TryVisitPipeline(pipeline, nextDepth),
+ CommandListSyntax list => TryVisitCommandList(list, role, nextDepth),
+ GroupSyntax group =>
+ group.GroupKind is ShellGroupKind.CurrentScope or ShellGroupKind.IsolatedScope &&
+ group.Body is not null &&
+ TryVisitChild(
+ group,
+ group.Body,
+ CommandAncestryRegion.GroupBody,
+ childIndex: null,
+ role,
+ nextDepth),
+ ForEachSyntax forEach => TryVisitForEach(forEach, nextDepth),
+ ConditionLoopSyntax loop =>
+ loop.LoopKind is ConditionLoopKind.While or ConditionLoopKind.Until &&
+ TryVisitConditionLoop(loop, nextDepth),
+ ConditionalSyntax conditional => TryVisitConditional(conditional, role, nextDepth),
+ ConditionalBranchSyntax branch => TryVisitConditionalBranch(branch, nextDepth),
+ CommandSubstitutionSyntax substitution =>
+ TryVisitCommandSubstitution(
+ substitution,
+ substitutionChildIndex,
+ nextDepth),
+ _ => false,
+ };
+
+ return succeeded;
+ }
+
+ private bool TryVisitBlock(
+ ShellBlockSyntax block,
+ CommandOccurrenceRole role,
+ int structuralDepth,
+ bool isRoot)
+ {
+ if (block.Statements is null)
+ {
+ return false;
+ }
+
+ var region = isRoot
+ ? CommandAncestryRegion.Root
+ : CommandAncestryRegion.Statement;
+ for (var index = 0; index < block.Statements.Count; index++)
+ {
+ var statement = block.Statements[index];
+ if (statement is null ||
+ !TryVisitChild(
+ block,
+ statement,
+ region,
+ index,
+ role,
+ structuralDepth))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool TryVisitSimple(
+ SimpleCommandSyntax simple,
+ CommandOccurrenceRole role,
+ int structuralDepth)
+ {
+ if (simple.Substitutions is null)
+ {
+ return false;
+ }
+
+ for (var index = 0; index < simple.Substitutions.Count; index++)
+ {
+ var substitution = simple.Substitutions[index];
+ if (substitution is null ||
+ !TryVisit(
+ substitution,
+ CommandOccurrenceRole.Substitution,
+ structuralDepth,
+ substitutionChildIndex: index))
+ {
+ return false;
+ }
+ }
+
+ if (simple.Clause is null ||
+ !IsValidClauseShape(simple.Clause) ||
+ !_visitedClauses.Add(simple.Clause))
+ {
+ return false;
+ }
+
+ var facts = _factsFactory(simple);
+ if (!TryCopyFacts(
+ simple.Clause,
+ facts,
+ out var effectiveArguments,
+ out var workingDirectory,
+ out var redirects))
+ {
+ return false;
+ }
+
+ _commands.Add(new CommandOccurrence
+ {
+ Clause = simple.Clause,
+ ImmediateRole = role,
+ Ancestry = _ancestry.ToArray(),
+ EffectiveArguments = effectiveArguments,
+ WorkingDirectory = workingDirectory,
+ Redirects = redirects,
+ IsComplete = facts.IsComplete,
+ });
+ _clauses.Add(simple.Clause);
+ return true;
+ }
+
+ private bool TryVisitCommandSubstitution(
+ CommandSubstitutionSyntax substitution,
+ int? childIndex,
+ int structuralDepth) =>
+ substitution.Body is not null &&
+ TryVisitChild(
+ substitution,
+ substitution.Body,
+ CommandAncestryRegion.Substitution,
+ childIndex,
+ CommandOccurrenceRole.Substitution,
+ structuralDepth);
+
+ private bool TryVisitPipeline(
+ PipelineSyntax pipeline,
+ int structuralDepth)
+ {
+ if (pipeline.Stages is null || pipeline.Stages.Count == 0)
+ {
+ return false;
+ }
+
+ for (var index = 0; index < pipeline.Stages.Count; index++)
+ {
+ var stage = pipeline.Stages[index];
+ if (stage is null ||
+ !TryVisitChild(
+ pipeline,
+ stage,
+ CommandAncestryRegion.PipelineStage,
+ index,
+ CommandOccurrenceRole.PipelineStage,
+ structuralDepth))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool TryVisitCommandList(
+ CommandListSyntax list,
+ CommandOccurrenceRole role,
+ int structuralDepth)
+ {
+ if (list.Items is null || list.Items.Count == 0)
+ {
+ return false;
+ }
+
+ for (var index = 0; index < list.Items.Count; index++)
+ {
+ var item = list.Items[index];
+ if (item is null ||
+ item.Command is null ||
+ !Enum.IsDefined(typeof(CompoundOperator), item.Operator) ||
+ !TryVisitChild(
+ list,
+ item.Command,
+ CommandAncestryRegion.Statement,
+ index,
+ role,
+ structuralDepth))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool TryVisitForEach(
+ ForEachSyntax forEach,
+ int structuralDepth)
+ {
+ if (forEach.Binding is null ||
+ string.IsNullOrEmpty(forEach.Binding.Name) ||
+ forEach.Binding.Source is null ||
+ !IsValidSourceFragment(forEach.Binding.Source) ||
+ forEach.Iterable is null ||
+ !IsValidSourceFragment(forEach.Iterable) ||
+ forEach.IteratorCommands is null ||
+ forEach.Body is null)
+ {
+ return false;
+ }
+
+ return TryVisitChild(
+ forEach,
+ forEach.IteratorCommands,
+ CommandAncestryRegion.Iterator,
+ childIndex: null,
+ CommandOccurrenceRole.Iterator,
+ structuralDepth) &&
+ TryVisitChild(
+ forEach,
+ forEach.Body,
+ CommandAncestryRegion.LoopBody,
+ childIndex: null,
+ CommandOccurrenceRole.LoopBody,
+ structuralDepth);
+ }
+
+ private bool TryVisitConditionLoop(
+ ConditionLoopSyntax loop,
+ int structuralDepth)
+ {
+ if (loop.Condition is null || loop.Body is null)
+ {
+ return false;
+ }
+
+ return TryVisitChild(
+ loop,
+ loop.Condition,
+ CommandAncestryRegion.Condition,
+ childIndex: null,
+ CommandOccurrenceRole.Condition,
+ structuralDepth) &&
+ TryVisitChild(
+ loop,
+ loop.Body,
+ CommandAncestryRegion.LoopBody,
+ childIndex: null,
+ CommandOccurrenceRole.LoopBody,
+ structuralDepth);
+ }
+
+ private bool TryVisitConditional(
+ ConditionalSyntax conditional,
+ CommandOccurrenceRole role,
+ int structuralDepth)
+ {
+ if (conditional.Branches is null || conditional.Branches.Count == 0)
+ {
+ return false;
+ }
+
+ for (var index = 0; index < conditional.Branches.Count; index++)
+ {
+ var branch = conditional.Branches[index];
+ if (branch is null ||
+ !TryVisitChild(
+ conditional,
+ branch,
+ CommandAncestryRegion.Branch,
+ index,
+ role,
+ structuralDepth))
+ {
+ return false;
+ }
+ }
+
+ return conditional.Else is null ||
+ TryVisitChild(
+ conditional,
+ conditional.Else,
+ CommandAncestryRegion.Branch,
+ conditional.Branches.Count,
+ CommandOccurrenceRole.Branch,
+ structuralDepth);
+ }
+
+ private bool TryVisitConditionalBranch(
+ ConditionalBranchSyntax branch,
+ int structuralDepth)
+ {
+ if (branch.Condition is null || branch.Body is null)
+ {
+ return false;
+ }
+
+ return TryVisitChild(
+ branch,
+ branch.Condition,
+ CommandAncestryRegion.Condition,
+ childIndex: null,
+ CommandOccurrenceRole.Condition,
+ structuralDepth) &&
+ TryVisitChild(
+ branch,
+ branch.Body,
+ CommandAncestryRegion.Branch,
+ childIndex: null,
+ CommandOccurrenceRole.Branch,
+ structuralDepth);
+ }
+
+ private bool TryVisitChild(
+ ShellSyntaxNode ancestor,
+ ShellSyntaxNode child,
+ CommandAncestryRegion region,
+ int? childIndex,
+ CommandOccurrenceRole role,
+ int structuralDepth)
+ {
+ _ancestry.Add(new CommandAncestryFrame
+ {
+ AncestorKind = ancestor.Kind,
+ Region = region,
+ ChildIndex = childIndex,
+ SourceStart = ancestor.SourceStart,
+ SourceLength = ancestor.SourceLength,
+ });
+ var succeeded = TryVisit(
+ child,
+ role,
+ structuralDepth,
+ substitutionChildIndex: child is CommandSubstitutionSyntax
+ ? childIndex
+ : null);
+ _ancestry.RemoveAt(_ancestry.Count - 1);
+ return succeeded;
+ }
+
+ private static bool CountsTowardStructuralDepth(ShellSyntaxNode node) =>
+ node is ForEachSyntax or
+ ConditionLoopSyntax or
+ ConditionalSyntax or
+ GroupSyntax or
+ CommandSubstitutionSyntax;
+
+ private static bool TryCopyFacts(
+ Clause clause,
+ CommandOccurrenceFacts facts,
+ out IReadOnlyList effectiveArguments,
+ out ShellValueDomain workingDirectory,
+ out IReadOnlyList redirects)
+ {
+ effectiveArguments = Array.Empty();
+ workingDirectory = ShellValueDomain.Unknown;
+ redirects = Array.Empty();
+ if (facts is null ||
+ facts.EffectiveArguments is null ||
+ facts.WorkingDirectory is null ||
+ facts.Redirects is null ||
+ ContainsNull(facts.EffectiveArguments) ||
+ ContainsNull(facts.Redirects) ||
+ !IsValidValueDomain(facts.WorkingDirectory) ||
+ facts.WorkingDirectory.Kind is not (
+ ShellValueDomainKind.Unknown or ShellValueDomainKind.Exact) ||
+ !AreValidEffectiveArguments(clause, facts.EffectiveArguments) ||
+ !AreValidRedirects(clause, facts.Redirects, facts.IsComplete) ||
+ facts.IsComplete &&
+ (clause.Verb.IsDynamic || clause.Verb.Tokens.Count == 0))
+ {
+ return false;
+ }
+
+ effectiveArguments = Copy(facts.EffectiveArguments);
+ workingDirectory = facts.WorkingDirectory;
+ redirects = Copy(facts.Redirects);
+ return true;
+ }
+
+ private static bool IsValidClauseShape(Clause clause) =>
+ Enum.IsDefined(typeof(CompoundOperator), clause.Operator) &&
+ clause.Verb is not null &&
+ clause.Verb.Tokens is not null &&
+ clause.Args is not null &&
+ clause.Redirects is not null &&
+ clause.Elements is not null &&
+ !ContainsNull(clause.Verb.Tokens) &&
+ !ContainsNull(clause.Args) &&
+ !ContainsNull(clause.Redirects) &&
+ !ContainsNull(clause.Elements);
+
+ private static bool AreValidEffectiveArguments(
+ Clause clause,
+ IReadOnlyList arguments)
+ {
+ var coordinates = new HashSet();
+ for (var index = 0; index < arguments.Count; index++)
+ {
+ var argument = arguments[index];
+ if (argument.ClauseElementIndex < 0 ||
+ argument.ClauseElementIndex >= clause.Elements.Count ||
+ clause.Elements[argument.ClauseElementIndex].Role != ClauseElementRole.Argument ||
+ !coordinates.Add(argument.ClauseElementIndex) ||
+ argument.Value is null ||
+ !IsValidValueDomain(argument.Value))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool AreValidRedirects(
+ Clause clause,
+ IReadOnlyList redirects,
+ bool occurrenceIsComplete)
+ {
+ if (occurrenceIsComplete && redirects.Count != clause.Redirects.Count)
+ {
+ return false;
+ }
+
+ var coordinates = new HashSet();
+ for (var index = 0; index < redirects.Count; index++)
+ {
+ var redirect = redirects[index];
+ if (redirect.RedirectIndex < 0 ||
+ redirect.RedirectIndex >= clause.Redirects.Count ||
+ !coordinates.Add(redirect.RedirectIndex) ||
+ occurrenceIsComplete && !redirect.IsComplete ||
+ !IsValidRedirect(redirect))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsValidRedirect(RedirectAnalysis redirect)
+ {
+ if (redirect.Source is null ||
+ redirect.Target is null ||
+ !IsValidRedirectSource(redirect.Source) ||
+ !IsValidValueDomain(redirect.Target))
+ {
+ return false;
+ }
+
+ if (redirect.IsComplete && redirect.Source.Kind == RedirectSourceKind.Unknown)
+ {
+ return false;
+ }
+
+ return redirect.Operation switch
+ {
+ RedirectOperation.Unknown =>
+ !redirect.IsComplete &&
+ redirect.TargetDescriptor is null &&
+ redirect.HereDocument is null &&
+ !redirect.IsPathRelevant &&
+ redirect.Target.Kind == ShellValueDomainKind.Unknown,
+ RedirectOperation.FileInput or
+ RedirectOperation.FileOutput or
+ RedirectOperation.FileAppend or
+ RedirectOperation.CombinedOutput or
+ RedirectOperation.CombinedOutputAppend =>
+ redirect.TargetDescriptor is null &&
+ redirect.HereDocument is null &&
+ redirect.IsPathRelevant,
+ RedirectOperation.DescriptorDuplicate or RedirectOperation.DescriptorMove =>
+ (!redirect.IsComplete || redirect.TargetDescriptor >= 0) &&
+ (redirect.TargetDescriptor is null || redirect.TargetDescriptor >= 0) &&
+ redirect.HereDocument is null &&
+ !redirect.IsPathRelevant &&
+ redirect.Target.Kind == ShellValueDomainKind.Unknown,
+ RedirectOperation.DescriptorClose =>
+ redirect.TargetDescriptor is null &&
+ redirect.HereDocument is null &&
+ !redirect.IsPathRelevant &&
+ redirect.Target.Kind == ShellValueDomainKind.Unknown,
+ RedirectOperation.HereDocument =>
+ redirect.TargetDescriptor is null &&
+ redirect.HereDocument is not null &&
+ !redirect.IsPathRelevant &&
+ redirect.Target.Kind == ShellValueDomainKind.Unknown &&
+ IsValidHereDocument(redirect.HereDocument, redirect.IsComplete),
+ RedirectOperation.HereString =>
+ redirect.TargetDescriptor is null &&
+ redirect.HereDocument is null &&
+ !redirect.IsPathRelevant,
+ _ => false,
+ };
+ }
+
+ private static bool IsValidRedirectSource(RedirectSource source) =>
+ source.Kind switch
+ {
+ RedirectSourceKind.Unknown => source.Descriptor is null,
+ RedirectSourceKind.Default => source.Descriptor is null,
+ RedirectSourceKind.Descriptor => source.Descriptor >= 0,
+ RedirectSourceKind.PowerShellAllStreams => source.Descriptor is null,
+ _ => false,
+ };
+
+ private static bool IsValidHereDocument(
+ HereDocumentAnalysis hereDocument,
+ bool redirectIsComplete) =>
+ hereDocument.Delimiter is not null &&
+ hereDocument.Body is not null &&
+ IsValidSourceFragment(hereDocument.Delimiter) &&
+ IsValidSourceFragment(hereDocument.Body) &&
+ hereDocument.ExpansionMode is
+ HereDocumentExpansionMode.Unknown or
+ HereDocumentExpansionMode.Literal or
+ HereDocumentExpansionMode.Expand &&
+ (!hereDocument.IsComplete ||
+ hereDocument.ExpansionMode is
+ HereDocumentExpansionMode.Literal or HereDocumentExpansionMode.Expand) &&
+ (!redirectIsComplete || hereDocument.IsComplete);
+
+ private static bool IsValidSourceFragment(ShellSourceFragment fragment) =>
+ fragment.Raw is not null &&
+ IsValidSpan(fragment.SourceStart, fragment.SourceLength);
+
+ private static bool IsValidSpan(int? sourceStart, int? sourceLength) =>
+ sourceStart.HasValue == sourceLength.HasValue &&
+ (!sourceStart.HasValue || sourceStart >= 0 && sourceLength >= 0);
+
+ private static bool IsValidValueDomain(ShellValueDomain domain)
+ {
+ if (domain.Values is null || ContainsNull(domain.Values))
+ {
+ return false;
+ }
+
+ return domain.Kind switch
+ {
+ ShellValueDomainKind.Unknown =>
+ domain.Values.Count == 0 &&
+ domain.Pattern is null &&
+ domain.CoveringDirectory is null,
+ ShellValueDomainKind.Exact =>
+ domain.Values.Count == 1 &&
+ domain.Pattern is null &&
+ domain.CoveringDirectory is null,
+ ShellValueDomainKind.FiniteSet =>
+ domain.Values.Count >= 2 &&
+ domain.Values.Count <= ShellAnalysisLimits.MaxValueCandidates &&
+ AreDistinct(domain.Values) &&
+ domain.Pattern is null &&
+ domain.CoveringDirectory is null,
+ ShellValueDomainKind.Pattern =>
+ domain.Values.Count == 0 &&
+ !string.IsNullOrEmpty(domain.Pattern) &&
+ !string.IsNullOrEmpty(domain.CoveringDirectory),
+ _ => false,
+ };
+ }
+
+ private static bool AreDistinct(IReadOnlyList values)
+ {
+ var distinct = new HashSet(StringComparer.Ordinal);
+ for (var index = 0; index < values.Count; index++)
+ {
+ if (!distinct.Add(values[index]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static T[] Copy(IReadOnlyList items)
+ {
+ var copy = new T[items.Count];
+ for (var index = 0; index < items.Count; index++)
+ {
+ copy[index] = items[index];
+ }
+
+ return copy;
+ }
+
+ private static bool ContainsNull(IReadOnlyList items)
+ where T : class
+ {
+ for (var index = 0; index < items.Count; index++)
+ {
+ if (items[index] is null)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ private sealed class NodeReferenceComparer : IEqualityComparer
+ {
+ internal static NodeReferenceComparer Instance { get; } = new();
+
+ public bool Equals(ShellSyntaxNode? x, ShellSyntaxNode? y) =>
+ ReferenceEquals(x, y);
+
+ public int GetHashCode(ShellSyntaxNode obj) => RuntimeHelpers.GetHashCode(obj);
+ }
+
+ private sealed class ClauseReferenceComparer : IEqualityComparer
+ {
+ internal static ClauseReferenceComparer Instance { get; } = new();
+
+ public bool Equals(Clause? x, Clause? y) => ReferenceEquals(x, y);
+
+ public int GetHashCode(Clause obj) => RuntimeHelpers.GetHashCode(obj);
+ }
+}
diff --git a/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs b/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs
new file mode 100644
index 0000000..9a4a071
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/ShellSyntaxProjectionTests.cs
@@ -0,0 +1,1023 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+
+namespace ShellSyntaxTree.Tests;
+
+/// Tests parser-owned command and compatibility projections.
+public class ShellSyntaxProjectionTests
+{
+ [Fact]
+ public void Root_command_preserves_identity_and_joins_analysis_facts()
+ {
+ var clause = ClauseFor("echo") with
+ {
+ Elements = new[]
+ {
+ new ClauseElement { Role = ClauseElementRole.Verb },
+ new ClauseElement { Role = ClauseElementRole.Argument },
+ },
+ Redirects = new[] { new Redirect() },
+ };
+ var leaf = new SimpleCommandSyntax
+ {
+ Clause = clause,
+ SourceStart = 0,
+ SourceLength = 10,
+ };
+ var root = new ShellBlockSyntax
+ {
+ SourceStart = 0,
+ SourceLength = 10,
+ Statements = new ShellSyntaxNode[] { leaf },
+ };
+ var effectiveArguments = new[]
+ {
+ new EffectiveArgument
+ {
+ ClauseElementIndex = 1,
+ Value = new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Exact,
+ Values = new[] { "ready" },
+ },
+ },
+ };
+ var redirects = new[]
+ {
+ new RedirectAnalysis
+ {
+ RedirectIndex = 0,
+ Source = new RedirectSource { Kind = RedirectSourceKind.Default },
+ Operation = RedirectOperation.FileOutput,
+ Target = new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Exact,
+ Values = new[] { "/work/out.txt" },
+ },
+ IsPathRelevant = true,
+ IsComplete = true,
+ },
+ };
+ var workingDirectory = new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Exact,
+ Values = new[] { "/work" },
+ };
+
+ var succeeded = ShellSyntaxProjection.TryProject(
+ root,
+ _ => new CommandOccurrenceFacts
+ {
+ EffectiveArguments = effectiveArguments,
+ WorkingDirectory = workingDirectory,
+ Redirects = redirects,
+ IsComplete = true,
+ },
+ out var result);
+
+ Assert.True(succeeded);
+ var occurrence = Assert.Single(result.Commands);
+ Assert.Same(clause, occurrence.Clause);
+ Assert.Same(clause, Assert.Single(result.Clauses));
+ Assert.Equal(CommandOccurrenceRole.Ordinary, occurrence.ImmediateRole);
+ Assert.True(occurrence.IsComplete);
+ Assert.Same(workingDirectory, occurrence.WorkingDirectory);
+ Assert.NotSame(effectiveArguments, occurrence.EffectiveArguments);
+ Assert.Same(effectiveArguments[0], Assert.Single(occurrence.EffectiveArguments));
+ Assert.NotSame(redirects, occurrence.Redirects);
+ Assert.Same(redirects[0], Assert.Single(occurrence.Redirects));
+
+ var rootFrame = Assert.Single(occurrence.Ancestry);
+ Assert.Equal(ShellSyntaxKind.Block, rootFrame.AncestorKind);
+ Assert.Equal(CommandAncestryRegion.Root, rootFrame.Region);
+ Assert.Equal(0, rootFrame.ChildIndex);
+ Assert.Equal(0, rootFrame.SourceStart);
+ Assert.Equal(10, rootFrame.SourceLength);
+ }
+
+ [Fact]
+ public void Embedded_substitution_precedes_its_containing_command()
+ {
+ var find = Leaf("find", 5);
+ var remove = Leaf("rm", 0) with
+ {
+ Substitutions = new[]
+ {
+ Substitution(3, find),
+ },
+ };
+ var root = Block(0, remove);
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(new[] { "find", "rm" }, result.Commands.Select(Verb));
+ Assert.Equal(result.Commands.Select(command => command.Clause), result.Clauses);
+ Assert.Same(find.Clause, result.Clauses[0]);
+ Assert.Same(remove.Clause, result.Clauses[1]);
+ AssertFrames(
+ result.Commands[0],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0),
+ (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ AssertFrames(
+ result.Commands[1],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0));
+ }
+
+ [Fact]
+ public void Sibling_substitutions_use_authored_order_and_child_indices_without_spans()
+ {
+ var first = Leaf("first", 1);
+ var second = Leaf("second", 2);
+ var outer = Leaf("printf", 0) with
+ {
+ Substitutions = new[]
+ {
+ new CommandSubstitutionSyntax
+ {
+ Body = new ShellBlockSyntax
+ {
+ Statements = new ShellSyntaxNode[] { first },
+ },
+ },
+ new CommandSubstitutionSyntax
+ {
+ Body = new ShellBlockSyntax
+ {
+ Statements = new ShellSyntaxNode[] { second },
+ },
+ },
+ },
+ };
+
+ var succeeded = ShellSyntaxProjection.TryProject(Block(0, outer), out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(new[] { "first", "second", "printf" }, result.Commands.Select(Verb));
+ Assert.Equal(0, result.Commands[0].Ancestry[1].ChildIndex);
+ Assert.Equal(1, result.Commands[1].Ancestry[1].ChildIndex);
+ Assert.Null(result.Commands[0].Ancestry[1].SourceStart);
+ Assert.Null(result.Commands[1].Ancestry[1].SourceStart);
+ }
+
+ [Fact]
+ public void Nested_substitutions_are_innermost_first_and_keep_parentage()
+ {
+ var whoami = Leaf("whoami", 9);
+ var echo = Leaf("echo", 5) with
+ {
+ Substitutions = new[]
+ {
+ Substitution(8, whoami),
+ },
+ };
+ var print = Leaf("printf", 0) with
+ {
+ Substitutions = new[]
+ {
+ Substitution(3, echo),
+ },
+ };
+
+ var succeeded = ShellSyntaxProjection.TryProject(Block(0, print), out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(new[] { "whoami", "echo", "printf" }, result.Commands.Select(Verb));
+ AssertFrames(
+ result.Commands[0],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0),
+ (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0),
+ (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ }
+
+ [Fact]
+ public void Iterator_substitutions_keep_nearest_role_and_authored_indices()
+ {
+ var first = Substitution(2, Leaf("first", 3));
+ var second = Substitution(4, Leaf("second", 5));
+ var body = Leaf("body", 8);
+ var loop = new ForEachSyntax
+ {
+ Binding = Binding("item"),
+ Iterable = new ShellSourceFragment { Raw = "$(first) $(second)" },
+ IteratorCommands = Block(1, first, second),
+ Body = Block(7, body),
+ };
+
+ var succeeded = ShellSyntaxProjection.TryProject(Block(0, loop), out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(new[] { "first", "second", "body" }, result.Commands.Select(Verb));
+ Assert.Equal(
+ new[]
+ {
+ CommandOccurrenceRole.Substitution,
+ CommandOccurrenceRole.Substitution,
+ CommandOccurrenceRole.LoopBody,
+ },
+ result.Commands.Select(command => command.ImmediateRole));
+ AssertFrames(
+ result.Commands[0],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0),
+ (ShellSyntaxKind.ForEach, CommandAncestryRegion.Iterator, null),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0),
+ (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 0),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ AssertFrames(
+ result.Commands[1],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0),
+ (ShellSyntaxKind.ForEach, CommandAncestryRegion.Iterator, null),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 1),
+ (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 1),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ }
+
+ [Fact]
+ public void Invalid_or_reused_substitution_children_discard_partial_projections()
+ {
+ var nullCollection = Leaf("null-collection", 0) with
+ {
+ Substitutions = null!,
+ };
+ Assert.False(ShellSyntaxProjection.TryProject(
+ Block(0, nullCollection),
+ out var nullCollectionResult));
+ Assert.Empty(nullCollectionResult.Commands);
+ Assert.Empty(nullCollectionResult.Clauses);
+
+ var nullChild = Leaf("null-child", 0) with
+ {
+ Substitutions = new CommandSubstitutionSyntax[] { null! },
+ };
+ Assert.False(ShellSyntaxProjection.TryProject(
+ Block(0, nullChild),
+ out var nullChildResult));
+ Assert.Empty(nullChildResult.Commands);
+ Assert.Empty(nullChildResult.Clauses);
+
+ var shared = Substitution(1, Leaf("inner", 2));
+ var reused = Leaf("reused", 0) with
+ {
+ Substitutions = new[] { shared, shared },
+ };
+ Assert.False(ShellSyntaxProjection.TryProject(
+ Block(0, reused),
+ out var reusedResult));
+ Assert.Empty(reusedResult.Commands);
+ Assert.Empty(reusedResult.Clauses);
+ }
+
+ [Fact]
+ public void Nested_projection_is_source_ordered_and_uses_nearest_execution_role()
+ {
+ var find = Leaf("find", 12);
+ var iteratorSort = Leaf("sort-iterator", 20);
+ var print = Leaf("printf", 40);
+ var bodySort = Leaf("sort-body", 50);
+ var test = Leaf("test", 70);
+ var remove = Leaf("rm", 80);
+ var probe = Leaf("probe", 90);
+ var echo = Leaf("echo", 100);
+ var fallback = Leaf("fallback", 110);
+ var inner = Leaf("inner", 130);
+
+ var loop = new ForEachSyntax
+ {
+ SourceStart = 0,
+ SourceLength = 60,
+ Binding = Binding("f"),
+ Iterable = new ShellSourceFragment { Raw = "$(find .)" },
+ IteratorCommands = Block(
+ 10,
+ new PipelineSyntax
+ {
+ SourceStart = 10,
+ SourceLength = 20,
+ Stages = new ShellSyntaxNode[] { find, iteratorSort },
+ }),
+ Body = Block(
+ 35,
+ new PipelineSyntax
+ {
+ SourceStart = 35,
+ SourceLength = 25,
+ Stages = new ShellSyntaxNode[] { print, bodySort },
+ }),
+ };
+ var conditional = new ConditionalSyntax
+ {
+ SourceStart = 65,
+ SourceLength = 55,
+ Branches = new[]
+ {
+ new ConditionalBranchSyntax
+ {
+ SourceStart = 65,
+ SourceLength = 20,
+ Condition = Block(68, test),
+ Body = Block(78, remove),
+ },
+ new ConditionalBranchSyntax
+ {
+ SourceStart = 86,
+ SourceLength = 20,
+ Condition = Block(88, probe),
+ Body = Block(98, echo),
+ },
+ },
+ Else = Block(108, fallback),
+ };
+ var substitution = new CommandSubstitutionSyntax
+ {
+ SourceStart = 125,
+ SourceLength = 15,
+ Body = Block(128, inner),
+ };
+ var root = new ShellBlockSyntax
+ {
+ SourceStart = 0,
+ SourceLength = 140,
+ Statements = new ShellSyntaxNode[] { loop, conditional, substitution },
+ };
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(
+ new[]
+ {
+ "find", "sort-iterator", "printf", "sort-body", "test",
+ "rm", "probe", "echo", "fallback", "inner",
+ },
+ result.Commands.Select(Verb));
+ Assert.Equal(
+ new[]
+ {
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.Condition,
+ CommandOccurrenceRole.Branch,
+ CommandOccurrenceRole.Condition,
+ CommandOccurrenceRole.Branch,
+ CommandOccurrenceRole.Branch,
+ CommandOccurrenceRole.Substitution,
+ },
+ result.Commands.Select(command => command.ImmediateRole));
+ Assert.Equal(result.Commands.Select(command => command.Clause), result.Clauses);
+
+ AssertFrames(
+ result.Commands[2],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0),
+ (ShellSyntaxKind.ForEach, CommandAncestryRegion.LoopBody, null),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0),
+ (ShellSyntaxKind.Pipeline, CommandAncestryRegion.PipelineStage, 0));
+ AssertFrames(
+ result.Commands[4],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 1),
+ (ShellSyntaxKind.Conditional, CommandAncestryRegion.Branch, 0),
+ (ShellSyntaxKind.ConditionalBranch, CommandAncestryRegion.Condition, null),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ AssertFrames(
+ result.Commands[8],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 1),
+ (ShellSyntaxKind.Conditional, CommandAncestryRegion.Branch, 2),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ AssertFrames(
+ result.Commands[9],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 2),
+ (ShellSyntaxKind.CommandSubstitution, CommandAncestryRegion.Substitution, 2),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ }
+
+ [Fact]
+ public void Compatibility_projection_preserves_authored_operators_without_synthesizing_relations()
+ {
+ var condition = ClauseFor("test");
+ var thenClause = ClauseFor("rm", CompoundOperator.AndIf);
+ var elseClause = ClauseFor("echo");
+ var root = Block(
+ 0,
+ new ConditionalSyntax
+ {
+ Branches = new[]
+ {
+ new ConditionalBranchSyntax
+ {
+ Condition = Block(1, Leaf(condition, 1)),
+ Body = Block(2, Leaf(thenClause, 2)),
+ },
+ },
+ Else = Block(3, Leaf(elseClause, 3)),
+ });
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(3, result.Clauses.Count);
+ Assert.Same(condition, result.Clauses[0]);
+ Assert.Same(thenClause, result.Clauses[1]);
+ Assert.Same(elseClause, result.Clauses[2]);
+ Assert.Equal(
+ new[] { CompoundOperator.None, CompoundOperator.AndIf, CompoundOperator.None },
+ result.Clauses.Select(clause => clause.Operator));
+ }
+
+ [Fact]
+ public void Sixteen_structural_containers_are_supported()
+ {
+ var root = NestedGroups(ShellAnalysisLimits.MaxStructuralNesting);
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.True(succeeded);
+ Assert.Single(result.Commands);
+ Assert.Equal(
+ (ShellAnalysisLimits.MaxStructuralNesting * 2) + 1,
+ result.Commands[0].Ancestry.Count);
+ }
+
+ [Fact]
+ public void Sixteen_nested_substitutions_are_supported()
+ {
+ var root = NestedSubstitutions(ShellAnalysisLimits.MaxStructuralNesting);
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(ShellAnalysisLimits.MaxStructuralNesting + 1, result.Commands.Count);
+ Assert.Equal(
+ (ShellAnalysisLimits.MaxStructuralNesting * 2) + 1,
+ result.Commands[0].Ancestry.Count);
+ }
+
+ [Fact]
+ public void Seventeenth_nested_substitution_discards_partial_projections()
+ {
+ var root = NestedSubstitutions(ShellAnalysisLimits.MaxStructuralNesting + 1);
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+
+ [Fact]
+ public void Structural_depth_overflow_discards_partial_projections()
+ {
+ var first = Leaf("first", 0);
+ var overflow = NestedGroups(ShellAnalysisLimits.MaxStructuralNesting + 1).Statements[0];
+ var root = Block(0, first, overflow);
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+
+ [Fact]
+ public void Cyclic_syntax_discards_partial_projections()
+ {
+ var statements = new List();
+ var root = new ShellBlockSyntax { Statements = statements };
+ statements.Add(Leaf("first", 0));
+ statements.Add(root);
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+
+ [Fact]
+ public void Reused_node_or_clause_identity_discards_the_whole_projection()
+ {
+ var sharedLeaf = Leaf("shared", 1);
+ var repeatedNode = Block(0, sharedLeaf, sharedLeaf);
+
+ Assert.False(ShellSyntaxProjection.TryProject(repeatedNode, out var nodeResult));
+ Assert.Empty(nodeResult.Commands);
+ Assert.Empty(nodeResult.Clauses);
+
+ var sharedClause = ClauseFor("shared-clause");
+ var repeatedClause = Block(
+ 0,
+ Leaf(sharedClause, 1),
+ Leaf(sharedClause, 2));
+
+ Assert.False(ShellSyntaxProjection.TryProject(repeatedClause, out var clauseResult));
+ Assert.Empty(clauseResult.Commands);
+ Assert.Empty(clauseResult.Clauses);
+ }
+
+ [Fact]
+ public void Unknown_or_empty_structural_shapes_fail_closed()
+ {
+ var invalidNodes = new ShellSyntaxNode[]
+ {
+ new GroupSyntax { Body = Block(1, Leaf("group", 1)) },
+ new ConditionLoopSyntax
+ {
+ Condition = Block(1),
+ Body = Block(2, Leaf("loop", 2)),
+ },
+ new PipelineSyntax(),
+ new CommandListSyntax(),
+ new CommandListSyntax
+ {
+ Items = new[]
+ {
+ new CommandListItemSyntax
+ {
+ Command = Leaf("invalid-operator", 1),
+ Operator = (CompoundOperator)999,
+ },
+ },
+ },
+ new ConditionalSyntax(),
+ new ForEachSyntax
+ {
+ Binding = new LoopBindingSyntax(),
+ Iterable = new ShellSourceFragment(),
+ Body = Block(2, Leaf("foreach", 2)),
+ },
+ new ForEachSyntax
+ {
+ Binding = Binding("item"),
+ Iterable = new ShellSourceFragment
+ {
+ Raw = "value",
+ SourceStart = -1,
+ SourceLength = 1,
+ },
+ Body = Block(2, Leaf("foreach-span", 2)),
+ },
+ };
+
+ foreach (var invalidNode in invalidNodes)
+ {
+ var succeeded = ShellSyntaxProjection.TryProject(
+ Block(0, invalidNode),
+ out var result);
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+ }
+
+ [Fact]
+ public void Group_and_command_list_retain_condition_and_body_roles()
+ {
+ var condition = Leaf("condition", 4);
+ var body = Leaf("body", 12);
+ var root = Block(
+ 0,
+ new ConditionLoopSyntax
+ {
+ LoopKind = ConditionLoopKind.While,
+ Condition = Block(
+ 2,
+ new GroupSyntax
+ {
+ GroupKind = ShellGroupKind.CurrentScope,
+ Body = Block(3, condition),
+ }),
+ Body = Block(
+ 10,
+ new CommandListSyntax
+ {
+ Items = new[]
+ {
+ new CommandListItemSyntax
+ {
+ Command = body,
+ Operator = CompoundOperator.None,
+ },
+ },
+ }),
+ });
+
+ var succeeded = ShellSyntaxProjection.TryProject(root, out var result);
+
+ Assert.True(succeeded);
+ Assert.Equal(CommandOccurrenceRole.Condition, result.Commands[0].ImmediateRole);
+ Assert.Equal(CommandOccurrenceRole.LoopBody, result.Commands[1].ImmediateRole);
+ AssertFrames(
+ result.Commands[0],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0),
+ (ShellSyntaxKind.ConditionLoop, CommandAncestryRegion.Condition, null),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0),
+ (ShellSyntaxKind.Group, CommandAncestryRegion.GroupBody, null),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0));
+ AssertFrames(
+ result.Commands[1],
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Root, 0),
+ (ShellSyntaxKind.ConditionLoop, CommandAncestryRegion.LoopBody, null),
+ (ShellSyntaxKind.Block, CommandAncestryRegion.Statement, 0),
+ (ShellSyntaxKind.CommandList, CommandAncestryRegion.Statement, 0));
+ }
+
+ [Fact]
+ public void Null_spans_are_preserved_but_malformed_spans_fail_closed()
+ {
+ var root = new ShellBlockSyntax
+ {
+ Statements = new ShellSyntaxNode[] { Leaf("nullable-span", 1) },
+ };
+
+ Assert.True(ShellSyntaxProjection.TryProject(root, out var validResult));
+ var frame = Assert.Single(validResult.Commands[0].Ancestry);
+ Assert.Null(frame.SourceStart);
+ Assert.Null(frame.SourceLength);
+
+ var malformed = new PipelineSyntax
+ {
+ SourceStart = 1,
+ Stages = new ShellSyntaxNode[] { Leaf("bad-span", 2) },
+ };
+ Assert.False(ShellSyntaxProjection.TryProject(Block(0, malformed), out var invalidResult));
+ Assert.Empty(invalidResult.Commands);
+ Assert.Empty(invalidResult.Clauses);
+ }
+
+ [Fact]
+ public void Invalid_value_domains_and_argument_coordinates_fail_closed()
+ {
+ var clause = ClauseFor("echo") with
+ {
+ Elements = new[]
+ {
+ new ClauseElement { Role = ClauseElementRole.Verb },
+ new ClauseElement { Role = ClauseElementRole.Argument },
+ },
+ };
+ var root = Block(0, Leaf(clause, 0));
+ var invalidFacts = new Func[]
+ {
+ () => new CommandOccurrenceFacts
+ {
+ WorkingDirectory = new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.FiniteSet,
+ Values = new[] { "/a", "/b" },
+ },
+ },
+ () => new CommandOccurrenceFacts
+ {
+ EffectiveArguments = new[]
+ {
+ new EffectiveArgument
+ {
+ ClauseElementIndex = 0,
+ Value = ShellValueDomain.Unknown,
+ },
+ },
+ },
+ () => new CommandOccurrenceFacts
+ {
+ EffectiveArguments = new[]
+ {
+ new EffectiveArgument
+ {
+ ClauseElementIndex = 1,
+ Value = new ShellValueDomain { Kind = ShellValueDomainKind.Exact },
+ },
+ },
+ },
+ () => new CommandOccurrenceFacts
+ {
+ EffectiveArguments = new[]
+ {
+ new EffectiveArgument
+ {
+ ClauseElementIndex = 1,
+ Value = ShellValueDomain.Unknown,
+ },
+ new EffectiveArgument
+ {
+ ClauseElementIndex = 1,
+ Value = ShellValueDomain.Unknown,
+ },
+ },
+ },
+ };
+
+ foreach (var createFacts in invalidFacts)
+ {
+ var succeeded = ShellSyntaxProjection.TryProject(
+ root,
+ _ => createFacts(),
+ out var result);
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+ }
+
+ [Fact]
+ public void Value_domain_validator_accepts_only_the_four_locked_shapes()
+ {
+ var clause = ClauseFor("echo") with
+ {
+ Elements = new[]
+ {
+ new ClauseElement { Role = ClauseElementRole.Verb },
+ new ClauseElement { Role = ClauseElementRole.Argument },
+ },
+ };
+ var root = Block(0, Leaf(clause, 0));
+ var validDomains = new[]
+ {
+ ShellValueDomain.Unknown,
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Exact,
+ Values = new[] { "one" },
+ },
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.FiniteSet,
+ Values = new[] { "one", "two" },
+ },
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Pattern,
+ Pattern = "/work/*.txt",
+ CoveringDirectory = "/work",
+ },
+ };
+
+ foreach (var domain in validDomains)
+ {
+ var succeeded = ShellSyntaxProjection.TryProject(
+ root,
+ _ => FactsForArgument(domain),
+ out var result);
+ Assert.True(succeeded);
+ Assert.Single(result.Commands);
+ }
+
+ var overLimit = Enumerable.Range(
+ 0,
+ ShellAnalysisLimits.MaxValueCandidates + 1)
+ .Select(index => index.ToString())
+ .ToArray();
+ var invalidDomains = new[]
+ {
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Unknown,
+ Values = new[] { "unexpected" },
+ },
+ new ShellValueDomain { Kind = ShellValueDomainKind.Exact },
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Exact,
+ Values = new[] { "one", "two" },
+ },
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.FiniteSet,
+ Values = new[] { "one" },
+ },
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.FiniteSet,
+ Values = new[] { "duplicate", "duplicate" },
+ },
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.FiniteSet,
+ Values = overLimit,
+ },
+ new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Pattern,
+ Pattern = "/work/*.txt",
+ },
+ new ShellValueDomain { Kind = (ShellValueDomainKind)999 },
+ };
+
+ foreach (var domain in invalidDomains)
+ {
+ var succeeded = ShellSyntaxProjection.TryProject(
+ root,
+ _ => FactsForArgument(domain),
+ out var result);
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+ }
+
+ [Fact]
+ public void Invalid_redirect_facts_fail_closed()
+ {
+ var clause = ClauseFor("echo") with
+ {
+ Redirects = new[] { new Redirect() },
+ };
+ var root = Block(0, Leaf(clause, 0));
+ var invalidRedirects = new[]
+ {
+ new RedirectAnalysis
+ {
+ RedirectIndex = 1,
+ Source = new RedirectSource { Kind = RedirectSourceKind.Default },
+ Operation = RedirectOperation.FileOutput,
+ IsPathRelevant = true,
+ },
+ new RedirectAnalysis
+ {
+ RedirectIndex = 0,
+ Operation = RedirectOperation.FileOutput,
+ IsPathRelevant = true,
+ IsComplete = true,
+ },
+ new RedirectAnalysis
+ {
+ RedirectIndex = 0,
+ Source = new RedirectSource
+ {
+ Kind = RedirectSourceKind.Default,
+ Descriptor = 1,
+ },
+ Operation = RedirectOperation.FileOutput,
+ IsPathRelevant = true,
+ },
+ new RedirectAnalysis
+ {
+ RedirectIndex = 0,
+ Source = new RedirectSource { Kind = RedirectSourceKind.Default },
+ Operation = RedirectOperation.HereDocument,
+ },
+ new RedirectAnalysis
+ {
+ RedirectIndex = 0,
+ Source = new RedirectSource { Kind = RedirectSourceKind.Default },
+ Operation = RedirectOperation.HereDocument,
+ HereDocument = new HereDocumentAnalysis
+ {
+ Delimiter = new ShellSourceFragment
+ {
+ Raw = "EOF",
+ SourceStart = 1,
+ },
+ ExpansionMode = HereDocumentExpansionMode.Literal,
+ },
+ },
+ };
+
+ foreach (var invalidRedirect in invalidRedirects)
+ {
+ var succeeded = ShellSyntaxProjection.TryProject(
+ root,
+ _ => new CommandOccurrenceFacts
+ {
+ Redirects = new[] { invalidRedirect },
+ },
+ out var result);
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+ }
+
+ [Fact]
+ public void Invalid_parser_owned_analysis_discards_partial_projections()
+ {
+ var root = Block(0, Leaf("echo", 0));
+
+ var succeeded = ShellSyntaxProjection.TryProject(
+ root,
+ _ => null!,
+ out var result);
+
+ Assert.False(succeeded);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+
+ private static ShellBlockSyntax NestedGroups(int count)
+ {
+ ShellSyntaxNode current = Leaf("deepest", count);
+ for (var index = 0; index < count; index++)
+ {
+ current = new GroupSyntax
+ {
+ GroupKind = ShellGroupKind.CurrentScope,
+ SourceStart = index,
+ SourceLength = count - index,
+ Body = Block(index, current),
+ };
+ }
+
+ return Block(0, current);
+ }
+
+ private static ShellBlockSyntax NestedSubstitutions(int count)
+ {
+ var current = Leaf("deepest", count);
+ for (var index = count - 1; index >= 0; index--)
+ {
+ current = Leaf($"level-{index}", index) with
+ {
+ Substitutions = new[]
+ {
+ Substitution(index, current),
+ },
+ };
+ }
+
+ return Block(0, current);
+ }
+
+ private static ShellBlockSyntax Block(int sourceStart, params ShellSyntaxNode[] statements) =>
+ new()
+ {
+ SourceStart = sourceStart,
+ SourceLength = statements.Length,
+ Statements = statements,
+ };
+
+ private static SimpleCommandSyntax Leaf(string verb, int sourceStart) =>
+ Leaf(ClauseFor(verb), sourceStart);
+
+ private static SimpleCommandSyntax Leaf(Clause clause, int sourceStart) =>
+ new()
+ {
+ Clause = clause,
+ SourceStart = sourceStart,
+ SourceLength = 1,
+ };
+
+ private static CommandSubstitutionSyntax Substitution(
+ int sourceStart,
+ params ShellSyntaxNode[] statements) =>
+ new()
+ {
+ SourceStart = sourceStart,
+ SourceLength = statements.Length,
+ Body = Block(sourceStart, statements),
+ };
+
+ private static Clause ClauseFor(
+ string verb,
+ CompoundOperator @operator = CompoundOperator.None) =>
+ new()
+ {
+ Operator = @operator,
+ Verb = new VerbChain { Tokens = new[] { verb } },
+ Elements = new[] { new ClauseElement { Role = ClauseElementRole.Verb } },
+ };
+
+ private static LoopBindingSyntax Binding(string name) =>
+ new()
+ {
+ Name = name,
+ Source = new ShellSourceFragment { Raw = name },
+ };
+
+ private static CommandOccurrenceFacts FactsForArgument(ShellValueDomain domain) =>
+ new()
+ {
+ EffectiveArguments = new[]
+ {
+ new EffectiveArgument
+ {
+ ClauseElementIndex = 1,
+ Value = domain,
+ },
+ },
+ };
+
+ private static string Verb(CommandOccurrence occurrence) =>
+ Assert.Single(occurrence.Clause.Verb.Tokens);
+
+ private static void AssertFrames(
+ CommandOccurrence occurrence,
+ params (ShellSyntaxKind Kind, CommandAncestryRegion Region, int? ChildIndex)[] expected)
+ {
+ Assert.Equal(expected.Length, occurrence.Ancestry.Count);
+ for (var index = 0; index < expected.Length; index++)
+ {
+ Assert.Equal(expected[index].Kind, occurrence.Ancestry[index].AncestorKind);
+ Assert.Equal(expected[index].Region, occurrence.Ancestry[index].Region);
+ Assert.Equal(expected[index].ChildIndex, occurrence.Ancestry[index].ChildIndex);
+ }
+ }
+}