@drkmttr/adhere 0.21.2 → 0.24.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 +28 -1552
- package/package.json +10 -7
- package/presets/README.md +9 -1
- 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 +214 -30
- package/skills/adhere-fix/SKILL.md +1 -1
- package/skills/adhere-setup/SKILL.md +49 -16
- package/src/cli.ts +216 -25
- package/src/config.ts +73 -4
- package/src/create/ask.ts +255 -9
- package/src/create/examples.ts +94 -0
- package/src/create/look.ts +62 -19
- package/src/create/oxlint-trial.ts +464 -0
- package/src/create/oxlint.ts +1012 -0
- package/src/create/trial-review.ts +625 -0
- package/src/create/trial.ts +663 -0
- package/src/create/wizard.ts +131 -95
- package/src/filters.ts +86 -0
- package/src/init.ts +169 -40
- package/src/install.ts +295 -93
- package/src/instructions.ts +34 -12
- package/src/markdown.ts +21 -2
- 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/services/AdhereConfig.ts +36 -3
- package/src/services/AuditCache.ts +176 -169
- package/src/services/SourceWalker.ts +12 -5
- package/src/wording.ts +3 -3
- package/src/workflows/audit.ts +40 -7
- package/src/workflows/format.ts +1 -1
package/src/create/wizard.ts
CHANGED
|
@@ -11,33 +11,34 @@
|
|
|
11
11
|
import { CONFIG_FILES, DEFAULT_READS } from "#config.ts";
|
|
12
12
|
import { AGENTS, type Agent, installedAgents, run } from "#create/agents.ts";
|
|
13
13
|
import {
|
|
14
|
-
type Candidate,
|
|
15
|
-
Candidates,
|
|
16
|
-
type Reviewed,
|
|
17
14
|
RuleNotCreated,
|
|
18
15
|
Shape,
|
|
16
|
+
adhereRevisionPrompt,
|
|
19
17
|
asking,
|
|
20
18
|
askingFor,
|
|
21
|
-
examplesPrompt,
|
|
22
19
|
shapePrompt,
|
|
23
20
|
unfenced,
|
|
24
21
|
writePrompt,
|
|
25
22
|
} from "#create/ask.ts";
|
|
23
|
+
import { reviewedInRepository } from "#create/examples.ts";
|
|
24
|
+
import { createOxlintRule } from "#create/oxlint.ts";
|
|
25
|
+
import { trialAndReview, trialStatus, trialWriteSuffix } from "#create/trial-review.ts";
|
|
26
|
+
import { oxlintRule } from "#oxlint/catalog.ts";
|
|
26
27
|
import {
|
|
27
28
|
decided,
|
|
28
|
-
excerpt,
|
|
29
29
|
indented,
|
|
30
30
|
intro,
|
|
31
31
|
line,
|
|
32
32
|
look,
|
|
33
33
|
note,
|
|
34
34
|
outro,
|
|
35
|
-
|
|
35
|
+
renderMarkdown,
|
|
36
36
|
spinning,
|
|
37
37
|
warn,
|
|
38
38
|
} from "#create/look.ts";
|
|
39
39
|
import { cloned } from "#install.ts";
|
|
40
|
-
import { parseRuleMarkdown } from "#markdown.ts";
|
|
40
|
+
import { parseRuleMarkdown, ruleBodyOf } from "#markdown.ts";
|
|
41
|
+
import { isSharedRepo, refreshIndexIfShared, refreshReadme, validateReadme } from "#readme.ts";
|
|
41
42
|
import { importRule } from "#rules.ts";
|
|
42
43
|
import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
|
|
43
44
|
import { adhereConfigDirectory } from "#services/Credentials.ts";
|
|
@@ -112,58 +113,6 @@ const failed = (cause: unknown) =>
|
|
|
112
113
|
const git = (cwd: string, args: ReadonlyArray<string>) =>
|
|
113
114
|
Effect.tryPromise({ try: () => run("git", args, cwd, ""), catch: failed });
|
|
114
115
|
|
|
115
|
-
/**
|
|
116
|
-
* Shows the user each excerpt the agent found, read from the file itself with
|
|
117
|
-
* three lines either side, and asks whether it breaks the rule. An excerpt
|
|
118
|
-
* that does not match the repo is left out.
|
|
119
|
-
*/
|
|
120
|
-
const review = (agent: Agent, root: string, candidates: ReadonlyArray<Candidate>) =>
|
|
121
|
-
Effect.gen(function* () {
|
|
122
|
-
const fs = yield* FileSystem.FileSystem;
|
|
123
|
-
const path = yield* Path.Path;
|
|
124
|
-
const reviewed: Array<Reviewed> = [];
|
|
125
|
-
let missing = 0;
|
|
126
|
-
for (const [index, candidate] of candidates.entries()) {
|
|
127
|
-
const file = path.resolve(root, candidate.file);
|
|
128
|
-
const where = path.relative(root, file);
|
|
129
|
-
const text = where.startsWith("..")
|
|
130
|
-
? Option.none()
|
|
131
|
-
: yield* fs.readFileString(file).pipe(Effect.option);
|
|
132
|
-
const lines = Option.getOrElse(text, () => "").split("\n");
|
|
133
|
-
const start = candidate.startLine;
|
|
134
|
-
if (Option.isNone(text) || start < 1 || start > lines.length) {
|
|
135
|
-
missing++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
const end = Math.min(Math.max(candidate.endLine, start), start + 14, lines.length);
|
|
139
|
-
const at = `${where}:${start === end ? start : `${start}-${end}`}`;
|
|
140
|
-
yield* line("");
|
|
141
|
-
yield* line(`${at} (${index + 1} of ${candidates.length})`);
|
|
142
|
-
yield* excerpt(lines, {
|
|
143
|
-
from: Math.max(1, start - 3),
|
|
144
|
-
to: Math.min(lines.length, end + 3),
|
|
145
|
-
start,
|
|
146
|
-
end,
|
|
147
|
-
});
|
|
148
|
-
yield* said(
|
|
149
|
-
`${agent}: ${candidate.violation ? "breaks it" : "follows it"}. ${candidate.reason}`,
|
|
150
|
-
);
|
|
151
|
-
const violation = yield* Prompt.run(
|
|
152
|
-
Prompt.Confirm({ message: "Is this a violation?", initial: candidate.violation }),
|
|
153
|
-
);
|
|
154
|
-
reviewed.push({
|
|
155
|
-
where: at,
|
|
156
|
-
code: lines.slice(start - 1, end).join("\n"),
|
|
157
|
-
violation,
|
|
158
|
-
agreed: violation === candidate.violation,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
if (missing > 0) {
|
|
162
|
-
yield* note(`Left out ${missing} excerpt${missing === 1 ? "" : "s"} not found in the repo.`);
|
|
163
|
-
}
|
|
164
|
-
return reviewed;
|
|
165
|
-
});
|
|
166
|
-
|
|
167
116
|
/**
|
|
168
117
|
* What adhere can say of a draft without running it: for a RULE.md, what
|
|
169
118
|
* `validate` would; for a RULE.ts, whether it parses and has a default export.
|
|
@@ -184,11 +133,31 @@ const problemsOf = (text: string, file: string, format: Shape["format"]) =>
|
|
|
184
133
|
Effect.catch((message) => Effect.succeed([`It does not parse: ${message}`])),
|
|
185
134
|
);
|
|
186
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Shows a draft as the user reads it: a RULE.md rendered from Markdown with
|
|
138
|
+
* its frontmatter and source intact, a RULE.ts as written, either on the rail.
|
|
139
|
+
* Only the preview is rendered; the draft itself stays raw for the agent, the
|
|
140
|
+
* trial, and the file.
|
|
141
|
+
*/
|
|
142
|
+
const showDraft = (text: string, format: Shape["format"]) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
if (format !== "markdown") return yield* line(`\n${text}\n`);
|
|
145
|
+
const body = ruleBodyOf(text);
|
|
146
|
+
const frontmatter = text.slice(0, text.length - body.length);
|
|
147
|
+
yield* line(`\n${frontmatter}${yield* renderMarkdown(body)}\n`);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** A RULE.ts that imported, with its wording strays, or one that did not import. */
|
|
151
|
+
export type LoadedDraft =
|
|
152
|
+
| { readonly _tag: "Loaded"; readonly warnings: ReadonlyArray<string> }
|
|
153
|
+
| { readonly _tag: "Unavailable"; readonly problems: ReadonlyArray<string> };
|
|
154
|
+
|
|
187
155
|
/**
|
|
188
156
|
* What `validate` would say of a RULE.ts the user has approved: imported from
|
|
189
|
-
* a temporary directory, so its code runs.
|
|
157
|
+
* a temporary directory, so its code runs. A wording stray is a warning about
|
|
158
|
+
* a rule that still runs; only a failure to import makes it untriallable.
|
|
190
159
|
*/
|
|
191
|
-
const
|
|
160
|
+
export const loadedDraft = (text: string, shown: string) =>
|
|
192
161
|
Effect.gen(function* () {
|
|
193
162
|
const fs = yield* FileSystem.FileSystem;
|
|
194
163
|
const path = yield* Path.Path;
|
|
@@ -198,16 +167,45 @@ const loadedProblems = (text: string, shown: string) =>
|
|
|
198
167
|
);
|
|
199
168
|
yield* fs.writeFileString(file, text);
|
|
200
169
|
return yield* importRule(file).pipe(
|
|
201
|
-
Effect.map((rule) =>
|
|
202
|
-
|
|
203
|
-
|
|
170
|
+
Effect.map((rule): LoadedDraft => ({
|
|
171
|
+
_tag: "Loaded",
|
|
172
|
+
warnings: strayingOf(rule).map(sentenceOf),
|
|
173
|
+
})),
|
|
174
|
+
Effect.catchTag("ConfigUnavailable", ({ message }): Effect.Effect<LoadedDraft> =>
|
|
175
|
+
Effect.succeed({
|
|
176
|
+
_tag: "Unavailable",
|
|
177
|
+
problems: [message.replaceAll(file, shown)],
|
|
178
|
+
}),
|
|
204
179
|
),
|
|
205
180
|
);
|
|
206
181
|
});
|
|
207
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Whether the trial may import and run the draft: a RULE.md has no code, while
|
|
185
|
+
* a RULE.ts is imported only after the user approves it.
|
|
186
|
+
*/
|
|
187
|
+
const approveDraft = (text: string, format: Shape["format"], shown: string) =>
|
|
188
|
+
Effect.gen(function* () {
|
|
189
|
+
if (format === "markdown") return { _tag: "Approved", draft: text } as const;
|
|
190
|
+
const run = yield* Prompt.run(
|
|
191
|
+
Prompt.Confirm({
|
|
192
|
+
message: "adhere will import this RULE.ts and run its code to trial it. Run it?",
|
|
193
|
+
initial: true,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
if (!run) return { _tag: "Declined" } as const;
|
|
197
|
+
const loaded = yield* loadedDraft(text, shown);
|
|
198
|
+
if (loaded._tag === "Unavailable") {
|
|
199
|
+
return { _tag: "Unavailable", problems: loaded.problems } as const;
|
|
200
|
+
}
|
|
201
|
+
for (const warning of loaded.warnings) yield* warn(warning);
|
|
202
|
+
return { _tag: "Approved", draft: text } as const;
|
|
203
|
+
});
|
|
204
|
+
|
|
208
205
|
/**
|
|
209
206
|
* The config settings the rule needs that `config` lacks, as they would be
|
|
210
|
-
* written in it.
|
|
207
|
+
* written in it. The rule declares its reads, but the config still enables
|
|
208
|
+
* them and type awareness.
|
|
211
209
|
*/
|
|
212
210
|
const settingsFor = (
|
|
213
211
|
shape: Shape,
|
|
@@ -249,14 +247,23 @@ export const createRule = Effect.fn("createRule")(
|
|
|
249
247
|
: yield* Prompt.run(Prompt.String({ message: "What should the rule enforce?" }));
|
|
250
248
|
|
|
251
249
|
const shape = yield* askingFor(agent, "how to shape it", root, shapePrompt(idea), Shape);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
250
|
+
const native = shape.linter === null ? undefined : oxlintRule(shape.linter);
|
|
251
|
+
if (native !== undefined) {
|
|
252
|
+
return yield* createOxlintRule({ agent, idea, shape, root, shared });
|
|
253
|
+
}
|
|
254
|
+
if (shape.mechanical && !shape.typeAware) {
|
|
255
|
+
const approach = yield* Prompt.run(
|
|
256
|
+
Prompt.Select({
|
|
257
|
+
message: "A custom oxlint JS plugin can check this. Which do you want?",
|
|
258
|
+
choices: [
|
|
259
|
+
{ title: "Write an oxlint plugin", value: "plugin" as const },
|
|
260
|
+
{ title: "Write an adhere rule", value: "adhere" as const },
|
|
261
|
+
],
|
|
257
262
|
}),
|
|
258
263
|
);
|
|
259
|
-
if (
|
|
264
|
+
if (approach === "plugin") {
|
|
265
|
+
return yield* createOxlintRule({ agent, idea, shape, root, shared });
|
|
266
|
+
}
|
|
260
267
|
}
|
|
261
268
|
const ruleFile = shape.format === "typescript" ? "RULE.ts" : "RULE.md";
|
|
262
269
|
yield* decided(`A ${ruleFile}`, shape.formatReason);
|
|
@@ -327,20 +334,7 @@ export const createRule = Effect.fn("createRule")(
|
|
|
327
334
|
}),
|
|
328
335
|
);
|
|
329
336
|
|
|
330
|
-
|
|
331
|
-
// without its sandbox, leaves the rule's examples to the draft.
|
|
332
|
-
const found = yield* askingFor(
|
|
333
|
-
agent,
|
|
334
|
-
"for code to judge",
|
|
335
|
-
root,
|
|
336
|
-
examplesPrompt(idea, id),
|
|
337
|
-
Candidates,
|
|
338
|
-
).pipe(
|
|
339
|
-
Effect.catch(({ message }) =>
|
|
340
|
-
warn(`No code from the repo: ${message}`).pipe(Effect.as({ candidates: [] })),
|
|
341
|
-
),
|
|
342
|
-
);
|
|
343
|
-
const reviewed = yield* review(agent, root, found.candidates);
|
|
337
|
+
const reviewed = yield* reviewedInRepository(agent, root, idea, id);
|
|
344
338
|
|
|
345
339
|
const file = path.join(rules, id, ruleFile);
|
|
346
340
|
const branch = `adhere/${id}`;
|
|
@@ -360,31 +354,66 @@ export const createRule = Effect.fn("createRule")(
|
|
|
360
354
|
);
|
|
361
355
|
const problems = yield* problemsOf(text, file, shape.format);
|
|
362
356
|
|
|
363
|
-
yield*
|
|
357
|
+
yield* showDraft(text, shape.format);
|
|
364
358
|
for (const problem of problems) yield* warn(problem);
|
|
359
|
+
const shown = path.relative(root, file);
|
|
360
|
+
const outcome = yield* trialAndReview({
|
|
361
|
+
agent,
|
|
362
|
+
root,
|
|
363
|
+
id,
|
|
364
|
+
draft: text,
|
|
365
|
+
toDraft: (source) => ({ kind: "adhere", id, format: shape.format, source }),
|
|
366
|
+
approve: (source) => approveDraft(source, shape.format, shown),
|
|
367
|
+
revise: (source, evidence) =>
|
|
368
|
+
Effect.gen(function* () {
|
|
369
|
+
const revised = unfenced(
|
|
370
|
+
yield* asking(
|
|
371
|
+
agent,
|
|
372
|
+
"to revise the rule",
|
|
373
|
+
root,
|
|
374
|
+
adhereRevisionPrompt(idea, id, shape, source, evidence),
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
for (const problem of yield* problemsOf(revised, file, shape.format)) {
|
|
378
|
+
yield* warn(problem);
|
|
379
|
+
}
|
|
380
|
+
return revised;
|
|
381
|
+
}),
|
|
382
|
+
show: (source) => showDraft(source, shape.format),
|
|
383
|
+
});
|
|
384
|
+
if (outcome._tag === "Cancelled") return yield* outro("Nothing written.");
|
|
385
|
+
const finalText = outcome.draft;
|
|
386
|
+
const finalProblems = yield* problemsOf(finalText, file, shape.format);
|
|
387
|
+
if (finalText !== text) {
|
|
388
|
+
yield* showDraft(finalText, shape.format);
|
|
389
|
+
for (const problem of finalProblems) yield* warn(problem);
|
|
390
|
+
}
|
|
365
391
|
const write = yield* Prompt.run(
|
|
366
392
|
Prompt.Confirm({
|
|
367
393
|
message:
|
|
368
|
-
scope === "project"
|
|
394
|
+
(scope === "project"
|
|
369
395
|
? `Write ${path.relative(root, file)}?`
|
|
370
|
-
: `Push it to ${shared} on the branch ${branch}
|
|
371
|
-
initial:
|
|
396
|
+
: `Push it to ${shared} on the branch ${branch}?`) + trialWriteSuffix(outcome),
|
|
397
|
+
initial: finalProblems.length === 0 && outcome._tag === "Accepted",
|
|
372
398
|
}),
|
|
373
399
|
);
|
|
374
400
|
if (!write) return yield* outro("Nothing written.");
|
|
375
401
|
// A RULE.ts is only imported, and its code run, once the user has read it.
|
|
376
402
|
if (shape.format === "typescript") {
|
|
377
|
-
const loaded = yield*
|
|
378
|
-
|
|
403
|
+
const loaded = yield* loadedDraft(finalText, path.relative(root, file));
|
|
404
|
+
const problems = loaded._tag === "Loaded" ? loaded.warnings : loaded.problems;
|
|
405
|
+
for (const problem of problems) yield* warn(problem);
|
|
379
406
|
if (
|
|
380
|
-
|
|
407
|
+
problems.length > 0 &&
|
|
381
408
|
!(yield* Prompt.run(Prompt.Confirm({ message: "Write it anyway?", initial: false })))
|
|
382
409
|
) {
|
|
383
410
|
return yield* outro("Nothing written.");
|
|
384
411
|
}
|
|
385
412
|
}
|
|
413
|
+
const sharedHere = scope === "project" ? yield* isSharedRepo(root) : false;
|
|
414
|
+
if (sharedHere) yield* validateReadme(root);
|
|
386
415
|
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
|
|
387
|
-
yield* fs.writeFileString(file, `${
|
|
416
|
+
yield* fs.writeFileString(file, `${finalText}\n`);
|
|
388
417
|
|
|
389
418
|
if (scope === "project") {
|
|
390
419
|
const configFile = yield* findConfigFile(root).pipe(Effect.orElseSucceed(() => undefined));
|
|
@@ -400,8 +429,9 @@ export const createRule = Effect.fn("createRule")(
|
|
|
400
429
|
`${id} needs ${needed.join(" and ")} in ${configFile ?? CONFIG_FILES[0]}${shape.typeAware ? " (or lint --type-aware)" : ""}. Until then, lint judges it without that context.`,
|
|
401
430
|
);
|
|
402
431
|
}
|
|
432
|
+
const refreshed = sharedHere ? yield* refreshReadme(root) : undefined;
|
|
403
433
|
return yield* outro(
|
|
404
|
-
`Wrote ${path.relative(root, file)}.\
|
|
434
|
+
`Wrote ${path.relative(root, file)}.\n${trialStatus(outcome)}\n${refreshed === undefined ? "" : `${refreshed.message}\n`}Run adhere validate to check it against your other rules.`,
|
|
405
435
|
);
|
|
406
436
|
}
|
|
407
437
|
const needed = settingsFor(shape, {});
|
|
@@ -409,8 +439,13 @@ export const createRule = Effect.fn("createRule")(
|
|
|
409
439
|
needed.length === 0
|
|
410
440
|
? []
|
|
411
441
|
: [`Repos that install it need ${needed.join(" and ")} in their adhere config.`];
|
|
442
|
+
// A shared repo's index joins the rule in one commit and one pull request;
|
|
443
|
+
// a legacy rules-only repo has no index to refresh.
|
|
444
|
+
const indexed = yield* refreshIndexIfShared(repo);
|
|
412
445
|
yield* git(repo, ["switch", "--quiet", "--create", branch]);
|
|
413
|
-
|
|
446
|
+
const staged = [path.relative(repo, file)];
|
|
447
|
+
if (indexed) staged.push("README.md");
|
|
448
|
+
yield* git(repo, ["add", "--", ...staged]);
|
|
414
449
|
yield* git(repo, [
|
|
415
450
|
"commit",
|
|
416
451
|
"--quiet",
|
|
@@ -422,6 +457,7 @@ export const createRule = Effect.fn("createRule")(
|
|
|
422
457
|
yield* outro(
|
|
423
458
|
[
|
|
424
459
|
`Pushed ${branch} to ${shared}.`,
|
|
460
|
+
trialStatus(outcome),
|
|
425
461
|
...needs,
|
|
426
462
|
`Open the pull request: https://github.com/${shared}/compare/${branch}?expand=1`,
|
|
427
463
|
].join("\n"),
|
package/src/filters.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Rule, RuleFilter } from "#config.ts";
|
|
2
|
+
import { matchesGlob } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** A repository path as every matcher here reads it: `/`-separated, without a leading `./`. */
|
|
5
|
+
export const normalizedPath = (path: string): string =>
|
|
6
|
+
path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
7
|
+
|
|
8
|
+
interface CompiledSide {
|
|
9
|
+
readonly paths: ReadonlyArray<string>;
|
|
10
|
+
readonly content: ReadonlyArray<RegExp>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CompiledFilters {
|
|
14
|
+
readonly include: CompiledSide;
|
|
15
|
+
readonly exclude: CompiledSide;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const compiled = new WeakMap<Pick<Rule, "include" | "exclude">, CompiledFilters>();
|
|
19
|
+
|
|
20
|
+
const compileSide = (filter: RuleFilter | undefined): CompiledSide => ({
|
|
21
|
+
paths: (filter?.paths ?? []).map(normalizedPath),
|
|
22
|
+
// Validated when the rule was decoded, so this cannot throw here.
|
|
23
|
+
content: (filter?.content ?? []).map((source) => new RegExp(source)),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A rule's filters, compiled once per rule: its globs with `/` separators, and
|
|
28
|
+
* its content patterns as regular expressions. Both sides are kept, since
|
|
29
|
+
* `include` and `exclude` differ only in what a match means.
|
|
30
|
+
*/
|
|
31
|
+
export const compiledFiltersOf = (rule: Pick<Rule, "include" | "exclude">): CompiledFilters => {
|
|
32
|
+
const held = compiled.get(rule);
|
|
33
|
+
if (held !== undefined) return held;
|
|
34
|
+
const filters = { include: compileSide(rule.include), exclude: compileSide(rule.exclude) };
|
|
35
|
+
compiled.set(rule, filters);
|
|
36
|
+
return filters;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Whether a rule has either filter, so a run can leave unfiltered rules untouched. */
|
|
40
|
+
export const hasFilters = (rule: Pick<Rule, "include" | "exclude">): boolean =>
|
|
41
|
+
rule.include !== undefined || rule.exclude !== undefined;
|
|
42
|
+
|
|
43
|
+
/** Whether a rule's content patterns, if any, need the file's text to decide. */
|
|
44
|
+
export const filtersNeedText = (rule: Pick<Rule, "include" | "exclude">): boolean => {
|
|
45
|
+
const { include, exclude } = compiledFiltersOf(rule);
|
|
46
|
+
return include.content.length > 0 || exclude.content.length > 0;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const matchesAnyPath = (path: string, patterns: ReadonlyArray<string>): boolean =>
|
|
50
|
+
patterns.some((pattern) => matchesGlob(path, pattern));
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a repository-relative path passes a rule's path filters alone: an
|
|
54
|
+
* `include.paths` list must match when it has entries, and any
|
|
55
|
+
* `exclude.paths` match drops the file even when it matched.
|
|
56
|
+
*/
|
|
57
|
+
export const pathsPass = (rule: Rule, relativePath: string): boolean => {
|
|
58
|
+
const { include, exclude } = compiledFiltersOf(rule);
|
|
59
|
+
const path = normalizedPath(relativePath);
|
|
60
|
+
return (
|
|
61
|
+
(include.paths.length === 0 || matchesAnyPath(path, include.paths)) &&
|
|
62
|
+
!(exclude.paths.length > 0 && matchesAnyPath(path, exclude.paths))
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a rule's content filters allow `text`, the file as written: any
|
|
68
|
+
* `include` entry matches, and no `exclude` entry does. A path fix for one
|
|
69
|
+
* file cannot change between planning and execution, so reloading a file
|
|
70
|
+
* rechecks only this half.
|
|
71
|
+
*/
|
|
72
|
+
export const contentPasses = (rule: Rule, text: string): boolean => {
|
|
73
|
+
const { include, exclude } = compiledFiltersOf(rule);
|
|
74
|
+
return (
|
|
75
|
+
(include.content.length === 0 || include.content.some((pattern) => pattern.test(text))) &&
|
|
76
|
+
!(exclude.content.length > 0 && exclude.content.some((pattern) => pattern.test(text)))
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether a rule applies to a file at a repository-relative path whose text
|
|
82
|
+
* is `text`: `pathsPass` and `contentPasses`. An `include` category that is
|
|
83
|
+
* set must match, while a match in any `exclude` category drops the file.
|
|
84
|
+
*/
|
|
85
|
+
export const ruleAppliesTo = (rule: Rule, relativePath: string, text: string): boolean =>
|
|
86
|
+
pathsPass(rule, relativePath) && contentPasses(rule, text);
|