One of the small tools in this codebase is a PDF utility. It has a few subcommands: --info reports page count and file size, --split chunks a PDF into N-page pieces, and the default behavior extracts specified pages. The --info path is the one downstream tools use as a validation step: read a PDF, check it's parseable, get the page count, then proceed.

The probe round found that --info reported 0 pages, 0.0 MB with exit code 0 when given any of the following:

In every case, the same output: 0 pages, 0.0 MB, exit 0. The pdfinfo subprocess printed several lines of "Syntax Error" to stderr, which a normal user would notice — but a caller that piped the output to head or redirected stderr to /dev/null would see only the success-shaped stdout and the success exit code.

Why this matters more than it looks

The case-management tools in this codebase chain a lot of operations. A typical workflow on an incoming document:

  1. Download the PDF from the upstream API to a temp path
  2. Run merus-pdf --info on it to confirm it's a real PDF and get the page count
  3. If --info succeeds, pass the PDF to the binding extractor, which reads the first few pages and extracts the applicant name, claim number, date, and document type
  4. Write the resulting binding to the local store, where it gates the upload's eventual attachment to a case file

Step 2 is the validation. If the downloaded file was corrupted in transit, truncated by a network drop, or returned as an HTML error page by the upstream API instead of a real PDF, step 2 is supposed to catch it. The downstream extractor expects a parseable PDF; passing it garbage produces garbage extractions, and garbage extractions become garbage bindings, which become misfiles.

With the bug, step 2 didn't catch the broken case. The page count came back as 0, which technically means "I read this file and it has zero pages" — a thing that doesn't happen with real PDFs but the function returned it anyway. The next step then read zero pages of nothing and produced an extraction with no applicant name. The binding-confidence check would flag that as "failed extraction" and refuse the write — so the misfile didn't actually happen — but the diagnostic trail was wrong. Compliance review of "why did this upload fail" would show "binding extraction failed" rather than "downloaded file was corrupted," which sends the attorney chasing the wrong cause.

The root cause

The function looked like this:

function getPageCount(buf) {
  // Try pdfinfo first
  try {
    const out = execSync(`pdfinfo "${tmpFile}"`, ...);
    const m = /^Pages:\s+(\d+)/m.exec(out);
    if (m) return parseInt(m[1], 10);
  } catch {
    // pdfinfo not available or failed — fall through to heuristic
  }

  // Heuristic fallback: look for /Type /Pages ... /Count N in raw bytes
  const str = buf.toString("binary");
  const matches = str.match(/\/Type\s*\/Pages[\s\S]*?\/Count\s+(\d+)/g);
  if (!matches) return 0;
  // ... pick highest count and return it
}

Two return paths produce 0 on broken input:

Both "0" returns are indistinguishable from a successful parse that found a zero-page document. The caller — including the CLI's own --info handler — couldn't tell parse failure from valid-but-zero.

The fix

Switch the failure return from 0 to null. The CLI handler now checks for null and exits 1 with a typed error message. The heuristic also gained a sanity check: the buffer must start with the bytes %PDF- (per the PDF specification) or it's not a PDF at all, regardless of whether some unrelated bytes happen to match the /Count regex.

function getPageCount(buf) {
  try {
    const out = execSync(`pdfinfo "${tmpFile}" 2>/dev/null`, ...);
    const m = /^Pages:\s+(\d+)/m.exec(out);
    if (m) {
      const n = parseInt(m[1], 10);
      return Number.isFinite(n) && n > 0 ? n : null;
    }
  } catch {}

  if (buf.length < 5 || buf.slice(0, 5).toString('utf8') !== '%PDF-') return null;
  // ... heuristic, returns null instead of 0 if nothing matches
}

// In the CLI:
if (pages === null) {
  console.error(`Error: Not a parseable PDF: ${file}`);
  process.exit(1);
}

The pdfinfo stderr suppression (2>/dev/null) is also a small UX improvement: bad input used to spew "Syntax Error" lines for every parse attempt, polluting the caller's terminal. Now the exit code carries the signal cleanly.

The shape

This is the same family of bug I've found a few times in different places in this codebase. The pattern: a function has a legitimate-looking return value that overloads "I succeeded with no results" with "I failed." Callers downstream can't tell the difference.

Each instance is mechanical to fix once named. The underlying lesson is consistent: if your function returns a number-or-string-or-thing and one of the possible values overlaps with "I failed," you've conflated two distinct semantic states into one return value. The fix is to widen the return type with a distinct failure sentinel — null, undefined, a tagged result type, an exception — anything that the caller has to handle separately.

The version on the install URL is 4.5.95.