@@ -166,6 +166,11 @@ const UNWALKED_DIRECTORIES = new Set([
166166 TASKLESS_DIRECTORY ,
167167] ) ;
168168
169+ /** `glob`'s `exclude` predicate for {@link UNWALKED_DIRECTORIES}. */
170+ function isUnwalkedEntry ( entry : string | Buffer ) : boolean {
171+ return UNWALKED_DIRECTORIES . has ( basename ( String ( entry ) ) ) ;
172+ }
173+
169174/**
170175 * Converter-dependent files inside the run's target set.
171176 *
@@ -260,18 +265,48 @@ export interface OversizedFile {
260265 * discover the cost the hard way — is the same trade `converterExclusionGlobs`
261266 * makes for a format Vale cannot parse at all.
262267 *
263- * Unlike the converter walk, this cannot be scoped by extension: an oversized
264- * file can have any extension, or none, so every file under the target roots
265- * has to be listed and stat'd. That is a real cost on the happy path, where
266- * nothing is oversized and the walk still runs — measured and reported in the
267- * PR that introduced this function.
268- *
269- * A named path is stat'd directly, exactly as `targetFileParseError` does
270- * elsewhere in this package: an explicit request is not resolved through the
271- * walk that answers a whole-project run. Whether named or discovered by the
272- * walk, an oversized file is excluded unconditionally, on every run — the same
273- * asymmetry `findConverterDependentFiles` documents, and for the same reason:
274- * handing Vale this file does not check it badly, it risks the entire batch's
268+ * `sectionGlobs`, when given, is `AssembledValeConfig.sections` from
269+ * `assembleValeConfig` — the exact section patterns Vale's own rules are
270+ * scoped to. The scan globs those patterns instead of every file in the
271+ * tree, exactly as {@link findConverterDependentFiles} globs by its extension
272+ * list, and for the same reason precision matters here: a bare `**\/*` walk
273+ * finds every file under the target roots regardless of whether any rule
274+ * would ever touch it, and reporting one of those as "not checked" is a false
275+ * positive, not a caught coverage hole. Measured against this repository:
276+ * `pnpm-lock.yaml` and `packages/cli/CHANGELOG.md` are both over the limit,
277+ * and neither is named by any `[section]` in any rule's `.vale.ini` — no
278+ * rule was ever going to open either one, so the un-scoped walk reported
279+ * lost coverage that never existed.
280+ *
281+ * The patterns are read from `assembleValeConfig`'s own return value, never
282+ * by re-parsing the `.vale.ini` it wrote — see the doc on
283+ * `sectionPatternsOf` in `assemble.ts` for why that distinction matters.
284+ *
285+ * `sectionGlobs === undefined` falls back to the previous exhaustive `**\/*`
286+ * walk under each target root. That path exists for a caller with no
287+ * assembled config to ask — `verifyValeRule`'s isolating config, or a test
288+ * that hands `runVale` a hand-written `.vale.ini` directly — and is
289+ * unaffected by everything below: same cost, same behavior as before this
290+ * parameter existed.
291+ *
292+ * A named path is stat'd directly when there is no `sectionGlobs` to consult
293+ * (the fallback below), exactly as `targetFileParseError` does elsewhere in
294+ * this package: an explicit request is not resolved through the walk that
295+ * answers a whole-project run. **That changes once `sectionGlobs` is given.**
296+ * An explicitly named file is not exempt from scoping either — measured
297+ * against the real binary, Vale spends 9ms and reports nothing on a 128KB+
298+ * file whose extension no section names, the same as a file it never opened
299+ * at all, because no rule is ever assigned to run against it. Checking it
300+ * unconditionally would reintroduce the exact false positive this parameter
301+ * exists to remove, just reachable via `check some-file.yaml` instead of a
302+ * whole-project run. So when sections are known, a named file is a candidate
303+ * only if it is also a match for one of them — the same membership test the
304+ * walk below already computes.
305+ *
306+ * Whether named or discovered by the walk, an oversized file is excluded
307+ * unconditionally once it qualifies, on every run — the same asymmetry
308+ * `findConverterDependentFiles` documents, and for the same reason: handing
309+ * Vale this file does not check it badly, it risks the entire batch's
275310 * timeout.
276311 *
277312 * Errors are swallowed the same way as {@link findConverterDependentFiles} and
@@ -283,51 +318,83 @@ export interface OversizedFile {
283318export async function findOversizedFiles (
284319 cwd : string ,
285320 paths : string [ ] ,
286- maxBytes : number
321+ maxBytes : number ,
322+ sectionGlobs ?: string [ ]
287323) : Promise < OversizedFile [ ] > {
288- const named : OversizedFile [ ] = [ ] ;
289- const roots : string [ ] = [ ] ;
290- if ( paths . length === 0 ) {
291- roots . push ( "." ) ;
292- } else {
293- for ( const path of paths ) {
324+ const roots = paths . length === 0 ? [ "." ] : paths ;
325+ const found = new Map < string , OversizedFile > ( ) ;
326+
327+ const checkCandidate = async ( relative : string ) : Promise < void > => {
328+ if ( found . has ( relative ) ) return ;
329+ try {
330+ const stats = await stat ( resolvePath ( cwd , relative ) ) ;
331+ if ( stats . isFile ( ) && stats . size > maxBytes ) {
332+ found . set ( relative , { file : relative , size : stats . size } ) ;
333+ }
334+ } catch {
335+ // Gone between listing and stat, or unreadable. Not a reason to drop
336+ // the exclusion already computed.
337+ }
338+ } ;
339+
340+ if ( sectionGlobs === undefined ) {
341+ // No assembled config to ask what Vale would actually lint — fall back to
342+ // the previous behavior: every named path is a candidate regardless of
343+ // scope, and every root is walked exhaustively.
344+ for ( const path of paths ) await checkCandidate ( path ) ;
345+ for ( const root of roots ) {
346+ const prefix = root === "." || root === "" ? "" : `${ root } /` ;
294347 try {
295- const stats = await stat ( resolvePath ( cwd , path ) ) ;
296- if ( stats . isFile ( ) && stats . size > maxBytes ) {
297- named . push ( { file : path , size : stats . size } ) ;
348+ for await ( const match of glob ( `${ prefix } **/*` , {
349+ cwd,
350+ exclude : isUnwalkedEntry ,
351+ } ) ) {
352+ await checkCandidate ( String ( match ) ) ;
298353 }
299354 } catch {
300- // Unreadable or missing: not this function's problem. The run itself
301- // will report it if it matters.
355+ // A target that is not a directory, an unreadable subtree, a
356+ // platform where `glob` rejects the pattern: all of them mean "no
357+ // notice, never no fix". The exclusion has already been applied by
358+ // the time this runs.
302359 }
303- roots . push ( path ) ;
304360 }
361+ return [ ...found . values ( ) ] . toSorted ( ( a , b ) => a . file . localeCompare ( b . file ) ) ;
305362 }
306363
307- const found = new Map ( named . map ( ( entry ) => [ entry . file , entry ] ) ) ;
308- for ( const root of roots ) {
309- const prefix = root === "." || root === "" ? "" : `${ root } /` ;
364+ // Section patterns are root-relative, exactly as Vale reads them — never
365+ // prefixed per target root, the way the extension-based fallback above is.
366+ // A whole-project run (`roots === ["."]`) needs no further narrowing: every
367+ // match is already in scope. An explicit target (`check src/` or
368+ // `check src/doc.md`) narrows the matches down to that subtree afterward
369+ // instead, because a section like `CLAUDE.md` or `**/README.md` has no
370+ // meaningful "under src/" form to prefix onto — Vale itself evaluates every
371+ // section against the whole project and only its own target list decides
372+ // what it actually visits, so intersecting after the glob mirrors that
373+ // rather than guessing at one. This is also what makes a named file's
374+ // in-scope test free: it needs no separate membership check, because a
375+ // pattern like `**/README.md` already matches a top-level `README.md`
376+ // found this way, whether or not the caller named it explicitly.
377+ const wholeProject = paths . length === 0 ;
378+ for ( const pattern of sectionGlobs ) {
310379 try {
311- for await ( const match of glob ( ` ${ prefix } **/*` , {
380+ for await ( const match of glob ( pattern , {
312381 cwd,
313- exclude : ( entry ) => UNWALKED_DIRECTORIES . has ( basename ( String ( entry ) ) ) ,
382+ exclude : isUnwalkedEntry ,
314383 } ) ) {
315384 const relative = String ( match ) ;
316- if ( found . has ( relative ) ) continue ;
317- try {
318- const stats = await stat ( resolvePath ( cwd , relative ) ) ;
319- if ( stats . isFile ( ) && stats . size > maxBytes ) {
320- found . set ( relative , { file : relative , size : stats . size } ) ;
321- }
322- } catch {
323- // Same reasoning as above: gone between listing and stat, or
324- // unreadable. Not a reason to drop the exclusion already computed.
385+ if (
386+ ! wholeProject &&
387+ ! roots . some (
388+ ( root ) => relative === root || relative . startsWith ( `${ root } /` )
389+ )
390+ ) {
391+ continue ;
325392 }
393+ await checkCandidate ( relative ) ;
326394 }
327395 } catch {
328- // A target that is not a directory, an unreadable subtree, a platform
329- // where `glob` rejects the pattern: all of them mean "no notice, never
330- // no fix". The exclusion has already been applied by the time this runs.
396+ // Same reasoning as the fallback walk: a malformed pattern or an
397+ // unreadable subtree means "no notice, never no fix".
331398 }
332399 }
333400
0 commit comments