Skip to content

[linter-miner] feat(linters): add jsonmarshalignoredeerror linter to catch discarded json.Marshal/Unmarshal errors#35767

Merged
pelikhan merged 2 commits into
mainfrom
linter-miner/json-marshal-ignored-error-457d00c274fb69a6
May 29, 2026
Merged

[linter-miner] feat(linters): add jsonmarshalignoredeerror linter to catch discarded json.Marshal/Unmarshal errors#35767
pelikhan merged 2 commits into
mainfrom
linter-miner/json-marshal-ignored-error-457d00c274fb69a6

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot commented May 29, 2026

Summary

Adds a new jsonmarshalignoredeerror static analysis linter that detects json.Marshal and json.Unmarshal calls where the error return value is explicitly discarded with _. The linter is registered with the linter binary and ships with full test coverage and testdata fixtures.


Changes

New: jsonmarshalignoredeerror analyzer (pkg/linters/jsonmarshalignoredeerror/)

  • jsonmarshalignoredeerror.go — Core go/analysis pass. Inspects call expressions for json.Marshal / json.Unmarshal and reports a diagnostic whenever the error return is assigned to _.
  • jsonmarshalignoredeerror_test.go — Wires the analyzer through analysistest to validate diagnostics against the testdata fixture.
  • testdata/src/jsonmarshalignoredeerror/jsonmarshalignoredeerror.go — Fixture file containing Bad functions (expected diagnostic hits) and Good functions (clean patterns) that serve as ground truth for the analyzer tests.

Modified: cmd/linters/main.go

  • Registered the new jsonmarshalignoredeerror analyzer with the linter binary so it participates in all linter runs.

Motivation

Silently discarding errors from json.Marshal / json.Unmarshal (val, _ := json.Marshal(...) or _ = json.Unmarshal(...)) is a common source of subtle bugs — marshalling failures go undetected and callers operate on zero-value or stale data. This linter makes that class of mistake a compile-time-equivalent error in CI.


Testing

  • Unit tests use golang.org/x/tools/go/analysis/analysistest with explicit // want annotations in the testdata fixture.
  • No breaking changes; existing code that already handles errors is unaffected.

Risk

Area Risk Notes
Existing callers Low Linter is additive; only flags _-discarded errors
Breaking change None No API or behaviour changes to production code
Test coverage Full Analyzer, happy-path, and bad-path all covered

Generated by PR Description Updater for issue #35767 · sonnet46 1M ·

github-actions Bot and others added 2 commits May 29, 2026 18:29
…e bodies (#35752)

* Initial plan

* strengthen spdd daily planner output contract

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

* clarify spdd output contract requirements

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Catches json.Marshal and json.Unmarshal calls where the error return
is explicitly discarded with _, silently producing nil bytes or leaving
the target value in a partial state.

Patterns flagged:
  val, _ := json.Marshal(x)           // error dropped
  _ = json.Unmarshal(data, &v)        // error dropped

Evidence: 15+ call-sites found in pkg/workflow (copilot_logs.go,
mcp_config_playwright_renderer.go, safe_outputs_config_generation.go,
cache.go, args.go, and more).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! go-linters labels May 29, 2026
@pelikhan pelikhan marked this pull request as ready for review May 29, 2026 19:14
Copilot AI review requested due to automatic review settings May 29, 2026 19:14
@pelikhan pelikhan merged commit 7ba083c into main May 29, 2026
20 checks passed
@pelikhan pelikhan deleted the linter-miner/json-marshal-ignored-error-457d00c274fb69a6 branch May 29, 2026 19:14
@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 29, 2026

🧪 Test Quality Sentinel completed test quality analysis.

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 29, 2026

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 29, 2026

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions
Copy link
Copy Markdown
Contributor Author

github-actions Bot commented May 29, 2026

PR Code Quality Reviewer completed the code quality review.

@github-actions
Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 1 test: 1 design, 0 implementation, 0 guideline violations.

📊 Metrics & Test Classification (1 test analyzed)
Metric Value
New/modified tests analyzed 1
✅ Design tests (behavioral contracts) 1 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (100%)
Duplicate test clusters 0
Test inflation detected No
🚨 Coding-guideline violations 0

Test Classification Details

Test File Classification Issues Detected
TestAnalyzer pkg/linters/jsonmarshalignoredeerror/jsonmarshalignoredeerror_test.go:12 ✅ Design None

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 1 test — unit (//go:build !integration)

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%).

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 1M ·

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 100/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new custom Go analyzer jsonmarshalignoredeerror that flags json.Marshal and json.Unmarshal calls whose error return is discarded via the blank identifier (val, _ := json.Marshal(x) or _ = json.Unmarshal(data, &v)). It follows the design of the existing strconvparseignorederror linter, registers the analyzer in the multichecker cmd/linters/main.go, and ships a small analysistest fixture.

Changes:

  • New analyzer package with inspect.Analyzer-based walker that resolves the receiver via pass.TypesInfo.Uses to confirm encoding/json.
  • Analysistest with a positive Bad() and negative Good() fixture covering basic Marshal/Unmarshal cases.
  • Registration of the new analyzer in cmd/linters/main.go.
Show a summary per file
File Description
pkg/linters/jsonmarshalignoredeerror/jsonmarshalignoredeerror.go New analyzer implementing the discard-detection logic for json.Marshal / json.Unmarshal.
pkg/linters/jsonmarshalignoredeerror/jsonmarshalignoredeerror_test.go analysistest-based test entry point for the analyzer.
pkg/linters/jsonmarshalignoredeerror/testdata/src/jsonmarshalignoredeerror/jsonmarshalignoredeerror.go Fixture with positive Marshal/Unmarshal discards and properly-handled negatives.
cmd/linters/main.go Registers the new analyzer in the multichecker binary.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 1

Comment on lines +14 to +21
// Analyzer is the json-marshal-ignored-error analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "jsonmarshalignoredeerror",
Doc: "reports json.Marshal and json.Unmarshal calls where the error return is discarded with _",
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/jsonmarshalignoredeerror",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking observations on the new linter — two issues worth a follow-up.

### Findings summary

1. Typo in package/analyzer name (medium) — jsonmarshalignoredeerror has a double-e; should be jsonmarshalignorederror. This propagates into the directory name, the package declaration, and Analyzer.Name. Suppression directives (//nolint:...) will silently do nothing if someone types the correctly-spelled name.

2. json.MarshalIndent not covered (medium) — same ([]byte, error) signature, same failure modes; the linter misses this common companion function.

Dropped findings from sub-agent:

  • Nil-pointer on pass.TypesInfo.Uses[ident]: the code uses the two-value type-assertion form, which safely returns nil, false on a nil interface — not a panic.
  • Unmarshal return-type guard: isJSONFunc already resolves to the exact encoding/json.Unmarshal symbol via the type-checker; signature is fixed.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 1.4M

// Analyzer is the json-marshal-ignored-error analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "jsonmarshalignoredeerror",
Doc: "reports json.Marshal and json.Unmarshal calls where the error return is discarded with _",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in analyzer Name (and package/directory name): jsonmarshalignoredeerror contains a double-e"ignoredeerror" should be "ignorederror".

💡 Details

The misspelling is baked into:

  • The directory name jsonmarshalignoredeerror/
  • The package declaration
  • Analyzer.Name (the string that appears in diagnostics and //nolint:jsonmarshalignoredeerror suppressions)

Users writing //nolint suppressions will hit a silent no-op if they spell the analyzer name correctly (jsonmarshalignorederror), because the registered name is the typo'd form. A rename now (before widespread suppression comments accumulate) is cheaper than a later migration.

}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json.MarshalIndent is not covered: the linter silently ignores discarded errors from json.MarshalIndent, which has the same ([]byte, error) signature and the same failure modes.

💡 Suggested fix

Extend the Marshal branch to also check MarshalIndent:

if isJSONFunc(pass, call, "Marshal") || isJSONFunc(pass, call, "MarshalIndent") {
    pass.ReportRangef(call, "error return from json.Marshal/MarshalIndent is discarded; marshal failures produce nil bytes silently")
}

Or make isJSONFunc accept a variadic list of names:

func isJSONFunc(pass *analysis.Pass, call *ast.CallExpr, names ...string) bool {
    ...
    for _, n := range names {
        if sel.Sel.Name == n { return true }
    }
    return false
}

json.MarshalIndent can fail for the same reasons (json.Marshal does (cyclic values, unsupported types) and is used widely in formatting and logging paths.

Copy link
Copy Markdown
Contributor Author

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd and /grill-with-docs — requesting changes on three issues: a permanent typo in the analyzer name, a missing detection pattern, and a missing function variant.

📋 Key Themes & Highlights

Issues

  1. Typo in Analyzer.Name (jsonmarshalignoredeerror has a double eignored + error = ignorederror, but the name has ignoredeerror). This is a permanent lint diagnostic identifier.
  2. Bare json.Unmarshal(data, &v) not caught — calling it as a statement expression (no LHS at all) silently discards the error but is not an AssignStmt, so the linter skips it.
  3. json.MarshalIndent not covered — same return signature as Marshal, also commonly used.

Positive Highlights

  • ✅ Correct package-path resolution via pass.TypesInfo.Uses prevents false positives from local variables named json
  • ✅ Follows the established strconvparseignorederror pattern exactly — easy to review and maintain
  • ✅ Build tag present in test file; analysistest harness used correctly
  • ✅ Clear PR description with concrete evidence of 15+ real call-sites

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 1.4M

// Analyzer is the json-marshal-ignored-error analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "jsonmarshalignoredeerror",
Doc: "reports json.Marshal and json.Unmarshal calls where the error return is discarded with _",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] Typo in the public analyzer Name: the package is spelled jsonmarshalignoredeerror (double e: ignoredd + errore = de but the package has dee). This string appears in lint output and is a permanent API identifier once merged.

💡 Suggested fix

Rename throughout (directory, package declaration, Analyzer.Name, Analyzer.Doc, and URL):

  • jsonmarshalignoredeerror (current — double e)
  • jsonmarshalignoredererror or more readably jsonmarshalignoredeerror

Precisely: json + marshal + ignored + errorjsonmarshalignoredererror. Check: ignored = i-g-n-o-r-e-d, error = e-r-r-o-r, so joined = ignorederror. Current name ignoredeerror inserts an extra e.


func run(pass *analysis.Pass) (any, error) {
insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
nodeFilter := []ast.Node{(*ast.AssignStmt)(nil)}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The linter only inspects *ast.AssignStmt nodes, so it misses json.Unmarshal(data, &v) called as a bare expression statement — which is also a discarded error and arguably more common.

💡 Suggested fix

Add (*ast.ExprStmt)(nil) to the nodeFilter and handle it:

nodeFilter := []ast.Node{(*ast.AssignStmt)(nil), (*ast.ExprStmt)(nil)}

// in the callback:
exprStmt, ok := n.(*ast.ExprStmt)
if ok {
    call, ok := exprStmt.X.(*ast.CallExpr)
    if ok {
        if isJSONFunc(pass, call, "Unmarshal") {
            pass.ReportRangef(call, "error return from json.Unmarshal is discarded; unmarshal failures leave the target value in a partial state")
        }
    }
}

Add a fixture in the test data:

func BadBare() {
    var f Foo
    json.Unmarshal([]byte(`{}`), &f) // want `error return from json\.Unmarshal is discarded`
}

blank, ok := assign.Lhs[1].(*ast.Ident)
if ok && blank.Name == "_" {
call, ok := assign.Rhs[0].(*ast.CallExpr)
if ok {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] json.MarshalIndent also returns ([]byte, error) and is used throughout the codebase, but the linter only checks for "Marshal" by exact name — MarshalIndent calls with a discarded error will be silently skipped.

💡 Suggested fix

Extend isJSONFunc to accept a list of names, or add a second check:

// Check for both Marshal and MarshalIndent
for _, fn := range []string{"Marshal", "MarshalIndent"} {
    if isJSONFunc(pass, call, fn) {
        pass.ReportRangef(call, "error return from json.%s is discarded; marshal failures produce nil bytes silently", fn)
    }
}

Add a fixture:

val2, _ := json.MarshalIndent(f, "", "  ") // want `error return from json\.MarshalIndent is discarded`
_ = val2

@github-actions
Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

This PR adds >100 new lines in business-logic directories (124 additions in pkg/, cmd/) but has no linked Architecture Decision Record (ADR). A draft ADR has been generated for docs/adr/35767-add-jsonmarshalignoredeerror-linter.md and is included inline below.

⚠️ Automated commit to the PR branch was not possible in this run (the PR head branch could not be resolved as a remote tracking ref), so the draft ADR is provided inline for you to add manually.

🔒 This PR should not merge until an ADR is linked in the PR body.

📄 Draft ADR — copy to docs/adr/35767-add-jsonmarshalignoredeerror-linter.md
# ADR-35767: Add jsonmarshalignoredeerror Linter

**Date**: 2026-05-29
**Status**: Draft
**Deciders**: Unknown

## Context

A code-pattern scan found 15+ call-sites in `pkg/workflow` alone (e.g. `copilot_logs.go`, `mcp_config_playwright_renderer.go`, `safe_outputs_config_generation.go`, `cache.go`, `args.go`) where the error return of `json.Marshal` or `json.Unmarshal` is explicitly discarded with the blank identifier `_`. Discarding a `json.Marshal` error means the caller proceeds with `nil` bytes when marshalling fails (cyclic values, unsupported types); discarding a `json.Unmarshal` error leaves the target value in a partial or zero state on a bad payload — both producing silent, hard-to-trace downstream corruption. The repository already houses a family of small, focused, in-house analyzers under `pkg/linters/` (e.g. `strconvparseignorederror`, `uncheckedtypeassertion`, `fileclosenotdeferred`) registered through `cmd/linters/main.go`, so the convention is to add another analyzer rather than rely on review or an external tool.

## Decision

We will add a static-analysis linter, `jsonmarshalignoredeerror`, that flags `val, _ := json.Marshal(x)` and `_ = json.Unmarshal(data, &v)`. It lives under `pkg/linters/jsonmarshalignoredeerror/`, is registered in `cmd/linters/main.go`, walks `*ast.AssignStmt` via the shared `inspect.Analyzer`, and verifies the call receiver resolves to `encoding/json` via `pass.TypesInfo.Uses` (not a lexical name match) to avoid false positives. It mirrors ADR-35544 (`strconvparseignorederror`).

## Alternatives Considered

### Alternative 1: Fix known instances and rely on review
Patch the flagged sites and trust reviewers for future ones. Rejected: the shape recurred across many independent files, so review has not been sufficient; a mechanical per-PR check is cheaper and cannot regress.

### Alternative 2: Use a general-purpose third-party linter (`errcheck`)
Rejected: the project convention is small focused in-house analyzers; `errcheck` would emit a large volume of un-opted-in diagnostics unrelated to this high-signal pattern.

### Alternative 3: Broaden to any function returning `(T, error)`
Rejected: cost/benefit is uneven — `json` discards are especially insidious (nil bytes, partial decode) whereas many other `(T, error)` returns are guarded downstream. Starting narrow matches the evidence; a broader rule can be a separate analyzer later.

## Consequences

### Positive
- New `json.Marshal`/`json.Unmarshal` discards fail in CI rather than landing on `main`.
- Follows the existing `pkg/linters/<name>/` layout and `testdata` convention.
- `TypesInfo.Uses` receiver resolution avoids same-name false positives.

### Negative
- Narrow to `encoding/json`; misses `yaml`/`xml`/streaming encoders.
- Pre-existing discards in `pkg/workflow` are not fixed here, so the rule can't be a blocking gate without follow-up.
- One more analyzer marginally increases `cmd/linters` build/run time.

### Neutral
- Detection is syntactic; `b, err := json.Marshal(x); _ = err` is intentionally not flagged.
- Streaming forms (`json.NewEncoder/Decoder`) are out of scope this iteration.
- Diagnostic is positional only; no suggested-fix code action.
📋 What to do next
  1. Add the draft ADR above to docs/adr/35767-add-jsonmarshalignoredeerror-linter.md on this branch.
  2. Complete the missing sections — refine context the scan couldn't infer, confirm the alternatives, and adjust consequences.
  3. Reference the ADR in this PR body, e.g.:

    ADR: ADR-35767: Add jsonmarshalignoredeerror Linter

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you. This change introduces a new in-house static-analysis tool and a new convention for catching discarded encoding/json errors — exactly the kind of decision worth recording.

📋 Michael Nygard ADR Format Reference

A complete ADR contains four sections: Context (the problem and forces), Decision (what and why), Alternatives Considered (≥2 genuine options), and Consequences (positive and negative trade-offs). ADRs live in docs/adr/ as Markdown numbered by PR number.

🔒 Blocking notice: link an ADR in the PR body before merge.

References: §26657118601

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · opus48 1.3M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automation cookie Issue Monster Loves Cookies! go-linters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants