@drkmttr/adhere 0.23.0 → 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/package.json +6 -6
- package/skills/adhere/SKILL.md +28 -8
- package/src/config.ts +46 -0
- package/src/create/ask.ts +31 -1
- package/src/create/look.ts +3 -11
- package/src/create/trial-review.ts +71 -8
- package/src/create/trial.ts +76 -24
- package/src/create/wizard.ts +5 -3
- package/src/filters.ts +86 -0
- package/src/markdown.ts +10 -1
- package/src/workflows/audit.ts +35 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -93,10 +93,10 @@
|
|
|
93
93
|
"--force"
|
|
94
94
|
],
|
|
95
95
|
"optionalDependencies": {
|
|
96
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
97
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
98
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
99
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
100
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
96
|
+
"@drkmttr/adhere-darwin-arm64": "0.24.0",
|
|
97
|
+
"@drkmttr/adhere-darwin-x64": "0.24.0",
|
|
98
|
+
"@drkmttr/adhere-linux-arm64": "0.24.0",
|
|
99
|
+
"@drkmttr/adhere-linux-x64": "0.24.0",
|
|
100
|
+
"@drkmttr/adhere-win32-x64": "0.24.0"
|
|
101
101
|
}
|
|
102
102
|
}
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -121,6 +121,18 @@ Front matter besides `description`, all optional:
|
|
|
121
121
|
- `tests: only` for a rule about tests, `tests: include` for one that holds in
|
|
122
122
|
tests too. Otherwise rules skip `.test` and `.spec` files and files under
|
|
123
123
|
`test/`, `tests/`, `__tests__/`, and `fixtures/`.
|
|
124
|
+
- `include: {"paths":["src/**"],"content":["from\\s+[\"']effect[\"']"]}`
|
|
125
|
+
and `exclude: {"paths":["**/generated/**"],"content":["@generated"]}`,
|
|
126
|
+
JSON objects on one line, filter files locally before requests or cached
|
|
127
|
+
findings. TypeScript rules use ordinary objects with the same fields.
|
|
128
|
+
Paths are repository-root-relative globs, even for nested rules. Content
|
|
129
|
+
patterns are JavaScript regex source strings without delimiters or flags,
|
|
130
|
+
matched against original text including comments. Invalid regex refuses
|
|
131
|
+
loading. Entries within each list are alternatives; every nonempty include
|
|
132
|
+
list must match, and any exclude match vetoes the file. Empty lists impose
|
|
133
|
+
no restriction. Existing scope, test settings, and ignores still apply.
|
|
134
|
+
An import regex is not import resolution: check comments, alternative import
|
|
135
|
+
forms, and re-exports before narrowing a rule and silently missing violations.
|
|
124
136
|
- `appliesTo: ["a declared union type or schema"]` and
|
|
125
137
|
`excludeIf: ["a type that mirrors a third-party format"]`, JSON arrays on
|
|
126
138
|
one line, scope a rule without widening its description: a finding stands
|
|
@@ -354,14 +366,18 @@ A convention a linter can decide exactly does not belong in a `RULE.md`.
|
|
|
354
366
|
Whichever route it takes, the draft is trialled on real files before anything
|
|
355
367
|
is written: the first 100 applicable files by default, the entire repo, or a
|
|
356
368
|
number the user types, with the plan's known cost shown first. The authoring
|
|
357
|
-
agent reads the saved report in `.adhere/trial/` and the source it names
|
|
358
|
-
the
|
|
359
|
-
|
|
360
|
-
the
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
369
|
+
agent reads the saved report in `.adhere/trial/` and the source it names. After
|
|
370
|
+
the first completed adhere trial, it is explicitly asked whether path/content
|
|
371
|
+
filters would help: recommend exact entries or explain why none are needed,
|
|
372
|
+
including the risk of hiding real violations. The user accepts the draft,
|
|
373
|
+
reviews findings, or asks for a revision. Findings and examples take optional
|
|
374
|
+
notes; revisions receive those notes and the filter recommendation, are
|
|
375
|
+
re-approved, and rerun the same sample. Newly filtered files are reported as
|
|
376
|
+
skipped, not replaced. The initial cap counts files that pass filters.
|
|
377
|
+
Skipping is allowed, but the write prompt then says `(not trialled)` and the
|
|
378
|
+
closing line says `Not trialled: …`; nothing is called validated on a skipped,
|
|
379
|
+
partial, or stale run. Native settings and approved plugins are trialled too,
|
|
380
|
+
without adhere-only filter recommendations.
|
|
365
381
|
|
|
366
382
|
A project plugin and its checked-in test go to
|
|
367
383
|
`.adhere/oxlint/<plugin>.mjs` and `.adhere/oxlint/<plugin>.test.mjs`; the
|
|
@@ -586,6 +602,10 @@ Per rule, read its findings and decide:
|
|
|
586
602
|
- Many hits at 0.80 to 0.90, mostly not violations: the description is too
|
|
587
603
|
broad. Narrow it, or scope it with `appliesTo` or `excludeIf`, before
|
|
588
604
|
raising its `threshold`: a threshold hides, a sentence explains.
|
|
605
|
+
- Hits in irrelevant files: consider local `include`/`exclude` path or content
|
|
606
|
+
filters before paying to judge them. Verify the boundary against real
|
|
607
|
+
violations and unflagged files; an absent import does not prove the library
|
|
608
|
+
is unused when the project uses wrappers or re-exports.
|
|
589
609
|
- Hits on code that follows the pattern through another API: the `must`
|
|
590
610
|
block is too specific. Use the repo's most general correct example.
|
|
591
611
|
- Right less than half the time and not fixable by wording: `level: warning`,
|
package/src/config.ts
CHANGED
|
@@ -71,6 +71,44 @@ export const Reads = Schema.Array(
|
|
|
71
71
|
Schema.Literals(["workspacePackages", "dependents", "symbols", "references"]),
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A JavaScript regular expression's source, checked here so a rule with a bad
|
|
76
|
+
* pattern fails to load rather than at the first file it would filter.
|
|
77
|
+
*/
|
|
78
|
+
const isValidRegexSource = (source: string): string | undefined => {
|
|
79
|
+
try {
|
|
80
|
+
new RegExp(source);
|
|
81
|
+
return undefined;
|
|
82
|
+
} catch (problem) {
|
|
83
|
+
return `is not a valid JavaScript regular expression: ${
|
|
84
|
+
problem instanceof Error ? problem.message : String(problem)
|
|
85
|
+
}`;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const RegexSource = Schema.String.check(
|
|
90
|
+
Schema.makeFilter(isValidRegexSource, {
|
|
91
|
+
toCode: () => ({ runtime: "Schema.makeFilter(isValidRegexSource)" }),
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One side of a rule's file filter: repository-relative globs for paths, and
|
|
97
|
+
* regular expression sources for the file's text as written. A rule's
|
|
98
|
+
* `include` and `exclude` each take this shape.
|
|
99
|
+
*/
|
|
100
|
+
export const RuleFilter = Schema.Struct({
|
|
101
|
+
paths: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
|
|
102
|
+
description:
|
|
103
|
+
"Repository-root-relative glob patterns, `/`-separated, as in `src/**` or `**/*.test.ts`.",
|
|
104
|
+
}),
|
|
105
|
+
content: Schema.optionalKey(Schema.Array(RegexSource)).annotate({
|
|
106
|
+
description:
|
|
107
|
+
"JavaScript regular expression sources, without `/` delimiters or flags, matched against the file's text as written, comments included.",
|
|
108
|
+
}),
|
|
109
|
+
}).annotate({ identifier: "RuleFilter" });
|
|
110
|
+
export interface RuleFilter extends Schema.Schema.Type<typeof RuleFilter> {}
|
|
111
|
+
|
|
74
112
|
/**
|
|
75
113
|
* A rule's examples, named in RFC 2119's words, which Jev reads as written. A
|
|
76
114
|
* requirement shows code that `must` be written and code that must `never`
|
|
@@ -109,6 +147,14 @@ export const Rule = Schema.Struct({
|
|
|
109
147
|
description:
|
|
110
148
|
"How a finding under the rule is reported: an `error`, the default, which fails the run, or a `warning`, for a nit or a rule that errs toward false findings, which does not.",
|
|
111
149
|
}),
|
|
150
|
+
include: Schema.optionalKey(RuleFilter).annotate({
|
|
151
|
+
description:
|
|
152
|
+
"Where the rule applies, beside its scope and `tests`: a file must match every configured category, and within `paths` or `content` any entry is enough. An omitted or empty list puts no condition on that category.",
|
|
153
|
+
}),
|
|
154
|
+
exclude: Schema.optionalKey(RuleFilter).annotate({
|
|
155
|
+
description:
|
|
156
|
+
"Where the rule does not apply: the same shape as `include`. Any matching path or content entry drops the file before it is judged, so no cached finding for it is read or reported.",
|
|
157
|
+
}),
|
|
112
158
|
appliesTo: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
|
|
113
159
|
description:
|
|
114
160
|
"Where the rule applies, each a description of code that breaks it: Jev is asked of each whether any code that breaks the rule is as it describes, and a finding stands only when it says yes to every one.",
|
package/src/create/ask.ts
CHANGED
|
@@ -155,6 +155,17 @@ const readsInstruction = (shape: Shape) => {
|
|
|
155
155
|
return `Declare the context Jev needs beside the code as \`reads: [${reads.map((read) => `"${read}"`).join(", ")}]\` ${shape.format === "typescript" ? "in `defineRule({...})`" : "in its front matter"}. A rule's \`reads\` is a declaration, not an enablement: the project's config still decides what Jev reads, and lint warns that the rule may be less accurate until a declared read is enabled.`;
|
|
156
156
|
};
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* What a rule may do to narrow the files it judges at all: a mechanical
|
|
160
|
+
* filter, taught when a rule is written or revised, never to an oxlint rule.
|
|
161
|
+
*/
|
|
162
|
+
const filtersInstruction = (shape: Shape) =>
|
|
163
|
+
`A rule may also narrow the files it judges before Jev sees them, with optional \`include\` and \`exclude\` objects. Each holds \`paths\`, globs relative to the repository root with forward slashes, matched as \`--filter\` matches them, and \`content\`, JavaScript regular expression sources without / delimiters or flags, tested against each file's original text, comments included. Any entry in a list suffices; \`include\` applies only when every list it sets matches, while a match in either \`exclude\` list drops the file. An absent object or an empty list restricts nothing. A filter is silent: a file it drops is never judged, so a wrong one is a false negative. Use \`paths\` for where the rule applies and \`content\` for what a file says; a filter is not a substitute for \`appliesTo\` or \`excludeIf\`, which ask Jev about the code. \`content\` can match a named package's import specifier, such as from "effect", or a directive, but a regular expression cannot resolve aliases or re-exports, know whether a name is a type or a value, or reliably tell code from comments and other strings; use \`paths\`, \`appliesTo\`, or a TypeScript rule for that. Because \`content\` entries are strings, escape a regex's backslashes: a regex ^\\s*import is written "^\\\\s*import" in both front matter JSON and a RULE.ts string. ${
|
|
164
|
+
shape.format === "typescript"
|
|
165
|
+
? 'Set them in defineRule({...}) as object literals, such as include: { paths: ["src/**"] }.'
|
|
166
|
+
: 'Set them in the front matter as one-line JSON objects, such as include: {"paths": ["src/**"]}.'
|
|
167
|
+
}`;
|
|
168
|
+
|
|
158
169
|
const typescriptFormat = (shape: Shape) =>
|
|
159
170
|
`Write it as a RULE.ts, because ${shape.formatReason} It imports defineRule from "@drkmttr/adhere" and default-exports defineRule({ description, details, must, never, reads, appendState }). appendState(state, file, Bun) runs before each request for the rule: file is { path, contents }, its path absolute, and Bun is Bun's API, as in Bun.file(path).text() or new Bun.Glob(pattern). What it returns is spread over the request's state; name each key it adds in the description, in backticks. Helpers go in the same file.`;
|
|
160
171
|
|
|
@@ -183,6 +194,8 @@ Rule id: ${id}
|
|
|
183
194
|
What it enforces: ${idea}
|
|
184
195
|
What Jev reads beside the rule: ${contextOf(shape)}
|
|
185
196
|
${readsInstruction(shape)}
|
|
197
|
+
${filtersInstruction(shape)}
|
|
198
|
+
Leave them out unless the idea itself names a kind of file, such as API routes or React components; the trial will settle whether they are needed.
|
|
186
199
|
${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
|
|
187
200
|
|
|
188
201
|
${reviewedOf(reviewed)}
|
|
@@ -255,6 +268,21 @@ export const inspectionPrompt = (report: string, id: string) =>
|
|
|
255
268
|
- Concrete changes to the rule's wording or examples, if any.
|
|
256
269
|
Do not edit files. Keep it short.`;
|
|
257
270
|
|
|
271
|
+
/**
|
|
272
|
+
* The first trial of an adhere rule asks the authoring agent, explicitly,
|
|
273
|
+
* whether the rule should narrow which files it judges. A filter is a silent
|
|
274
|
+
* false negative, so the agent names exact entries or says why none fit.
|
|
275
|
+
*/
|
|
276
|
+
export const filterAdvicePrompt = (report: string, id: string) =>
|
|
277
|
+
`adhere trialled the adhere rule ${id} on real files without installing it. The full report is at ${report}, and it holds the rule's source. Read the report, the rule, and the files it names, then answer in plain text:
|
|
278
|
+
- Whether the rule should carry \`include\` or \`exclude\` filters at all.
|
|
279
|
+
- If it should, the exact entries, as the rule's file writes them: in a RULE.md's front matter, one-line JSON objects such as include: {"paths": ["src/**"]}; in a RULE.ts, object literals such as include: { paths: ["src/**"] }. Say which list each entry belongs to and what it leaves out or keeps.
|
|
280
|
+
- If it should not, one sentence saying why the rule is better judging every file Jev reads.
|
|
281
|
+
|
|
282
|
+
Filters are mechanical and silent: a file one leaves out is never judged, so a wrong filter hides real violations. \`paths\` are repository-root-relative globs with forward slashes, matched as \`--filter\` matches them. \`content\` entries are JavaScript regex source strings without / delimiters or flags, tested against original text including comments. Content filters may match a named package's import specifier, for example from "effect". They cannot resolve aliases or re-exports, and matching text in comments or strings does not prove the file imports the package. Escape regex backslashes in JSON and TypeScript strings: the regex ^\\s*import is written "^\\\\s*import". Within each list, any entry may match; all nonempty inclusion lists must match, and a match in either exclusion list vetoes the file. Empty lists impose no restriction. Recommend a filter only when the evidence supports that boundary.
|
|
283
|
+
|
|
284
|
+
Do not suggest filters for an oxlint rule: those have no such fields. Do not edit files. Keep it short.`;
|
|
285
|
+
|
|
258
286
|
/** Asks for the adhere rule again, with what the trial found and what the user made of it. */
|
|
259
287
|
export const adhereRevisionPrompt = (
|
|
260
288
|
idea: string,
|
|
@@ -269,6 +297,8 @@ export const adhereRevisionPrompt = (
|
|
|
269
297
|
Revise the adhere rule ${id}. It enforces: ${idea}
|
|
270
298
|
What Jev reads beside the rule: ${contextOf(shape)}
|
|
271
299
|
${readsInstruction(shape)}
|
|
300
|
+
${filtersInstruction(shape)}
|
|
301
|
+
Apply, change, or remove \`include\` and \`exclude\` as the trial, the recommendation, and the user's verdicts support; keep a filter only while the evidence supports it.
|
|
272
302
|
${shape.format === "typescript" ? typescriptFormat(shape) : "Write it as a RULE.md."}
|
|
273
303
|
|
|
274
304
|
A trial judged real files against the current rule. What it found, and what the user made of it:
|
|
@@ -279,7 +309,7 @@ The current file:
|
|
|
279
309
|
|
|
280
310
|
${draft}
|
|
281
311
|
|
|
282
|
-
Keep what the evidence supports and change what it contradicts. Reply with only the corrected file, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
|
|
312
|
+
Keep what the evidence supports and change what it contradicts, including its filter recommendation. Reply with only the corrected file, starting with its ${shape.format === "typescript" ? "imports" : "front matter"}.`;
|
|
283
313
|
|
|
284
314
|
/** Asks for the oxlint plugin again, with what the trial found and what the user made of it. */
|
|
285
315
|
export const pluginRevisionPrompt = (
|
package/src/create/look.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* rail, and a spinner, as `gum spin` draws one, while an agent works.
|
|
5
5
|
*/
|
|
6
6
|
import { highlightedLines } from "#workflows/format.ts";
|
|
7
|
-
import { ruleBodyOf } from "#markdown.ts";
|
|
8
7
|
import { Status, clearingStatus } from "#services/Status.ts";
|
|
9
8
|
import { Console, Duration, Effect, Fiber } from "effect";
|
|
10
9
|
import { Prompt } from "effect/cli";
|
|
@@ -61,23 +60,16 @@ const columns = () => process.stderr.columns ?? 80;
|
|
|
61
60
|
export const indented = (text: string) =>
|
|
62
61
|
Bun.wrapAnsi(text, Math.max(columns() - 5, 20)).replaceAll("\n", "\n ");
|
|
63
62
|
|
|
64
|
-
/**
|
|
65
|
-
* Renders the body of a rule for preview, preserving its YAML frontmatter.
|
|
66
|
-
* The frontmatter is shown as-is so its metadata stays visible; the body is
|
|
67
|
-
* rendered with Bun's native Markdown-to-ANSI renderer.
|
|
68
|
-
*/
|
|
63
|
+
/** Renders Markdown for preview with Bun's native Markdown-to-ANSI renderer. */
|
|
69
64
|
export const renderMarkdown = (text: string) =>
|
|
70
65
|
Effect.gen(function* () {
|
|
71
66
|
const theme = yield* Prompt.Theme;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const rendered = Bun.markdown
|
|
75
|
-
.ansi(body, {
|
|
67
|
+
return Bun.markdown
|
|
68
|
+
.ansi(text, {
|
|
76
69
|
colors: theme.colors,
|
|
77
70
|
columns: Math.max(columns() - 4, 20),
|
|
78
71
|
})
|
|
79
72
|
.trimEnd();
|
|
80
|
-
return prefix + rendered;
|
|
81
73
|
});
|
|
82
74
|
|
|
83
75
|
type Color = (theme: Prompt.Theme) => string;
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* explicitly untested; it never counts as validation.
|
|
9
9
|
*/
|
|
10
10
|
import { type Agent } from "#create/agents.ts";
|
|
11
|
-
import { RuleNotCreated, asking, inspectionPrompt } from "#create/ask.ts";
|
|
12
|
-
import { line, note, said, spinning, warn } from "#create/look.ts";
|
|
11
|
+
import { RuleNotCreated, asking, filterAdvicePrompt, inspectionPrompt } from "#create/ask.ts";
|
|
12
|
+
import { line, note, renderMarkdown, said, spinning, warn } from "#create/look.ts";
|
|
13
13
|
import {
|
|
14
14
|
type TrialDraft,
|
|
15
15
|
type TrialFinding,
|
|
@@ -128,7 +128,11 @@ const showPreview = (scope: TrialScope, preview: TrialPreview) =>
|
|
|
128
128
|
if (preview.deferred > 0) {
|
|
129
129
|
yield* note(`${filesWord(preview.deferred)} wait for a later run.`);
|
|
130
130
|
}
|
|
131
|
-
if (preview.skipped > 0)
|
|
131
|
+
if (preview.skipped > 0) {
|
|
132
|
+
yield* note(
|
|
133
|
+
`${filesWord(preview.skipped)} skipped (too long, or left out by the rule's filters).`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
132
136
|
if (preview.requests === 0) {
|
|
133
137
|
yield* note("Nothing to send: the selected files have no pending requests.");
|
|
134
138
|
}
|
|
@@ -265,7 +269,7 @@ const showRun = (run: TrialRun) =>
|
|
|
265
269
|
);
|
|
266
270
|
yield* note(`${run.findings.length} finding${run.findings.length === 1 ? "" : "s"}.`);
|
|
267
271
|
if (run.judged === 0 && run.files > 0) {
|
|
268
|
-
yield* note("
|
|
272
|
+
yield* note("No request was sent: every selected file was cached or skipped.");
|
|
269
273
|
}
|
|
270
274
|
yield* note(`Report: ${run.reportPath}`);
|
|
271
275
|
for (const limitation of run.notes) yield* note(limitation);
|
|
@@ -286,6 +290,36 @@ const inspect = <D, R>(subject: TrialSubject<D, R>, run: TrialRun) =>
|
|
|
286
290
|
Effect.catch((cause) => Effect.succeed({ _tag: "Failed", message: messageOf(cause) } as const)),
|
|
287
291
|
);
|
|
288
292
|
|
|
293
|
+
type Advice =
|
|
294
|
+
| { readonly _tag: "Text"; readonly text: string }
|
|
295
|
+
| { readonly _tag: "Failed"; readonly message: string }
|
|
296
|
+
| { readonly _tag: "None" };
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The first adhere trial asks the authoring agent whether the rule should
|
|
300
|
+
* filter files. An oxlint rule has no filter fields, and a rerun already has
|
|
301
|
+
* the answer.
|
|
302
|
+
*/
|
|
303
|
+
const filterAdvice = <D, R>(subject: TrialSubject<D, R>, run: TrialRun, first: boolean) =>
|
|
304
|
+
first && run.draft.kind === "adhere"
|
|
305
|
+
? spinning(
|
|
306
|
+
`Asking ${subject.agent} whether the rule needs filters`,
|
|
307
|
+
asking(
|
|
308
|
+
subject.agent,
|
|
309
|
+
"to recommend filters",
|
|
310
|
+
subject.root,
|
|
311
|
+
filterAdvicePrompt(run.reportPath, subject.id),
|
|
312
|
+
),
|
|
313
|
+
).pipe(
|
|
314
|
+
Effect.map((text): Advice =>
|
|
315
|
+
text.trim() === "" ? { _tag: "None" } : { _tag: "Text", text: text.trim() },
|
|
316
|
+
),
|
|
317
|
+
Effect.catch((cause): Effect.Effect<Advice> =>
|
|
318
|
+
Effect.succeed({ _tag: "Failed", message: messageOf(cause) }),
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
: Effect.succeed<Advice>({ _tag: "None" });
|
|
322
|
+
|
|
289
323
|
const MAX_SHOWN = 40;
|
|
290
324
|
|
|
291
325
|
const showFindings = (run: TrialRun) =>
|
|
@@ -421,14 +455,27 @@ const reviewFindings = (run: TrialRun) =>
|
|
|
421
455
|
: ({ _tag: "Cancel" } as const);
|
|
422
456
|
});
|
|
423
457
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
458
|
+
/**
|
|
459
|
+
* What the agent reads when revising: the report, the counts, the filter
|
|
460
|
+
* recommendation the first trial asked for, and the user's verdicts.
|
|
461
|
+
*/
|
|
462
|
+
export const trialEvidence = (
|
|
463
|
+
run: TrialRun,
|
|
464
|
+
reviews: ReadonlyArray<FindingReview>,
|
|
465
|
+
advice?: string,
|
|
466
|
+
) => {
|
|
426
467
|
const lines = [
|
|
427
468
|
`Trial report: ${run.reportPath}`,
|
|
428
469
|
`Scope: ${run.scope.kind === "all" ? "the entire repo" : `the first ${run.scope.limit} files`}; ${filesWord(run.selectedFiles.length)} selected.`,
|
|
429
470
|
`Counts: ${run.files} files, ${run.judged} judged, ${run.cached} cached, ${run.skipped} skipped, ${run.waiting} waiting.`,
|
|
430
471
|
`Findings: ${run.findings.length}.`,
|
|
431
472
|
];
|
|
473
|
+
if (advice !== undefined) {
|
|
474
|
+
lines.push(
|
|
475
|
+
"The agent's filter recommendation from the first trial. It is historical: the latest trial report and the user's verdicts decide, and a filter the evidence contradicts should be removed.",
|
|
476
|
+
);
|
|
477
|
+
lines.push(advice);
|
|
478
|
+
}
|
|
432
479
|
if (reviews.length > 0) {
|
|
433
480
|
lines.push("The user's verdicts:");
|
|
434
481
|
for (const review of reviews) {
|
|
@@ -497,6 +544,8 @@ export const trialAndReview = <D, R>(subject: TrialSubject<D, R>) =>
|
|
|
497
544
|
yield* showPreview(scope, preview.preview);
|
|
498
545
|
|
|
499
546
|
let pinned: ReadonlyArray<string> | undefined;
|
|
547
|
+
let firstRun = true;
|
|
548
|
+
let advice: string | undefined;
|
|
500
549
|
for (;;) {
|
|
501
550
|
const result = yield* runOnce(subject, draft, scope, pinned);
|
|
502
551
|
if (result._tag === "Failed") {
|
|
@@ -510,10 +559,24 @@ export const trialAndReview = <D, R>(subject: TrialSubject<D, R>) =>
|
|
|
510
559
|
yield* showRun(run);
|
|
511
560
|
const inspection = yield* inspect(subject, run);
|
|
512
561
|
if (inspection._tag === "Text") {
|
|
513
|
-
if (inspection.text.trim() !== "")
|
|
562
|
+
if (inspection.text.trim() !== "") {
|
|
563
|
+
yield* line(yield* renderMarkdown(inspection.text.trim()));
|
|
564
|
+
}
|
|
514
565
|
} else {
|
|
515
566
|
yield* warn(`The agent could not read the report: ${inspection.message}`);
|
|
516
567
|
}
|
|
568
|
+
const filters = yield* filterAdvice(subject, run, firstRun);
|
|
569
|
+
firstRun = false;
|
|
570
|
+
if (filters._tag === "Text") {
|
|
571
|
+
advice = filters.text;
|
|
572
|
+
yield* note("The agent's filter recommendation:");
|
|
573
|
+
yield* line(filters.text);
|
|
574
|
+
yield* note(
|
|
575
|
+
"Accept the draft as it is, or ask the agent to revise it with this recommendation.",
|
|
576
|
+
);
|
|
577
|
+
} else if (filters._tag === "Failed") {
|
|
578
|
+
yield* warn(`The agent could not recommend filters: ${filters.message}`);
|
|
579
|
+
}
|
|
517
580
|
const decision = yield* reviewFindings(run);
|
|
518
581
|
if (decision._tag === "Accept") {
|
|
519
582
|
return { _tag: "Accepted", draft, run, reviews: decision.reviews } as const;
|
|
@@ -521,7 +584,7 @@ export const trialAndReview = <D, R>(subject: TrialSubject<D, R>) =>
|
|
|
521
584
|
if (decision._tag === "Cancel") {
|
|
522
585
|
return { _tag: "Cancelled", draft, reason: "The trial was cancelled." } as const;
|
|
523
586
|
}
|
|
524
|
-
const revised = yield* subject.revise(draft, trialEvidence(run, decision.reviews));
|
|
587
|
+
const revised = yield* subject.revise(draft, trialEvidence(run, decision.reviews, advice));
|
|
525
588
|
yield* subject.show(revised);
|
|
526
589
|
const again = yield* approved(subject, revised);
|
|
527
590
|
if (again._tag === "Declined") {
|
package/src/create/trial.ts
CHANGED
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
type TrialRun,
|
|
30
30
|
type TrialScope,
|
|
31
31
|
} from "#create/oxlint-trial.ts";
|
|
32
|
+
import { filtersNeedText, hasFilters, ruleAppliesTo } from "#filters.ts";
|
|
32
33
|
import { parseRuleMarkdown } from "#markdown.ts";
|
|
34
|
+
import { WalkUnavailable } from "#models/Audit.ts";
|
|
33
35
|
import { importRule, judgesFile } from "#rules.ts";
|
|
34
36
|
import {
|
|
35
37
|
AdhereConfig,
|
|
@@ -176,27 +178,70 @@ const eligibleFiles = (root: string, config: ResolvedConfig) =>
|
|
|
176
178
|
});
|
|
177
179
|
|
|
178
180
|
interface Selection {
|
|
181
|
+
/** The files planning may judge: the selected sample, less those filters leave out. */
|
|
179
182
|
readonly files: ReadonlyArray<SourceFile>;
|
|
180
183
|
/** Sorted repo-relative paths, exactly as run and as the report names them. */
|
|
181
184
|
readonly paths: ReadonlyArray<string>;
|
|
182
185
|
/** Applicable files before the cap. */
|
|
183
186
|
readonly eligible: number;
|
|
187
|
+
/** Pinned paths the draft's filters now leave out: kept in the sample, never planned. */
|
|
188
|
+
readonly excluded: ReadonlyArray<string>;
|
|
184
189
|
}
|
|
185
190
|
|
|
186
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Which candidate files the draft's own include/exclude filters allow. Only a
|
|
193
|
+
* content filter reads the file; a path-only filter is decided from its path,
|
|
194
|
+
* and an unfiltered rule allows every file.
|
|
195
|
+
*/
|
|
196
|
+
const allowedFiles = (
|
|
197
|
+
rule: Rule,
|
|
198
|
+
walker: SourceWalker["Service"],
|
|
199
|
+
candidates: ReadonlyArray<SourceFile>,
|
|
200
|
+
root: string,
|
|
201
|
+
): Effect.Effect<ReadonlyMap<SourceFile, boolean>, WalkUnavailable> => {
|
|
202
|
+
if (!hasFilters(rule)) return Effect.succeed(new Map());
|
|
203
|
+
if (!filtersNeedText(rule)) {
|
|
204
|
+
return Effect.succeed(
|
|
205
|
+
new Map(
|
|
206
|
+
candidates.map(
|
|
207
|
+
(file) => [file, ruleAppliesTo(rule, display(root, file.path), "")] as const,
|
|
208
|
+
),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return Effect.forEach(
|
|
213
|
+
candidates,
|
|
214
|
+
(file) =>
|
|
215
|
+
Effect.map(walker.read(file), ({ lines }) => {
|
|
216
|
+
const allowed = ruleAppliesTo(rule, display(root, file.path), lines.join("\n"));
|
|
217
|
+
return [file, allowed] as const;
|
|
218
|
+
}),
|
|
219
|
+
{ concurrency: 8 },
|
|
220
|
+
).pipe(Effect.map((verdicts) => new Map(verdicts)));
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The cap is on files, cached ones included, and filters apply before it on a
|
|
225
|
+
* first run. A pinned rerun keeps its exact set: files the draft's filters now
|
|
226
|
+
* leave out stay in the sample but are not planned, so before and after remain
|
|
227
|
+
* comparable. A file that is missing, or ineligible for any other reason, is
|
|
228
|
+
* still an error rather than a skip.
|
|
229
|
+
*/
|
|
187
230
|
const selectionOf = (
|
|
188
231
|
root: string,
|
|
189
232
|
eligible: ReadonlyArray<SourceFile>,
|
|
190
233
|
applies: (file: SourceFile) => boolean,
|
|
191
234
|
scope: TrialScope,
|
|
192
235
|
pinned: ReadonlyArray<string> | undefined,
|
|
236
|
+
allowed: ReadonlyMap<SourceFile, boolean> = new Map(),
|
|
193
237
|
): Effect.Effect<Selection, TrialUnavailable> =>
|
|
194
238
|
Effect.gen(function* () {
|
|
195
|
-
const
|
|
196
|
-
const
|
|
239
|
+
const candidates = [...eligible].filter(applies).sort((a, b) => a.path.localeCompare(b.path));
|
|
240
|
+
const allowedByPath = (file: SourceFile) => allowed.get(file) !== false;
|
|
241
|
+
const sorted = candidates.filter(allowedByPath);
|
|
197
242
|
if (pinned !== undefined) {
|
|
198
243
|
const wanted = [...new Set(pinned.map(normalized))];
|
|
199
|
-
const known = new Map(
|
|
244
|
+
const known = new Map(candidates.map((file) => [display(root, file.path), file] as const));
|
|
200
245
|
const missing = wanted.filter((file) => !known.has(file));
|
|
201
246
|
if (missing.length > 0) {
|
|
202
247
|
return yield* trialError(
|
|
@@ -204,10 +249,13 @@ const selectionOf = (
|
|
|
204
249
|
`The pinned trial files are no longer eligible: ${missing.join(", ")}. Run the trial again without pinned files.`,
|
|
205
250
|
);
|
|
206
251
|
}
|
|
252
|
+
const files = wanted.map((file) => known.get(file)!);
|
|
253
|
+
const excluded = files.filter((file) => !allowedByPath(file));
|
|
207
254
|
return {
|
|
208
|
-
files:
|
|
255
|
+
files: files.filter(allowedByPath),
|
|
209
256
|
paths: wanted,
|
|
210
257
|
eligible: sorted.length,
|
|
258
|
+
excluded: excluded.map((file) => display(root, file.path)),
|
|
211
259
|
};
|
|
212
260
|
}
|
|
213
261
|
const limit = scope.kind === "files" ? scope.limit : undefined;
|
|
@@ -222,6 +270,7 @@ const selectionOf = (
|
|
|
222
270
|
files: chosen,
|
|
223
271
|
paths: chosen.map((file) => display(root, file.path)),
|
|
224
272
|
eligible: sorted.length,
|
|
273
|
+
excluded: [],
|
|
225
274
|
};
|
|
226
275
|
});
|
|
227
276
|
|
|
@@ -245,7 +294,12 @@ const planTrial = (
|
|
|
245
294
|
|
|
246
295
|
const requestsOf = (plan: AuditPlan): number => plan.requests + (plan.cachedLocateRequests ?? 0);
|
|
247
296
|
|
|
248
|
-
const previewOf = (
|
|
297
|
+
const previewOf = (
|
|
298
|
+
plan: AuditPlan,
|
|
299
|
+
eligible: number,
|
|
300
|
+
selected: number,
|
|
301
|
+
filteredOut = 0,
|
|
302
|
+
): TrialPreview => ({
|
|
249
303
|
eligible,
|
|
250
304
|
selected,
|
|
251
305
|
requests: requestsOf(plan),
|
|
@@ -253,7 +307,7 @@ const previewOf = (plan: AuditPlan, eligible: number, selected: number): TrialPr
|
|
|
253
307
|
...(plan.price === undefined ? {} : { price: plan.price }),
|
|
254
308
|
cached: plan.cached,
|
|
255
309
|
deferred: plan.deferred,
|
|
256
|
-
skipped: plan.skipped,
|
|
310
|
+
skipped: plan.skipped + filteredOut,
|
|
257
311
|
model: plan.model,
|
|
258
312
|
...(plan.provider === undefined ? {} : { provider: plan.provider }),
|
|
259
313
|
});
|
|
@@ -346,19 +400,15 @@ export const previewTrial = (draft: TrialDraft, scope: TrialScope, options: Tria
|
|
|
346
400
|
const rule = yield* draftRule(draft);
|
|
347
401
|
const config = yield* draftConfig(root, raw, draft.id, rule);
|
|
348
402
|
const { walker, files } = yield* eligibleFiles(root, config);
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
(file) => judgesFile(rule, file.test === true),
|
|
353
|
-
scope,
|
|
354
|
-
options.files,
|
|
355
|
-
);
|
|
403
|
+
const applies = (file: SourceFile) => judgesFile(rule, file.test === true);
|
|
404
|
+
const allowed = yield* allowedFiles(rule, walker, files.filter(applies), root);
|
|
405
|
+
const selected = yield* selectionOf(root, files, applies, scope, options.files, allowed);
|
|
356
406
|
const cache = options.services?.cache ?? AuditCacheAt(root);
|
|
357
407
|
const context = yield* Layer.build(
|
|
358
408
|
Layer.mergeAll(Layer.succeed(AdhereConfig, config), cache),
|
|
359
409
|
);
|
|
360
410
|
const plan = yield* planTrial(selected, walker, context, root);
|
|
361
|
-
return previewOf(plan, selected.eligible, selected.paths.length);
|
|
411
|
+
return previewOf(plan, selected.eligible, selected.paths.length, selected.excluded.length);
|
|
362
412
|
}),
|
|
363
413
|
).pipe(
|
|
364
414
|
Effect.catchTag("ConfigUnavailable", (problem) =>
|
|
@@ -454,13 +504,9 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
|
|
|
454
504
|
const rule = yield* draftRule(draft);
|
|
455
505
|
const config = yield* draftConfig(root, raw, draft.id, rule);
|
|
456
506
|
const { walker, files } = yield* eligibleFiles(root, config);
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
(file) => judgesFile(rule, file.test === true),
|
|
461
|
-
scope,
|
|
462
|
-
options.files,
|
|
463
|
-
);
|
|
507
|
+
const applies = (file: SourceFile) => judgesFile(rule, file.test === true);
|
|
508
|
+
const allowed = yield* allowedFiles(rule, walker, files.filter(applies), root);
|
|
509
|
+
const selected = yield* selectionOf(root, files, applies, scope, options.files, allowed);
|
|
464
510
|
const cache = options.services?.cache ?? AuditCacheAt(root);
|
|
465
511
|
const jev =
|
|
466
512
|
options.services?.jev ??
|
|
@@ -523,6 +569,11 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
|
|
|
523
569
|
`${result.skipped} selected files were too long to judge with this rule and its context.`,
|
|
524
570
|
]
|
|
525
571
|
: []),
|
|
572
|
+
...(selected.excluded.length > 0
|
|
573
|
+
? [
|
|
574
|
+
`${selected.excluded.length} pinned files are no longer in the draft's include/exclude filters and were skipped: ${selected.excluded.join(", ")}.`,
|
|
575
|
+
]
|
|
576
|
+
: []),
|
|
526
577
|
...(config.typeAware
|
|
527
578
|
? [
|
|
528
579
|
"Type-aware reads were requested; symbols and references came from the installed TypeScript compiler.",
|
|
@@ -540,6 +591,7 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
|
|
|
540
591
|
scope,
|
|
541
592
|
eligible: selected.eligible,
|
|
542
593
|
selectedFiles: selected.paths,
|
|
594
|
+
...(selected.excluded.length === 0 ? {} : { excludedFiles: selected.excluded }),
|
|
543
595
|
plan: {
|
|
544
596
|
model: plan.model,
|
|
545
597
|
...(plan.provider === undefined ? {} : { provider: plan.provider }),
|
|
@@ -570,10 +622,10 @@ export const runTrial = (draft: TrialDraft, scope: TrialScope, options: TrialRun
|
|
|
570
622
|
scope,
|
|
571
623
|
revision,
|
|
572
624
|
selectedFiles: selected.paths,
|
|
573
|
-
files: result.files,
|
|
625
|
+
files: result.files + selected.excluded.length,
|
|
574
626
|
judged: result.judged,
|
|
575
627
|
cached: result.cached,
|
|
576
|
-
skipped: result.skipped,
|
|
628
|
+
skipped: result.skipped + selected.excluded.length,
|
|
577
629
|
waiting: result.waiting,
|
|
578
630
|
findings: result.findings.map((finding) => trialFinding(root, finding)),
|
|
579
631
|
reportPath,
|
package/src/create/wizard.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
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
41
|
import { isSharedRepo, refreshIndexIfShared, refreshReadme, validateReadme } from "#readme.ts";
|
|
42
42
|
import { importRule } from "#rules.ts";
|
|
43
43
|
import { findConfigFile, loadConfigFile } from "#services/AdhereConfig.ts";
|
|
@@ -141,8 +141,10 @@ const problemsOf = (text: string, file: string, format: Shape["format"]) =>
|
|
|
141
141
|
*/
|
|
142
142
|
const showDraft = (text: string, format: Shape["format"]) =>
|
|
143
143
|
Effect.gen(function* () {
|
|
144
|
-
|
|
145
|
-
|
|
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`);
|
|
146
148
|
});
|
|
147
149
|
|
|
148
150
|
/** A RULE.ts that imported, with its wording strays, or one that did not import. */
|
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);
|
package/src/markdown.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
|
|
1
|
+
import { ConfigUnavailable, type Example, Reads, type Rule, RuleFilter } from "#config.ts";
|
|
2
2
|
import { Effect, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -34,16 +34,25 @@ import { Effect, Schema } from "effect";
|
|
|
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
|
+
* `include` and `exclude`, its file filters, are JSON objects on one line, as
|
|
38
|
+
* in `include: {"paths": ["src/**"], "content": ["^import"]}`: a file
|
|
39
|
+
* must match every `include` category, any match in a category is enough, and
|
|
40
|
+
* any `exclude` match drops it. `content` takes regular expression sources
|
|
41
|
+
* matched against the file as written, comments included.
|
|
37
42
|
* `reads` is a JSON array of the same names, as in `reads: ["dependents"]`:
|
|
38
43
|
* it declares what the rule needs, while the config still decides what runs.
|
|
39
44
|
*/
|
|
40
45
|
const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
46
|
+
/** A filter object on one line, as in `include: {"paths": ["src/**"]}`. */
|
|
47
|
+
const Filters = Schema.fromJsonString(RuleFilter);
|
|
41
48
|
|
|
42
49
|
const FrontMatter = Schema.Struct({
|
|
43
50
|
description: Schema.String,
|
|
44
51
|
threshold: Schema.optionalKey(Schema.FiniteFromString),
|
|
45
52
|
tests: Schema.optionalKey(Schema.Literals(["only", "include"])),
|
|
46
53
|
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
54
|
+
include: Schema.optionalKey(Filters),
|
|
55
|
+
exclude: Schema.optionalKey(Filters),
|
|
47
56
|
appliesTo: Schema.optionalKey(Matchers),
|
|
48
57
|
excludeIf: Schema.optionalKey(Matchers),
|
|
49
58
|
reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
|
package/src/workflows/audit.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type Rules,
|
|
10
10
|
} from "#config.ts";
|
|
11
11
|
import { type Excerpt, type Range } from "#excerpt.ts";
|
|
12
|
+
import { contentPasses, filtersNeedText, hasFilters, ruleAppliesTo } from "#filters.ts";
|
|
12
13
|
import { type ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
13
14
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
14
15
|
import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
|
|
@@ -147,6 +148,8 @@ const fingerprintOf = (model: string, reads: string, rule: Rule) => {
|
|
|
147
148
|
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
148
149
|
const options =
|
|
149
150
|
rule.optionValues === undefined ? "" : `\u0000${JSON.stringify(rule.optionValues)}`;
|
|
151
|
+
// Filters decide which files a rule may judge, not what Jev answers, so they
|
|
152
|
+
// do not change the fingerprint: applicability is checked before the cache.
|
|
150
153
|
return sha256(
|
|
151
154
|
`${model}\u0000${LAYOUT}${reads}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${options}`,
|
|
152
155
|
);
|
|
@@ -352,7 +355,12 @@ const sameSuppressions = (left: Suppressions, right: Suppressions): boolean => {
|
|
|
352
355
|
);
|
|
353
356
|
};
|
|
354
357
|
|
|
355
|
-
/**
|
|
358
|
+
/**
|
|
359
|
+
* Validate code, ignore policy, and text-dependent filters before sending a
|
|
360
|
+
* request or showing a cached excerpt. A comment-only edit keeps the code hash
|
|
361
|
+
* but can change what a content filter sees, so those filters are rechecked
|
|
362
|
+
* against the new text; path filters cannot change for the same file.
|
|
363
|
+
*/
|
|
356
364
|
const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeComments: boolean) {
|
|
357
365
|
const original = yield* (yield* SourceWalker).read(plan.file);
|
|
358
366
|
const { lines, suppressions } = normalized(original.lines, includeComments);
|
|
@@ -364,6 +372,17 @@ const reloadFile = Effect.fn("audit.reload")(function* (plan: FilePlan, includeC
|
|
|
364
372
|
message: `${plan.file.path}: source changed since planning. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
|
|
365
373
|
});
|
|
366
374
|
}
|
|
375
|
+
let written: string | undefined;
|
|
376
|
+
const dropped = Object.entries(plan.prepared).flatMap(([id, { rule }]) =>
|
|
377
|
+
filtersNeedText(rule) && !contentPasses(rule, (written ??= original.lines.join("\n")))
|
|
378
|
+
? [id]
|
|
379
|
+
: [],
|
|
380
|
+
);
|
|
381
|
+
if (dropped.length > 0) {
|
|
382
|
+
return yield* WalkUnavailable.make({
|
|
383
|
+
message: `${plan.file.path}: source changed since planning, and content filters no longer apply to ${dropped.join(", ")}. Rerun adhere lint to create a fresh plan; changed contents were not sent or cached under the old content key. Files already completed remain cached.`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
367
386
|
return { file: { ...plan.file, lines }, original: original.lines } satisfies LoadedFile;
|
|
368
387
|
});
|
|
369
388
|
|
|
@@ -455,15 +474,17 @@ export const planAudit = (
|
|
|
455
474
|
);
|
|
456
475
|
|
|
457
476
|
// The rules that judge a file: those scoped to it, of which only the rules that say `tests`
|
|
458
|
-
// judge a test,
|
|
459
|
-
|
|
477
|
+
// judge a test, none that an adhere-ignore-file comment names, and none whose own
|
|
478
|
+
// include/exclude filters leave this file out.
|
|
479
|
+
const configuredRules = (file: SourceFile, suppressions: Suppressions, text: () => string) =>
|
|
460
480
|
Record.filter(
|
|
461
481
|
config.scopedRules === undefined
|
|
462
482
|
? config.rules
|
|
463
483
|
: applicableRules(file.path, config.scopedRules),
|
|
464
484
|
(rule, id) =>
|
|
465
485
|
judgesFile(rule, file.test === true) &&
|
|
466
|
-
!suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) }))
|
|
486
|
+
!suppressions.file.has(shownId({ id, preset: presetOfRule.get(rule) })) &&
|
|
487
|
+
(!hasFilters(rule) || ruleAppliesTo(rule, fromRoot.get(file.path)!, text())),
|
|
467
488
|
);
|
|
468
489
|
|
|
469
490
|
// Model, thresholds, and presets are fixed per plan; share immutable descriptors by Rule identity.
|
|
@@ -501,7 +522,16 @@ export const planAudit = (
|
|
|
501
522
|
const planFile = Effect.fn("audit.plan")(function* (file: SourceFile) {
|
|
502
523
|
const original = yield* walker.read(file);
|
|
503
524
|
const { lines, suppressions } = normalized(original.lines, config.includeComments === true);
|
|
504
|
-
|
|
525
|
+
// A rule's content filters read the file as written, comments and all, even
|
|
526
|
+
// when Jev is sent code without them. The decision is kept in `prepared`;
|
|
527
|
+
// reloading rechecks only those filters, in case a comment-only edit changed
|
|
528
|
+
// their answer without changing the code hash.
|
|
529
|
+
let written: string | undefined;
|
|
530
|
+
const rules = configuredRules(
|
|
531
|
+
file,
|
|
532
|
+
suppressions,
|
|
533
|
+
() => (written ??= original.lines.join("\n")),
|
|
534
|
+
);
|
|
505
535
|
const code = lines.join("\n");
|
|
506
536
|
const readState: ReadState = {
|
|
507
537
|
...(workspacePackages === undefined
|