@drkmttr/adhere 0.18.1 → 0.23.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.
Files changed (43) hide show
  1. package/README.md +62 -1575
  2. package/package.json +18 -15
  3. package/presets/README.md +140 -0
  4. package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
  7. package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
  8. package/skills/adhere/SKILL.md +194 -29
  9. package/skills/adhere-fix/SKILL.md +1 -1
  10. package/skills/adhere-setup/SKILL.md +60 -26
  11. package/src/cli.ts +334 -36
  12. package/src/config.ts +173 -142
  13. package/src/create/agents.ts +149 -0
  14. package/src/create/ask.ts +363 -0
  15. package/src/create/examples.ts +94 -0
  16. package/src/create/look.ts +191 -0
  17. package/src/create/oxlint-trial.ts +464 -0
  18. package/src/create/oxlint.ts +1012 -0
  19. package/src/create/trial-review.ts +562 -0
  20. package/src/create/trial.ts +611 -0
  21. package/src/create/wizard.ts +466 -0
  22. package/src/index.ts +2 -0
  23. package/src/init.ts +171 -41
  24. package/src/install.ts +296 -94
  25. package/src/instructions.ts +98 -0
  26. package/src/markdown.ts +15 -11
  27. package/src/oxlint/catalog.json +7844 -0
  28. package/src/oxlint/catalog.ts +72 -0
  29. package/src/oxlint/shared.ts +140 -0
  30. package/src/readme-guide.ts +431 -0
  31. package/src/readme.ts +291 -0
  32. package/src/rules.ts +42 -45
  33. package/src/services/AdhereConfig.ts +67 -21
  34. package/src/services/AuditCache.ts +176 -169
  35. package/src/services/Credentials.ts +14 -8
  36. package/src/services/Jev.reads.test.ts +22 -0
  37. package/src/services/Jev.ts +35 -13
  38. package/src/services/SourceWalker.ts +12 -5
  39. package/src/wording.ts +4 -4
  40. package/src/workflows/audit.ts +85 -14
  41. package/src/workflows/format.ts +98 -1
  42. package/src/workspaces.test.ts +37 -1
  43. package/src/workspaces.ts +121 -12
package/src/readme.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Writes the managed guide into a shared repo's README: only a marked block,
3
+ * so the rest of the README stays the author's. The block itself, its rules
4
+ * grouped by topic and its lint settings, is rendered by `#readme-guide.ts`.
5
+ */
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { run } from "#create/agents.ts";
9
+ import { cloned, describeRulesAt } from "#install.ts";
10
+ import { lintEntries, renderReadmeIndex, type LintConfig } from "#readme-guide.ts";
11
+ import { SHARED_LINT_EXPORT, SHARED_LINT_RULES } from "#oxlint/shared.ts";
12
+ import { Effect, Option, Schema } from "effect";
13
+
14
+ export type { LintConfig, ReadmeRule } from "#readme-guide.ts";
15
+ export { renderReadmeIndex };
16
+
17
+ export const README_START = "<!-- adhere:rules:start -->";
18
+ export const README_END = "<!-- adhere:rules:end -->";
19
+
20
+ export class ReadmeUnavailable extends Schema.TaggedError<ReadmeUnavailable>()(
21
+ "ReadmeUnavailable",
22
+ { message: Schema.String },
23
+ ) {}
24
+
25
+ const LintConfigShape = Schema.Struct({
26
+ rules: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
27
+ jsPlugins: Schema.optionalKey(Schema.Array(Schema.Unknown)),
28
+ });
29
+
30
+ const Manifest = Schema.Struct({
31
+ exports: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
32
+ });
33
+
34
+ /** What a README index wrote, and how much it listed. */
35
+ export interface ReadmeRefresh {
36
+ readonly changed: boolean;
37
+ readonly message: string;
38
+ }
39
+
40
+ const readOptional = (file: string): Effect.Effect<string | undefined, ReadmeUnavailable> =>
41
+ Effect.tryPromise({
42
+ try: async () => {
43
+ try {
44
+ return { text: await readFile(file, "utf8") };
45
+ } catch (cause) {
46
+ if (
47
+ typeof cause === "object" &&
48
+ cause !== null &&
49
+ "code" in cause &&
50
+ cause.code === "ENOENT"
51
+ ) {
52
+ return {};
53
+ }
54
+ throw cause;
55
+ }
56
+ },
57
+ catch: (cause) =>
58
+ new ReadmeUnavailable({
59
+ message: `Could not read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`,
60
+ }),
61
+ }).pipe(Effect.map(({ text }) => text));
62
+
63
+ /** The shared lint config, `{}` when absent; invalid JSON refuses the refresh. */
64
+ const lintConfigAt = (root: string): Effect.Effect<LintConfig, ReadmeUnavailable> =>
65
+ Effect.gen(function* () {
66
+ const file = join(root, SHARED_LINT_RULES);
67
+ const text = yield* readOptional(file);
68
+ if (text === undefined) return {};
69
+ const parsed = yield* Effect.try({
70
+ try: () => JSON.parse(text) as unknown,
71
+ catch: () =>
72
+ new ReadmeUnavailable({
73
+ message: `${file} is not valid JSON; left the README unchanged.`,
74
+ }),
75
+ });
76
+ const decoded = Schema.decodeUnknownOption(LintConfigShape)(parsed);
77
+ return Option.isSome(decoded)
78
+ ? decoded.value
79
+ : yield* ReadmeUnavailable.make({
80
+ message: `${file} is not an object of rules and jsPlugins; left the README unchanged.`,
81
+ });
82
+ });
83
+
84
+ /**
85
+ * Whether `root` is a shared rules repo, by the metadata `init --shared`
86
+ * writes: a package.json whose exports name `./oxlint`. Its rules directory
87
+ * alone is every project's, so this is what tells a shared repo from one that
88
+ * merely has rules.
89
+ */
90
+ export const isSharedRepo = (root: string): Effect.Effect<boolean> =>
91
+ Effect.promise(async () => {
92
+ try {
93
+ const parsed: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
94
+ const decoded = Schema.decodeUnknownOption(Manifest)(parsed);
95
+ return Option.isSome(decoded) && decoded.value.exports?.[SHARED_LINT_EXPORT] !== undefined;
96
+ } catch {
97
+ return false;
98
+ }
99
+ });
100
+
101
+ /**
102
+ * Replace only the marked block; append it when absent. A README that is
103
+ * missing one marker, or repeats them, refuses before any write.
104
+ */
105
+ export const applyReadmeIndex = (
106
+ previous: string,
107
+ block: string,
108
+ ): Effect.Effect<string, ReadmeUnavailable> =>
109
+ Effect.gen(function* () {
110
+ const start = previous.indexOf(README_START);
111
+ const end = previous.indexOf(README_END);
112
+ if (
113
+ (start === -1) !== (end === -1) ||
114
+ (start !== -1 &&
115
+ (end < start ||
116
+ previous.indexOf(README_START, start + README_START.length) !== -1 ||
117
+ previous.indexOf(README_END, end + README_END.length) !== -1))
118
+ ) {
119
+ return yield* ReadmeUnavailable.make({
120
+ message:
121
+ "README.md has malformed or duplicate adhere rules markers. Fix them before running adhere rules readme.",
122
+ });
123
+ }
124
+ const wrapped = `${README_START}\n${block}\n${README_END}`;
125
+ return start === -1
126
+ ? `${previous}${previous === "" ? "" : previous.endsWith("\n\n") ? "" : previous.endsWith("\n") ? "\n" : "\n\n"}${wrapped}\n`
127
+ : `${previous.slice(0, start)}${wrapped}${previous.slice(end + README_END.length)}`;
128
+ });
129
+
130
+ /**
131
+ * Check that `root`'s README accepts an index refresh, without writing: a
132
+ * local writer calls this before it writes a rule or plugin, so malformed
133
+ * markers fail before a partial change lands.
134
+ */
135
+ export const validateReadme = (root: string): Effect.Effect<void, ReadmeUnavailable> =>
136
+ Effect.gen(function* () {
137
+ const previous = (yield* readOptional(join(root, "README.md"))) ?? "";
138
+ yield* applyReadmeIndex(previous, "");
139
+ });
140
+
141
+ /**
142
+ * Refresh the README at `root` from its rules and lint config. `root` is
143
+ * taken as shared because init knows it is; commands that start in a
144
+ * directory check `isSharedRepo` first.
145
+ */
146
+ export const refreshReadme = (root: string): Effect.Effect<ReadmeRefresh, ReadmeUnavailable> =>
147
+ Effect.gen(function* () {
148
+ const rules = yield* describeRulesAt(root).pipe(
149
+ Effect.mapError(
150
+ (cause) =>
151
+ new ReadmeUnavailable({
152
+ message: cause instanceof Error ? cause.message : String(cause),
153
+ }),
154
+ ),
155
+ );
156
+ for (const rule of rules) {
157
+ const text = `${rule.description}\n${rule.body ?? ""}`;
158
+ if (text.includes(README_START) || text.includes(README_END)) {
159
+ return yield* ReadmeUnavailable.make({
160
+ message: `${rule.id} has an adhere rules marker in its description or body; left the README unchanged.`,
161
+ });
162
+ }
163
+ }
164
+ const config = yield* lintConfigAt(root);
165
+ const entries = lintEntries(config);
166
+ const block = renderReadmeIndex(rules, config);
167
+ const target = join(root, "README.md");
168
+ const previous = (yield* readOptional(target)) ?? "";
169
+ const next = yield* applyReadmeIndex(previous, block);
170
+ const changed = next !== previous;
171
+ if (changed) {
172
+ yield* Effect.tryPromise({
173
+ try: () => writeFile(target, next, "utf8"),
174
+ catch: (cause) =>
175
+ new ReadmeUnavailable({
176
+ message: `Could not write ${target}: ${cause instanceof Error ? cause.message : String(cause)}`,
177
+ }),
178
+ });
179
+ }
180
+ const settings = entries.native.length + entries.custom.length;
181
+ return {
182
+ changed,
183
+ message: `${changed ? "Updated" : "Unchanged"} README.md: ${rules.length} ${rules.length === 1 ? "rule" : "rules"}, ${settings} enabled lint ${settings === 1 ? "setting" : "settings"}.`,
184
+ };
185
+ });
186
+
187
+ /**
188
+ * Refresh `root`'s index only when it is a shared repo, as an org push
189
+ * discovered it. A legacy rules-only repo is left exactly as it was: no README
190
+ * is invented. Returns whether the root is shared, so the caller stages
191
+ * README.md only then. Malformed markers propagate and stop the push.
192
+ */
193
+ export const refreshIndexIfShared = (root: string): Effect.Effect<boolean, ReadmeUnavailable> =>
194
+ Effect.gen(function* () {
195
+ if (!(yield* isSharedRepo(root))) return false;
196
+ yield* refreshReadme(root);
197
+ return true;
198
+ });
199
+
200
+ /** Refresh a shared repo's README, refusing a directory that is not one. */
201
+ export const refreshSharedReadme = (
202
+ root: string,
203
+ ): Effect.Effect<ReadmeRefresh, ReadmeUnavailable> =>
204
+ Effect.gen(function* () {
205
+ if (!(yield* isSharedRepo(root))) {
206
+ return yield* ReadmeUnavailable.make({
207
+ message: `${root} is not a shared rules repo: package.json must export ${SHARED_LINT_EXPORT}, as adhere init --shared writes it.`,
208
+ });
209
+ }
210
+ return yield* refreshReadme(root);
211
+ });
212
+
213
+ const GITHUB_URL = /^(?:[^@/\s]+@)?github\.com(?::\d+)?[:/]([^/\s]+)\/([^/\s]+)$/;
214
+
215
+ /**
216
+ * The shared rules repo a GitHub origin implies, as `owner/standards`, from the
217
+ * forms an origin takes: `https://github.com/owner/repo.git`,
218
+ * `git@github.com:owner/repo.git`, and `ssh://git@github.com/owner/repo.git`.
219
+ * An origin on another host, or one that names no owner and repo, implies
220
+ * none. The origin's repo is read for its owner alone: an org's shared rules
221
+ * live in that org's `standards`, never in the consumer repo the origin names.
222
+ */
223
+ export const standardsRepoOf = (url: string): string | undefined => {
224
+ const path = url
225
+ .trim()
226
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
227
+ .replace(/\/+$/, "");
228
+ const [, owner, named] = GITHUB_URL.exec(path) ?? [];
229
+ const repo = named?.replace(/\.git$/, "");
230
+ if (owner === undefined || repo === undefined) return undefined;
231
+ return /^[\w.-]+$/.test(owner) && /^[\w.-]+$/.test(repo) ? `${owner}/standards` : undefined;
232
+ };
233
+
234
+ /**
235
+ * This checkout's `origin` URL, trimmed, or undefined when git names none: no
236
+ * origin, or a directory git does not see as a repo. Read from the config, not
237
+ * `git remote get-url`, which applies `url.<base>.insteadOf` rewrites and so
238
+ * would not say which GitHub repo the origin names.
239
+ */
240
+ export const originUrlAt = (root: string): Effect.Effect<string | undefined> =>
241
+ Effect.promise(() =>
242
+ run("git", ["config", "--get", "remote.origin.url"], root, "").then(
243
+ (url) => url.trim(),
244
+ () => undefined,
245
+ ),
246
+ );
247
+
248
+ const git = (cwd: string, args: ReadonlyArray<string>) =>
249
+ Effect.tryPromise({
250
+ try: () => run("git", args, cwd, ""),
251
+ catch: (cause) =>
252
+ new ReadmeUnavailable({ message: cause instanceof Error ? cause.message : String(cause) }),
253
+ });
254
+
255
+ /** What a remote refresh did: its README result, the repo, and the branch it pushed. */
256
+ export interface RemoteReadmeRefresh extends ReadmeRefresh {
257
+ readonly repo: string;
258
+ /** The review branch pushed with the new index, or undefined when it was unchanged. */
259
+ readonly branch?: string;
260
+ }
261
+
262
+ /**
263
+ * Refresh the README of `repo` from a clone of its default branch, and push the
264
+ * change on a review branch for a pull request. Only README.md is committed,
265
+ * nothing is pushed when the index is already current, and the clone is
266
+ * temporary, so the checkout this was asked from is untouched. No `./oxlint`
267
+ * export is needed: a rules-only repo has a README index too.
268
+ */
269
+ export const refreshRemoteReadme = (
270
+ repo: string,
271
+ ): Effect.Effect<RemoteReadmeRefresh, ReadmeUnavailable> =>
272
+ Effect.scoped(
273
+ Effect.gen(function* () {
274
+ const clone = yield* cloned({ repo, rule: "" }).pipe(
275
+ Effect.mapError(
276
+ (cause) =>
277
+ new ReadmeUnavailable({
278
+ message: `${cause instanceof Error ? cause.message : String(cause)}. Check that ${repo} exists and git can read it, or name another repo: adhere rules readme org/repo.`,
279
+ }),
280
+ ),
281
+ );
282
+ const refreshed = yield* refreshReadme(clone);
283
+ if (!refreshed.changed) return { ...refreshed, repo };
284
+ const branch = `adhere/readme-${Date.now()}`;
285
+ yield* git(clone, ["switch", "--quiet", "--create", branch]);
286
+ yield* git(clone, ["add", "--", "README.md"]);
287
+ yield* git(clone, ["commit", "--quiet", "--message", "Refresh the shared README index"]);
288
+ yield* git(clone, ["push", "--quiet", "origin", branch]);
289
+ return { ...refreshed, repo, branch };
290
+ }),
291
+ );
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`,
@@ -2,6 +2,7 @@ import {
2
2
  AdhereConfig as AdhereConfigSchema,
3
3
  CONFIG_FILES,
4
4
  ConfigUnavailable,
5
+ declaresNativeReads,
5
6
  decodeConfig,
6
7
  type Loaded,
7
8
  type Flags,
@@ -10,6 +11,7 @@ import {
10
11
  type ResolvedConfig,
11
12
  resolveConfig,
12
13
  RULES_DIRECTORY,
14
+ typeAwareOf,
13
15
  } from "#config.ts";
14
16
  import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
15
17
  import {
@@ -21,14 +23,14 @@ import {
21
23
  shownId,
22
24
  withOverrides,
23
25
  } from "#rules.ts";
24
- import { workspacePackagesOf } from "#workspaces.ts";
26
+ import { workspaceDependentsOf, workspacePackagesOf } from "#workspaces.ts";
25
27
  import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
26
28
 
27
29
  export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
28
30
  "@drkmttr/adhere/services/AdhereConfig",
29
31
  ) {}
30
32
 
31
- const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
33
+ export const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
32
34
  const path = yield* Path.Path;
33
35
  const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
34
36
  const loaded = yield* Effect.tryPromise({
@@ -45,7 +47,10 @@ const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
45
47
  );
46
48
  });
47
49
 
48
- const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetName, base: string) {
50
+ export const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (
51
+ name: PresetName,
52
+ base: string,
53
+ ) {
49
54
  const { topic, ...preset } = presetOf(name);
50
55
  const all = yield* materializeRules(preset.rules, base);
51
56
  const rules =
@@ -53,6 +58,23 @@ const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetN
53
58
  return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
54
59
  });
55
60
 
61
+ /** Locate the optional config without loading rules or presets. */
62
+ export const findConfigFile = Effect.fn("AdhereConfig.find")(function* (cwd: string) {
63
+ const path = yield* Path.Path;
64
+ const fs = yield* FileSystem.FileSystem;
65
+ const found = yield* Effect.filter(CONFIG_FILES, (file) =>
66
+ fs
67
+ .exists(path.join(cwd, file))
68
+ .pipe(Effect.mapError((problem) => ConfigUnavailable.make({ message: problem.message }))),
69
+ );
70
+ if (found.length > 1) {
71
+ return yield* ConfigUnavailable.make({
72
+ message: `More than one config file: ${found.join(", ")}. Keep one.`,
73
+ });
74
+ }
75
+ return found[0];
76
+ });
77
+
56
78
  /** Every rule of every built-in preset, by the id a report names it with. */
57
79
  const everyPresetRule = Effect.fn("AdhereConfig.everyPresetRule")(function* (base: string) {
58
80
  const loaded = yield* Effect.forEach(Object.entries(presets), ([name, preset]) =>
@@ -73,19 +95,8 @@ export const AdhereConfigLive = (flags: Flags) =>
73
95
  Layer.effect(AdhereConfig)(
74
96
  Effect.gen(function* () {
75
97
  const path = yield* Path.Path;
76
- const fs = yield* FileSystem.FileSystem;
77
98
  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];
99
+ const configFile = yield* findConfigFile(cwd);
89
100
  const hasPreset = flags.presets !== undefined && flags.presets.length > 0;
90
101
  const config: typeof AdhereConfigSchema.Type =
91
102
  configFile === undefined
@@ -103,9 +114,17 @@ export const AdhereConfigLive = (flags: Flags) =>
103
114
  ? discoveredEntries
104
115
  : globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
105
116
  const rules = applicableRules(cwd, projectEntries);
106
- const registry = yield* Effect.forEach(presetsOf(config, flags), (name) =>
117
+ const loadedPresets = yield* Effect.forEach(presetsOf(config, flags), (name) =>
107
118
  loadPreset(name, cwd),
108
119
  ).pipe(Effect.map(Record.fromEntries));
120
+ // A bundled rule that declares the compiler's native reads is left out
121
+ // unless this run enables them; a project's own rule stays and warns.
122
+ const registry = typeAwareOf(config, flags)
123
+ ? loadedPresets
124
+ : Record.map(loadedPresets, (preset) => ({
125
+ ...preset,
126
+ rules: Record.filter(preset.rules, (rule) => !declaresNativeReads(rule)),
127
+ }));
109
128
  const resolved = resolveConfig({ ...config, rules }, flags, registry);
110
129
  const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
111
130
  globalRuleSet(preset.rules, cwd).map((entry) => ({
@@ -121,7 +140,8 @@ export const AdhereConfigLive = (flags: Flags) =>
121
140
  const loaded = new Set(entries.map(shownId));
122
141
  const elsewhere = Object.keys(overrides).filter((id) => !loaded.has(id));
123
142
  if (elsewhere.length > 0) {
124
- // A rule of a preset this run leaves out may be overridden; one no rule has is a typo.
143
+ // A rule of a preset this run leaves out, or skips without type awareness,
144
+ // may be overridden; one no rule has is a typo.
125
145
  const every = yield* everyPresetRule(cwd);
126
146
  const unknown = elsewhere.filter((id) => !every.has(id));
127
147
  if (unknown.length > 0) {
@@ -130,15 +150,41 @@ export const AdhereConfigLive = (flags: Flags) =>
130
150
  });
131
151
  }
132
152
  }
133
- const scopedRules = withOverrides(entries, overrides);
153
+ const scopedRules = yield* withOverrides(entries, overrides);
154
+ for (const entry of scopedRules) {
155
+ const missing = (entry.rule.reads ?? []).filter((read) => !resolved.reads.includes(read));
156
+ if (missing.length === 0) continue;
157
+ const workspaceMissing = missing.filter(
158
+ (read) => read === "workspacePackages" || read === "dependents",
159
+ );
160
+ const nativeMissing = missing.filter((read) => read === "symbols" || read === "references");
161
+ const enable = [
162
+ ...(workspaceMissing.length === 0
163
+ ? []
164
+ : [
165
+ `add ${workspaceMissing.map((read) => `"${read}"`).join(", ")} to the config's reads`,
166
+ ]),
167
+ ...(nativeMissing.length === 0 ? [] : ["enable typeAware: true (or pass --type-aware)"]),
168
+ ].join(" and ");
169
+ yield* Effect.logWarning(
170
+ `${shownId(entry)} declares reads ${missing.join(", ")}, which this run does not enable; the rule may be less accurate until enabled. ${enable}.`,
171
+ );
172
+ }
134
173
  // 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;
174
+ const dependents = resolved.reads.includes("dependents");
175
+ const workspacePackages =
176
+ dependents || resolved.reads.includes("workspacePackages")
177
+ ? yield* workspacePackagesOf(cwd)
178
+ : undefined;
179
+ const workspaceDependents =
180
+ dependents && workspacePackages !== undefined
181
+ ? yield* workspaceDependentsOf(cwd, workspacePackages)
182
+ : undefined;
138
183
  return AdhereConfig.of({
139
184
  ...resolved,
140
185
  scopedRules,
141
186
  ...(workspacePackages === undefined ? {} : { workspacePackages }),
187
+ ...(workspaceDependents === undefined ? {} : { workspaceDependents }),
142
188
  });
143
189
  }),
144
190
  );