@drkmttr/adhere 0.4.1 → 0.6.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 +55 -21
- package/package.json +6 -6
- package/presets/effect/basics/fn-for-named-effects.md +1 -1
- package/presets/effect/basics/gen-for-sequencing.md +1 -1
- package/presets/effect/basics/instrument-with-pipe.md +1 -1
- package/presets/effect/config/business-logic-depends-on-config-service.md +1 -1
- package/presets/effect/config/secrets-are-redacted.md +1 -1
- package/presets/effect/config/validate-with-schema.md +1 -1
- package/presets/effect/data/brand-meaningful-primitives.md +1 -1
- package/presets/effect/data/decode-json-with-schema.md +1 -1
- package/presets/effect/data/records-are-schema-classes.md +1 -1
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +1 -1
- package/presets/effect/errors/defects-for-bugs.md +1 -1
- package/presets/effect/errors/domain-errors-are-tagged.md +1 -1
- package/presets/effect/errors/wrap-external-errors.md +1 -1
- package/presets/effect/platform/os-access-through-platform-services.md +1 -1
- package/presets/effect/services/fresh-layer-per-test.md +1 -1
- package/presets/effect/services/memoize-parameterized-layers.md +1 -1
- package/presets/effect/services/methods-have-no-requirements.md +1 -1
- package/presets/effect/services/provide-at-entry.md +1 -1
- package/presets/effect/services/test-layers-are-in-memory.md +1 -1
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +1 -1
- package/src/cli.ts +86 -8
- package/src/contradictions.ts +51 -57
- package/src/services/Jev.http.ts +80 -11
- package/src/services/Jev.ts +244 -36
- package/src/workflows/audit.ts +195 -79
- package/src/workflows/format.ts +42 -1
package/src/workflows/audit.ts
CHANGED
|
@@ -2,7 +2,15 @@ import type { Rule, 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";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fits,
|
|
7
|
+
Jev,
|
|
8
|
+
JevUnavailable,
|
|
9
|
+
judgeQuestion,
|
|
10
|
+
judgeRequests,
|
|
11
|
+
locateRequests,
|
|
12
|
+
snippetOf,
|
|
13
|
+
} from "#services/Jev.ts";
|
|
6
14
|
import { SourceWalker } from "#services/SourceWalker.ts";
|
|
7
15
|
import { applicableRules } from "#rules.ts";
|
|
8
16
|
import { type Crypto, Effect, Record } from "effect";
|
|
@@ -45,47 +53,90 @@ const fileResult = (
|
|
|
45
53
|
const isEmpty = Record.isEmptyReadonlyRecord;
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
|
-
* What a judgment depends on besides the file
|
|
49
|
-
*
|
|
50
|
-
*
|
|
56
|
+
* What a judgment depends on besides the file: the model, and the question
|
|
57
|
+
* asked for the rule, which carries the rule. An edited rule re-judges
|
|
58
|
+
* itself, and a question asked differently re-judges every rule.
|
|
51
59
|
*/
|
|
52
60
|
const fingerprintOf = (model: string, rule: Rule) =>
|
|
53
|
-
sha256(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
sha256(`${model}\u0000${JSON.stringify(judgeQuestion(rule))}`);
|
|
62
|
+
|
|
63
|
+
/** One file before anything is sent: its rules, and which of them the cache answers. */
|
|
64
|
+
export interface FilePlan {
|
|
65
|
+
readonly file: ScannedFile;
|
|
66
|
+
/** Too long for Jev: counted, not judged. */
|
|
67
|
+
readonly skipped: boolean;
|
|
68
|
+
readonly hash: string;
|
|
69
|
+
readonly prepared: Readonly<Record<RuleId, PreparedRule>>;
|
|
70
|
+
/** Cached judgments still valid for the file's content and the rule's text. */
|
|
71
|
+
readonly kept: Readonly<Record<RuleId, Judgment>>;
|
|
72
|
+
readonly pending: Readonly<Record<RuleId, PreparedRule>>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
76
|
+
export interface AuditPlan {
|
|
77
|
+
readonly files: ReadonlyArray<FilePlan>;
|
|
78
|
+
/** Rules that apply to at least one file that is judged. */
|
|
79
|
+
readonly rules: number;
|
|
80
|
+
/** Every pair of a judged file and a rule that applies to it. */
|
|
81
|
+
readonly checks: number;
|
|
82
|
+
/** Checks a cached judgment answers. */
|
|
83
|
+
readonly cached: number;
|
|
84
|
+
/** Files too long to judge. */
|
|
85
|
+
readonly skipped: number;
|
|
86
|
+
/** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
|
|
87
|
+
readonly requests: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A file finished, for a progress counter: the requests it took and the findings it has. */
|
|
91
|
+
export interface FileDone {
|
|
92
|
+
readonly requests: number;
|
|
93
|
+
readonly findings: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(some, (k) => k.rule);
|
|
97
|
+
|
|
98
|
+
const sizeOf = (some: Readonly<Record<string, unknown>>): number => Object.keys(some).length;
|
|
99
|
+
|
|
100
|
+
/** A refusal that names the file it stopped at. Files finished before it are already cached. */
|
|
101
|
+
const inFile = (file: ScannedFile) =>
|
|
102
|
+
Effect.mapError((problem: JevUnavailable) =>
|
|
103
|
+
JevUnavailable.make({
|
|
104
|
+
message: `${file.path}: ${problem.message}. Files judged before it are cached, so a rerun continues from there.`,
|
|
105
|
+
}),
|
|
58
106
|
);
|
|
59
107
|
|
|
60
|
-
export const
|
|
61
|
-
|
|
62
|
-
WalkUnavailable
|
|
63
|
-
AdhereConfig | AuditCache | Crypto.Crypto |
|
|
108
|
+
export const planAudit: Effect.Effect<
|
|
109
|
+
AuditPlan,
|
|
110
|
+
WalkUnavailable,
|
|
111
|
+
AdhereConfig | AuditCache | Crypto.Crypto | SourceWalker
|
|
64
112
|
> = Effect.gen(function* () {
|
|
65
113
|
const config = yield* AdhereConfig;
|
|
66
|
-
const jev = yield* Jev;
|
|
67
114
|
const cache = yield* AuditCache;
|
|
68
115
|
const files = yield* (yield* SourceWalker).files;
|
|
69
116
|
|
|
70
117
|
const configuredRules = (file: string) =>
|
|
71
|
-
config.scopedRules === undefined
|
|
72
|
-
? config.rules
|
|
73
|
-
: applicableRules(file, config.scopedRules);
|
|
118
|
+
config.scopedRules === undefined ? config.rules : applicableRules(file, config.scopedRules);
|
|
74
119
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (file.lines
|
|
120
|
+
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
121
|
+
const rules = configuredRules(file.path);
|
|
122
|
+
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
123
|
+
if (!fits(file.lines, rules)) {
|
|
124
|
+
return {
|
|
125
|
+
file,
|
|
126
|
+
skipped: true,
|
|
127
|
+
hash: "",
|
|
128
|
+
prepared: {},
|
|
129
|
+
kept: {},
|
|
130
|
+
pending: {},
|
|
131
|
+
} satisfies FilePlan;
|
|
132
|
+
}
|
|
79
133
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
80
|
-
Object.entries(
|
|
134
|
+
Object.entries(rules),
|
|
81
135
|
([id, rule]) =>
|
|
82
136
|
Effect.map(
|
|
83
137
|
fingerprintOf(config.model, rule),
|
|
84
138
|
(fingerprint) =>
|
|
85
|
-
[
|
|
86
|
-
id,
|
|
87
|
-
{ rule, fingerprint, threshold: rule.threshold ?? config.threshold },
|
|
88
|
-
] as const,
|
|
139
|
+
[id, { rule, fingerprint, threshold: rule.threshold ?? config.threshold }] as const,
|
|
89
140
|
),
|
|
90
141
|
).pipe(Effect.map(Record.fromEntries));
|
|
91
142
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
@@ -96,64 +147,129 @@ export const runAudit: Effect.Effect<
|
|
|
96
147
|
(judgment, id) => judgment.fingerprint === prepared[id]?.fingerprint,
|
|
97
148
|
);
|
|
98
149
|
const pending = Record.filter(prepared, (_, id) => kept[id] === undefined);
|
|
99
|
-
|
|
100
|
-
const probabilities = isEmpty(pending) ? {} : yield* jev.judge(file.lines, rulesOf(pending));
|
|
101
|
-
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
102
|
-
for (const [id, probability] of Object.entries(probabilities)) {
|
|
103
|
-
const k = pending[id];
|
|
104
|
-
if (k !== undefined) judged[id] = { fingerprint: k.fingerprint, probability };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const flagged = Record.filter(prepared, (k, id) => {
|
|
108
|
-
const judgment = judged[id];
|
|
109
|
-
return (
|
|
110
|
-
judgment !== undefined && judgment.probability > k.threshold && judgment.line === undefined
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
const lines = isEmpty(flagged) ? {} : yield* jev.locate(file.lines, rulesOf(flagged));
|
|
114
|
-
const located = Record.map(judged, (judgment, id) => {
|
|
115
|
-
const line = lines[id];
|
|
116
|
-
return line === undefined
|
|
117
|
-
? judgment
|
|
118
|
-
: { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
122
|
-
if (!cached) yield* cache.put(file.path, { hash, judgments: located });
|
|
123
|
-
|
|
124
|
-
const findings = Object.entries(located)
|
|
125
|
-
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
126
|
-
const k = prepared[id];
|
|
127
|
-
return k !== undefined && judgment.line !== undefined && judgment.probability > k.threshold
|
|
128
|
-
? [
|
|
129
|
-
{
|
|
130
|
-
rule: id,
|
|
131
|
-
description: k.rule.description,
|
|
132
|
-
reference: k.rule.reference,
|
|
133
|
-
avoid: k.rule.avoid,
|
|
134
|
-
file: file.path,
|
|
135
|
-
line: judgment.line,
|
|
136
|
-
snippet: judgment.snippet ?? "",
|
|
137
|
-
probability: judgment.probability,
|
|
138
|
-
},
|
|
139
|
-
]
|
|
140
|
-
: [];
|
|
141
|
-
})
|
|
142
|
-
.sort((a, b) => a.line - b.line);
|
|
143
|
-
return fileResult(cached ? "cached" : "judged", findings);
|
|
150
|
+
return { file, skipped: false, hash, prepared, kept, pending } satisfies FilePlan;
|
|
144
151
|
});
|
|
145
152
|
|
|
146
153
|
const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
const planned: ReadonlyArray<FilePlan> = yield* Effect.forEach(sorted, planFile, {
|
|
155
|
+
concurrency: 8,
|
|
156
|
+
});
|
|
157
|
+
const judged = planned.filter((plan) => !plan.skipped);
|
|
158
|
+
const total = (count: (plan: FilePlan) => number) =>
|
|
159
|
+
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
150
160
|
return {
|
|
151
|
-
files:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
files: planned,
|
|
162
|
+
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
163
|
+
checks: total((plan) => sizeOf(plan.prepared)),
|
|
164
|
+
cached: total((plan) => sizeOf(plan.kept)),
|
|
165
|
+
skipped: planned.length - judged.length,
|
|
166
|
+
requests: total((plan) =>
|
|
167
|
+
isEmpty(plan.pending) ? 0 : judgeRequests(plan.file.lines, rulesOf(plan.pending)),
|
|
168
|
+
),
|
|
156
169
|
};
|
|
157
170
|
});
|
|
158
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Judges what the plan left pending and locates the findings, a file at a
|
|
174
|
+
* time, telling `progress` as each file finishes.
|
|
175
|
+
*/
|
|
176
|
+
export const executeAudit = (
|
|
177
|
+
plan: AuditPlan,
|
|
178
|
+
progress: (done: FileDone) => Effect.Effect<void> = () => Effect.void,
|
|
179
|
+
): Effect.Effect<AuditResult, JevUnavailable, AuditCache | Jev> =>
|
|
180
|
+
Effect.gen(function* () {
|
|
181
|
+
const jev = yield* Jev;
|
|
182
|
+
const cache = yield* AuditCache;
|
|
183
|
+
|
|
184
|
+
const auditFile = Effect.fn("audit.file")(function* ({
|
|
185
|
+
file,
|
|
186
|
+
skipped,
|
|
187
|
+
hash,
|
|
188
|
+
prepared,
|
|
189
|
+
kept,
|
|
190
|
+
pending,
|
|
191
|
+
}: FilePlan) {
|
|
192
|
+
if (skipped) {
|
|
193
|
+
yield* progress({ requests: 0, findings: 0 });
|
|
194
|
+
return fileResult("skipped", []);
|
|
195
|
+
}
|
|
196
|
+
const probabilities = isEmpty(pending)
|
|
197
|
+
? {}
|
|
198
|
+
: yield* jev.judge(file.lines, rulesOf(pending)).pipe(inFile(file));
|
|
199
|
+
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
200
|
+
for (const [id, probability] of Object.entries(probabilities)) {
|
|
201
|
+
const k = pending[id];
|
|
202
|
+
if (k !== undefined) judged[id] = { fingerprint: k.fingerprint, probability };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const flagged = Record.filter(prepared, (k, id) => {
|
|
206
|
+
const judgment = judged[id];
|
|
207
|
+
return (
|
|
208
|
+
judgment !== undefined &&
|
|
209
|
+
judgment.probability > k.threshold &&
|
|
210
|
+
judgment.line === undefined
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
const lines = isEmpty(flagged)
|
|
214
|
+
? {}
|
|
215
|
+
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(inFile(file));
|
|
216
|
+
const located = Record.map(judged, (judgment, id) => {
|
|
217
|
+
const line = lines[id];
|
|
218
|
+
return line === undefined
|
|
219
|
+
? judgment
|
|
220
|
+
: { ...judgment, line, snippet: snippetOf(file.lines[line - 1] ?? "") };
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
224
|
+
if (!cached) yield* cache.put(file.path, { hash, judgments: located });
|
|
225
|
+
|
|
226
|
+
const findings = Object.entries(located)
|
|
227
|
+
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
228
|
+
const k = prepared[id];
|
|
229
|
+
return k !== undefined &&
|
|
230
|
+
judgment.line !== undefined &&
|
|
231
|
+
judgment.probability > k.threshold
|
|
232
|
+
? [
|
|
233
|
+
{
|
|
234
|
+
rule: id,
|
|
235
|
+
description: k.rule.description,
|
|
236
|
+
reference: k.rule.reference,
|
|
237
|
+
avoid: k.rule.avoid,
|
|
238
|
+
file: file.path,
|
|
239
|
+
line: judgment.line,
|
|
240
|
+
snippet: judgment.snippet ?? "",
|
|
241
|
+
probability: judgment.probability,
|
|
242
|
+
},
|
|
243
|
+
]
|
|
244
|
+
: [];
|
|
245
|
+
})
|
|
246
|
+
.sort((a, b) => a.line - b.line);
|
|
247
|
+
yield* progress({
|
|
248
|
+
requests:
|
|
249
|
+
(isEmpty(pending) ? 0 : judgeRequests(file.lines, rulesOf(pending))) +
|
|
250
|
+
(isEmpty(flagged) ? 0 : locateRequests(file.lines, rulesOf(flagged))),
|
|
251
|
+
findings: findings.length,
|
|
252
|
+
});
|
|
253
|
+
return fileResult(cached ? "cached" : "judged", findings);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
|
|
257
|
+
const count = (status: FileResult["status"]) =>
|
|
258
|
+
results.filter((result) => result.status === status).length;
|
|
259
|
+
return {
|
|
260
|
+
files: plan.files.length,
|
|
261
|
+
judged: count("judged"),
|
|
262
|
+
cached: count("cached"),
|
|
263
|
+
skipped: count("skipped"),
|
|
264
|
+
findings: results.flatMap((result) => result.findings),
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
/** Plans the run and executes it, without asking and without a counter. */
|
|
269
|
+
export const runAudit: Effect.Effect<
|
|
270
|
+
AuditResult,
|
|
271
|
+
WalkUnavailable | JevUnavailable,
|
|
272
|
+
AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
|
|
273
|
+
> = Effect.flatMap(planAudit, (plan) => executeAudit(plan));
|
|
274
|
+
|
|
159
275
|
export { type RenderOptions, render } from "#workflows/format.ts";
|
package/src/workflows/format.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Kind, tokenize } from "#highlight.ts";
|
|
2
|
-
import type { AuditResult, Finding } from "#workflows/audit.ts";
|
|
2
|
+
import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The report as data: lines of spans, each span a part of the frame or a
|
|
@@ -143,3 +143,44 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
143
143
|
...result.findings.flatMap((finding) => [...frame(finding, options.root), []]),
|
|
144
144
|
...summary(result),
|
|
145
145
|
].map((line) => serialize(line, options.color === true));
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What a run will do, before it asks: the files and rules found, the checks
|
|
149
|
+
* between them and how many the cache answers, then what judging the rest
|
|
150
|
+
* takes.
|
|
151
|
+
*/
|
|
152
|
+
export const describePlan = (plan: AuditPlan): ReadonlyArray<string> => {
|
|
153
|
+
const cached = plan.cached > 0 ? `, ${plan.cached} cached` : "";
|
|
154
|
+
const skipped =
|
|
155
|
+
plan.skipped > 0 ? `, ${counted(plan.skipped, "file", "files")} too long to judge` : "";
|
|
156
|
+
const found = `${counted(plan.files.length, "file", "files")} and ${counted(plan.rules, "rule", "rules")}: ${counted(plan.checks, "check", "checks")}${cached}${skipped}.`;
|
|
157
|
+
if (plan.requests === 0) {
|
|
158
|
+
return [found, plan.checks === 0 ? "Nothing to judge." : "The cache answers every check."];
|
|
159
|
+
}
|
|
160
|
+
const rest = plan.cached > 0 ? `the other ${plan.checks - plan.cached}` : "them";
|
|
161
|
+
return [
|
|
162
|
+
found,
|
|
163
|
+
`Judging ${rest} takes ${counted(plan.requests, "request", "requests")} to Jev, plus 1 or more for each file with a finding.`,
|
|
164
|
+
];
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** The question before a run sends anything. */
|
|
168
|
+
export const sendQuestion = (plan: AuditPlan): string =>
|
|
169
|
+
`Send ${counted(plan.requests, "request", "requests")} to Jev?`;
|
|
170
|
+
|
|
171
|
+
/** A run's progress so far: every file it finished, with the requests and findings they added up to. */
|
|
172
|
+
export interface Progress {
|
|
173
|
+
readonly files: number;
|
|
174
|
+
readonly requests: number;
|
|
175
|
+
readonly findings: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const progressAfter = (progress: Progress, done: FileDone): Progress => ({
|
|
179
|
+
files: progress.files + 1,
|
|
180
|
+
requests: progress.requests + done.requests,
|
|
181
|
+
findings: progress.findings + done.findings,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/** The counter a run redraws as files finish. */
|
|
185
|
+
export const progressLine = (progress: Progress, plan: AuditPlan): string =>
|
|
186
|
+
`${progress.files}/${plan.files.length} files, ${counted(progress.requests, "request", "requests")} sent, ${counted(progress.findings, "finding", "findings")}`;
|