@drkmttr/adhere 0.21.2 → 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/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
+ );
@@ -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 {
@@ -45,7 +47,10 @@ export const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: st
45
47
  );
46
48
  });
47
49
 
48
- const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetName, base: string) {
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 =
@@ -109,9 +114,17 @@ export const AdhereConfigLive = (flags: Flags) =>
109
114
  ? discoveredEntries
110
115
  : globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
111
116
  const rules = applicableRules(cwd, projectEntries);
112
- const registry = yield* Effect.forEach(presetsOf(config, flags), (name) =>
117
+ const loadedPresets = yield* Effect.forEach(presetsOf(config, flags), (name) =>
113
118
  loadPreset(name, cwd),
114
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
+ }));
115
128
  const resolved = resolveConfig({ ...config, rules }, flags, registry);
116
129
  const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
117
130
  globalRuleSet(preset.rules, cwd).map((entry) => ({
@@ -127,7 +140,8 @@ export const AdhereConfigLive = (flags: Flags) =>
127
140
  const loaded = new Set(entries.map(shownId));
128
141
  const elsewhere = Object.keys(overrides).filter((id) => !loaded.has(id));
129
142
  if (elsewhere.length > 0) {
130
- // A rule of a preset this run leaves out may be overridden; one no rule has is a typo.
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.
131
145
  const every = yield* everyPresetRule(cwd);
132
146
  const unknown = elsewhere.filter((id) => !every.has(id));
133
147
  if (unknown.length > 0) {
@@ -137,6 +151,25 @@ export const AdhereConfigLive = (flags: Flags) =>
137
151
  }
138
152
  }
139
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
+ }
140
173
  // Read on every run so workspace edits are seen, but only when the config enables this context.
141
174
  const dependents = resolved.reads.includes("dependents");
142
175
  const workspacePackages =