Skip to content

ASpli jDUreport() crashes with invalid 'row.names' length when altPSI slot is empty #19

Description

@aromanowski

Hi Ariel, Andres and Team!
I found a bug when running jDUreport on organisms only contain one isoform per gene, which is the case for annotations in many non-model organisms.

Basically, jDUreport() throws Error in '.rowNamesDF<-'(x, value = value) : invalid 'row.names' length when the ASpliAS object returned by jCounts() contains zero rows in its altPSI slot. This situation arises routinely when analysing species whose genome annotation contains only one isoform per gene because binGenome() correctly identifies zero alternative splice site events and jCounts() produces an empty altPSI table as a result.

Environment
This bug was found when running:

  • R4.5.2 (2025-10-31 ucrt)
  • Bioconductor 3.22
  • ASpli 2.20.0

Reproducible context
In our case the species is Ostreococcus tauri (RefSeq assembly GCF_000214015.3), whose RefSeq annotation provides exactly one transcript per gene (7,833 genes, 7,834 transcripts). binGenome() correctly reports 0 AS bins of every type, and jCounts() correctly returns 0 rows in altPSI(asd).

Full traceback

Error in `.rowNamesDF<-`(x, value = value) : invalid 'row.names' length
10. stop("invalid 'row.names' length")
9.  `.rowNamesDF<-`(x, value = value)
8.  `row.names<-.data.frame`(`*tmp*`, value = value)
7.  `row.names<-`(`*tmp*`, value = value)
6.  `rownames<-`(`*tmp*`, value = paste0(rownames(J3), ".1"))
5.  `rownames<-`(`*tmp*`, value = paste0(rownames(J3), ".1"))
4.  .makeJunctions(data, targets, start_J1, start_J2, start_J3, minAvgCounts,
    filterWithContrasted, contrast, strongFilter, alt = T)
3.  .junctionDUreportExt(asd, ...)
2.  jDUreport(asd, contrast = contrast_vec)
1.  jDUreport(asd, contrast = contrast_vec)

What I believe caused it
The bug has two components, both in jDUreport_functions.R.

1 — .makeJunctions(), lines 707–712
When data (i.e. altPSI(asd)) has 0 rows, J1, J2, and J3 are all 0-row data frames. The filtering step produces 0-row data frames for each:

reliables[["J1"]] <- .filterJunctionBySampleWithContrast(J1, ...)
if(nrow(reliables[["J1"]]) > 0) reliables[["J1"]] <- rownames(reliables[["J1"]])

Because nrow == 0, the if guard is never executed, leaving reliables[["J1"]] as a 0-row data frame instead of character(0).
The same applies to reliables[["J2"]] and reliables[["J3"]].
When alt = TRUE, these are then passed to intersect():

reliables <- unique(c(intersect(reliables[["J1"]], reliables[["J3"]]),
               intersect(reliables[["J2"]], reliables[["J3"]])))

intersect() on data frames instead of character vectors produces corrupted output (it operates column-wise rather than row-wise). The corrupted reliables then causes J3 to be subsetted to a different number of rows than J1, so the subsequent assignment:

rownames(J1) <- paste0(rownames(J3), ".1") # line 730

fails because the lengths of rownames(J3) and nrow(J1) no longer match.

2 — .junctionDUreportExt(), line 446
Even if .makeJunctions() were fixed to return an empty list correctly, the caller immediately checks:

if(nrow(Js$J3) == 0) stop("No junctions to analyze! ...")

This stop() is appropriate for non-alt junction types where no data is genuinely an error, but for altPSI it is an expected outcome in single-isoform annotations. The function should return the partially-built jdu object (with irPIR, esPIR, junctionsPJU, and junctionsPIR
already computed) rather than stopping.

Proposed fix
FIX 1 .makeJunctions(): always convert filter result to character
Lines 707–712 of jDUreport_functions.R. Remove the if(nrow > 0) guard so that the result is always converted to a character vector. When the data frame has 0 rows, rownames() correctly returns character(0), which then propagates cleanly through intersect() and the subsequent row-subsetting.

# BEFORE (buggy):
reliables[["J1"]] <- .filterJunctionBySampleWithContrast( J1, targets,
    minAvgCounts, filterWithContrasted = filterWithContrasted, contrast )
if(nrow(reliables[["J1"]]) > 0) reliables[["J1"]] <- rownames(reliables[["J1"]])
reliables[["J2"]] <- .filterJunctionBySampleWithContrast( J2, targets,
    minAvgCounts, filterWithContrasted = filterWithContrasted, contrast )
if(nrow(reliables[["J2"]]) > 0) reliables[["J2"]] <- rownames(reliables[["J2"]])
reliables[["J3"]] <- .filterJunctionBySampleWithContrast( J3, targets,
    minAvgCounts, filterWithContrasted = filterWithContrasted, contrast )
if(nrow(reliables[["J3"]]) > 0) reliables[["J3"]] <- rownames(reliables[["J3"]])

# AFTER (fixed):
reliables[["J1"]] <- .filterJunctionBySampleWithContrast( J1, targets,
    minAvgCounts, filterWithContrasted = filterWithContrasted, contrast )
reliables[["J1"]] <- rownames(reliables[["J1"]])   # character(0) when empty
reliables[["J2"]] <- .filterJunctionBySampleWithContrast( J2, targets,
    minAvgCounts, filterWithContrasted = filterWithContrasted, contrast )
reliables[["J2"]] <- rownames(reliables[["J2"]])
reliables[["J3"]] <- .filterJunctionBySampleWithContrast( J3, targets,
    minAvgCounts, filterWithContrasted = filterWithContrasted, contrast )
reliables[["J3"]] <- rownames(reliables[["J3"]])

FIX 2 — .junctionDUreportExt(): guard the altPSI section for empty input
After line 445 (data <- data[!is.na(data$J3), ]), add an early return before .makeJunctions() is called, so that zero altPSI events produces an empty jalt slot rather than stopping execution:

# Around line 445 in .junctionDUreportExt(), inside the #ALT PSI# section:

data <- altPSI(asd)
# ... (useSubset block) ...
data <- data[!is.na(data$J3), ]

# ADD: early return when no altPSI events exist
if (nrow(data) == 0L) {
  message("altPSI is empty — no alternative splice site events to test.")
  jalt(jdu) <- data.frame()
  return(jdu)
}

Js <- .makeJunctions(data, targets, start_J1, start_J2, start_J3,
                     minAvgCounts, filterWithContrasted, contrast,
                     strongFilter, alt = T)

Extra considerations

  1. Both fixes are required: Fix 1 alone still triggers the stop() in .junctionDUreportExt(); Fix 2 alone does not address the underlying
  2. type error in .makeJunctions() if the function is ever reached with empty data for other reasons.
  3. The bug does not affect organisms with at least one annotated alternative splice site, as altPSI will always have ≥ 1 row in those cases.
  4. All other junction analyses (irPIR, esPSI, junctionsPJU, junctionsPIR) complete correctly before the altPSI section is reached; Fix 2 preserves those results by returning jdu early rather than calling stop().
  5. The fixes cause a downstream snowball effect on splicingReport() and integrateSignals(), which has to be accounted for.

splicingReport(): jalt$nonuniformity <- NA (line 43) fails on a 0-row data frame because NA has length 1. Directly caused by Bug 1's fix exposing a missing guard in .splicingReport(). Fix: wrap the jalt processing block in if(nrow(jalt) == 0L) and exclude it from the rbind.

integrateSignals(): Independent of Bugs 1–2. Partial column assignments aa$locus[i] <- val on a data.table pass the partial vector directly to $<-.data.table in current data.table versions, bypassing the [<- step that base R would apply first. Fix: replace both occurrences with data.table::set(aa, as.integer(i), "locus", val).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions