@drkmttr/adhere 0.7.1 → 0.8.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/LICENSE +21 -0
- package/README.md +156 -32
- package/package.json +13 -12
- package/presets/effect/basics/external-calls-are-resilient.md +1 -1
- package/presets/effect/testing/test-clock-for-time.md +1 -1
- package/skills/adhere/SKILL.md +3 -2
- package/src/cli.ts +39 -15
- 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 +1 -2
- package/src/rules.ts +2 -0
- package/src/services/AdhereConfig.ts +13 -17
- package/src/services/AuditCache.ts +272 -23
- package/src/services/Jev.http.ts +92 -18
- package/src/services/Jev.ts +77 -7
- package/src/services/SourceWalker.ts +11 -4
- package/src/wording.ts +90 -0
- package/src/workflows/audit.ts +210 -30
- package/src/workflows/format.ts +77 -21
- package/presets/effect/basics/fn-for-named-effects.md +0 -19
- package/presets/effect/data/decode-json-with-schema.md +0 -18
- 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/excerpt.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { scan } from "#highlight.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The lines a finding shows: the statement its line is in, such as the
|
|
5
|
+
* function around it, and a few lines on either side of the line.
|
|
6
|
+
*/
|
|
7
|
+
export interface Excerpt {
|
|
8
|
+
/** The number of the first line, counting from 1. */
|
|
9
|
+
readonly start: number;
|
|
10
|
+
readonly lines: ReadonlyArray<string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** How far an excerpt reaches on either side of the finding's line, in whole statements. */
|
|
14
|
+
const AROUND = 3;
|
|
15
|
+
/** The most lines a statement can have and still be shown whole. */
|
|
16
|
+
const WHOLE = 30;
|
|
17
|
+
|
|
18
|
+
/** Lines of the code, first to last, counting from 1. */
|
|
19
|
+
interface Range {
|
|
20
|
+
readonly first: number;
|
|
21
|
+
readonly last: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const holds = (range: Range, line: number): boolean => range.first <= line && line <= range.last;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A token that shapes the code: a bracket, a separator, an operator, or a
|
|
28
|
+
* value, such as a name, a keyword, or a literal. Only a template literal
|
|
29
|
+
* ends on a later line than the one it starts on.
|
|
30
|
+
*/
|
|
31
|
+
interface Piece {
|
|
32
|
+
readonly text: string;
|
|
33
|
+
readonly role: "open" | "close" | "separator" | "operator" | "value";
|
|
34
|
+
readonly line: number;
|
|
35
|
+
readonly end: number;
|
|
36
|
+
/** Whether whitespace or a comment comes right before it. */
|
|
37
|
+
readonly spaced: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ROLES: Readonly<Record<string, Piece["role"]>> = {
|
|
41
|
+
"(": "open",
|
|
42
|
+
"[": "open",
|
|
43
|
+
"{": "open",
|
|
44
|
+
")": "close",
|
|
45
|
+
"]": "close",
|
|
46
|
+
"}": "close",
|
|
47
|
+
";": "separator",
|
|
48
|
+
",": "separator",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The code as pieces, without whitespace and comments, and the comments and
|
|
53
|
+
* literals that span lines: a line that starts inside one does not read as
|
|
54
|
+
* code on its own.
|
|
55
|
+
*/
|
|
56
|
+
const piecesOf = (code: string) => {
|
|
57
|
+
const pieces: Array<Piece> = [];
|
|
58
|
+
const spans: Array<Range> = [];
|
|
59
|
+
let line = 1;
|
|
60
|
+
let spaced = true;
|
|
61
|
+
for (const { text, kind } of scan(code)) {
|
|
62
|
+
const end = line + text.split("\n").length - 1;
|
|
63
|
+
if (kind !== undefined && end > line) spans.push({ first: line, last: end });
|
|
64
|
+
if (kind === "comment" || text.trim().length === 0) {
|
|
65
|
+
spaced = true;
|
|
66
|
+
} else if (kind !== undefined || /^[\w$]/.test(text)) {
|
|
67
|
+
pieces.push({ text, role: "value", line, end, spaced });
|
|
68
|
+
spaced = false;
|
|
69
|
+
} else {
|
|
70
|
+
// A run of punctuation, like `});` or `=>{`: each bracket and separator is a piece.
|
|
71
|
+
for (const part of text.match(/[()[\]{};,]|[^()[\]{};,]+/g) ?? []) {
|
|
72
|
+
pieces.push({ text: part, role: ROLES[part] ?? "operator", line, end, spaced });
|
|
73
|
+
spaced = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
line = end;
|
|
77
|
+
}
|
|
78
|
+
return { pieces, spans };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Where each group closes: the index of the piece that opens it, mapped to
|
|
83
|
+
* the index of the one that closes it. Type arguments broken over lines, from
|
|
84
|
+
* a `Type<` that ends a line to a `>` that starts one, are a group too, so
|
|
85
|
+
* their commas do not end a statement; a `<` that no such `>` closes was a
|
|
86
|
+
* comparison. A bracket left open runs to the end.
|
|
87
|
+
*/
|
|
88
|
+
const closesOf = (pieces: ReadonlyArray<Piece>): ReadonlyMap<number, number> => {
|
|
89
|
+
const closes = new Map<number, number>();
|
|
90
|
+
const opened: Array<number> = [];
|
|
91
|
+
const isAngle = (index: number | undefined): index is number =>
|
|
92
|
+
index !== undefined && pieces[index]?.text === "<";
|
|
93
|
+
pieces.forEach((piece, index) => {
|
|
94
|
+
const before = pieces[index - 1];
|
|
95
|
+
const after = pieces[index + 1];
|
|
96
|
+
const top = opened.at(-1);
|
|
97
|
+
if (piece.role === "open") {
|
|
98
|
+
opened.push(index);
|
|
99
|
+
} else if (piece.text === "<") {
|
|
100
|
+
const endsLine = after === undefined || after.line > piece.line;
|
|
101
|
+
if (endsLine && !piece.spaced && before?.role === "value") opened.push(index);
|
|
102
|
+
} else if (piece.text === ">" && isAngle(top) && (before?.end ?? 0) < piece.line) {
|
|
103
|
+
opened.pop();
|
|
104
|
+
closes.set(top, index);
|
|
105
|
+
} else if (piece.role === "close" || piece.text === ";") {
|
|
106
|
+
while (isAngle(opened.at(-1))) opened.pop();
|
|
107
|
+
const opener = piece.role === "close" ? opened.pop() : undefined;
|
|
108
|
+
if (opener !== undefined) closes.set(opener, index);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
for (const opener of opened) {
|
|
112
|
+
if (!isAngle(opener)) closes.set(opener, pieces.length - 1);
|
|
113
|
+
}
|
|
114
|
+
return closes;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Operators that leave an expression open at the end of a line: `=`, `=>`, `&&`, `.`. */
|
|
118
|
+
const LEAVES_OPEN = /[-+*/%&|^<>=?:.]$/;
|
|
119
|
+
/** Operators that carry the expression above on at the start of a line: `.pipe(`, `? a`, `| B`. */
|
|
120
|
+
const CARRIES_ON = /^[-+*/%&|^>=?:.]/;
|
|
121
|
+
/** Words that need more after them at the end of a line. */
|
|
122
|
+
const OPEN_WORDS = new Set(["as", "await", "extends", "implements", "keyof", "new", "satisfies"]);
|
|
123
|
+
/** Words that go on with the statement above at the start of a line. */
|
|
124
|
+
const ON_WORDS = new Set(["as", "catch", "else", "extends", "finally", "implements", "satisfies"]);
|
|
125
|
+
|
|
126
|
+
const isStep = (text: string): boolean => text === "++" || text === "--";
|
|
127
|
+
|
|
128
|
+
const leavesOpen = (piece: Piece): boolean =>
|
|
129
|
+
piece.role === "operator"
|
|
130
|
+
? LEAVES_OPEN.test(piece.text) && !isStep(piece.text)
|
|
131
|
+
: piece.role === "value" && OPEN_WORDS.has(piece.text);
|
|
132
|
+
|
|
133
|
+
const carriesOn = (piece: Piece): boolean =>
|
|
134
|
+
piece.role === "operator"
|
|
135
|
+
? CARRIES_ON.test(piece.text) && !isStep(piece.text)
|
|
136
|
+
: piece.role === "value" && ON_WORDS.has(piece.text);
|
|
137
|
+
|
|
138
|
+
/** A statement's lines, and the groups directly inside it, as the pieces that open and close each. */
|
|
139
|
+
interface Statement extends Range {
|
|
140
|
+
readonly groups: ReadonlyArray<readonly [number, number]>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The statements in the pieces from `from` up to `to`, each group inside one
|
|
145
|
+
* taken whole. A statement ends at a `;` or `,`, or at a line break that
|
|
146
|
+
* neither side carries across: about where JavaScript inserts a semicolon.
|
|
147
|
+
*/
|
|
148
|
+
const statementsIn = (
|
|
149
|
+
pieces: ReadonlyArray<Piece>,
|
|
150
|
+
closes: ReadonlyMap<number, number>,
|
|
151
|
+
from: number,
|
|
152
|
+
to: number,
|
|
153
|
+
): ReadonlyArray<Statement> => {
|
|
154
|
+
const statements: Array<Statement> = [];
|
|
155
|
+
let current:
|
|
156
|
+
| { first: number; last: number; groups: Array<readonly [number, number]> }
|
|
157
|
+
| undefined;
|
|
158
|
+
let open = false;
|
|
159
|
+
let index = from;
|
|
160
|
+
while (index < to) {
|
|
161
|
+
const piece = pieces[index];
|
|
162
|
+
if (piece === undefined) break;
|
|
163
|
+
if (current !== undefined && piece.line > current.last && !open && !carriesOn(piece)) {
|
|
164
|
+
statements.push(current);
|
|
165
|
+
current = undefined;
|
|
166
|
+
}
|
|
167
|
+
const close = Math.min(closes.get(index) ?? index, to - 1);
|
|
168
|
+
const end = pieces[close]?.end ?? piece.end;
|
|
169
|
+
current ??= { first: piece.line, last: end, groups: [] };
|
|
170
|
+
current.last = end;
|
|
171
|
+
if (close > index) current.groups.push([index, close]);
|
|
172
|
+
// A group's closing bracket leaves nothing open.
|
|
173
|
+
open = close === index && leavesOpen(piece);
|
|
174
|
+
if (piece.role === "separator") {
|
|
175
|
+
statements.push(current);
|
|
176
|
+
current = undefined;
|
|
177
|
+
}
|
|
178
|
+
index = close + 1;
|
|
179
|
+
}
|
|
180
|
+
if (current !== undefined) statements.push(current);
|
|
181
|
+
return statements;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The statements `line` is in, outermost first: the one at the top level,
|
|
186
|
+
* then the one around `line` inside the group of that statement it is in,
|
|
187
|
+
* and so on inward. A line where two statements meet is in both, together.
|
|
188
|
+
*/
|
|
189
|
+
const statementsAround = (
|
|
190
|
+
pieces: ReadonlyArray<Piece>,
|
|
191
|
+
closes: ReadonlyMap<number, number>,
|
|
192
|
+
line: number,
|
|
193
|
+
from = 0,
|
|
194
|
+
to = pieces.length,
|
|
195
|
+
): ReadonlyArray<Range> => {
|
|
196
|
+
const around = statementsIn(pieces, closes, from, to).filter((statement) =>
|
|
197
|
+
holds(statement, line),
|
|
198
|
+
);
|
|
199
|
+
const [outer, inner] = [around[0], around.at(-1)];
|
|
200
|
+
if (outer === undefined || inner === undefined) return [];
|
|
201
|
+
const group = around
|
|
202
|
+
.flatMap((statement) => statement.groups)
|
|
203
|
+
.find(
|
|
204
|
+
([open, close]) => (pieces[open]?.line ?? line) < line && line <= (pieces[close]?.line ?? 0),
|
|
205
|
+
);
|
|
206
|
+
return [
|
|
207
|
+
{ first: outer.first, last: inner.last },
|
|
208
|
+
...(group === undefined ? [] : statementsAround(pieces, closes, line, group[0] + 1, group[1])),
|
|
209
|
+
];
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The excerpt for a finding on `line`: the outermost statement around it
|
|
214
|
+
* with at most `WHOLE` lines, such as the function it is in, and up to
|
|
215
|
+
* `AROUND` lines on either side of `line`. A comment or template literal
|
|
216
|
+
* that spans lines counts as a statement inside the one it is in.
|
|
217
|
+
*
|
|
218
|
+
* Around `line`, the excerpt shows only whole statements, never the end of
|
|
219
|
+
* the one above or the start of the one below, so it cannot start partway
|
|
220
|
+
* through a comment, whose lines do not read as code on their own. What
|
|
221
|
+
* `line` itself is in can show in part: a long function's first line, above
|
|
222
|
+
* the statement of its body. It ends at code, since a comment at the end
|
|
223
|
+
* would be about code the excerpt leaves out.
|
|
224
|
+
*/
|
|
225
|
+
export const excerptOf = (lines: ReadonlyArray<string>, line: number): Excerpt => {
|
|
226
|
+
const { pieces, spans } = piecesOf(lines.join("\n"));
|
|
227
|
+
const closes = closesOf(pieces);
|
|
228
|
+
/** What a line is in, outermost first. */
|
|
229
|
+
const around = (at: number): ReadonlyArray<Range> => [
|
|
230
|
+
...statementsAround(pieces, closes, at),
|
|
231
|
+
...spans.filter((span) => holds(span, at)),
|
|
232
|
+
];
|
|
233
|
+
const scope = around(line).find((range) => range.last - range.first < WHOLE);
|
|
234
|
+
const top = Math.max(1, Math.min(scope?.first ?? line, line - AROUND));
|
|
235
|
+
const bottom = Math.min(lines.length, Math.max(scope?.last ?? line, line + AROUND));
|
|
236
|
+
/** What a line is in that `line` is not, which an edge shows whole or not at all. */
|
|
237
|
+
const beside = (at: number) => around(at).filter((range) => !holds(range, line));
|
|
238
|
+
let first = Math.max(
|
|
239
|
+
top,
|
|
240
|
+
...beside(top)
|
|
241
|
+
.filter((range) => range.first < top)
|
|
242
|
+
.map((range) => range.last + 1),
|
|
243
|
+
);
|
|
244
|
+
let last = Math.min(
|
|
245
|
+
bottom,
|
|
246
|
+
...beside(bottom)
|
|
247
|
+
.filter((range) => range.last > bottom)
|
|
248
|
+
.map((range) => range.first - 1),
|
|
249
|
+
);
|
|
250
|
+
const isBlank = (at: number) => (lines[at - 1] ?? "").trim().length === 0;
|
|
251
|
+
const hasCode = (at: number) => pieces.some((piece) => piece.line <= at && at <= piece.end);
|
|
252
|
+
while (first < line && isBlank(first)) first += 1;
|
|
253
|
+
while (last > line && !hasCode(last)) last -= 1;
|
|
254
|
+
return { start: first, lines: lines.slice(first - 1, last) };
|
|
255
|
+
};
|
package/src/highlight.ts
CHANGED
|
@@ -27,11 +27,11 @@ const NOT_KEYS = words("default super this");
|
|
|
27
27
|
/** The groups that name their token's kind. */
|
|
28
28
|
const CLASSES = ["comment", "string", "constant"] as const;
|
|
29
29
|
|
|
30
|
-
/** One group per token class, tried in order at each position. */
|
|
30
|
+
/** One group per token class, tried in order at each position. A template literal is read apart. */
|
|
31
31
|
const TOKEN = new RegExp(
|
|
32
32
|
[
|
|
33
33
|
/(?<comment>\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))/,
|
|
34
|
-
/(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'
|
|
34
|
+
/(?<string>"(?:[^"\\\n]|\\.)*"?|'(?:[^'\\\n]|\\.)*'?)/,
|
|
35
35
|
/(?<constant>0[box][\da-f_]+n?|\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?n?)/,
|
|
36
36
|
/(?<word>[a-z_$][\w$]*)/,
|
|
37
37
|
/(?<space>\s+)/,
|
|
@@ -71,36 +71,98 @@ const kindOf = (word: string, previous: string | undefined, isKey: boolean): Kin
|
|
|
71
71
|
return KEYWORDS.has(word) ? "keyword" : PRIMITIVES.has(word) ? "type" : undefined;
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
/** Whether a token is what the next one reads as before it: not whitespace or a comment. */
|
|
75
|
+
const counts = (token: Token): boolean => token.kind !== "comment" && token.text.trim().length > 0;
|
|
76
|
+
|
|
77
|
+
/** The token at `at`, after `previous`, the last one that counts. */
|
|
78
|
+
const tokenAt = (code: string, at: number, previous: string | undefined): Token | undefined => {
|
|
79
|
+
if (code[at] === "`") return { text: code.slice(at, templateEnd(code, at)), kind: "string" };
|
|
80
|
+
const regex = regexMayStart(previous) ? matchAt(REGEX, code, at) : null;
|
|
81
|
+
const match = regex ?? matchAt(TOKEN, code, at);
|
|
82
|
+
// `punctuation` matches any character the other groups do not.
|
|
83
|
+
if (match === null) return undefined;
|
|
84
|
+
const text = match[0];
|
|
85
|
+
const groups = match.groups ?? {};
|
|
86
|
+
const kind: Kind | undefined =
|
|
87
|
+
regex !== null
|
|
88
|
+
? "string"
|
|
89
|
+
: groups.word !== undefined
|
|
90
|
+
? kindOf(groups.word, previous, matchAt(KEY, code, at + text.length) !== null)
|
|
91
|
+
: CLASSES.find((name) => groups[name] !== undefined);
|
|
92
|
+
return kind === undefined ? { text } : { text, kind };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where the template literal that opens at `at` closes: past its closing
|
|
97
|
+
* backtick, or at the end of the code. The code in each `${…}` is read as
|
|
98
|
+
* tokens, so a brace or a template in it, as in `${xs.map((x) => `${x}`)}`,
|
|
99
|
+
* does not close the template early.
|
|
100
|
+
*/
|
|
101
|
+
const templateEnd = (code: string, at: number): number => {
|
|
102
|
+
let index = at + 1;
|
|
103
|
+
while (index < code.length && code[index] !== "`") {
|
|
104
|
+
if (code[index] === "\\") index += 2;
|
|
105
|
+
else if (code.startsWith("${", index)) index = substitutionEnd(code, index + 2);
|
|
106
|
+
else index += 1;
|
|
107
|
+
}
|
|
108
|
+
return Math.min(index + 1, code.length);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Where the code of a `${…}` that starts at `at` ends: past the brace that closes it. */
|
|
112
|
+
const substitutionEnd = (code: string, at: number): number => {
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let previous: string | undefined;
|
|
115
|
+
let index = at;
|
|
116
|
+
while (index < code.length) {
|
|
117
|
+
const token = tokenAt(code, index, previous);
|
|
118
|
+
if (token === undefined) break;
|
|
119
|
+
// Braces are punctuation; one in a string or a nested template is text.
|
|
120
|
+
if (token.kind === undefined) {
|
|
121
|
+
for (let offset = 0; offset < token.text.length; offset += 1) {
|
|
122
|
+
if (token.text[offset] === "{") depth += 1;
|
|
123
|
+
else if (token.text[offset] === "}") {
|
|
124
|
+
if (depth === 0) return index + offset + 1;
|
|
125
|
+
depth -= 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (counts(token)) previous = token.text;
|
|
130
|
+
index += token.text.length;
|
|
131
|
+
}
|
|
132
|
+
return code.length;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The code's tokens, in order. A token that crosses a newline, like a block
|
|
137
|
+
* comment or a template literal, stays whole.
|
|
138
|
+
*/
|
|
139
|
+
export const scan = (code: string): ReadonlyArray<Token> => {
|
|
140
|
+
const tokens: Array<Token> = [];
|
|
141
|
+
let previous: string | undefined;
|
|
142
|
+
let at = 0;
|
|
143
|
+
while (at < code.length) {
|
|
144
|
+
const token = tokenAt(code, at, previous);
|
|
145
|
+
if (token === undefined) break;
|
|
146
|
+
tokens.push(token);
|
|
147
|
+
if (counts(token)) previous = token.text;
|
|
148
|
+
at += token.text.length;
|
|
149
|
+
}
|
|
150
|
+
return tokens;
|
|
151
|
+
};
|
|
152
|
+
|
|
74
153
|
/**
|
|
75
154
|
* The code's lines, each as tokens. A token that crosses a newline, like a
|
|
76
155
|
* block comment or a template literal, is cut into one token per line.
|
|
77
156
|
*/
|
|
78
157
|
export const tokenize = (code: string): ReadonlyArray<ReadonlyArray<Token>> => {
|
|
79
158
|
const lines: Array<Array<Token>> = [[]];
|
|
80
|
-
|
|
81
|
-
let previous: string | undefined;
|
|
82
|
-
let at = 0;
|
|
83
|
-
while (at < code.length) {
|
|
84
|
-
const regex = regexMayStart(previous) ? matchAt(REGEX, code, at) : null;
|
|
85
|
-
const match = regex ?? matchAt(TOKEN, code, at);
|
|
86
|
-
// `punctuation` matches any character the other groups do not.
|
|
87
|
-
if (match === null) break;
|
|
88
|
-
const text = match[0];
|
|
89
|
-
const groups = match.groups ?? {};
|
|
90
|
-
const kind: Kind | undefined =
|
|
91
|
-
regex !== null
|
|
92
|
-
? "string"
|
|
93
|
-
: groups.word !== undefined
|
|
94
|
-
? kindOf(groups.word, previous, matchAt(KEY, code, at + text.length) !== null)
|
|
95
|
-
: CLASSES.find((name) => groups[name] !== undefined);
|
|
159
|
+
for (const { text, kind } of scan(code)) {
|
|
96
160
|
for (const [index, piece] of text.split("\n").entries()) {
|
|
97
161
|
if (index > 0) lines.push([]);
|
|
98
162
|
if (piece.length > 0) {
|
|
99
163
|
lines.at(-1)?.push(kind === undefined ? { text: piece } : { text: piece, kind });
|
|
100
164
|
}
|
|
101
165
|
}
|
|
102
|
-
if (groups.comment === undefined && groups.space === undefined) previous = text;
|
|
103
|
-
at += text.length;
|
|
104
166
|
}
|
|
105
167
|
return lines;
|
|
106
168
|
};
|
package/src/init.ts
CHANGED
|
@@ -20,18 +20,24 @@ export default {
|
|
|
20
20
|
`;
|
|
21
21
|
|
|
22
22
|
const SMALL_FILES = `---
|
|
23
|
-
description: A file should be small and focused, with one primary responsibility.
|
|
23
|
+
description: A file should be small and focused, with one primary responsibility, and should not mix unrelated concerns.
|
|
24
24
|
threshold: 0.8
|
|
25
25
|
---
|
|
26
26
|
|
|
27
|
-
A guideline, so its description and its code say should
|
|
28
|
-
hold says must and never instead, as name-domain-actions does.
|
|
29
|
-
as a template for repository-specific conventions.
|
|
27
|
+
A guideline, so its description and its code say should and should not. A
|
|
28
|
+
rule that must hold says must and never instead, as name-domain-actions does.
|
|
29
|
+
Use this rule as a template for repository-specific conventions.
|
|
30
30
|
|
|
31
31
|
\`\`\`ts should
|
|
32
32
|
export const parsePort = (value: string) =>
|
|
33
33
|
Port.make(Number.parseInt(value, 10));
|
|
34
34
|
\`\`\`
|
|
35
|
+
|
|
36
|
+
\`\`\`ts should not
|
|
37
|
+
export const parsePort = (value: string) => Port.make(Number.parseInt(value, 10));
|
|
38
|
+
export const sendWelcomeEmail = (to: Email) => Mailer.send(to, welcomeTemplate);
|
|
39
|
+
export const renderInvoice = (invoice: Invoice) => Html.table(invoice.lines);
|
|
40
|
+
\`\`\`
|
|
35
41
|
`;
|
|
36
42
|
|
|
37
43
|
const NAME_EFFECTS = `---
|
package/src/main.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as adhere from "#index.ts";
|
|
|
4
4
|
import { version } from "#version.ts";
|
|
5
5
|
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
6
6
|
import { plugin } from "bun";
|
|
7
|
-
import { Effect } from "effect";
|
|
7
|
+
import { Cause, Effect, Layer, Logger, References, Runtime } from "effect";
|
|
8
8
|
import { Command } from "effect/unstable/cli";
|
|
9
9
|
|
|
10
10
|
// A config file may import `@drkmttr/adhere`, for `defineConfig`. The compiled
|
|
@@ -17,8 +17,39 @@ plugin({
|
|
|
17
17
|
},
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Every log on stderr, so stdout carries the report and nothing else. The
|
|
22
|
+
* pretty logger picks its stream from `LogToStderr`, not from an option.
|
|
23
|
+
*/
|
|
24
|
+
const logger = Layer.merge(
|
|
25
|
+
Logger.layer([Logger.consolePretty()]),
|
|
26
|
+
Layer.succeed(References.LogToStderr, true),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How a failed run says so, through the logger above: runMain's own report
|
|
31
|
+
* goes to stdout. A failure adhere declares, such as a refused request or a
|
|
32
|
+
* config that does not decode, is its message alone; its stack would only
|
|
33
|
+
* point into adhere. A defect, which is a bug, keeps its whole cause.
|
|
34
|
+
*/
|
|
35
|
+
const report = (cause: Cause.Cause<unknown>): Effect.Effect<void> => {
|
|
36
|
+
if (Cause.hasInterruptsOnly(cause)) return Effect.void;
|
|
37
|
+
const error = Cause.squash(cause);
|
|
38
|
+
const marked = typeof error === "object" && error !== null && Runtime.errorReported in error;
|
|
39
|
+
if (
|
|
40
|
+
marked &&
|
|
41
|
+
(error as { readonly [Runtime.errorReported]: boolean })[Runtime.errorReported] === false
|
|
42
|
+
) {
|
|
43
|
+
return Effect.void;
|
|
44
|
+
}
|
|
45
|
+
return !Cause.hasDies(cause) && error instanceof Error
|
|
46
|
+
? Effect.logError(error.message)
|
|
47
|
+
: Effect.logError(cause);
|
|
48
|
+
};
|
|
49
|
+
|
|
20
50
|
Command.run(cli, { version }).pipe(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
51
|
+
Effect.tapCause(report),
|
|
52
|
+
// oxlint-disable-next-line effecttsgo/strict-effect-provide -- this executable entrypoint supplies Bun's process services and the logger.
|
|
53
|
+
Effect.provide(Layer.merge(BunServices.layer, logger)),
|
|
54
|
+
(program) => BunRuntime.runMain(program, { disableErrorReporting: true }),
|
|
24
55
|
);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Rule } from "#config.ts";
|
|
2
|
+
import type { RuleEntry } from "#rules.ts";
|
|
3
|
+
import { AuditCache, sha256, type Tally } from "#services/AuditCache.ts";
|
|
4
|
+
import { linterQuestion } from "#services/Jev.ts";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The linter check asks, beside a rule's judge question, whether a regular
|
|
9
|
+
* linter could have decided the file against the rule. The answer is about
|
|
10
|
+
* the rule, so lint asks it on this many files per rule and then stops.
|
|
11
|
+
*/
|
|
12
|
+
export const SAMPLE = 10;
|
|
13
|
+
|
|
14
|
+
/** A tally's key: the model and the question, which carries the rule, so an edited rule starts over. */
|
|
15
|
+
export const tallyKeyOf = (model: string, rule: Rule) =>
|
|
16
|
+
sha256(`${model}\u0000${JSON.stringify(linterQuestion(rule))}`);
|
|
17
|
+
|
|
18
|
+
/** A rule and its linter check so far: the files lint asked on, and how many Jev said a linter could check. */
|
|
19
|
+
export interface Tallied {
|
|
20
|
+
readonly entry: RuleEntry;
|
|
21
|
+
readonly asked: number;
|
|
22
|
+
readonly flagged: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const talliedOf = (entry: RuleEntry, tally: Tally | undefined): Tallied => {
|
|
26
|
+
const answers = Object.values(tally?.files ?? {});
|
|
27
|
+
return { entry, asked: answers.length, flagged: answers.filter((p) => p > 0.5).length };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The project's rules with their tallies. A preset's rules are left out: they are not the user's to change. */
|
|
31
|
+
export const tallyProjectRules = Effect.fn("mechanical.tally")(function* (
|
|
32
|
+
entries: ReadonlyArray<RuleEntry>,
|
|
33
|
+
model: string,
|
|
34
|
+
) {
|
|
35
|
+
const cache = yield* AuditCache;
|
|
36
|
+
return yield* Effect.forEach(
|
|
37
|
+
entries.filter((entry) => entry.preset === undefined),
|
|
38
|
+
(entry) =>
|
|
39
|
+
Effect.map(Effect.flatMap(tallyKeyOf(model, entry.rule), cache.tally), (tally) =>
|
|
40
|
+
talliedOf(entry, tally),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Of a full sample, the share Jev said a linter could check: from 0.7 a
|
|
47
|
+
* linter should probably check the rule, and from 0.3 the rule reads
|
|
48
|
+
* differently from file to file.
|
|
49
|
+
*/
|
|
50
|
+
const LINTER = 0.7;
|
|
51
|
+
const MIXED = 0.3;
|
|
52
|
+
|
|
53
|
+
const displayPath = (file: string, root: string): string =>
|
|
54
|
+
file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
|
|
55
|
+
|
|
56
|
+
/** One flagged rule's advice, from its tally. */
|
|
57
|
+
const adviceOf = ({ asked, flagged }: Tallied): string =>
|
|
58
|
+
flagged >= LINTER * asked
|
|
59
|
+
? `Flagged on ${flagged} of ${asked} files: a regular linter should probably check this rule.`
|
|
60
|
+
: `Flagged on ${flagged} of ${asked} files: say more precisely what the rule applies to.`;
|
|
61
|
+
|
|
62
|
+
/** The linter check's part of `adhere validate`'s report: each rule it flags, then any it has too few answers for. */
|
|
63
|
+
export const formatLinterCheck = (
|
|
64
|
+
tallied: ReadonlyArray<Tallied>,
|
|
65
|
+
root: string,
|
|
66
|
+
): ReadonlyArray<string> => {
|
|
67
|
+
if (tallied.length === 0) return [];
|
|
68
|
+
const sampled = tallied.filter((one) => one.asked >= SAMPLE);
|
|
69
|
+
const flagged = sampled.filter((one) => one.flagged >= MIXED * one.asked);
|
|
70
|
+
const waiting = tallied.length - sampled.length;
|
|
71
|
+
return [
|
|
72
|
+
...(flagged.length === 0
|
|
73
|
+
? sampled.length === 0
|
|
74
|
+
? []
|
|
75
|
+
: ["The linter check flags no rule."]
|
|
76
|
+
: [
|
|
77
|
+
`${flagged.length} ${flagged.length === 1 ? "rule" : "rules"} flagged by the linter check:`,
|
|
78
|
+
...flagged.flatMap((one) => [
|
|
79
|
+
` ${one.entry.id}${one.entry.file === undefined ? "" : ` (${displayPath(one.entry.file, root)})`}`,
|
|
80
|
+
` ${adviceOf(one)}`,
|
|
81
|
+
]),
|
|
82
|
+
]),
|
|
83
|
+
...(waiting === 0
|
|
84
|
+
? []
|
|
85
|
+
: [
|
|
86
|
+
`${waiting} ${waiting === 1 ? "rule has" : "rules have"} fewer than ${SAMPLE} answers from lint yet, which asks as it judges files.`,
|
|
87
|
+
]),
|
|
88
|
+
];
|
|
89
|
+
};
|
package/src/presets.ts
CHANGED
|
@@ -7,8 +7,7 @@ import type { Preset } from "#config.ts";
|
|
|
7
7
|
* is the check Bun's docs give for this. Measured on Bun 1.4. The `typeof`
|
|
8
8
|
* guard is for Vitest, which imports this module on Node.
|
|
9
9
|
*/
|
|
10
|
-
const isStandaloneExecutable =
|
|
11
|
-
typeof Bun !== "undefined" && Bun.isStandaloneExecutable;
|
|
10
|
+
const isStandaloneExecutable = typeof Bun !== "undefined" && Bun.isStandaloneExecutable;
|
|
12
11
|
|
|
13
12
|
const presetsRoot = isStandaloneExecutable
|
|
14
13
|
? new URL("./presets/", import.meta.url)
|
package/src/rules.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface RuleEntry {
|
|
|
18
18
|
readonly rule: Rule;
|
|
19
19
|
readonly scope: string;
|
|
20
20
|
readonly file?: string;
|
|
21
|
+
/** The built-in rule set the rule came from, when it is not the project's own. */
|
|
22
|
+
readonly preset?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export type RuleSet = ReadonlyArray<RuleEntry>;
|
|
@@ -21,10 +21,9 @@ import {
|
|
|
21
21
|
} from "#rules.ts";
|
|
22
22
|
import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
|
|
23
23
|
|
|
24
|
-
export class AdhereConfig extends Context.Service<
|
|
25
|
-
AdhereConfig,
|
|
26
|
-
|
|
27
|
-
>()("@drkmttr/adhere/services/AdhereConfig") {}
|
|
24
|
+
export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
|
|
25
|
+
"@drkmttr/adhere/services/AdhereConfig",
|
|
26
|
+
) {}
|
|
28
27
|
|
|
29
28
|
const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
|
|
30
29
|
const path = yield* Path.Path;
|
|
@@ -72,14 +71,12 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
72
71
|
});
|
|
73
72
|
}
|
|
74
73
|
const configFile = found[0];
|
|
75
|
-
const hasPreset =
|
|
76
|
-
overrides.presets !== undefined && overrides.presets.length > 0;
|
|
74
|
+
const hasPreset = overrides.presets !== undefined && overrides.presets.length > 0;
|
|
77
75
|
const config: Decoded =
|
|
78
76
|
configFile === undefined
|
|
79
77
|
? yield* decodeConfig({})
|
|
80
78
|
: yield* loadConfigFile(path.join(cwd, configFile));
|
|
81
|
-
const discoveredEntries =
|
|
82
|
-
config.rules === undefined ? yield* loadAdhereRuleSet(cwd) : [];
|
|
79
|
+
const discoveredEntries = config.rules === undefined ? yield* loadAdhereRuleSet(cwd) : [];
|
|
83
80
|
const hasProjectRules = discoveredEntries.length > 0;
|
|
84
81
|
if (configFile === undefined && !hasProjectRules && !hasPreset) {
|
|
85
82
|
return yield* ConfigUnavailable.make({
|
|
@@ -89,18 +86,17 @@ export const AdhereConfigLive = (overrides: Overrides) =>
|
|
|
89
86
|
const projectEntries: RuleSet =
|
|
90
87
|
config.rules === undefined
|
|
91
88
|
? discoveredEntries
|
|
92
|
-
: globalRuleSet(
|
|
93
|
-
yield* materializeRules(config.rules ?? {}, cwd),
|
|
94
|
-
cwd,
|
|
95
|
-
);
|
|
89
|
+
: globalRuleSet(yield* materializeRules(config.rules ?? {}, cwd), cwd);
|
|
96
90
|
const rules = applicableRules(cwd, projectEntries);
|
|
97
|
-
const registry = yield* Effect.forEach(
|
|
98
|
-
|
|
99
|
-
(name) => loadPreset(name, cwd),
|
|
91
|
+
const registry = yield* Effect.forEach(presetsOf(config, overrides), (name) =>
|
|
92
|
+
loadPreset(name, cwd),
|
|
100
93
|
).pipe(Effect.map(Record.fromEntries));
|
|
101
94
|
const resolved = resolveConfig({ ...config, rules }, overrides, registry);
|
|
102
|
-
const presetEntries = Object.entries(registry).flatMap(([, preset]) =>
|
|
103
|
-
globalRuleSet(preset.rules, cwd),
|
|
95
|
+
const presetEntries = Object.entries(registry).flatMap(([name, preset]) =>
|
|
96
|
+
globalRuleSet(preset.rules, cwd).map((entry) => ({ ...entry, preset: name })),
|
|
97
|
+
);
|
|
98
|
+
yield* Effect.logDebug(
|
|
99
|
+
`config: ${configFile ?? "no config file"}; presets ${Object.keys(registry).join(", ") || "none"}; ${presetEntries.length} preset and ${projectEntries.length} project rules; model ${resolved.model}, threshold ${resolved.threshold}${resolved.rpm === undefined ? "" : `, at most ${resolved.rpm} requests a minute`}`,
|
|
104
100
|
);
|
|
105
101
|
return AdhereConfig.of({
|
|
106
102
|
...resolved,
|