@drkmttr/adhere 0.6.1 → 0.7.1
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 +77 -31
- package/package.json +7 -7
- package/presets/effect/basics/external-calls-are-resilient.md +6 -2
- package/presets/effect/basics/fn-for-named-effects.md +10 -2
- package/presets/effect/basics/gen-for-sequencing.md +10 -2
- package/presets/effect/basics/instrument-with-pipe.md +9 -2
- package/presets/effect/cli/handlers-delegate.md +13 -2
- package/presets/effect/config/business-logic-depends-on-config-service.md +9 -2
- package/presets/effect/config/secrets-are-redacted.md +6 -2
- package/presets/effect/config/tests-provide-values-directly.md +8 -2
- package/presets/effect/config/validate-with-schema.md +9 -2
- package/presets/effect/data/brand-meaningful-primitives.md +11 -2
- package/presets/effect/data/decode-json-with-schema.md +6 -2
- package/presets/effect/data/records-are-schema-classes.md +9 -2
- package/presets/effect/data/variants-are-tagged-unions.md +9 -2
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
- package/presets/effect/errors/defects-for-bugs.md +13 -2
- package/presets/effect/errors/domain-errors-are-tagged.md +6 -2
- package/presets/effect/errors/wrap-external-errors.md +7 -2
- package/presets/effect/platform/os-access-through-platform-services.md +8 -2
- package/presets/effect/services/fresh-layer-per-test.md +9 -2
- package/presets/effect/services/memoize-parameterized-layers.md +9 -2
- package/presets/effect/services/methods-have-no-requirements.md +9 -2
- package/presets/effect/services/no-mutable-state.md +9 -2
- package/presets/effect/services/provide-at-entry.md +7 -2
- package/presets/effect/services/test-layers-are-in-memory.md +12 -2
- package/presets/effect/testing/test-clock-for-time.md +11 -2
- package/presets/effect/testing/test-random.md +9 -2
- package/skills/adhere/SKILL.md +24 -17
- package/src/cli.ts +71 -36
- package/src/config.ts +72 -13
- package/src/init.ts +10 -8
- package/src/markdown.ts +59 -25
- package/src/services/Jev.http.ts +27 -5
- package/src/services/Jev.ts +45 -31
- package/src/services/SourceWalker.ts +47 -23
- package/src/workflows/audit.ts +108 -61
- package/src/workflows/format.ts +51 -8
package/src/markdown.ts
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
|
-
import { ConfigUnavailable, type Rule } from "#config.ts";
|
|
1
|
+
import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
|
|
2
2
|
import { Effect, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A rule as a Markdown file:
|
|
6
6
|
*
|
|
7
7
|
* ---
|
|
8
|
-
* description: One sentence
|
|
8
|
+
* description: One sentence saying what code must be, or never be.
|
|
9
9
|
* threshold: 0.8
|
|
10
10
|
* ---
|
|
11
11
|
* Any prose, rendered on GitHub and ignored by adhere.
|
|
12
12
|
*
|
|
13
|
-
* ```ts
|
|
14
|
-
* const
|
|
13
|
+
* ```ts must
|
|
14
|
+
* const written = "code that must be written this way"
|
|
15
15
|
* ```
|
|
16
16
|
*
|
|
17
|
-
* ```ts
|
|
18
|
-
* const avoided = "
|
|
17
|
+
* ```ts never
|
|
18
|
+
* const avoided = "code that must never be written this way"
|
|
19
19
|
* ```
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* fence
|
|
21
|
+
* A fence's info string names its code with RFC 2119's words: `must` and
|
|
22
|
+
* `never` for a requirement, `should` and `should not` for a guideline. The
|
|
23
|
+
* first fence under each word is that example, and a rule has either or both.
|
|
24
|
+
* An untagged fence is the code to write, and without a fence the whole body
|
|
25
|
+
* is. `avoid`, the word before 0.7, means `never`. `threshold` is optional.
|
|
24
26
|
*/
|
|
25
27
|
const FrontMatter = Schema.Struct({
|
|
26
28
|
description: Schema.String,
|
|
@@ -29,7 +31,6 @@ const FrontMatter = Schema.Struct({
|
|
|
29
31
|
|
|
30
32
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
31
33
|
const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
|
|
32
|
-
const AVOID = "avoid";
|
|
33
34
|
|
|
34
35
|
const unquote = (value: string): string => {
|
|
35
36
|
const trimmed = value.trim();
|
|
@@ -50,17 +51,27 @@ const parseFrontMatter = (block: string): Record<string, string> => {
|
|
|
50
51
|
return fields;
|
|
51
52
|
};
|
|
52
53
|
|
|
54
|
+
type Word = Example["word"];
|
|
55
|
+
|
|
53
56
|
interface Fence {
|
|
54
|
-
|
|
57
|
+
/** The word the info string names the code with; none for an untagged fence. */
|
|
58
|
+
readonly word: Word | undefined;
|
|
55
59
|
readonly code: string;
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
/** `must not`, like `never` and the older `avoid`, names code never to write. */
|
|
63
|
+
const wordOf = (info: string): Word | undefined => {
|
|
64
|
+
const words = info.trim().toLowerCase().split(/\s+/);
|
|
65
|
+
const not = words.includes("not");
|
|
66
|
+
if (words.includes("never") || words.includes("avoid")) return "never";
|
|
67
|
+
if (words.includes("should")) return not ? "should not" : "should";
|
|
68
|
+
if (words.includes("must")) return not ? "never" : "must";
|
|
69
|
+
return undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
58
72
|
const fencesOf = (body: string): ReadonlyArray<Fence> =>
|
|
59
73
|
Array.from(body.matchAll(FENCE), (match) => ({
|
|
60
|
-
|
|
61
|
-
.trim()
|
|
62
|
-
.split(/\s+/)
|
|
63
|
-
.some((word) => word.toLowerCase() === AVOID),
|
|
74
|
+
word: wordOf(match[2] ?? ""),
|
|
64
75
|
code: (match[3] ?? "").trim(),
|
|
65
76
|
}));
|
|
66
77
|
|
|
@@ -68,16 +79,32 @@ const fencesOf = (body: string): ReadonlyArray<Fence> =>
|
|
|
68
79
|
const nonEmpty = (code: string | undefined): string | undefined =>
|
|
69
80
|
code === undefined || code.length === 0 ? undefined : code;
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
type Code = Pick<Rule, "must" | "never" | "should" | "shouldNot">;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Each example the body has, under its word. An untagged fence is the code
|
|
86
|
+
* to write: `should` in a guideline, which a `should` or `should not` fence
|
|
87
|
+
* makes the rule, and `must` otherwise.
|
|
88
|
+
*/
|
|
89
|
+
const codeOf = (body: string): Code => {
|
|
73
90
|
const fences = fencesOf(body);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
if (fences.length === 0) {
|
|
92
|
+
const must = nonEmpty(body.trim());
|
|
93
|
+
return must === undefined ? {} : { must };
|
|
94
|
+
}
|
|
95
|
+
const first = (word: Word | undefined) =>
|
|
96
|
+
nonEmpty(fences.find((fence) => fence.word === word)?.code);
|
|
97
|
+
const guideline = fences.some((fence) => fence.word === "should" || fence.word === "should not");
|
|
98
|
+
const untagged = first(undefined);
|
|
99
|
+
const must = first("must") ?? (guideline ? undefined : untagged);
|
|
100
|
+
const never = first("never");
|
|
101
|
+
const should = first("should") ?? (guideline ? untagged : undefined);
|
|
102
|
+
const shouldNot = first("should not");
|
|
78
103
|
return {
|
|
79
|
-
...(
|
|
80
|
-
...(
|
|
104
|
+
...(must === undefined ? {} : { must }),
|
|
105
|
+
...(never === undefined ? {} : { never }),
|
|
106
|
+
...(should === undefined ? {} : { should }),
|
|
107
|
+
...(shouldNot === undefined ? {} : { shouldNot }),
|
|
81
108
|
};
|
|
82
109
|
};
|
|
83
110
|
|
|
@@ -98,9 +125,16 @@ export const parseRuleMarkdown = (
|
|
|
98
125
|
),
|
|
99
126
|
);
|
|
100
127
|
const code = codeOf(match[2]);
|
|
101
|
-
|
|
128
|
+
const requirement = code.must !== undefined || code.never !== undefined;
|
|
129
|
+
const guideline = code.should !== undefined || code.shouldNot !== undefined;
|
|
130
|
+
if (requirement && guideline) {
|
|
131
|
+
return yield* ConfigUnavailable.make({
|
|
132
|
+
message: `${file}: a rule is a requirement (must, never) or a guideline (should, should not), not both`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (!requirement && !guideline) {
|
|
102
136
|
return yield* ConfigUnavailable.make({
|
|
103
|
-
message: `${file}: the body needs
|
|
137
|
+
message: `${file}: the body needs code, in a fence tagged must, never, should, or should not, or untagged`,
|
|
104
138
|
});
|
|
105
139
|
}
|
|
106
140
|
return { ...front, ...code };
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -19,9 +19,10 @@ import {
|
|
|
19
19
|
requestsOf,
|
|
20
20
|
type Rules,
|
|
21
21
|
} from "#services/Jev.ts";
|
|
22
|
-
import { type Cause, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
22
|
+
import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
23
23
|
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
24
24
|
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
|
25
|
+
import { RateLimiter } from "effect/unstable/persistence";
|
|
25
26
|
|
|
26
27
|
const SYSTEM_ONE = "https://api.typesafe.ai/v1/systemone";
|
|
27
28
|
|
|
@@ -53,7 +54,9 @@ const isOverflow = (body: string): boolean =>
|
|
|
53
54
|
* the run with its status and what Jev said, since the status alone does not
|
|
54
55
|
* say why, and a request that got no answer refuses with the cause.
|
|
55
56
|
*/
|
|
56
|
-
const failed = (
|
|
57
|
+
const failed = (
|
|
58
|
+
problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
|
|
59
|
+
) => {
|
|
57
60
|
if (!HttpClientError.isHttpClientError(problem) || problem.reason._tag !== "StatusCodeError") {
|
|
58
61
|
return Effect.fail(refused(`System One request failed: ${problem.message}`));
|
|
59
62
|
}
|
|
@@ -79,8 +82,27 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
79
82
|
Effect.gen(function* () {
|
|
80
83
|
const config = yield* AdhereConfig;
|
|
81
84
|
const credentials = yield* Credentials;
|
|
82
|
-
const
|
|
83
|
-
|
|
85
|
+
const http = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
|
|
86
|
+
// `--rpm`: one request per 60/rpm seconds, evenly spaced rather than in a
|
|
87
|
+
// burst each minute. Before the retries, so a retry waits its turn too.
|
|
88
|
+
type Failure = HttpClientError.HttpClientError | RateLimiter.RateLimiterError;
|
|
89
|
+
const paced: HttpClient.HttpClient.With<Failure> =
|
|
90
|
+
config.rpm === undefined
|
|
91
|
+
? // The same client, typed to fail the ways the throttled one can.
|
|
92
|
+
HttpClient.transformResponse(
|
|
93
|
+
http,
|
|
94
|
+
(response): Effect.Effect<HttpClientResponse.HttpClientResponse, Failure> => response,
|
|
95
|
+
)
|
|
96
|
+
: http.pipe(
|
|
97
|
+
HttpClient.withRateLimiter({
|
|
98
|
+
limiter: yield* RateLimiter.RateLimiter,
|
|
99
|
+
key: "jev",
|
|
100
|
+
algorithm: "token-bucket",
|
|
101
|
+
limit: 1,
|
|
102
|
+
window: Duration.millis(60_000 / config.rpm),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
const client = paced.pipe(
|
|
84
106
|
HttpClient.transformResponse(Effect.timeout("30 seconds")),
|
|
85
107
|
HttpClient.retryTransient({
|
|
86
108
|
schedule: Schedule.exponential("500 millis"),
|
|
@@ -172,4 +194,4 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
172
194
|
|
|
173
195
|
return Jev.of({ judge, locate, conflicts, contradicts });
|
|
174
196
|
}),
|
|
175
|
-
);
|
|
197
|
+
).pipe(Layer.provide(RateLimiter.layer.pipe(Layer.provide(RateLimiter.layerStoreMemory))));
|
package/src/services/Jev.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Example, examplesOf, type Rule, type RuleId, type Rules } from "#config.ts";
|
|
2
2
|
import { Context, type Effect, Record, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
export type { Rules };
|
|
@@ -91,19 +91,23 @@ const stateOf = (lines: Lines) => ({
|
|
|
91
91
|
code: lines.map((line, index) => `${index + 1} | ${line}`).join("\n"),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
/** The
|
|
95
|
-
const
|
|
96
|
-
rule: rule.description,
|
|
97
|
-
...(rule.reference === undefined ? {} : { reference: rule.reference }),
|
|
98
|
-
...(rule.avoid === undefined ? {} : { avoid: rule.avoid }),
|
|
99
|
-
});
|
|
94
|
+
/** The field an example goes under in a question: its word, with `should not` as `should_not`. */
|
|
95
|
+
const fieldOf = (example: Example): string => example.word.replace(" ", "_");
|
|
100
96
|
|
|
101
|
-
/** A rule's
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
})
|
|
97
|
+
/** A rule's examples under their words. Code a rule lacks is left out, not sent empty. */
|
|
98
|
+
const exampleFields = (rule: Rule): Record<string, string> => {
|
|
99
|
+
const { good, bad } = examplesOf(rule);
|
|
100
|
+
return {
|
|
101
|
+
...(good === undefined ? {} : { [fieldOf(good)]: good.code }),
|
|
102
|
+
...(bad === undefined ? {} : { [fieldOf(bad)]: bad.code }),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The rule as its questions carry it: the description, and its examples under their words. */
|
|
107
|
+
const ruleFields = (rule: Rule) => ({ rule: rule.description, ...exampleFields(rule) });
|
|
108
|
+
|
|
109
|
+
/** A rule's text as a comparison of rules reads it. */
|
|
110
|
+
const ruleState = (rule: Rule) => ({ description: rule.description, ...exampleFields(rule) });
|
|
107
111
|
|
|
108
112
|
/**
|
|
109
113
|
* The compared rules a question mentions, keyed by index: ids alone would
|
|
@@ -122,24 +126,31 @@ const comparedState = (rules: ReadonlyArray<ComparedRule>, mentioned: Iterable<n
|
|
|
122
126
|
|
|
123
127
|
/**
|
|
124
128
|
* Whether the file breaks the rule. The rule rides in the question, not the
|
|
125
|
-
* state, so each question reads its own rule and no other
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
129
|
+
* state, so each question reads its own rule and no other, its code under its
|
|
130
|
+
* own words: `must` and `never`, or a guideline's `should` and `should_not`.
|
|
131
|
+
* A rule with code to write asks whether the file diverges from it, with the
|
|
132
|
+
* code not to write as an example when there is some. A rule without any
|
|
133
|
+
* asks whether the file contains the code not to write. The criteria draw the
|
|
134
|
+
* line at the rule's scope: a file with no code the rule is about is a no.
|
|
135
|
+
*
|
|
136
|
+
* On the eval, "diverge from the pattern shown in `must`" judged better than
|
|
137
|
+
* asking whether code "must be written the way `must` shows": the same words,
|
|
138
|
+
* but a pattern to compare rather than code to match.
|
|
130
139
|
*/
|
|
131
140
|
export const judgeQuestion = (rule: Rule) => {
|
|
132
|
-
const
|
|
141
|
+
const { good, bad } = examplesOf(rule);
|
|
142
|
+
const avoided = fieldOf(bad ?? { word: "never", code: "" });
|
|
143
|
+
const example = bad === undefined ? "" : `, for example by doing what \`${avoided}\` shows`;
|
|
133
144
|
const [question, yes, no] =
|
|
134
|
-
|
|
145
|
+
good === undefined
|
|
135
146
|
? [
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
`Does \`code\` contain the pattern shown in \`${avoided}\`, which \`rule\` rules out? Answer no if nothing in this file resembles it.`,
|
|
148
|
+
`\`code\` contains the pattern shown in \`${avoided}\``,
|
|
149
|
+
`\`code\` has nothing like \`${avoided}\``,
|
|
139
150
|
]
|
|
140
151
|
: [
|
|
141
|
-
`Does \`code\` diverge from the pattern shown in
|
|
142
|
-
`\`code\` diverges from the pattern shown in
|
|
152
|
+
`Does \`code\` diverge from the pattern shown in \`${fieldOf(good)}\`${example}, as described by \`rule\`? Answer no if the pattern does not apply to this file.`,
|
|
153
|
+
`\`code\` diverges from the pattern shown in \`${fieldOf(good)}\`${example}`,
|
|
143
154
|
"`code` follows the pattern",
|
|
144
155
|
];
|
|
145
156
|
return {
|
|
@@ -153,12 +164,15 @@ export const judgeQuestion = (rule: Rule) => {
|
|
|
153
164
|
};
|
|
154
165
|
|
|
155
166
|
/** What the offending line does, for the line and block questions. */
|
|
156
|
-
const offense = (rule: Rule): string =>
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
const offense = (rule: Rule): string => {
|
|
168
|
+
const { good, bad } = examplesOf(rule);
|
|
169
|
+
const avoided = `\`${fieldOf(bad ?? { word: "never", code: "" })}\``;
|
|
170
|
+
return good === undefined
|
|
171
|
+
? `shows the pattern in ${avoided}`
|
|
172
|
+
: bad === undefined
|
|
173
|
+
? `diverges from \`${fieldOf(good)}\``
|
|
174
|
+
: `diverges from \`${fieldOf(good)}\` or resembles ${avoided}`;
|
|
175
|
+
};
|
|
162
176
|
|
|
163
177
|
const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
|
|
164
178
|
const criteria: Record<string, string> = {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchesGlob } from "node:path";
|
|
1
2
|
import { Context, Effect, FileSystem, Layer, Path } from "effect";
|
|
2
3
|
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
|
|
3
4
|
import type { ScannedFile } from "#models/Audit.ts";
|
|
@@ -31,6 +32,22 @@ export const isInSkippedTree = (relative: string): boolean =>
|
|
|
31
32
|
.split(/[\\/]/)
|
|
32
33
|
.some((segment) => segment === ADHERE_DIRECTORY || SKIPPED_DIRECTORIES.has(segment));
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Whether a path, relative to the working directory, passes a run's filter.
|
|
37
|
+
* A pattern is a glob; one starting with `!` leaves out what it matches. A
|
|
38
|
+
* path passes when no `!` pattern matches it and either another pattern does
|
|
39
|
+
* or there are none.
|
|
40
|
+
*/
|
|
41
|
+
export const passesFilter = (relative: string, patterns: ReadonlyArray<string>): boolean => {
|
|
42
|
+
const path = relative.replaceAll("\\", "/");
|
|
43
|
+
const matches = (pattern: string) => matchesGlob(path, pattern);
|
|
44
|
+
const include = patterns.filter((pattern) => !pattern.startsWith("!"));
|
|
45
|
+
const exclude = patterns
|
|
46
|
+
.filter((pattern) => pattern.startsWith("!"))
|
|
47
|
+
.map((pattern) => pattern.slice(1));
|
|
48
|
+
return (include.length === 0 || include.some(matches)) && !exclude.some(matches);
|
|
49
|
+
};
|
|
50
|
+
|
|
34
51
|
const isInside = (file: string, root: string): boolean =>
|
|
35
52
|
file === root || file.startsWith(`${root}/`);
|
|
36
53
|
|
|
@@ -49,18 +66,21 @@ const refused = (problem: { readonly message: string }): WalkUnavailable =>
|
|
|
49
66
|
/** `readdir(recursive)` yields paths relative to the root it read: root them. */
|
|
50
67
|
const rooted = (root: string, paths: ReadonlyArray<string>) => paths.map((rel) => `${root}/${rel}`);
|
|
51
68
|
|
|
69
|
+
/** A test on a path relative to the root being read: whether the run wants it. */
|
|
70
|
+
type Keep = (relative: string) => boolean;
|
|
71
|
+
|
|
52
72
|
/** What the root actually contains: only paths the audit reads. */
|
|
53
|
-
const scannable = (root: string, paths: ReadonlyArray<string>, self: string) =>
|
|
73
|
+
const scannable = (root: string, paths: ReadonlyArray<string>, self: string, keep: Keep) =>
|
|
54
74
|
rooted(
|
|
55
75
|
root,
|
|
56
|
-
paths.filter((relative) => !isInSkippedTree(relative)),
|
|
76
|
+
paths.filter((relative) => !isInSkippedTree(relative) && keep(relative)),
|
|
57
77
|
).filter((file) => isScannable(file, self));
|
|
58
78
|
|
|
59
79
|
/** One root's paths, filtered to what the audit reads. */
|
|
60
80
|
const listRoot = Effect.fn("SourceWalker.listRoot")(
|
|
61
|
-
(fs: FileSystem.FileSystem, root: string, self: string) =>
|
|
81
|
+
(fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
|
|
62
82
|
Effect.map(fs.readDirectory(root, { recursive: true }), (paths) =>
|
|
63
|
-
scannable(root, paths, self),
|
|
83
|
+
scannable(root, paths, self, keep),
|
|
64
84
|
),
|
|
65
85
|
);
|
|
66
86
|
|
|
@@ -78,8 +98,8 @@ const readEach = (fs: FileSystem.FileSystem, paths: ReadonlyArray<string>) =>
|
|
|
78
98
|
|
|
79
99
|
/** One root's files, in directory order: read every scannable path. */
|
|
80
100
|
const readRoot = Effect.fn("SourceWalker.readRoot")(
|
|
81
|
-
(fs: FileSystem.FileSystem, root: string, self: string) =>
|
|
82
|
-
Effect.flatMap(listRoot(fs, root, self), (paths) => readEach(fs, paths)),
|
|
101
|
+
(fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
|
|
102
|
+
Effect.flatMap(listRoot(fs, root, self, keep), (paths) => readEach(fs, paths)),
|
|
83
103
|
);
|
|
84
104
|
|
|
85
105
|
/**
|
|
@@ -103,20 +123,24 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
|
|
|
103
123
|
* The scan root is the current working directory, so the audit reads the
|
|
104
124
|
* repository it is run in.
|
|
105
125
|
*/
|
|
106
|
-
export const SourceWalkerLive =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
);
|
|
126
|
+
export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
127
|
+
Layer.effect(SourceWalker)(
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const fs = yield* FileSystem.FileSystem;
|
|
130
|
+
const path = yield* Path.Path;
|
|
131
|
+
// No segments: Path resolves against the working directory.
|
|
132
|
+
const root = path.resolve();
|
|
133
|
+
const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
|
|
134
|
+
const walk = Effect.fn("SourceWalker.files")(function* () {
|
|
135
|
+
const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
|
|
136
|
+
const trees = yield* Effect.forEach(dirs, (dir) =>
|
|
137
|
+
readRoot(fs, path.join(root, dir), self, (relative) =>
|
|
138
|
+
// Filter patterns are relative to the working directory, not the tree read.
|
|
139
|
+
passesFilter(dir === "." ? relative : `${dir}/${relative}`, filter),
|
|
140
|
+
).pipe(Effect.mapError(refused)),
|
|
141
|
+
);
|
|
142
|
+
return trees.flat();
|
|
143
|
+
});
|
|
144
|
+
return SourceWalker.of({ files: walk() });
|
|
145
|
+
}),
|
|
146
|
+
);
|