@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.
Files changed (30) hide show
  1. package/README.md +55 -21
  2. package/package.json +6 -6
  3. package/presets/effect/basics/fn-for-named-effects.md +1 -1
  4. package/presets/effect/basics/gen-for-sequencing.md +1 -1
  5. package/presets/effect/basics/instrument-with-pipe.md +1 -1
  6. package/presets/effect/config/business-logic-depends-on-config-service.md +1 -1
  7. package/presets/effect/config/secrets-are-redacted.md +1 -1
  8. package/presets/effect/config/validate-with-schema.md +1 -1
  9. package/presets/effect/data/brand-meaningful-primitives.md +1 -1
  10. package/presets/effect/data/decode-json-with-schema.md +1 -1
  11. package/presets/effect/data/records-are-schema-classes.md +1 -1
  12. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  13. package/presets/effect/errors/catch-defects-at-boundaries-only.md +1 -1
  14. package/presets/effect/errors/defects-for-bugs.md +1 -1
  15. package/presets/effect/errors/domain-errors-are-tagged.md +1 -1
  16. package/presets/effect/errors/wrap-external-errors.md +1 -1
  17. package/presets/effect/platform/os-access-through-platform-services.md +1 -1
  18. package/presets/effect/services/fresh-layer-per-test.md +1 -1
  19. package/presets/effect/services/memoize-parameterized-layers.md +1 -1
  20. package/presets/effect/services/methods-have-no-requirements.md +1 -1
  21. package/presets/effect/services/provide-at-entry.md +1 -1
  22. package/presets/effect/services/test-layers-are-in-memory.md +1 -1
  23. package/presets/effect/testing/test-clock-for-time.md +1 -1
  24. package/skills/adhere/SKILL.md +1 -1
  25. package/src/cli.ts +86 -8
  26. package/src/contradictions.ts +51 -57
  27. package/src/services/Jev.http.ts +80 -11
  28. package/src/services/Jev.ts +244 -36
  29. package/src/workflows/audit.ts +195 -79
  30. package/src/workflows/format.ts +42 -1
@@ -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 { Jev, type JevUnavailable, MAX_LINES, snippetOf } from "#services/Jev.ts";
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. A rule without code to avoid
49
- * hashes what every rule hashed before `avoid` existed, so its cached
50
- * judgments survive the upgrade.
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
- model +
55
- rule.description +
56
- (rule.reference ?? "") +
57
- (rule.avoid === undefined ? "" : `\u0000avoid\u0000${rule.avoid}`),
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 runAudit: Effect.Effect<
61
- AuditResult,
62
- WalkUnavailable | JevUnavailable,
63
- AdhereConfig | AuditCache | Crypto.Crypto | Jev | SourceWalker
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 rulesOf = (some: Readonly<Record<RuleId, PreparedRule>>) => Record.map(some, (k) => k.rule);
76
-
77
- const auditFile = Effect.fn("audit.file")(function* (file: ScannedFile) {
78
- if (file.lines.length > MAX_LINES) return fileResult("skipped", []);
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(configuredRules(file.path)),
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 results = yield* Effect.forEach(sorted, auditFile, { concurrency: 8 });
148
- const count = (status: FileResult["status"]) =>
149
- results.filter((result) => result.status === status).length;
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: sorted.length,
152
- judged: count("judged"),
153
- cached: count("cached"),
154
- skipped: count("skipped"),
155
- findings: results.flatMap((result) => result.findings),
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";
@@ -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")}`;