Skip to content

Commit 09134f5

Browse files
committed
ref(runtime): share the capture enumeration, not only the classifier
`loadCaptureRules` and `verify`'s loop both read `captures/`, filtered to `.yml`/`.yaml`, sorted, parsed each file and called `assessCaptureRule`. Only the classification was shared, so a change to what counts as a capture — a new extension, an exclusion, the sort order — had two call sites to keep in step, and they could drift even though the classifier could not. `assessCaptureDirectory` is now the one enumeration both walk. Unparseable YAML reports through the same channel rather than as a separate branch in each caller, so there is one list of reasons a capture is refused rather than one list plus an exception. `verify`'s "no capture rules in captures/" check counts the same assessments instead of doing its own `readdir`. Corrects the reason I gave for deferring this. I said the shared helper wanted to be the one the file-set writer uses when it validates `captures/`, and that is wrong: the writer inspects delivered paths in memory and never enumerates a directory, so there was no second use to wait for. Found by the review on #216.
1 parent fd22d5b commit 09134f5

2 files changed

Lines changed: 57 additions & 36 deletions

File tree

‎packages/cli/src/rules/inspect.ts‎

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { readFile } from "node:fs/promises";
2-
import { readdir } from "node:fs/promises";
3-
import { join } from "node:path";
42

53
import { parse } from "yaml";
64

75
import { ruleCapturesDirectory, ruleConfigPath, ruleFilePath } from "./engines";
86
import { type EngineName } from "./layout";
9-
import { assessCaptureRule } from "./runtime/discover";
7+
import { assessCaptureDirectory } from "./runtime/discover";
108
import { validateValeRule } from "../schemas/vale-rule";
119
import { verifyRule, type VerifyResult } from "./verify";
1210
import { verifyValeRule } from "./vale/verify";
@@ -177,16 +175,8 @@ export async function verifyOneRule(
177175
}
178176
const captures = ruleCapturesDirectory(cwd, engine, ruleId);
179177
if (captures !== undefined) {
180-
let captureFiles: string[] = [];
181-
try {
182-
const entries = await readdir(captures);
183-
captureFiles = entries.filter(
184-
(entry) => entry.endsWith(".yml") || entry.endsWith(".yaml")
185-
);
186-
} catch {
187-
captureFiles = [];
188-
}
189-
if (captureFiles.length === 0) {
178+
const assessed = await assessCaptureDirectory(captures);
179+
if (assessed.length === 0) {
190180
errors.push(
191181
`${ruleId} has no capture rules in captures/, so check.ts would never be invoked.`
192182
);
@@ -198,18 +188,10 @@ export async function verifyOneRule(
198188
// the reason is named here, while the author is looking at one rule rather
199189
// than at an empty report.
200190
//
201-
// `assessCaptureRule` is the SAME function discovery refuses with, so
202-
// "verify says it is fine" and "the run silently skipped it" cannot come
203-
// apart.
204-
for (const fileName of captureFiles.toSorted()) {
205-
let parsed: unknown;
206-
try {
207-
parsed = parse(await readFile(join(captures, fileName), "utf8"));
208-
} catch {
209-
errors.push(`${ruleId}/captures/${fileName} is not valid YAML.`);
210-
continue;
211-
}
212-
const assessment = assessCaptureRule(parsed);
191+
// The SAME enumeration and the SAME assessor discovery uses, so "verify
192+
// says it is fine" and "the run silently skipped it" cannot come apart —
193+
// neither about which files are candidates, nor about what each one is.
194+
for (const { fileName, assessment } of assessed) {
213195
if (!assessment.ok) {
214196
errors.push(
215197
`${ruleId}/captures/${fileName} ${assessment.reason}. The capture is skipped, so the rule would run against less than it was written for.`

‎packages/cli/src/rules/runtime/discover.ts‎

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,33 +143,72 @@ export function assessCaptureRule(value: unknown): CaptureAssessment {
143143
return { ok: true, rule: candidate as CaptureRule & { id: string }, match };
144144
}
145145

146-
/** Load and parse the capture rules of a single runtime-rule directory. */
147-
async function loadCaptureRules(
146+
/** One capture file in a rule's `captures/`, with the verdict on it. */
147+
export interface AssessedCapture {
148+
/** Absolute path to the capture file. */
149+
file: string;
150+
/** Basename, as `verify` reports it. */
151+
fileName: string;
152+
assessment: CaptureAssessment;
153+
}
154+
155+
/**
156+
* Enumerate a rule's `captures/` and assess every file in it.
157+
*
158+
* ONE ENUMERATION, TWO CALLERS, for the same reason there is one assessor:
159+
* discovery and `verify` must not disagree about which files are even
160+
* candidates. Sharing only the classifier left this loop duplicated, so a
161+
* change to what counts as a capture -- a new extension, an exclusion, the sort
162+
* order -- had two call sites to keep in step, and they could drift even though
163+
* the classifier could not.
164+
*
165+
* Unparseable YAML is reported through the same channel rather than as a
166+
* separate branch in each caller, so there is one list of reasons a capture is
167+
* refused rather than one list plus an exception.
168+
*/
169+
export async function assessCaptureDirectory(
148170
directory: string
149-
): Promise<LoadedCaptureRule[]> {
171+
): Promise<AssessedCapture[]> {
150172
let entries: string[];
151173
try {
152174
entries = await readdir(directory);
153175
} catch {
154176
return [];
155177
}
156-
const ymlFiles = entries.filter(
157-
(f) => f.endsWith(".yml") || f.endsWith(".yaml")
158-
);
159-
const loaded: LoadedCaptureRule[] = [];
160-
for (const fileName of ymlFiles.toSorted()) {
178+
const assessed: AssessedCapture[] = [];
179+
const candidates = entries
180+
.filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
181+
.toSorted();
182+
for (const fileName of candidates) {
161183
const file = join(directory, fileName);
162184
let parsed: unknown;
163185
try {
164186
parsed = parse(await readFile(file, "utf8"));
165187
} catch {
166-
continue; // not valid YAML — skip
188+
assessed.push({
189+
file,
190+
fileName,
191+
assessment: { ok: false, reason: "is not valid YAML" },
192+
});
193+
continue;
167194
}
195+
assessed.push({ file, fileName, assessment: assessCaptureRule(parsed) });
196+
}
197+
return assessed;
198+
}
199+
200+
/** Load the capture rules of one runtime-rule directory, refusing the rest. */
201+
async function loadCaptureRules(
202+
directory: string
203+
): Promise<LoadedCaptureRule[]> {
204+
const loaded: LoadedCaptureRule[] = [];
205+
for (const { file, fileName, assessment } of await assessCaptureDirectory(
206+
directory
207+
)) {
168208
// Refused, never coerced or guessed at. `verify` names the file and the
169-
// reason (see `inspect.ts`, which calls the same assessor), so a capture
209+
// reason (see `inspect.ts`, which walks the same assessments), so a capture
170210
// disappearing from the run is explained rather than left to look like a
171211
// rule that found nothing.
172-
const assessment = assessCaptureRule(parsed);
173212
if (!assessment.ok) continue;
174213
const { rule, match } = assessment;
175214
loaded.push({

0 commit comments

Comments
 (0)