---
name: fix-rule
description: >
  Fix a shuck lint rule that has conformance deltas against ShellCheck. Use this
  skill whenever the user asks to fix a rule (e.g., "fix C001", "fix the deltas
  for C006", "address the corpus failures in C019"), debug why a rule is
  producing wrong results, or work through a bug document in docs/bugs/. This
  skill is about diagnosing whether a delta is a shuck bug or a ShellCheck quirk
  and then fixing at the right layer — not about implementing new rules from
  scratch (that's the implement-rule skill) or just running the corpus test
  (that's the conformance-check skill).
---

# Fix a Lint Rule

This skill takes a rule with known conformance deltas and fixes it. The core
judgment call on every delta is: **is ShellCheck right or is shuck right?** When
shuck is wrong, the fix belongs at whatever layer actually caused the problem —
not hacked into the rule just to satisfy the oracle.

## The hard architectural constraint

Before you change anything, internalize this: **rule files in
`crates/shuck-linter/src/rules/` must not parse, scan, or walk anything.**
They are cheap filters over precomputed facts and semantic data. All
structural discovery lives in lower layers:

- Tokenizing/parsing source → `crates/shuck-parser`
- Bindings, references, scopes, CFG, dataflow → `crates/shuck-semantic`
- Normalized commands, words, redirects, tests, pipelines, loops, surface
  fragments, etc. → `crates/shuck-linter/src/facts.rs` and `src/facts/`

Rule files **must not**:

- Walk or recurse through AST nodes (no `walk_commands`, `iter_commands`,
  manual child recursion).
- Re-parse or re-scan `checker.source()` to discover shell structure.
- Normalize commands, classify words/tests/redirects, parse command options,
  or otherwise recompute what the fact builder is responsible for.
- Import from `crate::rules::common::*` (rule-facing types come from the
  crate root or a rule-local helper module).
- Reference AST traversal types blocked by the architecture test in
  `src/rules/mod.rs` (`WordPart`, `ConditionalExpr`, `iter_commands`,
  `query::`, etc.).

If a fix tempts you to do any of those things in the rule file, **stop and
push the work down a layer.** See `crates/shuck-linter/AGENTS.md` for the full
contract.

## Before you start

Read these files to understand the rule and its current state:

1. The rule's YAML definition: `docs/rules/CXXX.yaml`
2. The rule implementation: `crates/shuck-linter/src/rules/{category}/{name}.rs`
3. The rule's facts (if any): `crates/shuck-linter/src/facts.rs` — search for the rule name
4. The bug document (if one exists): `docs/bugs/CXXX.md`
5. Existing corpus-metadata (if any): `crates/shuck/tests/testdata/corpus-metadata/cXXX.yaml`
6. The linter architecture contract: `crates/shuck-linter/AGENTS.md`

## Step 1: Set up the worktree

If you're in a git worktree (not the main worktree), the large corpus cache
won't exist. Symlink it from the main worktree so you don't re-download
everything:

```bash
make ensure-cache
```

This is idempotent — safe to run even if `.cache` already exists.

## Step 2: Run the corpus test for this rule

Run a targeted comparison for just the rule in question:

```bash
make test-large-corpus SHUCK_LARGE_CORPUS_RULES=CXXX
```

The test will likely fail — the failure output IS the data you need. Capture and
read the full output.

If the full run takes too long or produces overwhelming output, start with a
sample:

```bash
make test-large-corpus SHUCK_LARGE_CORPUS_RULES=CXXX SHUCK_LARGE_CORPUS_SAMPLE_PERCENT=10
```

### Reading the output

Each failure block shows a fixture where shuck and ShellCheck disagree:

```
compatibility diff (code + location):
  SCXXXX: shellcheck=N shuck=M
  labels:
    location-only | directive-handling | shellcheck-parse-abort | ...
  shellcheck diagnostics:
    SC1234 line:col-endline:endcol level message
  shuck diagnostics:
    CXXX=>SCXXXX line:col-endline:endcol severity message
```

Key signals:
- **shellcheck=N shuck=0** — shuck is under-reporting (missing detections)
- **shellcheck=0 shuck=M** — shuck is over-reporting (false positives)
- **shellcheck=N shuck=N but different lines** — span anchoring issue
- **`shellcheck-parse-abort` label** — ShellCheck bailed on a parse error; delta is unreliable
- **`project-closure` label** — delta depends on source/dot resolution differences

## Step 3: Investigate each delta pattern

For each failing fixture, read the actual script source to understand what the
code is doing. Find the script in the corpus:

```bash
find .cache/large-corpus -name "fixture_name_here"
```

Then read the script around the lines mentioned in the diagnostics. Group
failures by root cause — most deltas cluster into a few distinct patterns.

### The core judgment: who is right?

For each pattern, decide:

**ShellCheck is right, shuck is wrong** — shuck has a bug. Proceed to Step 4.

**ShellCheck is wrong, shuck is right** — the oracle is imperfect. Common cases:
- ShellCheck warns on code that's actually safe (e.g., variables consumed by
  a sourced script, dynamic parameter expansion patterns)
- ShellCheck's parse aborted and it emitted partial/incorrect results
- ShellCheck misunderstands the script's intended shell dialect

For oracle issues, skip to Step 5 to record the divergence so it won't block
the corpus test or waste future investigation time.

**Genuine disagreement on policy** — shuck intentionally diverges from
ShellCheck (stricter or more lenient by design). Also goes to Step 5.

To understand ShellCheck's reasoning, run it directly:

```bash
nix --extra-experimental-features 'nix-command flakes' develop --command \
  shellcheck --format=json /path/to/script.sh 2>/dev/null | jq '.[] | select(.code == NNNN)'
```

## Step 4: Fix shuck at the right layer

This is the most important part. Fixing at the wrong layer creates tech debt
that compounds — a hack in the rule to work around a parser bug means every
future rule hitting the same construct will need the same hack. **And rules
are not allowed to compensate for missing structural data by walking or
parsing themselves** (see "The hard architectural constraint" above).

### Identify the layer

Work from the bottom up. The issue lives at the **lowest layer that gets it wrong**:

**Layer 1 — Lexer** (`crates/shuck-parser/src/parser/lexer.rs`)
Symptoms: tokens are wrong, quoting boundaries are off, heredoc content is
misattributed. Check by printing the token stream for a minimal reproducer.

**Layer 2 — Parser/AST** (`crates/shuck-parser/src/parser/`)
Symptoms: the AST structure doesn't match what the script actually says. A
command is parsed as the wrong type, redirections are attached to the wrong
node, compound commands have wrong boundaries. Check by dumping the AST for a
minimal reproducer.

**Layer 3 — Semantic model** (`crates/shuck-semantic/`)
Symptoms: bindings/references are wrong, scope assignment is off, def-use
chains miss a connection. The AST is correct but the semantic analysis
misinterprets it. Check by querying the semantic model directly.

**Layer 4 — Fact generation** (`crates/shuck-linter/src/facts.rs` and
`src/facts/`)
Symptoms: the semantic model is correct but the linter-level facts derived
from it are wrong. A test fact misclassifies an operand, a command fact
normalizes incorrectly, a needed structural summary doesn't exist yet. Check
by inspecting the facts for the failing fixture. **If a rule needs structural
data that no fact exposes, the fix is to add the fact here — not to walk the
AST in the rule.**

**Layer 5 — Rule implementation** (`crates/shuck-linter/src/rules/`)
Symptoms: the facts are correct but the rule's filtering predicate has a gap.
It reports a violation where it shouldn't (missing filter) or misses one it
should catch (incomplete matching over correct facts). This is the only layer
where the fix belongs in the rule file itself, and the fix should still be a
filter over existing facts — not new traversal or parsing.

### The "where does this fix go?" decision

Ask, in order:

1. Is the AST wrong for this snippet? → fix the parser (Layer 1/2).
2. Is the AST right but the semantic model misinterprets it? → fix the
   semantic layer (Layer 3).
3. Is the semantic model right but the rule has no fact that exposes the
   distinction it needs? → add or extend a fact in `facts.rs` (Layer 4).
4. Are the facts right and the rule is just filtering wrong? → fix the
   predicate in the rule file (Layer 5).

If you ever find yourself wanting to add `walk_commands`, `iter_commands`,
substring/regex scanning of `checker.source()`, `WordPart`/`ConditionalExpr`
matching, command-name normalization, option parsing, or test-operand
reconstruction inside a rule file — the answer is Layer 4 (add or extend a
fact), not Layer 5. The architecture test in
`crates/shuck-linter/src/rules/mod.rs` will fail the build if you try.

### Making the fix

Once you've identified the layer:

1. Write a **minimal reproducer** — the smallest shell snippet that triggers the wrong behavior
2. Add it as a unit test at the appropriate layer (parser test, semantic test, fact test, or linter fixture)
3. Fix the issue at that layer
4. If the fix is a new/extended fact, surface it via `LinterFacts` and update
   the rule to consume it. Do not bypass the fact and add traversal in the
   rule.
5. Run the layer's own tests to confirm the fix: `cargo test -p shuck-parser`, `cargo test -p shuck-semantic`, `cargo test -p shuck-linter`, etc.
6. Run the full workspace tests to check for regressions: `cargo test --workspace`
7. Accept any updated snapshots: `cargo insta accept --workspace`

## Step 5: Record oracle divergences in corpus-metadata

When a delta is a ShellCheck issue (or an intentional policy divergence) that
we won't fix, record it in the corpus-metadata so the test passes and future
investigators don't re-analyze the same case.

**File:** `crates/shuck/tests/testdata/corpus-metadata/cXXX.yaml`

Create the file if it doesn't exist. The format:

```yaml
reviewed_divergences:
  - side: shellcheck-only        # or "shuck-only"
    path_suffix: "owner__repo__path__to__script.sh"  # optional, narrows to one fixture
    line: 42                     # optional, narrows to one location
    end_line: 42                 # optional
    column: 5                    # optional
    end_column: 18               # optional
    labels:                      # optional, requires all listed labels to match
      - project-closure
    reason: "brief explanation citing shell spec or semantic reasoning"
```

### Field guidelines

- **side**: `shellcheck-only` when ShellCheck reports something shuck
  intentionally doesn't. `shuck-only` when shuck reports something ShellCheck
  misses and we've decided shuck is right.
- **path_suffix + line + column fields**: Use these to narrow the match. Omit
  any field to match broadly. A divergence with only `side` and `reason` (no
  location fields) applies to *all* diagnostics of that type for the rule.
  Prefer narrow matches when possible — broad matches can accidentally suppress
  real bugs.
- **reason**: Write this from scratch in your own words (clean-room policy).
  Explain the shell semantics that justify the divergence.

### When to use broad vs. narrow matches

**Narrow** (specific fixture + line): Use when the divergence is about a specific
script's context (e.g., "this variable is consumed by a sibling script after
sourcing").

**Broad** (side + reason only): Use when the divergence reflects a systematic
policy difference (e.g., "shuck intentionally flags dynamic parameter-expansion
patterns that ShellCheck ignores").

### comparison_target_notes

If the ShellCheck code mapping is wrong for corpus testing purposes (the rule
maps to an SC code but the corpus shows a different SC code's behavior), add a
note:

```yaml
comparison_target_notes:
  - current_shellcheck_code: "SC2124"
    reason: "The corpus surfaces SC2124 array-to-scalar warnings, not the behavior this rule targets."
```

## Step 6: Verify the fix

Re-run the corpus test for the rule:

```bash
make test-large-corpus SHUCK_LARGE_CORPUS_RULES=CXXX
```

Check that:
- Fixed deltas are now gone
- Corpus-metadata entries suppress the reviewed divergences
- No new regressions appeared
- If the rule now passes cleanly and its corpus-metadata file still has
  `review_all_divergences: true`, remove that field and rerun the targeted
  corpus test to confirm the rule no longer needs the rule-wide ignore entry

If there are still failures, go back to Step 3 and work through the remaining
patterns.

## Step 7: Run the full test suite

```bash
make test
cargo test --workspace
```

Confirm nothing else broke.

## Step 8: Report

Summarize to the user:
- How many deltas existed and how many were addressed
- Which were shuck fixes (and at what layer) vs. oracle divergences
- The corpus-metadata entries that were added
- Whether the rule now passes the corpus test clean
