feat: #104 skim lint — linter parsers (eslint, ruff, mypy, golangci-lint)#106
feat: #104 skim lint — linter parsers (eslint, ruff, mypy, golangci-lint)#106
Conversation
…int) Add `skim lint` subcommand with four linter parsers following the three-tier degradation pattern (Full JSON, Degraded regex, Passthrough). - LintResult/LintIssue/LintGroup canonical types in output/canonical.rs - CommandType::Lint variant in analytics - eslint parser: --format json tier 1, default formatter regex tier 2 - ruff parser: --output-format json tier 1, text regex tier 2 - mypy parser: --output json NDJSON tier 1, text regex tier 2 - golangci-lint parser: --out-format json tier 1, text regex tier 2 - --json flag for structured JSON output on all linters - 9 rewrite rules (eslint, npx eslint, ruff, ruff check, mypy, python -m mypy, python3 -m mypy, golangci-lint, golangci-lint run) - E2E tests for all tiers, --json flag, help, unknown linter - Rewrite E2E tests for all 9 lint rules Co-Authored-By: Claude <noreply@anthropic.com>
crates/rskim/src/cmd/lint/eslint.rs
Outdated
|
|
||
| let output = if use_stdin { | ||
| let mut stdin_buf = String::new(); | ||
| io::stdin().read_to_string(&mut stdin_buf)?; |
There was a problem hiding this comment.
Unbounded stdin read in run_json_mode (Confidence: 95%)
The io::stdin().read_to_string(&mut stdin_buf) call at this line reads without a size limit, creating an unbounded memory growth risk. The established pattern in run_parsed_command_with_mode (cmd/mod.rs) enforces a 64 MiB limit via .take(MAX_STDIN_BYTES). A malicious or runaway pipe could cause OOM.
Fix: Apply the bounded read pattern:
const MAX_STDIN_BYTES: u64 = 64 * 1024 * 1024;
let bytes_read = io::stdin()
.take(MAX_STDIN_BYTES)
.read_to_string(&mut stdin_buf)?;
if bytes_read as u64 >= MAX_STDIN_BYTES {
anyhow::bail!("stdin input exceeded 64 MiB limit");
}Note: This same pattern is needed in ruff.rs:79, mypy.rs:72, and golangci.rs:79.
crates/rskim/src/cmd/lint/eslint.rs
Outdated
| ); | ||
| } | ||
|
|
||
| Ok(ExitCode::SUCCESS) |
There was a problem hiding this comment.
Exit code always SUCCESS in --json mode (Confidence: 85%)
When --json is used, this unconditionally returns Ok(ExitCode::SUCCESS). The non-json codepath (via run_parsed_command_with_mode) correctly preserves the linter's exit code. This means a linter reporting errors (exit code 1) will appear successful when --json is used. Downstream scripts checking 0 will not detect linter failures.
Fix: Capture and return the actual exit code:
let code = output.exit_code.unwrap_or(0);
Ok(ExitCode::from(code.clamp(0, 255) as u8))Note: This same issue exists in ruff.rs:148, mypy.rs:145, and golangci.rs:150.
crates/rskim/src/cmd/lint/eslint.rs
Outdated
| } | ||
|
|
||
| /// Run in `--json` mode: parse output and serialize result as JSON to stdout. | ||
| fn run_json_mode( |
There was a problem hiding this comment.
Massive run_json_mode duplication (Confidence: 95%)
This function (lines 67-145) is copy-pasted nearly identically across all four lint modules (~80 lines each, ~320 lines total). The only differences are: program name, env overrides, and install hint. The function body -- stdin reading, command execution, ANSI stripping, error handling, parse_impl call, serialization, stdout writing, stats, and analytics -- is duplicated verbatim.
Impact: When the JSON output format evolves (e.g., adding a field, updating tier serialization), all 4 files must be updated in lockstep. This is the exact OCP/DRY anti-pattern that the existing run_parsed_command_with_mode was designed to prevent.
Fix: Extract a shared helper in cmd/lint/mod.rs parameterized by the same ParsedCommandConfig struct already used by run_parsed_command_with_mode. Each linter would call this shared helper with its config and parse_impl closure.
Note: This duplication also exists in ruff.rs:70-148, mypy.rs:63-145, and golangci.rs:70-150.
crates/rskim/src/cmd/lint/eslint.rs
Outdated
| let mut issues: Vec<LintIssue> = Vec::new(); | ||
|
|
||
| for file_entry in &arr { | ||
| let file_path = file_entry.get("filePath")?.as_str()?; |
There was a problem hiding this comment.
? operator inside loop silently discards all parsed issues (Confidence: 95%)
The early-return via ? inside the for-loop iteration means if a single JSON entry has a missing filePath or messages field, the entire function returns None -- discarding ALL previously parsed issues and falling through to Tier 2 regex parsing. A valid JSON response with one malformed entry causes complete parse failure.
Fix: Use continue to skip malformed entries instead of aborting:
for file_entry in &arr {
let Some(file_path) = file_entry.get("filePath").and_then(|v| v.as_str()) else {
continue;
};
let Some(messages) = file_entry.get("messages").and_then(|v| v.as_array()) else {
continue;
};
// ... continue parsing
}Note: Same issue in ruff.rs:190-194 and golangci.rs:207-209. Mypy correctly uses continue.
| @@ -122,7 +123,7 @@ fn serialize_category<S: serde::Serializer>( | |||
| } | |||
There was a problem hiding this comment.
Comment mismatch: says 26 rules, actual is 24 (Confidence: 95%)
The comment was updated from "15 rules" to "26 rules" but the actual count is 24 (15 original + 9 new lint rules). Intent vs reality mismatch.
Fix:
// Rule table (24 rules, ordered longest-prefix-first within same leading token)
PR #106 Code Review SummaryOverall Status: Review Coverage: 8 reviewers across 7 dimensions (security, architecture, performance, complexity, consistency, regression, tests) Findings by SeverityBLOCKING ISSUES (See inline comments)
Additional Findings (60-79% Confidence - Should Fix in Future PRs)High-Priority Suggestions1. Inconsistent error handling between
|
| Dimension | Score | Status |
|---|---|---|
| Security | 8/10 | Issues require fixes |
| Architecture | 5/10 | Duplication needs refactoring |
| Performance | 7/10 | Unbounded stdin is main risk |
| Complexity | 7/10 | Duplication inflates line count |
| Consistency | 7/10 | Needs alignment across 4 modules |
| Regression | 7/10 | Exit codes + stdin limits must be fixed |
| Tests | 7/10 | Few weak assertions, mostly good |
| Rust | 7/10 | Early-return via ?, duplication, casting |
Consensus: Well-structured with excellent test coverage, but requires addressing 5 blocking issues and test assertion tightening before merge.
Created by Claude Code | Review timestamp: 2026-03-28
…loops, tighten assertions
- Extract `run_lint_json_mode` in lint/mod.rs to replace 4 duplicated
`run_json_mode` functions (~320 lines -> single shared helper)
- Add 64 MiB stdin limit via `.take()` in shared helper, consistent
with `run_parsed_command_with_mode`
- Preserve child process exit code instead of always returning SUCCESS
- Replace `?` operators in try_parse_json loops (eslint, ruff, golangci)
with `let-else { continue }` to avoid discarding previously parsed issues
- Fix rule count comment in rewrite.rs (26 -> 24)
Co-Authored-By: Claude <noreply@anthropic.com>
- eslint Tier 2 test: replace weak > 0 check with exact assert_eq counts - golangci Tier 1 test: replace sum assertion with individual error/warning counts - golangci E2E test: add count assertions for 1 error and 3 warnings - Add unit tests for group_issues (Info severity, empty input) - Add unit tests for extract_json_flag (present and absent) - Add E2E test for --show-stats with lint subcommand Co-Authored-By: Claude <noreply@anthropic.com>
crates/rskim/src/cmd/lint/mod.rs
Outdated
|
|
||
| /// Combine stdout and stderr into a single string for regex fallback parsing. | ||
| /// | ||
| /// Returns stdout directly when stderr is empty to avoid an unnecessary allocation. |
There was a problem hiding this comment.
Missing emit_markers call (Confidence: 88%)
The JSON mode path does not emit degradation-tier markers ([warning], [notice]) to stderr. This breaks the behavioral contract established by the non-JSON path in cmd/mod.rs:224-226.
When --json falls back to Tier 2/3, the markers should still be written to stderr for observability. Without markers, users piping JSON output won't see that the result is degraded.
Fix: Add marker emission after parsing (following the pattern in cmd/mod.rs:222-226):
crates/rskim/src/cmd/lint/mod.rs
Outdated
| crate::analytics::try_record_command( | ||
| output.stdout, | ||
| json_str, | ||
| format!("skim lint {program} {}", cmd_args.join(" ")), |
There was a problem hiding this comment.
Analytics label inconsistency (Confidence: 85%)
The JSON code path records analytics as "skim lint eslint ..." while the non-JSON path (via run_parsed_command_with_mode) records as "skim eslint ..." — missing the lint subcommand.
The build module avoids this by using its own run_parsed_command helper. Consider passing a custom label through config or extracting a run_lint_parsed_command helper to keep labels consistent across both code paths.
crates/rskim/src/cmd/lint/mod.rs
Outdated
| /// Combine stdout and stderr into a single string for regex fallback parsing. | ||
| /// | ||
| /// Returns stdout directly when stderr is empty to avoid an unnecessary allocation. | ||
| pub(crate) fn combine_stdout_stderr(output: &CommandOutput) -> String { |
There was a problem hiding this comment.
Unnecessary clone when stderr is empty (Confidence: 90%)
This function always returns String, cloning stdout even when stderr is empty. The existing combine_output in pytest.rs:210 correctly uses Cow<'_, str> to avoid this allocation.
Fix: Use Cow pattern:
pub(crate) fn combine_stdout_stderr<'a>(output: &'a CommandOutput) -> Cow<'a, str> {
if output.stderr.is_empty() {
Cow::Borrowed(&output.stdout)
} else {
Cow::Owned(format!("{}\n{}", output.stdout, output.stderr))
}
}
crates/rskim/src/cmd/lint/mod.rs
Outdated
| /// This is the single implementation shared by all lint parsers, eliminating | ||
| /// the per-linter `run_json_mode` duplication. The caller supplies a | ||
| /// `parse_fn` that implements the linter-specific three-tier parse logic. | ||
| pub(crate) fn run_lint_json_mode( |
There was a problem hiding this comment.
Duplicates ~80 lines from run_parsed_command_with_mode (Confidence: 88%)
The stdin reading, command execution, analytics recording, and exit code mapping are nearly identical to cmd/mod.rs:157+. This violates DRY and creates a maintenance burden.
Fix: Refactor to share the execution infrastructure. Extract a lower-level helper that both paths call, keeping only output formatting (JSON vs plain text) distinct. Alternatively, add an output formatter strategy (trait/closure) to run_parsed_command_with_mode.
crates/rskim/src/cmd/lint/mod.rs
Outdated
| let output = if use_stdin { | ||
| let mut stdin_buf = String::new(); | ||
| let bytes_read = io::stdin() | ||
| .take(MAX_STDIN_BYTES) |
There was a problem hiding this comment.
High complexity: 98 lines, 4 nested branches, mixed responsibilities (Confidence: 85%)
This function handles stdin reading, command execution, error detection, three-tier serialization, stdout writing, stats, and analytics — each a separate change reason.
Suggested refactoring (follow the complexity report):
- Extract stdin/command execution into
obtain_output()helper - Extract ParseResult→JSON serialization into
serialize_parse_result()helper - This brings each function under 40 lines with single responsibility
crates/rskim/src/cmd/lint/eslint.rs
Outdated
|
|
||
| issues.push(LintIssue { | ||
| file: file_path.to_string(), | ||
| line: line as u32, |
There was a problem hiding this comment.
Unchecked u64 as u32 cast truncates large line numbers (Confidence: 82%)
JSON as_u64() returns values up to u64::MAX. Casting to u32 without bounds checking silently truncates values > 4,294,967,295.
While line numbers exceeding 4 billion are unrealistic, Rust idiom is to use saturating casts:
line: u32::try_from(line).unwrap_or(u32::MAX),Same issue appears in ruff.rs:133, mypy.rs:144, golangci.rs:149.
|
|
||
| let mut output = format!("LINT: {errors} errors, {warnings} warnings | {tool}"); | ||
| for group in groups { | ||
| let _ = write!( |
There was a problem hiding this comment.
Unnecessary format!() calls in render loop (Confidence: 85%)
Lines that do format!("{}", group.severity) allocate a String just to get "error"/"warning" strings. These are then immediately written to the output buffer.
Fix: Use severity directly with inline pluralization:
let _ = write!(
output,
"\n {} ({} {}{}): ",
group.rule,
group.count,
group.severity,
if group.count == 1 { "" } else { "s" }
);| /// ```json | ||
| /// [{"filePath": "...", "messages": [{"ruleId": "...", "severity": 1, "message": "...", "line": 12}]}] | ||
| /// ``` | ||
| fn try_parse_json(stdout: &str) -> Option<LintResult> { |
There was a problem hiding this comment.
High nesting (4 levels) in try_parse_json (Confidence: 82%)
The function iterates over JSON array → iterates over messages → nested let-else guards. This cumulative density makes the 44-line function harder to follow.
Fix: Extract inner loop into parse_eslint_message() helper:
fn parse_eslint_message(file_path: &str, msg: &Value) -> Option<LintIssue> {
let severity_num = msg.get("severity").and_then(|v| v.as_u64())?;
let severity = match severity_num { 2 => Error, 1 => Warning, _ => Info };
let rule_id = msg.get("ruleId").and_then(|v| v.as_str()).unwrap_or("(unknown)");
let message = msg.get("message").and_then(|v| v.as_str())?;
let line = msg.get("line").and_then(|v| v.as_u64())?;
Some(LintIssue { file: file_path.to_string(), line: line as u32, rule: rule_id.to_string(), message: message.to_string(), severity })
}Then refactor outer loop to filter_map over the extracted helper.
| @@ -0,0 +1,323 @@ | |||
| //! Lint subcommand dispatcher (#104) | |||
There was a problem hiding this comment.
Code Review Summary
Overall Assessment: 8 high-confidence (≥80%) inline comments created covering blocking and should-fix findings.
Critical Issues Addressed (≥80% confidence)
- Behavioral divergence: Missing
emit_markers()in JSON path (88%) - Duplication:
run_lint_json_modereproduces 80+ lines from shared infrastructure (88%) - Analytics inconsistency: Labels differ between JSON and non-JSON paths (85%)
- Complexity:
run_lint_json_modeat 98 lines with 4+ nesting levels (85%) - Performance:
combine_stdout_stderrclones unnecessarily (90%) - Unsafe casts:
u64 as u32truncates without bounds checking (82-90%) - Test coverage gap: Missing Degraded tier tests in 3 of 4 linters (85%)
Lower-Confidence Findings (60-79%) - Summary Only
These are documented in the review reports but below the 80% threshold for inline comments:
-
Linter
run()signature divergence (80%): All four lint parsers take(args, show_stats, json_output)while test/build use(args, show_stats). Inconsistent pattern for handling--jsonflag threading through individual parsers. -
Structural duplication in
parse_implfunctions (83%): All four linters implement identical three-tier dispatch. A genericthree_tier_parse(try_json, try_regex)helper would eliminate this. -
Repeated
run()boilerplate (85%): All four linters clone args, inject format flags, branch on json_output. ALinterConfigstruct could reduce per-linter duplication. -
Golangci severity always defaults to Warning (82%): Tier 2 regex has no severity field in text output. This is a known limitation, just document it.
-
Missing Degraded tier E2E test for JSON path (80%): The
--jsonenvelope with degraded results is not tested at E2E level. -
combine_stdout_stderrunit test gap (82%): No test for non-empty stderr branch.
Pre-existing / Non-blocking
- Stale test comment: "all 15 rules" should be "24 rules" (rewrite.rs:1513)
- Various
Vec::new()without capacity hints (low impact) - Regex
.unwrap()on LazyLock (idiomatic, covered by tests)
Test Coverage Notes
The PR adds 60+ tests across:
- 21 E2E tests with real stdin piping
- 35+ unit tests for parse tiers
- 10 fixture files per-linter
All 818+ existing tests pass. The main gap is asymmetric parse_impl coverage (eslint has all 3 tiers, others missing Degraded).
Next Steps for Merge Readiness
- Address
emit_markers()missing call (behavioral contract) - Consider refactoring
run_lint_json_modecomplexity and duplication - Add Degraded tier tests for ruff, mypy, golangci
- Fix
u64 as u32casts to use saturating bounds - Fix
combine_stdout_stderrto useCowpattern
Reviewed with 80% confidence threshold for inline comments. Review reports at .docs/reviews/feat-104-lint-parsers/2026-03-29_0215/ include full analysis of all findings.
Review conducted by Claude Code on 2026-03-29.
Replace truncating `as u32` casts with `u32::try_from(x).unwrap_or(u32::MAX)` in all four lint parsers (eslint, golangci, mypy, ruff) to prevent silent data loss on line numbers exceeding u32::MAX. Co-Authored-By: Claude <noreply@anthropic.com>
- Remove unnecessary String allocation in LintResult::render by using direct write! calls with inline pluralization instead of format!() - Add test_parse_impl_text_produces_degraded to ruff, mypy, and golangci modules, matching the existing eslint pattern - Add E2E test for `skim lint eslint --json` with degraded tier input - Document golangci Tier 2 regex severity-defaults-to-Warning as a known limitation (text format lacks severity information) - Fix Cow<str> type mismatch in parse_impl Passthrough arms after combine_stdout_stderr was refactored to return Cow - Add stderr tier markers to run_lint_json_mode for observability Co-Authored-By: Claude <noreply@anthropic.com>
Collapse stderr lock/emit/drop, inline single-use variables, deduplicate render write calls, optimize group_issues ownership.
…n_linter helper - Add OutputFormat enum (Text/Json) and output_format field to ParsedCommandConfig - Add to_json_envelope() on ParseResult<T: Serialize> for JSON serialization - Widen T bound to AsRef<str> + Serialize in run_parsed_command_with_mode - Add JSON output branch in run_parsed_command_with_mode - Extract LinterConfig + run_linter helper in lint/mod.rs - Migrate all 4 linters (eslint, ruff, mypy, golangci) to use run_linter - Delete run_lint_json_mode + LintJsonConfig (dead code) Resolves tech debt items 1-3 from PR #106 review. Co-Authored-By: Claude <noreply@anthropic.com>
- De-duplicate stats/analytics logic in run_parsed_command_with_mode by extracting compressed content from the output_format match arms - Replace fully-qualified crate::cmd:: paths with proper use imports in lint/mod.rs and test/cargo.rs for consistency
…ith tech debt fixes Merges feat/104-lint-parsers and feat/105-pkg-parsers into a single consolidation branch. Resolves merge conflicts (additive — both PRs add independent modules, types, and tests to shared files). Post-merge fixes: - Add output_format field to pkg's ParsedCommandConfig construction - Add Serialize bound to run_pkg_subcommand generic parameter - Reconstruct mangled struct/impl/test boundaries in canonical.rs Tech debt resolved (items 1-3 from PR #106): - Unified lint JSON mode into shared run_parsed_command_with_mode - Extracted run_linter helper, migrated all 4 linters - Deleted run_lint_json_mode + LintJsonConfig Tech debt resolved (items 5-6 from PR #107): - Extracted run_pkg_subcommand helper, migrated all 10 functions - Block-based cargo audit parser replaces fragile triple-regex - Removed dead regex statics
## Summary - **Items 1-3 (PR #106):** Unified lint JSON mode into shared `run_parsed_command_with_mode` via `OutputFormat` enum. Extracted `run_linter` helper. Migrated all 4 linters (eslint, ruff, mypy, golangci). Deleted `run_lint_json_mode` + `LintJsonConfig`. - **Item 5 (PR #107):** Extracted `run_pkg_subcommand` helper. Migrated all 10 `run_*` functions across npm, pnpm, pip, cargo. - **Item 6 (PR #107):** Replaced fragile triple-regex cargo audit parser with block-based parsing. Handles missing/reordered fields correctly. Removed dead regex statics. Net reduction: ~150 lines of duplicated infrastructure eliminated. ## Test plan - [x] `cargo test` — all tests pass (913+ unit, 45+ integration, 11 doc tests) - [x] `cargo clippy -- -D warnings` — clean - [x] `cargo fmt -- --check` — clean - [x] Quality gates passed: Validator, Simplifier, Scrutinizer, Shepherd (both PRs) --------- Co-authored-by: Dean Sharon <deanshrn@gmain.com> Co-authored-by: Claude <noreply@anthropic.com>
|
Merged via consolidation PR #108 |
Summary
skim lintsubcommand with four linter parsers following the existing three-tier degradation pattern (Full JSON, Degraded regex, Passthrough)LintResult/LintIssue/LintGroupcanonical types,CommandType::Lintanalytics variant,--jsonstructured output flag, 9 rewrite rules, and comprehensive testscargo clippy -- -D warningscleanChanges
Types & Infrastructure
LintSeverity,LintIssue,LintGroup,LintResultinoutput/canonical.rs— follows exact same pattern asTestResult/BuildResult/GitResult(pre-rendered display,ensure_rendered(),AsRef<str>, serde roundtrip)CommandType::Lintvariant inanalytics/mod.rslintregistered incmd/mod.rs(KNOWN_SUBCOMMANDS, dispatch)Parsers (4 linters, 3 tiers each)
--format jsonarray; Tier 2: regex on default formatter--output-format jsonarray; Tier 2: regex on text output--output jsonNDJSON (line-by-line); Tier 2: regex on text--out-format jsonobject withIssueskey (handlesnull); Tier 2: regex on textRewrite Rules (9 rules)
eslint,npx eslint(skip:--format,-f)ruff,ruff check(skip:--output-format)mypy,python -m mypy,python3 -m mypy(skip:--output)golangci-lint,golangci-lint run(skip:--out-format)Tests
cli_e2e_lint_parsers.rs— all linters at all tiers,--jsonflag, help, unknown linterTest plan
cargo test— all 1652 tests passcargo clippy -- -D warnings— zero warnings--jsonflag produces structured JSON outputskim lint --helpshows all linterseslint --format jsonsuppressedCloses #104