Skip to content

fix(cli): skip an oversized file instead of letting Vale time out on it - #323

Open
thecodedrift wants to merge 4 commits into
mainfrom
fix/skip-oversized-vale-files
Open

fix(cli): skip an oversized file instead of letting Vale time out on it#323
thecodedrift wants to merge 4 commits into
mainfrom
fix/skip-oversized-vale-files

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 8, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Summary

check abandons every file's findings when a single file is large enough to exceed the Vale timeout, because Vale's cost is quadratic in one file's size and VALE_TIMEOUT_MS bounds the whole run, not one file. This is the same shape #300 fixed for unparseable files, on a path #300 did not cover.

runVale now excludes a target file over VALE_MAX_FILE_BYTES before invoking Vale at all, and reports it in a notices entry, the same preemptive treatment converterExclusionGlobs already gives a format Vale cannot parse.

Update: a review round found that the first version of this fix scanned the whole tree for oversized files with no relationship to what any Vale rule actually covers, producing false-positive notices on this very repository. That is fixed in a follow-up commit — see "Follow-up: scoping the scan" below.

Decisions, with evidence

1. Threshold: 128KB (VALE_MAX_FILE_BYTES in src/rules/vale/run.ts). The repository owner supplied fresh measurements against the pinned binary (one rule, one file, median of three runs):

size median ~4x slower CI runner (estimate) share of the 60s budget
128KB 0.77s ~3.1s 5%
192KB 1.87s ~7.5s 12%
256KB 3.30s ~13.2s 22%
384KB 7.27s ~29.1s 48%

VALE_TIMEOUT_MS covers the whole run, not one file, so the question is how much of the shared budget one outlier may consume. At 128KB a pathological file costs at most ~5%, even on the slower unmeasured CI estimate. 128KB of markdown is ~20,000 words, comfortably past hand-written prose — this excludes generated output, pasted data, or exported notes, not documents someone wrote. The full reasoning and table are in VALE_MAX_FILE_BYTES's docblock.

2. Finding vs. notice: notice, not a finding. I initially matched #300's precedent (a hard severity: "error" finding), on the issue's own suggested direction. Running the required before/after repro against this repository's own whole-project check surfaced the problem: it reported pnpm-lock.yaml and packages/cli/CHANGELOG.md as failures, and neither file is named by any [section] in any rule's .vale.ini — no rule was ever going to open either one. Unlike #300, where Vale itself proves a file was a real target by erroring on it, this exclusion runs from a bare filesystem walk with no way to confirm any rule's matcher would ever have reached the file. So this follows skippedFilesNotice's existing precedent instead: a notices entry, not a finding. The file is still excluded from the Vale invocation unconditionally either way — only the reporting softened.

3. Happy-path cost of the stat walk: ~20-25ms measured against this repository's own whole-project target set. Negligible against the 60s run budget. (Numbers updated in the follow-up below — the scoped scan is comparable, not faster, in this repo specifically; see that section for why.)

Follow-up: scoping the scan to what Vale would actually lint

A second review round against this repository's own check caught a real problem in the first version: the size scan walked **/* under the target root with no relationship to any rule's actual matcher, and reported pnpm-lock.yaml (152,820 bytes) and packages/cli/CHANGELOG.md (139,171 bytes) as "not checked" — even though no rule's [section] names either file. Vale was never going to open them, so that was a false positive, not a caught coverage hole.

Fixed by asking the generator, not by re-parsing its output:

  • assembleValeConfig (src/rules/assemble.ts) now returns the section glob patterns it wrote into the assembled config, alongside the config path — read from the exact in-memory strings it is about to write, never by re-reading the .vale.ini afterward (STYLEGUIDE-CODE.md's "Verify Build Output In The Build, Not By Parsing It" is directly on point here).
  • Threaded through assembleEngineConfigs -> DispatchOptions.valeSections -> ValeRunOptions.sectionGlobs -> findOversizedFiles, which now globs by those section patterns instead of a bare **/* walk — exactly as findConverterDependentFiles globs by its extension list.
  • An explicitly named path is no longer stat-checked unconditionally either, once sections are known: measured against the real binary, Vale spends 9ms and reports nothing on an oversized file no section names, the same as a file it never opened at all (no rule is ever assigned to run against it). Checking it regardless of scope would reintroduce the same false positive via check some-file.yaml instead of a whole-project run.
  • A caller with no assembled config to ask (sectionGlobs left undefined) keeps the exact previous exhaustive-walk behavior — verifyValeRule's isolating config, or a test that hands runVale a hand-written .vale.ini directly.

Verified after the fix:

  • taskless check --json on this repository: {"success":true,"results":[]} — no oversize notice at all, because neither pnpm-lock.yaml nor CHANGELOG.md is in any rule's scope.
  • A scratch project with a rule scoped to [**/README.md], a 200KB in-scope README.md, and a 200KB out-of-scope pnpm-lock.yaml: the notice names only README.md.
  • Mutation-checked: forcing the fallback branch to always run (simulating "the scoping fix wasn't applied") makes both the unit test (findOversizedFiles directly) and the integration test (runVale with sectionGlobs) fail with pnpm-lock.yaml/huge.yaml reappearing; reverting restores green.
  • Happy-path cost, re-measured: section-scoped scan ~22-24ms vs. the old whole-tree walk's ~20-21ms on this repository — comparable rather than faster here, because one of this repo's own section patterns (packages/cli/src/**/*.ts) is itself a broad recursive glob over the whole TypeScript tree, and running N pattern-scoped globs instead of one **/* walk adds a bit of fixed overhead. Both are negligible against the 60s run budget; the primary win is eliminating the false positive, not speed.

Repro (dedicated fixture: two normal .md files + one oversized file)

Before (stashed the fix, rebuilt, .md file at 1.6MB, matching the issue's own repro size):

$ time taskless check -d /tmp/repro-321 --json
{"success":false,"results":[],"failures":["Vale exceeded 60000ms and was terminated. The Vale engine reported a timeout; other engines were unaffected."]}
# 60.4s wall time — both good files' findings are gone

After (this fix):

$ time taskless check -d /tmp/repro-321 --json
{"success":true,"results":[
  {"source":"vale","ruleId":"no-simply","severity":"warning","message":"Avoid 'simply'","file":"good-1.md", ...},
  {"source":"vale","ruleId":"no-simply","severity":"warning","message":"Avoid 'simply'","file":"good-2.md", ...}
],"notices":["Vale did not check 1 file(s) over 131072 bytes: huge.md. ..."]}
# 0.5s wall time — both good files' findings intact, huge.md named in notices

Tests

test/vale-run.test.ts, describe("an oversized target file (taskless/cli#321)"):

  • excludes the oversized file while its neighbours' findings still come back (fixture contains real simply matches, not filler, so an unexcluded file would visibly add thousands of findings)
  • reports the skip as a notice, not a finding
  • still checks a file exactly at VALE_MAX_FILE_BYTES - 1 (boundary test)
  • new: names only the oversized files a section pattern actually reaches, using a rule scoped to *.md alongside an oversized, out-of-scope .yaml file

test/vale-formats.test.ts, describe("finding oversized files, scoped to what Vale would actually lint"):

  • reports an oversized file matching a section pattern
  • does not report an oversized file no section pattern reaches (mirrors the pnpm-lock.yaml/README.md repro in miniature)
  • still checks a matching file that is not oversized
  • falls back to the exhaustive walk when no sections are given

test/assemble.test.ts: assembleValeConfig returns every section pattern it wrote, deduplicated and sorted.

All mutation-checked by hand (applied, confirmed red, reverted, confirmed green): the exclusion-glob entry, the notice-building call, the >/>= size comparison, and (in the follow-up) forcing the un-scoped fallback branch to always run.

What the issue got wrong / worth flagging

Nothing factually wrong in the issue itself, but the issue's suggested direction ("report an oversized file as its own finding") turned out to be the wrong choice once tested against a real project — see decision 2. Separately, my own first attempt at the notice/exclusion fix was itself too broad until a second review round caught it against this repository's own check output — see the follow-up section.

Verification

  • pnpm typecheck — pass
  • pnpm test (full @taskless/cli suite) — 1383/1383 pass
  • pnpm lint (build + eslint + house-style check) — pass, "No issues found." (no oversize notice at all now, since neither pnpm-lock.yaml nor CHANGELOG.md is in scope)
  • Docs: packages/cli/src/agent/create-vale-rule.md documents the new limit and its scoping next to the existing "single unreadable file" callout; topic bumped v6 -> v7.

Fixes #321

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 3m 41s —— View job


Review mode: incremental — read 0 prior review thread(s) before reviewing.

.prior-review.json held no review threads and no reviews — only the @claude /review trigger comment itself. This is the first review of this PR, so everything below is assessed fresh; nothing is being repeated or skipped as previously addressed.

Todo

  • Read prior review data (.prior-review.json — empty, first review)
  • Fetch PR diff, commits, and changed files via gh pr diff / gh pr view
  • Read full contents of formats.ts, run.ts, assemble.ts, dispatch.ts, check.ts, walk-scope.ts, and this repo's own .vale.ini rule configs
  • Verify correctness of the new findOversizedFiles scoping logic end-to-end
  • Check for duplication, simplification, and efficiency issues
  • Check conventions (CLAUDE.md / STYLEGUIDE-CODE.md) compliance
  • Post inline comments and this summary

Summary

The core idea — scope the oversized-file guard to what Vale would actually lint, using assembleValeConfig's own section patterns rather than re-parsing .vale.ini — is sound and correctly follows this repo's "verify build output in the build, don't parse it" convention. However, verifying the scoping logic against this repository's own .vale.ini files and test suite turned up a confirmed, high-severity regression, plus a plausible second one, both inline below.

Confirmed, high severity: findOversizedFiles (formats.ts:324,377) derives "whole project" from paths.length === 0 instead of reusing isWholeProjectWalk — the exact helper this codebase already introduced to fix this same bug class, whose own docblock names check . specifically. run.ts passes the raw, pre-isWholeProjectWalk paths into this function, so check . (which arrives as paths = ["."], not []) makes wholeProject = false and roots = ["."], and every discovered oversized file then fails the roots.some(...) scope filter and is silently dropped from the guard — unconditionally, no matter the file's size. That's the exact #321 timeout risk this PR exists to close, reintroduced for the single most common explicit invocation. I confirmed no existing test exercises sectionGlobs together with paths: ["."] — every test uses paths: [] or explicit non-dot filenames. See the inline comment on formats.ts:324-377 for the full trace.

Plausible, needs verification: the new sectionGlobs branch hands Vale's own section-header strings (e.g. this repo's real [CLAUDE.md], [dotagents.md] sections) straight to node's fs.promises.glob. This file's own docblock on converterExclusionGlobs documents, measured against the real binary, that Vale matches a bare (no-/) pattern against a file's basename at any depth — node's glob does not give a literal string like CLAUDE.md that same recursive reach. Unlike every other Vale-glob fact in this file, nothing pins whether [section] matching (as opposed to the --glob CLI flag) shares that semantic, and no test in this PR exercises a bare section pattern. See the inline comment on formats.ts:378-381.

Also flagged, lower severity:

  • Discovered oversized-file paths are spliced unescaped into buildValeGlob's --glob=!{...} alternation (run.ts:669) — a path containing [, ], {, }, or , would not be excluded as intended. Pre-existing pattern for excludedTargets, but this PR adds a second, more easily triggered source of raw filenames into it.
  • The timeout-flake margin in vale-run.test.ts:619 shrank from ~35x to ~4.5x to keep the fixture under the new 128KB cap — the PR's own comment is candid about this, but it's worth watching for the same class of CI flake e1ed936 (earlier in this stack) fixed.
  • A handful of reuse/simplification nits: findConverterDependentFiles (formats.ts:229) still inlines the exclude predicate this PR extracted as isUnwalkedEntry right above it rather than calling it; skippedFilesNotice/oversizedFilesNotice duplicate the same sample-list formatting; both branches of findOversizedFiles end in an identical duplicated return statement.

Conventions: no violations found. Barrel-import rules, declaration-emit export correctness (AssembledValeConfig), and the "verify build output in the build" rule (sectionPatternsOf reads structured data pre-write, exactly as prescribed) are all followed correctly.

Everything else in the diff — the notices-vs-finding design decision, the VALE_MAX_FILE_BYTES threshold and its docblock, the assemble.ts→dispatch.ts→run.ts wiring for the happy path, and the new test coverage for the false-positive fix itself — reads as correct and well-reasoned.

Comment thread packages/cli/src/rules/vale/formats.ts Outdated
Comment thread packages/cli/src/rules/vale/formats.ts Outdated
Comment thread packages/cli/src/rules/vale/run.ts Outdated
Comment thread packages/cli/test/vale-run.test.ts
Comment thread packages/cli/src/rules/vale/formats.ts Outdated
Comment thread packages/cli/src/rules/vale/formats.ts Outdated
An error occurred while trying to automatically change base from fix/deterministic-vale-timeout-test to fix/rule-guard-json-envelope September 9, 2026 05:08
@thecodedrift
thecodedrift changed the base branch from fix/deterministic-vale-timeout-test to main September 9, 2026 05:11
Vale is quadratic in a single file's size, and VALE_TIMEOUT_MS bounds the
whole run rather than one file: a large enough document can consume most
or all of that 60s budget on its own, and a timeout discards every other
file's findings with it. This is the #300 failure again, on a path #300
did not cover.

runVale now excludes a target file over VALE_MAX_FILE_BYTES (128KB) before
invoking Vale, unconditionally, the same preemptive treatment already
given to a converter-dependent format. Excluded files are named in a
`notices` entry rather than reported as a blocking finding: unlike an
unparseable file, where Vale itself proves the file was a real target by
erroring on it, this exclusion runs from a bare filesystem walk with no
way to confirm any rule's matcher would have reached the file. Reporting
it as a hard error produced false failures on files no rule ever touches,
measured on this repository's own pnpm-lock.yaml and CHANGELOG.md.

Fixes #321
The preemptive size scan walked every file in the tree, with no relationship
to what any Vale rule's matcher covers. Running it against this repository
reported pnpm-lock.yaml and packages/cli/CHANGELOG.md as "not checked" even
though no rule's .vale.ini section names either file — Vale was never going
to open them, so that was a false positive, not a caught coverage hole.

assembleValeConfig now returns the section glob patterns it wrote into the
assembled config alongside its path, read from the same in-memory strings it
is about to write rather than by re-parsing the file afterward. That is
threaded through assembleEngineConfigs -> DispatchOptions.valeSections ->
ValeRunOptions.sectionGlobs -> findOversizedFiles, which globs by those
patterns instead of a bare **/* walk, exactly as findConverterDependentFiles
globs by its extension list. A named path is no longer stat-checked
unconditionally either, once sections are known: Vale spends 9ms and finds
nothing on an oversized file no section names, the same as a file it never
opened, so checking it regardless of scope would reintroduce the same false
positive via an explicit path instead of a whole-project walk.

A caller with no assembled config to ask (runVale's sectionGlobs left
undefined) keeps the previous exhaustive walk unchanged - verifyValeRule's
isolating config, and any test handing runVale a hand-written .vale.ini
directly.
@thecodedrift
thecodedrift force-pushed the fix/skip-oversized-vale-files branch from 683353f to 406d89c Compare September 9, 2026 05:12
… literal exclusion paths

check . silently disabled the whole oversized-file guard: filterExistingPaths
normalizes a bare . into paths = ["."], length 1, so findOversizedFiles's own
paths.length === 0 test misread it as an explicit target and every glob match
failed the root-membership check. wholeProject is now a required parameter,
threaded from runVale's own isWholeProjectWalk result instead of being
recomputed (and re-broken) inside findOversizedFiles.

A discovered file's own name is spliced into buildValeGlob's !{...}
alternation as a literal pattern, and a comma, brace, or bracket in it meant
something other than itself: --glob=!{big,comma.md} verified against the real
binary splits into two patterns, neither of which excludes the real file.
escapeGlobLiteral now escapes those characters before an oversized file's name
or a #300 retry-loop candidate reaches the glob, reusing GLOB_METACHARACTERS
(now exported) from git-ignored.ts's sibling case rather than a second
definition -- that case drops a dangerous entry instead, which is the wrong
call here since dropping the file the guard is protecting defeats it.

VALE_MAX_FILE_BYTES is threaded through runVale as an optional maxFileBytes,
not exposed as a CLI flag or config surface, so the timeout test can restore
its original 320KB fixture and ~3200ms headroom over its 100ms budget instead
of being capped at 128KB by the guard it also exercises.

Also: verified the seven section-pattern shapes this repository's own rules
use match identically between Vale and node's glob() on ordinary paths, and
documented the one confirmed gap (glob() does not descend into
dot-directories, Vale's own walker does) as a known, pre-existing limitation
rather than fixing it in this pass.
…glob

Review of #323 raised whether findOversizedFiles's use of node's glob()
against Vale's own [section] header strings shares the CLI --glob flag's
documented basename-at-any-depth semantics for a slash-free pattern -- if
it did, node's non-recursive glob("CLAUDE.md") would miss a nested,
section-matched oversized file entirely.

Measured against the real binary: it does not. A rule scoped to [CLAUDE.md]
matched only the project-root file, not a sub/CLAUDE.md fixture at a
different depth -- the same anchoring node's glob() already applies. Pinned
in vale-vendor-contract.test.ts so a future Vale version that unifies the
two matching paths fails loudly here instead of silently reopening the
timeout risk #321 closed.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude — "The core idea — scope the oversized-file guard to what Vale would actually lint, using assembleValeConfig's own section patterns rather than re-parsing .vale.ini — is sound..."

Addressed all four medium/low items and the two duplication nits, each with its own reply on the relevant thread:

  1. check . silently disabled the guard — confirmed. wholeProject is now a required parameter threaded from runVale's isWholeProjectWalk(paths) instead of being recomputed inside findOversizedFiles. New coverage: a unit test with paths: ["."] + sectionGlobs, an end-to-end runVale test, mutation-checked. Re-verified live that check --json and check . --json now report identical notices against a scratch project.
  2. Glob-dialect mismatch (bare [section] vs --glob basename recursion) — investigated against the real binary rather than assumed. A [CLAUDE.md] section did NOT match a nested sub/CLAUDE.md, so [section] matching does not share --glob's recursive semantics — node's glob() agrees with Vale here by measured fact. Pinned in vale-vendor-contract.test.ts.
  3. Unescaped commas/braces in buildValeGlob's alternation — confirmed with the real binary (!{big,comma.md} fails to exclude the real file). Added escapeGlobLiteral, applied to both the oversized-file exclusion and the pre-existing #300 retry-loop gap, reusing git-ignored.ts's GLOB_METACHARACTERS rather than a second definition.
  4. Timeout-test margin (ratio vs. absolute headroom) — you were right the ratio was the wrong metric; measured the absolute headroom instead (45ms / 3200ms / 430ms across the three versions). Added maxFileBytes as a runVale option (test seam only, no CLI/config surface) to restore the original 320KB fixture and ~3200ms headroom.
  5. Duplication nits — consolidated the sample/remainder formatting into a shared summarizeList helper; consolidated findOversizedFiles's two identical return lines into one. Declined to also merge with findConverterDependentFiles's sort, since it sorts a different container type by a different comparator — only incidentally similar.

Full verification: pnpm typecheck, pnpm test (1388/1388), pnpm lint all pass on the final commits. Pushed as 3c24fee, 2eb81ec.

— AI Coding Agent

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One large markdown file blocks the whole Vale run, because Vale is quadratic in file size

1 participant