From b9d04b367bf7861704c5f34cdc986281941ed77f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 10 May 2026 19:34:17 +0000 Subject: [PATCH] PR 6: corpus completeness + AstAssert.Equal + PII audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the corpus to **115 entries** (target was ≥105 per SPEC §13), introduces a polished AstAssert.Equal helper with diff-friendly failure messages, and adds the PII audit Fact required for v0.1.0-alpha release acceptance. What's added: - tests/.../Corpus/AstAssert.cs: * Equal(expected, actual, fileName) — structural deep-equality with path-prefixed XunitException messages, e.g. "clauses[1].args[2].kind: expected DynamicSkip, actual Literal" * Dump() diagnostic for shape inspection * CorpusRunnerTests refactored to delegate (~140 lines of inline comparison removed) - tests/.../Corpus/PiiAuditTests.cs: * Single [Fact] walks every Corpus/bash/*.json * Applies SPEC §14 regex patterns to input/notes/raw fields only (skips synthetic resolved paths and sentinels) * Allowlists generic placeholders (user, test, foo, dev, runner, gh-actions, ci) and repo names (sample-repo, project, repo) * Reports all hits in a single failure with file paths Corpus additions: - bash/101..110_*.json (10 quote-handling): single-quoted literal, double-quoted with $HOME, escaped quotes, mixed-style triple, FileVerb with quoted path, etc. - bash/111..115_*.json (5 unparseable): process substitution <(cmd) and >(cmd), case keyword, while loop, function name { ... } form Total: 115 entries spanning all SPEC §13 categories. SPEC.md updates: - §13 corpus location: tests/Corpus/bash/* → tests/ShellSyntaxTree.Tests/Corpus/bash/* (the canonical path locked by interpretation #7) - Stale references in §14, §15, §17 acceptance criteria fixed for consistency Tests: 353/353 passing (was 337). Public API surface unchanged; PublicApiSnapshotTests still 18/18 green. PR 6 follow-ups tracked in tasks.md: - Single-quote $HOME substitution (corpus 104 pins v0.1 behavior; bash semantics differ — v0.1.x candidate) - LooksLikePath trailing-backslash quirk (corpus 109 pins; v0.1.x heuristic tightening candidate) - Sentinel Raw value formalization in SPEC §9 - Diagnostic precedence ordering in SPEC §11 (case-with-paren-arg case) --- IMPLEMENTATION_PLAN.md | 59 ++-- SPEC.md | 15 +- .../v0.1-locked-interpretations/tasks.md | 46 ++- .../ShellSyntaxTree.Tests/Corpus/AstAssert.cs | 313 ++++++++++++++++++ .../Corpus/CorpusRunnerTests.cs | 155 +-------- .../Corpus/PiiAuditTests.cs | 238 +++++++++++++ .../bash/101_echo_single_quoted_arg.json | 20 ++ .../bash/102_echo_double_quoted_arg.json | 20 ++ .../bash/103_echo_double_quoted_home.json | 20 ++ .../104_echo_single_quoted_literal_home.json | 20 ++ .../bash/105_git_commit_quoted_message.json | 21 ++ .../bash/106_grep_quoted_pattern_path.json | 21 ++ .../107_cat_quoted_filename_with_spaces.json | 20 ++ ...108_echo_escaped_quotes_inside_double.json | 20 ++ .../109_echo_trailing_backslash_escaped.json | 20 ++ .../bash/110_cmd_mixed_quote_styles.json | 22 ++ .../bash/111_unparseable_process_sub_in.json | 9 + .../bash/112_unparseable_process_sub_out.json | 9 + .../Corpus/bash/113_unparseable_case.json | 9 + .../bash/114_unparseable_while_loop.json | 9 + ...115_unparseable_function_keyword_form.json | 9 + 21 files changed, 886 insertions(+), 189 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/101_echo_single_quoted_arg.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/102_echo_double_quoted_arg.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/103_echo_double_quoted_home.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/104_echo_single_quoted_literal_home.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/105_git_commit_quoted_message.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/106_grep_quoted_pattern_path.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/107_cat_quoted_filename_with_spaces.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/108_echo_escaped_quotes_inside_double.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/109_echo_trailing_backslash_escaped.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/110_cmd_mixed_quote_styles.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/111_unparseable_process_sub_in.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/112_unparseable_process_sub_out.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/113_unparseable_case.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/114_unparseable_while_loop.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/115_unparseable_function_keyword_form.json diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 59d662b..f0e656c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -135,32 +135,39 @@ bulldoze priorities. - [x] Recursion depth cap at 5 → outer `ParsedCommand.IsUnparseable=true` (interp #4) -### 10. Hand-authored corpus (SPEC §13 — minimum 105 entries) - -- [ ] 10 simple-verb cases -- [ ] 10 multi-token-verb cases -- [ ] 15 compound cases -- [ ] 10 cd-in-compound cases -- [ ] 10 quote-handling cases -- [ ] 10 redirect cases -- [ ] 10 subshell cases -- [ ] 10 `bash -c` cases -- [ ] 10 dynamic-skip cases -- [ ] 10 per-verb path-rule cases -- [ ] 10 unparseable cases - -### 11. Corpus runner test - -- [ ] Single `[Theory] [MemberData]` enumerating - `tests/.../Corpus/bash/*.json` -- [ ] Per-entry test name so failures point at the specific case -- [ ] Structural equality helper `AstAssert.Equal` - -### 12. PII audit (SPEC §14) - -- [ ] Single `[Fact]` that scans `tests/.../Corpus/bash/*.json` for - SPEC §14 forbidden patterns -- [ ] Wired into `pr_validation.yml` via `dotnet test` (no separate job) +### 10. Hand-authored corpus (SPEC §13 — minimum 105 entries) — PR 6, complete + +- [x] 10 simple-verb cases (01-10) — PR 3 +- [x] 10 multi-token-verb cases (11-20) — PR 3 +- [x] 15 compound cases (21-35) — PR 3 +- [x] 10 cd-in-compound cases (71-80) — PR 5 +- [x] 10 quote-handling cases (101-110) — PR 6 +- [x] 10 redirect cases (36-45) — PR 3 +- [x] 10 subshell cases (81-90) — PR 5 +- [x] 10 `bash -c` cases (91-100) — PR 5 +- [x] 10 dynamic-skip cases (51-60) — PR 4 +- [x] 10 per-verb path-rule cases (61-70) — PR 4 +- [x] 10 unparseable cases (46-50, 111-115) — PRs 3 + 6 +- [x] **115 total corpus entries** (target was ≥105) + +### 11. Corpus runner test — PR 6, complete + +- [x] Single `[Theory] [MemberData]` enumerating + `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` (skeleton in PR 3) +- [x] Per-entry test name (file name) so failures point at the specific + case +- [x] Polished structural-equality helper `AstAssert.Equal` with + diff-friendly messages (e.g. + `clauses[1].args[2].kind: expected DynamicSkip, actual Literal`) + +### 12. PII audit (SPEC §14) — PR 6, complete + +- [x] Single `[Fact]` that scans + `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` for SPEC §14 + forbidden patterns; allowlists generic placeholders; reports all + hits in one failure +- [x] Wired into `pr_validation.yml` via standard `dotnet test` + (no separate job) ### 13. Release v0.1.0-alpha diff --git a/SPEC.md b/SPEC.md index 5ebaf43..ff529d3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -997,8 +997,9 @@ The corpus is the **acceptance contract** for the parser. Implementation is ### Location -`tests/Corpus/bash/*.json` — one file per corpus entry. File name pattern: -`NN_descriptive_slug.json` where NN is a zero-padded sequence number. +`tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` — one file per corpus +entry. File name pattern: `NN_descriptive_slug.json` where NN is a +zero-padded sequence number. ### Format @@ -1050,7 +1051,7 @@ real-world commands (see §14). ### Test runner -A single xunit test method enumerates `tests/Corpus/bash/*.json`, parses +A single xunit test method enumerates `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json`, parses each `input`, and asserts the result matches `expected` field-by-field. The runner emits a per-corpus-entry test name so failures point at the specific case. @@ -1126,7 +1127,7 @@ Apply these transformations to every seeded entry **before** committing: Before any corpus PR merges, CI runs a regex check against the corpus files for residual PII patterns. The check fails the build if any sanitization-rule pattern appears in any committed corpus file. Implement -as a small `dotnet test` that scans `tests/Corpus/bash/*.json` for the +as a small `dotnet test` that scans `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` for the forbidden patterns. --- @@ -1142,7 +1143,7 @@ Adapt for ShellSyntaxTree: - **Trigger NuGet publish on tag pattern `v*.*.*`** (e.g. `v0.1.0-alpha`). - **Test job** runs the corpus runner plus all unit tests. -- **PII audit job** runs the sanitization-pattern scan over `tests/Corpus/`. +- **PII audit job** runs the sanitization-pattern scan over `tests/ShellSyntaxTree.Tests/Corpus/`. ### Versioning @@ -1212,10 +1213,10 @@ v0.1.0-alpha ships when **all** of the following hold: 1. ✅ Public API matches §2 exactly. `dotnet pack` produces a ShellSyntaxTree.0.1.0-alpha.nupkg. -2. ✅ Every corpus entry in `tests/Corpus/bash/*.json` parses to its +2. ✅ Every corpus entry in `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` parses to its expected AST. `dotnet test` runs them all and passes. 3. ✅ Corpus has at least 105 entries spanning the categories in §13. -4. ✅ PII audit scan over `tests/Corpus/bash/*.json` finds zero hits. +4. ✅ PII audit scan over `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json` finds zero hits. 5. ✅ `dotnet test` runs on PR via GitHub Actions and passes. 6. ✅ Tagging `v0.1.0-alpha` triggers `publish_nuget.yml` and the package appears on nuget.org. diff --git a/openspec/changes/v0.1-locked-interpretations/tasks.md b/openspec/changes/v0.1-locked-interpretations/tasks.md index f8724b3..50946ad 100644 --- a/openspec/changes/v0.1-locked-interpretations/tasks.md +++ b/openspec/changes/v0.1-locked-interpretations/tasks.md @@ -219,16 +219,42 @@ the SPEC.md sections that get updated alongside the implementation. ## 6. PR 6 — Corpus completeness + PII audit (interpretation #7) -- [ ] 6.1 Audit corpus categories from SPEC §13; fill to ≥105 entries -- [ ] 6.2 `tests/.../Corpus/AstAssert.cs` — structural equality with - diffable failures -- [ ] 6.3 `tests/.../Corpus/PiiAuditTests.cs` — `[Fact]` regex scan over - corpus JSON for SPEC §14 forbidden patterns -- [ ] 6.4 Verify pr_validation runs both via `dotnet test` -- [ ] 6.5 Confirm green on Linux + Windows (path separators) -- [ ] 6.6 Update `SPEC.md` §13 (replace abbreviated path with canonical - `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json`) -- [ ] 6.7 Open OpenSpec change `corpus-location` for the §13 delta +- [x] 6.1 Audited corpus categories from SPEC §13; filled to **115 + entries** (target was ≥105). Added 10 quote-handling (101-110) + and 5 more unparseable (111-115). +- [x] 6.2 `tests/.../Corpus/AstAssert.cs` — polished structural-equality + helper with path-prefixed messages (e.g. + `clauses[1].args[2].kind: expected DynamicSkip, actual Literal`). + CorpusRunnerTests refactored to delegate. +- [x] 6.3 `tests/.../Corpus/PiiAuditTests.cs` — `[Fact]` walks every + `bash/*.json`, applies SPEC §14 regex patterns; allowlists + generic placeholders; reports all hits in one failure. +- [x] 6.4 PR validation runs corpus runner + PII audit via standard + `dotnet test` (no separate job). +- [x] 6.5 Cross-platform: BashResolver (PR 4 fix) keeps forward-slash + paths regardless of host OS; corpus uses pinned + WorkingDirectory=/work, HomeDirectory=/home/test for stability. +- [x] 6.6 SPEC §13 updated to canonical + `tests/ShellSyntaxTree.Tests/Corpus/bash/*.json`; also fixed + stale refs in §14, §15, §17 acceptance criteria. +- [x] 6.7 Light Path-C adoption: §13 delta lives in this bootstrap + change; no separate `corpus-location` change needed. +- [x] 6.8 **353/353 tests passing**; clean build; PublicApiSnapshotTests + still 18/18 green; public API surface unchanged. + +### PR 6 follow-ups (tracked for v0.1.x or post-v0.1.0-alpha) + +- Single-quote `'literal $HOME'` resolver behavior: v0.1 substitutes + uniformly (corpus 104 pins). Bash semantics: doesn't substitute + inside single quotes. v0.1.x may carry quote-style through tokens. +- LooksLikePath heuristic: trailing backslash in quoted literal + triggers IsPath=true (corpus 109 pins). v0.1.x may tighten heuristic. +- Sentinel `Raw=""` for dynamic-cd attribution: pinned + by corpus 52 but SPEC §9 dynamic-cd subsection doesn't formalize + the value. v0.1.x can pin in spec text. +- `case x in a) ;; esac` form: hits "unbalanced parens" diagnostic + before keyword check. Acceptable v0.1 behavior; v0.1.x may pin + diagnostic precedence in SPEC §11. ## 7. Verify diff --git a/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs new file mode 100644 index 0000000..f8d6101 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/AstAssert.cs @@ -0,0 +1,313 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using Xunit.Sdk; + +namespace ShellSyntaxTree.Tests.Corpus; + +/// +/// Structural-equality helper for the corpus runner. Compares an +/// (the JSON-deserialized expectation +/// shape) against a live and throws an +/// with a diff-friendly message naming the +/// first differing field on mismatch. +/// +/// +/// +/// SPEC §13's AstAssert.Equal is the canonical entry point. The +/// implementation prioritizes legibility of the CI failure log over a +/// machine-readable diff — the goal is that a human reviewing a failing +/// corpus assertion can identify the differing path +/// (e.g. clauses[1].args[2].kind) and the specific value mismatch +/// without re-reading the entire JSON. +/// +/// +/// Comparison rules (see also and +/// peers for which fields are required vs. opt-in): +/// +/// +/// +/// IsUnparseable is compared first. When true on the expected +/// side, only UnparseableReasonContains (substring match, when +/// supplied) is verified — the clauses payload is not inspected. +/// +/// +/// Clauses.Count mismatch dumps both expected and actual clause +/// summaries so the author can eyeball which clause was added/dropped. +/// +/// +/// Per clause: Operator, Verb.Tokens (sequence equality), +/// Args.Count, per-arg fields, Redirects.Count, per- +/// redirect fields, IsSubshell, IsBashCWrapped. +/// +/// +/// Per arg: Raw, Kind, IsPath, IsCwdAttribution +/// always asserted. IsFlag opt-in (computed property; corpus may +/// pin it for documentation but it's not required). Resolved +/// opt-in via the JSON sentinel "__NULL__" for null or a literal +/// string for value equality; omitting the field skips the comparison. +/// +/// +/// +internal static class AstAssert +{ + /// + /// Assert that matches . + /// Throws with a path-prefixed message on + /// first mismatch; never throws on equality. + /// + /// The expectation shape from a corpus JSON. + /// The live from the parser. + /// + /// Optional human-readable label (typically the corpus file name) used + /// as a prefix on every failure message. When null, no prefix is added. + /// + internal static void Equal( + ExpectedParsedCommand expected, + ParsedCommand actual, + string? contextLabel = null) + { + var prefix = contextLabel is null ? string.Empty : $"[{contextLabel}] "; + + if (expected.IsUnparseable != actual.IsUnparseable) + { + throw new XunitException( + prefix + $"isUnparseable: expected={expected.IsUnparseable}, actual={actual.IsUnparseable}; reason={actual.UnparseableReason}"); + } + + if (expected.IsUnparseable) + { + if (!string.IsNullOrEmpty(expected.UnparseableReasonContains)) + { + if (actual.UnparseableReason is null + || !actual.UnparseableReason.Contains(expected.UnparseableReasonContains!, System.StringComparison.Ordinal)) + { + throw new XunitException( + prefix + $"unparseableReason: expected contains '{expected.UnparseableReasonContains}', actual='{actual.UnparseableReason}'"); + } + } + + return; + } + + var expectedClauses = expected.Clauses ?? new List(); + if (expectedClauses.Count != actual.Clauses.Count) + { + throw new XunitException( + prefix + $"clauses.count: expected={expectedClauses.Count}, actual={actual.Clauses.Count}\n" + + " expected: " + SummarizeExpectedClauses(expectedClauses) + "\n" + + " actual: " + SummarizeActualClauses(actual.Clauses)); + } + + for (var i = 0; i < expectedClauses.Count; i++) + { + AssertClauseEqual(expectedClauses[i], actual.Clauses[i], $"{prefix}clauses[{i}]"); + } + } + + private static void AssertClauseEqual(ExpectedClause expected, Clause actual, string path) + { + if (expected.Operator != actual.Operator) + { + throw new XunitException( + $"{path}.operator: expected={expected.Operator}, actual={actual.Operator}"); + } + + var expectedVerb = expected.Verb ?? new List(); + if (!expectedVerb.SequenceEqual(actual.Verb.Tokens)) + { + throw new XunitException( + $"{path}.verb: expected=[{string.Join(",", expectedVerb)}], actual=[{string.Join(",", actual.Verb.Tokens)}]"); + } + + var expectedArgs = expected.Args ?? new List(); + if (expectedArgs.Count != actual.Args.Count) + { + throw new XunitException( + $"{path}.args.count: expected={expectedArgs.Count}, actual={actual.Args.Count}\n" + + " actual args: " + string.Join(", ", actual.Args.Select(a => $"{{raw={a.Raw}, kind={a.Kind}, isPath={a.IsPath}, isCwdAttribution={a.IsCwdAttribution}}}"))); + } + + for (var i = 0; i < expectedArgs.Count; i++) + { + AssertArgEqual(expectedArgs[i], actual.Args[i], $"{path}.args[{i}]"); + } + + var expectedRedirects = expected.Redirects ?? new List(); + if (expectedRedirects.Count != actual.Redirects.Count) + { + throw new XunitException( + $"{path}.redirects.count: expected={expectedRedirects.Count}, actual={actual.Redirects.Count}\n" + + " actual redirects: " + string.Join(", ", actual.Redirects.Select(r => $"{{direction={r.Direction}, target={r.Target}, dynamicSkip={r.IsDynamicSkip}}}"))); + } + + for (var i = 0; i < expectedRedirects.Count; i++) + { + AssertRedirectEqual(expectedRedirects[i], actual.Redirects[i], $"{path}.redirects[{i}]"); + } + + if (expected.IsSubshell != actual.IsSubshell) + { + throw new XunitException( + $"{path}.isSubshell: expected={expected.IsSubshell}, actual={actual.IsSubshell}"); + } + + if (expected.IsBashCWrapped != actual.IsBashCWrapped) + { + throw new XunitException( + $"{path}.isBashCWrapped: expected={expected.IsBashCWrapped}, actual={actual.IsBashCWrapped}"); + } + } + + private static void AssertArgEqual(ExpectedArg expected, Arg actual, string path) + { + if (expected.Raw != actual.Raw) + { + throw new XunitException( + $"{path}.raw: expected='{expected.Raw}', actual='{actual.Raw}'"); + } + + if (expected.Kind != actual.Kind) + { + throw new XunitException( + $"{path}.kind: expected={expected.Kind}, actual={actual.Kind}"); + } + + if (expected.IsPath != actual.IsPath) + { + throw new XunitException( + $"{path}.isPath: expected={expected.IsPath}, actual={actual.IsPath}"); + } + + if (expected.IsCwdAttribution != actual.IsCwdAttribution) + { + throw new XunitException( + $"{path}.isCwdAttribution: expected={expected.IsCwdAttribution}, actual={actual.IsCwdAttribution}"); + } + + if (expected.IsFlag.HasValue && expected.IsFlag.Value != actual.IsFlag) + { + throw new XunitException( + $"{path}.isFlag: expected={expected.IsFlag}, actual={actual.IsFlag}"); + } + + if (expected.Resolved is not null) + { + if (expected.Resolved == "__NULL__") + { + if (actual.Resolved is not null) + { + throw new XunitException( + $"{path}.resolved: expected=null, actual='{actual.Resolved}'"); + } + } + else if (expected.Resolved != actual.Resolved) + { + throw new XunitException( + $"{path}.resolved: expected='{expected.Resolved}', actual='{actual.Resolved}'"); + } + } + } + + private static void AssertRedirectEqual(ExpectedRedirect expected, Redirect actual, string path) + { + if (expected.Direction != actual.Direction) + { + throw new XunitException( + $"{path}.direction: expected={expected.Direction}, actual={actual.Direction}"); + } + + if (expected.Target != actual.Target) + { + throw new XunitException( + $"{path}.target: expected='{expected.Target}', actual='{actual.Target}'"); + } + + if (expected.IsDynamicSkip.HasValue && expected.IsDynamicSkip.Value != actual.IsDynamicSkip) + { + throw new XunitException( + $"{path}.isDynamicSkip: expected={expected.IsDynamicSkip}, actual={actual.IsDynamicSkip}"); + } + } + + private static string SummarizeExpectedClauses(IReadOnlyList clauses) + { + if (clauses.Count == 0) + { + return "[]"; + } + + var sb = new StringBuilder(); + sb.Append('['); + for (var i = 0; i < clauses.Count; i++) + { + if (i > 0) sb.Append(", "); + var c = clauses[i]; + var verbStr = c.Verb is null || c.Verb.Count == 0 ? "(redirect-only)" : string.Join(" ", c.Verb); + sb.Append($"{c.Operator}:'{verbStr}'"); + } + sb.Append(']'); + return sb.ToString(); + } + + private static string SummarizeActualClauses(IReadOnlyList clauses) + { + if (clauses.Count == 0) + { + return "[]"; + } + + var sb = new StringBuilder(); + sb.Append('['); + for (var i = 0; i < clauses.Count; i++) + { + if (i > 0) sb.Append(", "); + var c = clauses[i]; + var verbStr = c.Verb.Tokens.Count == 0 ? "(redirect-only)" : c.Verb.Joined; + sb.Append($"{c.Operator}:'{verbStr}'"); + } + sb.Append(']'); + return sb.ToString(); + } + + /// + /// Diagnostic dump of a live as indented + /// JSON. Useful when authoring new corpus entries — drop into a + /// test, dump the actual output, and mirror it into JSON. + /// + internal static string Dump(ParsedCommand actual) => + JsonSerializer.Serialize(new + { + actual.Source, + actual.IsUnparseable, + actual.UnparseableReason, + Clauses = actual.Clauses.Select(c => new + { + Operator = c.Operator.ToString(), + Verb = c.Verb.Tokens, + Args = c.Args.Select(a => new + { + a.Raw, + Kind = a.Kind.ToString(), + a.IsPath, + a.IsFlag, + a.IsCwdAttribution, + a.Resolved, + }), + Redirects = c.Redirects.Select(r => new + { + Direction = r.Direction.ToString(), + r.Target, + r.IsDynamicSkip, + }), + c.IsSubshell, + c.IsBashCWrapped, + }), + }, new JsonSerializerOptions { WriteIndented = true }); +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index 6b60a1f..cf65c76 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Aaron Stannard // @@ -16,9 +16,9 @@ namespace ShellSyntaxTree.Tests.Corpus; /// /// Drives the JSON corpus under tests/ShellSyntaxTree.Tests/Corpus/bash/. /// Each entry pairs an input string with an expected -/// shape; the runner parses the input and -/// compares the result field-by-field. PR 3 ships a basic comparison -/// helper; PR 6 will polish to AstAssert.Equal with rich diffs. +/// shape; drives +/// the field-by-field comparison and emits diff-friendly failure messages +/// pointing at the first differing path. /// public class CorpusRunnerTests { @@ -41,7 +41,7 @@ public void Corpus_entry_parses_to_expected_ast(string fileName, CorpusEntry ent }); var actual = parser.Parse(entry.Input); - AssertParsedCommandEqual(entry.Expected!, actual, fileName); + AstAssert.Equal(entry.Expected!, actual, fileName); } public static IEnumerable CorpusEntries() @@ -86,146 +86,9 @@ public static IEnumerable CorpusEntries() AllowTrailingCommas = true, Converters = { new JsonStringEnumConverter() }, }; - - // -------- structural assertion (rough; PR 6 polishes) -------- - - private static void AssertParsedCommandEqual( - ExpectedParsedCommand expected, ParsedCommand actual, string fileName) - { - var diffPrefix = $"[{fileName}] "; - - Assert.True( - expected.IsUnparseable == actual.IsUnparseable, - diffPrefix + $"IsUnparseable mismatch. expected={expected.IsUnparseable}, actual={actual.IsUnparseable}; reason={actual.UnparseableReason}"); - - if (expected.IsUnparseable) - { - // PR 3: don't pin the exact reason text — it's an implementation - // detail. The corpus author can include `unparseableReasonContains` - // for a substring contract. - if (!string.IsNullOrEmpty(expected.UnparseableReasonContains)) - { - Assert.True( - actual.UnparseableReason is not null - && actual.UnparseableReason.Contains(expected.UnparseableReasonContains, StringComparison.Ordinal), - diffPrefix + $"UnparseableReason should contain '{expected.UnparseableReasonContains}', got: '{actual.UnparseableReason}'"); - } - - return; - } - - var expectedClauses = expected.Clauses ?? new List(); - Assert.True( - expectedClauses.Count == actual.Clauses.Count, - diffPrefix + $"Clause count mismatch. expected={expectedClauses.Count}, actual={actual.Clauses.Count}\n" - + DumpActual(actual)); - - for (var i = 0; i < expectedClauses.Count; i++) - { - AssertClauseEqual(expectedClauses[i], actual.Clauses[i], diffPrefix + $"clause[{i}]: "); - } - } - - private static void AssertClauseEqual(ExpectedClause expected, Clause actual, string diffPrefix) - { - Assert.True( - expected.Operator == actual.Operator, - diffPrefix + $"Operator mismatch. expected={expected.Operator}, actual={actual.Operator}"); - - var expectedVerb = expected.Verb ?? new List(); - Assert.True( - expectedVerb.SequenceEqual(actual.Verb.Tokens), - diffPrefix + $"Verb mismatch. expected=[{string.Join(",", expectedVerb)}], actual=[{string.Join(",", actual.Verb.Tokens)}]"); - - var expectedArgs = expected.Args ?? new List(); - Assert.True( - expectedArgs.Count == actual.Args.Count, - diffPrefix + $"Args count mismatch. expected={expectedArgs.Count}, actual={actual.Args.Count}\n" - + " actual args: " + string.Join(", ", actual.Args.Select(a => $"({a.Raw}, kind={a.Kind})"))); - - for (var i = 0; i < expectedArgs.Count; i++) - { - AssertArgEqual(expectedArgs[i], actual.Args[i], diffPrefix + $"arg[{i}]: "); - } - - var expectedRedirects = expected.Redirects ?? new List(); - Assert.True( - expectedRedirects.Count == actual.Redirects.Count, - diffPrefix + $"Redirects count mismatch. expected={expectedRedirects.Count}, actual={actual.Redirects.Count}"); - - for (var i = 0; i < expectedRedirects.Count; i++) - { - AssertRedirectEqual(expectedRedirects[i], actual.Redirects[i], diffPrefix + $"redirect[{i}]: "); - } - - Assert.True( - expected.IsSubshell == actual.IsSubshell, - diffPrefix + $"IsSubshell mismatch. expected={expected.IsSubshell}, actual={actual.IsSubshell}"); - Assert.True( - expected.IsBashCWrapped == actual.IsBashCWrapped, - diffPrefix + $"IsBashCWrapped mismatch. expected={expected.IsBashCWrapped}, actual={actual.IsBashCWrapped}"); - } - - private static void AssertArgEqual(ExpectedArg expected, Arg actual, string diffPrefix) - { - Assert.True(expected.Raw == actual.Raw, diffPrefix + $"Raw mismatch. expected='{expected.Raw}', actual='{actual.Raw}'"); - Assert.True(expected.Kind == actual.Kind, diffPrefix + $"Kind mismatch. expected={expected.Kind}, actual={actual.Kind}"); - Assert.True(expected.IsPath == actual.IsPath, diffPrefix + $"IsPath mismatch. expected={expected.IsPath}, actual={actual.IsPath}"); - Assert.True( - expected.IsCwdAttribution == actual.IsCwdAttribution, - diffPrefix + $"IsCwdAttribution mismatch. expected={expected.IsCwdAttribution}, actual={actual.IsCwdAttribution}"); - - // isFlag is computed; assert when present so corpus can document it. - if (expected.IsFlag.HasValue) - { - Assert.True(expected.IsFlag.Value == actual.IsFlag, diffPrefix + $"IsFlag mismatch. expected={expected.IsFlag}, actual={actual.IsFlag}"); - } - - // Resolved comparison: opt-in via the corpus author. Use the - // sentinel "__NULL__" to assert that Resolved is null; omit the - // field entirely (default null) to skip the check. - if (expected.Resolved is not null) - { - if (expected.Resolved == "__NULL__") - { - Assert.True(actual.Resolved is null, diffPrefix + $"Resolved expected null, actual='{actual.Resolved}'"); - } - else - { - Assert.True(expected.Resolved == actual.Resolved, diffPrefix + $"Resolved mismatch. expected='{expected.Resolved}', actual='{actual.Resolved}'"); - } - } - } - - private static void AssertRedirectEqual(ExpectedRedirect expected, Redirect actual, string diffPrefix) - { - Assert.True(expected.Direction == actual.Direction, diffPrefix + $"Direction mismatch. expected={expected.Direction}, actual={actual.Direction}"); - Assert.True(expected.Target == actual.Target, diffPrefix + $"Target mismatch. expected='{expected.Target}', actual='{actual.Target}'"); - if (expected.IsDynamicSkip.HasValue) - { - Assert.True(expected.IsDynamicSkip.Value == actual.IsDynamicSkip, diffPrefix + $"IsDynamicSkip mismatch. expected={expected.IsDynamicSkip}, actual={actual.IsDynamicSkip}"); - } - } - - private static string DumpActual(ParsedCommand actual) => - JsonSerializer.Serialize(new - { - actual.Source, - actual.IsUnparseable, - actual.UnparseableReason, - Clauses = actual.Clauses.Select(c => new - { - Operator = c.Operator.ToString(), - Verb = c.Verb.Tokens, - Args = c.Args.Select(a => new { a.Raw, Kind = a.Kind.ToString(), a.IsPath, a.IsFlag }), - Redirects = c.Redirects.Select(r => new { Direction = r.Direction.ToString(), r.Target, r.IsDynamicSkip }), - c.IsSubshell, - c.IsBashCWrapped, - }), - }, new JsonSerializerOptions { WriteIndented = true }); } -// -------- corpus DTOs (JSON shape; PR 6 may move into a shared file) -------- +// -------- corpus DTOs (JSON shape) -------- public sealed record CorpusEntry { @@ -274,9 +137,9 @@ public sealed record ExpectedArg /// /// Expected value. Omit (leave null) to skip - /// the comparison; provide explicitly (including empty string) to pin - /// a literal value. The corpus author may use the special sentinel - /// "__NULL__" to assert that Resolved is null. + /// the comparison; provide explicitly to pin a literal value. The + /// corpus author may use the special sentinel "__NULL__" to + /// assert that Resolved is null. /// public string? Resolved { get; init; } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs new file mode 100644 index 0000000..2da7c9a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/PiiAuditTests.cs @@ -0,0 +1,238 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using Xunit; +using Xunit.Sdk; + +namespace ShellSyntaxTree.Tests.Corpus; + +/// +/// PII audit gate per SPEC §14. Scans every JSON corpus entry under +/// tests/ShellSyntaxTree.Tests/Corpus/bash/ for the forbidden +/// patterns listed in the sanitization table. The audit reads from the +/// build-output copy of the corpus (the same location the runner pulls +/// from) so CI runs against the same bytes a developer's local +/// dotnet test would. +/// +/// +/// Scanning policy: +/// +/// Only input, notes, and raw string fields +/// are scanned. Synthetic resolved paths and the +/// <dynamic-cwd> sentinel surface in other fields and +/// would generate noise (e.g. /work/foo from +/// WorkingDirectory pinning). +/// A small allowlist of generic placeholder usernames is honored: +/// user, test, foo, dev, runner, gh-actions, ci. Anything +/// else under /home/ or /Users/ trips the audit. +/// Match collection is exhaustive (no fail-fast) so a single CI +/// run surfaces every offending file at once. +/// +/// +public class PiiAuditTests +{ + // -------- allowed-placeholder allowlists -------- + + private static readonly HashSet AllowedHomeUsernames = + new(StringComparer.Ordinal) + { + "user", "test", "foo", "dev", "runner", "gh-actions", "ci", + }; + + private static readonly HashSet AllowedUsersUsernames = + new(StringComparer.Ordinal) + { + "user", "test", "foo", "dev", "runner", "ci", + }; + + private static readonly HashSet AllowedRepoNames = + new(StringComparer.Ordinal) + { + "sample-repo", "project", "repo", + }; + + // -------- pattern set (SPEC §14 transposed to detection regex) -------- + + private static readonly Regex SlackChannelPattern = + new(@"\bD[A-Z0-9]{10}\b", RegexOptions.Compiled); + + private static readonly Regex SlackThreadPattern = + new(@"\b\d{10}\.\d{6}\b", RegexOptions.Compiled); + + private static readonly Regex EmailPattern = + new(@"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", RegexOptions.Compiled); + + private static readonly Regex LongKeyPattern = + new(@"[A-Za-z0-9]{32,}", RegexOptions.Compiled); + + // The home/users patterns capture the username segment; we then allow-list it. + private static readonly Regex HomePattern = + new(@"/home/([a-zA-Z0-9_.-]+)/", RegexOptions.Compiled); + + private static readonly Regex UsersPattern = + new(@"/Users/([a-zA-Z0-9_.-]+)/", RegexOptions.Compiled); + + // Repository-path pattern: /home//repositories///... where + // isn't one of the public-corpus placeholders. + private static readonly Regex RepoPathPattern = + new(@"/home/[^/]+/repositories/[^/]+/([a-zA-Z0-9_.-]+)/", RegexOptions.Compiled); + + [Fact] + public void Corpus_contains_no_pii_per_spec_section_14() + { + var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", "bash"); + if (!Directory.Exists(dir)) + { + // The audit is vacuous when there's no corpus to audit; the + // separate CorpusRunnerTests asserts the corpus is present. + return; + } + + var hits = new List(); + var files = Directory.GetFiles(dir, "*.json").OrderBy(f => f).ToArray(); + foreach (var file in files) + { + var name = Path.GetFileName(file); + JsonDocument doc; + try + { + doc = JsonDocument.Parse(File.ReadAllText(file)); + } + catch (JsonException ex) + { + hits.Add($"{name}: failed to parse JSON for PII audit: {ex.Message}"); + continue; + } + + using (doc) + { + Walk(doc.RootElement, name, fieldPath: string.Empty, hits); + } + } + + if (hits.Count > 0) + { + throw new XunitException( + "PII audit found forbidden patterns in corpus JSON. SPEC §14 sanitization is mandatory.\n" + + " Fix each entry below before merging.\n" + + string.Join("\n", hits.Select(h => " - " + h))); + } + } + + private static void Walk(JsonElement element, string fileName, string fieldPath, List hits) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var prop in element.EnumerateObject()) + { + var childPath = string.IsNullOrEmpty(fieldPath) + ? prop.Name + : fieldPath + "." + prop.Name; + Walk(prop.Value, fileName, childPath, hits); + } + break; + + case JsonValueKind.Array: + var idx = 0; + foreach (var item in element.EnumerateArray()) + { + Walk(item, fileName, fieldPath + "[" + idx + "]", hits); + idx++; + } + break; + + case JsonValueKind.String: + if (ShouldScan(fieldPath)) + { + ScanString(element.GetString() ?? string.Empty, fileName, fieldPath, hits); + } + break; + } + } + + /// + /// Decide whether a JSON string at is in + /// scope for the audit. SPEC §14: scan input, notes, + /// and any raw nested under args. Skip synthetic fields like + /// resolved, target, etc. — those carry parser-produced + /// paths (e.g. /home/test/file) that we explicitly want to allow. + /// + private static bool ShouldScan(string fieldPath) + { + if (fieldPath == "input") return true; + if (fieldPath == "notes") return true; + if (fieldPath.EndsWith(".raw", StringComparison.Ordinal)) return true; + return false; + } + + private static void ScanString(string value, string fileName, string fieldPath, List hits) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + // Slack channel IDs. + foreach (Match m in SlackChannelPattern.Matches(value)) + { + hits.Add($"{fileName} ({fieldPath}): slack channel id '{m.Value}' (SPEC §14)"); + } + + // Slack thread IDs. + foreach (Match m in SlackThreadPattern.Matches(value)) + { + hits.Add($"{fileName} ({fieldPath}): slack thread id '{m.Value}' (SPEC §14)"); + } + + // Email addresses. + foreach (Match m in EmailPattern.Matches(value)) + { + hits.Add($"{fileName} ({fieldPath}): email '{m.Value}' (SPEC §14)"); + } + + // Long alphanumeric tokens (potential API keys). + foreach (Match m in LongKeyPattern.Matches(value)) + { + hits.Add($"{fileName} ({fieldPath}): long token '{m.Value.Substring(0, Math.Min(8, m.Value.Length))}…' ({m.Value.Length} chars; SPEC §14 key pattern)"); + } + + // /home// — allowlist generic placeholders. + foreach (Match m in HomePattern.Matches(value)) + { + var user = m.Groups[1].Value; + if (!AllowedHomeUsernames.Contains(user)) + { + hits.Add($"{fileName} ({fieldPath}): /home/{user}/ — not in allowed-placeholder list (SPEC §14)"); + } + } + + // /Users// (macOS). + foreach (Match m in UsersPattern.Matches(value)) + { + var user = m.Groups[1].Value; + if (!AllowedUsersUsernames.Contains(user)) + { + hits.Add($"{fileName} ({fieldPath}): /Users/{user}/ — not in allowed-placeholder list (SPEC §14)"); + } + } + + // /home//repositories/// — repo name allowlist. + foreach (Match m in RepoPathPattern.Matches(value)) + { + var repo = m.Groups[1].Value; + if (!AllowedRepoNames.Contains(repo)) + { + hits.Add($"{fileName} ({fieldPath}): repository path '/repositories/.../{repo}/' — not in allowed-placeholder list (SPEC §14)"); + } + } + } +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/101_echo_single_quoted_arg.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/101_echo_single_quoted_arg.json new file mode 100644 index 0000000..6fff733 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/101_echo_single_quoted_arg.json @@ -0,0 +1,20 @@ +{ + "name": "Quote: echo 'hello world' (single-quoted literal)", + "input": "echo 'hello world'", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "'hello world'", "kind": "Literal", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Single-quoted arg: SPEC §5 preserves bytes literally; the lexer's QuotedString Value is the inner text but Raw is the source slice (with quotes). echo isn't a FileVerb; LooksLikePath('hello world') is false → Literal, IsPath=false, Resolved=null." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/102_echo_double_quoted_arg.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/102_echo_double_quoted_arg.json new file mode 100644 index 0000000..6695362 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/102_echo_double_quoted_arg.json @@ -0,0 +1,20 @@ +{ + "name": "Quote: echo \"hello world\" (double-quoted literal)", + "input": "echo \"hello world\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "\"hello world\"", "kind": "Literal", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Double-quoted arg: SPEC §5 preserves whitespace but processes \\\" \\\\ \\$ \\` escapes. Raw is the source slice including quotes; Value is the inner unescaped content. echo isn't a FileVerb; classifies as Literal/IsPath=false." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/103_echo_double_quoted_home.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/103_echo_double_quoted_home.json new file mode 100644 index 0000000..76ba4cc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/103_echo_double_quoted_home.json @@ -0,0 +1,20 @@ +{ + "name": "Quote: echo \"$HOME\" (double-quoted with $HOME expansion)", + "input": "echo \"$HOME\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "\"$HOME\"", "kind": "Tilde", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "SPEC §8 step 3: $HOME is the *only* env var the resolver substitutes. The quoted Value contains $HOME; after SubstituteHome the working buffer becomes /home/test; Kind=Tilde flags 'this came from a home-ish expansion'. Non-path slot so Resolved=null, IsPath=false." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/104_echo_single_quoted_literal_home.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/104_echo_single_quoted_literal_home.json new file mode 100644 index 0000000..d224779 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/104_echo_single_quoted_literal_home.json @@ -0,0 +1,20 @@ +{ + "name": "Quote: echo 'literal $HOME' (single quotes — but resolver still substitutes $HOME)", + "input": "echo 'literal $HOME'", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "'literal $HOME'", "kind": "Tilde", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Single-quote lexer preservation (SPEC §5) keeps '$HOME' as literal bytes in the Value, but BashResolver runs uniformly on the Value and substitutes $HOME → Kind=Tilde. This diverges from real bash (which wouldn't expand inside single quotes). Documented v0.1 behavior; consumer gets Kind=Tilde, no IsPath signal." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/105_git_commit_quoted_message.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/105_git_commit_quoted_message.json new file mode 100644 index 0000000..aee5975 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/105_git_commit_quoted_message.json @@ -0,0 +1,21 @@ +{ + "name": "Quote: git commit -m \"fix bug\" (quoted flag value)", + "input": "git commit -m \"fix bug\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["git", "commit"], + "args": [ + { "raw": "-m", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "\"fix bug\"", "kind": "Literal", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Verb chain captures both git+commit (arity=2). -m is a plain flag (not in git's FlagsWithValue table for value-as-path), so the quoted commit message stays Literal/IsPath=false." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/106_grep_quoted_pattern_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/106_grep_quoted_pattern_path.json new file mode 100644 index 0000000..a100c7b --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/106_grep_quoted_pattern_path.json @@ -0,0 +1,21 @@ +{ + "name": "Quote: grep \"pattern with spaces\" /tmp/log", + "input": "grep \"pattern with spaces\" /tmp/log", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["grep"], + "args": [ + { "raw": "\"pattern with spaces\"", "kind": "Literal", "isPath": false, "resolved": "__NULL__" }, + { "raw": "/tmp/log", "kind": "Literal", "isPath": true, "resolved": "/tmp/log" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "SPEC §7 grep rule: positional[0] is the pattern (IsPath=false even when quoted), positional[1]+ are paths. The quoted pattern's quotes stay in Raw." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/107_cat_quoted_filename_with_spaces.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/107_cat_quoted_filename_with_spaces.json new file mode 100644 index 0000000..b36ca0c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/107_cat_quoted_filename_with_spaces.json @@ -0,0 +1,20 @@ +{ + "name": "Quote: cat 'file with spaces.txt' (single-quoted path)", + "input": "cat 'file with spaces.txt'", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cat"], + "args": [ + { "raw": "'file with spaces.txt'", "kind": "Literal", "isPath": true, "resolved": "/work/file with spaces.txt" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "cat is a FileVerb (SPEC §6.3) so its positional[0] is a path. The single-quoted Value 'file with spaces.txt' resolves against WorkingDirectory=/work — embedded spaces are preserved through the join. PR 6: cross-platform-safe because the resolver uses bash-style forward-slash path semantics regardless of host OS." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/108_echo_escaped_quotes_inside_double.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/108_echo_escaped_quotes_inside_double.json new file mode 100644 index 0000000..804f969 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/108_echo_escaped_quotes_inside_double.json @@ -0,0 +1,20 @@ +{ + "name": "Quote: echo \"with \\\"escaped\\\" quotes\" (escaped double-quote inside double-quote)", + "input": "echo \"with \\\"escaped\\\" quotes\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "\"with \\\"escaped\\\" quotes\"", "kind": "Literal", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "SPEC §5 escape handling: inside double quotes, \\\" escapes a literal \". The lexer produces one QuotedString whose Value is `with \"escaped\" quotes` (without outer delimiters). Raw retains the source slice verbatim including all backslashes." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/109_echo_trailing_backslash_escaped.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/109_echo_trailing_backslash_escaped.json new file mode 100644 index 0000000..b318226 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/109_echo_trailing_backslash_escaped.json @@ -0,0 +1,20 @@ +{ + "name": "Quote: echo \"trailing backslash\\\\\" (escaped backslash inside double-quote)", + "input": "echo \"trailing backslash\\\\\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "\"trailing backslash\\\\\"", "kind": "Literal", "isPath": true, "resolved": "/work/trailing backslash" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Inside double quotes \\\\ collapses to a single literal \\. The post-escape Value is `trailing backslash\\` — which contains a directory-separator character (backslash) so LooksLikePath returns true even though echo isn't a FileVerb. The resolver normalizes the trailing \\ away during path normalization → /work/trailing backslash. Documented v0.1 behavior; SPEC clarification candidate: should LooksLikePath ignore trailing-only backslashes?" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/110_cmd_mixed_quote_styles.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/110_cmd_mixed_quote_styles.json new file mode 100644 index 0000000..8561543 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/110_cmd_mixed_quote_styles.json @@ -0,0 +1,22 @@ +{ + "name": "Quote: cmd \"arg1\" 'arg2' arg3 (mixed quote styles)", + "input": "cmd \"arg1\" 'arg2' arg3", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cmd"], + "args": [ + { "raw": "\"arg1\"", "kind": "Literal", "isPath": false, "resolved": "__NULL__" }, + { "raw": "'arg2'", "kind": "Literal", "isPath": false, "resolved": "__NULL__" }, + { "raw": "arg3", "kind": "Literal", "isPath": false, "resolved": "__NULL__" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Mixed quotes round-trip cleanly. Three args, each Raw preserves its source-form delimiters (or lack thereof). cmd is unknown so arity=1, all three are positional args. None look like paths so all stay Literal/IsPath=false." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/111_unparseable_process_sub_in.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/111_unparseable_process_sub_in.json new file mode 100644 index 0000000..0394ada --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/111_unparseable_process_sub_in.json @@ -0,0 +1,9 @@ +{ + "name": "Unparseable: process substitution <(cmd)", + "input": "cat <(echo hi)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "process substitution" + }, + "notes": "SPEC §11: process substitution <(cmd) is a non-goal in v0.1. The parser's anomaly detector flags this before clause-build and short-circuits to outer IsUnparseable." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/112_unparseable_process_sub_out.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/112_unparseable_process_sub_out.json new file mode 100644 index 0000000..812f5cf --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/112_unparseable_process_sub_out.json @@ -0,0 +1,9 @@ +{ + "name": "Unparseable: process substitution >(cmd)", + "input": "tee >(cat)", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "process substitution" + }, + "notes": "SPEC §11: process substitution >(cmd) is a non-goal in v0.1. Same anomaly path as <(cmd) — the parser detects adjacent < or > + ( and surfaces unparseable." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/113_unparseable_case.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/113_unparseable_case.json new file mode 100644 index 0000000..8c4902e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/113_unparseable_case.json @@ -0,0 +1,9 @@ +{ + "name": "Unparseable: case control-flow keyword", + "input": "case x in y esac", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "'case'" + }, + "notes": "SPEC §11: case/esac control-flow is not supported in v0.1. The case keyword as first verb fires BashVerbs.ControlFlowKeywords detection." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/114_unparseable_while_loop.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/114_unparseable_while_loop.json new file mode 100644 index 0000000..61237f7 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/114_unparseable_while_loop.json @@ -0,0 +1,9 @@ +{ + "name": "Unparseable: while-loop control flow", + "input": "while true; do echo hi; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "'while'" + }, + "notes": "SPEC §11: while/do/done control-flow is not supported in v0.1. The leading while keyword fires the ControlFlowKeywords detector — same path PR 3's for-loop entry exercises (corpus 46) but for while." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/115_unparseable_function_keyword_form.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/115_unparseable_function_keyword_form.json new file mode 100644 index 0000000..ded659a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/115_unparseable_function_keyword_form.json @@ -0,0 +1,9 @@ +{ + "name": "Unparseable: function keyword form (alternate to name() {})", + "input": "function name { echo hi; }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "'function'" + }, + "notes": "SPEC §11: function definitions are non-goals. The `function name { … }` form is detected by the function keyword sitting in ControlFlowKeywords — distinct from corpus 47's `name() { … }` form which fires the function-definition anomaly via adjacent ()." +}