@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/services/Jev.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Example, examplesOf, type Rule, type RuleId, type Rules } from "#config.ts";
|
|
2
|
+
import { type Range, sectionsOf } from "#excerpt.ts";
|
|
2
3
|
import { Context, type Effect, Record, Schema } from "effect";
|
|
3
4
|
|
|
4
5
|
export type { Rules };
|
|
@@ -24,12 +25,20 @@ export class JevBlocked extends Schema.TaggedError<JevBlocked>()("JevBlocked", {
|
|
|
24
25
|
ray: Schema.String,
|
|
25
26
|
}) {}
|
|
26
27
|
|
|
28
|
+
/** Jev's probability for each of a rule's matchers, in the order the rule lists them. */
|
|
29
|
+
export interface MatcherScores {
|
|
30
|
+
readonly appliesTo: ReadonlyArray<number>;
|
|
31
|
+
readonly excludeIf: ReadonlyArray<number>;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
/** What judging a file answers: per rule, whether it breaks it, and the linter check's answers. */
|
|
28
35
|
export interface Judged {
|
|
29
36
|
/** Per rule, Jev's probability that the file breaks it. */
|
|
30
37
|
readonly probabilities: Readonly<Record<RuleId, number>>;
|
|
31
38
|
/** Per sampled rule, Jev's probability that a regular linter could have decided the file against it. */
|
|
32
39
|
readonly linter: Readonly<Record<RuleId, number>>;
|
|
40
|
+
/** Per rule with `appliesTo` or `excludeIf`, the scores of its matchers. */
|
|
41
|
+
readonly matchers?: Readonly<Record<RuleId, MatcherScores>>;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export class Jev extends Context.Service<
|
|
@@ -45,10 +54,15 @@ export class Jev extends Context.Service<
|
|
|
45
54
|
rules: Rules,
|
|
46
55
|
sampled?: ReadonlyArray<RuleId>,
|
|
47
56
|
) => Effect.Effect<Judged, JevUnavailable | JevOverflow | JevBlocked>;
|
|
57
|
+
/**
|
|
58
|
+
* Per rule, the section of the file that most clearly breaks it, and
|
|
59
|
+
* whether the file shows enough to decide. A rule Jev answers either
|
|
60
|
+
* question for with nothing it offered is left out.
|
|
61
|
+
*/
|
|
48
62
|
readonly locate: (
|
|
49
63
|
lines: Lines,
|
|
50
64
|
rules: Rules,
|
|
51
|
-
) => Effect.Effect<Record<RuleId,
|
|
65
|
+
) => Effect.Effect<Record<RuleId, Located>, JevUnavailable | JevOverflow | JevBlocked>;
|
|
52
66
|
/**
|
|
53
67
|
* Per rule, the partner Jev names as impossible to follow in the same code,
|
|
54
68
|
* as `[rule, partner]` indexes into `rules`. A rule Jev names none for is
|
|
@@ -69,6 +83,15 @@ export class Jev extends Context.Service<
|
|
|
69
83
|
}
|
|
70
84
|
>()("@drkmttr/adhere/services/Jev") {}
|
|
71
85
|
|
|
86
|
+
/** Where a finding is, and how sure Jev is that the file shows enough to decide it. */
|
|
87
|
+
export interface Located {
|
|
88
|
+
readonly section: Range;
|
|
89
|
+
/** Jev's probability that everything judging the rule turns on is in the file. */
|
|
90
|
+
readonly sufficiency: number;
|
|
91
|
+
/** The lines in the section the code that breaks the rule starts and ends on, when Jev named them. */
|
|
92
|
+
readonly lines?: Range;
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
/** A rule compared with other rules rather than with a file. */
|
|
73
96
|
export interface ComparedRule {
|
|
74
97
|
readonly id: RuleId;
|
|
@@ -78,10 +101,8 @@ export interface ComparedRule {
|
|
|
78
101
|
/** Two rules, as indexes into the rules being compared. */
|
|
79
102
|
export type Pair = readonly [number, number];
|
|
80
103
|
|
|
81
|
-
/** A `choice` question accepts at most this many criteria. */
|
|
104
|
+
/** A `choice` question accepts at most this many criteria, so a file has at most this many sections. */
|
|
82
105
|
const CHOICE_LIMIT = 255;
|
|
83
|
-
const LINES_PER_BLOCK = 20;
|
|
84
|
-
const MAX_LINES = CHOICE_LIMIT * LINES_PER_BLOCK;
|
|
85
106
|
|
|
86
107
|
/**
|
|
87
108
|
* Jev 1.13's context, from its model card: the state with its longest
|
|
@@ -103,16 +124,19 @@ const encoder = new TextEncoder();
|
|
|
103
124
|
export const tokensOf = (value: unknown): number =>
|
|
104
125
|
Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
|
|
105
126
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
/** The file, numbered so a line question can name a line. The only thing the questions share. */
|
|
127
|
+
/**
|
|
128
|
+
* The file as Jev reads it: its sections, by `sectionsOf`, under their
|
|
129
|
+
* numbers from 1, so the locate question can name one. The only thing the
|
|
130
|
+
* questions share. On the eval it judged as well as the file with every line
|
|
131
|
+
* numbered, and located as well as a choice among the lines.
|
|
132
|
+
*/
|
|
114
133
|
const stateOf = (lines: Lines) => ({
|
|
115
|
-
code:
|
|
134
|
+
code: Object.fromEntries(
|
|
135
|
+
sectionsOf(lines).map((section, index) => [
|
|
136
|
+
String(index + 1),
|
|
137
|
+
lines.slice(section.first - 1, section.last).join("\n"),
|
|
138
|
+
]),
|
|
139
|
+
),
|
|
116
140
|
});
|
|
117
141
|
|
|
118
142
|
/** The field an example goes under in a question: its word, with `should not` as `should_not`. */
|
|
@@ -187,7 +211,7 @@ export const judgeQuestion = (rule: Rule) => {
|
|
|
187
211
|
};
|
|
188
212
|
};
|
|
189
213
|
|
|
190
|
-
/** What the offending
|
|
214
|
+
/** What the offending section does, for the locate question. */
|
|
191
215
|
const offense = (rule: Rule): string => {
|
|
192
216
|
const { good, bad } = examplesOf(rule);
|
|
193
217
|
const avoided = `\`${fieldOf(bad ?? { word: "never", code: "" })}\``;
|
|
@@ -198,15 +222,6 @@ const offense = (rule: Rule): string => {
|
|
|
198
222
|
: `diverges from \`${fieldOf(good)}\` or resembles ${avoided}`;
|
|
199
223
|
};
|
|
200
224
|
|
|
201
|
-
const lineCriteria = (lines: Lines, from: number, to: number): Record<string, string> => {
|
|
202
|
-
const criteria: Record<string, string> = {};
|
|
203
|
-
for (let n = from; n <= Math.min(to, lines.length); n++) {
|
|
204
|
-
const text = lines[n - 1] ?? "";
|
|
205
|
-
if (!isBlank(text)) criteria[String(n)] = snippetOf(text);
|
|
206
|
-
}
|
|
207
|
-
return criteria;
|
|
208
|
-
};
|
|
209
|
-
|
|
210
225
|
/**
|
|
211
226
|
* The linter check's question: whether a regular linter could have decided
|
|
212
227
|
* `code` against the rule. It carries the judge question's fields, so Jev
|
|
@@ -232,11 +247,56 @@ export const linterQuestion = (rule: Rule) => {
|
|
|
232
247
|
/** The key a rule's linter question rides under, beside its judge question. */
|
|
233
248
|
export const linterKey = (id: RuleId): string => `linter:${id}`;
|
|
234
249
|
|
|
250
|
+
type MatcherKind = keyof MatcherScores;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A matcher's question: whether the code that breaks the rule is as the
|
|
254
|
+
* matcher describes. It asks about that code rather than the file, so a file
|
|
255
|
+
* with a real violation beside code a matcher excludes keeps its finding: for
|
|
256
|
+
* `appliesTo`, whether any of that code is as described, and for `excludeIf`,
|
|
257
|
+
* whether all of it is. It carries the rule as the judge question does.
|
|
258
|
+
*/
|
|
259
|
+
export const matcherQuestion = (rule: Rule, kind: MatcherKind, matcher: string) => {
|
|
260
|
+
const { instructions } = judgeQuestion(rule);
|
|
261
|
+
const [quantity, yes, no] =
|
|
262
|
+
kind === "appliesTo" ? ["any", "Some", "None"] : ["all", "All", "Not all"];
|
|
263
|
+
return {
|
|
264
|
+
type: "noul" as const,
|
|
265
|
+
instructions: {
|
|
266
|
+
...instructions,
|
|
267
|
+
question: `Is ${quantity} of the code in \`code\` that breaks \`rule\` as \`matcher\` describes? Answer no if no code in \`code\` breaks \`rule\`.`,
|
|
268
|
+
matcher,
|
|
269
|
+
},
|
|
270
|
+
criteria: {
|
|
271
|
+
true: `${yes} of the code in \`code\` that breaks \`rule\` is as \`matcher\` describes`,
|
|
272
|
+
false: `${no} of the code in \`code\` that breaks \`rule\` is as \`matcher\` describes, or no code in \`code\` breaks \`rule\``,
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** The key a rule's matcher question rides under, beside its judge question. */
|
|
278
|
+
export const matcherKey = (kind: MatcherKind, id: RuleId, index: number): string =>
|
|
279
|
+
`${kind}:${id}:${index}`;
|
|
280
|
+
|
|
281
|
+
const MATCHER_KINDS: ReadonlyArray<MatcherKind> = ["appliesTo", "excludeIf"];
|
|
282
|
+
|
|
283
|
+
/** A rule's matcher questions under their keys, `appliesTo` first, each in the rule's order. */
|
|
284
|
+
export const matcherQuestions = (id: RuleId, rule: Rule) =>
|
|
285
|
+
MATCHER_KINDS.flatMap((kind) =>
|
|
286
|
+
(rule[kind] ?? []).map(
|
|
287
|
+
(matcher, index) =>
|
|
288
|
+
[matcherKey(kind, id, index), matcherQuestion(rule, kind, matcher)] as const,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
|
|
235
292
|
/** The tokens a file's code leaves in Jev's context for any one question beside it, as `fits` counts them. */
|
|
236
293
|
export const questionRoom = (lines: Lines): number =>
|
|
237
294
|
QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines));
|
|
238
295
|
|
|
239
|
-
/**
|
|
296
|
+
/**
|
|
297
|
+
* One question per rule, one per matcher of each rule, and the linter
|
|
298
|
+
* question beside each rule in `sampled`.
|
|
299
|
+
*/
|
|
240
300
|
export const judgeBody = (
|
|
241
301
|
model: string,
|
|
242
302
|
lines: Lines,
|
|
@@ -247,6 +307,9 @@ export const judgeBody = (
|
|
|
247
307
|
state: stateOf(lines),
|
|
248
308
|
questions: {
|
|
249
309
|
...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
|
|
310
|
+
...Object.fromEntries(
|
|
311
|
+
Object.entries(rules).flatMap(([id, rule]) => matcherQuestions(id, rule)),
|
|
312
|
+
),
|
|
250
313
|
...Object.fromEntries(
|
|
251
314
|
sampled.flatMap((id) => {
|
|
252
315
|
const rule = rules[id];
|
|
@@ -256,62 +319,123 @@ export const judgeBody = (
|
|
|
256
319
|
},
|
|
257
320
|
});
|
|
258
321
|
|
|
259
|
-
|
|
260
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Whether the file shows enough to decide the rule at all: a finding that
|
|
324
|
+
* turns on another file, a library, or a service is a coin flip, and the
|
|
325
|
+
* report says so. It asks the condition itself, beside the rule: on the
|
|
326
|
+
* eval, asking whether `code` held enough to answer the judge question, put
|
|
327
|
+
* in a field, ranked real findings worse, and read no easier. Of findings
|
|
328
|
+
* it scored below 0.6, 62% were false, where 22% of all were.
|
|
329
|
+
*/
|
|
330
|
+
export const sufficiencyQuestion = (rule: Rule) => ({
|
|
331
|
+
type: "noul" as const,
|
|
332
|
+
instructions: {
|
|
333
|
+
question:
|
|
334
|
+
"Can you tell whether `code` breaks `rule` from `code` alone, without knowing what other files, libraries, services, or configuration do?",
|
|
335
|
+
...ruleFields(rule),
|
|
336
|
+
},
|
|
337
|
+
criteria: {
|
|
338
|
+
true: "`code` shows everything needed to tell whether it breaks `rule`",
|
|
339
|
+
false:
|
|
340
|
+
"Whether `code` breaks `rule` depends on something `code` does not show, such as another file, a library, a service, or configuration",
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/** The key a rule's sufficiency question rides under, beside its locate question. */
|
|
345
|
+
export const sufficiencyKey = (id: RuleId): string => `sufficient:${id}`;
|
|
346
|
+
|
|
347
|
+
/** The key of the question where in section `section` (from 1) the code that breaks a rule starts or ends. */
|
|
348
|
+
export const edgeKey = (edge: "start" | "end", id: RuleId, section: number): string =>
|
|
349
|
+
`${edge}:${id}:${section}`;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Which line of one section the code that breaks the rule starts or ends
|
|
353
|
+
* on: a choice among the section's lines that hold code, each option the
|
|
354
|
+
* line's text, since the state carries no line numbers.
|
|
355
|
+
*/
|
|
356
|
+
const edgeQuestion = (
|
|
357
|
+
rule: Rule,
|
|
358
|
+
edge: "start" | "end",
|
|
359
|
+
key: string,
|
|
261
360
|
lines: Lines,
|
|
262
|
-
|
|
263
|
-
blocks?: Readonly<Record<RuleId, number>>,
|
|
361
|
+
numbers: ReadonlyArray<number>,
|
|
264
362
|
) => ({
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
...ruleFields(rule),
|
|
274
|
-
},
|
|
275
|
-
criteria:
|
|
276
|
-
block === undefined
|
|
277
|
-
? lineCriteria(lines, 1, lines.length)
|
|
278
|
-
: lineCriteria(lines, block * LINES_PER_BLOCK + 1, (block + 1) * LINES_PER_BLOCK),
|
|
279
|
-
};
|
|
280
|
-
}),
|
|
363
|
+
type: "choice" as const,
|
|
364
|
+
instructions: {
|
|
365
|
+
question: `Which line of \`code["${key}"]\` does the code that breaks \`rule\` ${edge} on?`,
|
|
366
|
+
...ruleFields(rule),
|
|
367
|
+
},
|
|
368
|
+
criteria: Object.fromEntries(
|
|
369
|
+
numbers.map((line) => [String(line), (lines[line - 1] ?? "").trim().slice(0, 120)]),
|
|
370
|
+
),
|
|
281
371
|
});
|
|
282
372
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Per rule, which section of `code` breaks it, one choice among the
|
|
375
|
+
* sections' numbers; for every section, the lines in it the code that breaks
|
|
376
|
+
* the rule starts and ends on, of which the chosen section's are kept; and
|
|
377
|
+
* the sufficiency question. The section options carry no text: each is a
|
|
378
|
+
* key of the state, and describing a section by its first line or by what
|
|
379
|
+
* it declares located no better on the eval. A file of one section has no
|
|
380
|
+
* section to choose. On the eval, asked together, the start and end lines
|
|
381
|
+
* held a violation for 94% of real findings, and 98% where the section was
|
|
382
|
+
* right, and were most often one line.
|
|
383
|
+
*/
|
|
384
|
+
export const locateBody = (model: string, lines: Lines, rules: Rules) => {
|
|
385
|
+
const state = stateOf(lines);
|
|
386
|
+
const criteria = Record.map(state.code, () => null);
|
|
387
|
+
const choosing = Object.keys(criteria).length > 1;
|
|
388
|
+
const withCode = sectionsOf(lines).map(({ first, last }) =>
|
|
389
|
+
Array.from({ length: last - first + 1 }, (_, index) => first + index).filter(
|
|
390
|
+
(line) => (lines[line - 1] ?? "").trim() !== "",
|
|
391
|
+
),
|
|
392
|
+
);
|
|
291
393
|
return {
|
|
292
394
|
model,
|
|
293
|
-
state
|
|
294
|
-
questions:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
395
|
+
state,
|
|
396
|
+
questions: Object.fromEntries(
|
|
397
|
+
Object.entries(rules).flatMap(([id, rule]) => [
|
|
398
|
+
...(choosing
|
|
399
|
+
? [
|
|
400
|
+
[
|
|
401
|
+
id,
|
|
402
|
+
{
|
|
403
|
+
type: "choice" as const,
|
|
404
|
+
instructions: {
|
|
405
|
+
question: `Which entry of \`code\` most clearly ${offense(rule)}?`,
|
|
406
|
+
...ruleFields(rule),
|
|
407
|
+
},
|
|
408
|
+
criteria,
|
|
409
|
+
},
|
|
410
|
+
] as const,
|
|
411
|
+
]
|
|
412
|
+
: []),
|
|
413
|
+
...withCode.flatMap((numbers, index) =>
|
|
414
|
+
numbers.length === 0
|
|
415
|
+
? []
|
|
416
|
+
: (["start", "end"] as const).map(
|
|
417
|
+
(edge) =>
|
|
418
|
+
[
|
|
419
|
+
edgeKey(edge, id, index + 1),
|
|
420
|
+
edgeQuestion(rule, edge, String(index + 1), lines, numbers),
|
|
421
|
+
] as const,
|
|
422
|
+
),
|
|
423
|
+
),
|
|
424
|
+
[sufficiencyKey(id), sufficiencyQuestion(rule)] as const,
|
|
425
|
+
]),
|
|
426
|
+
),
|
|
302
427
|
};
|
|
303
428
|
};
|
|
304
429
|
|
|
305
430
|
/**
|
|
306
|
-
* Whether Jev can take every request a file may need: its
|
|
307
|
-
*
|
|
431
|
+
* Whether Jev can take every request a file may need: its sections within
|
|
432
|
+
* one choice, and its code beside the longest question any rule asks.
|
|
308
433
|
*/
|
|
309
434
|
export const fits = (lines: Lines, rules: Rules): boolean => {
|
|
310
|
-
if (lines.length >
|
|
311
|
-
const located = needsBlocks(lines) ? blockBody("", lines, rules) : locateBody("", lines, rules);
|
|
435
|
+
if (sectionsOf(lines).length > CHOICE_LIMIT) return false;
|
|
312
436
|
const questions = [
|
|
313
437
|
...Object.values(judgeBody("", lines, rules).questions),
|
|
314
|
-
...Object.values(
|
|
438
|
+
...Object.values(locateBody("", lines, rules).questions),
|
|
315
439
|
];
|
|
316
440
|
const longest = Math.max(0, ...questions.map(tokensOf));
|
|
317
441
|
return tokensOf(stateOf(lines)) + longest <= QUESTION_CONTEXT - WRAPPER;
|
|
@@ -368,14 +492,8 @@ export const judgeRequests = (
|
|
|
368
492
|
sampled: ReadonlyArray<RuleId> = [],
|
|
369
493
|
): number => requestsOf(judgeBody("", lines, rules, sampled)).length;
|
|
370
494
|
|
|
371
|
-
/**
|
|
372
|
-
* The requests locating `rules` in a file takes, split to fit as `locate`
|
|
373
|
-
* sends them: a block choice first for a long file, then a line choice. For a
|
|
374
|
-
* long file the line choice is counted before a block is chosen, so it can
|
|
375
|
-
* run high.
|
|
376
|
-
*/
|
|
495
|
+
/** The requests locating `rules` in a file takes, split to fit as `locate` sends them. */
|
|
377
496
|
export const locateRequests = (lines: Lines, rules: Rules): number =>
|
|
378
|
-
(needsBlocks(lines) ? requestsOf(blockBody("", lines, rules)).length : 0) +
|
|
379
497
|
requestsOf(locateBody("", lines, rules)).length;
|
|
380
498
|
|
|
381
499
|
/** A choice keeps one criterion for "none", which leaves this many for partners. */
|
|
@@ -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,42 @@ 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
|
+
* A fixture or test helper named as one: `fixtures.ts`, `GitHubHttpFixtures.ts`,
|
|
56
|
+
* `ledger-copy.fixture.ts`, `linear-test-helpers.ts`, `GitHubHttpTestUtils.ts`.
|
|
57
|
+
* "test" starts a word, so `latest-utils.ts` is not one.
|
|
58
|
+
*/
|
|
59
|
+
const TEST_SUPPORT_NAME =
|
|
60
|
+
/(?:[fF]ixtures?|(?:(?:^|[-_.])[tT]est[-_]?|[a-z]Test)(?:[hH]elpers?|[uU]tils?))\.[cm]?tsx?$/;
|
|
61
|
+
|
|
62
|
+
/** A TypeScript source file: `.ts`, `.tsx`, `.mts`, or `.cts`. */
|
|
63
|
+
const SOURCE = /\.(?:tsx?|mts|cts)$/;
|
|
64
|
+
/** A declaration file: `.d.ts`, `.d.mts`, or `.d.cts`. */
|
|
65
|
+
const DECLARATION = /\.d\.[cm]?ts$/;
|
|
66
|
+
/** A test by its name: `a.test.ts`, `Button.spec.tsx`. */
|
|
67
|
+
const TEST_NAME = /\.(?:test|spec)\.[cm]?tsx?$/;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether a path, relative to the working directory, is a test: a `.test` or
|
|
71
|
+
* `.spec` file, such as `a.test.ts` or `Button.spec.tsx`, a fixture or test
|
|
72
|
+
* helper named as one, or any file under a test directory such as `test/`.
|
|
73
|
+
*/
|
|
74
|
+
export const isTestFile = (relative: string): boolean => {
|
|
75
|
+
const segments = relative.split(/[\\/]/);
|
|
76
|
+
const name = segments.at(-1) ?? "";
|
|
77
|
+
return (
|
|
78
|
+
TEST_NAME.test(name) ||
|
|
79
|
+
TEST_SUPPORT_NAME.test(name) ||
|
|
80
|
+
segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment))
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
53
84
|
const isInside = (file: string, root: string): boolean =>
|
|
54
85
|
file === root || file.startsWith(`${root}/`);
|
|
55
86
|
|
|
56
|
-
const isScannable = (file: string, self: string): boolean =>
|
|
57
|
-
|
|
58
|
-
!
|
|
59
|
-
!file.endsWith(".test.ts") &&
|
|
87
|
+
export const isScannable = (file: string, self: string): boolean =>
|
|
88
|
+
SOURCE.test(file) &&
|
|
89
|
+
!DECLARATION.test(file) &&
|
|
60
90
|
!file.endsWith("/adhere.config.ts") &&
|
|
61
91
|
!file.endsWith("/.adhere.config.ts") &&
|
|
62
92
|
// The audit's own detector patterns are data, not violations of themselves.
|
|
@@ -169,8 +199,10 @@ const scanDirs = Effect.fn("SourceWalker.scanDirs")(function* (
|
|
|
169
199
|
|
|
170
200
|
/**
|
|
171
201
|
* The real walker: the roots walked a directory at a time, skipped trees
|
|
172
|
-
* left unread, each file read and split into lines.
|
|
173
|
-
*
|
|
202
|
+
* left unread, each file read and split into lines. The run's filter and the
|
|
203
|
+
* config's `exclude` leave out what they match. Test files are read, and
|
|
204
|
+
* marked, even when no rule judges them, so a prune keeps what other runs
|
|
205
|
+
* know of them. Tests substitute their own tree.
|
|
174
206
|
* The scan root is the current working directory, so the audit reads the
|
|
175
207
|
* repository it is run in.
|
|
176
208
|
*/
|
|
@@ -179,9 +211,11 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
179
211
|
Effect.gen(function* () {
|
|
180
212
|
const fs = yield* FileSystem.FileSystem;
|
|
181
213
|
const path = yield* Path.Path;
|
|
214
|
+
const config = yield* AdhereConfig;
|
|
182
215
|
// No segments: Path resolves against the working directory.
|
|
183
216
|
const root = path.resolve();
|
|
184
217
|
const self = yield* path.fromFileUrl(new URL("../../", import.meta.url)).pipe(Effect.orDie);
|
|
218
|
+
const patterns = [...filter, ...(config.exclude ?? []).map((glob) => `!${glob}`)];
|
|
185
219
|
const walk = Effect.fn("SourceWalker.files")(function* () {
|
|
186
220
|
const dirs = yield* scanDirs(fs, root).pipe(Effect.mapError(refused));
|
|
187
221
|
const trees = yield* clearingStatus(
|
|
@@ -193,12 +227,16 @@ export const SourceWalkerLive = (filter: ReadonlyArray<string> = []) =>
|
|
|
193
227
|
dir === "." ? "the working directory" : `${dir}/`,
|
|
194
228
|
self,
|
|
195
229
|
(relative) =>
|
|
196
|
-
//
|
|
197
|
-
passesFilter(dir === "." ? relative : `${dir}/${relative}`,
|
|
230
|
+
// Patterns are relative to the working directory, not the tree read.
|
|
231
|
+
passesFilter(dir === "." ? relative : `${dir}/${relative}`, patterns),
|
|
198
232
|
).pipe(Effect.mapError(refused)),
|
|
199
233
|
),
|
|
200
234
|
);
|
|
201
|
-
return trees
|
|
235
|
+
return trees
|
|
236
|
+
.flat()
|
|
237
|
+
.map((file) =>
|
|
238
|
+
isTestFile(path.relative(root, file.path)) ? { ...file, test: true } : file,
|
|
239
|
+
);
|
|
202
240
|
});
|
|
203
241
|
return SourceWalker.of({ files: walk() });
|
|
204
242
|
}),
|
package/src/suppress.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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,
|
|
9
|
+
* or the line alone.
|
|
10
|
+
* `// adhere-ignore-file <rules> -- <reason>` suppresses them in the whole
|
|
11
|
+
* file, which they then do not judge. Rules are named as a report names
|
|
12
|
+
* them, separated by commas.
|
|
13
|
+
*/
|
|
14
|
+
export interface Suppressions {
|
|
15
|
+
/** Rules suppressed in the whole file. */
|
|
16
|
+
readonly file: ReadonlySet<string>;
|
|
17
|
+
/** Rules suppressed over the lines of one statement. */
|
|
18
|
+
readonly statements: ReadonlyArray<Range & { readonly rules: ReadonlySet<string> }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const NO_SUPPRESSIONS: Suppressions = { file: new Set(), statements: [] };
|
|
22
|
+
|
|
23
|
+
/** A directive's comment: whether it covers the file, then what follows, up to a block comment's end. */
|
|
24
|
+
const DIRECTIVE = /^(?:\/\/|\/\*)\s*adhere-ignore(-file)?\s+(.*?)\s*(?:\*\/)?$/s;
|
|
25
|
+
|
|
26
|
+
/** An `adhere-ignore` comment: where it is, and the rules it names. */
|
|
27
|
+
interface Directive {
|
|
28
|
+
readonly line: number;
|
|
29
|
+
/** At the end of a line of code, rather than on a line of its own. */
|
|
30
|
+
readonly trailing: boolean;
|
|
31
|
+
/** `adhere-ignore-file`, for the whole file. */
|
|
32
|
+
readonly file: boolean;
|
|
33
|
+
readonly rules: ReadonlySet<string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The rules a directive names, before any ` -- ` and its reason. None if it is not a directive. */
|
|
37
|
+
const directiveOf = (comment: string): Pick<Directive, "file" | "rules"> | undefined => {
|
|
38
|
+
const match = DIRECTIVE.exec(comment);
|
|
39
|
+
const named = (match?.[2] ?? "").split(/(?:^|\s)--(?:\s|$)/)[0] ?? "";
|
|
40
|
+
const rules = named.split(/[\s,]+/).filter((rule) => rule.length > 0);
|
|
41
|
+
return match === null || rules.length === 0
|
|
42
|
+
? undefined
|
|
43
|
+
: { file: match[1] !== undefined, rules: new Set(rules) };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Whether a line holds nothing a statement could start with: blank, or a comment. */
|
|
47
|
+
const isQuiet = (line: string): boolean => {
|
|
48
|
+
const text = line.trim();
|
|
49
|
+
return (
|
|
50
|
+
text.length === 0 || text.startsWith("//") || text.startsWith("/*") || text.startsWith("*")
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The statement that starts on the first line of code after `line`, or that line alone. */
|
|
55
|
+
const statementAfter = (lines: ReadonlyArray<string>, line: number): Range | undefined => {
|
|
56
|
+
for (let next = line + 1; next <= lines.length; next++) {
|
|
57
|
+
if (isQuiet(lines[next - 1] ?? "")) continue;
|
|
58
|
+
return statementFrom(lines, next) ?? { first: next, last: next };
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A file's lines with its `adhere-ignore` comments blanked, each line keeping
|
|
65
|
+
* its number, so Jev never reads them, and what they suppress.
|
|
66
|
+
*/
|
|
67
|
+
export const suppressionsOf = (
|
|
68
|
+
lines: ReadonlyArray<string>,
|
|
69
|
+
): { readonly lines: ReadonlyArray<string>; readonly suppressions: Suppressions } => {
|
|
70
|
+
const code = lines.join("\n");
|
|
71
|
+
if (!code.includes("adhere-ignore")) return { lines, suppressions: NO_SUPPRESSIONS };
|
|
72
|
+
const tokens = scan(code);
|
|
73
|
+
const found: Array<Directive> = [];
|
|
74
|
+
const kept: Array<string> = [];
|
|
75
|
+
let line = 1;
|
|
76
|
+
// Whether code comes before the current token on its line.
|
|
77
|
+
let afterCode = false;
|
|
78
|
+
for (const token of tokens) {
|
|
79
|
+
const directive = token.kind === "comment" ? directiveOf(token.text) : undefined;
|
|
80
|
+
if (directive !== undefined) found.push({ line, trailing: afterCode, ...directive });
|
|
81
|
+
kept.push(directive === undefined ? token.text : token.text.replace(/[^\n]/g, ""));
|
|
82
|
+
const breaks = token.text.split("\n").length - 1;
|
|
83
|
+
const tail = token.text.slice(token.text.lastIndexOf("\n") + 1);
|
|
84
|
+
if (breaks > 0) afterCode = false;
|
|
85
|
+
if (token.kind !== "comment" && tail.trim().length > 0) afterCode = true;
|
|
86
|
+
line += breaks;
|
|
87
|
+
}
|
|
88
|
+
// What the highlighter could not read, if anything, stays as it was.
|
|
89
|
+
const read = tokens.reduce((length, token) => length + token.text.length, 0);
|
|
90
|
+
const cleaned = (kept.join("") + code.slice(read))
|
|
91
|
+
.split("\n")
|
|
92
|
+
.map((text, index) => (text === lines[index] ? text : text.trimEnd()));
|
|
93
|
+
const statements = found.flatMap(({ line: at, trailing, file, rules }) => {
|
|
94
|
+
if (file) return [];
|
|
95
|
+
const range = trailing
|
|
96
|
+
? (statementFrom(cleaned, at) ?? { first: at, last: at })
|
|
97
|
+
: statementAfter(cleaned, at);
|
|
98
|
+
return range === undefined ? [] : [{ ...range, rules }];
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
lines: cleaned,
|
|
102
|
+
suppressions: {
|
|
103
|
+
file: new Set(found.filter(({ file }) => file).flatMap(({ rules }) => [...rules])),
|
|
104
|
+
statements,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether a file's comments suppress `rule`, named as a report names it, at
|
|
111
|
+
* `place`, the lines Jev points at or, without them, their section: in the
|
|
112
|
+
* whole file, or in a statement that overlaps it.
|
|
113
|
+
*/
|
|
114
|
+
export const suppresses = (suppressions: Suppressions, rule: string, place: Range): boolean =>
|
|
115
|
+
suppressions.file.has(rule) ||
|
|
116
|
+
suppressions.statements.some(
|
|
117
|
+
(statement) =>
|
|
118
|
+
statement.rules.has(rule) && statement.first <= place.last && place.first <= statement.last,
|
|
119
|
+
);
|
package/src/version.ts
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
1
|
import packageJson from "../package.json";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `git describe --tags --dirty --always` where `bun run build` compiled
|
|
5
|
+
* this, which it defines: the release it follows, the commits since, and
|
|
6
|
+
* whether the tree had changes, or the commit alone in a clone without tags.
|
|
7
|
+
* A release build defines nothing.
|
|
8
|
+
*/
|
|
9
|
+
declare const ADHERE_BUILD: string;
|
|
10
|
+
|
|
11
|
+
/** Read off `globalThis`, as in `presets.ts`, for Vitest on Node and for Bun's types left out. */
|
|
12
|
+
const isStandaloneExecutable =
|
|
13
|
+
(globalThis as { readonly Bun?: { readonly isStandaloneExecutable?: boolean } }).Bun
|
|
14
|
+
?.isStandaloneExecutable === true;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What `--version` prints after `adhere v`: a local build's `git describe`,
|
|
18
|
+
* such as `0.10.0-16-g9a41328`, so it is not taken for the release it
|
|
19
|
+
* follows; a release build's version; and from source, the version the
|
|
20
|
+
* checkout says, marked as such.
|
|
21
|
+
*/
|
|
22
|
+
export const version =
|
|
23
|
+
typeof ADHERE_BUILD === "string" && ADHERE_BUILD !== ""
|
|
24
|
+
? ADHERE_BUILD.replace(/^v/, "")
|
|
25
|
+
: isStandaloneExecutable
|
|
26
|
+
? packageJson.version
|
|
27
|
+
: `${packageJson.version} (source)`;
|