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
35 changes: 35 additions & 0 deletions .changeset/skip-oversized-vale-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@taskless/cli": patch
---

`check` no longer risks losing every Vale finding in a run to one oversized
file. Vale's cost is quadratic in a single file's size (measured against the
pinned binary: 128KB is ~0.8s for one rule, 384KB is already ~7s), and
`VALE_TIMEOUT_MS` bounds the whole run, not one file — a large enough
document could consume most or all of that budget on its own, and a timeout
discards every other file's findings along with it (the same failure #300
fixed, on a path #300 did not cover).

`runVale` now excludes a target file over 128KB (`VALE_MAX_FILE_BYTES` in
`src/rules/vale/run.ts`) before invoking Vale at all, the same preemptive
treatment already given to a format Vale cannot parse — but only when some
Vale rule's own `.vale.ini` section could actually reach that file.
`assembleValeConfig` now returns the section patterns it wrote alongside the
config path, and the size scan globs by those patterns (`findOversizedFiles`
in `src/rules/vale/formats.ts`) instead of walking every file in the project.
A first version of this fix scanned the whole tree unconditionally and named
`pnpm-lock.yaml` and `packages/cli/CHANGELOG.md` as "not checked" on this very
repository, even though no rule's matcher touches either file — Vale was
never going to open them, so that was a false positive, not a caught coverage
hole. Excluded files are named in a `notices` entry rather than a finding:
unlike an unparseable file (where Vale itself proves the file was a real
target by erroring on it), this exclusion is a preemptive guess from a
filesystem walk, and a soft advisory fits an unconfirmed guess better than a
hard error.

A consumer may now see a `check` that previously counted a large file's
prose findings instead report a `notices` entry naming that file as skipped
— but only for a file some rule's own scope actually reaches. 128KB is
comfortably past hand-written prose (roughly 20,000 words); this should only
affect generated output, pasted data, or exported notes checked directly
against a matching rule.
24 changes: 23 additions & 1 deletion 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 v6)
# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v7)

## You are here
This is `create-vale-rule`. It helps you write a Vale rule: a check over
Expand Down Expand Up @@ -571,6 +571,28 @@ 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.

That example changed with Vale v3.18.0, which is the point: the
dangerous extension is whichever one the list above says needs a
program, not the one you remember. `.mdx` was the example until that
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ export const checkCommand = defineCommand({
cwd,
paths: existingPaths,
astGrepConfigPath: assembled.sg,
valeConfigPath: assembled.vale,
valeConfigPath: assembled.vale?.path,
valeSections: assembled.vale?.sections,
runtimeRules: plan.execute,
runtimeTimeoutMs: parseTimeoutMs(args.timeout),
});
Expand Down
51 changes: 46 additions & 5 deletions packages/cli/src/rules/assemble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,59 @@ function valeRuleBlock(ruleId: string, body: string): string {
return [`# tskl) rule = ${ruleId}`, body, ""].join("\n");
}

/**
* A section header (`[pattern]`) from an assembled rule's own body.
*
* Read from the exact string this module is about to write — never from the
* file after writing it. Re-reading the written `.vale.ini` to recover its
* own sections would be the mistake `STYLEGUIDE-CODE.md`'s "Verify Build
* Output In The Build, Not By Parsing It" warns against: this function
* already IS the generator, holding the structured pieces before they are
* joined into text, so there is nothing to re-derive.
*/
function sectionPatternsOf(body: string): string[] {
const patterns: string[] = [];
for (const line of body.split("\n")) {
const match = /^\[(.+)\]$/.exec(line.trim());
if (match?.[1] !== undefined) patterns.push(match[1]);
}
return patterns;
}

/**
* What `assembleValeConfig` produced: where to point `--config`, and the
* 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.
*/
export interface AssembledValeConfig {
/** Config path relative to the project root, for `--config`. */
path: string;
/**
* Every section glob pattern written into the config, deduplicated and
* sorted for a stable read order. Root-relative, exactly as Vale reads
* them — the same strings a `[…]` line in a rule's own `.vale.ini` names.
*/
sections: string[];
}

/**
* Assemble `.taskless/.vale.ini` from every Vale rule's own config.
*
* Returns the config path relative to the project root, or `undefined` when no
* Returns the config path and its section patterns, or `undefined` when no
* Vale rule declares any config — there is nothing to run, and writing an empty
* config would invite Vale to lint the project against no rules and report a
* clean pass.
*/
export async function assembleValeConfig(
cwd: string
): Promise<string | undefined> {
): Promise<AssembledValeConfig | undefined> {
const ruleIds = await listRuleIds(cwd, "vale");
const blocks: string[] = [];
const sections = new Set<string>();

for (const ruleId of ruleIds) {
const configPath = ruleConfigPath(cwd, "vale", ruleId);
Expand All @@ -125,6 +165,7 @@ export async function assembleValeConfig(
}
const body = ruleConfigBody(source);
if (body === "") continue;
for (const pattern of sectionPatternsOf(body)) sections.add(pattern);
blocks.push(valeRuleBlock(ruleId, body));
}

Expand All @@ -134,7 +175,7 @@ export async function assembleValeConfig(
const target = join(cwd, ASSEMBLED_VALE_CONFIG);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, contents, "utf8");
return ASSEMBLED_VALE_CONFIG;
return { path: ASSEMBLED_VALE_CONFIG, sections: [...sections].toSorted() };
}

/**
Expand Down Expand Up @@ -193,8 +234,8 @@ export async function assembleSgConfig(

/** Both assembled configs, for a run that needs whichever engines are present. */
export interface AssembledConfigs {
/** `--config` for Vale, or `undefined` when no Vale rule is configured. */
vale: string | undefined;
/** Vale's config and section patterns, or `undefined` when no Vale rule is configured. */
vale: AssembledValeConfig | undefined;
/** `-c` for ast-grep, or `undefined` when there are no ast-grep rules. */
sg: string | undefined;
}
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/rules/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ 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 @@ -177,6 +187,7 @@ 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
19 changes: 13 additions & 6 deletions packages/cli/src/rules/git-ignored.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,20 @@ const ROOT_ENTRIES = new Set(["./", "."]);
* a literal path into a matcher.
*
* An entry carrying any of them is left out of the exclusion rather than
* escaped. Vale's glob dialect is not ours to guess at, and the cost of leaving
* it out is that one pathologically-named ignored path is still linted — which
* is exactly the behavior that shipped before this module, so it is a gap
* rather than a regression. {@link isGitIgnoredPath} does not share the
* restriction, so such a path is still kept out of the skip notice.
* 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.
*
* The cost of dropping here is that one pathologically-named ignored path is
* still linted — which is exactly the behavior that shipped before this
* module, so it is a gap rather than a regression. {@link isGitIgnoredPath}
* does not share the restriction, so such a path is still kept out of the
* skip notice.
*/
const GLOB_METACHARACTERS = /[*?[\]{},\\!]/;
export const GLOB_METACHARACTERS = /[*?[\]{},\\!]/;

/**
* The ignored entries, rendered as patterns for Vale's `--glob`.
Expand Down
Loading
Loading