@drkmttr/adhere 0.9.4 → 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 +257 -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 +13 -3
- 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/mechanical.ts +2 -2
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +9 -3
- package/src/pricing.ts +14 -0
- package/src/rules.ts +43 -1
- package/src/services/AdhereConfig.ts +40 -14
- package/src/services/Jev.ts +17 -0
- package/src/services/SourceWalker.ts +32 -10
- package/src/suppress.ts +113 -0
- package/src/wording.ts +2 -2
- package/src/workflows/audit.ts +137 -27
- package/src/workflows/format.ts +62 -15
package/src/services/Jev.ts
CHANGED
|
@@ -344,6 +344,23 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
|
344
344
|
return requests.map(({ questions }) => ({ ...body, questions }));
|
|
345
345
|
};
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* The requests judging `rules` over a file takes, and about how many input
|
|
349
|
+
* tokens they carry, by `tokensOf`: the judge body, split to fit, as `judge`
|
|
350
|
+
* sends it, each request with the file again.
|
|
351
|
+
*/
|
|
352
|
+
export const judgeLoad = (
|
|
353
|
+
lines: Lines,
|
|
354
|
+
rules: Rules,
|
|
355
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
356
|
+
): { readonly requests: number; readonly tokens: number } => {
|
|
357
|
+
const requests = requestsOf(judgeBody("", lines, rules, sampled));
|
|
358
|
+
return {
|
|
359
|
+
requests: requests.length,
|
|
360
|
+
tokens: requests.reduce((sum, request) => sum + tokensOf(request), 0),
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
|
|
347
364
|
/** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
|
|
348
365
|
export const judgeRequests = (
|
|
349
366
|
lines: Lines,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { Context, Effect, FileSystem, Layer, Path } from "effect";
|
|
3
|
-
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES } from "#config.ts";
|
|
3
|
+
import { ADHERE_DIRECTORY, SKIPPED_DIRECTORIES, TEST_DIRECTORIES } from "#config.ts";
|
|
4
4
|
import type { ScannedFile } from "#models/Audit.ts";
|
|
5
5
|
import { WalkUnavailable } from "#models/Audit.ts";
|
|
6
6
|
import { walkFiles } from "#walk.ts";
|
|
7
|
+
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
7
8
|
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
8
9
|
|
|
9
10
|
/** Walks the repository's TypeScript source, skipping what is not ours to read. */
|
|
@@ -11,9 +12,9 @@ export class SourceWalker extends Context.Service<
|
|
|
11
12
|
SourceWalker,
|
|
12
13
|
{
|
|
13
14
|
/**
|
|
14
|
-
* Every TypeScript file under the audit's roots, in directory order
|
|
15
|
-
* file that cannot be read refuses the walk: a silent gap
|
|
16
|
-
* a clean audit that was never complete.
|
|
15
|
+
* Every TypeScript file under the audit's roots, in directory order, test
|
|
16
|
+
* files marked. A file that cannot be read refuses the walk: a silent gap
|
|
17
|
+
* would report a clean audit that was never complete.
|
|
17
18
|
*/
|
|
18
19
|
readonly files: Effect.Effect<ReadonlyArray<ScannedFile>, WalkUnavailable>;
|
|
19
20
|
}
|
|
@@ -50,13 +51,26 @@ export const passesFilter = (relative: string, patterns: ReadonlyArray<string>):
|
|
|
50
51
|
return (include.length === 0 || include.some(matches)) && !exclude.some(matches);
|
|
51
52
|
};
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Whether a path, relative to the working directory, is a test: a `.test.ts`
|
|
56
|
+
* or `.spec.ts` file, or any file under a test directory such as `test/`.
|
|
57
|
+
*/
|
|
58
|
+
export const isTestFile = (relative: string): boolean => {
|
|
59
|
+
const segments = relative.split(/[\\/]/);
|
|
60
|
+
const name = segments.at(-1) ?? "";
|
|
61
|
+
return (
|
|
62
|
+
name.endsWith(".test.ts") ||
|
|
63
|
+
name.endsWith(".spec.ts") ||
|
|
64
|
+
segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment))
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
const isInside = (file: string, root: string): boolean =>
|
|
54
69
|
file === root || file.startsWith(`${root}/`);
|
|
55
70
|
|
|
56
71
|
const isScannable = (file: string, self: string): boolean =>
|
|
57
72
|
file.endsWith(".ts") &&
|
|
58
73
|
!file.endsWith(".d.ts") &&
|
|
59
|
-
!file.endsWith(".test.ts") &&
|
|
60
74
|
!file.endsWith("/adhere.config.ts") &&
|
|
61
75
|
!file.endsWith("/.adhere.config.ts") &&
|
|
62
76
|
// The audit's own detector patterns are data, not violations of themselves.
|
|
@@ -169,8 +183,10 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
|
|
|
169
183
|
|
|
170
184
|
/**
|
|
171
185
|
* The real walker: the roots walked a directory at a time, skipped trees
|
|
172
|
-
* left unread, each file read and split into lines.
|
|
173
|
-
*
|
|
186
|
+
* left unread, each file read and split into lines. The run's filter and the
|
|
187
|
+
* config's `exclude` leave out what they match. Test files are read, and
|
|
188
|
+
* marked, even when no rule judges them, so a prune keeps what other runs
|
|
189
|
+
* know of them. Tests substitute their own tree.
|
|
174
190
|
* The scan root is the current working directory, so the audit reads the
|
|
175
191
|
* repository it is run in.
|
|
176
192
|
*/
|
|
@@ -179,9 +195,11 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
179
195
|
Effect.gen(function* () {
|
|
180
196
|
const fs = yield* FileSystem.FileSystem;
|
|
181
197
|
const path = yield* Path.Path;
|
|
198
|
+
const config = yield* AdhereConfig;
|
|
182
199
|
// No segments: Path resolves against the working directory.
|
|
183
200
|
const root = path.resolve();
|
|
184
201
|
const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
|
|
202
|
+
const patterns = [...filter, ...(config.exclude ?? []).map((glob) => `!${glob}`)];
|
|
185
203
|
const walk = Effect.fn("SourceWalker.files")(function* () {
|
|
186
204
|
const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
|
|
187
205
|
const trees = yield* clearingStatus(
|
|
@@ -193,12 +211,16 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
193
211
|
dir === "." ? "the working directory" : `${dir}/`,
|
|
194
212
|
self,
|
|
195
213
|
(relative) =>
|
|
196
|
-
//
|
|
197
|
-
passesFilter(dir === "." ? relative : `${dir}/${relative}`,
|
|
214
|
+
// Patterns are relative to the working directory, not the tree read.
|
|
215
|
+
passesFilter(dir === "." ? relative : `${dir}/${relative}`, patterns),
|
|
198
216
|
).pipe(Effect.mapError(refused)),
|
|
199
217
|
),
|
|
200
218
|
);
|
|
201
|
-
return trees
|
|
219
|
+
return trees
|
|
220
|
+
.flat()
|
|
221
|
+
.map((file) =>
|
|
222
|
+
isTestFile(path.relative(root, file.path)) ? { ...file, test: true } : file,
|
|
223
|
+
);
|
|
202
224
|
});
|
|
203
225
|
return SourceWalker.of({ files: walk() });
|
|
204
226
|
}),
|
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/wording.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Example, examplesOf, type Rule } from "#config.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type RuleEntry, shownId } from "#rules.ts";
|
|
3
3
|
|
|
4
4
|
type Word = Example["word"];
|
|
5
5
|
|
|
@@ -83,7 +83,7 @@ export const formatStrays = (strays: ReadonlyArray<Stray>, root: string): Readon
|
|
|
83
83
|
: [
|
|
84
84
|
`${strays.length} ${strays.length === 1 ? "rule is" : "rules are"} not worded as the rule writing tips recommend:`,
|
|
85
85
|
...strays.flatMap(({ entry, straying }) => [
|
|
86
|
-
` ${entry
|
|
86
|
+
` ${shownId(entry)}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
|
|
87
87
|
...straying.map((one) => ` ${sentenceOf(one)}`),
|
|
88
88
|
]),
|
|
89
89
|
`Rule writing tips: ${TIPS_URL}`,
|
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";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
JevUnavailable,
|
|
10
11
|
type Judged,
|
|
11
12
|
judgeQuestion,
|
|
13
|
+
judgeLoad,
|
|
12
14
|
judgeRequests,
|
|
13
15
|
linterQuestion,
|
|
14
16
|
locateRequests,
|
|
@@ -18,11 +20,15 @@ import {
|
|
|
18
20
|
import { SourceWalker } from "#services/SourceWalker.ts";
|
|
19
21
|
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
20
22
|
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
21
|
-
import {
|
|
23
|
+
import { priceOf } from "#pricing.ts";
|
|
24
|
+
import { applicableRules, judgesFile, shownId } from "#rules.ts";
|
|
25
|
+
import { NO_SUPPRESSIONS, suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
|
|
22
26
|
import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
|
|
23
27
|
|
|
24
28
|
export interface Finding {
|
|
25
29
|
readonly rule: RuleId;
|
|
30
|
+
/** The preset the rule came from, when it is not the project's own. */
|
|
31
|
+
readonly preset?: string;
|
|
26
32
|
readonly description: string;
|
|
27
33
|
readonly examples: Examples;
|
|
28
34
|
readonly file: string;
|
|
@@ -30,8 +36,17 @@ export interface Finding {
|
|
|
30
36
|
/** The code around `line` the report shows. */
|
|
31
37
|
readonly excerpt: Excerpt;
|
|
32
38
|
readonly probability: number;
|
|
39
|
+
/** An error fails the run; a warning does not, unless `--deny-warnings`. */
|
|
40
|
+
readonly level: Level;
|
|
33
41
|
}
|
|
34
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
|
+
|
|
35
50
|
export interface AuditResult {
|
|
36
51
|
readonly files: number;
|
|
37
52
|
readonly judged: number;
|
|
@@ -42,6 +57,8 @@ export interface AuditResult {
|
|
|
42
57
|
/** Files the firewall in front of Jev's API refused, with the ID to report each by. */
|
|
43
58
|
readonly blocked: ReadonlyArray<Blocked>;
|
|
44
59
|
readonly findings: ReadonlyArray<Finding>;
|
|
60
|
+
/** Findings an `adhere-ignore` comment covers, left out of `findings`. */
|
|
61
|
+
readonly suppressed: number;
|
|
45
62
|
}
|
|
46
63
|
|
|
47
64
|
/** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
|
|
@@ -54,12 +71,16 @@ interface FileResult {
|
|
|
54
71
|
readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
|
|
55
72
|
readonly findings: ReadonlyArray<Finding>;
|
|
56
73
|
readonly blocked?: Blocked;
|
|
74
|
+
/** Findings an `adhere-ignore` comment covers, left out of `findings`. */
|
|
75
|
+
readonly suppressed?: number;
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
interface PreparedRule {
|
|
60
79
|
readonly rule: Rule;
|
|
61
80
|
readonly fingerprint: string;
|
|
62
81
|
readonly threshold: number;
|
|
82
|
+
/** The preset the rule came from, when it is not the project's own. */
|
|
83
|
+
readonly preset?: string;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
const fileResult = (
|
|
@@ -79,7 +100,10 @@ const fingerprintOf = (model: string, rule: Rule) =>
|
|
|
79
100
|
|
|
80
101
|
/** One file before anything is sent: its rules, and which of them the cache answers. */
|
|
81
102
|
export interface FilePlan {
|
|
103
|
+
/** The file as Jev reads it: its comments taken out, unless the config keeps them. */
|
|
82
104
|
readonly file: ScannedFile;
|
|
105
|
+
/** The file's lines as written, which the report's excerpts show. */
|
|
106
|
+
readonly original: ReadonlyArray<string>;
|
|
83
107
|
/** Too long for Jev: counted, not judged. */
|
|
84
108
|
readonly skipped: boolean;
|
|
85
109
|
readonly hash: string;
|
|
@@ -91,11 +115,18 @@ export interface FilePlan {
|
|
|
91
115
|
readonly deferred: number;
|
|
92
116
|
/** Pending rules whose linter check question rides along on this file, each with its tally's key. */
|
|
93
117
|
readonly sampled: Readonly<Record<RuleId, string>>;
|
|
118
|
+
/** What the file's `adhere-ignore` comments suppress. */
|
|
119
|
+
readonly suppressions: Suppressions;
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
97
123
|
export interface AuditPlan {
|
|
98
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>;
|
|
99
130
|
/** Rules that apply to at least one file that is judged. */
|
|
100
131
|
readonly rules: number;
|
|
101
132
|
/** Every pair of a judged file and a rule that applies to it. */
|
|
@@ -108,6 +139,10 @@ export interface AuditPlan {
|
|
|
108
139
|
readonly deferred: number;
|
|
109
140
|
/** Requests to Jev that judging the other checks takes. Locating findings adds 1 or more per file. */
|
|
110
141
|
readonly requests: number;
|
|
142
|
+
/** About how many input tokens those requests carry, which Jev charges for. */
|
|
143
|
+
readonly tokens: number;
|
|
144
|
+
/** The model's price per million input tokens, in dollars, when adhere knows it. */
|
|
145
|
+
readonly price?: number;
|
|
111
146
|
}
|
|
112
147
|
|
|
113
148
|
/** A file finished, for a progress counter: the requests it took and the findings it has. */
|
|
@@ -155,7 +190,7 @@ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyA
|
|
|
155
190
|
|
|
156
191
|
export interface PlanOptions {
|
|
157
192
|
/** Judge at most this many checks; the rest wait for a later run. */
|
|
158
|
-
readonly limit?: number;
|
|
193
|
+
readonly limit?: number | undefined;
|
|
159
194
|
}
|
|
160
195
|
|
|
161
196
|
export const planAudit = (
|
|
@@ -168,13 +203,37 @@ export const planAudit = (
|
|
|
168
203
|
Effect.gen(function* () {
|
|
169
204
|
const config = yield* AdhereConfig;
|
|
170
205
|
const cache = yield* AuditCache;
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
);
|
|
175
234
|
|
|
176
235
|
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
177
|
-
const rules = configuredRules(file
|
|
236
|
+
const rules = configuredRules(file);
|
|
178
237
|
// Hashed even when skipped, so a prune keeps what other runs know of its content.
|
|
179
238
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
180
239
|
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
@@ -189,16 +248,25 @@ export const planAudit = (
|
|
|
189
248
|
pending: {},
|
|
190
249
|
deferred: 0,
|
|
191
250
|
sampled: {},
|
|
251
|
+
suppressions: suppressionsIn(file),
|
|
252
|
+
original: originalOf(file),
|
|
192
253
|
} satisfies FilePlan;
|
|
193
254
|
}
|
|
194
255
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
195
256
|
Object.entries(rules),
|
|
196
257
|
([id, rule]) =>
|
|
197
|
-
Effect.map(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
258
|
+
Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
|
|
259
|
+
const preset = presetOfRule.get(rule);
|
|
260
|
+
return [
|
|
261
|
+
id,
|
|
262
|
+
{
|
|
263
|
+
rule,
|
|
264
|
+
fingerprint,
|
|
265
|
+
threshold: rule.threshold ?? config.threshold,
|
|
266
|
+
...(preset === undefined ? {} : { preset }),
|
|
267
|
+
},
|
|
268
|
+
] as const;
|
|
269
|
+
}),
|
|
202
270
|
).pipe(Effect.map(Record.fromEntries));
|
|
203
271
|
const answers = yield* cache.get(hash, file.path);
|
|
204
272
|
const kept: Record<RuleId, Judgment> = Record.filterMap(prepared, ({ fingerprint }) => {
|
|
@@ -218,9 +286,17 @@ export const planAudit = (
|
|
|
218
286
|
pending,
|
|
219
287
|
deferred: 0,
|
|
220
288
|
sampled: {},
|
|
289
|
+
suppressions: suppressionsIn(file),
|
|
290
|
+
original: originalOf(file),
|
|
221
291
|
} satisfies FilePlan;
|
|
222
292
|
});
|
|
223
293
|
|
|
294
|
+
// Each preset's rule, with the preset it came from, for its findings to name.
|
|
295
|
+
const presetOfRule = new Map(
|
|
296
|
+
(config.scopedRules ?? []).flatMap((entry) =>
|
|
297
|
+
entry.preset === undefined ? [] : [[entry.rule, entry.preset] as const],
|
|
298
|
+
),
|
|
299
|
+
);
|
|
224
300
|
// Preset rules are not the project's to change, so the linter check leaves them out.
|
|
225
301
|
const presetRules = new Set(
|
|
226
302
|
(config.scopedRules ?? []).flatMap((entry) =>
|
|
@@ -282,7 +358,16 @@ export const planAudit = (
|
|
|
282
358
|
});
|
|
283
359
|
});
|
|
284
360
|
|
|
285
|
-
|
|
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
|
+
);
|
|
286
371
|
const status = yield* Status;
|
|
287
372
|
let done = 0;
|
|
288
373
|
const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
|
|
@@ -304,20 +389,25 @@ export const planAudit = (
|
|
|
304
389
|
options.limit === undefined ? everything : withinLimit(everything, options.limit);
|
|
305
390
|
const planned = yield* withSamples(limited);
|
|
306
391
|
const judged = planned.filter((plan) => !plan.skipped);
|
|
392
|
+
const loads = judged.map((plan) =>
|
|
393
|
+
isEmpty(plan.pending)
|
|
394
|
+
? { requests: 0, tokens: 0 }
|
|
395
|
+
: judgeLoad(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
|
|
396
|
+
);
|
|
397
|
+
const price = priceOf(config.model);
|
|
307
398
|
const total = (count: (plan: FilePlan) => number) =>
|
|
308
399
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
309
400
|
return {
|
|
310
401
|
files: planned,
|
|
402
|
+
unjudged,
|
|
311
403
|
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
312
404
|
checks: total((plan) => sizeOf(plan.prepared)),
|
|
313
405
|
cached: total((plan) => sizeOf(plan.kept)),
|
|
314
406
|
skipped: planned.length - judged.length,
|
|
315
407
|
deferred: total((plan) => plan.deferred),
|
|
316
|
-
requests:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
: judgeRequests(plan.file.lines, rulesOf(plan.pending), Object.keys(plan.sampled)),
|
|
320
|
-
),
|
|
408
|
+
requests: loads.reduce((sum, load) => sum + load.requests, 0),
|
|
409
|
+
tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
|
|
410
|
+
...(price === undefined ? {} : { price }),
|
|
321
411
|
};
|
|
322
412
|
});
|
|
323
413
|
|
|
@@ -359,6 +449,7 @@ export const executeAudit = (
|
|
|
359
449
|
/** Judges and locates one file the plan left pending, with the requests that took. */
|
|
360
450
|
const judgeFile = Effect.fn("audit.judgeFile")(function* ({
|
|
361
451
|
file,
|
|
452
|
+
original,
|
|
362
453
|
hash,
|
|
363
454
|
prepared,
|
|
364
455
|
kept,
|
|
@@ -389,9 +480,10 @@ export const executeAudit = (
|
|
|
389
480
|
const {
|
|
390
481
|
lines,
|
|
391
482
|
blocked,
|
|
392
|
-
}: {
|
|
393
|
-
|
|
394
|
-
|
|
483
|
+
}: {
|
|
484
|
+
readonly lines: Readonly<Record<RuleId, number>>;
|
|
485
|
+
readonly blocked: Blocked | undefined;
|
|
486
|
+
} = isEmpty(flagged)
|
|
395
487
|
? { lines: {}, blocked: undefined }
|
|
396
488
|
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
|
|
397
489
|
Effect.map((lines) => ({ lines, blocked: undefined })),
|
|
@@ -417,12 +509,14 @@ export const executeAudit = (
|
|
|
417
509
|
? [
|
|
418
510
|
{
|
|
419
511
|
rule: id,
|
|
512
|
+
...(k.preset === undefined ? {} : { preset: k.preset }),
|
|
420
513
|
description: k.rule.description,
|
|
421
514
|
examples: examplesOf(k.rule),
|
|
422
515
|
file: file.path,
|
|
423
516
|
line: judgment.line,
|
|
424
|
-
excerpt: excerptOf(
|
|
517
|
+
excerpt: excerptOf(original, judgment.line),
|
|
425
518
|
probability: judgment.probability,
|
|
519
|
+
level: k.rule.level ?? "error",
|
|
426
520
|
},
|
|
427
521
|
]
|
|
428
522
|
: [];
|
|
@@ -483,8 +577,22 @@ export const executeAudit = (
|
|
|
483
577
|
),
|
|
484
578
|
Effect.annotateLogs("file", plan.file.path),
|
|
485
579
|
);
|
|
486
|
-
|
|
487
|
-
|
|
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 };
|
|
488
596
|
});
|
|
489
597
|
|
|
490
598
|
const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
|
|
@@ -498,13 +606,15 @@ export const executeAudit = (
|
|
|
498
606
|
waiting: count("waiting"),
|
|
499
607
|
blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
|
|
500
608
|
findings: results.flatMap((result) => result.findings),
|
|
609
|
+
suppressed: results.reduce((sum, result) => sum + (result.suppressed ?? 0), 0),
|
|
501
610
|
};
|
|
502
611
|
});
|
|
503
612
|
|
|
504
613
|
/**
|
|
505
614
|
* Deletes from the cache the answers about content no file has anymore.
|
|
506
615
|
* Answers to rules the run left out stay, since a run with other presets or
|
|
507
|
-
* 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
|
|
508
618
|
* `--filter` cannot tell what content the files it left out have.
|
|
509
619
|
*/
|
|
510
620
|
export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
@@ -516,7 +626,7 @@ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
|
516
626
|
);
|
|
517
627
|
const deleted = yield* cache.prune({
|
|
518
628
|
// A file too long to judge counts: a run with shorter questions judges it.
|
|
519
|
-
hashes: new Set(plan.files.map((file) => file.hash)),
|
|
629
|
+
hashes: new Set([...plan.files.map((file) => file.hash), ...plan.unjudged]),
|
|
520
630
|
tallies: new Set(tallies),
|
|
521
631
|
});
|
|
522
632
|
yield* Effect.logDebug(`cache pruned: ${deleted} ${deleted === 1 ? "file" : "files"} deleted`);
|