@drkmttr/adhere 0.6.1 → 0.7.1
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 +77 -31
- package/package.json +7 -7
- package/presets/effect/basics/external-calls-are-resilient.md +6 -2
- package/presets/effect/basics/fn-for-named-effects.md +10 -2
- package/presets/effect/basics/gen-for-sequencing.md +10 -2
- package/presets/effect/basics/instrument-with-pipe.md +9 -2
- package/presets/effect/cli/handlers-delegate.md +13 -2
- package/presets/effect/config/business-logic-depends-on-config-service.md +9 -2
- package/presets/effect/config/secrets-are-redacted.md +6 -2
- package/presets/effect/config/tests-provide-values-directly.md +8 -2
- package/presets/effect/config/validate-with-schema.md +9 -2
- package/presets/effect/data/brand-meaningful-primitives.md +11 -2
- package/presets/effect/data/decode-json-with-schema.md +6 -2
- package/presets/effect/data/records-are-schema-classes.md +9 -2
- package/presets/effect/data/variants-are-tagged-unions.md +9 -2
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
- package/presets/effect/errors/defects-for-bugs.md +13 -2
- package/presets/effect/errors/domain-errors-are-tagged.md +6 -2
- package/presets/effect/errors/wrap-external-errors.md +7 -2
- package/presets/effect/platform/os-access-through-platform-services.md +8 -2
- package/presets/effect/services/fresh-layer-per-test.md +9 -2
- package/presets/effect/services/memoize-parameterized-layers.md +9 -2
- package/presets/effect/services/methods-have-no-requirements.md +9 -2
- package/presets/effect/services/no-mutable-state.md +9 -2
- package/presets/effect/services/provide-at-entry.md +7 -2
- package/presets/effect/services/test-layers-are-in-memory.md +12 -2
- package/presets/effect/testing/test-clock-for-time.md +11 -2
- package/presets/effect/testing/test-random.md +9 -2
- package/skills/adhere/SKILL.md +24 -17
- package/src/cli.ts +71 -36
- package/src/config.ts +72 -13
- package/src/init.ts +10 -8
- package/src/markdown.ts +59 -25
- package/src/services/Jev.http.ts +27 -5
- package/src/services/Jev.ts +45 -31
- package/src/services/SourceWalker.ts +47 -23
- package/src/workflows/audit.ts +108 -61
- package/src/workflows/format.ts +51 -8
package/src/workflows/audit.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Examples, examplesOf, type Rule, type RuleId } from "#config.ts";
|
|
2
2
|
import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
3
3
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
4
4
|
import { AuditCache, type Judgment, sha256 } from "#services/AuditCache.ts";
|
|
@@ -18,8 +18,7 @@ import { type Crypto, Effect, Record } from "effect";
|
|
|
18
18
|
export interface Finding {
|
|
19
19
|
readonly rule: RuleId;
|
|
20
20
|
readonly description: string;
|
|
21
|
-
readonly
|
|
22
|
-
readonly avoid?: string;
|
|
21
|
+
readonly examples: Examples;
|
|
23
22
|
readonly file: string;
|
|
24
23
|
readonly line: number;
|
|
25
24
|
readonly snippet: string;
|
|
@@ -31,11 +30,13 @@ export interface AuditResult {
|
|
|
31
30
|
readonly judged: number;
|
|
32
31
|
readonly cached: number;
|
|
33
32
|
readonly skipped: number;
|
|
33
|
+
/** Files whose checks `--limit` left for a later run, none judged in this one. */
|
|
34
|
+
readonly waiting: number;
|
|
34
35
|
readonly findings: ReadonlyArray<Finding>;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
interface FileResult {
|
|
38
|
-
readonly status: "judged" | "cached" | "skipped";
|
|
39
|
+
readonly status: "judged" | "cached" | "skipped" | "waiting";
|
|
39
40
|
readonly findings: ReadonlyArray<Finding>;
|
|
40
41
|
}
|
|
41
42
|
|
|
@@ -70,6 +71,8 @@ export interface FilePlan {
|
|
|
70
71
|
/** Cached judgments still valid for the file's content and the rule's text. */
|
|
71
72
|
readonly kept: Readonly<Record<RuleId, Judgment>>;
|
|
72
73
|
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
74
|
+
/** Pending checks `--limit` leaves for a later run. */
|
|
75
|
+
readonly deferred: number;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
@@ -83,6 +86,8 @@ export interface AuditPlan {
|
|
|
83
86
|
readonly cached: number;
|
|
84
87
|
/** Files too long to judge. */
|
|
85
88
|
readonly skipped: number;
|
|
89
|
+
/** Checks `--limit` leaves for a later run. */
|
|
90
|
+
readonly deferred: number;
|
|
86
91
|
/** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
|
|
87
92
|
readonly requests: number;
|
|
88
93
|
}
|
|
@@ -109,69 +114,110 @@ const inFile =
|
|
|
109
114
|
: problem,
|
|
110
115
|
);
|
|
111
116
|
|
|
112
|
-
|
|
117
|
+
/**
|
|
118
|
+
* The plan with at most `limit` checks left to judge, taken in path order.
|
|
119
|
+
* Past the limit a file's other checks wait for a later run; judgments are
|
|
120
|
+
* cached, so a rerun picks up where this one stopped.
|
|
121
|
+
*/
|
|
122
|
+
const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyArray<FilePlan> => {
|
|
123
|
+
let left = limit;
|
|
124
|
+
return planned.map((plan) => {
|
|
125
|
+
const ids = Object.keys(plan.pending);
|
|
126
|
+
const taken = new Set(ids.slice(0, left));
|
|
127
|
+
left -= taken.size;
|
|
128
|
+
return taken.size === ids.length
|
|
129
|
+
? plan
|
|
130
|
+
: {
|
|
131
|
+
...plan,
|
|
132
|
+
pending: Record.filter(plan.pending, (_, id) => taken.has(id)),
|
|
133
|
+
deferred: ids.length - taken.size,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export interface PlanOptions {
|
|
139
|
+
/** Judge at most this many checks; the rest wait for a later run. */
|
|
140
|
+
readonly limit?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const planAudit = (
|
|
144
|
+
options: PlanOptions = {},
|
|
145
|
+
): Effect.Effect<
|
|
113
146
|
AuditPlan,
|
|
114
147
|
WalkUnavailable,
|
|
115
148
|
AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
|
|
116
|
-
>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
> =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const config = yield* AdhereConfig;
|
|
152
|
+
const cache = yield* AuditCache;
|
|
153
|
+
const files = yield* (yield* SourceWalker).files;
|
|
120
154
|
|
|
121
|
-
|
|
122
|
-
|
|
155
|
+
const configuredRules = (file: string) =>
|
|
156
|
+
config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
|
|
123
157
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
159
|
+
const rules = configuredRules(file.path);
|
|
160
|
+
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
161
|
+
if (!fits(file.lines, rules)) {
|
|
162
|
+
return {
|
|
163
|
+
file,
|
|
164
|
+
skipped: true,
|
|
165
|
+
hash: "",
|
|
166
|
+
prepared: {},
|
|
167
|
+
kept: {},
|
|
168
|
+
pending: {},
|
|
169
|
+
deferred: 0,
|
|
170
|
+
} satisfies FilePlan;
|
|
171
|
+
}
|
|
172
|
+
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
173
|
+
Object.entries(rules),
|
|
174
|
+
([id, rule]) =>
|
|
175
|
+
Effect.map(
|
|
176
|
+
fingerprintOf(config.model, rule),
|
|
177
|
+
(fingerprint) =>
|
|
178
|
+
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
179
|
+
),
|
|
180
|
+
).pipe(Effect.map(Record.fromEntries));
|
|
181
|
+
const hash = yield* sha256(file.lines.join("\n"));
|
|
182
|
+
const entry = yield* cache.get(file.path);
|
|
183
|
+
const remembered = entry?.hash === hash ? entry.judgments : {};
|
|
184
|
+
const kept = Record.filter(
|
|
185
|
+
remembered,
|
|
186
|
+
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
187
|
+
);
|
|
188
|
+
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
128
189
|
return {
|
|
129
190
|
file,
|
|
130
|
-
skipped:
|
|
131
|
-
hash
|
|
132
|
-
prepared
|
|
133
|
-
kept
|
|
134
|
-
pending
|
|
191
|
+
skipped: false,
|
|
192
|
+
hash,
|
|
193
|
+
prepared,
|
|
194
|
+
kept,
|
|
195
|
+
pending,
|
|
196
|
+
deferred: 0,
|
|
135
197
|
} satisfies FilePlan;
|
|
136
|
-
}
|
|
137
|
-
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
138
|
-
Object.entries(rules),
|
|
139
|
-
([id, rule]) =>
|
|
140
|
-
Effect.map(
|
|
141
|
-
fingerprintOf(config.model, rule),
|
|
142
|
-
(fingerprint) =>
|
|
143
|
-
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
144
|
-
),
|
|
145
|
-
).pipe(Effect.map(Record.fromEntries));
|
|
146
|
-
const hash = yield* sha256(file.lines.join("\n"));
|
|
147
|
-
const entry = yield* cache.get(file.path);
|
|
148
|
-
const remembered = entry?.hash === hash ? entry.judgments : {};
|
|
149
|
-
const kept = Record.filter(
|
|
150
|
-
remembered,
|
|
151
|
-
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
152
|
-
);
|
|
153
|
-
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
154
|
-
return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
|
|
155
|
-
});
|
|
198
|
+
});
|
|
156
199
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
200
|
+
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
201
|
+
const everything: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
202
|
+
concurrency: 8,
|
|
203
|
+
});
|
|
204
|
+
const planned =
|
|
205
|
+
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
206
|
+
const judged = planned.filter((plan) => !plan.skipped);
|
|
207
|
+
const total = (count: (plan: FilePlan) => number) =>
|
|
208
|
+
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
209
|
+
return {
|
|
210
|
+
files: planned,
|
|
211
|
+
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
212
|
+
checks: total((plan) => sizeOf(plan.prepared)),
|
|
213
|
+
cached: total((plan) => sizeOf(plan.kept)),
|
|
214
|
+
skipped: planned.length - judged.length,
|
|
215
|
+
deferred: total((plan) => plan.deferred),
|
|
216
|
+
requests: total((plan) =>
|
|
217
|
+
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
218
|
+
),
|
|
219
|
+
};
|
|
160
220
|
});
|
|
161
|
-
const judged = planned.filter((plan) => !plan.skipped);
|
|
162
|
-
const total = (count: (plan: FilePlan) => number) =>
|
|
163
|
-
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
164
|
-
return {
|
|
165
|
-
files: planned,
|
|
166
|
-
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
167
|
-
checks: total((plan) => sizeOf(plan.prepared)),
|
|
168
|
-
cached: total((plan) => sizeOf(plan.kept)),
|
|
169
|
-
skipped: planned.length - judged.length,
|
|
170
|
-
requests: total((plan) =>
|
|
171
|
-
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
172
|
-
),
|
|
173
|
-
};
|
|
174
|
-
});
|
|
175
221
|
|
|
176
222
|
/**
|
|
177
223
|
* Judges what the plan left pending and locates the findings, a file at a
|
|
@@ -192,6 +238,7 @@ export const executeAudit = (
|
|
|
192
238
|
prepared,
|
|
193
239
|
kept,
|
|
194
240
|
pending,
|
|
241
|
+
deferred,
|
|
195
242
|
}: FilePlan) {
|
|
196
243
|
const probabilities = isEmpty(pending)
|
|
197
244
|
? {}
|
|
@@ -233,8 +280,7 @@ export const executeAudit = (
|
|
|
233
280
|
{
|
|
234
281
|
rule: id,
|
|
235
282
|
description: k.rule.description,
|
|
236
|
-
|
|
237
|
-
avoid: k.rule.avoid,
|
|
283
|
+
examples: examplesOf(k.rule),
|
|
238
284
|
file: file.path,
|
|
239
285
|
line: judgment.line,
|
|
240
286
|
snippet: judgment.snippet ?? "",
|
|
@@ -245,7 +291,7 @@ export const executeAudit = (
|
|
|
245
291
|
})
|
|
246
292
|
.sort((a, b) => a.line - b.line);
|
|
247
293
|
return {
|
|
248
|
-
result: fileResult(cached ? "cached" : "judged", findings),
|
|
294
|
+
result: fileResult(cached ? (deferred > 0 ? "waiting" : "cached") : "judged", findings),
|
|
249
295
|
requests:
|
|
250
296
|
(isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
|
|
251
297
|
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
@@ -276,6 +322,7 @@ export const executeAudit = (
|
|
|
276
322
|
judged: count("judged"),
|
|
277
323
|
cached: count("cached"),
|
|
278
324
|
skipped: count("skipped"),
|
|
325
|
+
waiting: count("waiting"),
|
|
279
326
|
findings: results.flatMap((result) => result.findings),
|
|
280
327
|
};
|
|
281
328
|
});
|
|
@@ -285,6 +332,6 @@ export const runAudit: Effect.Effect<
|
|
|
285
332
|
AuditResult,
|
|
286
333
|
WalkUnavailable | JevUnavailable,
|
|
287
334
|
AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
288
|
-
> = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
|
|
335
|
+
> = Effect.flatMap(planAudit(), (plan) => executeAudit(plan));
|
|
289
336
|
|
|
290
337
|
export { type RenderOptions, render } from "#workflows/format.ts";
|
package/src/workflows/format.ts
CHANGED
|
@@ -97,16 +97,20 @@ const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line
|
|
|
97
97
|
];
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* The code to write as the hint. A rule with only code not to write shows
|
|
102
|
+
* that instead, labeled with its word: `never`, or `should not`.
|
|
103
|
+
*/
|
|
101
104
|
const hint = (finding: Finding): ReadonlyArray<Line> => {
|
|
105
|
+
const { good, bad } = finding.examples;
|
|
102
106
|
const [label, code] =
|
|
103
|
-
|
|
107
|
+
good === undefined ? [bad?.word ?? "hint", bad?.code ?? ""] : ["hint", good.code];
|
|
104
108
|
return hanging(span(` ${label}: `, "label"), codeLines(code));
|
|
105
109
|
};
|
|
106
110
|
|
|
107
111
|
/**
|
|
108
112
|
* One diagnostic, in the frame `vp lint` prints on a terminal: a red header,
|
|
109
|
-
* the offending line, a pink underline, and the
|
|
113
|
+
* the offending line, a pink underline, and the code to write as the hint.
|
|
110
114
|
*/
|
|
111
115
|
const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
|
|
112
116
|
header(finding),
|
|
@@ -120,6 +124,7 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
120
124
|
`${result.judged} judged`,
|
|
121
125
|
`${result.cached} cached`,
|
|
122
126
|
...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
|
|
127
|
+
...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
|
|
123
128
|
];
|
|
124
129
|
return [
|
|
125
130
|
[span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
|
|
@@ -149,18 +154,56 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
149
154
|
* between them and how many the cache answers, then what judging the rest
|
|
150
155
|
* takes.
|
|
151
156
|
*/
|
|
152
|
-
|
|
157
|
+
/** What a run was asked to keep to, for the plan to say. */
|
|
158
|
+
export interface RunLimits {
|
|
159
|
+
readonly filter?: ReadonlyArray<string>;
|
|
160
|
+
readonly rpm?: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
|
|
164
|
+
const durationAt = (requests: number, rpm: number): string => {
|
|
165
|
+
const seconds = Math.ceil((requests / rpm) * 60);
|
|
166
|
+
return seconds < 60
|
|
167
|
+
? counted(seconds, "second", "seconds")
|
|
168
|
+
: counted(Math.round(seconds / 60), "minute", "minutes");
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const describePlan = (plan: AuditPlan, limits: RunLimits = {}): ReadonlyArray<string> => {
|
|
153
172
|
const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
|
|
154
173
|
const skipped =
|
|
155
174
|
plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
|
|
156
|
-
const
|
|
175
|
+
const matching = (limits.filter?.length ?? 0) > 0 ? " matching the filter" : "";
|
|
176
|
+
const found = `${counted(plan.files.length, "file", "files")}${matching} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
|
|
177
|
+
const pending = plan.checks - plan.cached;
|
|
178
|
+
const waiting =
|
|
179
|
+
plan.deferred > 0
|
|
180
|
+
? ` The other ${plan.deferred} ${plan.deferred === 1 ? "waits" : "wait"} for a later run.`
|
|
181
|
+
: "";
|
|
157
182
|
if (plan.requests === 0) {
|
|
158
|
-
|
|
183
|
+
const none =
|
|
184
|
+
plan.deferred > 0
|
|
185
|
+
? plan.deferred === 1
|
|
186
|
+
? "The limit leaves the 1 unjudged check for a later run."
|
|
187
|
+
: `The limit leaves all ${plan.deferred} unjudged checks for a later run.`
|
|
188
|
+
: plan.checks === 0
|
|
189
|
+
? "Nothing to judge."
|
|
190
|
+
: "The cache answers every check.";
|
|
191
|
+
return [found, none];
|
|
159
192
|
}
|
|
160
|
-
const
|
|
193
|
+
const judging = pending - plan.deferred;
|
|
194
|
+
const rest =
|
|
195
|
+
plan.deferred > 0
|
|
196
|
+
? `${judging} of ${plan.cached > 0 ? `the other ${pending}` : "them"}`
|
|
197
|
+
: plan.cached > 0
|
|
198
|
+
? `the other ${pending}`
|
|
199
|
+
: "them";
|
|
200
|
+
const pace =
|
|
201
|
+
limits.rpm === undefined
|
|
202
|
+
? ""
|
|
203
|
+
: ` At ${limits.rpm} a minute, they take about ${durationAt(plan.requests, limits.rpm)}.`;
|
|
161
204
|
return [
|
|
162
205
|
found,
|
|
163
|
-
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding
|
|
206
|
+
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.${pace}${waiting}`,
|
|
164
207
|
];
|
|
165
208
|
};
|
|
166
209
|
|