@drkmttr/adhere 0.9.3 → 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 CHANGED
@@ -101,8 +101,12 @@ prints a completion script.
101
101
 
102
102
  ### Before and during a run
103
103
 
104
- Before it sends anything, `lint` says on stderr what it found and what judging
105
- takes:
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 one in the config, or on the command line with
374
- `--preset`, in which case the config file is optional. There are two:
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 answers to rule texts no rule asks, and folds what is left into one
493
- file per content. A run narrowed by `--filter` does not prune. To keep the
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",
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.3",
72
- "@drkmttr/adhere-darwin-x64": "0.9.3",
73
- "@drkmttr/adhere-linux-arm64": "0.9.3",
74
- "@drkmttr/adhere-linux-x64": "0.9.3",
75
- "@drkmttr/adhere-win32-x64": "0.9.3"
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
- const preset = Flag.choice("preset", presetNames).pipe(
65
- Flag.optional,
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
- "Add a built-in rule set, or one of its topics, such as effect/basics. With a preset, .adhere/config.ts is optional.",
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: chosenPresets(input.preset),
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: chosenPresets(input.preset) })),
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 entries = yield* fs
112
- .readDirectory(root, { recursive: true })
113
- .pipe(Effect.mapError(refused(root)));
114
- const files = entries.filter(isNestedRuleFile).sort();
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 still needs from the cache. */
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 fingerprint of each rule text asked about a file. */
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 what `live` does not need, and folds the rest into one file per
88
- * content and per tally. Only after a run that read every file: it cannot
89
- * tell what the files it did not read need. Succeeds with the files deleted.
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 = Record.filter(
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 = live.tallies.has(key) ? yield* readGroup("tallies", key, decodeTally) : [];
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
- /** `readdir(recursive)` yields paths relative to the root it read: root them. */
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 paths, filtered to what the audit reads. */
80
- const listRoot = Effect.fn("SourceWalker.listRoot")(
81
- (fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
82
- Effect.flatMap(fs.readDirectory(root, { recursive: true }), (paths) => {
83
- const files = scannable(root, paths, self, keep);
84
- // The paths listed against the files kept: node_modules shows as the difference.
85
- return Effect.as(
86
- Effect.logDebug(
87
- `listed ${root}: ${paths.length} paths, ${files.length} source files to read`,
88
- ),
89
- files,
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
- /** Read a batch of paths; the inner loop hoisted out of `forEach`. */
103
- const readEach = (fs: FileSystem.FileSystem, paths: ReadonlyArray<string>) =>
104
- Effect.forEach(paths, (path: string) => readFile(fs, path));
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 directory order: read every scannable path. */
140
+ /** One root's files, in path order: read every scannable path. */
107
141
  const readRoot = Effect.fn("SourceWalker.readRoot")(
108
- (fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
109
- Effect.flatMap(listRoot(fs, root, self, keep), (paths) => readEach(fs, paths)),
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: `FileSystem.readDirectory` recursively over the roots,
129
- * each file read and split into lines. Tests substitute their own tree.
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* Effect.forEach(dirs, (dir) =>
144
- readRoot(fs, path.join(root, dir), self, (relative) =>
145
- // Filter patterns are relative to the working directory, not the tree read.
146
- passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
147
- ).pipe(Effect.mapError(refused)),
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
+ });
@@ -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 everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
285
- concurrency: 8,
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 what the files and rules of a run no longer need:
490
- * answers about content no file has, answers to rule texts no rule asks, and
491
- * tallies of rules gone. Only after a run that read every file; a run narrowed
492
- * by `--filter` cannot tell what the files it left out need.
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 read = plan.files.filter((file) => !file.skipped);
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
- hashes: new Set(read.map((file) => file.hash)),
504
- fingerprints: new Set(prepared.map(({ fingerprint }) => fingerprint)),
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`);