@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
@@ -155,180 +155,187 @@ const OLD_NAME = /^[0-9a-f]{64}$/;
155
155
  /**
156
156
  * `.adhere/cache/`, which is meant to be committed. `files/` holds answers by
157
157
  * the content they are about, and `tallies/` the linter check's tallies, with
158
- * paths from the working directory. A file is written once and never
159
- * rewritten: it is named for its group, a content hash or a tally key, and for
160
- * the hash of its own text. Two branches that add to a group add different
161
- * files, or the same one, so git merges them without a conflict. Reading a
162
- * group takes the union of its files, and pruning folds them back into one.
158
+ * paths from the checkout root (`base`, the working directory by default). A
159
+ * file is written once and never rewritten: it is named for its group, a
160
+ * content hash or a tally key, and for the hash of its own text. Two branches
161
+ * that add to a group add different files, or the same one, so git merges them
162
+ * without a conflict. Reading a group takes the union of its files, and pruning
163
+ * folds them back into one.
163
164
  *
164
165
  * A cache must never fail a run: whatever cannot be read counts as missing,
165
166
  * and whatever cannot be written is left unwritten.
166
167
  */
167
- export const AuditCacheLive = Layer.effect(AuditCache)(
168
- Effect.gen(function* () {
169
- const fs = yield* FileSystem.FileSystem;
170
- const path = yield* Path.Path;
171
- const crypto = yield* Crypto.Crypto;
172
- const root = path.resolve();
173
- const directory = path.join(root, CACHE_DIRECTORY);
174
- const hash = (text: string) => sha256(text).pipe(Effect.provideService(Crypto.Crypto, crypto));
175
-
176
- /** Each group's file names in one folder, listed once and kept current as files come and go. */
177
- const indexOf = (folder: string) =>
178
- Effect.cached(
179
- fs.readDirectory(path.join(directory, folder)).pipe(
180
- Effect.orElseSucceed((): ReadonlyArray<string> => []),
181
- Effect.map((names) => {
182
- const groups = new Map<string, Set<string>>();
183
- for (const name of names) {
184
- const group = NAME.exec(name)?.[1];
185
- if (group !== undefined)
186
- groups.set(group, (groups.get(group) ?? new Set()).add(name));
187
- }
188
- return groups;
189
- }),
190
- ),
191
- );
192
- const indexes = { files: yield* indexOf("files"), tallies: yield* indexOf("tallies") };
193
- type Folder = keyof typeof indexes;
194
-
195
- const read = <A>(decode: (text: string) => Option.Option<A>, file: string) =>
196
- fs.readFileString(file).pipe(
197
- Effect.map((text) => Option.getOrUndefined(decode(text))),
198
- Effect.orElseSucceed(() => undefined),
199
- );
200
-
201
- /** A group's files that decode, in name order. */
202
- const readGroup = Effect.fn("AuditCache.readGroup")(function* <A>(
203
- folder: Folder,
204
- group: string,
205
- decode: (text: string) => Option.Option<A>,
206
- ) {
207
- const names = [...((yield* indexes[folder]).get(group) ?? [])].sort();
208
- const decoded = yield* Effect.forEach(names, (name) =>
209
- read(decode, path.join(directory, folder, name)),
210
- );
211
- return decoded.filter((value): value is A => value !== undefined);
212
- });
213
-
214
- const nameOf = (group: string, text: string) =>
215
- Effect.map(hash(text), (digest) => `${group}.${digest.slice(0, 16)}.json`);
216
-
217
- /** Adds `value` to a group as a new file, unless the group has that file already. */
218
- const add = Effect.fn("AuditCache.add")(function* (
219
- folder: Folder,
220
- group: string,
221
- value: unknown,
222
- ) {
223
- const text = textOf(value);
224
- const name = yield* nameOf(group, text);
225
- const index = yield* indexes[folder];
226
- const names = index.get(group) ?? new Set<string>();
227
- if (names.has(name)) return;
228
- const written = yield* fs
229
- .makeDirectory(path.join(directory, folder), { recursive: true })
230
- .pipe(
231
- Effect.andThen(fs.writeFileString(path.join(directory, folder, name), text)),
232
- Effect.as(true),
233
- Effect.orElseSucceed(() => false),
168
+ export const AuditCacheAt = (base?: string) =>
169
+ Layer.effect(AuditCache)(
170
+ Effect.gen(function* () {
171
+ const fs = yield* FileSystem.FileSystem;
172
+ const path = yield* Path.Path;
173
+ const crypto = yield* Crypto.Crypto;
174
+ const root = path.resolve(base ?? ".");
175
+ const directory = path.join(root, CACHE_DIRECTORY);
176
+ const hash = (text: string) =>
177
+ sha256(text).pipe(Effect.provideService(Crypto.Crypto, crypto));
178
+
179
+ /** Each group's file names in one folder, listed once and kept current as files come and go. */
180
+ const indexOf = (folder: string) =>
181
+ Effect.cached(
182
+ fs.readDirectory(path.join(directory, folder)).pipe(
183
+ Effect.orElseSucceed((): ReadonlyArray<string> => []),
184
+ Effect.map((names) => {
185
+ const groups = new Map<string, Set<string>>();
186
+ for (const name of names) {
187
+ const group = NAME.exec(name)?.[1];
188
+ if (group !== undefined)
189
+ groups.set(group, (groups.get(group) ?? new Set()).add(name));
190
+ }
191
+ return groups;
192
+ }),
193
+ ),
234
194
  );
235
- if (written) index.set(group, names.add(name));
236
- });
237
-
238
- const remove = (file: string) =>
239
- fs.remove(file).pipe(
240
- Effect.as(1),
241
- Effect.orElseSucceed(() => 0),
242
- );
243
-
244
- /** Replaces a group's files with one holding `value`, or with none. Succeeds with the files deleted. */
245
- const fold = Effect.fn("AuditCache.fold")(function* (
246
- folder: Folder,
247
- group: string,
248
- value: unknown,
249
- ) {
250
- const index = yield* indexes[folder];
251
- const names = [...(index.get(group) ?? [])];
252
- const keep = value === undefined ? undefined : yield* nameOf(group, textOf(value));
253
- if (value !== undefined) yield* add(folder, group, value);
254
- let deleted = 0;
255
- for (const name of names) {
256
- if (name !== keep) deleted += yield* remove(path.join(directory, folder, name));
257
- }
258
- if (keep === undefined) index.delete(group);
259
- else index.set(group, new Set([keep]));
260
- return deleted;
261
- });
262
-
263
- const relativeOf = (tally: Tally): Tally => ({
264
- files: sorted(
265
- Record.mapKeys(tally.files, (file) =>
266
- path.isAbsolute(file) ? path.relative(root, file) : file,
267
- ),
268
- ),
269
- });
270
-
271
- const get = Effect.fn("AuditCache.get")(function* (contentHash: string, filePath: string) {
272
- const entries = yield* readGroup("files", contentHash, decodeEntry);
273
- if (entries.length > 0) return unionOf(entries.map((entry) => entry.answers));
274
- // Before content keys, a file's entry sat under the hash of its absolute path.
275
- const old = yield* read(decodeOldEntry, path.join(directory, yield* hash(filePath)));
276
- if (old === undefined || old.hash !== contentHash) return {};
277
- const answers = answersOf(old.judgments);
278
- yield* add("files", contentHash, { answers: sorted(answers) });
279
- return answers;
280
- });
281
-
282
- const tally = Effect.fn("AuditCache.tally")(function* (key: string) {
283
- const tallies = yield* readGroup("tallies", key, decodeTally);
284
- if (tallies.length > 0) {
285
- const { files } = tallyUnionOf(tallies);
286
- return { files: Record.mapKeys(files, (file) => path.resolve(root, file)) };
287
- }
288
- // Before, a tally sat under the hash of its key, with absolute paths.
289
- const old = yield* read(
290
- decodeTally,
291
- path.join(directory, yield* hash(`\u0000tally\u0000${key}`)),
292
- );
293
- if (old !== undefined) yield* add("tallies", key, relativeOf(old));
294
- return old;
295
- });
296
-
297
- const prune = Effect.fn("AuditCache.prune")(function* (live: Live) {
298
- // Tallies still in the layout before content keys move first, so deleting what is left of it loses nothing.
299
- yield* Effect.forEach(live.tallies, tally, { discard: true });
300
- let deleted = 0;
301
- // Folding replaces or removes the group being visited, which a Map's iteration allows.
302
- for (const group of (yield* indexes.files).keys()) {
303
- const entries = live.hashes.has(group) ? yield* readGroup("files", group, decodeEntry) : [];
304
- const answers = unionOf(entries.map((entry) => entry.answers));
305
- deleted += yield* fold(
306
- "files",
307
- group,
308
- Object.keys(answers).length === 0 ? undefined : { answers: sorted(answers) },
195
+ const indexes = { files: yield* indexOf("files"), tallies: yield* indexOf("tallies") };
196
+ type Folder = keyof typeof indexes;
197
+
198
+ const read = <A>(decode: (text: string) => Option.Option<A>, file: string) =>
199
+ fs.readFileString(file).pipe(
200
+ Effect.map((text) => Option.getOrUndefined(decode(text))),
201
+ Effect.orElseSucceed(() => undefined),
309
202
  );
310
- }
311
- for (const key of (yield* indexes.tallies).keys()) {
203
+
204
+ /** A group's files that decode, in name order. */
205
+ const readGroup = Effect.fn("AuditCache.readGroup")(function* <A>(
206
+ folder: Folder,
207
+ group: string,
208
+ decode: (text: string) => Option.Option<A>,
209
+ ) {
210
+ const names = [...((yield* indexes[folder]).get(group) ?? [])].sort();
211
+ const decoded = yield* Effect.forEach(names, (name) =>
212
+ read(decode, path.join(directory, folder, name)),
213
+ );
214
+ return decoded.filter((value): value is A => value !== undefined);
215
+ });
216
+
217
+ const nameOf = (group: string, text: string) =>
218
+ Effect.map(hash(text), (digest) => `${group}.${digest.slice(0, 16)}.json`);
219
+
220
+ /** Adds `value` to a group as a new file, unless the group has that file already. */
221
+ const add = Effect.fn("AuditCache.add")(function* (
222
+ folder: Folder,
223
+ group: string,
224
+ value: unknown,
225
+ ) {
226
+ const text = textOf(value);
227
+ const name = yield* nameOf(group, text);
228
+ const index = yield* indexes[folder];
229
+ const names = index.get(group) ?? new Set<string>();
230
+ if (names.has(name)) return;
231
+ const written = yield* fs
232
+ .makeDirectory(path.join(directory, folder), { recursive: true })
233
+ .pipe(
234
+ Effect.andThen(fs.writeFileString(path.join(directory, folder, name), text)),
235
+ Effect.as(true),
236
+ Effect.orElseSucceed(() => false),
237
+ );
238
+ if (written) index.set(group, names.add(name));
239
+ });
240
+
241
+ const remove = (file: string) =>
242
+ fs.remove(file).pipe(
243
+ Effect.as(1),
244
+ Effect.orElseSucceed(() => 0),
245
+ );
246
+
247
+ /** Replaces a group's files with one holding `value`, or with none. Succeeds with the files deleted. */
248
+ const fold = Effect.fn("AuditCache.fold")(function* (
249
+ folder: Folder,
250
+ group: string,
251
+ value: unknown,
252
+ ) {
253
+ const index = yield* indexes[folder];
254
+ const names = [...(index.get(group) ?? [])];
255
+ const keep = value === undefined ? undefined : yield* nameOf(group, textOf(value));
256
+ if (value !== undefined) yield* add(folder, group, value);
257
+ let deleted = 0;
258
+ for (const name of names) {
259
+ if (name !== keep) deleted += yield* remove(path.join(directory, folder, name));
260
+ }
261
+ if (keep === undefined) index.delete(group);
262
+ else index.set(group, new Set([keep]));
263
+ return deleted;
264
+ });
265
+
266
+ const relativeOf = (tally: Tally): Tally => ({
267
+ files: sorted(
268
+ Record.mapKeys(tally.files, (file) =>
269
+ path.isAbsolute(file) ? path.relative(root, file) : file,
270
+ ),
271
+ ),
272
+ });
273
+
274
+ const get = Effect.fn("AuditCache.get")(function* (contentHash: string, filePath: string) {
275
+ const entries = yield* readGroup("files", contentHash, decodeEntry);
276
+ if (entries.length > 0) return unionOf(entries.map((entry) => entry.answers));
277
+ // Before content keys, a file's entry sat under the hash of its absolute path.
278
+ const old = yield* read(decodeOldEntry, path.join(directory, yield* hash(filePath)));
279
+ if (old === undefined || old.hash !== contentHash) return {};
280
+ const answers = answersOf(old.judgments);
281
+ yield* add("files", contentHash, { answers: sorted(answers) });
282
+ return answers;
283
+ });
284
+
285
+ const tally = Effect.fn("AuditCache.tally")(function* (key: string) {
312
286
  const tallies = yield* readGroup("tallies", key, decodeTally);
313
- deleted += yield* fold(
314
- "tallies",
315
- key,
316
- tallies.length === 0 ? undefined : relativeOf(tallyUnionOf(tallies)),
287
+ if (tallies.length > 0) {
288
+ const { files } = tallyUnionOf(tallies);
289
+ return { files: Record.mapKeys(files, (file) => path.resolve(root, file)) };
290
+ }
291
+ // Before, a tally sat under the hash of its key, with absolute paths.
292
+ const old = yield* read(
293
+ decodeTally,
294
+ path.join(directory, yield* hash(`\u0000tally\u0000${key}`)),
317
295
  );
318
- }
319
- const old = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
320
- for (const name of old) {
321
- if (OLD_NAME.test(name)) deleted += yield* remove(path.join(directory, name));
322
- }
323
- return deleted;
324
- });
325
-
326
- return AuditCache.of({
327
- get,
328
- put: (contentHash, answers) => add("files", contentHash, { answers: sorted(answers) }),
329
- tally,
330
- putTally: (key, value) => add("tallies", key, relativeOf(value)),
331
- prune,
332
- });
333
- }),
334
- );
296
+ if (old !== undefined) yield* add("tallies", key, relativeOf(old));
297
+ return old;
298
+ });
299
+
300
+ const prune = Effect.fn("AuditCache.prune")(function* (live: Live) {
301
+ // Tallies still in the layout before content keys move first, so deleting what is left of it loses nothing.
302
+ yield* Effect.forEach(live.tallies, tally, { discard: true });
303
+ let deleted = 0;
304
+ // Folding replaces or removes the group being visited, which a Map's iteration allows.
305
+ for (const group of (yield* indexes.files).keys()) {
306
+ const entries = live.hashes.has(group)
307
+ ? yield* readGroup("files", group, decodeEntry)
308
+ : [];
309
+ const answers = unionOf(entries.map((entry) => entry.answers));
310
+ deleted += yield* fold(
311
+ "files",
312
+ group,
313
+ Object.keys(answers).length === 0 ? undefined : { answers: sorted(answers) },
314
+ );
315
+ }
316
+ for (const key of (yield* indexes.tallies).keys()) {
317
+ const tallies = yield* readGroup("tallies", key, decodeTally);
318
+ deleted += yield* fold(
319
+ "tallies",
320
+ key,
321
+ tallies.length === 0 ? undefined : relativeOf(tallyUnionOf(tallies)),
322
+ );
323
+ }
324
+ const old = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
325
+ for (const name of old) {
326
+ if (OLD_NAME.test(name)) deleted += yield* remove(path.join(directory, name));
327
+ }
328
+ return deleted;
329
+ });
330
+
331
+ return AuditCache.of({
332
+ get,
333
+ put: (contentHash, answers) => add("files", contentHash, { answers: sorted(answers) }),
334
+ tally,
335
+ putTally: (key, value) => add("tallies", key, relativeOf(value)),
336
+ prune,
337
+ });
338
+ }),
339
+ );
340
+
341
+ export const AuditCacheLive = AuditCacheAt();
@@ -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
  ),
@@ -194,6 +194,28 @@ describe("prepared shared Jev reads", () => {
194
194
  expect(locateRequests(lines, rules, readState)).toBe(3);
195
195
  });
196
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
+
197
219
  it("preserves caller comments and raw context through judge, locate, and appended maps", async () => {
198
220
  const readState = {
199
221
  __comment: "User context: only target code is audited.",
@@ -146,6 +146,10 @@ const READ_COMMENTS: Readonly<Record<string, string>> = {
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
 
@@ -172,20 +172,27 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
172
172
  * Tests are listed even when no rule judges them, so a prune keeps what other runs
173
173
  * know of them. Tests substitute their own tree.
174
174
  * The scan root is the current working directory, so the audit reads the
175
- * repository it is run in.
175
+ * repository it is run in. `roots` overrides the workspace trees read: `["."]`
176
+ * reads the whole working directory, for a trial that must not miss a file
177
+ * outside `agents/`, `apps/`, and `packages/`. `base` roots the walk
178
+ * elsewhere, for a trial of a checkout that is not the working directory.
176
179
  */
177
- export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
180
+ export const SourceWalkerLive = (
181
+ filter: ReadonlyArray<string> = [],
182
+ roots?: ReadonlyArray<string>,
183
+ base?: string,
184
+ ) =>
178
185
  Layer.effect(SourceWalker)(
179
186
  Effect.gen(function* () {
180
187
  const fs = yield* FileSystem.FileSystem;
181
188
  const path = yield* Path.Path;
182
189
  const config = yield* AdhereConfig;
183
- // No segments: Path resolves against the working directory.
184
- const root = path.resolve();
190
+ const root = base === undefined ? path.resolve() : path.resolve(base);
185
191
  const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
186
192
  const patterns = [...filter, ...(config.exclude ?? []).map((glob) => `!${glob}`)];
187
193
  const walk = Effect.fn("SourceWalker.files")(function* () {
188
- const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
194
+ const dirs =
195
+ roots === undefined ? yield* scanDirs(fs, root).pipe(Effect.mapError(refused)) : roots;
189
196
  const trees = yield* clearingStatus(
190
197
  Effect.forEach(dirs, (dir) =>
191
198
  listRoot(
package/src/wording.ts CHANGED
@@ -4,7 +4,7 @@ import { type RuleEntry, shownId } from "#rules.ts";
4
4
  type Word = Example["word"];
5
5
 
6
6
  /**
7
- * A way a rule's wording differs from the README's rule writing tips, which
7
+ * A way a rule's wording differs from the docs' rule writing tips, which
8
8
  * the eval measured: the rule shows code of both kinds, and its description
9
9
  * says the words its code is under.
10
10
  */
@@ -22,7 +22,7 @@ export interface Stray {
22
22
  readonly straying: ReadonlyArray<Straying>;
23
23
  }
24
24
 
25
- export const TIPS_URL = "https://github.com/darkmatter/adhere#rule-writing-tips";
25
+ export const TIPS_URL = "https://dm.sh/adhere/writing-good-rules/#rule-writing-tips";
26
26
 
27
27
  /** Whether a description says a word, whole and in any case: "Must" does, "mustard" does not. */
28
28
  const says = (description: string, word: Word): boolean =>
@@ -30,7 +30,7 @@ const says = (description: string, word: Word): boolean =>
30
30
 
31
31
  /**
32
32
  * How one rule strays from the tips. A rule with only code that breaks it
33
- * lacks no example: the README keeps that for a rule with no single correct
33
+ * lacks no example: the docs keep that for a rule with no single correct
34
34
  * form to show.
35
35
  */
36
36
  export const strayingOf = (rule: Rule): ReadonlyArray<Straying> => {
@@ -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.`;