@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.
- package/README.md +62 -1575
- package/package.json +18 -15
- package/presets/README.md +140 -0
- package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
- package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
- package/skills/adhere/SKILL.md +194 -29
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +60 -26
- package/src/cli.ts +334 -36
- package/src/config.ts +173 -142
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +363 -0
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +191 -0
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +562 -0
- package/src/create/trial.ts +611 -0
- package/src/create/wizard.ts +466 -0
- package/src/index.ts +2 -0
- package/src/init.ts +171 -41
- package/src/install.ts +296 -94
- package/src/instructions.ts +98 -0
- package/src/markdown.ts +15 -11
- package/src/oxlint/catalog.json +7844 -0
- package/src/oxlint/catalog.ts +72 -0
- package/src/oxlint/shared.ts +140 -0
- package/src/readme-guide.ts +431 -0
- package/src/readme.ts +291 -0
- package/src/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +67 -21
- package/src/services/AuditCache.ts +176 -169
- package/src/services/Credentials.ts +14 -8
- package/src/services/Jev.reads.test.ts +22 -0
- package/src/services/Jev.ts +35 -13
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +4 -4
- package/src/workflows/audit.ts +85 -14
- package/src/workflows/format.ts +98 -1
- package/src/workspaces.test.ts +37 -1
- 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
|
|
159
|
-
* rewritten: it is named for its group, a
|
|
160
|
-
* the hash of its own text. Two branches
|
|
161
|
-
* files, or the same one, so git merges them
|
|
162
|
-
* group takes the union of its files, and pruning
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 =
|
|
81
|
-
Config.
|
|
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.",
|
package/src/services/Jev.ts
CHANGED
|
@@ -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:
|
|
517
|
-
* one choice, and
|
|
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
|
|
637
|
-
* of its own so only its questions read what
|
|
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
|
|
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(
|
|
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 = (
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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://
|
|
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
|
|
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.`;
|