@drkmttr/adhere 0.9.5 → 0.11.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 +406 -90
- package/package.json +7 -7
- 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/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/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/gen-for-sequencing.md +7 -3
- 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 +7 -3
- package/presets/effect/data/variants-are-tagged-unions.md +7 -3
- 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 +7 -3
- 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/presets/react/effects/clean-up-what-they-start.md +23 -0
- package/presets/react/effects/event-logic-in-handlers.md +29 -0
- package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
- package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
- package/presets/react/effects/notify-parents-in-handlers.md +29 -0
- package/presets/react/server/no-private-data-to-client.md +23 -0
- package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
- package/presets/react/state/no-contradictory-state.md +17 -0
- package/presets/react/state/no-props-copied-into-state.md +23 -0
- package/presets/react/state/store-ids-not-copies.md +20 -0
- package/presets/security/constant-time-secret-compare.md +22 -0
- package/presets/security/no-secrets-in-output.md +19 -0
- package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
- package/presets/security/parameterized-queries.md +16 -0
- package/presets/typescript/async/concurrent-independent-work.md +23 -0
- package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
- package/presets/typescript/design/no-argument-mutation.md +20 -0
- package/presets/typescript/errors/no-swallowed-errors.md +29 -0
- package/presets/typescript/errors/release-on-every-path.md +29 -0
- package/presets/typescript/testing/assert-outcomes.md +22 -0
- package/presets/typescript/testing/independent-tests.md +41 -0
- package/presets/typescript/types/assertions-only-when-proven.md +20 -0
- package/presets/typescript/types/derive-dont-restate.md +24 -0
- package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
- package/presets/typescript/types/validate-external-data.md +21 -0
- package/skills/adhere/SKILL.md +41 -12
- package/skills/adhere-fix/SKILL.md +180 -0
- package/src/cli.ts +59 -14
- package/src/comments.ts +36 -0
- package/src/config.ts +126 -14
- package/src/contradictions.ts +10 -2
- package/src/excerpt.ts +78 -58
- package/src/highlight.ts +2 -0
- package/src/index.ts +1 -1
- package/src/init.ts +119 -12
- package/src/main.ts +0 -1
- package/src/markdown.ts +83 -14
- package/src/models/Audit.ts +2 -0
- package/src/presets.ts +12 -3
- package/src/rules.ts +36 -3
- package/src/services/AdhereConfig.ts +36 -13
- package/src/services/AuditCache.ts +31 -5
- package/src/services/Jev.http.ts +76 -11
- package/src/services/Jev.ts +191 -73
- package/src/services/SourceWalker.ts +51 -13
- package/src/suppress.ts +119 -0
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +192 -40
- package/src/workflows/format.ts +71 -24
- package/presets/alchemy/data/parameterized-sql.md +0 -13
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -15
- package/presets/effect/basics/external-calls-are-resilient.md +0 -17
package/src/workflows/audit.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type
|
|
1
|
+
import { isNote, withoutComments } from "#comments.ts";
|
|
2
|
+
import { type Examples, examplesOf, type Level, type Rule, type RuleId } from "#config.ts";
|
|
3
|
+
import { type Excerpt, type Range } from "#excerpt.ts";
|
|
3
4
|
import type { ScannedFile, WalkUnavailable } from "#models/Audit.ts";
|
|
4
5
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
5
6
|
import { AuditCache, answersOf, type Judgment, sha256, type Tally } from "#services/AuditCache.ts";
|
|
@@ -12,7 +13,9 @@ import {
|
|
|
12
13
|
judgeLoad,
|
|
13
14
|
judgeRequests,
|
|
14
15
|
linterQuestion,
|
|
16
|
+
type Located,
|
|
15
17
|
locateRequests,
|
|
18
|
+
matcherQuestions,
|
|
16
19
|
questionRoom,
|
|
17
20
|
tokensOf,
|
|
18
21
|
} from "#services/Jev.ts";
|
|
@@ -20,7 +23,8 @@ import { SourceWalker } from "#services/SourceWalker.ts";
|
|
|
20
23
|
import { clearingStatus, counted, countOf, Status } from "#services/Status.ts";
|
|
21
24
|
import { SAMPLE, tallyKeyOf } from "#mechanical.ts";
|
|
22
25
|
import { priceOf } from "#pricing.ts";
|
|
23
|
-
import { applicableRules } from "#rules.ts";
|
|
26
|
+
import { applicableRules, judgesFile, shownId } from "#rules.ts";
|
|
27
|
+
import { NO_SUPPRESSIONS, suppresses, type Suppressions, suppressionsOf } from "#suppress.ts";
|
|
24
28
|
import { type Crypto, Duration, Effect, Record, Result, Semaphore } from "effect";
|
|
25
29
|
|
|
26
30
|
export interface Finding {
|
|
@@ -30,12 +34,30 @@ export interface Finding {
|
|
|
30
34
|
readonly description: string;
|
|
31
35
|
readonly examples: Examples;
|
|
32
36
|
readonly file: string;
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
/** The section of the file Jev points at, which a finding is in. */
|
|
38
|
+
readonly section: Range;
|
|
39
|
+
/** The lines in the section the code that breaks the rule starts and ends on, when Jev named them. */
|
|
40
|
+
readonly lines?: Range;
|
|
41
|
+
/** The section's code as written, comments and all, without blank lines at either end. */
|
|
35
42
|
readonly excerpt: Excerpt;
|
|
43
|
+
/**
|
|
44
|
+
* Jev's probability that the file does not show enough to check the rule,
|
|
45
|
+
* when its probability that it does is below the sufficiency threshold:
|
|
46
|
+
* the report warns, and says how to add the context.
|
|
47
|
+
*/
|
|
48
|
+
readonly insufficiency?: number;
|
|
36
49
|
readonly probability: number;
|
|
50
|
+
/** An error fails the run; a warning does not, unless `--deny-warnings`. */
|
|
51
|
+
readonly level: Level;
|
|
37
52
|
}
|
|
38
53
|
|
|
54
|
+
/** The findings that fail a run: its errors, and its warnings too when `denyWarnings`. */
|
|
55
|
+
export const failing = (
|
|
56
|
+
findings: ReadonlyArray<Finding>,
|
|
57
|
+
denyWarnings = false,
|
|
58
|
+
): ReadonlyArray<Finding> =>
|
|
59
|
+
denyWarnings ? findings : findings.filter((finding) => finding.level === "error");
|
|
60
|
+
|
|
39
61
|
export interface AuditResult {
|
|
40
62
|
readonly files: number;
|
|
41
63
|
readonly judged: number;
|
|
@@ -46,6 +68,8 @@ export interface AuditResult {
|
|
|
46
68
|
/** Files the firewall in front of Jev's API refused, with the ID to report each by. */
|
|
47
69
|
readonly blocked: ReadonlyArray<Blocked>;
|
|
48
70
|
readonly findings: ReadonlyArray<Finding>;
|
|
71
|
+
/** Findings an `adhere-ignore` comment covers, left out of `findings`. */
|
|
72
|
+
readonly suppressed: number;
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
/** A file whose request the firewall refused, and Cloudflare's Ray ID for the refusal. */
|
|
@@ -58,6 +82,8 @@ interface FileResult {
|
|
|
58
82
|
readonly status: "judged" | "cached" | "skipped" | "waiting" | "blocked";
|
|
59
83
|
readonly findings: ReadonlyArray<Finding>;
|
|
60
84
|
readonly blocked?: Blocked;
|
|
85
|
+
/** Findings an `adhere-ignore` comment covers, left out of `findings`. */
|
|
86
|
+
readonly suppressed?: number;
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
interface PreparedRule {
|
|
@@ -75,17 +101,58 @@ const fileResult = (
|
|
|
75
101
|
|
|
76
102
|
const isEmpty = Record.isEmptyReadonlyRecord;
|
|
77
103
|
|
|
104
|
+
/** How Jev reads a file, which a judgment depends on: a new layout re-judges every rule. */
|
|
105
|
+
const LAYOUT = "sections";
|
|
106
|
+
|
|
78
107
|
/**
|
|
79
|
-
* What a judgment depends on besides the file: the model,
|
|
80
|
-
* asked for the rule, which
|
|
81
|
-
*
|
|
108
|
+
* What a judgment depends on besides the file: the model, how the file is
|
|
109
|
+
* laid out, and the questions asked for the rule, which carry the rule: its
|
|
110
|
+
* judge question, and its matchers' when it has any. An edited rule or
|
|
111
|
+
* matcher re-judges the rule, and a question asked differently re-judges
|
|
112
|
+
* every rule.
|
|
82
113
|
*/
|
|
83
|
-
const fingerprintOf = (model: string, rule: Rule) =>
|
|
84
|
-
|
|
114
|
+
const fingerprintOf = (model: string, rule: Rule) => {
|
|
115
|
+
const matchers = matcherQuestions("", rule).map(([, question]) => question);
|
|
116
|
+
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
117
|
+
return sha256(`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}`);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** A section's lines as written, without the blank lines at either end. */
|
|
121
|
+
const excerptOf = (lines: ReadonlyArray<string>, { first, last }: Range): Excerpt => {
|
|
122
|
+
let start = first;
|
|
123
|
+
let end = last;
|
|
124
|
+
while (start < end && (lines[start - 1] ?? "").trim() === "") start += 1;
|
|
125
|
+
while (end > start && (lines[end - 1] ?? "").trim() === "") end -= 1;
|
|
126
|
+
return { start, lines: lines.slice(start - 1, end) };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** A matcher's score counts as a yes above this. */
|
|
130
|
+
const MATCHER_THRESHOLD = 0.5;
|
|
131
|
+
|
|
132
|
+
/** Whether a judgment's matchers let it stand: a yes to every `appliesTo`, and to no `excludeIf`. */
|
|
133
|
+
const inScope = (judgment: Judgment): boolean =>
|
|
134
|
+
(judgment.appliesTo ?? []).every((score) => score > MATCHER_THRESHOLD) &&
|
|
135
|
+
!(judgment.excludeIf ?? []).some((score) => score > MATCHER_THRESHOLD);
|
|
136
|
+
|
|
137
|
+
/** The matchers that dropped a judgment, with their scores, as the debug log names them. */
|
|
138
|
+
const scopeMisses = (rule: Rule, judgment: Judgment): string =>
|
|
139
|
+
[
|
|
140
|
+
...(rule.appliesTo ?? []).flatMap((matcher, index) => {
|
|
141
|
+
const score = judgment.appliesTo?.[index] ?? 0;
|
|
142
|
+
return score > MATCHER_THRESHOLD ? [] : [`appliesTo "${matcher}" (${score.toFixed(2)})`];
|
|
143
|
+
}),
|
|
144
|
+
...(rule.excludeIf ?? []).flatMap((matcher, index) => {
|
|
145
|
+
const score = judgment.excludeIf?.[index] ?? 0;
|
|
146
|
+
return score > MATCHER_THRESHOLD ? [`excludeIf "${matcher}" (${score.toFixed(2)})`] : [];
|
|
147
|
+
}),
|
|
148
|
+
].join(", ");
|
|
85
149
|
|
|
86
150
|
/** One file before anything is sent: its rules, and which of them the cache answers. */
|
|
87
151
|
export interface FilePlan {
|
|
152
|
+
/** The file as Jev reads it: its comments taken out, unless the config keeps them. */
|
|
88
153
|
readonly file: ScannedFile;
|
|
154
|
+
/** The file's lines as written, which the report's excerpts show. */
|
|
155
|
+
readonly original: ReadonlyArray<string>;
|
|
89
156
|
/** Too long for Jev: counted, not judged. */
|
|
90
157
|
readonly skipped: boolean;
|
|
91
158
|
readonly hash: string;
|
|
@@ -97,11 +164,18 @@ export interface FilePlan {
|
|
|
97
164
|
readonly deferred: number;
|
|
98
165
|
/** Pending rules whose linter check question rides along on this file, each with its tally's key. */
|
|
99
166
|
readonly sampled: Readonly<Record<RuleId, string>>;
|
|
167
|
+
/** What the file's `adhere-ignore` comments suppress. */
|
|
168
|
+
readonly suppressions: Suppressions;
|
|
100
169
|
}
|
|
101
170
|
|
|
102
171
|
/** A run worked out from the files, the rules, and the cache, before any request. */
|
|
103
172
|
export interface AuditPlan {
|
|
104
173
|
readonly files: ReadonlyArray<FilePlan>;
|
|
174
|
+
/**
|
|
175
|
+
* The content hashes of files read that no rule in the run judges, such as
|
|
176
|
+
* tests when no rule says `tests`: a prune keeps what other runs know of them.
|
|
177
|
+
*/
|
|
178
|
+
readonly unjudged: ReadonlyArray<string>;
|
|
105
179
|
/** Rules that apply to at least one file that is judged. */
|
|
106
180
|
readonly rules: number;
|
|
107
181
|
/** Every pair of a judged file and a rule that applies to it. */
|
|
@@ -118,6 +192,8 @@ export interface AuditPlan {
|
|
|
118
192
|
readonly tokens: number;
|
|
119
193
|
/** The model's price per million input tokens, in dollars, when adhere knows it. */
|
|
120
194
|
readonly price?: number;
|
|
195
|
+
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
196
|
+
readonly sufficiencyThreshold: number;
|
|
121
197
|
}
|
|
122
198
|
|
|
123
199
|
/** A file finished, for a progress counter: the requests it took and the findings it has. */
|
|
@@ -165,7 +241,7 @@ const withinLimit = (planned: ReadonlyArray<FilePlan>, limit: number): ReadonlyA
|
|
|
165
241
|
|
|
166
242
|
export interface PlanOptions {
|
|
167
243
|
/** Judge at most this many checks; the rest wait for a later run. */
|
|
168
|
-
readonly limit?: number;
|
|
244
|
+
readonly limit?: number | undefined;
|
|
169
245
|
}
|
|
170
246
|
|
|
171
247
|
export const planAudit = (
|
|
@@ -178,13 +254,37 @@ export const planAudit = (
|
|
|
178
254
|
Effect.gen(function* () {
|
|
179
255
|
const config = yield* AdhereConfig;
|
|
180
256
|
const cache = yield* AuditCache;
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
257
|
+
const walked = yield* (yield* SourceWalker).files;
|
|
258
|
+
// Each file as Jev reads it, each line keeping its number: its adhere-ignore comments blanked,
|
|
259
|
+
// and every other comment too but the @adhere notes, unless the config keeps comments. A
|
|
260
|
+
// comment changes nothing a file does, so it should not change the report.
|
|
261
|
+
const suppressed = new Map<string, Suppressions>();
|
|
262
|
+
const originals = new Map<string, ReadonlyArray<string>>();
|
|
263
|
+
const files = walked.map((file) => {
|
|
264
|
+
const { lines: directed, suppressions } = suppressionsOf(file.lines);
|
|
265
|
+
if (suppressions !== NO_SUPPRESSIONS) suppressed.set(file.path, suppressions);
|
|
266
|
+
const lines = config.includeComments === true ? directed : withoutComments(directed, isNote);
|
|
267
|
+
if (lines === file.lines) return file;
|
|
268
|
+
originals.set(file.path, file.lines);
|
|
269
|
+
return { ...file, lines };
|
|
270
|
+
});
|
|
271
|
+
const suppressionsIn = (file: ScannedFile) => suppressed.get(file.path) ?? NO_SUPPRESSIONS;
|
|
272
|
+
const originalOf = (file: ScannedFile) => originals.get(file.path) ?? file.lines;
|
|
273
|
+
|
|
274
|
+
// The rules that judge a file: those scoped to it, of which only the rules that say `tests`
|
|
275
|
+
// judge a test, and none that an adhere-ignore-file comment names.
|
|
276
|
+
const configuredRules = (file: ScannedFile) =>
|
|
277
|
+
Record.filter(
|
|
278
|
+
config.scopedRules === undefined
|
|
279
|
+
? config.rules
|
|
280
|
+
: applicableRules(file.path, config.scopedRules),
|
|
281
|
+
(rule, id) =>
|
|
282
|
+
judgesFile(rule, file.test === true) &&
|
|
283
|
+
!suppressionsIn(file).file.has(shownId({ id, preset: presetOfRule.get(rule) })),
|
|
284
|
+
);
|
|
185
285
|
|
|
186
286
|
const planFile = Effect.fn("audit.plan")(function* (file: ScannedFile) {
|
|
187
|
-
const rules = configuredRules(file
|
|
287
|
+
const rules = configuredRules(file);
|
|
188
288
|
// Hashed even when skipped, so a prune keeps what other runs know of its content.
|
|
189
289
|
const hash = yield* sha256(file.lines.join("\n"));
|
|
190
290
|
// Too long for Jev's context: skipped and counted, rather than refused mid-run.
|
|
@@ -199,6 +299,8 @@ export const planAudit = (
|
|
|
199
299
|
pending: {},
|
|
200
300
|
deferred: 0,
|
|
201
301
|
sampled: {},
|
|
302
|
+
suppressions: suppressionsIn(file),
|
|
303
|
+
original: originalOf(file),
|
|
202
304
|
} satisfies FilePlan;
|
|
203
305
|
}
|
|
204
306
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
@@ -235,6 +337,8 @@ export const planAudit = (
|
|
|
235
337
|
pending,
|
|
236
338
|
deferred: 0,
|
|
237
339
|
sampled: {},
|
|
340
|
+
suppressions: suppressionsIn(file),
|
|
341
|
+
original: originalOf(file),
|
|
238
342
|
} satisfies FilePlan;
|
|
239
343
|
});
|
|
240
344
|
|
|
@@ -305,7 +409,16 @@ export const planAudit = (
|
|
|
305
409
|
});
|
|
306
410
|
});
|
|
307
411
|
|
|
308
|
-
|
|
412
|
+
// A file no rule judges, such as a test when no rule says `tests`, is not part of the run,
|
|
413
|
+
// but its content is: another run's rules may judge it.
|
|
414
|
+
const sorted = files
|
|
415
|
+
.filter((file) => !isEmpty(configuredRules(file)))
|
|
416
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
417
|
+
const unjudged = yield* Effect.forEach(
|
|
418
|
+
files.filter((file) => isEmpty(configuredRules(file))),
|
|
419
|
+
(file) => sha256(file.lines.join("\n")),
|
|
420
|
+
{ concurrency: 8 },
|
|
421
|
+
);
|
|
309
422
|
const status = yield* Status;
|
|
310
423
|
let done = 0;
|
|
311
424
|
const everything: ReadonlyArray<FilePlan> = yield* clearingStatus(
|
|
@@ -337,6 +450,7 @@ export const planAudit = (
|
|
|
337
450
|
judged.reduce((sum, plan) => sum + count(plan), 0);
|
|
338
451
|
return {
|
|
339
452
|
files: planned,
|
|
453
|
+
unjudged,
|
|
340
454
|
rules: new Set(judged.flatMap((plan) => Object.keys(plan.prepared))).size,
|
|
341
455
|
checks: total((plan) => sizeOf(plan.prepared)),
|
|
342
456
|
cached: total((plan) => sizeOf(plan.kept)),
|
|
@@ -345,6 +459,7 @@ export const planAudit = (
|
|
|
345
459
|
requests: loads.reduce((sum, load) => sum + load.requests, 0),
|
|
346
460
|
tokens: loads.reduce((sum, load) => sum + load.tokens, 0),
|
|
347
461
|
...(price === undefined ? {} : { price }),
|
|
462
|
+
sufficiencyThreshold: config.sufficiencyThreshold,
|
|
348
463
|
};
|
|
349
464
|
});
|
|
350
465
|
|
|
@@ -386,6 +501,7 @@ export const executeAudit = (
|
|
|
386
501
|
/** Judges and locates one file the plan left pending, with the requests that took. */
|
|
387
502
|
const judgeFile = Effect.fn("audit.judgeFile")(function* ({
|
|
388
503
|
file,
|
|
504
|
+
original,
|
|
389
505
|
hash,
|
|
390
506
|
prepared,
|
|
391
507
|
kept,
|
|
@@ -393,14 +509,25 @@ export const executeAudit = (
|
|
|
393
509
|
deferred,
|
|
394
510
|
sampled,
|
|
395
511
|
}: FilePlan) {
|
|
396
|
-
const { probabilities, linter }: Judged = isEmpty(pending)
|
|
512
|
+
const { probabilities, linter, matchers }: Judged = isEmpty(pending)
|
|
397
513
|
? { probabilities: {}, linter: {} }
|
|
398
514
|
: yield* jev.judge(file.lines, rulesOf(pending), Object.keys(sampled)).pipe(inFile(file));
|
|
399
515
|
if (!isEmpty(linter)) yield* record(file.path, sampled, linter);
|
|
400
516
|
const judged: Record<RuleId, Judgment> = { ...kept };
|
|
401
517
|
for (const [id, probability] of Object.entries(probabilities)) {
|
|
402
518
|
const k = pending[id];
|
|
403
|
-
if (k !== undefined)
|
|
519
|
+
if (k !== undefined) {
|
|
520
|
+
judged[id] = { fingerprint: k.fingerprint, probability, ...matchers?.[id] };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// Above its threshold, but its matchers put the code out of the rule's scope.
|
|
524
|
+
for (const [id, judgment] of Object.entries(judged)) {
|
|
525
|
+
const k = prepared[id];
|
|
526
|
+
if (k !== undefined && judgment.probability > k.threshold && !inScope(judgment)) {
|
|
527
|
+
yield* Effect.logDebug(
|
|
528
|
+
`${file.path}: ${id} (${judgment.probability.toFixed(2)}) dropped by ${scopeMisses(k.rule, judgment)}`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
404
531
|
}
|
|
405
532
|
|
|
406
533
|
const flagged = Record.filter(prepared, (k, id) => {
|
|
@@ -408,28 +535,30 @@ export const executeAudit = (
|
|
|
408
535
|
return (
|
|
409
536
|
judgment !== undefined &&
|
|
410
537
|
judgment.probability > k.threshold &&
|
|
411
|
-
judgment
|
|
538
|
+
inScope(judgment) &&
|
|
539
|
+
(judgment.section === undefined || judgment.sufficiency === undefined)
|
|
412
540
|
);
|
|
413
541
|
});
|
|
414
|
-
// A blocked
|
|
415
|
-
// asks only for the
|
|
542
|
+
// A blocked locate question still leaves the judgments worth caching: a rerun
|
|
543
|
+
// asks only for the sections again, not for every rule.
|
|
416
544
|
const {
|
|
417
|
-
|
|
545
|
+
places,
|
|
418
546
|
blocked,
|
|
419
|
-
}: {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
547
|
+
}: {
|
|
548
|
+
readonly places: Readonly<Record<RuleId, Located>>;
|
|
549
|
+
readonly blocked: Blocked | undefined;
|
|
550
|
+
} = isEmpty(flagged)
|
|
551
|
+
? { places: {}, blocked: undefined }
|
|
423
552
|
: yield* jev.locate(file.lines, rulesOf(flagged)).pipe(
|
|
424
|
-
Effect.map((
|
|
553
|
+
Effect.map((places) => ({ places, blocked: undefined })),
|
|
425
554
|
Effect.catchTag("JevBlocked", ({ ray }) =>
|
|
426
|
-
Effect.succeed({
|
|
555
|
+
Effect.succeed({ places: {}, blocked: { file: file.path, ray } }),
|
|
427
556
|
),
|
|
428
557
|
inFile(file),
|
|
429
558
|
);
|
|
430
559
|
const located = Record.map(judged, (judgment, id) => {
|
|
431
|
-
const
|
|
432
|
-
return
|
|
560
|
+
const place = places[id];
|
|
561
|
+
return place === undefined ? judgment : { ...judgment, ...place };
|
|
433
562
|
});
|
|
434
563
|
|
|
435
564
|
const cached = isEmpty(pending) && isEmpty(flagged);
|
|
@@ -439,8 +568,9 @@ export const executeAudit = (
|
|
|
439
568
|
.flatMap(([id, judgment]): ReadonlyArray<Finding> => {
|
|
440
569
|
const k = prepared[id];
|
|
441
570
|
return k !== undefined &&
|
|
442
|
-
judgment.
|
|
443
|
-
judgment.probability > k.threshold
|
|
571
|
+
judgment.section !== undefined &&
|
|
572
|
+
judgment.probability > k.threshold &&
|
|
573
|
+
inScope(judgment)
|
|
444
574
|
? [
|
|
445
575
|
{
|
|
446
576
|
rule: id,
|
|
@@ -448,14 +578,20 @@ export const executeAudit = (
|
|
|
448
578
|
description: k.rule.description,
|
|
449
579
|
examples: examplesOf(k.rule),
|
|
450
580
|
file: file.path,
|
|
451
|
-
|
|
452
|
-
|
|
581
|
+
section: judgment.section,
|
|
582
|
+
...(judgment.lines === undefined ? {} : { lines: judgment.lines }),
|
|
583
|
+
excerpt: excerptOf(original, judgment.section),
|
|
584
|
+
...(judgment.sufficiency !== undefined &&
|
|
585
|
+
judgment.sufficiency < plan.sufficiencyThreshold
|
|
586
|
+
? { insufficiency: 1 - judgment.sufficiency }
|
|
587
|
+
: {}),
|
|
453
588
|
probability: judgment.probability,
|
|
589
|
+
level: k.rule.level ?? "error",
|
|
454
590
|
},
|
|
455
591
|
]
|
|
456
592
|
: [];
|
|
457
593
|
})
|
|
458
|
-
.sort((a, b) => a.
|
|
594
|
+
.sort((a, b) => (a.lines ?? a.section).first - (b.lines ?? b.section).first);
|
|
459
595
|
return {
|
|
460
596
|
result:
|
|
461
597
|
blocked === undefined
|
|
@@ -511,8 +647,22 @@ export const executeAudit = (
|
|
|
511
647
|
),
|
|
512
648
|
Effect.annotateLogs("file", plan.file.path),
|
|
513
649
|
);
|
|
514
|
-
|
|
515
|
-
|
|
650
|
+
// A finding an adhere-ignore comment covers is left out of the report, and counted.
|
|
651
|
+
const shown = result.findings.filter(
|
|
652
|
+
(finding) =>
|
|
653
|
+
!suppresses(
|
|
654
|
+
plan.suppressions,
|
|
655
|
+
shownId({ id: finding.rule, preset: finding.preset }),
|
|
656
|
+
finding.lines ?? finding.section,
|
|
657
|
+
),
|
|
658
|
+
);
|
|
659
|
+
if (shown.length < result.findings.length) {
|
|
660
|
+
yield* Effect.logDebug(
|
|
661
|
+
`${result.findings.length - shown.length} suppressed by adhere-ignore`,
|
|
662
|
+
).pipe(Effect.annotateLogs("file", plan.file.path));
|
|
663
|
+
}
|
|
664
|
+
yield* progress({ requests, findings: shown.length });
|
|
665
|
+
return { ...result, findings: shown, suppressed: result.findings.length - shown.length };
|
|
516
666
|
});
|
|
517
667
|
|
|
518
668
|
const results = yield* Effect.forEach(plan.files, auditFile, { concurrency: 8 });
|
|
@@ -526,13 +676,15 @@ export const executeAudit = (
|
|
|
526
676
|
waiting: count("waiting"),
|
|
527
677
|
blocked: results.flatMap((result) => (result.blocked === undefined ? [] : [result.blocked])),
|
|
528
678
|
findings: results.flatMap((result) => result.findings),
|
|
679
|
+
suppressed: results.reduce((sum, result) => sum + (result.suppressed ?? 0), 0),
|
|
529
680
|
};
|
|
530
681
|
});
|
|
531
682
|
|
|
532
683
|
/**
|
|
533
684
|
* Deletes from the cache the answers about content no file has anymore.
|
|
534
685
|
* Answers to rules the run left out stay, since a run with other presets or
|
|
535
|
-
* rules asks them
|
|
686
|
+
* rules asks them, and so do answers about files the run read but judged
|
|
687
|
+
* against no rule. Only after a run that read every file; a run narrowed by
|
|
536
688
|
* `--filter` cannot tell what content the files it left out have.
|
|
537
689
|
*/
|
|
538
690
|
export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
@@ -544,7 +696,7 @@ export const pruneCache = Effect.fn("audit.prune")(function* (plan: AuditPlan) {
|
|
|
544
696
|
);
|
|
545
697
|
const deleted = yield* cache.prune({
|
|
546
698
|
// A file too long to judge counts: a run with shorter questions judges it.
|
|
547
|
-
hashes: new Set(plan.files.map((file) => file.hash)),
|
|
699
|
+
hashes: new Set([...plan.files.map((file) => file.hash), ...plan.unjudged]),
|
|
548
700
|
tallies: new Set(tallies),
|
|
549
701
|
});
|
|
550
702
|
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("): "),
|
|
@@ -100,36 +102,74 @@ const header = (finding: Finding): Line => [
|
|
|
100
102
|
];
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
|
-
* The path,
|
|
104
|
-
*
|
|
105
|
+
* The path at the lines Jev points at, or the section's first line when it
|
|
106
|
+
* named none, then the section with each line numbered in the gutter. One
|
|
107
|
+
* line Jev points at is underlined under its code; several are marked by a
|
|
108
|
+
* bar in a column of their own beside the code, as `vp lint` marks a span,
|
|
109
|
+
* so each line of code keeps one line of the report.
|
|
105
110
|
*/
|
|
106
111
|
const excerpt = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => {
|
|
107
112
|
const { start, lines } = finding.excerpt;
|
|
108
113
|
const width = String(start + lines.length - 1).length;
|
|
109
114
|
const gutter = span(" ".repeat(width + 2));
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
+
const indentOf = (line: string) => line.slice(0, line.length - line.trimStart().length);
|
|
116
|
+
const pointed = finding.lines;
|
|
117
|
+
const at = pointed?.first ?? start;
|
|
118
|
+
const location = `:${at}:${indentOf(lines[at - start] ?? "").length + 1}]`;
|
|
119
|
+
const spanning = pointed !== undefined && pointed.last > pointed.first;
|
|
120
|
+
const inside = (line: number) =>
|
|
121
|
+
pointed !== undefined && pointed.first <= line && line <= pointed.last;
|
|
115
122
|
return [
|
|
116
123
|
[gutter, span("╭─["), span(displayPath(finding.file, root), "path"), span(location)],
|
|
117
124
|
...highlighted(lines.join("\n")).flatMap((code, index): ReadonlyArray<Line> => {
|
|
118
125
|
const number = String(start + index);
|
|
126
|
+
const marker = spanning ? [inside(start + index) ? span("┃ ", "underline") : span(" ")] : [];
|
|
119
127
|
const numbered = [
|
|
120
128
|
span(" ".repeat(width - number.length + 1)),
|
|
121
129
|
span(number, "lineNumber"),
|
|
122
130
|
span(" │ "),
|
|
131
|
+
...marker,
|
|
123
132
|
...code,
|
|
124
133
|
];
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
const text = lines[index] ?? "";
|
|
135
|
+
// The line's own indentation, tabs and all, lines the underline up under its code.
|
|
136
|
+
return !spanning && inside(start + index) && text.trim() !== ""
|
|
137
|
+
? [
|
|
138
|
+
numbered,
|
|
139
|
+
[
|
|
140
|
+
gutter,
|
|
141
|
+
span("· "),
|
|
142
|
+
span(indentOf(text)),
|
|
143
|
+
span("─".repeat(text.trim().length), "underline"),
|
|
144
|
+
],
|
|
145
|
+
]
|
|
127
146
|
: [numbered];
|
|
128
147
|
}),
|
|
129
148
|
[gutter, span("╰────")],
|
|
130
149
|
];
|
|
131
150
|
};
|
|
132
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The warning on a finding whose file may not show enough to check its rule,
|
|
154
|
+
* with Jev's probability that it does not, and help: how to add what the
|
|
155
|
+
* code relies on as a note Jev reads, in the form the README asks for.
|
|
156
|
+
*/
|
|
157
|
+
const insufficient = (finding: Finding): ReadonlyArray<Line> =>
|
|
158
|
+
finding.insufficiency === undefined
|
|
159
|
+
? []
|
|
160
|
+
: [
|
|
161
|
+
[
|
|
162
|
+
span(" warning: ", "warning"),
|
|
163
|
+
span(
|
|
164
|
+
`this file may not show enough to check this rule (Jev: ${finding.insufficiency.toFixed(2)} that it does not)`,
|
|
165
|
+
),
|
|
166
|
+
],
|
|
167
|
+
...hanging(span(" help: ", "label"), [
|
|
168
|
+
[span("add what the code relies on outside this file as a note Jev reads:")],
|
|
169
|
+
highlighted("// @adhere <the fact>, and how you know it")[0] ?? [],
|
|
170
|
+
]),
|
|
171
|
+
];
|
|
172
|
+
|
|
133
173
|
/**
|
|
134
174
|
* The code to write as the hint. A rule with only code not to write shows
|
|
135
175
|
* that instead, labeled with its word: `never`, or `should not`.
|
|
@@ -143,15 +183,24 @@ const hint = (finding: Finding): ReadonlyArray<Line> => {
|
|
|
143
183
|
|
|
144
184
|
/**
|
|
145
185
|
* One diagnostic, in the frame `vp lint` prints on a terminal: a header with
|
|
146
|
-
* the rule in red, the
|
|
147
|
-
*
|
|
186
|
+
* the rule in red, the section of the file Jev points at with the lines it
|
|
187
|
+
* names in pink, a warning when the file may not show enough to decide, and
|
|
188
|
+
* the code to write as the hint.
|
|
148
189
|
*/
|
|
149
190
|
const frame = (finding: Finding, root: string | undefined): ReadonlyArray<Line> => [
|
|
150
191
|
header(finding),
|
|
151
192
|
...excerpt(finding, root),
|
|
193
|
+
...insufficient(finding),
|
|
152
194
|
...hint(finding),
|
|
153
195
|
];
|
|
154
196
|
|
|
197
|
+
/** The findings counted by level: `1 error`, or `2 errors and 1 warning` once there are warnings. */
|
|
198
|
+
const found = (findings: ReadonlyArray<Finding>): string => {
|
|
199
|
+
const warnings = findings.filter((finding) => finding.level === "warning").length;
|
|
200
|
+
const errors = counted(findings.length - warnings, "error", "errors");
|
|
201
|
+
return warnings === 0 ? errors : `${errors} and ${counted(warnings, "warning", "warnings")}`;
|
|
202
|
+
};
|
|
203
|
+
|
|
155
204
|
const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
156
205
|
const counts = [
|
|
157
206
|
counted(result.files, "file", "files"),
|
|
@@ -160,11 +209,9 @@ const summary = (result: AuditResult): ReadonlyArray<Line> => {
|
|
|
160
209
|
...(result.skipped > 0 ? [`${result.skipped} skipped`] : []),
|
|
161
210
|
...(result.waiting > 0 ? [`${result.waiting} waiting`] : []),
|
|
162
211
|
...(result.blocked.length > 0 ? [`${result.blocked.length} blocked`] : []),
|
|
212
|
+
...(result.suppressed > 0 ? [`${result.suppressed} suppressed`] : []),
|
|
163
213
|
];
|
|
164
|
-
return [
|
|
165
|
-
[span(`Found ${counted(result.findings.length, "error", "errors")}.`)],
|
|
166
|
-
[span(`${counts.join(", ")}.`)],
|
|
167
|
-
];
|
|
214
|
+
return [[span(`Found ${found(result.findings)}.`)], [span(`${counts.join(", ")}.`)]];
|
|
168
215
|
};
|
|
169
216
|
|
|
170
217
|
/**
|
|
@@ -214,7 +261,7 @@ export const render = (result: AuditResult, options: RenderOptions = {}): Readon
|
|
|
214
261
|
/** What a run was asked to keep to, for the plan to say. */
|
|
215
262
|
export interface RunLimits {
|
|
216
263
|
readonly filter?: ReadonlyArray<string>;
|
|
217
|
-
readonly rpm?: number;
|
|
264
|
+
readonly rpm?: number | undefined;
|
|
218
265
|
}
|
|
219
266
|
|
|
220
267
|
/** About how long `requests` take at `rpm`: seconds under a minute, minutes past it. */
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: SQL must pass values as parameters, through tagged-template interpolation, sql.in, sql.insert, or prepare().bind(), never by string concatenation or a template string handed to an unsafe or raw API.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
const user = yield* sql`SELECT * FROM users WHERE email = ${email}`;
|
|
7
|
-
const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = ?").bind(id).first());
|
|
8
|
-
```
|
|
9
|
-
|
|
10
|
-
```ts never
|
|
11
|
-
const user = yield* sql.unsafe(`SELECT * FROM users WHERE email = '${email}'`);
|
|
12
|
-
const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = " + id).first());
|
|
13
|
-
```
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: A secret, credential, connection string, token, signed URL, or private key must never be logged, returned in an HTTP response, or returned as a stack output; code must expose only names, presence, or non-secret metadata.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts must
|
|
6
|
-
if (url.pathname === "/secret") {
|
|
7
|
-
return yield* HttpServerResponse.json({ name: "API_TOKEN", set: Redacted.value(token).length > 0 });
|
|
8
|
-
}
|
|
9
|
-
return { serial: certificate.serial, expires: certificate.notAfter }; // stack output
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
```ts never
|
|
13
|
-
yield* Effect.log(`token=${Redacted.value(token)}`);
|
|
14
|
-
return { databaseUrl: connection.databaseUrl, tlsKey: certificate.privateKey }; // stack output
|
|
15
|
-
```
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: A call over the network, such as an HTTP request, a database query, or a third-party API, should carry a timeout and a retry schedule, and should not go out bare. A call through a client that already applies both, such as an HttpClient built with a timeout and retryTransient or an SDK with its own retries, has them. Calls through the platform FileSystem and Path services are local and are not in scope.
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
```ts should
|
|
6
|
-
const retryPolicy = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3)));
|
|
7
|
-
|
|
8
|
-
const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
|
|
9
|
-
Effect.timeout("2 seconds"),
|
|
10
|
-
Effect.retry(retryPolicy),
|
|
11
|
-
Effect.timeout("10 seconds"),
|
|
12
|
-
);
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
```ts should not
|
|
16
|
-
const response = yield* HttpClient.get(`https://api.example.com/users/${id}`);
|
|
17
|
-
```
|