@drkmttr/adhere 0.23.0 → 0.24.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -93,10 +93,10 @@
93
93
  "--force"
94
94
  ],
95
95
  "optionalDependencies": {
96
- "@drkmttr/adhere-darwin-arm64": "0.23.0",
97
- "@drkmttr/adhere-darwin-x64": "0.23.0",
98
- "@drkmttr/adhere-linux-arm64": "0.23.0",
99
- "@drkmttr/adhere-linux-x64": "0.23.0",
100
- "@drkmttr/adhere-win32-x64": "0.23.0"
96
+ "@drkmttr/adhere-darwin-arm64": "0.24.1",
97
+ "@drkmttr/adhere-darwin-x64": "0.24.1",
98
+ "@drkmttr/adhere-linux-arm64": "0.24.1",
99
+ "@drkmttr/adhere-linux-x64": "0.24.1",
100
+ "@drkmttr/adhere-win32-x64": "0.24.1"
101
101
  }
102
102
  }
@@ -121,6 +121,18 @@ Front matter besides `description`, all optional:
121
121
  - `tests: only` for a rule about tests, `tests: include` for one that holds in
122
122
  tests too. Otherwise rules skip `.test` and `.spec` files and files under
123
123
  `test/`, `tests/`, `__tests__/`, and `fixtures/`.
124
+ - `include: {"paths":["src/**"],"content":["from\\s+[\"']effect[\"']"]}`
125
+ and `exclude: {"paths":["**/generated/**"],"content":["@generated"]}`,
126
+ JSON objects on one line, filter files locally before requests or cached
127
+ findings. TypeScript rules use ordinary objects with the same fields.
128
+ Paths are repository-root-relative globs, even for nested rules. Content
129
+ patterns are JavaScript regex source strings without delimiters or flags,
130
+ matched against original text including comments. Invalid regex refuses
131
+ loading. Entries within each list are alternatives; every nonempty include
132
+ list must match, and any exclude match vetoes the file. Empty lists impose
133
+ no restriction. Existing scope, test settings, and ignores still apply.
134
+ An import regex is not import resolution: check comments, alternative import
135
+ forms, and re-exports before narrowing a rule and silently missing violations.
124
136
  - `appliesTo: ["a declared union type or schema"]` and
125
137
  `excludeIf: ["a type that mirrors a third-party format"]`, JSON arrays on
126
138
  one line, scope a rule without widening its description: a finding stands
@@ -354,14 +366,18 @@ A convention a linter can decide exactly does not belong in a `RULE.md`.
354
366
  Whichever route it takes, the draft is trialled on real files before anything
355
367
  is written: the first 100 applicable files by default, the entire repo, or a
356
368
  number the user types, with the plan's known cost shown first. The authoring
357
- agent reads the saved report in `.adhere/trial/` and the source it names, then
358
- the user accepts the findings, reviews each as correct, a false positive, or
359
- unsure, or asks for a revision. Findings and examples take optional notes for
360
- the agent; a revision is re-approved and reruns the same files, so the new
361
- evidence is comparable. Skipping is allowed, but the write prompt then says
362
- `(not trialled)` and the closing line says `Not trialled: …`; nothing is called
363
- validated on a skipped, partial, or stale run. The trial runs for native
364
- settings and approved plugins too.
369
+ agent reads the saved report in `.adhere/trial/` and the source it names. After
370
+ the first completed adhere trial, it is explicitly asked whether path/content
371
+ filters would help: recommend exact entries or explain why none are needed,
372
+ including the risk of hiding real violations. The user accepts the draft,
373
+ reviews findings, or asks for a revision. Findings and examples take optional
374
+ notes; revisions receive those notes and the filter recommendation, are
375
+ re-approved, and rerun the same sample. Newly filtered files are reported as
376
+ skipped, not replaced. The initial cap counts files that pass filters.
377
+ Skipping is allowed, but the write prompt then says `(not trialled)` and the
378
+ closing line says `Not trialled: …`; nothing is called validated on a skipped,
379
+ partial, or stale run. Native settings and approved plugins are trialled too,
380
+ without adhere-only filter recommendations.
365
381
 
366
382
  A project plugin and its checked-in test go to
367
383
  `.adhere/oxlint/<plugin>.mjs` and `.adhere/oxlint/<plugin>.test.mjs`; the
@@ -395,10 +411,12 @@ Oxlint. `adhere validate` reports rules left over that a linter could check.
395
411
  An organization keeps its instructions, rules, and lint settings in one repo,
396
412
  scaffolded by `adhere init --shared org/repo`. Its `README.md` carries a
397
413
  managed guide between `<!-- adhere:rules:start -->` and
398
- `<!-- adhere:rules:end -->`: a contents list nesting every rule under its
399
- topic, the rules grouped by topic with each bold statement and rule id opening
400
- that rule's `RULE.md` verbatim: examples and explanation, then a source link
401
- under a stable permalink, every enabled native setting in `oxlint/rules.json`
414
+ `<!-- adhere:rules:end -->`: a contents list linking the rules, lint settings,
415
+ and custom plugin sections. Under a level-two Rules heading, rules sort by
416
+ topic and id without visible topic headings. Each rule id opens an expandable
417
+ entry containing its description, its `RULE.md` examples and explanation,
418
+ and a source link under a stable permalink. The guide also lists every enabled
419
+ native setting in `oxlint/rules.json`
402
420
  with its severity, and each custom plugin's enabled rules, linked to
403
421
  `oxlint/plugins/<name>.js` when that is a local file. A `RULE.ts` is linked as
404
422
  TypeScript source only: the guide never reads or runs it, and plugin code is
@@ -586,6 +604,10 @@ Per rule, read its findings and decide:
586
604
  - Many hits at 0.80 to 0.90, mostly not violations: the description is too
587
605
  broad. Narrow it, or scope it with `appliesTo` or `excludeIf`, before
588
606
  raising its `threshold`: a threshold hides, a sentence explains.
607
+ - Hits in irrelevant files: consider local `include`/`exclude` path or content
608
+ filters before paying to judge them. Verify the boundary against real
609
+ violations and unflagged files; an absent import does not prove the library
610
+ is unused when the project uses wrappers or re-exports.
589
611
  - Hits on code that follows the pattern through another API: the `must`
590
612
  block is too specific. Use the repo's most general correct example.
591
613
  - Right less than half the time and not fixable by wording: `level: warning`,
package/src/config.ts CHANGED
@@ -71,6 +71,44 @@ export const Reads = Schema.Array(
71
71
  Schema.Literals(["workspacePackages", "dependents", "symbols", "references"]),
72
72
  );
73
73
 
74
+ /**
75
+ * A JavaScript regular expression's source, checked here so a rule with a bad
76
+ * pattern fails to load rather than at the first file it would filter.
77
+ */
78
+ const isValidRegexSource = (source: string): string | undefined => {
79
+ try {
80
+ new RegExp(source);
81
+ return undefined;
82
+ } catch (problem) {
83
+ return `is not a valid JavaScript regular expression: ${
84
+ problem instanceof Error ? problem.message : String(problem)
85
+ }`;
86
+ }
87
+ };
88
+
89
+ const RegexSource = Schema.String.check(
90
+ Schema.makeFilter(isValidRegexSource, {
91
+ toCode: () => ({ runtime: "Schema.makeFilter(isValidRegexSource)" }),
92
+ }),
93
+ );
94
+
95
+ /**
96
+ * One side of a rule's file filter: repository-relative globs for paths, and
97
+ * regular expression sources for the file's text as written. A rule's
98
+ * `include` and `exclude` each take this shape.
99
+ */
100
+ export const RuleFilter = Schema.Struct({
101
+ paths: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
102
+ description:
103
+ "Repository-root-relative glob patterns, `/`-separated, as in `src/**` or `**/*.test.ts`.",
104
+ }),
105
+ content: Schema.optionalKey(Schema.Array(RegexSource)).annotate({
106
+ description:
107
+ "JavaScript regular expression sources, without `/` delimiters or flags, matched against the file's text as written, comments included.",
108
+ }),
109
+ }).annotate({ identifier: "RuleFilter" });
110
+ export interface RuleFilter extends Schema.Schema.Type<typeof RuleFilter> {}
111
+
74
112
  /**
75
113
  * A rule's examples, named in RFC 2119's words, which Jev reads as written. A
76
114
  * requirement shows code that `must` be written and code that must `never`
@@ -109,6 +147,14 @@ export const Rule = Schema.Struct({
109
147
  description:
110
148
  "How a finding under the rule is reported: an `error`, the default, which fails the run, or a `warning`, for a nit or a rule that errs toward false findings, which does not.",
111
149
  }),
150
+ include: Schema.optionalKey(RuleFilter).annotate({
151
+ description:
152
+ "Where the rule applies, beside its scope and `tests`: a file must match every configured category, and within `paths` or `content` any entry is enough. An omitted or empty list puts no condition on that category.",
153
+ }),
154
+ exclude: Schema.optionalKey(RuleFilter).annotate({
155
+ description:
156
+ "Where the rule does not apply: the same shape as `include`. Any matching path or content entry drops the file before it is judged, so no cached finding for it is read or reported.",
157
+ }),
112
158
  appliesTo: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
113
159
  description:
114
160
  "Where the rule applies, each a description of code that breaks it: Jev is asked of each whether any code that breaks the rule is as it describes, and a finding stands only when it says yes to every one.",
package/src/create/ask.ts CHANGED
@@ -155,6 +155,17 @@ const readsInstruction = (shape: Shape) => {
155
155
  return `Declare the context Jev needs beside the code as \`reads: [${reads.map((read) => `"${read}"`).join(", ")}]\` ${shape.format === "typescript" ? "in `defineRule({...})`" : "in its front matter"}. A rule's \`reads\` is a declaration, not an enablement: the project's config still decides what Jev reads, and lint warns that the rule may be less accurate until a declared read is enabled.`;
156
156
  };
157
157
 
158
+ /**
159
+ * What a rule may do to narrow the files it judges at all: a mechanical
160
+ * filter, taught when a rule is written or revised, never to an oxlint rule.
161
+ */
162
+ const filtersInstruction = (shape: Shape) =>
163
+ `A rule may also narrow the files it judges before Jev sees them, with optional \`include\` and \`exclude\` objects. Each holds \`paths\`, globs relative to the repository root with forward slashes, matched as \`--filter\` matches them, and \`content\`, JavaScript regular expression sources without / delimiters or flags, tested against each file's original text, comments included. Any entry in a list suffices; \`include\` applies only when every list it sets matches, while a match in either \`exclude\` list drops the file. An absent object or an empty list restricts nothing. A filter is silent: a file it drops is never judged, so a wrong one is a false negative. Use \`paths\` for where the rule applies and \`content\` for what a file says; a filter is not a substitute for \`appliesTo\` or \`excludeIf\`, which ask Jev about the code. \`content\` can match a named package's import specifier, such as from "effect", or a directive, but a regular expression cannot resolve aliases or re-exports, know whether a name is a type or a value, or reliably tell code from comments and other strings; use \`paths\`, \`appliesTo\`, or a TypeScript rule for that. Because \`content\` entries are strings, escape a regex's backslashes: a regex ^\\s*import is written "^\\\\s*import" in both front matter JSON and a RULE.ts string. ${
164
+ shape.format === "typescript"
165
+ ? 'Set them in defineRule({...}) as object literals, such as include: { paths: ["src/**"] }.'
166
+ : 'Set them in the front matter as one-line JSON objects, such as include: {"paths": ["src/**"]}.'
167
+ }`;
168
+
158
169
  const typescriptFormat = (shape: Shape) =>
159
170
  `Write it as a RULE.ts, because ${shape.formatReason} It imports defineRule from "@drkmttr/adhere" and default-exports defineRule({ description, details, must, never, reads, appendState }). appendState(state, file, Bun) runs before each request for the rule: file is { path, contents }, its path absolute, and Bun is Bun's API, as in Bun.file(path).text() or new Bun.Glob(pattern). What it returns is spread over the request's state; name each key it adds in the description, in backticks. Helpers go in the same file.`;
160
171
 
@@ -183,6 +194,8 @@ Rule id: ${id}
183
194
  What it enforces: ${idea}
184
195
  What Jev reads beside the rule: ${contextOf(shape)}
185
196
  ${readsInstruction(shape)}
197
+ ${filtersInstruction(shape)}
198
+ Leave them out unless the idea itself names a kind of file, such as API routes or React components; the trial will settle whether they are needed.
186
199
  ${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
187
200
 
188
201
  ${reviewedOf(reviewed)}
@@ -255,6 +268,21 @@ export const inspectionPrompt = (report: string, id: string) =>
255
268
  - Concrete changes to the rule's wording or examples, if any.
256
269
  Do not edit files. Keep it short.`;
257
270
 
271
+ /**
272
+ * The first trial of an adhere rule asks the authoring agent, explicitly,
273
+ * whether the rule should narrow which files it judges. A filter is a silent
274
+ * false negative, so the agent names exact entries or says why none fit.
275
+ */
276
+ export const filterAdvicePrompt = (report: string, id: string) =>
277
+ `adhere trialled the adhere rule ${id} on real files without installing it. The full report is at ${report}, and it holds the rule's source. Read the report, the rule, and the files it names, then answer in plain text:
278
+ - Whether the rule should carry \`include\` or \`exclude\` filters at all.
279
+ - If it should, the exact entries, as the rule's file writes them: in a RULE.md's front matter, one-line JSON objects such as include: {"paths": ["src/**"]}; in a RULE.ts, object literals such as include: { paths: ["src/**"] }. Say which list each entry belongs to and what it leaves out or keeps.
280
+ - If it should not, one sentence saying why the rule is better judging every file Jev reads.
281
+
282
+ Filters are mechanical and silent: a file one leaves out is never judged, so a wrong filter hides real violations. \`paths\` are repository-root-relative globs with forward slashes, matched as \`--filter\` matches them. \`content\` entries are JavaScript regex source strings without / delimiters or flags, tested against original text including comments. Content filters may match a named package's import specifier, for example from "effect". They cannot resolve aliases or re-exports, and matching text in comments or strings does not prove the file imports the package. Escape regex backslashes in JSON and TypeScript strings: the regex ^\\s*import is written "^\\\\s*import". Within each list, any entry may match; all nonempty inclusion lists must match, and a match in either exclusion list vetoes the file. Empty lists impose no restriction. Recommend a filter only when the evidence supports that boundary.
283
+
284
+ Do not suggest filters for an oxlint rule: those have no such fields. Do not edit files. Keep it short.`;
285
+
258
286
  /** Asks for the adhere rule again, with what the trial found and what the user made of it. */
259
287
  export const adhereRevisionPrompt = (
260
288
  idea: string,
@@ -269,6 +297,8 @@ export const adhereRevisionPrompt = (
269
297
  Revise the adhere rule ${id}. It enforces: ${idea}
270
298
  What Jev reads beside the rule: ${contextOf(shape)}
271
299
  ${readsInstruction(shape)}
300
+ ${filtersInstruction(shape)}
301
+ Apply, change, or remove \`include\` and \`exclude\` as the trial, the recommendation, and the user's verdicts support; keep a filter only while the evidence supports it.
272
302
  ${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
273
303
 
274
304
  A trial judged real files against the current rule. What it found, and what the user made of it:
@@ -279,7 +309,7 @@ The current file:
279
309
 
280
310
  ${draft}
281
311
 
282
- Keep what the evidence supports and change what it contradicts. Reply with only the corrected file, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
312
+ Keep what the evidence supports and change what it contradicts, including its filter recommendation. Reply with only the corrected file, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
283
313
 
284
314
  /** Asks for the oxlint plugin again, with what the trial found and what the user made of it. */
285
315
  export const pluginRevisionPrompt = (
@@ -4,7 +4,6 @@
4
4
  * rail, and a spinner, as `gum spin` draws one, while an agent works.
5
5
  */
6
6
  import { highlightedLines } from "#workflows/format.ts";
7
- import { ruleBodyOf } from "#markdown.ts";
8
7
  import { Status, clearingStatus } from "#services/Status.ts";
9
8
  import { Console, Duration, Effect, Fiber } from "effect";
10
9
  import { Prompt } from "effect/cli";
@@ -61,23 +60,16 @@ const columns = () => process.stderr.columns ?? 80;
61
60
  export const indented = (text: string) =>
62
61
  Bun.wrapAnsi(text, Math.max(columns() - 5, 20)).replaceAll("\n", "\n ");
63
62
 
64
- /**
65
- * Renders the body of a rule for preview, preserving its YAML frontmatter.
66
- * The frontmatter is shown as-is so its metadata stays visible; the body is
67
- * rendered with Bun's native Markdown-to-ANSI renderer.
68
- */
63
+ /** Renders Markdown for preview with Bun's native Markdown-to-ANSI renderer. */
69
64
  export const renderMarkdown = (text: string) =>
70
65
  Effect.gen(function* () {
71
66
  const theme = yield* Prompt.Theme;
72
- const body = ruleBodyOf(text);
73
- const prefix = text.slice(0, text.length - body.length);
74
- const rendered = Bun.markdown
75
- .ansi(body, {
67
+ return Bun.markdown
68
+ .ansi(text, {
76
69
  colors: theme.colors,
77
70
  columns: Math.max(columns() - 4, 20),
78
71
  })
79
72
  .trimEnd();
80
- return prefix + rendered;
81
73
  });
82
74
 
83
75
  type Color = (theme: Prompt.Theme) => string;
@@ -8,8 +8,8 @@
8
8
  * explicitly untested; it never counts as validation.
9
9
  */
10
10
  import { type Agent } from "#create/agents.ts";
11
- import { RuleNotCreated, asking, inspectionPrompt } from "#create/ask.ts";
12
- import { line, note, said, spinning, warn } from "#create/look.ts";
11
+ import { RuleNotCreated, asking, filterAdvicePrompt, inspectionPrompt } from "#create/ask.ts";
12
+ import { line, note, renderMarkdown, said, spinning, warn } from "#create/look.ts";
13
13
  import {
14
14
  type TrialDraft,
15
15
  type TrialFinding,
@@ -128,7 +128,11 @@ const showPreview = (scope: TrialScope, preview: TrialPreview) =>
128
128
  if (preview.deferred > 0) {
129
129
  yield* note(`${filesWord(preview.deferred)} wait for a later run.`);
130
130
  }
131
- if (preview.skipped > 0) yield* note(`${filesWord(preview.skipped)} skipped as too long.`);
131
+ if (preview.skipped > 0) {
132
+ yield* note(
133
+ `${filesWord(preview.skipped)} skipped (too long, or left out by the rule's filters).`,
134
+ );
135
+ }
132
136
  if (preview.requests === 0) {
133
137
  yield* note("Nothing to send: the selected files have no pending requests.");
134
138
  }
@@ -265,7 +269,7 @@ const showRun = (run: TrialRun) =>
265
269
  );
266
270
  yield* note(`${run.findings.length} finding${run.findings.length === 1 ? "" : "s"}.`);
267
271
  if (run.judged === 0 && run.files > 0) {
268
- yield* note("Every selected file was cached; no request was sent.");
272
+ yield* note("No request was sent: every selected file was cached or skipped.");
269
273
  }
270
274
  yield* note(`Report: ${run.reportPath}`);
271
275
  for (const limitation of run.notes) yield* note(limitation);
@@ -286,6 +290,36 @@ const inspect = <D, R>(subject: TrialSubject<D, R>, run: TrialRun) =>
286
290
  Effect.catch((cause) => Effect.succeed({ _tag: "Failed", message: messageOf(cause) } as const)),
287
291
  );
288
292
 
293
+ type Advice =
294
+ | { readonly _tag: "Text"; readonly text: string }
295
+ | { readonly _tag: "Failed"; readonly message: string }
296
+ | { readonly _tag: "None" };
297
+
298
+ /**
299
+ * The first adhere trial asks the authoring agent whether the rule should
300
+ * filter files. An oxlint rule has no filter fields, and a rerun already has
301
+ * the answer.
302
+ */
303
+ const filterAdvice = <D, R>(subject: TrialSubject<D, R>, run: TrialRun, first: boolean) =>
304
+ first && run.draft.kind === "adhere"
305
+ ? spinning(
306
+ `Asking ${subject.agent} whether the rule needs filters`,
307
+ asking(
308
+ subject.agent,
309
+ "to recommend filters",
310
+ subject.root,
311
+ filterAdvicePrompt(run.reportPath, subject.id),
312
+ ),
313
+ ).pipe(
314
+ Effect.map((text): Advice =>
315
+ text.trim() === "" ? { _tag: "None" } : { _tag: "Text", text: text.trim() },
316
+ ),
317
+ Effect.catch((cause): Effect.Effect<Advice> =>
318
+ Effect.succeed({ _tag: "Failed", message: messageOf(cause) }),
319
+ ),
320
+ )
321
+ : Effect.succeed<Advice>({ _tag: "None" });
322
+
289
323
  const MAX_SHOWN = 40;
290
324
 
291
325
  const showFindings = (run: TrialRun) =>
@@ -421,14 +455,27 @@ const reviewFindings = (run: TrialRun) =>
421
455
  : ({ _tag: "Cancel" } as const);
422
456
  });
423
457
 
424
- /** What the agent reads when revising: the report, the counts, and the user's verdicts. */
425
- export const trialEvidence = (run: TrialRun, reviews: ReadonlyArray<FindingReview>) => {
458
+ /**
459
+ * What the agent reads when revising: the report, the counts, the filter
460
+ * recommendation the first trial asked for, and the user's verdicts.
461
+ */
462
+ export const trialEvidence = (
463
+ run: TrialRun,
464
+ reviews: ReadonlyArray<FindingReview>,
465
+ advice?: string,
466
+ ) => {
426
467
  const lines = [
427
468
  `Trial report: ${run.reportPath}`,
428
469
  `Scope: ${run.scope.kind === "all" ? "the entire repo" : `the first ${run.scope.limit} files`}; ${filesWord(run.selectedFiles.length)} selected.`,
429
470
  `Counts: ${run.files} files, ${run.judged} judged, ${run.cached} cached, ${run.skipped} skipped, ${run.waiting} waiting.`,
430
471
  `Findings: ${run.findings.length}.`,
431
472
  ];
473
+ if (advice !== undefined) {
474
+ lines.push(
475
+ "The agent's filter recommendation from the first trial. It is historical: the latest trial report and the user's verdicts decide, and a filter the evidence contradicts should be removed.",
476
+ );
477
+ lines.push(advice);
478
+ }
432
479
  if (reviews.length > 0) {
433
480
  lines.push("The user's verdicts:");
434
481
  for (const review of reviews) {
@@ -497,6 +544,8 @@ export const trialAndReview = <D, R>(subject: TrialSubject<D, R>) =>
497
544
  yield* showPreview(scope, preview.preview);
498
545
 
499
546
  let pinned: ReadonlyArray<string> | undefined;
547
+ let firstRun = true;
548
+ let advice: string | undefined;
500
549
  for (;;) {
501
550
  const result = yield* runOnce(subject, draft, scope, pinned);
502
551
  if (result._tag === "Failed") {
@@ -510,10 +559,24 @@ export const trialAndReview = <D, R>(subject: TrialSubject<D, R>) =>
510
559
  yield* showRun(run);
511
560
  const inspection = yield* inspect(subject, run);
512
561
  if (inspection._tag === "Text") {
513
- if (inspection.text.trim() !== "") yield* line(inspection.text.trim());
562
+ if (inspection.text.trim() !== "") {
563
+ yield* line(yield* renderMarkdown(inspection.text.trim()));
564
+ }
514
565
  } else {
515
566
  yield* warn(`The agent could not read the report: ${inspection.message}`);
516
567
  }
568
+ const filters = yield* filterAdvice(subject, run, firstRun);
569
+ firstRun = false;
570
+ if (filters._tag === "Text") {
571
+ advice = filters.text;
572
+ yield* note("The agent's filter recommendation:");
573
+ yield* line(filters.text);
574
+ yield* note(
575
+ "Accept the draft as it is, or ask the agent to revise it with this recommendation.",
576
+ );
577
+ } else if (filters._tag === "Failed") {
578
+ yield* warn(`The agent could not recommend filters: ${filters.message}`);
579
+ }
517
580
  const decision = yield* reviewFindings(run);
518
581
  if (decision._tag === "Accept") {
519
582
  return { _tag: "Accepted", draft, run, reviews: decision.reviews } as const;
@@ -521,7 +584,7 @@ export const trialAndReview = <D, R>(subject: TrialSubject<D, R>) =>
521
584
  if (decision._tag === "Cancel") {
522
585
  return { _tag: "Cancelled", draft, reason: "The trial was cancelled." } as const;
523
586
  }
524
- const revised = yield* subject.revise(draft, trialEvidence(run, decision.reviews));
587
+ const revised = yield* subject.revise(draft, trialEvidence(run, decision.reviews, advice));
525
588
  yield* subject.show(revised);
526
589
  const again = yield* approved(subject, revised);
527
590
  if (again._tag === "Declined") {
@@ -29,7 +29,9 @@ import {
29
29
  type TrialRun,
30
30
  type TrialScope,
31
31
  } from "#create/oxlint-trial.ts";
32
+ import { filtersNeedText, hasFilters, ruleAppliesTo } from "#filters.ts";
32
33
  import { parseRuleMarkdown } from "#markdown.ts";
34
+ import { WalkUnavailable } from "#models/Audit.ts";
33
35
  import { importRule, judgesFile } from "#rules.ts";
34
36
  import {
35
37
  AdhereConfig,
@@ -176,27 +178,70 @@ const eligibleFiles = (root: string, config: ResolvedConfig) =>
176
178
  });
177
179
 
178
180
  interface Selection {
181
+ /** The files planning may judge: the selected sample, less those filters leave out. */
179
182
  readonly files: ReadonlyArray<SourceFile>;
180
183
  /** Sorted repo-relative paths, exactly as run and as the report names them. */
181
184
  readonly paths: ReadonlyArray<string>;
182
185
  /** Applicable files before the cap. */
183
186
  readonly eligible: number;
187
+ /** Pinned paths the draft's filters now leave out: kept in the sample, never planned. */
188
+ readonly excluded: ReadonlyArray<string>;
184
189
  }
185
190
 
186
- /** The cap is on files, cached ones included; a pinned rerun keeps its exact set. */
191
+ /**
192
+ * Which candidate files the draft's own include/exclude filters allow. Only a
193
+ * content filter reads the file; a path-only filter is decided from its path,
194
+ * and an unfiltered rule allows every file.
195
+ */
196
+ const allowedFiles = (
197
+ rule: Rule,
198
+ walker: SourceWalker["Service"],
199
+ candidates: ReadonlyArray<SourceFile>,
200
+ root: string,
201
+ ): Effect.Effect<ReadonlyMap<SourceFile, boolean>, WalkUnavailable> => {
202
+ if (!hasFilters(rule)) return Effect.succeed(new Map());
203
+ if (!filtersNeedText(rule)) {
204
+ return Effect.succeed(
205
+ new Map(
206
+ candidates.map(
207
+ (file) => [file, ruleAppliesTo(rule, display(root, file.path), "")] as const,
208
+ ),
209
+ ),
210
+ );
211
+ }
212
+ return Effect.forEach(
213
+ candidates,
214
+ (file) =>
215
+ Effect.map(walker.read(file), ({ lines }) => {
216
+ const allowed = ruleAppliesTo(rule, display(root, file.path), lines.join("\n"));
217
+ return [file, allowed] as const;
218
+ }),
219
+ { concurrency: 8 },
220
+ ).pipe(Effect.map((verdicts) => new Map(verdicts)));
221
+ };
222
+
223
+ /**
224
+ * The cap is on files, cached ones included, and filters apply before it on a
225
+ * first run. A pinned rerun keeps its exact set: files the draft's filters now
226
+ * leave out stay in the sample but are not planned, so before and after remain
227
+ * comparable. A file that is missing, or ineligible for any other reason, is
228
+ * still an error rather than a skip.
229
+ */
187
230
  const selectionOf = (
188
231
  root: string,
189
232
  eligible: ReadonlyArray<SourceFile>,
190
233
  applies: (file: SourceFile) => boolean,
191
234
  scope: TrialScope,
192
235
  pinned: ReadonlyArray<string> | undefined,
236
+ allowed: ReadonlyMap<SourceFile, boolean> = new Map(),
193
237
  ): Effect.Effect<Selection, TrialUnavailable> =>
194
238
  Effect.gen(function* () {
195
- const sorted = [...eligible].filter(applies).sort((a, b) => a.path.localeCompare(b.path));
196
- const paths = sorted.map((file) => display(root, file.path));
239
+ const candidates = [...eligible].filter(applies).sort((a, b) => a.path.localeCompare(b.path));
240
+ const allowedByPath = (file: SourceFile) => allowed.get(file) !== false;
241
+ const sorted = candidates.filter(allowedByPath);
197
242
  if (pinned !== undefined) {
198
243
  const wanted = [...new Set(pinned.map(normalized))];
199
- const known = new Map(sorted.map((file, index) => [paths[index]!, file]));
244
+ const known = new Map(candidates.map((file) => [display(root, file.path), file] as const));
200
245
  const missing = wanted.filter((file) => !known.has(file));
201
246
  if (missing.length > 0) {
202
247
  return yield* trialError(
@@ -204,10 +249,13 @@ const selectionOf = (
204
249
  `The pinned trial files are no longer eligible: ${missing.join(", ")}. Run the trial again without pinned files.`,
205
250
  );
206
251
  }
252
+ const files = wanted.map((file) => known.get(file)!);
253
+ const excluded = files.filter((file) => !allowedByPath(file));
207
254
  return {
208
- files: wanted.map((file) => known.get(file)!),
255
+ files: files.filter(allowedByPath),
209
256
  paths: wanted,
210
257
  eligible: sorted.length,
258
+ excluded: excluded.map((file) => display(root, file.path)),
211
259
  };
212
260
  }
213
261
  const limit = scope.kind === "files" ? scope.limit : undefined;
@@ -222,6 +270,7 @@ const selectionOf = (
222
270
  files: chosen,
223
271
  paths: chosen.map((file) => display(root, file.path)),
224
272
  eligible: sorted.length,
273
+ excluded: [],
225
274
  };
226
275
  });
227
276
 
@@ -245,7 +294,12 @@ const planTrial = (
245
294
 
246
295
  const requestsOf = (plan: AuditPlan): number => plan.requests + (plan.cachedLocateRequests ?? 0);
247
296
 
248
- const previewOf = (plan: AuditPlan, eligible: number, selected: number): TrialPreview => ({
297
+ const previewOf = (
298
+ plan: AuditPlan,
299
+ eligible: number,
300
+ selected: number,
301
+ filteredOut = 0,
302
+ ): TrialPreview => ({
249
303
  eligible,
250
304
  selected,
251
305
  requests: requestsOf(plan),
@@ -253,7 +307,7 @@ const previewOf = (plan: AuditPlan, eligible: number, selected: number): TrialPr
253
307
  ...(plan.price === undefined ? {} : { price: plan.price }),
254
308
  cached: plan.cached,
255
309
  deferred: plan.deferred,
256
- skipped: plan.skipped,
310
+ skipped: plan.skipped + filteredOut,
257
311
  model: plan.model,
258
312
  ...(plan.provider === undefined ? {} : { provider: plan.provider }),
259
313
  });
@@ -346,19 +400,15 @@ export const previewTrial = (draft: TrialDraft, scope: TrialScope, options: Tria
346
400
  const rule = yield* draftRule(draft);
347
401
  const config = yield* draftConfig(root, raw, draft.id, rule);
348
402
  const { walker, files } = yield* eligibleFiles(root, config);
349
- const selected = yield* selectionOf(
350
- root,
351
- files,
352
- (file) => judgesFile(rule, file.test === true),
353
- scope,
354
- options.files,
355
- );
403
+ const applies = (file: SourceFile) => judgesFile(rule, file.test === true);
404
+ const allowed = yield* allowedFiles(rule, walker, files.filter(applies), root);
405
+ const selected = yield* selectionOf(root, files, applies, scope, options.files, allowed);
356
406
  const cache = options.services?.cache ?? AuditCacheAt(root);
357
407
  const context = yield* Layer.build(
358
408
  Layer.mergeAll(Layer.succeed(AdhereConfig, config), cache),
359
409
  );
360
410
  const plan = yield* planTrial(selected, walker, context, root);
361
- return previewOf(plan, selected.eligible, selected.paths.length);
411
+ return previewOf(plan, selected.eligible, selected.paths.length, selected.excluded.length);
362
412
  }),
363
413
  ).pipe(
364
414
  Effect.catchTag("ConfigUnavailable", (problem) =>
@@ -454,13 +504,9 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
454
504
  const rule = yield* draftRule(draft);
455
505
  const config = yield* draftConfig(root, raw, draft.id, rule);
456
506
  const { walker, files } = yield* eligibleFiles(root, config);
457
- const selected = yield* selectionOf(
458
- root,
459
- files,
460
- (file) => judgesFile(rule, file.test === true),
461
- scope,
462
- options.files,
463
- );
507
+ const applies = (file: SourceFile) => judgesFile(rule, file.test === true);
508
+ const allowed = yield* allowedFiles(rule, walker, files.filter(applies), root);
509
+ const selected = yield* selectionOf(root, files, applies, scope, options.files, allowed);
464
510
  const cache = options.services?.cache ?? AuditCacheAt(root);
465
511
  const jev =
466
512
  options.services?.jev ??
@@ -523,6 +569,11 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
523
569
  `${result.skipped} selected files were too long to judge with this rule and its context.`,
524
570
  ]
525
571
  : []),
572
+ ...(selected.excluded.length > 0
573
+ ? [
574
+ `${selected.excluded.length} pinned files are no longer in the draft's include/exclude filters and were skipped: ${selected.excluded.join(", ")}.`,
575
+ ]
576
+ : []),
526
577
  ...(config.typeAware
527
578
  ? [
528
579
  "Type-aware reads were requested; symbols and references came from the installed TypeScript compiler.",
@@ -540,6 +591,7 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
540
591
  scope,
541
592
  eligible: selected.eligible,
542
593
  selectedFiles: selected.paths,
594
+ ...(selected.excluded.length === 0 ? {} : { excludedFiles: selected.excluded }),
543
595
  plan: {
544
596
  model: plan.model,
545
597
  ...(plan.provider === undefined ? {} : { provider: plan.provider }),
@@ -570,10 +622,10 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
570
622
  scope,
571
623
  revision,
572
624
  selectedFiles: selected.paths,
573
- files: result.files,
625
+ files: result.files + selected.excluded.length,
574
626
  judged: result.judged,
575
627
  cached: result.cached,
576
- skipped: result.skipped,
628
+ skipped: result.skipped + selected.excluded.length,
577
629
  waiting: result.waiting,
578
630
  findings: result.findings.map((finding) => trialFinding(root, finding)),
579
631
  reportPath,
@@ -37,7 +37,7 @@ import {
37
37
  warn,
38
38
  } from "#create/look.ts";
39
39
  import { cloned } from "#install.ts";
40
- import { parseRuleMarkdown } from "#markdown.ts";
40
+ import { parseRuleMarkdown, ruleBodyOf } from "#markdown.ts";
41
41
  import { isSharedRepo, refreshIndexIfShared, refreshReadme, validateReadme } from "#readme.ts";
42
42
  import { importRule } from "#rules.ts";
43
43
  import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
@@ -141,8 +141,10 @@ const problemsOf = (text: string, file: string, format: Shape["format"]) =>
141
141
  */
142
142
  const showDraft = (text: string, format: Shape["format"]) =>
143
143
  Effect.gen(function* () {
144
- const shown = format === "markdown" ? yield* renderMarkdown(text) : text;
145
- yield* line(`\n${shown}\n`);
144
+ if (format !== "markdown") return yield* line(`\n${text}\n`);
145
+ const body = ruleBodyOf(text);
146
+ const frontmatter = text.slice(0, text.length - body.length);
147
+ yield* line(`\n${frontmatter}${yield* renderMarkdown(body)}\n`);
146
148
  });
147
149
 
148
150
  /** A RULE.ts that imported, with its wording strays, or one that did not import. */
package/src/filters.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { Rule, RuleFilter } from "#config.ts";
2
+ import { matchesGlob } from "node:path";
3
+
4
+ /** A repository path as every matcher here reads it: `/`-separated, without a leading `./`. */
5
+ export const normalizedPath = (path: string): string =>
6
+ path.replaceAll("\\", "/").replace(/^\.\//, "");
7
+
8
+ interface CompiledSide {
9
+ readonly paths: ReadonlyArray<string>;
10
+ readonly content: ReadonlyArray<RegExp>;
11
+ }
12
+
13
+ interface CompiledFilters {
14
+ readonly include: CompiledSide;
15
+ readonly exclude: CompiledSide;
16
+ }
17
+
18
+ const compiled = new WeakMap<Pick<Rule, "include" | "exclude">, CompiledFilters>();
19
+
20
+ const compileSide = (filter: RuleFilter | undefined): CompiledSide => ({
21
+ paths: (filter?.paths ?? []).map(normalizedPath),
22
+ // Validated when the rule was decoded, so this cannot throw here.
23
+ content: (filter?.content ?? []).map((source) => new RegExp(source)),
24
+ });
25
+
26
+ /**
27
+ * A rule's filters, compiled once per rule: its globs with `/` separators, and
28
+ * its content patterns as regular expressions. Both sides are kept, since
29
+ * `include` and `exclude` differ only in what a match means.
30
+ */
31
+ export const compiledFiltersOf = (rule: Pick<Rule, "include" | "exclude">): CompiledFilters => {
32
+ const held = compiled.get(rule);
33
+ if (held !== undefined) return held;
34
+ const filters = { include: compileSide(rule.include), exclude: compileSide(rule.exclude) };
35
+ compiled.set(rule, filters);
36
+ return filters;
37
+ };
38
+
39
+ /** Whether a rule has either filter, so a run can leave unfiltered rules untouched. */
40
+ export const hasFilters = (rule: Pick<Rule, "include" | "exclude">): boolean =>
41
+ rule.include !== undefined || rule.exclude !== undefined;
42
+
43
+ /** Whether a rule's content patterns, if any, need the file's text to decide. */
44
+ export const filtersNeedText = (rule: Pick<Rule, "include" | "exclude">): boolean => {
45
+ const { include, exclude } = compiledFiltersOf(rule);
46
+ return include.content.length > 0 || exclude.content.length > 0;
47
+ };
48
+
49
+ const matchesAnyPath = (path: string, patterns: ReadonlyArray<string>): boolean =>
50
+ patterns.some((pattern) => matchesGlob(path, pattern));
51
+
52
+ /**
53
+ * Whether a repository-relative path passes a rule's path filters alone: an
54
+ * `include.paths` list must match when it has entries, and any
55
+ * `exclude.paths` match drops the file even when it matched.
56
+ */
57
+ export const pathsPass = (rule: Rule, relativePath: string): boolean => {
58
+ const { include, exclude } = compiledFiltersOf(rule);
59
+ const path = normalizedPath(relativePath);
60
+ return (
61
+ (include.paths.length === 0 || matchesAnyPath(path, include.paths)) &&
62
+ !(exclude.paths.length > 0 && matchesAnyPath(path, exclude.paths))
63
+ );
64
+ };
65
+
66
+ /**
67
+ * Whether a rule's content filters allow `text`, the file as written: any
68
+ * `include` entry matches, and no `exclude` entry does. A path fix for one
69
+ * file cannot change between planning and execution, so reloading a file
70
+ * rechecks only this half.
71
+ */
72
+ export const contentPasses = (rule: Rule, text: string): boolean => {
73
+ const { include, exclude } = compiledFiltersOf(rule);
74
+ return (
75
+ (include.content.length === 0 || include.content.some((pattern) => pattern.test(text))) &&
76
+ !(exclude.content.length > 0 && exclude.content.some((pattern) => pattern.test(text)))
77
+ );
78
+ };
79
+
80
+ /**
81
+ * Whether a rule applies to a file at a repository-relative path whose text
82
+ * is `text`: `pathsPass` and `contentPasses`. An `include` category that is
83
+ * set must match, while a match in any `exclude` category drops the file.
84
+ */
85
+ export const ruleAppliesTo = (rule: Rule, relativePath: string, text: string): boolean =>
86
+ pathsPass(rule, relativePath) && contentPasses(rule, text);
package/src/markdown.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
1
+ import { ConfigUnavailable, type Example, Reads, type Rule, RuleFilter } from "#config.ts";
2
2
  import { Effect, Schema } from "effect";
3
3
 
4
4
  /**
@@ -34,16 +34,25 @@ import { Effect, Schema } from "effect";
34
34
  * `warning` reports the rule's findings as warnings, which do not fail a run.
35
35
  * `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
36
36
  * one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
37
+ * `include` and `exclude`, its file filters, are JSON objects on one line, as
38
+ * in `include: {"paths": ["src/**"], "content": ["^import"]}`: a file
39
+ * must match every `include` category, any match in a category is enough, and
40
+ * any `exclude` match drops it. `content` takes regular expression sources
41
+ * matched against the file as written, comments included.
37
42
  * `reads` is a JSON array of the same names, as in `reads: ["dependents"]`:
38
43
  * it declares what the rule needs, while the config still decides what runs.
39
44
  */
40
45
  const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
46
+ /** A filter object on one line, as in `include: {"paths": ["src/**"]}`. */
47
+ const Filters = Schema.fromJsonString(RuleFilter);
41
48
 
42
49
  const FrontMatter = Schema.Struct({
43
50
  description: Schema.String,
44
51
  threshold: Schema.optionalKey(Schema.FiniteFromString),
45
52
  tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
46
53
  level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
54
+ include: Schema.optionalKey(Filters),
55
+ exclude: Schema.optionalKey(Filters),
47
56
  appliesTo: Schema.optionalKey(Matchers),
48
57
  excludeIf: Schema.optionalKey(Matchers),
49
58
  reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
@@ -356,15 +356,15 @@ const ruleEntry = (rule: ReadmeRule, anchor: string, used: Set<string>): Readonl
356
356
  `<a id="${anchor}"></a>`,
357
357
  "",
358
358
  "<details>",
359
- rule.name === "RULE.ts"
360
- ? `<summary><strong>TypeScript rule</strong> ${label}</summary>`
361
- : `<summary><strong>${statement}</strong> ${label}</summary>`,
359
+ `<summary><h3>${label}</h3></summary>`,
362
360
  "",
363
361
  ...(rule.name === "RULE.ts"
364
362
  ? [
365
363
  `This rule is TypeScript, so the guide does not read or run it: [\`${rule.id}\`](${source}).`,
366
364
  ]
367
365
  : [
366
+ statement,
367
+ "",
368
368
  ...(body === "" ? [] : [embedBody(source, anchor, body, used), ""]),
369
369
  `Source: [\`${rule.id}\`](${source}).`,
370
370
  ]),
@@ -374,32 +374,25 @@ const ruleEntry = (rule: ReadmeRule, anchor: string, used: Set<string>): Readonl
374
374
  };
375
375
 
376
376
  /**
377
- * The managed block's body: a contents list naming every rule, each topic's
378
- * rules as expandable entries, then the lint settings and plugins. Rules sort
379
- * by id and topics by name, so the same checkout always writes the same text.
377
+ * The managed block's body: section links, rules as expandable entries, then
378
+ * the lint settings and plugins. Rules sort by id and topics by name, so the
379
+ * same checkout always writes the same text.
380
380
  */
381
381
  export const renderReadmeIndex = (rules: ReadonlyArray<ReadmeRule>, config: LintConfig): string => {
382
382
  const entries = lintEntries(config);
383
383
  const { topics, used } = topicsOf(rules);
384
384
  const contents = [
385
385
  `- [Rules](#${RULES_ANCHOR})`,
386
- ...topics.flatMap(({ name, anchor, rules: group }) => [
387
- ` - [\`${name}\`](#${anchor})`,
388
- ...group.map(({ rule, anchor: at }) => ` - [\`${rule.id}\`](#${at})`),
389
- ]),
390
386
  `- [Lint settings](#${LINT_SETTINGS_ANCHOR})`,
391
387
  `- [Custom lint plugins](#${PLUGINS_ANCHOR})`,
392
388
  ];
393
389
  const body =
394
390
  topics.length === 0
395
391
  ? ["- None.", ""]
396
- : topics.flatMap(({ name, anchor, rules: group }) => [
392
+ : topics.flatMap(({ anchor, rules: group }) => [
397
393
  `<a id="${anchor}"></a>`,
398
394
  "",
399
- `#### ${name}`,
400
- "",
401
395
  ...group.flatMap(({ rule, anchor: at }) => [...ruleEntry(rule, at, used), ""]),
402
- backToContents,
403
396
  "",
404
397
  ]);
405
398
  return [
@@ -411,7 +404,7 @@ export const renderReadmeIndex = (rules: ReadonlyArray<ReadmeRule>, config: Lint
411
404
  "",
412
405
  `<a id="${RULES_ANCHOR}"></a>`,
413
406
  "",
414
- "### Rules",
407
+ "## Rules",
415
408
  "",
416
409
  ...body,
417
410
  `<a id="${LINT_SETTINGS_ANCHOR}"></a>`,
@@ -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
 
@@ -455,15 +474,17 @@ export const planAudit = (
455
474
  );
456
475
 
457
476
  // The rules that judge a file: those scoped to it, of which only the rules that say `tests`
458
- // judge a test, and none that an adhere-ignore-file comment names.
459
- 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) =>
460
480
  Record.filter(
461
481
  config.scopedRules === undefined
462
482
  ? config.rules
463
483
  : applicableRules(file.path, config.scopedRules),
464
484
  (rule, id) =>
465
485
  judgesFile(rule, file.test === true) &&
466
- !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())),
467
488
  );
468
489
 
469
490
  // Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
@@ -501,7 +522,16 @@ export const planAudit = (
501
522
  const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
502
523
  const original = yield* walker.read(file);
503
524
  const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
504
- 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
+ );
505
535
  const code = lines.join("\n");
506
536
  const readState: ReadState = {
507
537
  ...(workspacePackages === undefined