@drkmttr/adhere 0.18.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -1575
- package/package.json +18 -15
- package/presets/README.md +140 -0
- package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
- package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
- package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
- package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
- package/skills/adhere/SKILL.md +194 -29
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +60 -26
- package/src/cli.ts +334 -36
- package/src/config.ts +173 -142
- package/src/create/agents.ts +149 -0
- package/src/create/ask.ts +363 -0
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +191 -0
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +562 -0
- package/src/create/trial.ts +611 -0
- package/src/create/wizard.ts +466 -0
- package/src/index.ts +2 -0
- package/src/init.ts +171 -41
- package/src/install.ts +296 -94
- package/src/instructions.ts +98 -0
- package/src/markdown.ts +15 -11
- package/src/oxlint/catalog.json +7844 -0
- package/src/oxlint/catalog.ts +72 -0
- package/src/oxlint/shared.ts +140 -0
- package/src/readme-guide.ts +431 -0
- package/src/readme.ts +291 -0
- package/src/rules.ts +42 -45
- package/src/services/AdhereConfig.ts +67 -21
- package/src/services/AuditCache.ts +176 -169
- package/src/services/Credentials.ts +14 -8
- package/src/services/Jev.reads.test.ts +22 -0
- package/src/services/Jev.ts +35 -13
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +4 -4
- package/src/workflows/audit.ts +85 -14
- package/src/workflows/format.ts +98 -1
- package/src/workspaces.test.ts +37 -1
- package/src/workspaces.ts +121 -12
package/src/readme.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the managed guide into a shared repo's README: only a marked block,
|
|
3
|
+
* so the rest of the README stays the author's. The block itself, its rules
|
|
4
|
+
* grouped by topic and its lint settings, is rendered by `#readme-guide.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { run } from "#create/agents.ts";
|
|
9
|
+
import { cloned, describeRulesAt } from "#install.ts";
|
|
10
|
+
import { lintEntries, renderReadmeIndex, type LintConfig } from "#readme-guide.ts";
|
|
11
|
+
import { SHARED_LINT_EXPORT, SHARED_LINT_RULES } from "#oxlint/shared.ts";
|
|
12
|
+
import { Effect, Option, Schema } from "effect";
|
|
13
|
+
|
|
14
|
+
export type { LintConfig, ReadmeRule } from "#readme-guide.ts";
|
|
15
|
+
export { renderReadmeIndex };
|
|
16
|
+
|
|
17
|
+
export const README_START = "<!-- adhere:rules:start -->";
|
|
18
|
+
export const README_END = "<!-- adhere:rules:end -->";
|
|
19
|
+
|
|
20
|
+
export class ReadmeUnavailable extends Schema.TaggedError<ReadmeUnavailable>()(
|
|
21
|
+
"ReadmeUnavailable",
|
|
22
|
+
{ message: Schema.String },
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
const LintConfigShape = Schema.Struct({
|
|
26
|
+
rules: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
|
27
|
+
jsPlugins: Schema.optionalKey(Schema.Array(Schema.Unknown)),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const Manifest = Schema.Struct({
|
|
31
|
+
exports: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** What a README index wrote, and how much it listed. */
|
|
35
|
+
export interface ReadmeRefresh {
|
|
36
|
+
readonly changed: boolean;
|
|
37
|
+
readonly message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const readOptional = (file: string): Effect.Effect<string | undefined, ReadmeUnavailable> =>
|
|
41
|
+
Effect.tryPromise({
|
|
42
|
+
try: async () => {
|
|
43
|
+
try {
|
|
44
|
+
return { text: await readFile(file, "utf8") };
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
if (
|
|
47
|
+
typeof cause === "object" &&
|
|
48
|
+
cause !== null &&
|
|
49
|
+
"code" in cause &&
|
|
50
|
+
cause.code === "ENOENT"
|
|
51
|
+
) {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
throw cause;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
catch: (cause) =>
|
|
58
|
+
new ReadmeUnavailable({
|
|
59
|
+
message: `Could not read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
60
|
+
}),
|
|
61
|
+
}).pipe(Effect.map(({ text }) => text));
|
|
62
|
+
|
|
63
|
+
/** The shared lint config, `{}` when absent; invalid JSON refuses the refresh. */
|
|
64
|
+
const lintConfigAt = (root: string): Effect.Effect<LintConfig, ReadmeUnavailable> =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const file = join(root, SHARED_LINT_RULES);
|
|
67
|
+
const text = yield* readOptional(file);
|
|
68
|
+
if (text === undefined) return {};
|
|
69
|
+
const parsed = yield* Effect.try({
|
|
70
|
+
try: () => JSON.parse(text) as unknown,
|
|
71
|
+
catch: () =>
|
|
72
|
+
new ReadmeUnavailable({
|
|
73
|
+
message: `${file} is not valid JSON; left the README unchanged.`,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
const decoded = Schema.decodeUnknownOption(LintConfigShape)(parsed);
|
|
77
|
+
return Option.isSome(decoded)
|
|
78
|
+
? decoded.value
|
|
79
|
+
: yield* ReadmeUnavailable.make({
|
|
80
|
+
message: `${file} is not an object of rules and jsPlugins; left the README unchanged.`,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether `root` is a shared rules repo, by the metadata `init --shared`
|
|
86
|
+
* writes: a package.json whose exports name `./oxlint`. Its rules directory
|
|
87
|
+
* alone is every project's, so this is what tells a shared repo from one that
|
|
88
|
+
* merely has rules.
|
|
89
|
+
*/
|
|
90
|
+
export const isSharedRepo = (root: string): Effect.Effect<boolean> =>
|
|
91
|
+
Effect.promise(async () => {
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
94
|
+
const decoded = Schema.decodeUnknownOption(Manifest)(parsed);
|
|
95
|
+
return Option.isSome(decoded) && decoded.value.exports?.[SHARED_LINT_EXPORT] !== undefined;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replace only the marked block; append it when absent. A README that is
|
|
103
|
+
* missing one marker, or repeats them, refuses before any write.
|
|
104
|
+
*/
|
|
105
|
+
export const applyReadmeIndex = (
|
|
106
|
+
previous: string,
|
|
107
|
+
block: string,
|
|
108
|
+
): Effect.Effect<string, ReadmeUnavailable> =>
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
const start = previous.indexOf(README_START);
|
|
111
|
+
const end = previous.indexOf(README_END);
|
|
112
|
+
if (
|
|
113
|
+
(start === -1) !== (end === -1) ||
|
|
114
|
+
(start !== -1 &&
|
|
115
|
+
(end < start ||
|
|
116
|
+
previous.indexOf(README_START, start + README_START.length) !== -1 ||
|
|
117
|
+
previous.indexOf(README_END, end + README_END.length) !== -1))
|
|
118
|
+
) {
|
|
119
|
+
return yield* ReadmeUnavailable.make({
|
|
120
|
+
message:
|
|
121
|
+
"README.md has malformed or duplicate adhere rules markers. Fix them before running adhere rules readme.",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const wrapped = `${README_START}\n${block}\n${README_END}`;
|
|
125
|
+
return start === -1
|
|
126
|
+
? `${previous}${previous === "" ? "" : previous.endsWith("\n\n") ? "" : previous.endsWith("\n") ? "\n" : "\n\n"}${wrapped}\n`
|
|
127
|
+
: `${previous.slice(0, start)}${wrapped}${previous.slice(end + README_END.length)}`;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check that `root`'s README accepts an index refresh, without writing: a
|
|
132
|
+
* local writer calls this before it writes a rule or plugin, so malformed
|
|
133
|
+
* markers fail before a partial change lands.
|
|
134
|
+
*/
|
|
135
|
+
export const validateReadme = (root: string): Effect.Effect<void, ReadmeUnavailable> =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const previous = (yield* readOptional(join(root, "README.md"))) ?? "";
|
|
138
|
+
yield* applyReadmeIndex(previous, "");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Refresh the README at `root` from its rules and lint config. `root` is
|
|
143
|
+
* taken as shared because init knows it is; commands that start in a
|
|
144
|
+
* directory check `isSharedRepo` first.
|
|
145
|
+
*/
|
|
146
|
+
export const refreshReadme = (root: string): Effect.Effect<ReadmeRefresh, ReadmeUnavailable> =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
const rules = yield* describeRulesAt(root).pipe(
|
|
149
|
+
Effect.mapError(
|
|
150
|
+
(cause) =>
|
|
151
|
+
new ReadmeUnavailable({
|
|
152
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
for (const rule of rules) {
|
|
157
|
+
const text = `${rule.description}\n${rule.body ?? ""}`;
|
|
158
|
+
if (text.includes(README_START) || text.includes(README_END)) {
|
|
159
|
+
return yield* ReadmeUnavailable.make({
|
|
160
|
+
message: `${rule.id} has an adhere rules marker in its description or body; left the README unchanged.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const config = yield* lintConfigAt(root);
|
|
165
|
+
const entries = lintEntries(config);
|
|
166
|
+
const block = renderReadmeIndex(rules, config);
|
|
167
|
+
const target = join(root, "README.md");
|
|
168
|
+
const previous = (yield* readOptional(target)) ?? "";
|
|
169
|
+
const next = yield* applyReadmeIndex(previous, block);
|
|
170
|
+
const changed = next !== previous;
|
|
171
|
+
if (changed) {
|
|
172
|
+
yield* Effect.tryPromise({
|
|
173
|
+
try: () => writeFile(target, next, "utf8"),
|
|
174
|
+
catch: (cause) =>
|
|
175
|
+
new ReadmeUnavailable({
|
|
176
|
+
message: `Could not write ${target}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const settings = entries.native.length + entries.custom.length;
|
|
181
|
+
return {
|
|
182
|
+
changed,
|
|
183
|
+
message: `${changed ? "Updated" : "Unchanged"} README.md: ${rules.length} ${rules.length === 1 ? "rule" : "rules"}, ${settings} enabled lint ${settings === 1 ? "setting" : "settings"}.`,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Refresh `root`'s index only when it is a shared repo, as an org push
|
|
189
|
+
* discovered it. A legacy rules-only repo is left exactly as it was: no README
|
|
190
|
+
* is invented. Returns whether the root is shared, so the caller stages
|
|
191
|
+
* README.md only then. Malformed markers propagate and stop the push.
|
|
192
|
+
*/
|
|
193
|
+
export const refreshIndexIfShared = (root: string): Effect.Effect<boolean, ReadmeUnavailable> =>
|
|
194
|
+
Effect.gen(function* () {
|
|
195
|
+
if (!(yield* isSharedRepo(root))) return false;
|
|
196
|
+
yield* refreshReadme(root);
|
|
197
|
+
return true;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/** Refresh a shared repo's README, refusing a directory that is not one. */
|
|
201
|
+
export const refreshSharedReadme = (
|
|
202
|
+
root: string,
|
|
203
|
+
): Effect.Effect<ReadmeRefresh, ReadmeUnavailable> =>
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
if (!(yield* isSharedRepo(root))) {
|
|
206
|
+
return yield* ReadmeUnavailable.make({
|
|
207
|
+
message: `${root} is not a shared rules repo: package.json must export ${SHARED_LINT_EXPORT}, as adhere init --shared writes it.`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return yield* refreshReadme(root);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const GITHUB_URL = /^(?:[^@/\s]+@)?github\.com(?::\d+)?[:/]([^/\s]+)\/([^/\s]+)$/;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The shared rules repo a GitHub origin implies, as `owner/standards`, from the
|
|
217
|
+
* forms an origin takes: `https://github.com/owner/repo.git`,
|
|
218
|
+
* `git@github.com:owner/repo.git`, and `ssh://git@github.com/owner/repo.git`.
|
|
219
|
+
* An origin on another host, or one that names no owner and repo, implies
|
|
220
|
+
* none. The origin's repo is read for its owner alone: an org's shared rules
|
|
221
|
+
* live in that org's `standards`, never in the consumer repo the origin names.
|
|
222
|
+
*/
|
|
223
|
+
export const standardsRepoOf = (url: string): string | undefined => {
|
|
224
|
+
const path = url
|
|
225
|
+
.trim()
|
|
226
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")
|
|
227
|
+
.replace(/\/+$/, "");
|
|
228
|
+
const [, owner, named] = GITHUB_URL.exec(path) ?? [];
|
|
229
|
+
const repo = named?.replace(/\.git$/, "");
|
|
230
|
+
if (owner === undefined || repo === undefined) return undefined;
|
|
231
|
+
return /^[\w.-]+$/.test(owner) && /^[\w.-]+$/.test(repo) ? `${owner}/standards` : undefined;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* This checkout's `origin` URL, trimmed, or undefined when git names none: no
|
|
236
|
+
* origin, or a directory git does not see as a repo. Read from the config, not
|
|
237
|
+
* `git remote get-url`, which applies `url.<base>.insteadOf` rewrites and so
|
|
238
|
+
* would not say which GitHub repo the origin names.
|
|
239
|
+
*/
|
|
240
|
+
export const originUrlAt = (root: string): Effect.Effect<string | undefined> =>
|
|
241
|
+
Effect.promise(() =>
|
|
242
|
+
run("git", ["config", "--get", "remote.origin.url"], root, "").then(
|
|
243
|
+
(url) => url.trim(),
|
|
244
|
+
() => undefined,
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const git = (cwd: string, args: ReadonlyArray<string>) =>
|
|
249
|
+
Effect.tryPromise({
|
|
250
|
+
try: () => run("git", args, cwd, ""),
|
|
251
|
+
catch: (cause) =>
|
|
252
|
+
new ReadmeUnavailable({ message: cause instanceof Error ? cause.message : String(cause) }),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
/** What a remote refresh did: its README result, the repo, and the branch it pushed. */
|
|
256
|
+
export interface RemoteReadmeRefresh extends ReadmeRefresh {
|
|
257
|
+
readonly repo: string;
|
|
258
|
+
/** The review branch pushed with the new index, or undefined when it was unchanged. */
|
|
259
|
+
readonly branch?: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Refresh the README of `repo` from a clone of its default branch, and push the
|
|
264
|
+
* change on a review branch for a pull request. Only README.md is committed,
|
|
265
|
+
* nothing is pushed when the index is already current, and the clone is
|
|
266
|
+
* temporary, so the checkout this was asked from is untouched. No `./oxlint`
|
|
267
|
+
* export is needed: a rules-only repo has a README index too.
|
|
268
|
+
*/
|
|
269
|
+
export const refreshRemoteReadme = (
|
|
270
|
+
repo: string,
|
|
271
|
+
): Effect.Effect<RemoteReadmeRefresh, ReadmeUnavailable> =>
|
|
272
|
+
Effect.scoped(
|
|
273
|
+
Effect.gen(function* () {
|
|
274
|
+
const clone = yield* cloned({ repo, rule: "" }).pipe(
|
|
275
|
+
Effect.mapError(
|
|
276
|
+
(cause) =>
|
|
277
|
+
new ReadmeUnavailable({
|
|
278
|
+
message: `${cause instanceof Error ? cause.message : String(cause)}. Check that ${repo} exists and git can read it, or name another repo: adhere rules readme org/repo.`,
|
|
279
|
+
}),
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
const refreshed = yield* refreshReadme(clone);
|
|
283
|
+
if (!refreshed.changed) return { ...refreshed, repo };
|
|
284
|
+
const branch = `adhere/readme-${Date.now()}`;
|
|
285
|
+
yield* git(clone, ["switch", "--quiet", "--create", branch]);
|
|
286
|
+
yield* git(clone, ["add", "--", "README.md"]);
|
|
287
|
+
yield* git(clone, ["commit", "--quiet", "--message", "Refresh the shared README index"]);
|
|
288
|
+
yield* git(clone, ["push", "--quiet", "origin", branch]);
|
|
289
|
+
return { ...refreshed, repo, branch };
|
|
290
|
+
}),
|
|
291
|
+
);
|
package/src/rules.ts
CHANGED
|
@@ -60,11 +60,9 @@ interface RuleFile {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* The rules among `leaves`, the paths in `directory`, whose rules sit under
|
|
63
|
-
* `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* every rule's directory, but a README.md: before rule directories, it was a
|
|
67
|
-
* rule, and the refusal says where it goes now to keep its id.
|
|
63
|
+
* `prefix` one to a directory: `<id>/RULE.md` or `<id>/RULE.ts`. Any other
|
|
64
|
+
* file, such as a helper a RULE.ts imports, is not a rule. A directory with
|
|
65
|
+
* both files refuses.
|
|
68
66
|
*/
|
|
69
67
|
const ruleFilesIn = (
|
|
70
68
|
directory: string,
|
|
@@ -85,29 +83,6 @@ const ruleFilesIn = (
|
|
|
85
83
|
}),
|
|
86
84
|
);
|
|
87
85
|
}
|
|
88
|
-
const homes = files.map(({ leaf }) => leaf.slice(0, leaf.lastIndexOf("/") + 1));
|
|
89
|
-
const flat = leaves.filter((leaf) => {
|
|
90
|
-
const name = segmentsOf(leaf).at(-1) ?? "";
|
|
91
|
-
return (
|
|
92
|
-
leaf.endsWith(".md") &&
|
|
93
|
-
!RULE_FILES.has(name) &&
|
|
94
|
-
name !== "README.md" &&
|
|
95
|
-
!homes.some((home) => leaf.startsWith(home))
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
if (flat.length > 0) {
|
|
99
|
-
return Effect.fail(
|
|
100
|
-
ConfigUnavailable.make({
|
|
101
|
-
message: [
|
|
102
|
-
`Rules now live one to a directory, as ${directory}/${prefix}<id>/RULE.md or RULE.ts. Move each of these to keep its id:`,
|
|
103
|
-
...flat.map(
|
|
104
|
-
(leaf) =>
|
|
105
|
-
` ${directory}/${leaf} → ${directory}/${prefix}${leaf.slice(0, -".md".length)}/RULE.md`,
|
|
106
|
-
),
|
|
107
|
-
].join("\n"),
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
86
|
return Effect.succeed(files);
|
|
112
87
|
};
|
|
113
88
|
|
|
@@ -161,7 +136,7 @@ const moreSpecific = (a: RuleEntry, b: RuleEntry): RuleEntry =>
|
|
|
161
136
|
* config's inline rule is, so a hook such as `appendState` comes with it.
|
|
162
137
|
* Like a config, the file is imported, so its code runs.
|
|
163
138
|
*/
|
|
164
|
-
const importRule = Effect.fn("importRule")(function* (file: string) {
|
|
139
|
+
export const importRule = Effect.fn("importRule")(function* (file: string) {
|
|
165
140
|
const path = yield* Path.Path;
|
|
166
141
|
const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
|
|
167
142
|
const loaded = yield* Effect.tryPromise({
|
|
@@ -255,25 +230,47 @@ export const globalRuleSet = (rules: Readonly<Record<RuleId, Rule>>, root: strin
|
|
|
255
230
|
/**
|
|
256
231
|
* The rules as a config's overrides leave them, each found by the id a report
|
|
257
232
|
* names it with: a rule set `off` gone, and any other with the level and
|
|
258
|
-
* threshold its override gives, over its own
|
|
233
|
+
* threshold its override gives, over its own, and its `options` decoded by
|
|
234
|
+
* the rule's schema, `{}` when unset. Options a rule does not take, or that
|
|
235
|
+
* do not decode, refuse the run.
|
|
259
236
|
*/
|
|
260
|
-
export const withOverrides = (
|
|
237
|
+
export const withOverrides = Effect.fn("withOverrides")(function* (
|
|
261
238
|
entries: RuleSet,
|
|
262
239
|
overrides: Readonly<Record<string, Override>>,
|
|
263
|
-
)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
240
|
+
) {
|
|
241
|
+
const kept = yield* Effect.forEach(entries, (entry) =>
|
|
242
|
+
Effect.gen(function* () {
|
|
243
|
+
const id = shownId(entry);
|
|
244
|
+
const override = overrides[id];
|
|
245
|
+
const { level, threshold, options }: Exclude<Override, string> =
|
|
246
|
+
typeof override === "string" ? { level: override } : (override ?? {});
|
|
247
|
+
if (level === "off") return [];
|
|
248
|
+
if (entry.rule.options === undefined && options !== undefined) {
|
|
249
|
+
return yield* ConfigUnavailable.make({
|
|
250
|
+
message: `overrides sets options for ${id}, which takes none.`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const optionValues =
|
|
254
|
+
entry.rule.options === undefined
|
|
255
|
+
? undefined
|
|
256
|
+
: yield* Schema.decodeUnknownEffect(entry.rule.options)(options ?? {}).pipe(
|
|
257
|
+
Effect.mapError((problem) =>
|
|
258
|
+
ConfigUnavailable.make({
|
|
259
|
+
message: `The options of ${id}, set in overrides, did not decode: ${problem.message}`,
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
const rule: Rule = {
|
|
264
|
+
...entry.rule,
|
|
265
|
+
...(level === undefined ? {} : { level }),
|
|
266
|
+
...(threshold === undefined ? {} : { threshold }),
|
|
267
|
+
...(optionValues === undefined ? {} : { optionValues }),
|
|
268
|
+
};
|
|
269
|
+
return [{ ...entry, rule }];
|
|
270
|
+
}),
|
|
271
|
+
);
|
|
272
|
+
return kept.flat();
|
|
273
|
+
});
|
|
277
274
|
|
|
278
275
|
/**
|
|
279
276
|
* Whether a rule judges a file: a test file only when the rule says `tests`,
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
AdhereConfig as AdhereConfigSchema,
|
|
3
3
|
CONFIG_FILES,
|
|
4
4
|
ConfigUnavailable,
|
|
5
|
+
declaresNativeReads,
|
|
5
6
|
decodeConfig,
|
|
6
7
|
type Loaded,
|
|
7
8
|
type Flags,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
type ResolvedConfig,
|
|
11
12
|
resolveConfig,
|
|
12
13
|
RULES_DIRECTORY,
|
|
14
|
+
typeAwareOf,
|
|
13
15
|
} from "#config.ts";
|
|
14
16
|
import { type PresetName, presetOf, presets, wholePresetOf } from "#presets.ts";
|
|
15
17
|
import {
|
|
@@ -21,14 +23,14 @@ import {
|
|
|
21
23
|
shownId,
|
|
22
24
|
withOverrides,
|
|
23
25
|
} from "#rules.ts";
|
|
24
|
-
import { workspacePackagesOf } from "#workspaces.ts";
|
|
26
|
+
import { workspaceDependentsOf, workspacePackagesOf } from "#workspaces.ts";
|
|
25
27
|
import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
|
|
26
28
|
|
|
27
29
|
export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
|
|
28
30
|
"@drkmttr/adhere/services/AdhereConfig",
|
|
29
31
|
) {}
|
|
30
32
|
|
|
31
|
-
const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
|
|
33
|
+
export const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
|
|
32
34
|
const path = yield* Path.Path;
|
|
33
35
|
const url = yield* path.toFileUrl(file).pipe(Effect.orDie);
|
|
34
36
|
const loaded = yield* Effect.tryPromise({
|
|
@@ -45,7 +47,10 @@ const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
|
|
|
45
47
|
);
|
|
46
48
|
});
|
|
47
49
|
|
|
48
|
-
const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (
|
|
50
|
+
export const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (
|
|
51
|
+
name: PresetName,
|
|
52
|
+
base: string,
|
|
53
|
+
) {
|
|
49
54
|
const { topic, ...preset } = presetOf(name);
|
|
50
55
|
const all = yield* materializeRules(preset.rules, base);
|
|
51
56
|
const rules =
|
|
@@ -53,6 +58,23 @@ const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetN
|
|
|
53
58
|
return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
|
|
54
59
|
});
|
|
55
60
|
|
|
61
|
+
/** Locate the optional config without loading rules or presets. */
|
|
62
|
+
export const findConfigFile = Effect.fn("AdhereConfig.find")(function* (cwd: string) {
|
|
63
|
+
const path = yield* Path.Path;
|
|
64
|
+
const fs = yield* FileSystem.FileSystem;
|
|
65
|
+
const found = yield* Effect.filter(CONFIG_FILES, (file) =>
|
|
66
|
+
fs
|
|
67
|
+
.exists(path.join(cwd, file))
|
|
68
|
+
.pipe(Effect.mapError((problem) => ConfigUnavailable.make({ message: problem.message }))),
|
|
69
|
+
);
|
|
70
|
+
if (found.length > 1) {
|
|
71
|
+
return yield* ConfigUnavailable.make({
|
|
72
|
+
message: `More than one config file: ${found.join(", ")}. Keep one.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return found[0];
|
|
76
|
+
});
|
|
77
|
+
|
|
56
78
|
/** Every rule of every built-in preset, by the id a report names it with. */
|
|
57
79
|
const everyPresetRule = Effect.fn("AdhereConfig.everyPresetRule")(function* (base: string) {
|
|
58
80
|
const loaded = yield* Effect.forEach(Object.entries(presets), ([name, preset]) =>
|
|
@@ -73,19 +95,8 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
73
95
|
Layer.effect(AdhereConfig)(
|
|
74
96
|
Effect.gen(function* () {
|
|
75
97
|
const path = yield* Path.Path;
|
|
76
|
-
const fs = yield* FileSystem.FileSystem;
|
|
77
98
|
const cwd = path.resolve();
|
|
78
|
-
const
|
|
79
|
-
fs
|
|
80
|
-
.exists(path.join(cwd, target))
|
|
81
|
-
.pipe(Effect.mapError((problem) => ConfigUnavailable.make({ message: problem.message })));
|
|
82
|
-
const found = yield* Effect.filter(CONFIG_FILES, present);
|
|
83
|
-
if (found.length > 1) {
|
|
84
|
-
return yield* ConfigUnavailable.make({
|
|
85
|
-
message: `More than one config file: ${found.join(", ")}. Keep one.`,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
const configFile = found[0];
|
|
99
|
+
const configFile = yield* findConfigFile(cwd);
|
|
89
100
|
const hasPreset = flags.presets !== undefined && flags.presets.length > 0;
|
|
90
101
|
const config: typeof AdhereConfigSchema.Type =
|
|
91
102
|
configFile === undefined
|
|
@@ -103,9 +114,17 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
103
114
|
? discoveredEntries
|
|
104
115
|
: globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
|
|
105
116
|
const rules = applicableRules(cwd, projectEntries);
|
|
106
|
-
const
|
|
117
|
+
const loadedPresets = yield* Effect.forEach(presetsOf(config, flags), (name) =>
|
|
107
118
|
loadPreset(name, cwd),
|
|
108
119
|
).pipe(Effect.map(Record.fromEntries));
|
|
120
|
+
// A bundled rule that declares the compiler's native reads is left out
|
|
121
|
+
// unless this run enables them; a project's own rule stays and warns.
|
|
122
|
+
const registry = typeAwareOf(config, flags)
|
|
123
|
+
? loadedPresets
|
|
124
|
+
: Record.map(loadedPresets, (preset) => ({
|
|
125
|
+
...preset,
|
|
126
|
+
rules: Record.filter(preset.rules, (rule) => !declaresNativeReads(rule)),
|
|
127
|
+
}));
|
|
109
128
|
const resolved = resolveConfig({ ...config, rules }, flags, registry);
|
|
110
129
|
const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
|
|
111
130
|
globalRuleSet(preset.rules, cwd).map((entry) => ({
|
|
@@ -121,7 +140,8 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
121
140
|
const loaded = new Set(entries.map(shownId));
|
|
122
141
|
const elsewhere = Object.keys(overrides).filter((id) => !loaded.has(id));
|
|
123
142
|
if (elsewhere.length > 0) {
|
|
124
|
-
// A rule of a preset this run leaves out
|
|
143
|
+
// A rule of a preset this run leaves out, or skips without type awareness,
|
|
144
|
+
// may be overridden; one no rule has is a typo.
|
|
125
145
|
const every = yield* everyPresetRule(cwd);
|
|
126
146
|
const unknown = elsewhere.filter((id) => !every.has(id));
|
|
127
147
|
if (unknown.length > 0) {
|
|
@@ -130,15 +150,41 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
130
150
|
});
|
|
131
151
|
}
|
|
132
152
|
}
|
|
133
|
-
const scopedRules = withOverrides(entries, overrides);
|
|
153
|
+
const scopedRules = yield* withOverrides(entries, overrides);
|
|
154
|
+
for (const entry of scopedRules) {
|
|
155
|
+
const missing = (entry.rule.reads ?? []).filter((read) => !resolved.reads.includes(read));
|
|
156
|
+
if (missing.length === 0) continue;
|
|
157
|
+
const workspaceMissing = missing.filter(
|
|
158
|
+
(read) => read === "workspacePackages" || read === "dependents",
|
|
159
|
+
);
|
|
160
|
+
const nativeMissing = missing.filter((read) => read === "symbols" || read === "references");
|
|
161
|
+
const enable = [
|
|
162
|
+
...(workspaceMissing.length === 0
|
|
163
|
+
? []
|
|
164
|
+
: [
|
|
165
|
+
`add ${workspaceMissing.map((read) => `"${read}"`).join(", ")} to the config's reads`,
|
|
166
|
+
]),
|
|
167
|
+
...(nativeMissing.length === 0 ? [] : ["enable typeAware: true (or pass --type-aware)"]),
|
|
168
|
+
].join(" and ");
|
|
169
|
+
yield* Effect.logWarning(
|
|
170
|
+
`${shownId(entry)} declares reads ${missing.join(", ")}, which this run does not enable; the rule may be less accurate until enabled. ${enable}.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
134
173
|
// Read on every run so workspace edits are seen, but only when the config enables this context.
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
174
|
+
const dependents = resolved.reads.includes("dependents");
|
|
175
|
+
const workspacePackages =
|
|
176
|
+
dependents || resolved.reads.includes("workspacePackages")
|
|
177
|
+
? yield* workspacePackagesOf(cwd)
|
|
178
|
+
: undefined;
|
|
179
|
+
const workspaceDependents =
|
|
180
|
+
dependents && workspacePackages !== undefined
|
|
181
|
+
? yield* workspaceDependentsOf(cwd, workspacePackages)
|
|
182
|
+
: undefined;
|
|
138
183
|
return AdhereConfig.of({
|
|
139
184
|
...resolved,
|
|
140
185
|
scopedRules,
|
|
141
186
|
...(workspacePackages === undefined ? {} : { workspacePackages }),
|
|
187
|
+
...(workspaceDependents === undefined ? {} : { workspaceDependents }),
|
|
142
188
|
});
|
|
143
189
|
}),
|
|
144
190
|
);
|