Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/vale-size-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@taskless/cli": patch
---

`check` no longer skips Vale target files over 128 KB. The guard
(`VALE_MAX_FILE_BYTES`, added in 0.11.2) was written against Vale 3.20.0,
whose lint time grew superlinearly with the size of a single Markdown block,
so one large file could consume the run's whole timeout and, because Vale
writes nothing until the run finishes, cost every other file its findings.
The same release moved the vendored Vale to 3.21.0, whose perf work makes that
cost linear regardless of block structure, so the cap no longer separates a
cheap file from an expensive one. Re-measured on the reproduction from
taskless/cli#325 against the vendored 3.21.0 binary (darwin/arm64, warm, median
of three):

| fixture | Vale 3.20.0 | Vale 3.21.0 |
| ------------------------------- | ----------- | ----------- |
| 3.2 MB, one block (`huge.md`) | ~81,000 ms | ~230 ms |
| 3.2 MB, blank-line separated | ~4,400 ms | ~290 ms |
| 128 KB, one block (the old cap) | ~770 ms | ~26 ms |
| 25 MB, one block | — | ~2,200 ms |

What a user sees: a file that 0.11.2 named in a `Vale did not check N file(s)
over 131072 bytes` notice is linted again and produces findings; the notice is
gone. The per-file retry for a target whose front matter Vale cannot parse
(taskless/cli#300) is unchanged, as is the 60 s run timeout. Vale still emits
nothing until the run completes, so a run killed by an external time limit
still loses every finding; with linear cost that takes a file in the hundreds
of megabytes rather than the hundreds of kilobytes.
30 changes: 8 additions & 22 deletions packages/cli/src/agent/create-vale-rule.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v8)
# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v9)

## You are here
This is `create-vale-rule`. It helps you write a Vale rule: a check over
Expand Down Expand Up @@ -598,27 +598,13 @@ it.
matcher that takes `check` down the first time the repo grows a
`.typ` file. Never put one of those extensions in a glob.

**A single oversized file is excluded before Vale ever opens it, not
linted slowly.** Vale's cost is quadratic in one file's size, so a
large enough document can consume the whole run's time budget on its
own and cost every other file its findings: the same failure mode as
the unreadable-file case above, from a different cause. `check`
preempts it: a target file over 128KB is skipped **only if some
matcher's own section would actually reach it**. The scan asks the
assembled config's own section patterns, the same ones you write in
this file's `.vale.ini`, rather than walking every file in the
project. A large lockfile or a generated file no rule's glob names is
left alone entirely, not merely reported softly: naming a file no
matcher was ever going to check would be a false positive, not a
caught coverage hole. A file that IS excluded is named in a `notices`
entry rather than a finding: unlike the unreadable-file case above,
where Vale's own error proves the file was a real target, this is a
preemptive guess from a filesystem walk, and a soft advisory fits an
unconfirmed guess better than a hard error does. A rule's own
fixtures are never this large in practice, so this should not surface
while authoring one. It matters when a matcher's glob is broad, such as
`[*.md]` or `[**/README.md]` at the project root, where a generated
changelog or an exported note can cross it.
**A large file is linted, not skipped.** Vale's cost is linear in a
file's size as of 3.21.0 (a 3MB single-block document measures
~230ms), so no file is excluded on size and a broad matcher such as
`[*.md]` at the project root reaches a generated changelog or an
exported note like any other document. If such a file should not be
checked, narrow the section rather than expecting `check` to skip
it.

That example changed with Vale v3.18.0, which is the point: the
dangerous extension is whichever one the list above says needs a
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/agent/update.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: update (CLI v%(CLI_VERSION)s / topic v6)
# Topic: update (CLI v%(CLI_VERSION)s / topic v7)

## You are here
This is `update`. It tells you what an upgrade changed for the rules
Expand Down Expand Up @@ -293,6 +293,20 @@ one trap (a leaf element on its own, `doc(h2)`, is inert; chain it).
No existing rule changes; this is a reason to revisit one that was
narrowed by hand.

### Migrating to 0.11.3

**Files over 128KB are linted again.** 0.11.2 skipped any target file
over 128KB that a matcher's section reached, naming it in a `notices`
entry instead of checking it, because Vale 3.20.0's cost grew
superlinearly with the size of one Markdown block and a single large
file could consume the whole run's time budget. 3.21.0, the Vale that
0.11.2 itself shipped, made that cost linear (a 3MB single-block file
measures ~230ms where 3.20.0 took ~81s), so the skip is gone. A file
that was reported as skipped now produces findings, and the `notices`
entry that named it no longer appears. Nothing in a rule changes; if a
large generated file was being kept quiet by that skip, narrow the
matcher's section so it is not reached.

## Errors

With `--json`, `--rules` failures emit `{ ok: false, code, message }`:
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,6 @@ export const checkCommand = defineCommand({
paths: existingPaths,
astGrepConfigPath: assembled.sg,
valeConfigPath: assembled.vale?.path,
valeSections: assembled.vale?.sections,
runtimeRules: plan.execute,
runtimeTimeoutMs: parseTimeoutMs(args.timeout),
});
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/rules/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,12 @@ function sectionPatternsOf(body: string): string[] {
* section patterns it wrote there.
*
* `sections` exists so a caller that needs to know what Vale would actually
* lint — `findOversizedFiles` in `vale/formats.ts`, scoping its preemptive
* size guard to files some rule's matcher could reach — can ask this module
* directly instead of re-parsing the config it just wrote.
* lint can ask this module directly instead of re-parsing the config it just
* wrote. Its only consumer so far, the oversized-file guard's scoped scan,
* went with that guard (taskless/cli#351). The field stays: this module is
* the generator, so it is the one place this fact can be stated rather than
* re-derived, and the next reader of the sections should not have to parse
* the file to get it.
*/
export interface AssembledValeConfig {
/** Config path relative to the project root, for `--config`. */
Expand Down
11 changes: 0 additions & 11 deletions packages/cli/src/rules/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,6 @@ export interface DispatchOptions {
* written. The config is the only honest signal that there is Vale work.
*/
valeConfigPath: string | undefined;
/**
* The section glob patterns `assembleValeConfig` wrote into that config, or
* `undefined` when it produced nothing (mirrors `valeConfigPath`).
*
* Threaded through to `runVale` so its preemptive oversized-file guard can
* scope its scan to files some rule's matcher could actually reach, rather
* than statting the whole project — see `findOversizedFiles` in
* `vale/formats.ts`.
*/
valeSections?: string[] | undefined;
/** Runtime rules that survived planning. Empty means the harness is skipped. */
runtimeRules: RuntimeRule[];
runtimeTimeoutMs?: number;
Expand Down Expand Up @@ -187,7 +177,6 @@ async function runValeEngine(options: DispatchOptions): Promise<EngineOutcome> {
paths: options.paths,
configPath: options.valeConfigPath,
timeoutMs: options.valeTimeoutMs,
sectionGlobs: options.valeSections,
});

if (outcome.status === "ok") {
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/rules/git-ignored.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,11 @@ const ROOT_ENTRIES = new Set(["./", "."]);
* An entry carrying any of them is left out of the exclusion rather than
* escaped **here**. Exported so `escapeGlobLiteral` in `vale/formats.ts` can
* share this exact character class rather than guessing its own — that
* function makes the opposite call (escape, not drop) for the oversized-file
* exclusion, where dropping would mean the pathological file that triggered
* the guard is the one file left unprotected. See its docblock for why the
* two literal-path exclusions in this codebase disagree on purpose.
* function makes the opposite call (escape, not drop) for the per-file retry
* exclusion in `vale/run.ts`, where dropping would mean the one file that
* aborts the whole invocation is the one file left in it. See its docblock
* for why the two literal-path exclusions in this codebase disagree on
* purpose.
*
* The cost of dropping here is that one pathologically-named ignored path is
* still linted — which is exactly the behavior that shipped before this
Expand Down
Loading
Loading