@drkmttr/adhere 0.21.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -155,180 +155,187 @@ const OLD_NAME = /^[0-9a-f]{64}$/;
155
155
  /**
156
156
  * `.adhere/cache/`, which is meant to be committed. `files/` holds answers by
157
157
  * the content they are about, and `tallies/` the linter check's tallies, with
158
- * paths from the working directory. A file is written once and never
159
- * rewritten: it is named for its group, a content hash or a tally key, and for
160
- * the hash of its own text. Two branches that add to a group add different
161
- * files, or the same one, so git merges them without a conflict. Reading a
162
- * group takes the union of its files, and pruning folds them back into one.
158
+ * paths from the checkout root (`base`, the working directory by default). A
159
+ * file is written once and never rewritten: it is named for its group, a
160
+ * content hash or a tally key, and for the hash of its own text. Two branches
161
+ * that add to a group add different files, or the same one, so git merges them
162
+ * without a conflict. Reading a group takes the union of its files, and pruning
163
+ * folds them back into one.
163
164
  *
164
165
  * A cache must never fail a run: whatever cannot be read counts as missing,
165
166
  * and whatever cannot be written is left unwritten.
166
167
  */
167
- export const AuditCacheLive = Layer.effect(AuditCache)(
168
- Effect.gen(function* () {
169
- const fs = yield* FileSystem.FileSystem;
170
- const path = yield* Path.Path;
171
- const crypto = yield* Crypto.Crypto;
172
- const root = path.resolve();
173
- const directory = path.join(root, CACHE_DIRECTORY);
174
- const hash = (text: string) => sha256(text).pipe(Effect.provideService(Crypto.Crypto, crypto));
175
-
176
- /** Each group's file names in one folder, listed once and kept current as files come and go. */
177
- const indexOf = (folder: string) =>
178
- Effect.cached(
179
- fs.readDirectory(path.join(directory, folder)).pipe(
180
- Effect.orElseSucceed((): ReadonlyArray<string> => []),
181
- Effect.map((names) => {
182
- const groups = new Map<string, Set<string>>();
183
- for (const name of names) {
184
- const group = NAME.exec(name)?.[1];
185
- if (group !== undefined)
186
- groups.set(group, (groups.get(group) ?? new Set()).add(name));
187
- }
188
- return groups;
189
- }),
190
- ),
191
- );
192
- const indexes = { files: yield* indexOf("files"), tallies: yield* indexOf("tallies") };
193
- type Folder = keyof typeof indexes;
194
-
195
- const read = <A>(decode: (text: string) => Option.Option<A>, file: string) =>
196
- fs.readFileString(file).pipe(
197
- Effect.map((text) => Option.getOrUndefined(decode(text))),
198
- Effect.orElseSucceed(() => undefined),
199
- );
200
-
201
- /** A group's files that decode, in name order. */
202
- const readGroup = Effect.fn("AuditCache.readGroup")(function* <A>(
203
- folder: Folder,
204
- group: string,
205
- decode: (text: string) => Option.Option<A>,
206
- ) {
207
- const names = [...((yield* indexes[folder]).get(group) ?? [])].sort();
208
- const decoded = yield* Effect.forEach(names, (name) =>
209
- read(decode, path.join(directory, folder, name)),
210
- );
211
- return decoded.filter((value): value is A => value !== undefined);
212
- });
213
-
214
- const nameOf = (group: string, text: string) =>
215
- Effect.map(hash(text), (digest) => `${group}.${digest.slice(0, 16)}.json`);
216
-
217
- /** Adds `value` to a group as a new file, unless the group has that file already. */
218
- const add = Effect.fn("AuditCache.add")(function* (
219
- folder: Folder,
220
- group: string,
221
- value: unknown,
222
- ) {
223
- const text = textOf(value);
224
- const name = yield* nameOf(group, text);
225
- const index = yield* indexes[folder];
226
- const names = index.get(group) ?? new Set<string>();
227
- if (names.has(name)) return;
228
- const written = yield* fs
229
- .makeDirectory(path.join(directory, folder), { recursive: true })
230
- .pipe(
231
- Effect.andThen(fs.writeFileString(path.join(directory, folder, name), text)),
232
- Effect.as(true),
233
- Effect.orElseSucceed(() => false),
168
+ export const AuditCacheAt = (base?: string) =>
169
+ Layer.effect(AuditCache)(
170
+ Effect.gen(function* () {
171
+ const fs = yield* FileSystem.FileSystem;
172
+ const path = yield* Path.Path;
173
+ const crypto = yield* Crypto.Crypto;
174
+ const root = path.resolve(base ?? ".");
175
+ const directory = path.join(root, CACHE_DIRECTORY);
176
+ const hash = (text: string) =>
177
+ sha256(text).pipe(Effect.provideService(Crypto.Crypto, crypto));
178
+
179
+ /** Each group's file names in one folder, listed once and kept current as files come and go. */
180
+ const indexOf = (folder: string) =>
181
+ Effect.cached(
182
+ fs.readDirectory(path.join(directory, folder)).pipe(
183
+ Effect.orElseSucceed((): ReadonlyArray<string> => []),
184
+ Effect.map((names) => {
185
+ const groups = new Map<string, Set<string>>();
186
+ for (const name of names) {
187
+ const group = NAME.exec(name)?.[1];
188
+ if (group !== undefined)
189
+ groups.set(group, (groups.get(group) ?? new Set()).add(name));
190
+ }
191
+ return groups;
192
+ }),
193
+ ),
234
194
  );
235
- if (written) index.set(group, names.add(name));
236
- });
237
-
238
- const remove = (file: string) =>
239
- fs.remove(file).pipe(
240
- Effect.as(1),
241
- Effect.orElseSucceed(() => 0),
242
- );
243
-
244
- /** Replaces a group's files with one holding `value`, or with none. Succeeds with the files deleted. */
245
- const fold = Effect.fn("AuditCache.fold")(function* (
246
- folder: Folder,
247
- group: string,
248
- value: unknown,
249
- ) {
250
- const index = yield* indexes[folder];
251
- const names = [...(index.get(group) ?? [])];
252
- const keep = value === undefined ? undefined : yield* nameOf(group, textOf(value));
253
- if (value !== undefined) yield* add(folder, group, value);
254
- let deleted = 0;
255
- for (const name of names) {
256
- if (name !== keep) deleted += yield* remove(path.join(directory, folder, name));
257
- }
258
- if (keep === undefined) index.delete(group);
259
- else index.set(group, new Set([keep]));
260
- return deleted;
261
- });
262
-
263
- const relativeOf = (tally: Tally): Tally => ({
264
- files: sorted(
265
- Record.mapKeys(tally.files, (file) =>
266
- path.isAbsolute(file) ? path.relative(root, file) : file,
267
- ),
268
- ),
269
- });
270
-
271
- const get = Effect.fn("AuditCache.get")(function* (contentHash: string, filePath: string) {
272
- const entries = yield* readGroup("files", contentHash, decodeEntry);
273
- if (entries.length > 0) return unionOf(entries.map((entry) => entry.answers));
274
- // Before content keys, a file's entry sat under the hash of its absolute path.
275
- const old = yield* read(decodeOldEntry, path.join(directory, yield* hash(filePath)));
276
- if (old === undefined || old.hash !== contentHash) return {};
277
- const answers = answersOf(old.judgments);
278
- yield* add("files", contentHash, { answers: sorted(answers) });
279
- return answers;
280
- });
281
-
282
- const tally = Effect.fn("AuditCache.tally")(function* (key: string) {
283
- const tallies = yield* readGroup("tallies", key, decodeTally);
284
- if (tallies.length > 0) {
285
- const { files } = tallyUnionOf(tallies);
286
- return { files: Record.mapKeys(files, (file) => path.resolve(root, file)) };
287
- }
288
- // Before, a tally sat under the hash of its key, with absolute paths.
289
- const old = yield* read(
290
- decodeTally,
291
- path.join(directory, yield* hash(`\u0000tally\u0000${key}`)),
292
- );
293
- if (old !== undefined) yield* add("tallies", key, relativeOf(old));
294
- return old;
295
- });
296
-
297
- const prune = Effect.fn("AuditCache.prune")(function* (live: Live) {
298
- // Tallies still in the layout before content keys move first, so deleting what is left of it loses nothing.
299
- yield* Effect.forEach(live.tallies, tally, { discard: true });
300
- let deleted = 0;
301
- // Folding replaces or removes the group being visited, which a Map's iteration allows.
302
- for (const group of (yield* indexes.files).keys()) {
303
- const entries = live.hashes.has(group) ? yield* readGroup("files", group, decodeEntry) : [];
304
- const answers = unionOf(entries.map((entry) => entry.answers));
305
- deleted += yield* fold(
306
- "files",
307
- group,
308
- Object.keys(answers).length === 0 ? undefined : { answers: sorted(answers) },
195
+ const indexes = { files: yield* indexOf("files"), tallies: yield* indexOf("tallies") };
196
+ type Folder = keyof typeof indexes;
197
+
198
+ const read = <A>(decode: (text: string) => Option.Option<A>, file: string) =>
199
+ fs.readFileString(file).pipe(
200
+ Effect.map((text) => Option.getOrUndefined(decode(text))),
201
+ Effect.orElseSucceed(() => undefined),
309
202
  );
310
- }
311
- for (const key of (yield* indexes.tallies).keys()) {
203
+
204
+ /** A group's files that decode, in name order. */
205
+ const readGroup = Effect.fn("AuditCache.readGroup")(function* <A>(
206
+ folder: Folder,
207
+ group: string,
208
+ decode: (text: string) => Option.Option<A>,
209
+ ) {
210
+ const names = [...((yield* indexes[folder]).get(group) ?? [])].sort();
211
+ const decoded = yield* Effect.forEach(names, (name) =>
212
+ read(decode, path.join(directory, folder, name)),
213
+ );
214
+ return decoded.filter((value): value is A => value !== undefined);
215
+ });
216
+
217
+ const nameOf = (group: string, text: string) =>
218
+ Effect.map(hash(text), (digest) => `${group}.${digest.slice(0, 16)}.json`);
219
+
220
+ /** Adds `value` to a group as a new file, unless the group has that file already. */
221
+ const add = Effect.fn("AuditCache.add")(function* (
222
+ folder: Folder,
223
+ group: string,
224
+ value: unknown,
225
+ ) {
226
+ const text = textOf(value);
227
+ const name = yield* nameOf(group, text);
228
+ const index = yield* indexes[folder];
229
+ const names = index.get(group) ?? new Set<string>();
230
+ if (names.has(name)) return;
231
+ const written = yield* fs
232
+ .makeDirectory(path.join(directory, folder), { recursive: true })
233
+ .pipe(
234
+ Effect.andThen(fs.writeFileString(path.join(directory, folder, name), text)),
235
+ Effect.as(true),
236
+ Effect.orElseSucceed(() => false),
237
+ );
238
+ if (written) index.set(group, names.add(name));
239
+ });
240
+
241
+ const remove = (file: string) =>
242
+ fs.remove(file).pipe(
243
+ Effect.as(1),
244
+ Effect.orElseSucceed(() => 0),
245
+ );
246
+
247
+ /** Replaces a group's files with one holding `value`, or with none. Succeeds with the files deleted. */
248
+ const fold = Effect.fn("AuditCache.fold")(function* (
249
+ folder: Folder,
250
+ group: string,
251
+ value: unknown,
252
+ ) {
253
+ const index = yield* indexes[folder];
254
+ const names = [...(index.get(group) ?? [])];
255
+ const keep = value === undefined ? undefined : yield* nameOf(group, textOf(value));
256
+ if (value !== undefined) yield* add(folder, group, value);
257
+ let deleted = 0;
258
+ for (const name of names) {
259
+ if (name !== keep) deleted += yield* remove(path.join(directory, folder, name));
260
+ }
261
+ if (keep === undefined) index.delete(group);
262
+ else index.set(group, new Set([keep]));
263
+ return deleted;
264
+ });
265
+
266
+ const relativeOf = (tally: Tally): Tally => ({
267
+ files: sorted(
268
+ Record.mapKeys(tally.files, (file) =>
269
+ path.isAbsolute(file) ? path.relative(root, file) : file,
270
+ ),
271
+ ),
272
+ });
273
+
274
+ const get = Effect.fn("AuditCache.get")(function* (contentHash: string, filePath: string) {
275
+ const entries = yield* readGroup("files", contentHash, decodeEntry);
276
+ if (entries.length > 0) return unionOf(entries.map((entry) => entry.answers));
277
+ // Before content keys, a file's entry sat under the hash of its absolute path.
278
+ const old = yield* read(decodeOldEntry, path.join(directory, yield* hash(filePath)));
279
+ if (old === undefined || old.hash !== contentHash) return {};
280
+ const answers = answersOf(old.judgments);
281
+ yield* add("files", contentHash, { answers: sorted(answers) });
282
+ return answers;
283
+ });
284
+
285
+ const tally = Effect.fn("AuditCache.tally")(function* (key: string) {
312
286
  const tallies = yield* readGroup("tallies", key, decodeTally);
313
- deleted += yield* fold(
314
- "tallies",
315
- key,
316
- tallies.length === 0 ? undefined : relativeOf(tallyUnionOf(tallies)),
287
+ if (tallies.length > 0) {
288
+ const { files } = tallyUnionOf(tallies);
289
+ return { files: Record.mapKeys(files, (file) => path.resolve(root, file)) };
290
+ }
291
+ // Before, a tally sat under the hash of its key, with absolute paths.
292
+ const old = yield* read(
293
+ decodeTally,
294
+ path.join(directory, yield* hash(`\u0000tally\u0000${key}`)),
317
295
  );
318
- }
319
- const old = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
320
- for (const name of old) {
321
- if (OLD_NAME.test(name)) deleted += yield* remove(path.join(directory, name));
322
- }
323
- return deleted;
324
- });
325
-
326
- return AuditCache.of({
327
- get,
328
- put: (contentHash, answers) => add("files", contentHash, { answers: sorted(answers) }),
329
- tally,
330
- putTally: (key, value) => add("tallies", key, relativeOf(value)),
331
- prune,
332
- });
333
- }),
334
- );
296
+ if (old !== undefined) yield* add("tallies", key, relativeOf(old));
297
+ return old;
298
+ });
299
+
300
+ const prune = Effect.fn("AuditCache.prune")(function* (live: Live) {
301
+ // Tallies still in the layout before content keys move first, so deleting what is left of it loses nothing.
302
+ yield* Effect.forEach(live.tallies, tally, { discard: true });
303
+ let deleted = 0;
304
+ // Folding replaces or removes the group being visited, which a Map's iteration allows.
305
+ for (const group of (yield* indexes.files).keys()) {
306
+ const entries = live.hashes.has(group)
307
+ ? yield* readGroup("files", group, decodeEntry)
308
+ : [];
309
+ const answers = unionOf(entries.map((entry) => entry.answers));
310
+ deleted += yield* fold(
311
+ "files",
312
+ group,
313
+ Object.keys(answers).length === 0 ? undefined : { answers: sorted(answers) },
314
+ );
315
+ }
316
+ for (const key of (yield* indexes.tallies).keys()) {
317
+ const tallies = yield* readGroup("tallies", key, decodeTally);
318
+ deleted += yield* fold(
319
+ "tallies",
320
+ key,
321
+ tallies.length === 0 ? undefined : relativeOf(tallyUnionOf(tallies)),
322
+ );
323
+ }
324
+ const old = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
325
+ for (const name of old) {
326
+ if (OLD_NAME.test(name)) deleted += yield* remove(path.join(directory, name));
327
+ }
328
+ return deleted;
329
+ });
330
+
331
+ return AuditCache.of({
332
+ get,
333
+ put: (contentHash, answers) => add("files", contentHash, { answers: sorted(answers) }),
334
+ tally,
335
+ putTally: (key, value) => add("tallies", key, relativeOf(value)),
336
+ prune,
337
+ });
338
+ }),
339
+ );
340
+
341
+ export const AuditCacheLive = AuditCacheAt();
@@ -172,20 +172,27 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
172
172
  * Tests are listed even when no rule judges them, so a prune keeps what other runs
173
173
  * know of them. Tests substitute their own tree.
174
174
  * The scan root is the current working directory, so the audit reads the
175
- * repository it is run in.
175
+ * repository it is run in. `roots` overrides the workspace trees read: `["."]`
176
+ * reads the whole working directory, for a trial that must not miss a file
177
+ * outside `agents/`, `apps/`, and `packages/`. `base` roots the walk
178
+ * elsewhere, for a trial of a checkout that is not the working directory.
176
179
  */
177
- export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
180
+ export const SourceWalkerLive = (
181
+ filter: ReadonlyArray<string> = [],
182
+ roots?: ReadonlyArray<string>,
183
+ base?: string,
184
+ ) =>
178
185
  Layer.effect(SourceWalker)(
179
186
  Effect.gen(function* () {
180
187
  const fs = yield* FileSystem.FileSystem;
181
188
  const path = yield* Path.Path;
182
189
  const config = yield* AdhereConfig;
183
- // No segments: Path resolves against the working directory.
184
- const root = path.resolve();
190
+ const root = base === undefined ? path.resolve() : path.resolve(base);
185
191
  const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
186
192
  const patterns = [...filter, ...(config.exclude ?? []).map((glob) => `!${glob}`)];
187
193
  const walk = Effect.fn("SourceWalker.files")(function* () {
188
- const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
194
+ const dirs =
195
+ roots === undefined ? yield* scanDirs(fs, root).pipe(Effect.mapError(refused)) : roots;
189
196
  const trees = yield* clearingStatus(
190
197
  Effect.forEach(dirs, (dir) =>
191
198
  listRoot(
package/src/wording.ts CHANGED
@@ -4,7 +4,7 @@ import { type RuleEntry, shownId } from "#rules.ts";
4
4
  type Word = Example["word"];
5
5
 
6
6
  /**
7
- * A way a rule's wording differs from the README's rule writing tips, which
7
+ * A way a rule's wording differs from the docs' rule writing tips, which
8
8
  * the eval measured: the rule shows code of both kinds, and its description
9
9
  * says the words its code is under.
10
10
  */
@@ -22,7 +22,7 @@ export interface Stray {
22
22
  readonly straying: ReadonlyArray<Straying>;
23
23
  }
24
24
 
25
- export const TIPS_URL = "https://github.com/darkmatter/adhere#rule-writing-tips";
25
+ export const TIPS_URL = "https://dm.sh/adhere/writing-good-rules/#rule-writing-tips";
26
26
 
27
27
  /** Whether a description says a word, whole and in any case: "Must" does, "mustard" does not. */
28
28
  const says = (description: string, word: Word): boolean =>
@@ -30,7 +30,7 @@ const says = (description: string, word: Word): boolean =>
30
30
 
31
31
  /**
32
32
  * How one rule strays from the tips. A rule with only code that breaks it
33
- * lacks no example: the README keeps that for a rule with no single correct
33
+ * lacks no example: the docs keep that for a rule with no single correct
34
34
  * form to show.
35
35
  */
36
36
  export const strayingOf = (rule: Rule): ReadonlyArray<Straying> => {
@@ -9,6 +9,7 @@ import {
9
9
  type Rules,
10
10
  } from "#config.ts";
11
11
  import { type Excerpt, type Range } from "#excerpt.ts";
12
+ import { contentPasses, filtersNeedText, hasFilters, ruleAppliesTo } from "#filters.ts";
12
13
  import { type ScannedFile, WalkUnavailable } from "#models/Audit.ts";
13
14
  import { AdhereConfig } from "#services/AdhereConfig.ts";
14
15
  import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
@@ -147,6 +148,8 @@ const fingerprintOf = (model: string, reads: string, rule: Rule) => {
147
148
  const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
148
149
  const options =
149
150
  rule.optionValues === undefined ? "" : `\u0000${JSON.stringify(rule.optionValues)}`;
151
+ // Filters decide which files a rule may judge, not what Jev answers, so they
152
+ // do not change the fingerprint: applicability is checked before the cache.
150
153
  return sha256(
151
154
  `${model}\u0000${LAYOUT}${reads}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${options}`,
152
155
  );
@@ -352,7 +355,12 @@ const sameSuppressions = (left: Suppressions, right: Suppressions): boolean => {
352
355
  );
353
356
  };
354
357
 
355
- /** Validate code and ignore policy before sending a request or showing a cached excerpt. */
358
+ /**
359
+ * Validate code, ignore policy, and text-dependent filters before sending a
360
+ * request or showing a cached excerpt. A comment-only edit keeps the code hash
361
+ * but can change what a content filter sees, so those filters are rechecked
362
+ * against the new text; path filters cannot change for the same file.
363
+ */
356
364
  const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeComments: boolean) {
357
365
  const original = yield* (yield* SourceWalker).read(plan.file);
358
366
  const { lines, suppressions } = normalized(original.lines, includeComments);
@@ -364,6 +372,17 @@ const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeC
364
372
  message: `${plan.file.path}: source changed since planning. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
365
373
  });
366
374
  }
375
+ let written: string | undefined;
376
+ const dropped = Object.entries(plan.prepared).flatMap(([id, { rule }]) =>
377
+ filtersNeedText(rule) && !contentPasses(rule, (written ??= original.lines.join("\n")))
378
+ ? [id]
379
+ : [],
380
+ );
381
+ if (dropped.length > 0) {
382
+ return yield* WalkUnavailable.make({
383
+ message: `${plan.file.path}: source changed since planning, and content filters no longer apply to ${dropped.join(", ")}. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
384
+ });
385
+ }
367
386
  return { file: { ...plan.file, lines }, original: original.lines } satisfies LoadedFile;
368
387
  });
369
388
 
@@ -385,6 +404,8 @@ const MAX_DEPENDENT_FILES = 50;
385
404
  export interface PlanOptions {
386
405
  /** Select at most this many files needing judge or cached-locate requests; zero plans only. */
387
406
  readonly limit?: number | undefined;
407
+ /** The checkout Jev's paths are relative to. Defaults to the working directory. */
408
+ readonly root?: string | undefined;
388
409
  }
389
410
 
390
411
  export const planAudit = (
@@ -410,9 +431,10 @@ export const planAudit = (
410
431
  };
411
432
  const walker = yield* SourceWalker;
412
433
  const files = yield* walker.files;
413
- // Each file's path from the working directory, `/`-separated, as Jev reads it.
434
+ // Each file's path from the checkout, `/`-separated, as Jev reads it.
435
+ const root = options.root ?? process.cwd();
414
436
  const fromRoot = new Map(
415
- files.map((file) => [file.path, relative(process.cwd(), file.path).split(sep).join("/")]),
437
+ files.map((file) => [file.path, relative(root, file.path).split(sep).join("/")]),
416
438
  );
417
439
  // Only the specifiers stay in memory: each file is read again when it is planned.
418
440
  const dependentFiles = config.reads.includes("dependents")
@@ -452,15 +474,17 @@ export const planAudit = (
452
474
  );
453
475
 
454
476
  // The rules that judge a file: those scoped to it, of which only the rules that say `tests`
455
- // judge a test, and none that an adhere-ignore-file comment names.
456
- const configuredRules = (file: SourceFile, suppressions: Suppressions) =>
477
+ // judge a test, none that an adhere-ignore-file comment names, and none whose own
478
+ // include/exclude filters leave this file out.
479
+ const configuredRules = (file: SourceFile, suppressions: Suppressions, text: () => string) =>
457
480
  Record.filter(
458
481
  config.scopedRules === undefined
459
482
  ? config.rules
460
483
  : applicableRules(file.path, config.scopedRules),
461
484
  (rule, id) =>
462
485
  judgesFile(rule, file.test === true) &&
463
- !suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })),
486
+ !suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })) &&
487
+ (!hasFilters(rule) || ruleAppliesTo(rule, fromRoot.get(file.path)!, text())),
464
488
  );
465
489
 
466
490
  // Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
@@ -498,7 +522,16 @@ export const planAudit = (
498
522
  const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
499
523
  const original = yield* walker.read(file);
500
524
  const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
501
- const rules = configuredRules(file, suppressions);
525
+ // A rule's content filters read the file as written, comments and all, even
526
+ // when Jev is sent code without them. The decision is kept in `prepared`;
527
+ // reloading rechecks only those filters, in case a comment-only edit changed
528
+ // their answer without changing the code hash.
529
+ let written: string | undefined;
530
+ const rules = configuredRules(
531
+ file,
532
+ suppressions,
533
+ () => (written ??= original.lines.join("\n")),
534
+ );
502
535
  const code = lines.join("\n");
503
536
  const readState: ReadState = {
504
537
  ...(workspacePackages === undefined
@@ -214,7 +214,7 @@ const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line
214
214
  /**
215
215
  * The warning on a finding whose file may not show enough to check its rule,
216
216
  * which its context score in the header says how far, and help: how to add
217
- * what the code relies on as a note Jev reads, in the form the README asks for.
217
+ * what the code relies on as a note Jev reads, in the form the docs ask for.
218
218
  */
219
219
  const insufficient = (finding: Finding, sufficiencyThreshold: number): ReadonlyArray<Line> =>
220
220
  !lacksContext(finding, sufficiencyThreshold)