@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.
Files changed (43) hide show
  1. package/README.md +62 -1575
  2. package/package.json +18 -15
  3. package/presets/README.md +140 -0
  4. package/presets/alchemy/runtime/bind-tag-not-implementation/RULE.md +1 -0
  5. package/presets/effect/data/brand-meaningful-primitives/RULE.md +1 -0
  6. package/presets/effect/data/variants-are-tagged-unions/RULE.md +1 -0
  7. package/presets/typescript/types/derive-dont-restate/RULE.md +1 -0
  8. package/skills/adhere/SKILL.md +194 -29
  9. package/skills/adhere-fix/SKILL.md +1 -1
  10. package/skills/adhere-setup/SKILL.md +60 -26
  11. package/src/cli.ts +334 -36
  12. package/src/config.ts +173 -142
  13. package/src/create/agents.ts +149 -0
  14. package/src/create/ask.ts +363 -0
  15. package/src/create/examples.ts +94 -0
  16. package/src/create/look.ts +191 -0
  17. package/src/create/oxlint-trial.ts +464 -0
  18. package/src/create/oxlint.ts +1012 -0
  19. package/src/create/trial-review.ts +562 -0
  20. package/src/create/trial.ts +611 -0
  21. package/src/create/wizard.ts +466 -0
  22. package/src/index.ts +2 -0
  23. package/src/init.ts +171 -41
  24. package/src/install.ts +296 -94
  25. package/src/instructions.ts +98 -0
  26. package/src/markdown.ts +15 -11
  27. package/src/oxlint/catalog.json +7844 -0
  28. package/src/oxlint/catalog.ts +72 -0
  29. package/src/oxlint/shared.ts +140 -0
  30. package/src/readme-guide.ts +431 -0
  31. package/src/readme.ts +291 -0
  32. package/src/rules.ts +42 -45
  33. package/src/services/AdhereConfig.ts +67 -21
  34. package/src/services/AuditCache.ts +176 -169
  35. package/src/services/Credentials.ts +14 -8
  36. package/src/services/Jev.reads.test.ts +22 -0
  37. package/src/services/Jev.ts +35 -13
  38. package/src/services/SourceWalker.ts +12 -5
  39. package/src/wording.ts +4 -4
  40. package/src/workflows/audit.ts +85 -14
  41. package/src/workflows/format.ts +98 -1
  42. package/src/workspaces.test.ts +37 -1
  43. package/src/workspaces.ts +121 -12
package/src/install.ts CHANGED
@@ -1,15 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
- import { access, cp, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
2
+ import { access, cp, mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join, sep } from "node:path";
5
- import { RULES_DIRECTORY, type RuleId } from "#config.ts";
6
- import { parseRuleMarkdown } from "#markdown.ts";
5
+ import { INSTRUCTIONS_DIRECTORY, RULES_DIRECTORY, type RuleId } from "#config.ts";
6
+ import { parseRuleMarkdown, ruleBodyOf } from "#markdown.ts";
7
7
  import { Effect } from "effect";
8
8
 
9
9
  /**
10
- * A GitHub repo whose `.adhere/rules/` another repo copies: `org/repo`, then
11
- * optionally a topic or a rule's id, then optionally `#` and a branch or tag,
12
- * as in `darkmatter/standards/data/brand-ports#v3`.
10
+ * A GitHub repo whose `.adhere/rules/` and `.adhere/instructions/` another
11
+ * repo copies: `org/repo`, then optionally a topic or a rule's id, then
12
+ * optionally `#` and a branch or tag, as in
13
+ * `darkmatter/standards/data/brand-ports#v3`. A `.` or `..` segment is not a
14
+ * repo, topic, or rule: copied under `.adhere/`, it would land outside.
13
15
  */
14
16
  export interface Source {
15
17
  readonly repo: string;
@@ -20,8 +22,16 @@ export interface Source {
20
22
 
21
23
  export const parseSource = (spec: string): Source | undefined => {
22
24
  const [path = "", ref] = spec.split("#");
23
- const [org, repo, ...rule] = path.split("/").filter((segment) => segment.length > 0);
24
- if (org === undefined || repo === undefined || ref === "") return undefined;
25
+ const segments = path.split("/").filter((segment) => segment.length > 0);
26
+ const [org, repo, ...rule] = segments;
27
+ if (
28
+ org === undefined ||
29
+ repo === undefined ||
30
+ ref === "" ||
31
+ segments.some((segment) => segment === "." || segment === "..")
32
+ ) {
33
+ return undefined;
34
+ }
25
35
  return { repo: `${org}/${repo}`, rule: rule.join("/"), ...(ref === undefined ? {} : { ref }) };
26
36
  };
27
37
 
@@ -34,7 +44,7 @@ const failed = (what: string) => (cause: unknown) =>
34
44
  * credentials git already has, and `url.<base>.insteadOf` can point
35
45
  * github.com elsewhere, such as at SSH.
36
46
  */
37
- const cloned = (source: Source) =>
47
+ export const cloned = (source: Source) =>
38
48
  Effect.acquireRelease(
39
49
  Effect.tryPromise({
40
50
  try: () => mkdtemp(join(tmpdir(), "adhere-install-")),
@@ -47,21 +57,20 @@ const cloned = (source: Source) =>
47
57
  try: async () => {
48
58
  const branch = source.ref === undefined ? [] : ["--branch", source.ref];
49
59
  const url = `https://github.com/${source.repo}.git`;
50
- await new Promise<void>((resolve, reject) => {
51
- let stderr = "";
52
- spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
53
- stdio: ["inherit", "ignore", "pipe"],
60
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
61
+ let stderr = "";
62
+ spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
63
+ stdio: ["inherit", "ignore", "pipe"],
64
+ })
65
+ .on("error", reject)
66
+ .on("exit", (code) => {
67
+ if (code === 0) resolve();
68
+ else reject(new Error(stderr.trim() || `git exited with ${code}`));
54
69
  })
55
- .on("error", reject)
56
- .on("exit", (code) =>
57
- code === 0
58
- ? resolve()
59
- : reject(new Error(stderr.trim() || `git exited with ${code}`)),
60
- )
61
- .stderr?.on("data", (chunk) => {
62
- stderr += chunk;
63
- });
64
- });
70
+ .stderr?.on("data", (chunk) => {
71
+ stderr += chunk;
72
+ });
73
+ await promise;
65
74
  },
66
75
  catch: failed(`Could not clone ${source.repo}`),
67
76
  }),
@@ -69,96 +78,289 @@ const cloned = (source: Source) =>
69
78
  );
70
79
 
71
80
  /**
72
- * The repo's rules in its root `.adhere/rules/`, as lint reads them there:
73
- * each directory that holds a RULE.md or a RULE.ts, by its path, with that file.
81
+ * Every Markdown file under the clone's `.adhere/instructions/`, by its path
82
+ * from there, so a shared repo's `security/hardening.md` is
83
+ * `security/hardening.md`. A directory whose name ends in `.md` is not a file.
74
84
  */
75
- const rulesIn = (source: Source, clone: string) =>
85
+ const instructionsIn = (clone: string) =>
76
86
  Effect.tryPromise({
77
87
  try: async () => {
78
- const directory = join(clone, RULES_DIRECTORY);
79
- const entries = await readdir(directory, { recursive: true }).catch(() => []);
80
- const rules = entries
81
- .flatMap((entry) => {
82
- const [, id, name] = /^(.+)\/(RULE\.(?:md|ts))$/.exec(entry.split(sep).join("/")) ?? [];
83
- return id === undefined || name === undefined ? [] : [{ id: id as RuleId, name }];
84
- })
85
- .filter(
86
- ({ id }) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
87
- )
88
- .sort((a, b) => a.id.localeCompare(b.id));
89
- if (rules.length === 0) {
90
- throw new Error(
91
- source.rule === ""
92
- ? `it has no rules in ${RULES_DIRECTORY}/`
93
- : `it has no rule or topic ${source.rule} in ${RULES_DIRECTORY}/`,
94
- );
88
+ const directory = join(clone, INSTRUCTIONS_DIRECTORY);
89
+ const entries = await readdir(directory, { recursive: true }).catch((cause: unknown) => {
90
+ if (
91
+ typeof cause === "object" &&
92
+ cause !== null &&
93
+ "code" in cause &&
94
+ cause.code === "ENOENT"
95
+ ) {
96
+ return [];
97
+ }
98
+ throw cause;
99
+ });
100
+ const files: Array<string> = [];
101
+ for (const entry of entries
102
+ .map((one) => one.split(sep).join("/"))
103
+ .filter((one) => one.endsWith(".md"))
104
+ .sort()) {
105
+ if ((await stat(join(directory, entry))).isFile()) files.push(entry);
95
106
  }
96
- return rules.map(({ id, name }) => ({ id, name, directory: join(directory, id) }));
107
+ return files;
97
108
  },
98
- catch: failed(`Nothing to copy from ${source.repo}`),
109
+ catch: failed(`Could not read ${INSTRUCTIONS_DIRECTORY}/`),
99
110
  });
100
111
 
101
112
  /**
102
- * Each rule a source names, with its description. A rule written in
103
- * TypeScript is listed without one: reading it would run the repo's code.
113
+ * The clone's rules in its root `.adhere/rules/`, as lint reads them there:
114
+ * each directory that holds a RULE.md or a RULE.ts, by its path, with that
115
+ * file. A directory with both refuses, as it does for lint.
104
116
  */
105
- export const listRules = (source: Source) =>
106
- Effect.scoped(
107
- Effect.gen(function* () {
108
- const rules = yield* rulesIn(source, yield* cloned(source));
109
- return yield* Effect.forEach(rules, ({ id, name, directory }) =>
110
- Effect.gen(function* () {
111
- if (name === "RULE.ts")
112
- return { id, description: "(TypeScript, which list does not run)" };
113
- const text = yield* Effect.tryPromise({
114
- try: () => readFile(join(directory, name), "utf8"),
115
- catch: failed(`Could not read ${id}`),
116
- });
117
- const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}/${name}`);
118
- return { id, description: rule.description };
119
- }),
117
+ const rulesIn = (clone: string) =>
118
+ Effect.tryPromise({
119
+ try: async () => {
120
+ const entries = await readdir(join(clone, RULES_DIRECTORY), { recursive: true }).catch(
121
+ (cause: unknown) => {
122
+ if (
123
+ typeof cause === "object" &&
124
+ cause !== null &&
125
+ "code" in cause &&
126
+ cause.code === "ENOENT"
127
+ ) {
128
+ return [];
129
+ }
130
+ throw cause;
131
+ },
120
132
  );
121
- }),
133
+ const byId = new Map<RuleId, "RULE.md" | "RULE.ts">();
134
+ for (const entry of entries.map((one) => one.split(sep).join("/")).sort()) {
135
+ const [, id, extension] = /^(.+)\/RULE\.(md|ts)$/.exec(entry) ?? [];
136
+ if (id === undefined || extension === undefined) continue;
137
+ const name = extension === "md" ? "RULE.md" : "RULE.ts";
138
+ const previous = byId.get(id);
139
+ if (previous !== undefined && previous !== name) {
140
+ throw new Error(
141
+ `${RULES_DIRECTORY}/${id} has both RULE.md and RULE.ts, and a rule is one`,
142
+ );
143
+ }
144
+ byId.set(id, name);
145
+ }
146
+ return [...byId].map(([id, name]) => ({ id, name })).sort((a, b) => a.id.localeCompare(b.id));
147
+ },
148
+ catch: failed(`Could not read ${RULES_DIRECTORY}/`),
149
+ });
150
+
151
+ /** A rule a source offers: its id, and the file that defines it. */
152
+ interface CatalogRule {
153
+ readonly id: RuleId;
154
+ readonly name: "RULE.md" | "RULE.ts";
155
+ }
156
+
157
+ /** What a source offers: its instruction files and its rules. */
158
+ interface Catalog {
159
+ /** Markdown files under `.adhere/instructions/`, by their path from there, sorted. */
160
+ readonly instructions: ReadonlyArray<string>;
161
+ readonly rules: ReadonlyArray<CatalogRule>;
162
+ }
163
+
164
+ /**
165
+ * Everything a clone of `source` offers, without reading a rule: a malformed
166
+ * RULE.md elsewhere must not stop a rule from being copied. `describeRules`
167
+ * reads the descriptions `list` and the picker show.
168
+ */
169
+ export const catalogOf = (source: Source, clone: string) =>
170
+ Effect.gen(function* () {
171
+ const instructions = yield* instructionsIn(clone);
172
+ const rules = yield* rulesIn(clone);
173
+ if (instructions.length === 0 && rules.length === 0) {
174
+ return yield* Effect.fail(
175
+ new Error(
176
+ `Nothing to copy from ${source.repo}: it has no instruction files in ${INSTRUCTIONS_DIRECTORY}/ and no rules in ${RULES_DIRECTORY}/`,
177
+ ),
178
+ );
179
+ }
180
+ return { instructions, rules };
181
+ });
182
+
183
+ const TYPESCRIPT_ONLY = "(TypeScript, which list does not run)";
184
+
185
+ /**
186
+ * A rule's description and, for a RULE.md, its body: what `adhere list` shows,
187
+ * and the Markdown a README embeds as written. A RULE.ts is named, never run.
188
+ */
189
+ const described = (source: Source, clone: string, { id, name }: CatalogRule) =>
190
+ Effect.gen(function* () {
191
+ if (name === "RULE.ts") {
192
+ return { id, name, description: TYPESCRIPT_ONLY };
193
+ }
194
+ const text = yield* Effect.tryPromise({
195
+ try: () => readFile(join(clone, RULES_DIRECTORY, id, name), "utf8"),
196
+ catch: failed(`Could not read ${id}`),
197
+ });
198
+ const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}/${name}`);
199
+ return { id, name, description: rule.description, body: ruleBodyOf(text) };
200
+ });
201
+
202
+ /**
203
+ * The description each rule shows: its Markdown's `description`, or a note
204
+ * for a RULE.ts, which reading would run.
205
+ */
206
+ export const describeRules = (source: Source, clone: string, rules: ReadonlyArray<CatalogRule>) =>
207
+ Effect.forEach(rules, (rule) =>
208
+ Effect.map(described(source, clone, rule), ({ id, name, description }) => ({
209
+ id,
210
+ name,
211
+ description,
212
+ })),
213
+ );
214
+
215
+ /**
216
+ * Every rule under `root`'s `.adhere/rules/`, with what a README shows: the
217
+ * description `adhere list` shows and, for a RULE.md, its body. Reads a
218
+ * RULE.md only; a RULE.ts is named, never run.
219
+ */
220
+ export const describeRulesAt = (root: string) =>
221
+ Effect.gen(function* () {
222
+ const rules = yield* rulesIn(root);
223
+ return yield* Effect.forEach(rules, (rule) =>
224
+ described({ repo: "this repo", rule: "" }, root, rule),
225
+ );
226
+ });
227
+
228
+ /** The rules a source's topic or id names, in catalog order. */
229
+ const rulesNamed = (source: Source, catalog: Catalog): ReadonlyArray<CatalogRule> =>
230
+ source.rule === ""
231
+ ? catalog.rules
232
+ : catalog.rules.filter(({ id }) => id === source.rule || id.startsWith(`${source.rule}/`));
233
+
234
+ const nothingNamed = (source: Source): Error =>
235
+ new Error(
236
+ source.rule === ""
237
+ ? `Nothing to copy from ${source.repo}: it has no rules in ${RULES_DIRECTORY}/`
238
+ : `Nothing to copy from ${source.repo}: it has no rule or topic ${source.rule} in ${RULES_DIRECTORY}/`,
122
239
  );
123
240
 
124
- export interface InstallResult {
241
+ /**
242
+ * What a source names: one rule to copy as it is, or the files to choose
243
+ * from. Only an exact rule id, with no rule deeper under it, is copied
244
+ * without asking. A bare repo chooses from its instruction files and every
245
+ * rule; a topic chooses from the rules under it, never from instructions,
246
+ * even when a topic is named `instructions`.
247
+ */
248
+ export type CatalogMatch =
249
+ | { readonly kind: "direct"; readonly rule: CatalogRule }
250
+ | {
251
+ readonly kind: "choose";
252
+ readonly instructions: ReadonlyArray<string>;
253
+ readonly rules: ReadonlyArray<CatalogRule>;
254
+ };
255
+
256
+ export const matchOf = (source: Source, catalog: Catalog) =>
257
+ Effect.gen(function* () {
258
+ const rules = rulesNamed(source, catalog);
259
+ const exact = source.rule === "" ? undefined : rules.find(({ id }) => id === source.rule);
260
+ const topic = source.rule !== "" && rules.some(({ id }) => id.startsWith(`${source.rule}/`));
261
+ if (exact !== undefined && !topic) return { kind: "direct", rule: exact } as const;
262
+ if (rules.length === 0 && source.rule !== "") return yield* Effect.fail(nothingNamed(source));
263
+ return {
264
+ kind: "choose",
265
+ instructions: source.rule === "" ? catalog.instructions : [],
266
+ rules,
267
+ } as const;
268
+ });
269
+
270
+ interface InstallResult {
125
271
  readonly created: ReadonlyArray<string>;
126
272
  readonly skipped: ReadonlyArray<string>;
127
273
  }
128
274
 
275
+ /** The catalog entries a run copies: instruction paths, and rule ids. */
276
+ export interface InstallPicks {
277
+ readonly instructions: ReadonlyArray<string>;
278
+ readonly rules: ReadonlyArray<RuleId>;
279
+ }
280
+
281
+ /** Where one instruction file lands in the repo that copies it. */
282
+ export const instructionPath = (source: Source, instruction: string): string =>
283
+ `${INSTRUCTIONS_DIRECTORY}/${source.repo}/${instruction}`;
284
+
285
+ /** Where one rule's directory lands in the repo that copies it, with its trailing slash. */
286
+ export const rulePath = (source: Source, rule: RuleId): string =>
287
+ `${RULES_DIRECTORY}/${source.repo}/${rule}/`;
288
+
289
+ const copyInto = (
290
+ from: string,
291
+ target: string,
292
+ path: string,
293
+ options: { readonly force: boolean; readonly recursive: boolean },
294
+ ) =>
295
+ Effect.tryPromise({
296
+ try: async () => {
297
+ const exists = await access(target).then(
298
+ () => true,
299
+ () => false,
300
+ );
301
+ if (exists && !options.force) return false;
302
+ if (exists) await rm(target, { recursive: true, force: true });
303
+ await mkdir(dirname(target), { recursive: true });
304
+ await cp(from, target, { recursive: options.recursive });
305
+ return true;
306
+ },
307
+ catch: failed(`Could not write ${path}`),
308
+ });
309
+
129
310
  /**
130
- * Copies the rules a source names, each its whole directory, helpers and all,
131
- * into `root`'s `.adhere/rules/` under the source's org and repo, so
132
- * `data/ports` from acme/rules is the rule `acme/rules/data/ports`, and two
133
- * sources' rules never collide. They are the project's own rules from then
134
- * on, and a copied RULE.ts runs whenever lint does. As with init, a rule
135
- * already there is skipped, unless `force` replaces its directory.
311
+ * Copies the picks from the clone into `root`: each instruction file to
312
+ * `.adhere/instructions/<org>/<repo>/<path>`, and each rule's whole
313
+ * directory, helpers and all, to `.adhere/rules/<org>/<repo>/<id>/`. Two
314
+ * sources' files never collide, and they are the project's own from then on,
315
+ * so a copied RULE.ts runs whenever lint does. Something already there is
316
+ * skipped, unless `force` replaces it. Nothing picked is ever deleted.
136
317
  */
137
- export const installRules = (root: string, source: Source, options: { readonly force?: boolean }) =>
318
+ export const installSelected = (
319
+ root: string,
320
+ source: Source,
321
+ clone: string,
322
+ picks: InstallPicks,
323
+ options: { readonly force?: boolean },
324
+ ) =>
325
+ Effect.gen(function* () {
326
+ const created: Array<string> = [];
327
+ const skipped: Array<string> = [];
328
+ const force = options.force === true;
329
+ for (const instruction of picks.instructions) {
330
+ const path = instructionPath(source, instruction);
331
+ const wrote = yield* copyInto(
332
+ join(clone, INSTRUCTIONS_DIRECTORY, instruction),
333
+ join(root, path),
334
+ path,
335
+ { force, recursive: false },
336
+ );
337
+ (wrote ? created : skipped).push(path);
338
+ }
339
+ for (const rule of picks.rules) {
340
+ const path = rulePath(source, rule);
341
+ const wrote = yield* copyInto(join(clone, RULES_DIRECTORY, rule), join(root, path), path, {
342
+ force,
343
+ recursive: true,
344
+ });
345
+ (wrote ? created : skipped).push(path);
346
+ }
347
+ return { created, skipped } satisfies InstallResult;
348
+ });
349
+
350
+ /**
351
+ * Each rule a source names, with its description. A rule written in
352
+ * TypeScript is listed without one: reading it would run the repo's code.
353
+ */
354
+ export const listRules = (source: Source) =>
138
355
  Effect.scoped(
139
356
  Effect.gen(function* () {
140
- const rules = yield* rulesIn(source, yield* cloned(source));
141
- const created: Array<string> = [];
142
- const skipped: Array<string> = [];
143
- for (const { id, directory } of rules) {
144
- const path = `${RULES_DIRECTORY}/${source.repo}/${id}/`;
145
- const target = join(root, path);
146
- const copied = yield* Effect.tryPromise({
147
- try: async () => {
148
- const exists = await access(target).then(
149
- () => true,
150
- () => false,
151
- );
152
- if (exists && options.force !== true) return false;
153
- await rm(target, { recursive: true, force: true });
154
- await mkdir(dirname(target), { recursive: true });
155
- await cp(directory, target, { recursive: true });
156
- return true;
157
- },
158
- catch: failed(`Could not write ${path}`),
159
- });
160
- (copied ? created : skipped).push(path);
161
- }
162
- return { created, skipped } satisfies InstallResult;
357
+ const clone = yield* cloned(source);
358
+ const catalog = yield* catalogOf(source, clone);
359
+ const rules = rulesNamed(source, catalog);
360
+ if (rules.length === 0) return yield* Effect.fail(nothingNamed(source));
361
+ return (yield* describeRules(source, clone, rules)).map(({ id, description }) => ({
362
+ id,
363
+ description,
364
+ }));
163
365
  }),
164
366
  );
@@ -0,0 +1,98 @@
1
+ import { INSTRUCTIONS_DIRECTORY } from "#config.ts";
2
+ import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
3
+ import { Effect, FileSystem, Path, Schema } from "effect";
4
+
5
+ const START = "<!-- adhere:instructions:start -->";
6
+ const END = "<!-- adhere:instructions:end -->";
7
+
8
+ class InstructionsUnavailable extends Schema.TaggedError<InstructionsUnavailable>()(
9
+ "InstructionsUnavailable",
10
+ { message: Schema.String },
11
+ ) {}
12
+
13
+ /**
14
+ * The order instruction files render in: a file more than one directory deep,
15
+ * as a repo copied from another repo installs them under
16
+ * `.adhere/instructions/<org>/<repo>/`, comes first; then a file chosen
17
+ * locally, directly in the instructions directory; then any other locally
18
+ * nested file. Depth is the only test, so a local file nested two levels deep
19
+ * is ordered with copied repos too. Each group sorts by path, so a run writes
20
+ * the same block every time.
21
+ */
22
+ const groupOf = (relative: string): number => {
23
+ const depth = relative.split("/").length;
24
+ if (depth >= 3) return 0;
25
+ return depth === 1 ? 1 : 2;
26
+ };
27
+
28
+ /** Update only adhere's marked block; leave all other AGENTS.md content untouched. */
29
+ export const renderInstructions = Effect.fn("renderInstructions")(function* (root: string) {
30
+ const fs = yield* FileSystem.FileSystem;
31
+ const path = yield* Path.Path;
32
+ const configFile = yield* findConfigFile(root);
33
+ const config =
34
+ configFile === undefined ? undefined : yield* loadConfigFile(path.join(root, configFile));
35
+ const candidates =
36
+ config?.instructions === undefined
37
+ ? [path.join(root, INSTRUCTIONS_DIRECTORY), path.join(root, ".agents/instructions")]
38
+ : [path.resolve(root, path.dirname(configFile ?? ""), config.instructions)];
39
+ let directory: string | undefined;
40
+ for (const candidate of candidates) {
41
+ if (yield* fs.exists(candidate)) {
42
+ directory = candidate;
43
+ break;
44
+ }
45
+ }
46
+ if (directory === undefined) {
47
+ return `Skipped AGENTS.md: no instructions directory (${candidates.map((candidate) => path.relative(root, candidate)).join(", ")}).`;
48
+ }
49
+
50
+ const entries = yield* fs.readDirectory(directory, { recursive: true });
51
+ const files: Array<string> = [];
52
+ for (const entry of entries) {
53
+ const relative = entry.split(path.sep).join("/");
54
+ if (!relative.endsWith(".md")) continue;
55
+ if ((yield* fs.stat(path.join(directory, entry))).type === "File") files.push(relative);
56
+ }
57
+ files.sort((a, b) => groupOf(a) - groupOf(b) || a.localeCompare(b));
58
+
59
+ const contents: string[] = [];
60
+ for (const relative of files) {
61
+ const file = path.join(directory, relative);
62
+ const text = (yield* fs.readFileString(file)).trim();
63
+ if (text.includes(START) || text.includes(END)) {
64
+ return yield* InstructionsUnavailable.make({
65
+ message: `${file} contains reserved adhere instruction markers.`,
66
+ });
67
+ }
68
+ if (text !== "") contents.push(text);
69
+ }
70
+ const target = path.join(root, "AGENTS.md");
71
+ const previous = yield* fs
72
+ .readFileString(target)
73
+ .pipe(
74
+ Effect.catchTag("PlatformError", (error) =>
75
+ error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error),
76
+ ),
77
+ );
78
+ const start = previous.indexOf(START);
79
+ const end = previous.indexOf(END);
80
+ if (
81
+ (start === -1) !== (end === -1) ||
82
+ (start !== -1 &&
83
+ (end < start ||
84
+ previous.indexOf(START, start + START.length) !== -1 ||
85
+ previous.indexOf(END, end + END.length) !== -1))
86
+ ) {
87
+ return yield* InstructionsUnavailable.make({
88
+ message: `${target} has malformed or duplicate adhere instruction markers. Fix them before running postinstall.`,
89
+ });
90
+ }
91
+ const block = [START, ...contents, END].join("\n\n");
92
+ const next =
93
+ start === -1
94
+ ? `${previous}${previous === "" ? "" : previous.endsWith("\n\n") ? "" : previous.endsWith("\n") ? "\n" : "\n\n"}${block}\n`
95
+ : `${previous.slice(0, start)}${block}${previous.slice(end + END.length)}`;
96
+ if (next !== previous) yield* fs.writeFileString(target, next);
97
+ return `${next === previous ? "Unchanged" : "Updated"} AGENTS.md: ${contents.length} instruction ${contents.length === 1 ? "file" : "files"} from ${path.relative(root, directory) || "."}.`;
98
+ });
package/src/markdown.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
1
+ import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
2
2
  import { Effect, Schema } from "effect";
3
3
 
4
4
  /**
@@ -28,13 +28,14 @@ import { Effect, Schema } from "effect";
28
28
  * fence's own word wins over its heading's. The first fence under each word is
29
29
  * that example, and a rule has either or both. An untagged fence outside those
30
30
  * sections is the code to write, and without a fence the whole body is.
31
- * `avoid`, the word before 0.7, means `never`. `threshold` is optional, and
31
+ * `threshold` is optional, and
32
32
  * so is `tests`: `only` for a rule about test files, which rules otherwise
33
33
  * skip, and `include` for a rule that judges them too. So is `level`:
34
34
  * `warning` reports the rule's findings as warnings, which do not fail a run.
35
35
  * `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
36
36
  * one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
37
-
37
+ * `reads` is a JSON array of the same names, as in `reads: ["dependents"]`:
38
+ * it declares what the rule needs, while the config still decides what runs.
38
39
  */
39
40
  const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
40
41
 
@@ -45,9 +46,18 @@ const FrontMatter = Schema.Struct({
45
46
  level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
46
47
  appliesTo: Schema.optionalKey(Matchers),
47
48
  excludeIf: Schema.optionalKey(Matchers),
49
+ reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
48
50
  });
49
51
 
50
52
  const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
53
+
54
+ /**
55
+ * A rule file's Markdown after its front matter, as written: a README embeds
56
+ * it whole, examples and prose alike. Without front matter there is nothing to
57
+ * strip, so the text is its own body; `parseRuleMarkdown` refuses that anyway.
58
+ */
59
+ export const ruleBodyOf = (text: string): string => FRONT_MATTER.exec(text)?.[2] ?? text;
60
+
51
61
  const FENCE = /^ {0,3}(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n {0,3}\1[ \t]*$/gm;
52
62
  /** An ATX heading: its level as `#`s, then its text, without a closing run of `#`s. */
53
63
  const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/gm;
@@ -85,11 +95,11 @@ interface Heading {
85
95
  readonly word: Word | undefined;
86
96
  }
87
97
 
88
- /** `must not`, like `never` and the older `avoid`, names code never to write. */
98
+ /** `must not`, like `never`, names code never to write. */
89
99
  const wordOf = (info: string): Word | undefined => {
90
100
  const words = info.trim().toLowerCase().split(/\s+/);
91
101
  const not = words.includes("not");
92
- if (words.includes("never") || words.includes("avoid")) return "never";
102
+ if (words.includes("never")) return "never";
93
103
  if (words.includes("should")) return not ? "should not" : "should";
94
104
  if (words.includes("must")) return not ? "never" : "must";
95
105
  return undefined;
@@ -99,7 +109,6 @@ const HEADING_WORDS: ReadonlySet<string> = new Set([
99
109
  "must",
100
110
  "must not",
101
111
  "never",
102
- "avoid",
103
112
  "should",
104
113
  "should not",
105
114
  ]);
@@ -208,11 +217,6 @@ export const parseRuleMarkdown = (
208
217
  });
209
218
  }
210
219
  const fields = parseFrontMatter(match[1]);
211
- if (Object.hasOwn(fields, "reads")) {
212
- return yield* ConfigUnavailable.make({
213
- message: `${file}: move reads from the rule's front matter to the top level of defineConfig({...}) in your config file.`,
214
- });
215
- }
216
220
  const front = yield* Schema.decodeUnknownEffect(FrontMatter)(fields).pipe(
217
221
  Effect.mapError((problem) =>
218
222
  ConfigUnavailable.make({ message: `${file}: ${problem.message}` }),