@drkmttr/adhere 0.18.0 → 0.21.2

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/src/rules.ts CHANGED
@@ -60,11 +60,9 @@ interface RuleFile {
60
60
 
61
61
  /**
62
62
  * The rules among `leaves`, the paths in `directory`, whose rules sit under
63
- * `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. Anything else
64
- * in a rule's directory, such as a helper its RULE.ts imports, is not a rule.
65
- * A directory with both files refuses, and so does a Markdown file outside
66
- * every rule's directory, but a README.md: before rule directories, it was a
67
- * rule, and the refusal says where it goes now to keep its id.
63
+ * `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. Any other
64
+ * file, such as a helper a RULE.ts imports, is not a rule. A directory with
65
+ * both files refuses.
68
66
  */
69
67
  const ruleFilesIn = (
70
68
  directory: string,
@@ -85,29 +83,6 @@ const ruleFilesIn = (
85
83
  }),
86
84
  );
87
85
  }
88
- const homes = files.map(({ leaf }) => leaf.slice(0, leaf.lastIndexOf("/") + 1));
89
- const flat = leaves.filter((leaf) => {
90
- const name = segmentsOf(leaf).at(-1) ?? "";
91
- return (
92
- leaf.endsWith(".md") &&
93
- !RULE_FILES.has(name) &&
94
- name !== "README.md" &&
95
- !homes.some((home) => leaf.startsWith(home))
96
- );
97
- });
98
- if (flat.length > 0) {
99
- return Effect.fail(
100
- ConfigUnavailable.make({
101
- message: [
102
- `Rules now live one to a directory, as ${directory}/${prefix}<id>/RULE.md or RULE.ts. Move each of these to keep its id:`,
103
- ...flat.map(
104
- (leaf) =>
105
- ` ${directory}/${leaf} → ${directory}/${prefix}${leaf.slice(0, -".md".length)}/RULE.md`,
106
- ),
107
- ].join("\n"),
108
- }),
109
- );
110
- }
111
86
  return Effect.succeed(files);
112
87
  };
113
88
 
@@ -161,7 +136,7 @@ const moreSpecific = (a: RuleEntry, b: RuleEntry): RuleEntry =>
161
136
  * config's inline rule is, so a hook such as `appendState` comes with it.
162
137
  * Like a config, the file is imported, so its code runs.
163
138
  */
164
- const importRule = Effect.fn("importRule")(function* (file: string) {
139
+ export const importRule = Effect.fn("importRule")(function* (file: string) {
165
140
  const path = yield* Path.Path;
166
141
  const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
167
142
  const loaded = yield* Effect.tryPromise({
@@ -255,25 +230,47 @@ export const globalRuleSet = (rules: Readonly<Record<RuleId, Rule>>, root: strin
255
230
  /**
256
231
  * The rules as a config's overrides leave them, each found by the id a report
257
232
  * names it with: a rule set `off` gone, and any other with the level and
258
- * threshold its override gives, over its own.
233
+ * threshold its override gives, over its own, and its `options` decoded by
234
+ * the rule's schema, `{}` when unset. Options a rule does not take, or that
235
+ * do not decode, refuse the run.
259
236
  */
260
- export const withOverrides = (
237
+ export const withOverrides = Effect.fn("withOverrides")(function* (
261
238
  entries: RuleSet,
262
239
  overrides: Readonly<Record<string, Override>>,
263
- ): RuleSet =>
264
- entries.flatMap((entry) => {
265
- const override = overrides[shownId(entry)];
266
- if (override === undefined) return [entry];
267
- const { level, threshold }: Exclude<Override, string> =
268
- typeof override === "string" ? { level: override } : override;
269
- if (level === "off") return [];
270
- const rule: Rule = {
271
- ...entry.rule,
272
- ...(level === undefined ? {} : { level }),
273
- ...(threshold === undefined ? {} : { threshold }),
274
- };
275
- return [{ ...entry, rule }];
276
- });
240
+ ) {
241
+ const kept = yield* Effect.forEach(entries, (entry) =>
242
+ Effect.gen(function* () {
243
+ const id = shownId(entry);
244
+ const override = overrides[id];
245
+ const { level, threshold, options }: Exclude<Override, string> =
246
+ typeof override === "string" ? { level: override } : (override ?? {});
247
+ if (level === "off") return [];
248
+ if (entry.rule.options === undefined && options !== undefined) {
249
+ return yield* ConfigUnavailable.make({
250
+ message: `overrides sets options for ${id}, which takes none.`,
251
+ });
252
+ }
253
+ const optionValues =
254
+ entry.rule.options === undefined
255
+ ? undefined
256
+ : yield* Schema.decodeUnknownEffect(entry.rule.options)(options ?? {}).pipe(
257
+ Effect.mapError((problem) =>
258
+ ConfigUnavailable.make({
259
+ message: `The options of ${id}, set in overrides, did not decode: ${problem.message}`,
260
+ }),
261
+ ),
262
+ );
263
+ const rule: Rule = {
264
+ ...entry.rule,
265
+ ...(level === undefined ? {} : { level }),
266
+ ...(threshold === undefined ? {} : { threshold }),
267
+ ...(optionValues === undefined ? {} : { optionValues }),
268
+ };
269
+ return [{ ...entry, rule }];
270
+ }),
271
+ );
272
+ return kept.flat();
273
+ });
277
274
 
278
275
  /**
279
276
  * Whether a rule judges a file: a test file only when the rule says `tests`,
@@ -21,14 +21,14 @@ import {
21
21
  shownId,
22
22
  withOverrides,
23
23
  } from "#rules.ts";
24
- import { workspacePackagesOf } from "#workspaces.ts";
24
+ import { workspaceDependentsOf, workspacePackagesOf } from "#workspaces.ts";
25
25
  import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
26
26
 
27
27
  export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
28
28
  "@drkmttr/adhere/services/AdhereConfig",
29
29
  ) {}
30
30
 
31
- const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
31
+ export const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
32
32
  const path = yield* Path.Path;
33
33
  const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
34
34
  const loaded = yield* Effect.tryPromise({
@@ -53,6 +53,23 @@ const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetN
53
53
  return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
54
54
  });
55
55
 
56
+ /** Locate the optional config without loading rules or presets. */
57
+ export const findConfigFile = Effect.fn("AdhereConfig.find")(function* (cwd: string) {
58
+ const path = yield* Path.Path;
59
+ const fs = yield* FileSystem.FileSystem;
60
+ const found = yield* Effect.filter(CONFIG_FILES, (file) =>
61
+ fs
62
+ .exists(path.join(cwd, file))
63
+ .pipe(Effect.mapError((problem) => ConfigUnavailable.make({ message: problem.message }))),
64
+ );
65
+ if (found.length > 1) {
66
+ return yield* ConfigUnavailable.make({
67
+ message: `More than one config file: ${found.join(", ")}. Keep one.`,
68
+ });
69
+ }
70
+ return found[0];
71
+ });
72
+
56
73
  /** Every rule of every built-in preset, by the id a report names it with. */
57
74
  const everyPresetRule = Effect.fn("AdhereConfig.everyPresetRule")(function* (base: string) {
58
75
  const loaded = yield* Effect.forEach(Object.entries(presets), ([name, preset]) =>
@@ -73,19 +90,8 @@ export const AdhereConfigLive = (flags: Flags) =>
73
90
  Layer.effect(AdhereConfig)(
74
91
  Effect.gen(function* () {
75
92
  const path = yield* Path.Path;
76
- const fs = yield* FileSystem.FileSystem;
77
93
  const cwd = path.resolve();
78
- const present = (target: string) =>
79
- fs
80
- .exists(path.join(cwd, target))
81
- .pipe(Effect.mapError((problem) => ConfigUnavailable.make({ message: problem.message })));
82
- const found = yield* Effect.filter(CONFIG_FILES, present);
83
- if (found.length > 1) {
84
- return yield* ConfigUnavailable.make({
85
- message: `More than one config file: ${found.join(", ")}. Keep one.`,
86
- });
87
- }
88
- const configFile = found[0];
94
+ const configFile = yield* findConfigFile(cwd);
89
95
  const hasPreset = flags.presets !== undefined && flags.presets.length > 0;
90
96
  const config: typeof AdhereConfigSchema.Type =
91
97
  configFile === undefined
@@ -130,15 +136,22 @@ export const AdhereConfigLive = (flags: Flags) =>
130
136
  });
131
137
  }
132
138
  }
133
- const scopedRules = withOverrides(entries, overrides);
139
+ const scopedRules = yield* withOverrides(entries, overrides);
134
140
  // Read on every run so workspace edits are seen, but only when the config enables this context.
135
- const workspacePackages = resolved.reads.includes("workspacePackages")
136
- ? yield* workspacePackagesOf(cwd)
137
- : undefined;
141
+ const dependents = resolved.reads.includes("dependents");
142
+ const workspacePackages =
143
+ dependents || resolved.reads.includes("workspacePackages")
144
+ ? yield* workspacePackagesOf(cwd)
145
+ : undefined;
146
+ const workspaceDependents =
147
+ dependents && workspacePackages !== undefined
148
+ ? yield* workspaceDependentsOf(cwd, workspacePackages)
149
+ : undefined;
138
150
  return AdhereConfig.of({
139
151
  ...resolved,
140
152
  scopedRules,
141
153
  ...(workspacePackages === undefined ? {} : { workspacePackages }),
154
+ ...(workspaceDependents === undefined ? {} : { workspaceDependents }),
142
155
  });
143
156
  }),
144
157
  );
@@ -66,6 +66,18 @@ export class Credentials extends Context.Service<
66
66
 
67
67
  const refused = (message: string) => CredentialsUnavailable.make({ message });
68
68
 
69
+ /** `$XDG_CONFIG_HOME/adhere`, or `~/.config/adhere` without it, on every platform: where adhere keeps a user's own files. */
70
+ export const adhereConfigDirectory = (path: Path.Path) =>
71
+ Config.NonEmptyString("XDG_CONFIG_HOME").pipe(
72
+ Config.orElse(() =>
73
+ Config.NonEmptyString("HOME").pipe(
74
+ Config.orElse(() => Config.NonEmptyString("USERPROFILE")),
75
+ Config.map((home) => path.join(home, ".config")),
76
+ ),
77
+ ),
78
+ Config.map((configHome) => path.join(configHome, "adhere")),
79
+ );
80
+
69
81
  /**
70
82
  * The keys live in `$XDG_CONFIG_HOME/adhere/credentials.json`, or under
71
83
  * `~/.config` without it, on every platform. The file is written whole to a
@@ -77,14 +89,8 @@ export const CredentialsLive = Layer.effect(Credentials)(
77
89
  const fs = yield* FileSystem.FileSystem;
78
90
  const path = yield* Path.Path;
79
91
 
80
- const file = Config.NonEmptyString("XDG_CONFIG_HOME").pipe(
81
- Config.orElse(() =>
82
- Config.NonEmptyString("HOME").pipe(
83
- Config.orElse(() => Config.NonEmptyString("USERPROFILE")),
84
- Config.map((home) => path.join(home, ".config")),
85
- ),
86
- ),
87
- Config.map((configHome) => path.join(configHome, "adhere", "credentials.json")),
92
+ const file = adhereConfigDirectory(path).pipe(
93
+ Config.map((directory) => path.join(directory, "credentials.json")),
88
94
  Effect.mapError(() =>
89
95
  refused("No home directory to keep the API key in: set HOME or XDG_CONFIG_HOME."),
90
96
  ),
@@ -76,6 +76,7 @@ const recordedJev = (
76
76
  Layer.provide([
77
77
  Layer.succeed(FetchHttpClient.Fetch, fetch),
78
78
  Layer.succeed(AdhereConfig, {
79
+ typeAware: false,
79
80
  model: "jev-latest",
80
81
  threshold: 0.8,
81
82
  sufficiencyThreshold: 0.6,
@@ -193,6 +194,28 @@ describe("prepared shared Jev reads", () => {
193
194
  expect(locateRequests(lines, rules, readState)).toBe(3);
194
195
  });
195
196
 
197
+ it("sends a rule's options in requests of its own, under `options`", () => {
198
+ const optioned = { ...plain, optionValues: { edgePaths: ["src/edge"] } };
199
+ const rules = { plain, optioned, alsoPlain: plain };
200
+ const readState: ReadState = { workspacePackages: { orders: "packages/orders" } };
201
+ const groups = requestGroups(rules);
202
+ expect(groups.map((group) => Object.keys(group))).toEqual([
203
+ ["plain", "alsoPlain"],
204
+ ["optioned"],
205
+ ]);
206
+ const [shared, own] = groups.map((group) => locateBody("", lines, group, readState).state);
207
+ expect(shared?.options).toBeUndefined();
208
+ expect(own?.options).toEqual({
209
+ edgePaths: ["src/edge"],
210
+ __comment: expect.stringMatching(/options\./),
211
+ });
212
+ expect(judgeBody("", lines, { optioned }, [], readState).state).toEqual(own);
213
+ expect(own?.workspacePackages).toEqual(shared?.workspacePackages);
214
+ const huge = { ...plain, optionValues: { edgePaths: ["p".repeat(120_000)] } };
215
+ expect(fits(lines, { plain, optioned }, readState)).toBe(true);
216
+ expect(fits(lines, { plain, huge }, readState)).toBe(false);
217
+ });
218
+
196
219
  it("preserves caller comments and raw context through judge, locate, and appended maps", async () => {
197
220
  const readState = {
198
221
  __comment: "User context: only target code is audited.",
@@ -493,9 +516,10 @@ describe("prepared shared Jev reads", () => {
493
516
  expect(readState.code).toEqual({});
494
517
  });
495
518
 
496
- it("does not prepare context in HTTP even with resolved default reads and omitted or partial shared state", async () => {
519
+ it("uses only caller-prepared context in HTTP with workspace-only defaults", async () => {
497
520
  const config = resolveConfig({ presets: [], rules: { plain } });
498
- expect(config.reads).toEqual(["workspacePackages", "symbols", "references"]);
521
+ expect(config.reads).toEqual(["workspacePackages"]);
522
+ expect(config.typeAware).toBe(false);
499
523
  const { sent, layer } = recordedJev({ unused: "packages/unused" }, config.reads);
500
524
  await Effect.runPromise(
501
525
  Effect.gen(function* () {
@@ -141,11 +141,15 @@ export const tokensOf = (value: unknown): number => Math.ceil(bytesOf(value) / 3
141
141
 
142
142
  const READ_COMMENTS: Readonly<Record<string, string>> = {
143
143
  symbols:
144
- "Compiler-inferred types, documentation, and JSDoc for symbols in `code`; keys are symbol names.",
144
+ "Compiler-rendered types and documentation for symbols used in `code`; keys are symbol names. A declared type, assertion, or type predicate is only a compile-time claim, not proof of runtime validation. When `references` show an implementation, inspect its actual checks. A predicate that returns true, tests only truthiness, or checks only whether a value is an object does not validate the fields it claims.",
145
145
  references:
146
146
  "Declarations of symbols used in `code`. Each key is `symbol:pathname`: the symbol name as used in `code` followed by its repository-relative declaration path. Each value is a complete declaration excerpt.",
147
147
  workspacePackages:
148
148
  "Workspace packages imported by `code`; keys are package names and values are repository-relative package directories. Only packages imported by this file are listed.",
149
+ dependents:
150
+ "What depends on the file `code` holds. `packages` maps workspace packages whose package.json depends on this file's package to their repository-relative directories. `files` lists repository-relative paths of files that import this file by a relative path; `omittedFiles`, when present, counts more that are not listed. Package-name imports of this file are not in `files`.",
151
+ options:
152
+ "Settings this project gives the rule the questions ask about, which the rule names as `options.<key>`.",
149
153
  };
150
154
 
151
155
  /** Explain the known context maps while preserving caller-provided descriptions and target code. */
@@ -347,11 +351,21 @@ export const matcherQuestions = (id: RuleId, rule: Rule) =>
347
351
  [matcherKey(kind, id, index), matcherQuestion(rule, kind, matcher)] as const,
348
352
  ),
349
353
  );
350
-
351
- /** The room for one question beside the file and its shared prepared context. Hooks are not predicted. */
354
+ /** The room for one question beside the file and the prepared context of its request. Hooks are not predicted. */
352
355
  export const questionRoom = (lines: Lines, readState: ReadState = {}): number =>
353
356
  QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines, readState));
354
357
 
358
+ /**
359
+ * The shared context, with the options of the rule in `rules` that has them
360
+ * under `options`: `requestGroups` gives such a rule a body of its own.
361
+ */
362
+ const withOptions = (rules: Rules, readState: ReadState): ReadState => {
363
+ const options = Object.values(rules).find(
364
+ (rule) => rule.optionValues !== undefined,
365
+ )?.optionValues;
366
+ return options === undefined ? readState : { ...readState, options };
367
+ };
368
+
355
369
  /**
356
370
  * One question per rule, one per matcher of each rule, and the linter
357
371
  * question beside each rule in `sampled`.
@@ -364,7 +378,7 @@ export const judgeBody = (
364
378
  readState: ReadState = {},
365
379
  ) => ({
366
380
  model,
367
- state: stateOf(lines, readState),
381
+ state: stateOf(lines, withOptions(rules, readState)),
368
382
  questions: {
369
383
  ...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
370
384
  ...Object.fromEntries(
@@ -482,7 +496,7 @@ export const locateBody = (
482
496
  readState: ReadState = {},
483
497
  ) => {
484
498
  const sections = sectionsOf(lines);
485
- const state = stateOf(lines, readState, sections);
499
+ const state = stateOf(lines, withOptions(rules, readState), sections);
486
500
  const criteria = Record.map(state.code, () => null);
487
501
  const choosing = sections.length > 1;
488
502
  const withCode = sections.map((section) => lineCriteria(lines, section));
@@ -513,10 +527,16 @@ export const locateBody = (
513
527
  };
514
528
 
515
529
  /**
516
- * Whether Jev can take every request a file may need: its sections within
517
- * one choice, and the shared state beside its longest question. Hooks are not predicted.
530
+ * Whether Jev can take every request a file may need: each request group's
531
+ * sections within one choice, and its state, options and all, beside its
532
+ * longest question. Hooks are not predicted.
518
533
  */
519
- export const fits = (lines: Lines, rules: Rules, readState: ReadState = {}): boolean => {
534
+ export const fits = (lines: Lines, rules: Rules, readState: ReadState = {}): boolean =>
535
+ Record.isEmptyRecord(rules)
536
+ ? groupFits(lines, rules, readState)
537
+ : requestGroups(rules).every((group) => groupFits(lines, group, withOptions(group, readState)));
538
+
539
+ const groupFits = (lines: Lines, rules: Rules, readState: ReadState): boolean => {
520
540
  const sections = sectionsOf(lines);
521
541
  if (sections.length > CHOICE_LIMIT) return false;
522
542
  const state = stateOf(lines, readState, sections);
@@ -633,16 +653,18 @@ export const requestsOf = <Q>(body: Body<Q>): ReadonlyArray<Body<Q>> => {
633
653
  };
634
654
 
635
655
  /**
636
- * All rules share one body except those with `appendState`, each in a body
637
- * of its own so only its questions read what its hook adds to the shared state.
656
+ * All rules share one body except those with `appendState` or options, each
657
+ * in a body of its own so only its questions read what it adds to the shared state.
638
658
  */
639
659
  export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
640
- const shared = Record.filter(rules, (rule) => rule.appendState === undefined);
660
+ const alone = Record.filter(
661
+ rules,
662
+ (rule) => rule.appendState !== undefined || rule.optionValues !== undefined,
663
+ );
664
+ const shared = Record.filter(rules, (_, id) => !(id in alone));
641
665
  return [
642
666
  ...(Record.isEmptyRecord(shared) ? [] : [shared]),
643
- ...Object.entries(rules).flatMap(([id, rule]) =>
644
- rule.appendState === undefined ? [] : [{ [id]: rule }],
645
- ),
667
+ ...Object.entries(alone).map(([id, rule]) => ({ [id]: rule })),
646
668
  ];
647
669
  };
648
670
 
package/src/wording.ts CHANGED
@@ -60,7 +60,7 @@ export const straysAmong = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<St
60
60
  });
61
61
 
62
62
  /** One way a rule strays, as a sentence. */
63
- const sentenceOf = (straying: Straying): string => {
63
+ export const sentenceOf = (straying: Straying): string => {
64
64
  switch (straying._tag) {
65
65
  case "Unsaid":
66
66
  return `The description does not say "${straying.word}", though the rule has a ${straying.word} example.`;
@@ -40,7 +40,13 @@ import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
40
40
  import { priceOf } from "#pricing.ts";
41
41
  import { applicableRules, judgesFile, shownId } from "#rules.ts";
42
42
  import { suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
43
- import { workspacePackagesIn } from "#workspaces.ts";
43
+ import {
44
+ dependentFilesIn,
45
+ importSpecifiersIn,
46
+ packageOf,
47
+ workspacePackagesIn,
48
+ } from "#workspaces.ts";
49
+ import { relative, sep } from "node:path";
44
50
  import { type Crypto, Duration, Effect, Option, Record, Result, Semaphore, Stream } from "effect";
45
51
 
46
52
  export interface Finding {
@@ -125,19 +131,24 @@ const LAYOUT = "sections";
125
131
 
126
132
  /**
127
133
  * What a judgment depends on besides the file: the model, how the file is
128
- * laid out, and the questions asked for the rule, which carry the rule: its
129
- * judge question, and its matchers' when it has any. An edited rule or
130
- * matcher re-judges the rule, and a question asked differently re-judges
131
- * every rule. So does an edited `appendState`, by its source; what the hook
132
- * reads outside the file is not known until it runs, so a change there does
133
- * not. Shared read context and related files do not invalidate judgments.
134
+ * laid out, what the config has Jev read, and the questions asked for the
135
+ * rule, which carry the rule: its judge question, and its matchers' when it
136
+ * has any. An edited rule or matcher re-judges the rule, and a question asked
137
+ * differently, or other reads, re-judge every rule. So does an edited
138
+ * `appendState`, by its source; what the hook reads outside the file is not
139
+ * known until it runs, so a change there does not. Options set differently
140
+ * re-judge their rule: decoded, a struct's keys come in its schema's order,
141
+ * however the config writes them. What the reads find, such as related
142
+ * files, does not invalidate judgments.
134
143
  */
135
- const fingerprintOf = (model: string, rule: Rule) => {
144
+ const fingerprintOf = (model: string, reads: string, rule: Rule) => {
136
145
  const matchers = matcherQuestions("", rule).map(([, question]) => question);
137
146
  const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
138
147
  const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
148
+ const options =
149
+ rule.optionValues === undefined ? "" : `\u0000${JSON.stringify(rule.optionValues)}`;
139
150
  return sha256(
140
- `${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
151
+ `${model}\u0000${LAYOUT}${reads}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${options}`,
141
152
  );
142
153
  };
143
154
 
@@ -368,6 +379,9 @@ const hasLocatedFindings = (plan: FilePlan): boolean =>
368
379
  );
369
380
  });
370
381
 
382
+ /** At most this many of a file's dependent files are listed; the rest are counted. */
383
+ const MAX_DEPENDENT_FILES = 50;
384
+
371
385
  export interface PlanOptions {
372
386
  /** Select at most this many files needing judge or cached-locate requests; zero plans only. */
373
387
  readonly limit?: number | undefined;
@@ -386,15 +400,49 @@ export const planAudit = (
386
400
  const workspacePackages = config.reads.includes("workspacePackages")
387
401
  ? (config.workspacePackages ?? {})
388
402
  : undefined;
403
+ const catalog = config.workspacePackages ?? {};
389
404
  const nativeReads: SourceReadRequest = {
390
- symbols: config.reads.includes("symbols"),
391
- references: config.reads.includes("references"),
405
+ symbols: config.typeAware,
406
+ references: config.typeAware,
392
407
  includeComments: config.includeComments === true,
393
408
  maxSymbolLength: config.maxSymbolLength,
394
409
  maxSymbolDocumentationLength: config.maxSymbolDocumentationLength,
395
410
  };
396
411
  const walker = yield* SourceWalker;
397
412
  const files = yield* walker.files;
413
+ // Each file's path from the working directory, `/`-separated, as Jev reads it.
414
+ const fromRoot = new Map(
415
+ files.map((file) => [file.path, relative(process.cwd(), file.path).split(sep).join("/")]),
416
+ );
417
+ // Only the specifiers stay in memory: each file is read again when it is planned.
418
+ const dependentFiles = config.reads.includes("dependents")
419
+ ? dependentFilesIn(
420
+ new Map(
421
+ yield* Effect.forEach(
422
+ files,
423
+ (file) =>
424
+ Effect.map(
425
+ walker.read(file),
426
+ ({ lines }) =>
427
+ [fromRoot.get(file.path)!, importSpecifiersIn(lines.join("\n"))] as const,
428
+ ),
429
+ { concurrency: 8 },
430
+ ),
431
+ ),
432
+ )
433
+ : undefined;
434
+ const dependentsOf = (file: SourceFile) => {
435
+ const at = fromRoot.get(file.path)!;
436
+ const name = packageOf(at, catalog);
437
+ const importers = dependentFiles?.get(at) ?? [];
438
+ return {
439
+ packages: (name === undefined ? undefined : config.workspaceDependents?.[name]) ?? {},
440
+ files: importers.slice(0, MAX_DEPENDENT_FILES),
441
+ ...(importers.length > MAX_DEPENDENT_FILES
442
+ ? { omittedFiles: importers.length - MAX_DEPENDENT_FILES }
443
+ : {}),
444
+ };
445
+ };
398
446
 
399
447
  // Each preset's rule, with the preset it came from, for its findings to name.
400
448
  const presetOfRule = new Map(
@@ -416,11 +464,23 @@ export const planAudit = (
416
464
  );
417
465
 
418
466
  // Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
467
+ // The reads that take effect key every judgment; code-only keeps the key it always had.
468
+ // includeComments shapes other files' `references` excerpts, which the target's hash misses.
469
+ const reads = [
470
+ ...(workspacePackages === undefined ? [] : ["workspacePackages"]),
471
+ ...(dependentFiles === undefined ? [] : ["dependents"]),
472
+ ...(config.typeAware
473
+ ? [
474
+ `symbols:${config.maxSymbolLength}:${config.maxSymbolDocumentationLength}:${config.includeComments === true}`,
475
+ ]
476
+ : []),
477
+ ];
478
+ const readsKey = reads.length === 0 ? "" : `\u0000${reads.join(",")}`;
419
479
  const preparedRules = new Map<Rule, PreparedRule>(
420
480
  yield* Effect.forEach(
421
481
  new Set(config.scopedRules?.map(({ rule }) => rule) ?? Object.values(config.rules)),
422
482
  (rule) =>
423
- Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
483
+ Effect.map(fingerprintOf(config.model, readsKey, rule), (fingerprint) => {
424
484
  const preset = presetOfRule.get(rule);
425
485
  return [
426
486
  rule,
@@ -440,10 +500,12 @@ export const planAudit = (
440
500
  const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
441
501
  const rules = configuredRules(file, suppressions);
442
502
  const code = lines.join("\n");
443
- const readState: ReadState =
444
- workspacePackages === undefined
503
+ const readState: ReadState = {
504
+ ...(workspacePackages === undefined
445
505
  ? {}
446
- : { workspacePackages: workspacePackagesIn(code, workspacePackages) };
506
+ : { workspacePackages: workspacePackagesIn(code, workspacePackages) }),
507
+ ...(dependentFiles === undefined ? {} : { dependents: dependentsOf(file) }),
508
+ };
447
509
  // Hashed even when unjudged or skipped, so a prune keeps what other runs know of its content.
448
510
  const hash = yield* sha256(code);
449
511
  const prepared = Record.map(rules, (rule) => preparedRules.get(rule)!);
@@ -802,7 +864,13 @@ export const executeAudit = (
802
864
  const room = questionRoom(file.lines, readState);
803
865
  const sentSamples = Record.filter(sampled, (_, id) => {
804
866
  const k = pending[id];
805
- return k !== undefined && tokensOf(linterQuestion(k.rule)) <= room;
867
+ if (k === undefined) return false;
868
+ // A rule with options asks beside them, in a request of its own.
869
+ const left =
870
+ k.rule.optionValues === undefined
871
+ ? room
872
+ : questionRoom(file.lines, { ...readState, options: k.rule.optionValues });
873
+ return tokensOf(linterQuestion(k.rule)) <= left;
806
874
  });
807
875
  const sampleIds = Object.keys(sentSamples);
808
876
  const { probabilities, linter, matchers }: Judged = isEmpty(pending)