@drkmttr/adhere 0.9.0 → 0.9.4
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 +15 -6
- package/bin/adhere.js +9 -0
- package/package.json +6 -6
- package/src/cli.ts +63 -10
- package/src/rules.ts +26 -4
- package/src/services/AuditCache.ts +10 -13
- package/src/services/SourceWalker.ts +80 -28
- package/src/services/Status.ts +31 -0
- package/src/walk.ts +85 -0
- package/src/workflows/audit.ts +28 -13
package/README.md
CHANGED
|
@@ -101,8 +101,12 @@ prints a completion script.
|
|
|
101
101
|
|
|
102
102
|
### Before and during a run
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
Until it knows what judging takes, a status line on stderr says what `lint` is
|
|
105
|
+
doing: looking for `.adhere/` rules, listing and reading the files, then
|
|
106
|
+
planning, each with a count, such as `Planning: 5,120 of 10,333 files`. The
|
|
107
|
+
line is drawn only on a terminal, and not at `--log-level debug` or below,
|
|
108
|
+
whose log lines would land in it. Then `lint` says on stderr what it found and
|
|
109
|
+
what judging takes:
|
|
106
110
|
|
|
107
111
|
```text
|
|
108
112
|
200 files and 14 rules: 2800 checks, 1400 cached.
|
|
@@ -370,8 +374,10 @@ each rule worded otherwise.
|
|
|
370
374
|
### Presets
|
|
371
375
|
|
|
372
376
|
A preset has the shape of a config without `presets`: `rules`, and optionally
|
|
373
|
-
`model` and `threshold`. Name
|
|
374
|
-
`--preset`,
|
|
377
|
+
`model` and `threshold`. Name presets in the config's `presets`, or on the
|
|
378
|
+
command line with `--preset`, repeated or separated by commas, as in
|
|
379
|
+
`--preset effect,alchemy`, in which case the config file is optional. Presets
|
|
380
|
+
named in both places apply together. There are two:
|
|
375
381
|
|
|
376
382
|
- `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
|
|
377
383
|
from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
|
|
@@ -489,8 +495,11 @@ A cache file is written once and never changed: it is also named for the hash
|
|
|
489
495
|
of its own text. Two branches that judge the same code add files rather than
|
|
490
496
|
edit them, so git merges the cache without a conflict. After a run that reads
|
|
491
497
|
every file, adhere prunes the cache: it deletes answers about content no file
|
|
492
|
-
has and
|
|
493
|
-
|
|
498
|
+
has, and folds what is left into one file per content. Answers to rules the
|
|
499
|
+
run left out stay, so a run with other presets or rules, or with one topic,
|
|
500
|
+
loses nothing another run still asks; answers to a rule's old texts stay too,
|
|
501
|
+
until the content they are about is gone. A run narrowed by `--filter` does not
|
|
502
|
+
prune. To keep the
|
|
494
503
|
cache out of diffs, mark it as generated in `.gitattributes`:
|
|
495
504
|
|
|
496
505
|
```text
|
package/bin/adhere.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
|
+
import { constants } from "node:os";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
|
|
13
14
|
const platform = `${process.platform}-${process.arch}`;
|
|
@@ -26,6 +27,14 @@ const resolveExecutable = () => {
|
|
|
26
27
|
const run = (command, commandArgs) => {
|
|
27
28
|
const result = spawnSync(command, commandArgs, { stdio: "inherit" });
|
|
28
29
|
if (result.error) throw result.error;
|
|
30
|
+
if (result.signal) {
|
|
31
|
+
// A killed process says nothing itself, so say what killed it.
|
|
32
|
+
const memory =
|
|
33
|
+
result.signal === "SIGKILL" ? ", usually the system ending it for running out of memory" : "";
|
|
34
|
+
console.error(`adhere: the executable was killed by ${result.signal}${memory}.`);
|
|
35
|
+
process.exitCode = 128 + (constants.signals[result.signal] ?? 0);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
29
38
|
process.exitCode = result.status ?? 1;
|
|
30
39
|
};
|
|
31
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
-
"@drkmttr/adhere-darwin-arm64": "0.9.
|
|
72
|
-
"@drkmttr/adhere-darwin-x64": "0.9.
|
|
73
|
-
"@drkmttr/adhere-linux-arm64": "0.9.
|
|
74
|
-
"@drkmttr/adhere-linux-x64": "0.9.
|
|
75
|
-
"@drkmttr/adhere-win32-x64": "0.9.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.9.4",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.9.4",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.9.4",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.9.4",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.9.4"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AuditCacheLive } from "#services/AuditCache.ts";
|
|
|
3
3
|
import { Credentials, CredentialsLive, CredentialsUnavailable } from "#services/Credentials.ts";
|
|
4
4
|
import { JevLive } from "#services/Jev.http.ts";
|
|
5
5
|
import { SourceWalkerLive } from "#services/SourceWalker.ts";
|
|
6
|
+
import { Status } from "#services/Status.ts";
|
|
6
7
|
import {
|
|
7
8
|
type AuditPlan,
|
|
8
9
|
executeAudit,
|
|
@@ -61,10 +62,31 @@ class ContradictionsReported extends Schema.TaggedError<ContradictionsReported>(
|
|
|
61
62
|
readonly [Runtime.errorReported] = false;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
/** The names each `--preset` gives, split at commas: `--preset effect,alchemy` gives two. */
|
|
66
|
+
const presetsIn = (values: ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
67
|
+
values
|
|
68
|
+
.flatMap((value) => value.split(","))
|
|
69
|
+
.map((name) => name.trim())
|
|
70
|
+
.filter((name) => name.length > 0);
|
|
71
|
+
|
|
72
|
+
const isPresetName = (name: string): name is PresetName =>
|
|
73
|
+
(presetNames as ReadonlyArray<string>).includes(name);
|
|
74
|
+
|
|
75
|
+
const preset = Flag.string("preset").pipe(
|
|
76
|
+
Flag.atLeast(0),
|
|
77
|
+
Flag.filterMap(
|
|
78
|
+
(values): Option.Option<ReadonlyArray<PresetName>> => {
|
|
79
|
+
const names = presetsIn(values);
|
|
80
|
+
return names.every(isPresetName) ? Option.some([...new Set(names)]) : Option.none();
|
|
81
|
+
},
|
|
82
|
+
// The CLI puts this after "Expected:".
|
|
83
|
+
(values) => {
|
|
84
|
+
const unknown = presetsIn(values).filter((name) => !isPresetName(name));
|
|
85
|
+
return `one of ${presetNames.join(", ")}, or several separated by commas (not ${unknown.join(", ")})`;
|
|
86
|
+
},
|
|
87
|
+
),
|
|
66
88
|
Flag.withDescription(
|
|
67
|
-
|
|
89
|
+
`Add built-in rule sets, or their topics, such as effect/basics. Repeat the flag or separate names with commas, as in --preset effect,alchemy. With a preset, .adhere/config.ts is optional. (choices: ${presetNames.join(", ")})`,
|
|
68
90
|
),
|
|
69
91
|
);
|
|
70
92
|
|
|
@@ -141,20 +163,51 @@ const counterFor = (plan: AuditPlan, logging: LogLevel.LogLevel) => {
|
|
|
141
163
|
};
|
|
142
164
|
};
|
|
143
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The status line for what a run does before its plan: walking the checkout,
|
|
168
|
+
* reading files, planning. On a terminal it is one line of stderr, redrawn at
|
|
169
|
+
* most every 100 ms and cut to the terminal's width; anywhere else, and at
|
|
170
|
+
* debug or below, whose log lines would land in it, it shows nothing.
|
|
171
|
+
*/
|
|
172
|
+
const statusLayer = Layer.effect(Status)(
|
|
173
|
+
Effect.gen(function* () {
|
|
174
|
+
const logging = yield* References.MinimumLogLevel;
|
|
175
|
+
if (process.stderr.isTTY !== true || !LogLevel.isGreaterThan(logging, "Debug")) {
|
|
176
|
+
return Status.defaultValue();
|
|
177
|
+
}
|
|
178
|
+
let drawn = 0;
|
|
179
|
+
let shown = false;
|
|
180
|
+
return {
|
|
181
|
+
show: (text: string) =>
|
|
182
|
+
Effect.sync(() => {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (now - drawn < 100) return;
|
|
185
|
+
drawn = now;
|
|
186
|
+
shown = true;
|
|
187
|
+
// A terminal that reports no width, as some report 0, gets 80 columns.
|
|
188
|
+
const width = (process.stderr.columns || 80) - 1;
|
|
189
|
+
process.stderr.write(`\r\u001b[2K${text.slice(0, width)}`);
|
|
190
|
+
}),
|
|
191
|
+
clear: Effect.sync(() => {
|
|
192
|
+
if (shown) process.stderr.write("\r\u001b[2K");
|
|
193
|
+
shown = false;
|
|
194
|
+
drawn = 0;
|
|
195
|
+
}),
|
|
196
|
+
};
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
144
200
|
const force = Flag.boolean("force").pipe(
|
|
145
201
|
Flag.withDefault(false),
|
|
146
202
|
Flag.withDescription("Overwrite existing scaffold files."),
|
|
147
203
|
);
|
|
148
204
|
|
|
149
|
-
const chosenPresets = (flag: Option.Option<PresetName>): ReadonlyArray<PresetName> =>
|
|
150
|
-
Option.isSome(flag) ? [flag.value] : [];
|
|
151
|
-
|
|
152
205
|
export const auditLayer = (overrides: Overrides, filter: ReadonlyArray<string> = []) =>
|
|
153
206
|
Layer.mergeAll(
|
|
154
207
|
SourceWalkerLive(filter),
|
|
155
208
|
AuditCacheLive,
|
|
156
209
|
JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
|
|
157
|
-
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
210
|
+
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
|
|
158
211
|
|
|
159
212
|
/** `adhere lint`: the audit. */
|
|
160
213
|
export const lintCommand = Command.make(
|
|
@@ -200,7 +253,7 @@ export const lintCommand = Command.make(
|
|
|
200
253
|
Command.provide((input) =>
|
|
201
254
|
auditLayer(
|
|
202
255
|
{
|
|
203
|
-
presets:
|
|
256
|
+
presets: input.preset,
|
|
204
257
|
threshold: Option.getOrUndefined(input.threshold),
|
|
205
258
|
rpm: Option.getOrUndefined(input.rpm),
|
|
206
259
|
},
|
|
@@ -214,7 +267,7 @@ export const validateLayer = (overrides: Overrides) =>
|
|
|
214
267
|
Layer.merge(
|
|
215
268
|
JevLive.pipe(Layer.provide([FetchHttpClient.layer, CredentialsLive])),
|
|
216
269
|
AuditCacheLive,
|
|
217
|
-
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)));
|
|
270
|
+
).pipe(Layer.provideMerge(AdhereConfigLive(overrides)), Layer.provideMerge(statusLayer));
|
|
218
271
|
|
|
219
272
|
/**
|
|
220
273
|
* `adhere validate`: everything `lint` loads, then how each rule's wording
|
|
@@ -250,7 +303,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
|
|
|
250
303
|
Command.withDescription(
|
|
251
304
|
"Load the config, rule files, and presets the way lint does, check each rule's wording against the README's rule writing tips, report the rules lint's linter check flags, then ask Jev whether any two rules that apply to the same files contradict each other.",
|
|
252
305
|
),
|
|
253
|
-
Command.provide((input) => validateLayer({ presets:
|
|
306
|
+
Command.provide((input) => validateLayer({ presets: input.preset })),
|
|
254
307
|
);
|
|
255
308
|
|
|
256
309
|
/** `adhere init`: the scaffold. */
|
package/src/rules.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
SKIPPED_DIRECTORIES,
|
|
7
7
|
} from "#config.ts";
|
|
8
8
|
import { parseRuleMarkdown } from "#markdown.ts";
|
|
9
|
+
import { walkFiles } from "#walk.ts";
|
|
10
|
+
import { clearingStatus, countOf, Status } from "#services/Status.ts";
|
|
9
11
|
import { Effect, FileSystem, Path, Record } from "effect";
|
|
10
12
|
|
|
11
13
|
const refused = (directory: string) => (problem: { readonly message: string }) =>
|
|
@@ -36,6 +38,21 @@ const segmentsOf = (relative: string): ReadonlyArray<string> =>
|
|
|
36
38
|
|
|
37
39
|
const isMarkdown = (path: string): boolean => path.endsWith(".md");
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Whether the rule walk reads into a directory: on the way to a `.adhere/`,
|
|
43
|
+
* not a skipped tree or `.git`; inside one, anything but its cache, since a
|
|
44
|
+
* directory there such as `.adhere/e2e/` is a rule topic.
|
|
45
|
+
*/
|
|
46
|
+
const entersForRules = (relative: string): boolean => {
|
|
47
|
+
const segments = segmentsOf(relative);
|
|
48
|
+
const name = segments.at(-1) ?? "";
|
|
49
|
+
const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
|
|
50
|
+
if (adhere >= 0 && adhere < segments.length - 1) {
|
|
51
|
+
return !(adhere === segments.length - 2 && name === CACHE_SEGMENT);
|
|
52
|
+
}
|
|
53
|
+
return name === ADHERE_SEGMENT || !(SKIPPED_DIRECTORIES.has(name) || name === ".git");
|
|
54
|
+
};
|
|
55
|
+
|
|
39
56
|
const isNestedRuleFile = (relative: string): boolean => {
|
|
40
57
|
const segments = segmentsOf(relative);
|
|
41
58
|
const adhere = segments.lastIndexOf(ADHERE_SEGMENT);
|
|
@@ -108,10 +125,15 @@ export const loadRules = Effect.fn("loadRules")(function* (directory: string) {
|
|
|
108
125
|
export const loadAdhereRuleSet = Effect.fn("loadAdhereRuleSet")(function* (root: string) {
|
|
109
126
|
const fs = yield* FileSystem.FileSystem;
|
|
110
127
|
const path = yield* Path.Path;
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
128
|
+
const status = yield* Status;
|
|
129
|
+
const entries = yield* clearingStatus(
|
|
130
|
+
walkFiles(fs, path, root, entersForRules, (directories) =>
|
|
131
|
+
status.show(
|
|
132
|
+
`Looking for .adhere/ rules: ${countOf(directories, "directory", "directories")} read`,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
).pipe(Effect.mapError(refused(root)));
|
|
136
|
+
const files = entries.filter(isNestedRuleFile);
|
|
115
137
|
return yield* Effect.forEach(files, (relative) =>
|
|
116
138
|
Effect.gen(function* () {
|
|
117
139
|
const file = joinPath(path, root, segmentsOf(relative));
|
|
@@ -45,13 +45,11 @@ export const Tally = Schema.Struct({
|
|
|
45
45
|
export interface Tally extends Schema.Schema.Type<typeof Tally> {}
|
|
46
46
|
const decodeTally = Schema.decodeUnknownOption(Schema.fromJsonString(Tally));
|
|
47
47
|
|
|
48
|
-
/** What a run that read every file
|
|
48
|
+
/** What a run that read every file tells the cache. */
|
|
49
49
|
export interface Live {
|
|
50
|
-
/** The hash of each file's content. */
|
|
50
|
+
/** The hash of each file's content, too long to judge or not. */
|
|
51
51
|
readonly hashes: ReadonlySet<string>;
|
|
52
|
-
/** The
|
|
53
|
-
readonly fingerprints: ReadonlySet<string>;
|
|
54
|
-
/** The key of each rule's tally. */
|
|
52
|
+
/** The key of each of the run's rules' tallies, to carry over from the layout before content keys. */
|
|
55
53
|
readonly tallies: ReadonlySet<string>;
|
|
56
54
|
}
|
|
57
55
|
|
|
@@ -84,9 +82,11 @@ export class AuditCache extends Context.Service<
|
|
|
84
82
|
readonly tally: (key: string) => Effect.Effect<Tally | undefined>;
|
|
85
83
|
readonly putTally: (key: string, tally: Tally) => Effect.Effect<void>;
|
|
86
84
|
/**
|
|
87
|
-
* Deletes
|
|
88
|
-
* content and per tally.
|
|
89
|
-
*
|
|
85
|
+
* Deletes the answers about content no file has, and folds the rest into
|
|
86
|
+
* one file per content and per tally. Answers to rules the run left out
|
|
87
|
+
* stay, as do their tallies: a run with other presets or rules asks them.
|
|
88
|
+
* Only after a run that read every file: it cannot tell what content the
|
|
89
|
+
* files it did not read have. Succeeds with the files deleted.
|
|
90
90
|
*/
|
|
91
91
|
readonly prune: (live: Live) => Effect.Effect<number>;
|
|
92
92
|
}
|
|
@@ -275,10 +275,7 @@ export const AuditCacheLive = Layer.effect(AuditCache)(
|
|
|
275
275
|
// Folding replaces or removes the group being visited, which a Map's iteration allows.
|
|
276
276
|
for (const group of (yield* indexes.files).keys()) {
|
|
277
277
|
const entries = live.hashes.has(group) ? yield* readGroup("files", group, decodeEntry) : [];
|
|
278
|
-
const answers =
|
|
279
|
-
unionOf(entries.map((entry) => entry.answers)),
|
|
280
|
-
(_, fingerprint) => live.fingerprints.has(fingerprint),
|
|
281
|
-
);
|
|
278
|
+
const answers = unionOf(entries.map((entry) => entry.answers));
|
|
282
279
|
deleted += yield* fold(
|
|
283
280
|
"files",
|
|
284
281
|
group,
|
|
@@ -286,7 +283,7 @@ export const AuditCacheLive = Layer.effect(AuditCache)(
|
|
|
286
283
|
);
|
|
287
284
|
}
|
|
288
285
|
for (const key of (yield* indexes.tallies).keys()) {
|
|
289
|
-
const tallies =
|
|
286
|
+
const tallies = yield* readGroup("tallies", key, decodeTally);
|
|
290
287
|
deleted += yield* fold(
|
|
291
288
|
"tallies",
|
|
292
289
|
key,
|
|
@@ -3,6 +3,8 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect";
|
|
|
3
3
|
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
|
|
4
4
|
import type { ScannedFile } from "#models/Audit.ts";
|
|
5
5
|
import { WalkUnavailable } from "#models/Audit.ts";
|
|
6
|
+
import { walkFiles } from "#walk.ts";
|
|
7
|
+
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
6
8
|
|
|
7
9
|
/** Walks the repository's TypeScript source, skipping what is not ours to read. */
|
|
8
10
|
export class SourceWalker extends Context.Service<
|
|
@@ -63,9 +65,15 @@ const isScannable = (file: string, self: string): boolean =>
|
|
|
63
65
|
const refused = (problem: { readonly message: string }): WalkUnavailable =>
|
|
64
66
|
WalkUnavailable.make({ message: problem.message });
|
|
65
67
|
|
|
66
|
-
/**
|
|
68
|
+
/** The walk yields paths relative to the root it read: root them. */
|
|
67
69
|
const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) => `${root}/${rel}`);
|
|
68
70
|
|
|
71
|
+
/** Whether the walk reads into a directory: never a skipped tree, `.adhere/`, or `.git`. */
|
|
72
|
+
const entersForSource = (relative: string): boolean => {
|
|
73
|
+
const name = relative.split("/").at(-1) ?? "";
|
|
74
|
+
return !(SKIPPED_DIRECTORIES.has(name) || name === ADHERE_DIRECTORY || name === ".git");
|
|
75
|
+
};
|
|
76
|
+
|
|
69
77
|
/** A test on a path relative to the root being read: whether the run wants it. */
|
|
70
78
|
type Keep = (relative: string) => boolean;
|
|
71
79
|
|
|
@@ -76,20 +84,27 @@ const scannable = (root: string, paths: ReadonlyArray<string>, self: string, kee
|
|
|
76
84
|
paths.filter((relative) => !isInSkippedTree(relative) && keep(relative)),
|
|
77
85
|
).filter((file) => isScannable(file, self));
|
|
78
86
|
|
|
79
|
-
/** One root's
|
|
80
|
-
const listRoot = Effect.fn("SourceWalker.listRoot")(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
87
|
+
/** One root's files, filtered to what the audit reads. `label` names the root on the status line. */
|
|
88
|
+
const listRoot = Effect.fn("SourceWalker.listRoot")(function* (
|
|
89
|
+
fs: FileSystem.FileSystem,
|
|
90
|
+
path: Path.Path,
|
|
91
|
+
root: string,
|
|
92
|
+
label: string,
|
|
93
|
+
self: string,
|
|
94
|
+
keep: Keep,
|
|
95
|
+
) {
|
|
96
|
+
const status = yield* Status;
|
|
97
|
+
const paths = yield* walkFiles(fs, path, root, entersForSource, (directories, found) =>
|
|
98
|
+
status.show(
|
|
99
|
+
`Listing ${label}: ${countOf(directories, "directory", "directories")}, ${countOf(found, "file", "files")}`,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
const files = scannable(root, paths, self, keep);
|
|
103
|
+
yield* Effect.logDebug(
|
|
104
|
+
`listed ${root}: ${paths.length} files, ${files.length} source files to read`,
|
|
105
|
+
);
|
|
106
|
+
return files;
|
|
107
|
+
});
|
|
93
108
|
|
|
94
109
|
/** One scannable path, read into lines. */
|
|
95
110
|
const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem, path: string) =>
|
|
@@ -99,14 +114,42 @@ const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem,
|
|
|
99
114
|
})).pipe(Effect.tap((file) => Effect.logTrace(`read ${path}: ${file.lines.length} lines`))),
|
|
100
115
|
);
|
|
101
116
|
|
|
102
|
-
/**
|
|
103
|
-
const readEach = (
|
|
104
|
-
|
|
117
|
+
/** Reads a root's paths, several at a time, keeping their order, and counts them on the status line. */
|
|
118
|
+
const readEach = Effect.fn("SourceWalker.readEach")(function* (
|
|
119
|
+
fs: FileSystem.FileSystem,
|
|
120
|
+
label: string,
|
|
121
|
+
paths: ReadonlyArray<string>,
|
|
122
|
+
) {
|
|
123
|
+
const status = yield* Status;
|
|
124
|
+
let read = 0;
|
|
125
|
+
return yield* Effect.forEach(
|
|
126
|
+
paths,
|
|
127
|
+
(path: string) =>
|
|
128
|
+
readFile(fs, path).pipe(
|
|
129
|
+
Effect.tap(() => {
|
|
130
|
+
read += 1;
|
|
131
|
+
return status.show(
|
|
132
|
+
`Reading ${label}: ${counted(read)} of ${countOf(paths.length, "file", "files")}`,
|
|
133
|
+
);
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
{ concurrency: 16 },
|
|
137
|
+
);
|
|
138
|
+
});
|
|
105
139
|
|
|
106
|
-
/** One root's files, in
|
|
140
|
+
/** One root's files, in path order: read every scannable path. */
|
|
107
141
|
const readRoot = Effect.fn("SourceWalker.readRoot")(
|
|
108
|
-
(
|
|
109
|
-
|
|
142
|
+
(
|
|
143
|
+
fs: FileSystem.FileSystem,
|
|
144
|
+
path: Path.Path,
|
|
145
|
+
root: string,
|
|
146
|
+
label: string,
|
|
147
|
+
self: string,
|
|
148
|
+
keep: Keep,
|
|
149
|
+
) =>
|
|
150
|
+
Effect.flatMap(listRoot(fs, path, root, label, self, keep), (paths) =>
|
|
151
|
+
readEach(fs, label, paths),
|
|
152
|
+
),
|
|
110
153
|
);
|
|
111
154
|
|
|
112
155
|
/**
|
|
@@ -125,8 +168,9 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
|
|
|
125
168
|
});
|
|
126
169
|
|
|
127
170
|
/**
|
|
128
|
-
* The real walker:
|
|
129
|
-
* each file read and split into lines. Tests substitute their
|
|
171
|
+
* The real walker: the roots walked a directory at a time, skipped trees
|
|
172
|
+
* left unread, each file read and split into lines. Tests substitute their
|
|
173
|
+
* own tree.
|
|
130
174
|
* The scan root is the current working directory, so the audit reads the
|
|
131
175
|
* repository it is run in.
|
|
132
176
|
*/
|
|
@@ -140,11 +184,19 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
140
184
|
const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
|
|
141
185
|
const walk = Effect.fn("SourceWalker.files")(function* () {
|
|
142
186
|
const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
|
|
143
|
-
const trees = yield*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
187
|
+
const trees = yield* clearingStatus(
|
|
188
|
+
Effect.forEach(dirs, (dir) =>
|
|
189
|
+
readRoot(
|
|
190
|
+
fs,
|
|
191
|
+
path,
|
|
192
|
+
path.join(root, dir),
|
|
193
|
+
dir === "." ? "the working directory" : `${dir}/`,
|
|
194
|
+
self,
|
|
195
|
+
(relative) =>
|
|
196
|
+
// Filter patterns are relative to the working directory, not the tree read.
|
|
197
|
+
passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
|
|
198
|
+
).pipe(Effect.mapError(refused)),
|
|
199
|
+
),
|
|
148
200
|
);
|
|
149
201
|
return trees.flat();
|
|
150
202
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface StatusLine {
|
|
4
|
+
/** Shows what the run is doing now, in place of what was shown before. */
|
|
5
|
+
readonly show: (text: string) => Effect.Effect<void>;
|
|
6
|
+
/** Takes the line away, before anything else is written. */
|
|
7
|
+
readonly clear: Effect.Effect<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a run is doing before it has anything to report: walking the
|
|
12
|
+
* checkout, reading files, planning. The CLI draws it as one line on
|
|
13
|
+
* stderr; wherever nothing provides it, as in tests, it shows nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const Status = Context.Reference<StatusLine>("@drkmttr/adhere/services/Status", {
|
|
16
|
+
defaultValue: () => ({ show: () => Effect.void, clear: Effect.void }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** `effect`, with the status line cleared when it ends, however it ends. */
|
|
20
|
+
export const clearingStatus = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const status = yield* Status;
|
|
23
|
+
return yield* Effect.ensuring(effect, status.clear);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** A count as the status line shows it: 10,333. */
|
|
27
|
+
export const counted = (n: number): string => n.toLocaleString("en-US");
|
|
28
|
+
|
|
29
|
+
/** A count and its noun: 1 directory, 2,679 directories. */
|
|
30
|
+
export const countOf = (n: number, one: string, many: string): string =>
|
|
31
|
+
`${counted(n)} ${n === 1 ? one : many}`;
|
package/src/walk.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Effect, type FileSystem, type Path, type PlatformError } from "effect";
|
|
2
|
+
|
|
3
|
+
/** A directory entry, as the walk sorts it: a file to list, a directory to read, or neither. */
|
|
4
|
+
type Entry =
|
|
5
|
+
| { readonly kind: "file"; readonly relative: string }
|
|
6
|
+
| { readonly kind: "directory"; readonly relative: string }
|
|
7
|
+
| { readonly kind: "skipped" };
|
|
8
|
+
|
|
9
|
+
const skipped: Entry = { kind: "skipped" };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every file under `root`, each as a path relative to it with `/` between
|
|
13
|
+
* segments, sorted. `enter` says, of each directory below the root by its
|
|
14
|
+
* relative path, whether to read into it, so a walk never lists what it would
|
|
15
|
+
* throw away.
|
|
16
|
+
*
|
|
17
|
+
* A directory reached through a symbolic link is never entered: pnpm's
|
|
18
|
+
* `node_modules` links packages into one another, and following the links
|
|
19
|
+
* lists every path through the dependency graph. A recursive `readdir` does
|
|
20
|
+
* follow them; on alchemy's checkout it passed 160 GB before the kernel
|
|
21
|
+
* killed it. A symbolic link to a file is listed like the file, and a link
|
|
22
|
+
* to nothing is left out.
|
|
23
|
+
*
|
|
24
|
+
* `progress` hears, as each directory is read, how many have been and how
|
|
25
|
+
* many files they held.
|
|
26
|
+
*/
|
|
27
|
+
export const walkFiles = Effect.fn("walkFiles")(function* (
|
|
28
|
+
fs: FileSystem.FileSystem,
|
|
29
|
+
path: Path.Path,
|
|
30
|
+
root: string,
|
|
31
|
+
enter: (relative: string) => boolean,
|
|
32
|
+
progress: (directories: number, files: number) => Effect.Effect<void> = () => Effect.void,
|
|
33
|
+
) {
|
|
34
|
+
const entryOf = (relative: string): Effect.Effect<Entry, PlatformError.PlatformError> => {
|
|
35
|
+
const full = path.join(root, relative);
|
|
36
|
+
return fs.stat(full).pipe(
|
|
37
|
+
Effect.flatMap((info): Effect.Effect<Entry> => {
|
|
38
|
+
if (info.type !== "Directory") return Effect.succeed({ kind: "file", relative });
|
|
39
|
+
if (!enter(relative)) return Effect.succeed(skipped);
|
|
40
|
+
// Only a link can be read as one, so a failed read is a real directory.
|
|
41
|
+
return fs.readLink(full).pipe(
|
|
42
|
+
Effect.match({
|
|
43
|
+
onFailure: (): Entry => ({ kind: "directory", relative }),
|
|
44
|
+
onSuccess: (): Entry => skipped,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}),
|
|
48
|
+
Effect.catchIf(
|
|
49
|
+
(problem) => problem.reason._tag === "NotFound",
|
|
50
|
+
() => Effect.succeed(skipped),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const files: Array<string> = [];
|
|
56
|
+
let directories = 0;
|
|
57
|
+
let level: ReadonlyArray<string> = [""];
|
|
58
|
+
while (level.length > 0) {
|
|
59
|
+
const next: Array<string> = [];
|
|
60
|
+
yield* Effect.forEach(
|
|
61
|
+
level,
|
|
62
|
+
(directory) =>
|
|
63
|
+
fs.readDirectory(directory === "" ? root : path.join(root, directory)).pipe(
|
|
64
|
+
Effect.flatMap((names) =>
|
|
65
|
+
Effect.forEach(
|
|
66
|
+
names,
|
|
67
|
+
(name) => entryOf(directory === "" ? name : `${directory}/${name}`),
|
|
68
|
+
{ concurrency: 16 },
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
Effect.flatMap((entries) => {
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (entry.kind === "file") files.push(entry.relative);
|
|
74
|
+
else if (entry.kind === "directory") next.push(entry.relative);
|
|
75
|
+
}
|
|
76
|
+
directories += 1;
|
|
77
|
+
return progress(directories, files.length);
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
{ concurrency: 8, discard: true },
|
|
81
|
+
);
|
|
82
|
+
level = next;
|
|
83
|
+
}
|
|
84
|
+
return files.sort();
|
|
85
|
+
});
|
package/src/workflows/audit.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
tokensOf,
|
|
17
17
|
} from "#services/Jev.ts";
|
|
18
18
|
import { SourceWalker } from "#services/SourceWalker.ts";
|
|
19
|
+
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
19
20
|
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
20
21
|
import { applicableRules } from "#rules.ts";
|
|
21
22
|
import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
|
|
@@ -174,13 +175,15 @@ export const planAudit = (
|
|
|
174
175
|
|
|
175
176
|
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
176
177
|
const rules = configuredRules(file.path);
|
|
178
|
+
// Hashed even when skipped, so a prune keeps what other runs know of its content.
|
|
179
|
+
const hash = yield* sha256(file.lines.join("\n"));
|
|
177
180
|
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
178
181
|
if (!fits(file.lines, rules)) {
|
|
179
182
|
yield* Effect.logTrace(`plan ${file.path}: too long for Jev's context, skipped`);
|
|
180
183
|
return {
|
|
181
184
|
file,
|
|
182
185
|
skipped: true,
|
|
183
|
-
hash
|
|
186
|
+
hash,
|
|
184
187
|
prepared: {},
|
|
185
188
|
kept: {},
|
|
186
189
|
pending: {},
|
|
@@ -197,7 +200,6 @@ export const planAudit = (
|
|
|
197
200
|
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
198
201
|
),
|
|
199
202
|
).pipe(Effect.map(Record.fromEntries));
|
|
200
|
-
const hash = yield* sha256(file.lines.join("\n"));
|
|
201
203
|
const answers = yield* cache.get(hash, file.path);
|
|
202
204
|
const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
|
|
203
205
|
const answer = answers[fingerprint];
|
|
@@ -281,9 +283,23 @@ export const planAudit = (
|
|
|
281
283
|
});
|
|
282
284
|
|
|
283
285
|
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
286
|
+
const status = yield* Status;
|
|
287
|
+
let done = 0;
|
|
288
|
+
const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
|
|
289
|
+
Effect.forEach(
|
|
290
|
+
sorted,
|
|
291
|
+
(file) =>
|
|
292
|
+
planFile(file).pipe(
|
|
293
|
+
Effect.tap(() => {
|
|
294
|
+
done += 1;
|
|
295
|
+
return status.show(
|
|
296
|
+
`Planning: ${counted(done)} of ${countOf(sorted.length, "file", "files")}`,
|
|
297
|
+
);
|
|
298
|
+
}),
|
|
299
|
+
),
|
|
300
|
+
{ concurrency: 8 },
|
|
301
|
+
),
|
|
302
|
+
);
|
|
287
303
|
const limited =
|
|
288
304
|
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
289
305
|
const planned = yield* withSamples(limited);
|
|
@@ -486,22 +502,21 @@ export const executeAudit = (
|
|
|
486
502
|
});
|
|
487
503
|
|
|
488
504
|
/**
|
|
489
|
-
* Deletes from the cache
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
505
|
+
* Deletes from the cache the answers about content no file has anymore.
|
|
506
|
+
* Answers to rules the run left out stay, since a run with other presets or
|
|
507
|
+
* rules asks them. Only after a run that read every file; a run narrowed by
|
|
508
|
+
* `--filter` cannot tell what content the files it left out have.
|
|
493
509
|
*/
|
|
494
510
|
export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
495
511
|
const config = yield* AdhereConfig;
|
|
496
512
|
const cache = yield* AuditCache;
|
|
497
|
-
const
|
|
498
|
-
const prepared = read.flatMap((file) => Object.values(file.prepared));
|
|
513
|
+
const prepared = plan.files.flatMap((file) => Object.values(file.prepared));
|
|
499
514
|
const tallies = yield* Effect.forEach(new Set(prepared.map(({ rule }) => rule)), (rule) =>
|
|
500
515
|
tallyKeyOf(config.model, rule),
|
|
501
516
|
);
|
|
502
517
|
const deleted = yield* cache.prune({
|
|
503
|
-
|
|
504
|
-
|
|
518
|
+
// A file too long to judge counts: a run with shorter questions judges it.
|
|
519
|
+
hashes: new Set(plan.files.map((file) => file.hash)),
|
|
505
520
|
tallies: new Set(tallies),
|
|
506
521
|
});
|
|
507
522
|
yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
|