@drkmttr/adhere 0.9.5 → 0.10.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 +245 -65
- package/package.json +6 -6
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +6 -2
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +6 -2
- package/presets/alchemy/data/commit-migrations-then-deploy.md +6 -2
- package/presets/alchemy/data/hyperdrive-direct-origin.md +6 -2
- package/presets/alchemy/data/migrations-use-direct-url.md +6 -2
- package/presets/alchemy/data/never-disable-tls-verification.md +6 -2
- package/presets/alchemy/data/parameterized-sql.md +6 -2
- package/presets/alchemy/data/private-endpoint-at-runtime.md +6 -2
- package/presets/alchemy/data/single-migration-owner.md +6 -2
- package/presets/alchemy/durable/idempotent-event-handlers.md +6 -2
- package/presets/alchemy/durable/io-inside-steps.md +6 -2
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +6 -2
- package/presets/alchemy/durable/stable-step-names.md +6 -2
- package/presets/alchemy/durable/webhook-state-is-truth.md +6 -2
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +6 -2
- package/presets/alchemy/providers/idempotent-delete.md +8 -3
- package/presets/alchemy/providers/lazy-layer-construction.md +6 -2
- package/presets/alchemy/providers/reconcile-from-observed-state.md +6 -2
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +6 -2
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +6 -2
- package/presets/alchemy/resources/previews-reference-shared-resources.md +6 -2
- package/presets/alchemy/resources/protect-irreplaceable-data.md +6 -2
- package/presets/alchemy/resources/remote-state-for-ci.md +6 -2
- package/presets/alchemy/resources/stable-logical-ids.md +6 -2
- package/presets/alchemy/resources/stage-unique-physical-names.md +6 -2
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +6 -2
- package/presets/alchemy/runtime/deterministic-config-defaults.md +6 -2
- package/presets/alchemy/runtime/disposables-in-request-scope.md +6 -2
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +6 -2
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +6 -2
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +6 -2
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +6 -2
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +6 -2
- package/presets/alchemy/secrets/none-in-browser-env.md +6 -2
- package/presets/alchemy/secrets/redacted-until-use.md +6 -2
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +6 -2
- package/presets/alchemy/security/authorize-public-endpoints.md +6 -2
- package/presets/alchemy/security/github-secret-not-variable.md +6 -2
- package/presets/alchemy/security/identity-from-verified-session.md +6 -2
- package/presets/alchemy/security/keep-auth-protections-on.md +6 -2
- package/presets/alchemy/security/least-privilege-bindings.md +6 -2
- package/presets/alchemy/security/least-privilege-ci-credentials.md +6 -2
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +6 -2
- package/presets/effect/basics/external-calls-are-resilient.md +6 -2
- package/presets/effect/basics/gen-for-sequencing.md +6 -2
- package/presets/effect/basics/instrument-with-pipe.md +6 -2
- package/presets/effect/config/business-logic-depends-on-config-service.md +6 -2
- package/presets/effect/config/secrets-are-redacted.md +6 -2
- package/presets/effect/config/tests-provide-values-directly.md +7 -2
- package/presets/effect/config/validate-with-schema.md +6 -2
- package/presets/effect/data/brand-meaningful-primitives.md +6 -2
- package/presets/effect/data/variants-are-tagged-unions.md +6 -2
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
- package/presets/effect/errors/defects-for-bugs.md +6 -2
- package/presets/effect/errors/wrap-external-errors.md +6 -2
- package/presets/effect/services/dependencies-through-layers.md +6 -2
- package/presets/effect/services/fresh-layer-per-test.md +7 -2
- package/presets/effect/services/memoize-parameterized-layers.md +6 -2
- package/presets/effect/services/no-mutable-state.md +6 -2
- package/presets/effect/services/operations-have-no-requirements.md +6 -2
- package/presets/effect/services/test-layers-are-in-memory.md +7 -2
- package/presets/effect/testing/test-clock-for-time.md +7 -2
- package/skills/adhere/SKILL.md +40 -12
- package/skills/adhere-fix/SKILL.md +174 -0
- package/src/cli.ts +51 -14
- package/src/comments.ts +36 -0
- package/src/config.ts +98 -14
- package/src/contradictions.ts +10 -2
- package/src/excerpt.ts +11 -1
- package/src/index.ts +1 -1
- package/src/init.ts +119 -12
- package/src/main.ts +0 -1
- package/src/markdown.ts +77 -14
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +6 -3
- package/src/rules.ts +35 -2
- package/src/services/AdhereConfig.ts +36 -13
- package/src/services/SourceWalker.ts +32 -10
- package/src/suppress.ts +113 -0
- package/src/workflows/audit.ts +99 -17
- package/src/workflows/format.ts +20 -13
package/src/suppress.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type Range, statementFrom } from "#excerpt.ts";
|
|
2
|
+
import { scan } from "#highlight.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a file's `adhere-ignore` comments suppress. `// adhere-ignore <rules>
|
|
6
|
+
* -- <reason>` on a line of its own suppresses the rules it names in the
|
|
7
|
+
* statement that starts on the next line of code, wherever in it Jev points;
|
|
8
|
+
* at the end of a line of code, in the statement that starts on that line, or
|
|
9
|
+
* the line alone. `// adhere-ignore-file <rules> -- <reason>` suppresses them
|
|
10
|
+
* in the whole file, which they then do not judge. Rules are named as a report
|
|
11
|
+
* names them, separated by commas.
|
|
12
|
+
*/
|
|
13
|
+
export interface Suppressions {
|
|
14
|
+
/** Rules suppressed in the whole file. */
|
|
15
|
+
readonly file: ReadonlySet<string>;
|
|
16
|
+
/** Rules suppressed over the lines of one statement. */
|
|
17
|
+
readonly statements: ReadonlyArray<Range & { readonly rules: ReadonlySet<string> }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const NO_SUPPRESSIONS: Suppressions = { file: new Set(), statements: [] };
|
|
21
|
+
|
|
22
|
+
/** A directive's comment: whether it covers the file, then what follows, up to a block comment's end. */
|
|
23
|
+
const DIRECTIVE = /^(?:\/\/|\/\*)\s*adhere-ignore(-file)?\s+(.*?)\s*(?:\*\/)?$/s;
|
|
24
|
+
|
|
25
|
+
/** An `adhere-ignore` comment: where it is, and the rules it names. */
|
|
26
|
+
interface Directive {
|
|
27
|
+
readonly line: number;
|
|
28
|
+
/** At the end of a line of code, rather than on a line of its own. */
|
|
29
|
+
readonly trailing: boolean;
|
|
30
|
+
/** `adhere-ignore-file`, for the whole file. */
|
|
31
|
+
readonly file: boolean;
|
|
32
|
+
readonly rules: ReadonlySet<string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The rules a directive names, before any ` -- ` and its reason. None if it is not a directive. */
|
|
36
|
+
const directiveOf = (comment: string): Pick<Directive, "file" | "rules"> | undefined => {
|
|
37
|
+
const match = DIRECTIVE.exec(comment);
|
|
38
|
+
const named = (match?.[2] ?? "").split(/(?:^|\s)--(?:\s|$)/)[0] ?? "";
|
|
39
|
+
const rules = named.split(/[\s,]+/).filter((rule) => rule.length > 0);
|
|
40
|
+
return match === null || rules.length === 0
|
|
41
|
+
? undefined
|
|
42
|
+
: { file: match[1] !== undefined, rules: new Set(rules) };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Whether a line holds nothing a statement could start with: blank, or a comment. */
|
|
46
|
+
const isQuiet = (line: string): boolean => {
|
|
47
|
+
const text = line.trim();
|
|
48
|
+
return (
|
|
49
|
+
text.length === 0 || text.startsWith("//") || text.startsWith("/*") || text.startsWith("*")
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The statement that starts on the first line of code after `line`, or that line alone. */
|
|
54
|
+
const statementAfter = (lines: ReadonlyArray<string>, line: number): Range | undefined => {
|
|
55
|
+
for (let next = line + 1; next <= lines.length; next++) {
|
|
56
|
+
if (isQuiet(lines[next - 1] ?? "")) continue;
|
|
57
|
+
return statementFrom(lines, next) ?? { first: next, last: next };
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A file's lines with its `adhere-ignore` comments blanked, each line keeping
|
|
64
|
+
* its number, so Jev never reads them, and what they suppress.
|
|
65
|
+
*/
|
|
66
|
+
export const suppressionsOf = (
|
|
67
|
+
lines: ReadonlyArray<string>,
|
|
68
|
+
): { readonly lines: ReadonlyArray<string>; readonly suppressions: Suppressions } => {
|
|
69
|
+
const code = lines.join("\n");
|
|
70
|
+
if (!code.includes("adhere-ignore")) return { lines, suppressions: NO_SUPPRESSIONS };
|
|
71
|
+
const tokens = scan(code);
|
|
72
|
+
const found: Array<Directive> = [];
|
|
73
|
+
const kept: Array<string> = [];
|
|
74
|
+
let line = 1;
|
|
75
|
+
// Whether code comes before the current token on its line.
|
|
76
|
+
let afterCode = false;
|
|
77
|
+
for (const token of tokens) {
|
|
78
|
+
const directive = token.kind === "comment" ? directiveOf(token.text) : undefined;
|
|
79
|
+
if (directive !== undefined) found.push({ line, trailing: afterCode, ...directive });
|
|
80
|
+
kept.push(directive === undefined ? token.text : token.text.replace(/[^\n]/g, ""));
|
|
81
|
+
const breaks = token.text.split("\n").length - 1;
|
|
82
|
+
const tail = token.text.slice(token.text.lastIndexOf("\n") + 1);
|
|
83
|
+
if (breaks > 0) afterCode = false;
|
|
84
|
+
if (token.kind !== "comment" && tail.trim().length > 0) afterCode = true;
|
|
85
|
+
line += breaks;
|
|
86
|
+
}
|
|
87
|
+
// What the highlighter could not read, if anything, stays as it was.
|
|
88
|
+
const read = tokens.reduce((length, token) => length + token.text.length, 0);
|
|
89
|
+
const cleaned = (kept.join("") + code.slice(read))
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((text, index) => (text === lines[index] ? text : text.trimEnd()));
|
|
92
|
+
const statements = found.flatMap(({ line: at, trailing, file, rules }) => {
|
|
93
|
+
if (file) return [];
|
|
94
|
+
const range = trailing
|
|
95
|
+
? (statementFrom(cleaned, at) ?? { first: at, last: at })
|
|
96
|
+
: statementAfter(cleaned, at);
|
|
97
|
+
return range === undefined ? [] : [{ ...range, rules }];
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
lines: cleaned,
|
|
101
|
+
suppressions: {
|
|
102
|
+
file: new Set(found.filter(({ file }) => file).flatMap(({ rules }) => [...rules])),
|
|
103
|
+
statements,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Whether a file's comments suppress `rule`, named as a report names it, at `line`. */
|
|
109
|
+
export const suppresses = (suppressions: Suppressions, rule: string, line: number): boolean =>
|
|
110
|
+
suppressions.file.has(rule) ||
|
|
111
|
+
suppressions.statements.some(
|
|
112
|
+
(statement) => statement.rules.has(rule) && statement.first <= line && line <= statement.last,
|
|
113
|
+
);
|
package/src/workflows/audit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isNote, withoutComments } from "#comments.ts";
|
|
2
|
+
import { type Examples, examplesOf, type Level, type Rule, type RuleId } from "#config.ts";
|
|
2
3
|
import { type Excerpt, excerptOf } from "#excerpt.ts";
|
|
3
4
|
import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
4
5
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
@@ -20,7 +21,8 @@ import { SourceWalker } from "#services/SourceWalker.ts";
|
|
|
20
21
|
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
21
22
|
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
22
23
|
import { priceOf } from "#pricing.ts";
|
|
23
|
-
import { applicableRules } from "#rules.ts";
|
|
24
|
+
import { applicableRules, judgesFile, shownId } from "#rules.ts";
|
|
25
|
+
import { NO_SUPPRESSIONS, suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
|
|
24
26
|
import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
|
|
25
27
|
|
|
26
28
|
export interface Finding {
|
|
@@ -34,8 +36,17 @@ export interface Finding {
|
|
|
34
36
|
/** The code around `line` the report shows. */
|
|
35
37
|
readonly excerpt: Excerpt;
|
|
36
38
|
readonly probability: number;
|
|
39
|
+
/** An error fails the run; a warning does not, unless `--deny-warnings`. */
|
|
40
|
+
readonly level: Level;
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
/** The findings that fail a run: its errors, and its warnings too when `denyWarnings`. */
|
|
44
|
+
export const failing = (
|
|
45
|
+
findings: ReadonlyArray<Finding>,
|
|
46
|
+
denyWarnings = false,
|
|
47
|
+
): ReadonlyArray<Finding> =>
|
|
48
|
+
denyWarnings ? findings : findings.filter((finding) => finding.level === "error");
|
|
49
|
+
|
|
39
50
|
export interface AuditResult {
|
|
40
51
|
readonly files: number;
|
|
41
52
|
readonly judged: number;
|
|
@@ -46,6 +57,8 @@ export interface AuditResult {
|
|
|
46
57
|
/** Files the firewall in front of Jev's API refused, with the ID to report each by. */
|
|
47
58
|
readonly blocked: ReadonlyArray<Blocked>;
|
|
48
59
|
readonly findings: ReadonlyArray<Finding>;
|
|
60
|
+
/** Findings an `adhere-ignore` comment covers, left out of `findings`. */
|
|
61
|
+
readonly suppressed: number;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
|
|
@@ -58,6 +71,8 @@ interface FileResult {
|
|
|
58
71
|
readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
|
|
59
72
|
readonly findings: ReadonlyArray<Finding>;
|
|
60
73
|
readonly blocked?: Blocked;
|
|
74
|
+
/** Findings an `adhere-ignore` comment covers, left out of `findings`. */
|
|
75
|
+
readonly suppressed?: number;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
interface PreparedRule {
|
|
@@ -85,7 +100,10 @@ const fingerprintOf = (model: string, rule: Rule) =>
|
|
|
85
100
|
|
|
86
101
|
/** One file before anything is sent: its rules, and which of them the cache answers. */
|
|
87
102
|
export interface FilePlan {
|
|
103
|
+
/** The file as Jev reads it: its comments taken out, unless the config keeps them. */
|
|
88
104
|
readonly file: ScannedFile;
|
|
105
|
+
/** The file's lines as written, which the report's excerpts show. */
|
|
106
|
+
readonly original: ReadonlyArray<string>;
|
|
89
107
|
/** Too long for Jev: counted, not judged. */
|
|
90
108
|
readonly skipped: boolean;
|
|
91
109
|
readonly hash: string;
|
|
@@ -97,11 +115,18 @@ export interface FilePlan {
|
|
|
97
115
|
readonly deferred: number;
|
|
98
116
|
/** Pending rules whose linter check question rides along on this file, each with its tally's key. */
|
|
99
117
|
readonly sampled: Readonly<Record<RuleId, string>>;
|
|
118
|
+
/** What the file's `adhere-ignore` comments suppress. */
|
|
119
|
+
readonly suppressions: Suppressions;
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
103
123
|
export interface AuditPlan {
|
|
104
124
|
readonly files: ReadonlyArray<FilePlan>;
|
|
125
|
+
/**
|
|
126
|
+
* The content hashes of files read that no rule in the run judges, such as
|
|
127
|
+
* tests when no rule says `tests`: a prune keeps what other runs know of them.
|
|
128
|
+
*/
|
|
129
|
+
readonly unjudged: ReadonlyArray<string>;
|
|
105
130
|
/** Rules that apply to at least one file that is judged. */
|
|
106
131
|
readonly rules: number;
|
|
107
132
|
/** Every pair of a judged file and a rule that applies to it. */
|
|
@@ -165,7 +190,7 @@ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyA
|
|
|
165
190
|
|
|
166
191
|
export interface PlanOptions {
|
|
167
192
|
/** Judge at most this many checks; the rest wait for a later run. */
|
|
168
|
-
readonly limit?: number;
|
|
193
|
+
readonly limit?: number | undefined;
|
|
169
194
|
}
|
|
170
195
|
|
|
171
196
|
export const planAudit = (
|
|
@@ -178,13 +203,37 @@ export const planAudit = (
|
|
|
178
203
|
Effect.gen(function* () {
|
|
179
204
|
const config = yield* AdhereConfig;
|
|
180
205
|
const cache = yield* AuditCache;
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
206
|
+
const walked = yield* (yield* SourceWalker).files;
|
|
207
|
+
// Each file as Jev reads it, each line keeping its number: its adhere-ignore comments blanked,
|
|
208
|
+
// and every other comment too but the @adhere notes, unless the config keeps comments. A
|
|
209
|
+
// comment changes nothing a file does, so it should not change the report.
|
|
210
|
+
const suppressed = new Map<string, Suppressions>();
|
|
211
|
+
const originals = new Map<string, ReadonlyArray<string>>();
|
|
212
|
+
const files = walked.map((file) => {
|
|
213
|
+
const { lines: directed, suppressions } = suppressionsOf(file.lines);
|
|
214
|
+
if (suppressions !== NO_SUPPRESSIONS) suppressed.set(file.path, suppressions);
|
|
215
|
+
const lines = config.includeComments === true ? directed : withoutComments(directed, isNote);
|
|
216
|
+
if (lines === file.lines) return file;
|
|
217
|
+
originals.set(file.path, file.lines);
|
|
218
|
+
return { ...file, lines };
|
|
219
|
+
});
|
|
220
|
+
const suppressionsIn = (file: ScannedFile) => suppressed.get(file.path) ?? NO_SUPPRESSIONS;
|
|
221
|
+
const originalOf = (file: ScannedFile) => originals.get(file.path) ?? file.lines;
|
|
222
|
+
|
|
223
|
+
// The rules that judge a file: those scoped to it, of which only the rules that say `tests`
|
|
224
|
+
// judge a test, and none that an adhere-ignore-file comment names.
|
|
225
|
+
const configuredRules = (file: ScannedFile) =>
|
|
226
|
+
Record.filter(
|
|
227
|
+
config.scopedRules === undefined
|
|
228
|
+
? config.rules
|
|
229
|
+
: applicableRules(file.path, config.scopedRules),
|
|
230
|
+
(rule, id) =>
|
|
231
|
+
judgesFile(rule, file.test === true) &&
|
|
232
|
+
!suppressionsIn(file).file.has(shownId({ id, preset: presetOfRule.get(rule) })),
|
|
233
|
+
);
|
|
185
234
|
|
|
186
235
|
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
187
|
-
const rules = configuredRules(file
|
|
236
|
+
const rules = configuredRules(file);
|
|
188
237
|
// Hashed even when skipped, so a prune keeps what other runs know of its content.
|
|
189
238
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
190
239
|
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
@@ -199,6 +248,8 @@ export const planAudit = (
|
|
|
199
248
|
pending: {},
|
|
200
249
|
deferred: 0,
|
|
201
250
|
sampled: {},
|
|
251
|
+
suppressions: suppressionsIn(file),
|
|
252
|
+
original: originalOf(file),
|
|
202
253
|
} satisfies FilePlan;
|
|
203
254
|
}
|
|
204
255
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
@@ -235,6 +286,8 @@ export const planAudit = (
|
|
|
235
286
|
pending,
|
|
236
287
|
deferred: 0,
|
|
237
288
|
sampled: {},
|
|
289
|
+
suppressions: suppressionsIn(file),
|
|
290
|
+
original: originalOf(file),
|
|
238
291
|
} satisfies FilePlan;
|
|
239
292
|
});
|
|
240
293
|
|
|
@@ -305,7 +358,16 @@ export const planAudit = (
|
|
|
305
358
|
});
|
|
306
359
|
});
|
|
307
360
|
|
|
308
|
-
|
|
361
|
+
// A file no rule judges, such as a test when no rule says `tests`, is not part of the run,
|
|
362
|
+
// but its content is: another run's rules may judge it.
|
|
363
|
+
const sorted = files
|
|
364
|
+
.filter((file) => !isEmpty(configuredRules(file)))
|
|
365
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
366
|
+
const unjudged = yield* Effect.forEach(
|
|
367
|
+
files.filter((file) => isEmpty(configuredRules(file))),
|
|
368
|
+
(file) => sha256(file.lines.join("\n")),
|
|
369
|
+
{ concurrency: 8 },
|
|
370
|
+
);
|
|
309
371
|
const status = yield* Status;
|
|
310
372
|
let done = 0;
|
|
311
373
|
const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
|
|
@@ -337,6 +399,7 @@ export const planAudit = (
|
|
|
337
399
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
338
400
|
return {
|
|
339
401
|
files: planned,
|
|
402
|
+
unjudged,
|
|
340
403
|
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
341
404
|
checks: total((plan) => sizeOf(plan.prepared)),
|
|
342
405
|
cached: total((plan) => sizeOf(plan.kept)),
|
|
@@ -386,6 +449,7 @@ export const executeAudit = (
|
|
|
386
449
|
/** Judges and locates one file the plan left pending, with the requests that took. */
|
|
387
450
|
const judgeFile = Effect.fn("audit.judgeFile")(function* ({
|
|
388
451
|
file,
|
|
452
|
+
original,
|
|
389
453
|
hash,
|
|
390
454
|
prepared,
|
|
391
455
|
kept,
|
|
@@ -416,9 +480,10 @@ export const executeAudit = (
|
|
|
416
480
|
const {
|
|
417
481
|
lines,
|
|
418
482
|
blocked,
|
|
419
|
-
}: {
|
|
420
|
-
|
|
421
|
-
|
|
483
|
+
}: {
|
|
484
|
+
readonly lines: Readonly<Record<RuleId, number>>;
|
|
485
|
+
readonly blocked: Blocked | undefined;
|
|
486
|
+
} = isEmpty(flagged)
|
|
422
487
|
? { lines: {}, blocked: undefined }
|
|
423
488
|
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
|
|
424
489
|
Effect.map((lines) => ({ lines, blocked: undefined })),
|
|
@@ -449,8 +514,9 @@ export const executeAudit = (
|
|
|
449
514
|
examples: examplesOf(k.rule),
|
|
450
515
|
file: file.path,
|
|
451
516
|
line: judgment.line,
|
|
452
|
-
excerpt: excerptOf(
|
|
517
|
+
excerpt: excerptOf(original, judgment.line),
|
|
453
518
|
probability: judgment.probability,
|
|
519
|
+
level: k.rule.level ?? "error",
|
|
454
520
|
},
|
|
455
521
|
]
|
|
456
522
|
: [];
|
|
@@ -511,8 +577,22 @@ export const executeAudit = (
|
|
|
511
577
|
),
|
|
512
578
|
Effect.annotateLogs("file", plan.file.path),
|
|
513
579
|
);
|
|
514
|
-
|
|
515
|
-
|
|
580
|
+
// A finding an adhere-ignore comment covers is left out of the report, and counted.
|
|
581
|
+
const shown = result.findings.filter(
|
|
582
|
+
(finding) =>
|
|
583
|
+
!suppresses(
|
|
584
|
+
plan.suppressions,
|
|
585
|
+
shownId({ id: finding.rule, preset: finding.preset }),
|
|
586
|
+
finding.line,
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
if (shown.length < result.findings.length) {
|
|
590
|
+
yield* Effect.logDebug(
|
|
591
|
+
`${result.findings.length - shown.length} suppressed by adhere-ignore`,
|
|
592
|
+
).pipe(Effect.annotateLogs("file", plan.file.path));
|
|
593
|
+
}
|
|
594
|
+
yield* progress({ requests, findings: shown.length });
|
|
595
|
+
return { ...result, findings: shown, suppressed: result.findings.length - shown.length };
|
|
516
596
|
});
|
|
517
597
|
|
|
518
598
|
const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
|
|
@@ -526,13 +606,15 @@ export const executeAudit = (
|
|
|
526
606
|
waiting: count("waiting"),
|
|
527
607
|
blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
|
|
528
608
|
findings: results.flatMap((result) => result.findings),
|
|
609
|
+
suppressed: results.reduce((sum, result) => sum + (result.suppressed ?? 0), 0),
|
|
529
610
|
};
|
|
530
611
|
});
|
|
531
612
|
|
|
532
613
|
/**
|
|
533
614
|
* Deletes from the cache the answers about content no file has anymore.
|
|
534
615
|
* Answers to rules the run left out stay, since a run with other presets or
|
|
535
|
-
* rules asks them
|
|
616
|
+
* rules asks them, and so do answers about files the run read but judged
|
|
617
|
+
* against no rule. Only after a run that read every file; a run narrowed by
|
|
536
618
|
* `--filter` cannot tell what content the files it left out have.
|
|
537
619
|
*/
|
|
538
620
|
export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
@@ -544,7 +626,7 @@ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
|
544
626
|
);
|
|
545
627
|
const deleted = yield* cache.prune({
|
|
546
628
|
// A file too long to judge counts: a run with shorter questions judges it.
|
|
547
|
-
hashes: new Set(plan.files.map((file) => file.hash)),
|
|
629
|
+
hashes: new Set([...plan.files.map((file) => file.hash), ...plan.unjudged]),
|
|
548
630
|
tallies: new Set(tallies),
|
|
549
631
|
});
|
|
550
632
|
yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
|
package/src/workflows/format.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { AuditPlan, AuditResult, FileDone, Finding } from "#workflows/audit
|
|
|
9
9
|
*/
|
|
10
10
|
type Role =
|
|
11
11
|
| "error"
|
|
12
|
+
| "warning"
|
|
12
13
|
| "probability"
|
|
13
14
|
| "description"
|
|
14
15
|
| "path"
|
|
@@ -27,15 +28,16 @@ type Line = ReadonlyArray<Span>;
|
|
|
27
28
|
/**
|
|
28
29
|
* SGR parameters per role. The frame's are set colors, from the 256-color
|
|
29
30
|
* palette (`38;5;N`) or truecolor (`38;2;R;G;B`), bold with a trailing `;1`,
|
|
30
|
-
* and a dim line number. Of the header, only the `×` and the rule are red,
|
|
31
|
-
* since a whole line of red is hard to read:
|
|
32
|
-
* the
|
|
33
|
-
* be gray. The code's are the terminal's
|
|
34
|
-
* theme picks shades that read on its
|
|
35
|
-
* would run into the underline.
|
|
31
|
+
* and a dim line number. Of the header, only the `×` and the rule are red, or
|
|
32
|
+
* a warning's `⚠` and rule amber, since a whole line of red is hard to read:
|
|
33
|
+
* the probability is an accent, and the description a cool near-white, set
|
|
34
|
+
* here since a theme's own white can be gray. The code's are the terminal's
|
|
35
|
+
* own colors (`3N`), so the user's theme picks shades that read on its
|
|
36
|
+
* background; none is magenta, which would run into the underline.
|
|
36
37
|
*/
|
|
37
38
|
const THEME: Readonly<Record<Role, string>> = {
|
|
38
39
|
error: "38;2;164;20;71;1",
|
|
40
|
+
warning: "38;2;214;154;0;1",
|
|
39
41
|
probability: "38;5;156",
|
|
40
42
|
description: "38;2;230;230;255",
|
|
41
43
|
path: "38;2;5;125;160;1",
|
|
@@ -90,9 +92,9 @@ const counted = (count: number, one: string, many: string): string =>
|
|
|
90
92
|
|
|
91
93
|
const header = (finding: Finding): Line => [
|
|
92
94
|
span(" "),
|
|
93
|
-
span("×",
|
|
95
|
+
span(finding.level === "warning" ? "⚠" : "×", finding.level),
|
|
94
96
|
span(" "),
|
|
95
|
-
span(shownId({ id: finding.rule, preset: finding.preset }),
|
|
97
|
+
span(shownId({ id: finding.rule, preset: finding.preset }), finding.level),
|
|
96
98
|
span(" ("),
|
|
97
99
|
span(finding.probability.toFixed(2), "probability"),
|
|
98
100
|
span("): "),
|
|
@@ -152,6 +154,13 @@ const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line>
|
|
|
152
154
|
...hint(finding),
|
|
153
155
|
];
|
|
154
156
|
|
|
157
|
+
/** The findings counted by level: `1 error`, or `2 errors and 1 warning` once there are warnings. */
|
|
158
|
+
const found = (findings: ReadonlyArray<Finding>): string => {
|
|
159
|
+
const warnings = findings.filter((finding) => finding.level === "warning").length;
|
|
160
|
+
const errors = counted(findings.length - warnings, "error", "errors");
|
|
161
|
+
return warnings === 0 ? errors : `${errors} and ${counted(warnings, "warning", "warnings")}`;
|
|
162
|
+
};
|
|
163
|
+
|
|
155
164
|
const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
156
165
|
const counts = [
|
|
157
166
|
counted(result.files, "file", "files"),
|
|
@@ -160,11 +169,9 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
160
169
|
...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
|
|
161
170
|
...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
|
|
162
171
|
...(result.blocked.length > 0 ? [`${result.blocked.length} blocked`] : []),
|
|
172
|
+
...(result.suppressed > 0 ? [`${result.suppressed} suppressed`] : []),
|
|
163
173
|
];
|
|
164
|
-
return [
|
|
165
|
-
[span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
|
|
166
|
-
[span(`${counts.join(", ")}.`)],
|
|
167
|
-
];
|
|
174
|
+
return [[span(`Found ${found(result.findings)}.`)], [span(`${counts.join(", ")}.`)]];
|
|
168
175
|
};
|
|
169
176
|
|
|
170
177
|
/**
|
|
@@ -214,7 +221,7 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
214
221
|
/** What a run was asked to keep to, for the plan to say. */
|
|
215
222
|
export interface RunLimits {
|
|
216
223
|
readonly filter?: ReadonlyArray<string>;
|
|
217
|
-
readonly rpm?: number;
|
|
224
|
+
readonly rpm?: number | undefined;
|
|
218
225
|
}
|
|
219
226
|
|
|
220
227
|
/** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
|