@drkmttr/adhere 0.7.3 → 0.9.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 +177 -37
- package/package.json +12 -12
- package/presets/alchemy/apis/match-remote-errors-by-tag.md +15 -0
- package/presets/alchemy/apis/schemas-at-trust-boundaries.md +18 -0
- package/presets/alchemy/data/commit-migrations-then-deploy.md +15 -0
- package/presets/alchemy/data/hyperdrive-direct-origin.md +14 -0
- package/presets/alchemy/data/migrations-use-direct-url.md +11 -0
- package/presets/alchemy/data/never-disable-tls-verification.md +16 -0
- package/presets/alchemy/data/parameterized-sql.md +13 -0
- package/presets/alchemy/data/private-endpoint-at-runtime.md +12 -0
- package/presets/alchemy/data/single-migration-owner.md +13 -0
- package/presets/alchemy/durable/idempotent-event-handlers.md +15 -0
- package/presets/alchemy/durable/io-inside-steps.md +17 -0
- package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +15 -0
- package/presets/alchemy/durable/stable-step-names.md +11 -0
- package/presets/alchemy/durable/webhook-state-is-truth.md +14 -0
- package/presets/alchemy/providers/diff-guards-unresolved-props.md +16 -0
- package/presets/alchemy/providers/idempotent-delete.md +14 -0
- package/presets/alchemy/providers/lazy-layer-construction.md +14 -0
- package/presets/alchemy/providers/reconcile-from-observed-state.md +20 -0
- package/presets/alchemy/resources/anchor-paths-with-import-meta.md +14 -0
- package/presets/alchemy/resources/idempotent-actions-and-seeds.md +19 -0
- package/presets/alchemy/resources/previews-reference-shared-resources.md +15 -0
- package/presets/alchemy/resources/protect-irreplaceable-data.md +12 -0
- package/presets/alchemy/resources/remote-state-for-ci.md +22 -0
- package/presets/alchemy/resources/stable-logical-ids.md +13 -0
- package/presets/alchemy/resources/stage-unique-physical-names.md +15 -0
- package/presets/alchemy/runtime/bind-tag-not-implementation.md +27 -0
- package/presets/alchemy/runtime/deterministic-config-defaults.md +13 -0
- package/presets/alchemy/runtime/disposables-in-request-scope.md +27 -0
- package/presets/alchemy/runtime/lambda-no-post-response-work.md +19 -0
- package/presets/alchemy/runtime/no-per-request-work-at-construction.md +24 -0
- package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +31 -0
- package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +13 -0
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +15 -0
- package/presets/alchemy/secrets/none-in-browser-env.md +15 -0
- package/presets/alchemy/secrets/redacted-until-use.md +19 -0
- package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +17 -0
- package/presets/alchemy/security/authorize-public-endpoints.md +20 -0
- package/presets/alchemy/security/github-secret-not-variable.md +12 -0
- package/presets/alchemy/security/identity-from-verified-session.md +14 -0
- package/presets/alchemy/security/keep-auth-protections-on.md +19 -0
- package/presets/alchemy/security/least-privilege-bindings.md +13 -0
- package/presets/alchemy/security/least-privilege-ci-credentials.md +24 -0
- package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +15 -0
- package/presets/effect/basics/external-calls-are-resilient.md +3 -3
- package/presets/effect/config/tests-provide-values-directly.md +3 -3
- package/presets/effect/config/validate-with-schema.md +13 -2
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +42 -0
- package/presets/effect/services/operations-have-no-requirements.md +46 -0
- package/presets/effect/services/test-layers-are-in-memory.md +3 -3
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +3 -2
- package/src/cli.ts +42 -16
- package/src/config.ts +12 -2
- package/src/excerpt.ts +255 -0
- package/src/highlight.ts +82 -20
- package/src/init.ts +10 -4
- package/src/main.ts +35 -4
- package/src/mechanical.ts +89 -0
- package/src/presets.ts +45 -8
- package/src/rules.ts +2 -0
- package/src/services/AdhereConfig.ts +18 -20
- package/src/services/AuditCache.ts +272 -23
- package/src/services/Jev.http.ts +66 -12
- package/src/services/Jev.ts +66 -6
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +169 -26
- package/src/workflows/format.ts +35 -18
- package/presets/effect/basics/fn-for-named-effects.md +0 -19
- package/presets/effect/cli/handlers-delegate.md +0 -24
- package/presets/effect/data/decode-json-with-schema.md +0 -18
- package/presets/effect/data/records-are-schema-classes.md +0 -23
- package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
- package/presets/effect/platform/os-access-through-platform-services.md +0 -26
- package/presets/effect/services/methods-have-no-requirements.md +0 -20
- package/presets/effect/services/provide-at-entry.md +0 -23
- package/presets/effect/testing/test-random.md +0 -22
package/src/services/Jev.http.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RuleId } from "#config.ts";
|
|
1
2
|
import { AdhereConfig } from "#services/AdhereConfig.ts";
|
|
2
3
|
import { Credentials } from "#services/Credentials.ts";
|
|
3
4
|
import {
|
|
@@ -10,8 +11,10 @@ import {
|
|
|
10
11
|
JevBlocked,
|
|
11
12
|
JevOverflow,
|
|
12
13
|
JevUnavailable,
|
|
14
|
+
type Judged,
|
|
13
15
|
judgeBody,
|
|
14
16
|
type Lines,
|
|
17
|
+
linterKey,
|
|
15
18
|
locateBody,
|
|
16
19
|
namedPairs,
|
|
17
20
|
needsBlocks,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
pairProbability,
|
|
20
23
|
requestsOf,
|
|
21
24
|
type Rules,
|
|
25
|
+
tokensOf,
|
|
22
26
|
} from "#services/Jev.ts";
|
|
23
27
|
import { type Cause, Duration, Effect, Layer, Option, Record, Schedule, Schema } from "effect";
|
|
24
28
|
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http";
|
|
@@ -36,6 +40,14 @@ const ChoiceAnswers = Schema.Struct({
|
|
|
36
40
|
|
|
37
41
|
const refused = (message: string) => JevUnavailable.make({ message });
|
|
38
42
|
|
|
43
|
+
/** A failed attempt, for the log: the status and Ray ID when Jev's side answered, the cause otherwise. */
|
|
44
|
+
const attemptFailure = (
|
|
45
|
+
problem: HttpClientError.HttpClientError | RateLimiter.RateLimiterError | Cause.TimeoutError,
|
|
46
|
+
): string =>
|
|
47
|
+
HttpClientError.isHttpClientError(problem) && problem.reason._tag === "StatusCodeError"
|
|
48
|
+
? `HTTP ${problem.reason.response.status}, Ray ID ${problem.reason.response.headers["cf-ray"] ?? "none"}`
|
|
49
|
+
: problem.message;
|
|
50
|
+
|
|
39
51
|
/** Jev's error body, on one line and cut short, or nothing when it sent none. */
|
|
40
52
|
const detailOf = (body: string): string => {
|
|
41
53
|
const text = body.trim().replace(/\s+/g, " ");
|
|
@@ -68,7 +80,14 @@ const failed = (
|
|
|
68
80
|
}
|
|
69
81
|
const { response } = problem.reason;
|
|
70
82
|
return Effect.flatMap(
|
|
71
|
-
Effect.orElseSucceed(response.text, () => "")
|
|
83
|
+
Effect.orElseSucceed(response.text, () => "").pipe(
|
|
84
|
+
Effect.tap((body) =>
|
|
85
|
+
Effect.logDebug(
|
|
86
|
+
`Jev answered HTTP ${response.status}, ${response.headers["content-type"] ?? "no content type"}, ${body.length} characters, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
Effect.tap((body) => Effect.logTrace(`Jev's answer: ${body.slice(0, 4000)}`)),
|
|
90
|
+
),
|
|
72
91
|
(body): Effect.Effect<never, JevOverflow | JevBlocked | JevUnavailable> =>
|
|
73
92
|
response.status === 400 && isOverflow(body)
|
|
74
93
|
? Effect.fail(JevOverflow.make())
|
|
@@ -124,13 +143,18 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
124
143
|
);
|
|
125
144
|
const client = paced.pipe(
|
|
126
145
|
HttpClient.transformResponse(Effect.timeout("30 seconds")),
|
|
146
|
+
// Every failed attempt, including those retried, so a log shows what retrying hid.
|
|
147
|
+
HttpClient.transformResponse(
|
|
148
|
+
Effect.tapError((problem) => Effect.logDebug(`attempt failed: ${attemptFailure(problem)}`)),
|
|
149
|
+
),
|
|
127
150
|
HttpClient.retryTransient({
|
|
128
151
|
schedule: Schedule.exponential("500 millis"),
|
|
129
152
|
times: 3,
|
|
130
153
|
}),
|
|
131
154
|
);
|
|
132
155
|
|
|
133
|
-
|
|
156
|
+
/** One request. `kind` names the question for the log: judge, locate, blocks, and so on. */
|
|
157
|
+
const ask = <A>(kind: string, body: Body, Answers: Schema.Codec<A, unknown, never, never>) =>
|
|
134
158
|
Effect.gen(function* () {
|
|
135
159
|
// Read here, not in the layer: a run with nothing pending needs no key.
|
|
136
160
|
const apiKey = yield* credentials.apiKey.pipe(
|
|
@@ -140,9 +164,16 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
140
164
|
HttpClientRequest.post(SYSTEM_ONE),
|
|
141
165
|
body,
|
|
142
166
|
).pipe(Effect.orDie);
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
167
|
+
const questions = Object.keys(body.questions).length;
|
|
168
|
+
yield* Effect.logDebug(
|
|
169
|
+
`${kind}: sending ${questions} ${questions === 1 ? "question" : "questions"}, about ${tokensOf(body)} tokens`,
|
|
170
|
+
);
|
|
171
|
+
const [elapsed, response] = yield* Effect.timed(
|
|
172
|
+
client.execute(HttpClientRequest.bearerToken(request, apiKey)).pipe(Effect.catch(failed)),
|
|
173
|
+
);
|
|
174
|
+
yield* Effect.logDebug(
|
|
175
|
+
`${kind}: HTTP ${response.status} in ${Math.round(Duration.toMillis(elapsed))} ms, Ray ID ${response.headers["cf-ray"] ?? "none"}`,
|
|
176
|
+
);
|
|
146
177
|
return yield* HttpClientResponse.schemaBodyJson(Answers)(response).pipe(
|
|
147
178
|
Effect.mapError((problem) =>
|
|
148
179
|
refused(`System One response did not decode: ${problem.message}`),
|
|
@@ -152,6 +183,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
152
183
|
|
|
153
184
|
/** Every answer to a body, over as many requests as Jev's context needs. */
|
|
154
185
|
const answersTo = <A>(
|
|
186
|
+
kind: string,
|
|
155
187
|
body: Body,
|
|
156
188
|
Answers: Schema.Codec<
|
|
157
189
|
{ readonly answers: Readonly<Record<string, A>> },
|
|
@@ -161,7 +193,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
161
193
|
>,
|
|
162
194
|
) =>
|
|
163
195
|
Effect.map(
|
|
164
|
-
Effect.forEach(requestsOf(body), (request) => ask(request, Answers)),
|
|
196
|
+
Effect.forEach(requestsOf(body), (request) => ask(kind, request, Answers)),
|
|
165
197
|
(responses) =>
|
|
166
198
|
responses.reduce<Record<string, A>>(
|
|
167
199
|
(answers, response) => ({ ...answers, ...response.answers }),
|
|
@@ -169,9 +201,28 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
169
201
|
),
|
|
170
202
|
);
|
|
171
203
|
|
|
172
|
-
const judge = Effect.fn("Jev.judge")(function* (
|
|
173
|
-
|
|
174
|
-
|
|
204
|
+
const judge = Effect.fn("Jev.judge")(function* (
|
|
205
|
+
lines: Lines,
|
|
206
|
+
rules: Rules,
|
|
207
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
208
|
+
) {
|
|
209
|
+
const answers = yield* answersTo(
|
|
210
|
+
"judge",
|
|
211
|
+
judgeBody(config.model, lines, rules, sampled),
|
|
212
|
+
NoulAnswers,
|
|
213
|
+
);
|
|
214
|
+
/** Each rule's answer, read from the question it rode under. */
|
|
215
|
+
const answered = (ids: ReadonlyArray<RuleId>, keyOf: (id: RuleId) => string) =>
|
|
216
|
+
Record.fromEntries(
|
|
217
|
+
ids.flatMap((id) => {
|
|
218
|
+
const answer = answers[keyOf(id)];
|
|
219
|
+
return answer === undefined ? [] : [[id, answer.noul] as const];
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
return {
|
|
223
|
+
probabilities: answered(Object.keys(rules), (id) => id),
|
|
224
|
+
linter: answered(sampled, linterKey),
|
|
225
|
+
} satisfies Judged;
|
|
175
226
|
});
|
|
176
227
|
|
|
177
228
|
const chosen = (answers: Readonly<Record<string, { choice: string }>>) =>
|
|
@@ -179,10 +230,10 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
179
230
|
|
|
180
231
|
const locate = Effect.fn("Jev.locate")(function* (lines: Lines, rules: Rules) {
|
|
181
232
|
const blocks = needsBlocks(lines)
|
|
182
|
-
? chosen(yield* answersTo(blockBody(config.model, lines, rules), ChoiceAnswers))
|
|
233
|
+
? chosen(yield* answersTo("blocks", blockBody(config.model, lines, rules), ChoiceAnswers))
|
|
183
234
|
: undefined;
|
|
184
235
|
return chosen(
|
|
185
|
-
yield* answersTo(locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
|
|
236
|
+
yield* answersTo("locate", locateBody(config.model, lines, rules, blocks), ChoiceAnswers),
|
|
186
237
|
);
|
|
187
238
|
});
|
|
188
239
|
|
|
@@ -191,6 +242,7 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
191
242
|
partners: ReadonlyArray<ReadonlyArray<number>>,
|
|
192
243
|
) {
|
|
193
244
|
const answers = yield* answersTo(
|
|
245
|
+
"conflicts",
|
|
194
246
|
conflictBody(config.model, rules, partners),
|
|
195
247
|
ChoiceAnswers,
|
|
196
248
|
).pipe(refuseOverflow);
|
|
@@ -205,7 +257,9 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
205
257
|
pairs,
|
|
206
258
|
(pair) =>
|
|
207
259
|
Effect.map(
|
|
208
|
-
ask(contradictBody(config.model, rules, pair), NoulAnswers).pipe(
|
|
260
|
+
ask("contradicts", contradictBody(config.model, rules, pair), NoulAnswers).pipe(
|
|
261
|
+
refuseOverflow,
|
|
262
|
+
),
|
|
209
263
|
({ answers }) => pairProbability(answers, pair),
|
|
210
264
|
),
|
|
211
265
|
{ concurrency: 8 },
|
package/src/services/Jev.ts
CHANGED
|
@@ -24,13 +24,27 @@ export class JevBlocked extends Schema.TaggedError<JevBlocked>()("JevBlocked", {
|
|
|
24
24
|
ray: Schema.String,
|
|
25
25
|
}) {}
|
|
26
26
|
|
|
27
|
+
/** What judging a file answers: per rule, whether it breaks it, and the linter check's answers. */
|
|
28
|
+
export interface Judged {
|
|
29
|
+
/** Per rule, Jev's probability that the file breaks it. */
|
|
30
|
+
readonly probabilities: Readonly<Record<RuleId, number>>;
|
|
31
|
+
/** Per sampled rule, Jev's probability that a regular linter could have decided the file against it. */
|
|
32
|
+
readonly linter: Readonly<Record<RuleId, number>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export class Jev extends Context.Service<
|
|
28
36
|
Jev,
|
|
29
37
|
{
|
|
38
|
+
/**
|
|
39
|
+
* Each rule's probability that the file breaks it. A rule in `sampled`
|
|
40
|
+
* also carries the linter check's question, whose answer comes back
|
|
41
|
+
* under `linter`.
|
|
42
|
+
*/
|
|
30
43
|
readonly judge: (
|
|
31
44
|
lines: Lines,
|
|
32
45
|
rules: Rules,
|
|
33
|
-
|
|
46
|
+
sampled?: ReadonlyArray<RuleId>,
|
|
47
|
+
) => Effect.Effect<Judged, JevUnavailable | JevOverflow | JevBlocked>;
|
|
34
48
|
readonly locate: (
|
|
35
49
|
lines: Lines,
|
|
36
50
|
rules: Rules,
|
|
@@ -89,7 +103,7 @@ const encoder = new TextEncoder();
|
|
|
89
103
|
export const tokensOf = (value: unknown): number =>
|
|
90
104
|
Math.ceil(encoder.encode(JSON.stringify(value)).length / 3);
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
const snippetOf = (line: string): string => line.trim().slice(0, 120);
|
|
93
107
|
|
|
94
108
|
const isBlank = (line: string): boolean => line.trim().length === 0;
|
|
95
109
|
|
|
@@ -193,10 +207,53 @@ const lineCriteria = (lines: Lines, from: number, to: number): Record<string, st
|
|
|
193
207
|
return criteria;
|
|
194
208
|
};
|
|
195
209
|
|
|
196
|
-
|
|
210
|
+
/**
|
|
211
|
+
* The linter check's question: whether a regular linter could have decided
|
|
212
|
+
* `code` against the rule. It carries the judge question's fields, so Jev
|
|
213
|
+
* sees the rule as it does when judging, with the file in view.
|
|
214
|
+
*/
|
|
215
|
+
export const linterQuestion = (rule: Rule) => {
|
|
216
|
+
const { instructions } = judgeQuestion(rule);
|
|
217
|
+
return {
|
|
218
|
+
type: "noul" as const,
|
|
219
|
+
instructions: {
|
|
220
|
+
...instructions,
|
|
221
|
+
question:
|
|
222
|
+
"Should `rule` have been checked by a regular linter? Consider `code`: could a linter or type checker have decided exactly whether it follows `rule`, without judgment?",
|
|
223
|
+
},
|
|
224
|
+
criteria: {
|
|
225
|
+
true: "A regular linter or type checker could decide exactly whether `code` follows `rule`",
|
|
226
|
+
false:
|
|
227
|
+
"Deciding whether `code` follows `rule` takes judgment about what the code means or is for",
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** The key a rule's linter question rides under, beside its judge question. */
|
|
233
|
+
export const linterKey = (id: RuleId): string => `linter:${id}`;
|
|
234
|
+
|
|
235
|
+
/** The tokens a file's code leaves in Jev's context for any one question beside it, as `fits` counts them. */
|
|
236
|
+
export const questionRoom = (lines: Lines): number =>
|
|
237
|
+
QUESTION_CONTEXT - WRAPPER - tokensOf(stateOf(lines));
|
|
238
|
+
|
|
239
|
+
/** One question per rule, and the linter question beside each rule in `sampled`. */
|
|
240
|
+
export const judgeBody = (
|
|
241
|
+
model: string,
|
|
242
|
+
lines: Lines,
|
|
243
|
+
rules: Rules,
|
|
244
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
245
|
+
) => ({
|
|
197
246
|
model,
|
|
198
247
|
state: stateOf(lines),
|
|
199
|
-
questions:
|
|
248
|
+
questions: {
|
|
249
|
+
...Record.map(rules, (rule): ReturnType<typeof judgeQuestion> => judgeQuestion(rule)),
|
|
250
|
+
...Object.fromEntries(
|
|
251
|
+
sampled.flatMap((id) => {
|
|
252
|
+
const rule = rules[id];
|
|
253
|
+
return rule === undefined ? [] : [[linterKey(id), linterQuestion(rule)] as const];
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
},
|
|
200
257
|
});
|
|
201
258
|
|
|
202
259
|
export const locateBody = (
|
|
@@ -288,8 +345,11 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
|
288
345
|
};
|
|
289
346
|
|
|
290
347
|
/** The requests judging `rules` over a file takes: the judge body, split to fit, as `judge` sends it. */
|
|
291
|
-
export const judgeRequests = (
|
|
292
|
-
|
|
348
|
+
export const judgeRequests = (
|
|
349
|
+
lines: Lines,
|
|
350
|
+
rules: Rules,
|
|
351
|
+
sampled: ReadonlyArray<RuleId> = [],
|
|
352
|
+
): number => requestsOf(judgeBody("", lines, rules, sampled)).length;
|
|
293
353
|
|
|
294
354
|
/**
|
|
295
355
|
* The requests locating `rules` in a file takes, split to fit as `locate`
|
|
@@ -79,9 +79,16 @@ const scannable = (root: string, paths: ReadonlyArray<string>, self: string, kee
|
|
|
79
79
|
/** One root's paths, filtered to what the audit reads. */
|
|
80
80
|
const listRoot = Effect.fn("SourceWalker.listRoot")(
|
|
81
81
|
(fs: FileSystem.FileSystem, root: string, self: string, keep: Keep) =>
|
|
82
|
-
Effect.
|
|
83
|
-
scannable(root, paths, self, keep)
|
|
84
|
-
|
|
82
|
+
Effect.flatMap(fs.readDirectory(root, { recursive: true }), (paths) => {
|
|
83
|
+
const files = scannable(root, paths, self, keep);
|
|
84
|
+
// The paths listed against the files kept: node_modules shows as the difference.
|
|
85
|
+
return Effect.as(
|
|
86
|
+
Effect.logDebug(
|
|
87
|
+
`listed ${root}: ${paths.length} paths, ${files.length} source files to read`,
|
|
88
|
+
),
|
|
89
|
+
files,
|
|
90
|
+
);
|
|
91
|
+
}),
|
|
85
92
|
);
|
|
86
93
|
|
|
87
94
|
/** One scannable path, read into lines. */
|
|
@@ -89,7 +96,7 @@ const readFile = Effect.fn("SourceWalker.readFile")((fs: FileSystem.FileSystem,
|
|
|
89
96
|
Effect.map(fs.readFileString(path), (content): ScannedFile => ({
|
|
90
97
|
path,
|
|
91
98
|
lines: content.split("\n"),
|
|
92
|
-
})),
|
|
99
|
+
})).pipe(Effect.tap((file) => Effect.logTrace(`read ${path}: ${file.lines.length} lines`))),
|
|
93
100
|
);
|
|
94
101
|
|
|
95
102
|
/** Read a batch of paths; the inner loop hoisted out of `forEach`. */
|
package/src/wording.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type Example, examplesOf, type Rule } from "#config.ts";
|
|
2
|
+
import type { RuleEntry } from "#rules.ts";
|
|
3
|
+
|
|
4
|
+
type Word = Example["word"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A way a rule's wording differs from the README's rule writing tips, which
|
|
8
|
+
* the eval measured: the rule shows code of both kinds, and its description
|
|
9
|
+
* says the words its code is under.
|
|
10
|
+
*/
|
|
11
|
+
export type Straying =
|
|
12
|
+
/** The rule has code under `word`, and its description does not say it. */
|
|
13
|
+
| { readonly _tag: "Unsaid"; readonly word: Word }
|
|
14
|
+
/** The rule has code to write, and no example under `word` of code that breaks it. */
|
|
15
|
+
| { readonly _tag: "Unshown"; readonly word: Word }
|
|
16
|
+
/** The description says `word`, which belongs to the other kind of rule. */
|
|
17
|
+
| { readonly _tag: "Mixed"; readonly word: Word };
|
|
18
|
+
|
|
19
|
+
/** A rule whose wording differs from the tips, with each way it does. */
|
|
20
|
+
export interface Stray {
|
|
21
|
+
readonly entry: RuleEntry;
|
|
22
|
+
readonly straying: ReadonlyArray<Straying>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const TIPS_URL = "https://github.com/darkmatter/adhere#rule-writing-tips";
|
|
26
|
+
|
|
27
|
+
/** Whether a description says a word, whole and in any case: "Must" does, "mustard" does not. */
|
|
28
|
+
const says = (description: string, word: Word): boolean =>
|
|
29
|
+
new RegExp(`\\b${word.replace(" ", "\\s+")}\\b`, "i").test(description);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How one rule strays from the tips. A rule with only code that breaks it
|
|
33
|
+
* lacks no example: the README keeps that for a rule with no single correct
|
|
34
|
+
* form to show.
|
|
35
|
+
*/
|
|
36
|
+
export const strayingOf = (rule: Rule): ReadonlyArray<Straying> => {
|
|
37
|
+
const { good, bad } = examplesOf(rule);
|
|
38
|
+
const guideline = good?.word === "should" || bad?.word === "should not";
|
|
39
|
+
const shown = [good, bad].flatMap((example) => (example === undefined ? [] : [example.word]));
|
|
40
|
+
const unshown: ReadonlyArray<Word> =
|
|
41
|
+
good !== undefined && bad === undefined ? [guideline ? "should not" : "never"] : [];
|
|
42
|
+
// "should" also finds "should not", so a requirement is checked for the one word.
|
|
43
|
+
const others: ReadonlyArray<Word> = guideline ? ["must", "never"] : ["should"];
|
|
44
|
+
return [
|
|
45
|
+
...shown
|
|
46
|
+
.filter((word) => !says(rule.description, word))
|
|
47
|
+
.map((word): Straying => ({ _tag: "Unsaid", word })),
|
|
48
|
+
...unshown.map((word): Straying => ({ _tag: "Unshown", word })),
|
|
49
|
+
...others
|
|
50
|
+
.filter((word) => says(rule.description, word))
|
|
51
|
+
.map((word): Straying => ({ _tag: "Mixed", word })),
|
|
52
|
+
];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The rules that stray, in the order given. */
|
|
56
|
+
export const straysAmong = (entries: ReadonlyArray<RuleEntry>): ReadonlyArray<Stray> =>
|
|
57
|
+
entries.flatMap((entry) => {
|
|
58
|
+
const straying = strayingOf(entry.rule);
|
|
59
|
+
return straying.length === 0 ? [] : [{ entry, straying }];
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** One way a rule strays, as a sentence. */
|
|
63
|
+
const sentenceOf = (straying: Straying): string => {
|
|
64
|
+
switch (straying._tag) {
|
|
65
|
+
case "Unsaid":
|
|
66
|
+
return `The description does not say "${straying.word}", though the rule has a ${straying.word} example.`;
|
|
67
|
+
case "Unshown":
|
|
68
|
+
return `The rule has no ${straying.word} example. Rules with one example of each kind judge best.`;
|
|
69
|
+
case "Mixed":
|
|
70
|
+
return straying.word === "should"
|
|
71
|
+
? `The description says "should", a guideline's word, but the rule is a requirement.`
|
|
72
|
+
: `The description says "${straying.word}", a requirement's word, but the rule is a guideline.`;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const displayPath = (file: string, root: string): string =>
|
|
77
|
+
file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
|
|
78
|
+
|
|
79
|
+
/** The wording part of `adhere validate`'s report: each rule that strays, then how. */
|
|
80
|
+
export const formatStrays = (strays: ReadonlyArray<Stray>, root: string): ReadonlyArray<string> =>
|
|
81
|
+
strays.length === 0
|
|
82
|
+
? ["Every rule is worded as the rule writing tips recommend."]
|
|
83
|
+
: [
|
|
84
|
+
`${strays.length} ${strays.length === 1 ? "rule is" : "rules are"} not worded as the rule writing tips recommend:`,
|
|
85
|
+
...strays.flatMap(({ entry, straying }) => [
|
|
86
|
+
` ${entry.id}${entry.file === undefined ? "" : ` (${displayPath(entry.file, root)})`}`,
|
|
87
|
+
...straying.map((one) => ` ${sentenceOf(one)}`),
|
|
88
|
+
]),
|
|
89
|
+
`Rule writing tips: ${TIPS_URL}`,
|
|
90
|
+
];
|