@drkmttr/adhere 0.21.2 → 0.24.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 +28 -1552
- package/package.json +10 -7
- package/presets/README.md +9 -1
- 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 +214 -30
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +49 -16
- package/src/cli.ts +216 -25
- package/src/config.ts +73 -4
- package/src/create/ask.ts +255 -9
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +62 -19
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +625 -0
- package/src/create/trial.ts +663 -0
- package/src/create/wizard.ts +131 -95
- package/src/filters.ts +86 -0
- package/src/init.ts +169 -40
- package/src/install.ts +295 -93
- package/src/instructions.ts +34 -12
- package/src/markdown.ts +21 -2
- 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/services/AdhereConfig.ts +36 -3
- package/src/services/AuditCache.ts +176 -169
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +3 -3
- package/src/workflows/audit.ts +40 -7
- package/src/workflows/format.ts +1 -1
|
@@ -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();
|
|
@@ -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> => {
|
package/src/workflows/audit.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type Rules,
|
|
10
10
|
} from "#config.ts";
|
|
11
11
|
import { type Excerpt, type Range } from "#excerpt.ts";
|
|
12
|
+
import { contentPasses, filtersNeedText, hasFilters, ruleAppliesTo } from "#filters.ts";
|
|
12
13
|
import { type ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
13
14
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
14
15
|
import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
|
|
@@ -147,6 +148,8 @@ const fingerprintOf = (model: string, reads: string, rule: Rule) => {
|
|
|
147
148
|
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
148
149
|
const options =
|
|
149
150
|
rule.optionValues === undefined ? "" : `\u0000${JSON.stringify(rule.optionValues)}`;
|
|
151
|
+
// Filters decide which files a rule may judge, not what Jev answers, so they
|
|
152
|
+
// do not change the fingerprint: applicability is checked before the cache.
|
|
150
153
|
return sha256(
|
|
151
154
|
`${model}\u0000${LAYOUT}${reads}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${options}`,
|
|
152
155
|
);
|
|
@@ -352,7 +355,12 @@ const sameSuppressions = (left: Suppressions, right: Suppressions): boolean => {
|
|
|
352
355
|
);
|
|
353
356
|
};
|
|
354
357
|
|
|
355
|
-
/**
|
|
358
|
+
/**
|
|
359
|
+
* Validate code, ignore policy, and text-dependent filters before sending a
|
|
360
|
+
* request or showing a cached excerpt. A comment-only edit keeps the code hash
|
|
361
|
+
* but can change what a content filter sees, so those filters are rechecked
|
|
362
|
+
* against the new text; path filters cannot change for the same file.
|
|
363
|
+
*/
|
|
356
364
|
const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeComments: boolean) {
|
|
357
365
|
const original = yield* (yield* SourceWalker).read(plan.file);
|
|
358
366
|
const { lines, suppressions } = normalized(original.lines, includeComments);
|
|
@@ -364,6 +372,17 @@ const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeC
|
|
|
364
372
|
message: `${plan.file.path}: source changed since planning. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
|
|
365
373
|
});
|
|
366
374
|
}
|
|
375
|
+
let written: string | undefined;
|
|
376
|
+
const dropped = Object.entries(plan.prepared).flatMap(([id, { rule }]) =>
|
|
377
|
+
filtersNeedText(rule) && !contentPasses(rule, (written ??= original.lines.join("\n")))
|
|
378
|
+
? [id]
|
|
379
|
+
: [],
|
|
380
|
+
);
|
|
381
|
+
if (dropped.length > 0) {
|
|
382
|
+
return yield* WalkUnavailable.make({
|
|
383
|
+
message: `${plan.file.path}: source changed since planning, and content filters no longer apply to ${dropped.join(", ")}. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
367
386
|
return { file: { ...plan.file, lines }, original: original.lines } satisfies LoadedFile;
|
|
368
387
|
});
|
|
369
388
|
|
|
@@ -385,6 +404,8 @@ const MAX_DEPENDENT_FILES = 50;
|
|
|
385
404
|
export interface PlanOptions {
|
|
386
405
|
/** Select at most this many files needing judge or cached-locate requests; zero plans only. */
|
|
387
406
|
readonly limit?: number | undefined;
|
|
407
|
+
/** The checkout Jev's paths are relative to. Defaults to the working directory. */
|
|
408
|
+
readonly root?: string | undefined;
|
|
388
409
|
}
|
|
389
410
|
|
|
390
411
|
export const planAudit = (
|
|
@@ -410,9 +431,10 @@ export const planAudit = (
|
|
|
410
431
|
};
|
|
411
432
|
const walker = yield* SourceWalker;
|
|
412
433
|
const files = yield* walker.files;
|
|
413
|
-
// Each file's path from the
|
|
434
|
+
// Each file's path from the checkout, `/`-separated, as Jev reads it.
|
|
435
|
+
const root = options.root ?? process.cwd();
|
|
414
436
|
const fromRoot = new Map(
|
|
415
|
-
files.map((file) => [file.path, relative(
|
|
437
|
+
files.map((file) => [file.path, relative(root, file.path).split(sep).join("/")]),
|
|
416
438
|
);
|
|
417
439
|
// Only the specifiers stay in memory: each file is read again when it is planned.
|
|
418
440
|
const dependentFiles = config.reads.includes("dependents")
|
|
@@ -452,15 +474,17 @@ export const planAudit = (
|
|
|
452
474
|
);
|
|
453
475
|
|
|
454
476
|
// The rules that judge a file: those scoped to it, of which only the rules that say `tests`
|
|
455
|
-
// judge a test,
|
|
456
|
-
|
|
477
|
+
// judge a test, none that an adhere-ignore-file comment names, and none whose own
|
|
478
|
+
// include/exclude filters leave this file out.
|
|
479
|
+
const configuredRules = (file: SourceFile, suppressions: Suppressions, text: () => string) =>
|
|
457
480
|
Record.filter(
|
|
458
481
|
config.scopedRules === undefined
|
|
459
482
|
? config.rules
|
|
460
483
|
: applicableRules(file.path, config.scopedRules),
|
|
461
484
|
(rule, id) =>
|
|
462
485
|
judgesFile(rule, file.test === true) &&
|
|
463
|
-
!suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) }))
|
|
486
|
+
!suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })) &&
|
|
487
|
+
(!hasFilters(rule) || ruleAppliesTo(rule, fromRoot.get(file.path)!, text())),
|
|
464
488
|
);
|
|
465
489
|
|
|
466
490
|
// Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
|
|
@@ -498,7 +522,16 @@ export const planAudit = (
|
|
|
498
522
|
const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
|
|
499
523
|
const original = yield* walker.read(file);
|
|
500
524
|
const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
|
|
501
|
-
|
|
525
|
+
// A rule's content filters read the file as written, comments and all, even
|
|
526
|
+
// when Jev is sent code without them. The decision is kept in `prepared`;
|
|
527
|
+
// reloading rechecks only those filters, in case a comment-only edit changed
|
|
528
|
+
// their answer without changing the code hash.
|
|
529
|
+
let written: string | undefined;
|
|
530
|
+
const rules = configuredRules(
|
|
531
|
+
file,
|
|
532
|
+
suppressions,
|
|
533
|
+
() => (written ??= original.lines.join("\n")),
|
|
534
|
+
);
|
|
502
535
|
const code = lines.join("\n");
|
|
503
536
|
const readState: ReadState = {
|
|
504
537
|
...(workspacePackages === undefined
|
package/src/workflows/format.ts
CHANGED
|
@@ -214,7 +214,7 @@ const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line
|
|
|
214
214
|
/**
|
|
215
215
|
* The warning on a finding whose file may not show enough to check its rule,
|
|
216
216
|
* which its context score in the header says how far, and help: how to add
|
|
217
|
-
* what the code relies on as a note Jev reads, in the form the
|
|
217
|
+
* what the code relies on as a note Jev reads, in the form the docs ask for.
|
|
218
218
|
*/
|
|
219
219
|
const insufficient = (finding: Finding, sufficiencyThreshold: number): ReadonlyArray<Line> =>
|
|
220
220
|
!lacksContext(finding, sufficiencyThreshold)
|