@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/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/`
|
|
11
|
-
* optionally a topic or a rule's id, then
|
|
12
|
-
* as in
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
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
|
|
85
|
+
const instructionsIn = (clone: string) =>
|
|
76
86
|
Effect.tryPromise({
|
|
77
87
|
try: async () => {
|
|
78
|
-
const directory = join(clone,
|
|
79
|
-
const entries = await readdir(directory, { recursive: true }).catch(() =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
107
|
+
return files;
|
|
97
108
|
},
|
|
98
|
-
catch: failed(`
|
|
109
|
+
catch: failed(`Could not read ${INSTRUCTIONS_DIRECTORY}/`),
|
|
99
110
|
});
|
|
100
111
|
|
|
101
112
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
106
|
-
Effect.
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* sources'
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
|
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
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
* `
|
|
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
|
|
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")
|
|
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}` }),
|