@avi2dg/checks 0.16.0 → 0.18.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/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -1
- package/dependency-cruiser.config.js +8 -0
- package/dist/feature-rules.js +5 -4
- package/dist/index.js +426 -7
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/dependency-rules.md +1 -0
- package/docs/configs/effect-rules.md +1 -0
- package/docs/configs/quality-file.md +2 -2
- package/docs/configs/typescript-rules.md +36 -0
- package/docs/design.md +3 -1
- package/docs/gates/checks-comment-gate.md +2 -0
- package/docs/gates/checks-quality.md +23 -8
- package/docs/gates/checks-size-budget.md +4 -4
- package/oxlintrc.json +1 -0
- package/package.json +3 -1
- package/quality.schema.json +2 -2
- package/scripts/backtest.ts +57 -41
- package/scripts/ci-wiring.ts +2 -63
- package/scripts/comment-gate.ts +3 -4
- package/scripts/comment-matchers.ts +73 -58
- package/scripts/commit-identity.ts +3 -4
- package/scripts/doc-outline.ts +34 -16
- package/scripts/doc-rules.ts +34 -21
- package/scripts/docs.ts +3 -4
- package/scripts/feature-owners.ts +5 -8
- package/scripts/git.ts +31 -26
- package/scripts/prose-matchers.ts +93 -83
- package/scripts/quality.ts +157 -10
- package/scripts/range-gate.ts +9 -0
- package/scripts/shell-command.ts +74 -0
- package/scripts/size-budget.ts +18 -17
- package/scripts/size-rules.ts +13 -4
- package/scripts/suppressions-ratchet.ts +22 -24
- package/scripts/test-layout.ts +37 -35
- package/scripts/test-report.ts +18 -13
package/scripts/doc-rules.ts
CHANGED
|
@@ -89,29 +89,42 @@ function statusProblem(outline: Outline): Violation | undefined {
|
|
|
89
89
|
return { line: opening?.line ?? status.heading.line, message: `\`## Status\` opens with ${found} where one of ${ADR_STATUSES.join(", ")} goes` };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function
|
|
93
|
-
const violations: Violation[] = [];
|
|
94
|
-
const filed = recordNumber(path);
|
|
95
|
-
if (filed === undefined) {
|
|
96
|
-
violations.push({ line: 1, message: `is not named as a record, a four-digit number and a kebab-case name directly in ${ADR_DIRECTORY}` });
|
|
97
|
-
}
|
|
92
|
+
function recordTitleProblem(outline: Outline, filed: number | undefined): Violation | undefined {
|
|
98
93
|
const title = titleOf(outline);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (title !== undefined && titled !== undefined && filed !== undefined && Number(titled) !== filed) {
|
|
104
|
-
violations.push({ line: title.line, message: `${marked(1, title.title)} carries number ${titled}, and the file name ${filed}` });
|
|
94
|
+
if (title === undefined) return undefined;
|
|
95
|
+
const titled = RECORD_TITLE.exec(title.title)?.[1];
|
|
96
|
+
if (titled === undefined) {
|
|
97
|
+
return { line: title.line, message: `${marked(1, title.title)} does not open with the record's number, as in \`# 7. The decision\`` };
|
|
105
98
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
99
|
+
if (filed === undefined || Number(titled) === filed) return undefined;
|
|
100
|
+
return { line: title.line, message: `${marked(1, title.title)} carries number ${titled}, and the file name ${filed}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function recordDateProblem({ lead }: Outline): Violation | undefined {
|
|
104
|
+
const dated = firstText(lead);
|
|
105
|
+
if (isDate(DATE_LINE.exec(dated?.text.trim() ?? "")?.[1])) return undefined;
|
|
106
|
+
return { line: dated?.line ?? 1, message: "does not follow its title with a `Date: YYYY-MM-DD` line" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sharedNumberProblem(path: string, filed: number | undefined, records: readonly string[]): Violation | undefined {
|
|
110
|
+
if (filed === undefined) return undefined;
|
|
111
|
+
const sharing = records.filter((other) => other !== path && recordNumber(other) === filed);
|
|
112
|
+
return sharing.length === 0 ? undefined : { line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function adrProblems(path: string, outline: Outline, records: readonly string[]): readonly Violation[] {
|
|
116
|
+
const filed = recordNumber(path);
|
|
117
|
+
const misnamed: Violation | undefined =
|
|
118
|
+
filed === undefined
|
|
119
|
+
? { line: 1, message: `is not named as a record, a four-digit number and a kebab-case name directly in ${ADR_DIRECTORY}` }
|
|
120
|
+
: undefined;
|
|
121
|
+
return [
|
|
122
|
+
misnamed,
|
|
123
|
+
recordTitleProblem(outline, filed),
|
|
124
|
+
recordDateProblem(outline),
|
|
125
|
+
statusProblem(outline),
|
|
126
|
+
sharedNumberProblem(path, filed, records),
|
|
127
|
+
].filter((violation) => violation !== undefined);
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
export const RELEASED = /^Released (\S+)\.$/;
|
package/scripts/docs.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { Console, Effect } from "effect";
|
|
|
3
3
|
import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
4
4
|
import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
|
|
5
5
|
import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
6
|
-
import { changedLines, changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
|
|
7
|
-
import { runMain
|
|
6
|
+
import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "./git.ts";
|
|
7
|
+
import { runMain } from "./main.ts";
|
|
8
8
|
import { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
|
|
9
9
|
import { readQuality } from "./quality-file.ts";
|
|
10
10
|
|
|
@@ -152,8 +152,7 @@ export function report({ held, edited, living, findings, advisory, brokenBefore
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
const docs = Effect.gen(function* () {
|
|
155
|
-
const
|
|
156
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
155
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
157
156
|
|
|
158
157
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
159
158
|
const { base, head } = yield* rangeEnds(first, second, root);
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { parse } from "@swc/core";
|
|
3
3
|
import { Console, Effect, Path, Schema } from "effect";
|
|
4
4
|
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
5
|
-
import { runMain
|
|
6
|
-
import { PROOF_DIRECTORY,
|
|
5
|
+
import { runMain } from "./main.ts";
|
|
6
|
+
import { PROOF_DIRECTORY, type Feature } from "./quality-file.ts";
|
|
7
|
+
import { rangeGateInputs } from "./range-gate.ts";
|
|
7
8
|
|
|
8
9
|
type Unproven = {
|
|
9
10
|
readonly feature: string;
|
|
@@ -120,17 +121,13 @@ function signalReport(touched: readonly Touch[]): string {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
const owners = Effect.gen(function* () {
|
|
123
|
-
const
|
|
124
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
125
|
-
|
|
126
|
-
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
127
|
-
const { source, quality } = yield* readQuality(root);
|
|
124
|
+
const { refs, root, source, quality } = yield* rangeGateInputs(USAGE);
|
|
128
125
|
const features = quality.features ?? [];
|
|
129
126
|
if (features.length === 0) {
|
|
130
127
|
yield* Console.log(`${NAME}: ${source} declares no feature`);
|
|
131
128
|
return true;
|
|
132
129
|
}
|
|
133
|
-
const { base, head } = yield* rangeEnds(first, second, root);
|
|
130
|
+
const { base, head } = yield* rangeEnds(refs.first, refs.second, root);
|
|
134
131
|
const found = yield* unproven(root, head, features);
|
|
135
132
|
yield* Console.log(proofReport(features, found));
|
|
136
133
|
if (quality.changeSignal === "advisory") {
|
package/scripts/git.ts
CHANGED
|
@@ -87,37 +87,37 @@ function newPathOf(line: string): string | undefined {
|
|
|
87
87
|
|
|
88
88
|
const HUNK = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
89
89
|
|
|
90
|
+
type HunkPosition = {
|
|
91
|
+
readonly line: number;
|
|
92
|
+
readonly oldLeft: number;
|
|
93
|
+
readonly newLeft: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function hunkStart(row: string): HunkPosition | undefined {
|
|
97
|
+
const hunk = HUNK.exec(row);
|
|
98
|
+
return hunk === null ? undefined : { oldLeft: Number(hunk[1] ?? 1), line: Number(hunk[2]), newLeft: Number(hunk[3] ?? 1) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pastHunkRow(row: string, { line, oldLeft, newLeft }: HunkPosition): HunkPosition {
|
|
102
|
+
if (row.startsWith("+")) return { line: line + 1, oldLeft, newLeft: newLeft - 1 };
|
|
103
|
+
if (row.startsWith("-")) return { line, oldLeft: oldLeft - 1, newLeft };
|
|
104
|
+
if (row.startsWith(" ")) return { line: line + 1, oldLeft: oldLeft - 1, newLeft: newLeft - 1 };
|
|
105
|
+
return { line, oldLeft, newLeft };
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
// A hunk's header counts its lines, so an added line reading `++ x` is not taken for the next file's `+++` header.
|
|
91
109
|
export function parseAddedLines(diff: string): Map<string, Set<number>> {
|
|
92
110
|
const added = new Map<string, Set<number>>();
|
|
93
111
|
let path: string | undefined;
|
|
94
|
-
let
|
|
95
|
-
let oldLeft = 0;
|
|
96
|
-
let newLeft = 0;
|
|
112
|
+
let at: HunkPosition = { line: 0, oldLeft: 0, newLeft: 0 };
|
|
97
113
|
for (const row of diff.split("\n")) {
|
|
98
|
-
if (oldLeft > 0 || newLeft > 0) {
|
|
99
|
-
if (row.startsWith("+"))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
newLeft -= 1;
|
|
103
|
-
} else if (row.startsWith("-")) {
|
|
104
|
-
oldLeft -= 1;
|
|
105
|
-
} else if (row.startsWith(" ")) {
|
|
106
|
-
line += 1;
|
|
107
|
-
oldLeft -= 1;
|
|
108
|
-
newLeft -= 1;
|
|
109
|
-
}
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (row.startsWith("+++ ")) {
|
|
114
|
+
if (at.oldLeft > 0 || at.newLeft > 0) {
|
|
115
|
+
if (row.startsWith("+") && path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(at.line));
|
|
116
|
+
at = pastHunkRow(row, at);
|
|
117
|
+
} else if (row.startsWith("+++ ")) {
|
|
113
118
|
path = newPathOf(row);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const hunk = HUNK.exec(row);
|
|
117
|
-
if (hunk !== null) {
|
|
118
|
-
oldLeft = Number(hunk[1] ?? 1);
|
|
119
|
-
line = Number(hunk[2]);
|
|
120
|
-
newLeft = Number(hunk[3] ?? 1);
|
|
119
|
+
} else {
|
|
120
|
+
at = hunkStart(row) ?? at;
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
return added;
|
|
@@ -161,9 +161,14 @@ export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second
|
|
|
161
161
|
return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
export const
|
|
164
|
+
export const refArgs = Effect.fn("refArgs")(function* (args: readonly string[], usage: string) {
|
|
165
165
|
const [first, second, ...extra] = args;
|
|
166
166
|
if (first === undefined || extra.length > 0) return yield* new Usage({ message: usage });
|
|
167
|
+
return { first, second };
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
|
|
171
|
+
const { first, second } = yield* refArgs(args, usage);
|
|
167
172
|
return yield* rangeEnds(first, second, cwd);
|
|
168
173
|
});
|
|
169
174
|
|
|
@@ -97,59 +97,70 @@ function targetOf(destination: string): string {
|
|
|
97
97
|
return trimmed.split(/\s/, 1)[0] ?? "";
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
type Masking = { readonly raw: string; readonly out: string[]; readonly code: string[]; readonly links: string[]; inComment: boolean };
|
|
101
|
+
|
|
102
|
+
function hide(masking: Masking, from: number, to: number, fill: string): void {
|
|
103
|
+
for (let at = from; at < to; at += 1) masking.out[at] = fill;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A step returns an index past `at`, or undefined when its token does not start there.
|
|
107
|
+
function commentStep(masking: Masking, at: number): number | undefined {
|
|
108
|
+
const { raw } = masking;
|
|
109
|
+
if (!masking.inComment) {
|
|
110
|
+
if (!raw.startsWith("<!--", at)) return undefined;
|
|
111
|
+
hide(masking, at, at + 4, BLANK);
|
|
112
|
+
masking.inComment = true;
|
|
113
|
+
return at + 4;
|
|
114
|
+
}
|
|
115
|
+
const close = raw.indexOf("-->", at);
|
|
116
|
+
const end = close < 0 ? raw.length : close + 3;
|
|
117
|
+
hide(masking, at, end, BLANK);
|
|
118
|
+
masking.inComment = close < 0;
|
|
119
|
+
return end;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function codeSpanStep(masking: Masking, at: number): number | undefined {
|
|
123
|
+
const { raw } = masking;
|
|
124
|
+
if (raw.charAt(at) !== "`") return undefined;
|
|
125
|
+
const run = runOf(raw, at, "`");
|
|
126
|
+
const close = closingRun(raw, at + run, run);
|
|
127
|
+
if (close < 0) return at + run;
|
|
128
|
+
masking.code.push(raw.slice(at + run, close).trim());
|
|
129
|
+
hide(masking, at, close + run, HIDDEN);
|
|
130
|
+
return close + run;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function linkStep(masking: Masking, at: number): number | undefined {
|
|
134
|
+
const { raw } = masking;
|
|
135
|
+
const end = raw.startsWith("](", at) ? destinationEnd(raw, at + 1) : -1;
|
|
136
|
+
if (end < 0) return undefined;
|
|
137
|
+
masking.links.push(targetOf(raw.slice(at + 2, end)));
|
|
138
|
+
hide(masking, at + 1, end + 1, HIDDEN);
|
|
139
|
+
return end + 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hiddenStep(masking: Masking, at: number): number | undefined {
|
|
143
|
+
const hidden = hiddenAt(masking.raw, at, masking.raw.slice(at));
|
|
144
|
+
if (hidden === undefined) return undefined;
|
|
145
|
+
hide(masking, at, at + hidden.length, hidden.fill);
|
|
146
|
+
return at + hidden.length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const MASK_STEPS = [commentStep, codeSpanStep, linkStep, hiddenStep];
|
|
150
|
+
|
|
151
|
+
function maskStep(masking: Masking, at: number): number {
|
|
152
|
+
for (const step of MASK_STEPS) {
|
|
153
|
+
const next = step(masking, at);
|
|
154
|
+
if (next !== undefined) return next;
|
|
155
|
+
}
|
|
156
|
+
return at + 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
100
159
|
function mask(raw: string, startsInComment: boolean): Masked {
|
|
101
|
-
const
|
|
102
|
-
const code: string[] = [];
|
|
103
|
-
const links: string[] = [];
|
|
104
|
-
const hide = (from: number, to: number, fill: string): void => {
|
|
105
|
-
for (let at = from; at < to; at += 1) out[at] = fill;
|
|
106
|
-
};
|
|
107
|
-
let inComment = startsInComment;
|
|
160
|
+
const masking: Masking = { raw, out: raw.split(""), code: [], links: [], inComment: startsInComment };
|
|
108
161
|
let at = 0;
|
|
109
|
-
while (at < raw.length)
|
|
110
|
-
|
|
111
|
-
const close = raw.indexOf("-->", at);
|
|
112
|
-
const end = close < 0 ? raw.length : close + 3;
|
|
113
|
-
hide(at, end, BLANK);
|
|
114
|
-
inComment = close < 0;
|
|
115
|
-
at = end;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const rest = raw.slice(at);
|
|
119
|
-
if (rest.startsWith("<!--")) {
|
|
120
|
-
hide(at, at + 4, BLANK);
|
|
121
|
-
inComment = true;
|
|
122
|
-
at += 4;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (rest.startsWith("`")) {
|
|
126
|
-
const run = runOf(raw, at, "`");
|
|
127
|
-
const close = closingRun(raw, at + run, run);
|
|
128
|
-
if (close >= 0) {
|
|
129
|
-
code.push(raw.slice(at + run, close).trim());
|
|
130
|
-
hide(at, close + run, HIDDEN);
|
|
131
|
-
at = close + run;
|
|
132
|
-
} else at += run;
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (rest.startsWith("](")) {
|
|
136
|
-
const end = destinationEnd(raw, at + 1);
|
|
137
|
-
if (end >= 0) {
|
|
138
|
-
links.push(targetOf(raw.slice(at + 2, end)));
|
|
139
|
-
hide(at + 1, end + 1, HIDDEN);
|
|
140
|
-
at = end + 1;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
const hidden = hiddenAt(raw, at, rest);
|
|
145
|
-
if (hidden !== undefined) {
|
|
146
|
-
hide(at, at + hidden.length, hidden.fill);
|
|
147
|
-
at += hidden.length;
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
at += 1;
|
|
151
|
-
}
|
|
152
|
-
return { prose: out.join(""), code, links, inComment };
|
|
162
|
+
while (at < raw.length) at = maskStep(masking, at);
|
|
163
|
+
return { prose: masking.out.join(""), code: masking.code, links: masking.links, inComment: masking.inComment };
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
const FENCE = /^\s*(`{3,}|~{3,})(.*)$/;
|
|
@@ -191,42 +202,41 @@ function settext(lines: MarkdownLine[]): MarkdownLine[] {
|
|
|
191
202
|
});
|
|
192
203
|
}
|
|
193
204
|
|
|
205
|
+
function frontMatterLength(raws: readonly string[]): number {
|
|
206
|
+
if (raws[0] !== "---") return 0;
|
|
207
|
+
const close = raws.findIndex((raw, index) => index > 0 && (raw === "---" || raw === "..."));
|
|
208
|
+
return close < 0 ? raws.length : close + 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type Scanning = { fence: string | undefined; inComment: boolean; inHtmlBlock: boolean };
|
|
212
|
+
|
|
213
|
+
function scanLine(line: number, raw: string, scanning: Scanning): MarkdownLine {
|
|
214
|
+
if (scanning.fence !== undefined) {
|
|
215
|
+
if (fenceCloses(raw, scanning.fence)) scanning.fence = undefined;
|
|
216
|
+
return unread(line, raw, "code");
|
|
217
|
+
}
|
|
218
|
+
const opener = scanning.inComment ? undefined : fenceOpener(raw);
|
|
219
|
+
if (opener !== undefined) {
|
|
220
|
+
scanning.fence = opener;
|
|
221
|
+
return unread(line, raw, "code");
|
|
222
|
+
}
|
|
223
|
+
const definition = scanning.inComment ? null : DEFINITION.exec(raw);
|
|
224
|
+
if (definition !== null) return { ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] };
|
|
225
|
+
const masked = mask(raw, scanning.inComment);
|
|
226
|
+
scanning.inComment = masked.inComment;
|
|
227
|
+
const kind = kindOf(raw, masked.prose);
|
|
228
|
+
// An HTML block runs to the next blank line, whatever its later lines open with.
|
|
229
|
+
scanning.inHtmlBlock = kind === "html" || (scanning.inHtmlBlock && kind !== "blank");
|
|
230
|
+
return { line, kind: scanning.inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links };
|
|
231
|
+
}
|
|
232
|
+
|
|
194
233
|
export function scanMarkdown(text: string): readonly MarkdownLine[] {
|
|
195
234
|
const raws = text.split("\n").map((raw) => raw.replace(/\r$/, ""));
|
|
235
|
+
const frontMatter = frontMatterLength(raws);
|
|
236
|
+
const scanning: Scanning = { fence: undefined, inComment: false, inHtmlBlock: false };
|
|
196
237
|
const lines: MarkdownLine[] = [];
|
|
197
|
-
let fence: string | undefined;
|
|
198
|
-
let inComment = false;
|
|
199
|
-
let inHtmlBlock = false;
|
|
200
|
-
let frontMatter = raws[0] === "---";
|
|
201
238
|
for (const [index, raw] of raws.entries()) {
|
|
202
|
-
|
|
203
|
-
if (frontMatter) {
|
|
204
|
-
frontMatter = index === 0 || (raw !== "---" && raw !== "...");
|
|
205
|
-
lines.push(unread(line, raw, "front-matter"));
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (fence !== undefined) {
|
|
209
|
-
if (fenceCloses(raw, fence)) fence = undefined;
|
|
210
|
-
lines.push(unread(line, raw, "code"));
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
const opener = inComment ? undefined : fenceOpener(raw);
|
|
214
|
-
if (opener !== undefined) {
|
|
215
|
-
fence = opener;
|
|
216
|
-
lines.push(unread(line, raw, "code"));
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
const definition = inComment ? null : DEFINITION.exec(raw);
|
|
220
|
-
if (definition !== null) {
|
|
221
|
-
lines.push({ ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] });
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
const masked = mask(raw, inComment);
|
|
225
|
-
inComment = masked.inComment;
|
|
226
|
-
const kind = kindOf(raw, masked.prose);
|
|
227
|
-
// An HTML block runs to the next blank line, whatever its later lines open with.
|
|
228
|
-
inHtmlBlock = kind === "html" || (inHtmlBlock && kind !== "blank");
|
|
229
|
-
lines.push({ line, kind: inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links });
|
|
239
|
+
lines.push(index < frontMatter ? unread(index + 1, raw, "front-matter") : scanLine(index + 1, raw, scanning));
|
|
230
240
|
}
|
|
231
241
|
return settext(lines);
|
|
232
242
|
}
|
package/scripts/quality.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import kitOxlint from "../oxlintrc.json" with { type: "json" };
|
|
4
|
+
import kitManifest from "../package.json" with { type: "json" };
|
|
4
5
|
import effectLanguageService from "../presets/effect.language-service.json" with { type: "json" };
|
|
5
6
|
import effectOxlint from "../presets/effect.oxlint.json" with { type: "json" };
|
|
6
7
|
import { git } from "./git.ts";
|
|
8
|
+
import { DEFAULT_BRANCH } from "./gates.ts";
|
|
7
9
|
import { runMain, Usage } from "./main.ts";
|
|
8
|
-
import { readQuality, renderJson, type Quality } from "./quality-file.ts";
|
|
10
|
+
import { QualityUnreadable, readQuality, renderJson, type Quality } from "./quality-file.ts";
|
|
11
|
+
import { invokes, plainCommand } from "./shell-command.ts";
|
|
9
12
|
|
|
10
13
|
// oxlint resolves an override's files, and the language service an override's include, against the
|
|
11
14
|
// directory of the config that holds it, so a fragment anywhere but the root matches nothing there.
|
|
12
15
|
export const OXLINT_FRAGMENT = "oxlintrc.quality.json";
|
|
13
16
|
export const TSCONFIG_FRAGMENT = "tsconfig.quality.json";
|
|
17
|
+
export const SUITE_WORKFLOW = ".github/workflows/ci.yml";
|
|
18
|
+
export const COMMITLINT_WORKFLOW = ".github/workflows/commitlint.yml";
|
|
14
19
|
|
|
15
20
|
const NAME = "checks-quality";
|
|
16
21
|
const USAGE = `usage: ${NAME} --check | generate`;
|
|
@@ -25,6 +30,16 @@ export type Fragment = {
|
|
|
25
30
|
readonly content: unknown;
|
|
26
31
|
};
|
|
27
32
|
|
|
33
|
+
export type GeneratedWorkflow = {
|
|
34
|
+
readonly file: typeof SUITE_WORKFLOW | typeof COMMITLINT_WORKFLOW;
|
|
35
|
+
readonly content: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type WorkflowRecipe = {
|
|
39
|
+
readonly commitlintConfig: string;
|
|
40
|
+
readonly bunVersionFile: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
28
43
|
class NativeConfigUnreadable extends Schema.TaggedError<NativeConfigUnreadable>()("NativeConfigUnreadable", {
|
|
29
44
|
message: Schema.String,
|
|
30
45
|
}) {}
|
|
@@ -51,14 +66,86 @@ function tsconfigFragment({ paths, exempt = [] }: EffectSources): unknown {
|
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
const FRAGMENTS = [
|
|
54
|
-
{
|
|
55
|
-
|
|
69
|
+
{
|
|
70
|
+
file: OXLINT_FRAGMENT,
|
|
71
|
+
extendedBy: ".oxlintrc.json",
|
|
72
|
+
reader: "oxlint",
|
|
73
|
+
kitConfig: "./node_modules/@avi2dg/checks/oxlintrc.json",
|
|
74
|
+
kitRepositoryConfig: "./oxlintrc.json",
|
|
75
|
+
kitConfigReason: "so the kit's oxlint rules are not loaded",
|
|
76
|
+
build: oxlintFragment,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
file: TSCONFIG_FRAGMENT,
|
|
80
|
+
extendedBy: "tsconfig.json",
|
|
81
|
+
reader: "the language service",
|
|
82
|
+
kitConfig: "@avi2dg/checks/tsconfig.effect.json",
|
|
83
|
+
kitRepositoryConfig: "./tsconfig.effect.json",
|
|
84
|
+
kitConfigReason: "the one accepted spelling of the kit's Effect config",
|
|
85
|
+
build: tsconfigFragment,
|
|
86
|
+
},
|
|
56
87
|
] as const;
|
|
57
88
|
|
|
58
89
|
export function fragmentsFor(quality: Quality): readonly Fragment[] {
|
|
59
90
|
const effect = quality.sources?.effect;
|
|
60
91
|
if (effect === undefined) return [];
|
|
61
|
-
return FRAGMENTS.map(({
|
|
92
|
+
return FRAGMENTS.map(({ file, extendedBy, reader, build }) => ({ file, extendedBy, reader, content: build(effect) }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const OWN_COMMITLINT_CONFIG = "./commitlint.config.js";
|
|
96
|
+
const KIT_COMMITLINT_CONFIG = "./node_modules/@avi2dg/checks/commitlint.config.js";
|
|
97
|
+
|
|
98
|
+
function titleLint(commitlintConfig: string): string {
|
|
99
|
+
return `./node_modules/.bin/commitlint --config ${commitlintConfig} --edit "$RUNNER_TEMP/pr-title"`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runsInTitleLint(gate: string, commitlintConfig: string): boolean {
|
|
103
|
+
const words = plainCommand(gate);
|
|
104
|
+
return words !== undefined && invokes(plainCommand(titleLint(commitlintConfig)), words);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runScalar(command: string): string {
|
|
108
|
+
return /(^[\s#&*!|>@`'"%{[-])|:\s|\s#|:$|[\n\\]/.test(command) ? JSON.stringify(command) : command;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function commitlintWorkflow(commitlintConfig: string): string {
|
|
112
|
+
return `on:
|
|
113
|
+
pull_request:
|
|
114
|
+
types: [opened, edited, synchronize, reopened]
|
|
115
|
+
jobs:
|
|
116
|
+
commitlint:
|
|
117
|
+
runs-on: ubuntu-latest
|
|
118
|
+
steps:
|
|
119
|
+
- uses: actions/checkout@v5
|
|
120
|
+
- uses: oven-sh/setup-bun@v2
|
|
121
|
+
- run: bun install --frozen-lockfile
|
|
122
|
+
# Only the title is linted: it is what a squash merge lands, with GitHub appending " (#N)" to it.
|
|
123
|
+
- run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
|
|
124
|
+
env:
|
|
125
|
+
PR_TITLE: \${{ github.event.pull_request.title }}
|
|
126
|
+
- run: ${titleLint(commitlintConfig)}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function suiteWorkflow(defaultBranch: string, gates: readonly string[], bunVersionFile: boolean): string {
|
|
131
|
+
const setup = bunVersionFile
|
|
132
|
+
? " - uses: oven-sh/setup-bun@v2\n with:\n bun-version-file: .bun-version\n"
|
|
133
|
+
: " - uses: oven-sh/setup-bun@v2\n";
|
|
134
|
+
const steps = gates.map((gate) => ` - run: ${runScalar(gate)}\n`).join("");
|
|
135
|
+
return `name: ci\non:\n push:\n branches: [${defaultBranch}]\n pull_request:\n types: [opened, edited, synchronize, reopened]\njobs:\n checks:\n runs-on: ubuntu-latest\n steps:\n # The head, not GitHub's merge ref, so the tree the gates read is the commit the range ends at.\n # The whole history, since the range starts where the head branched from the base branch.\n - uses: actions/checkout@v5\n with:\n ref: \${{ github.event.pull_request.head.sha || github.sha }}\n fetch-depth: 0\n${setup} - run: bun install --frozen-lockfile\n${steps}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function workflowsFor(quality: Quality, recipe: WorkflowRecipe): readonly GeneratedWorkflow[] {
|
|
139
|
+
const commitlint: GeneratedWorkflow = {
|
|
140
|
+
file: COMMITLINT_WORKFLOW,
|
|
141
|
+
content: commitlintWorkflow(recipe.commitlintConfig),
|
|
142
|
+
};
|
|
143
|
+
if (quality.gates?.ci === undefined) return [commitlint];
|
|
144
|
+
const suite = quality.gates.ci.filter((gate) => !runsInTitleLint(gate, recipe.commitlintConfig));
|
|
145
|
+
return [
|
|
146
|
+
{ file: SUITE_WORKFLOW, content: suiteWorkflow(quality.defaultBranch ?? DEFAULT_BRANCH, suite, recipe.bunVersionFile) },
|
|
147
|
+
commitlint,
|
|
148
|
+
];
|
|
62
149
|
}
|
|
63
150
|
|
|
64
151
|
const NativeConfig = Schema.Struct({
|
|
@@ -94,11 +181,24 @@ const sameJson = (text: string, content: unknown): Effect.Effect<boolean> =>
|
|
|
94
181
|
Effect.orElseSucceed(() => false),
|
|
95
182
|
);
|
|
96
183
|
|
|
184
|
+
const decodePackageName = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ name: Schema.optionalKey(Schema.String) })));
|
|
185
|
+
|
|
186
|
+
const isKit = Effect.fn("isKit")(function* (root: string) {
|
|
187
|
+
const fs = yield* FileSystem.FileSystem;
|
|
188
|
+
const manifest = (yield* Path.Path).join(root, "package.json");
|
|
189
|
+
if (!(yield* fs.exists(manifest))) return false;
|
|
190
|
+
const { name } = yield* decodePackageName(yield* fs.readFileString(manifest)).pipe(
|
|
191
|
+
Effect.mapError((cause) => new QualityUnreadable({ message: `package.json: ${cause.message}` })),
|
|
192
|
+
);
|
|
193
|
+
return name === kitManifest.name;
|
|
194
|
+
});
|
|
195
|
+
|
|
97
196
|
const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string, source: string, expected: readonly Fragment[]) {
|
|
98
197
|
const fs = yield* FileSystem.FileSystem;
|
|
99
198
|
const path = yield* Path.Path;
|
|
100
199
|
const problems: string[] = [];
|
|
101
|
-
|
|
200
|
+
const kitRepository = yield* isKit(root);
|
|
201
|
+
for (const { file, extendedBy, reader, kitConfig, kitRepositoryConfig, kitConfigReason } of FRAGMENTS) {
|
|
102
202
|
const target = path.join(root, file);
|
|
103
203
|
const fragment = expected.find((candidate) => candidate.file === file);
|
|
104
204
|
const present = yield* fs.exists(target);
|
|
@@ -111,8 +211,13 @@ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string,
|
|
|
111
211
|
} else if (!(yield* sameJson(yield* fs.readFileString(target), fragment.content))) {
|
|
112
212
|
problems.push(`${file} is stale against ${source} and the kit's presets; run ${GENERATE}`);
|
|
113
213
|
}
|
|
114
|
-
|
|
115
|
-
|
|
214
|
+
const configured = yield* extendsOf(root, extendedBy);
|
|
215
|
+
if (!configured.includes(file)) {
|
|
216
|
+
problems.push(`${extendedBy} does not extend ./${file}, so ${reader} never reads it`);
|
|
217
|
+
}
|
|
218
|
+
const requiredKitConfig = kitRepository ? kitRepositoryConfig : kitConfig;
|
|
219
|
+
if (!configured.includes(path.normalize(requiredKitConfig))) {
|
|
220
|
+
problems.push(`${extendedBy} does not extend ${requiredKitConfig}, ${kitConfigReason}`);
|
|
116
221
|
}
|
|
117
222
|
}
|
|
118
223
|
return problems;
|
|
@@ -135,18 +240,54 @@ const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, qual
|
|
|
135
240
|
return problems;
|
|
136
241
|
});
|
|
137
242
|
|
|
243
|
+
const recipeOf = Effect.fn("recipeOf")(function* (root: string) {
|
|
244
|
+
const fs = yield* FileSystem.FileSystem;
|
|
245
|
+
const path = yield* Path.Path;
|
|
246
|
+
return {
|
|
247
|
+
// The kit never installs itself, so its own tree lints with its root config.
|
|
248
|
+
commitlintConfig: (yield* isKit(root)) ? OWN_COMMITLINT_CONFIG : KIT_COMMITLINT_CONFIG,
|
|
249
|
+
bunVersionFile: yield* fs.exists(path.join(root, ".bun-version")),
|
|
250
|
+
} satisfies WorkflowRecipe;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const workflowProblems = Effect.fn("workflowProblems")(function* (
|
|
254
|
+
root: string,
|
|
255
|
+
source: string,
|
|
256
|
+
expected: readonly GeneratedWorkflow[],
|
|
257
|
+
) {
|
|
258
|
+
const fs = yield* FileSystem.FileSystem;
|
|
259
|
+
const path = yield* Path.Path;
|
|
260
|
+
const problems: string[] = [];
|
|
261
|
+
for (const { file, content } of expected) {
|
|
262
|
+
const target = path.join(root, file);
|
|
263
|
+
if (!(yield* fs.exists(target))) {
|
|
264
|
+
problems.push(`${file} is missing; run ${GENERATE}`);
|
|
265
|
+
} else if ((yield* fs.readFileString(target)) !== content) {
|
|
266
|
+
problems.push(`${file} is stale against ${source} and the kit recipe; run ${GENERATE}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return problems;
|
|
270
|
+
});
|
|
271
|
+
|
|
138
272
|
const check = Effect.fn("check")(function* (root: string) {
|
|
139
273
|
const { source, quality } = yield* readQuality(root);
|
|
140
274
|
const expected = fragmentsFor(quality);
|
|
141
|
-
const
|
|
275
|
+
const workflows = workflowsFor(quality, yield* recipeOf(root));
|
|
276
|
+
const problems = [
|
|
277
|
+
...(yield* fragmentProblems(root, source, expected)),
|
|
278
|
+
...(yield* workflowProblems(root, source, workflows)),
|
|
279
|
+
...(yield* unmatchedPaths(root, quality)),
|
|
280
|
+
];
|
|
142
281
|
if (problems.length > 0) {
|
|
143
282
|
yield* Console.error([`${NAME}: ${problems.length} problem(s) with what ${source} declares:`, ...problems.map((line) => ` ${line}`)].join("\n"));
|
|
144
283
|
return false;
|
|
145
284
|
}
|
|
285
|
+
const held = [...expected.map((fragment) => fragment.file), ...workflows.map((workflow) => workflow.file)];
|
|
286
|
+
const verb = held.length === 1 ? "holds" : "hold";
|
|
146
287
|
yield* Console.log(
|
|
147
288
|
expected.length === 0
|
|
148
|
-
? `${NAME}: no sources.effect is declared, so
|
|
149
|
-
: `${NAME}: ${
|
|
289
|
+
? `${NAME}: no sources.effect is declared, so no fragment is generated; ${held.join(" and ")} ${verb} the kit recipe`
|
|
290
|
+
: `${NAME}: ${held.join(" and ")} ${verb} what ${source} declares`,
|
|
150
291
|
);
|
|
151
292
|
return true;
|
|
152
293
|
});
|
|
@@ -167,6 +308,12 @@ const generate = Effect.fn("generate")(function* (root: string) {
|
|
|
167
308
|
yield* Console.log(`${NAME}: removed ${file}`);
|
|
168
309
|
}
|
|
169
310
|
}
|
|
311
|
+
const workflows = workflowsFor(quality, yield* recipeOf(root));
|
|
312
|
+
yield* fs.makeDirectory(path.join(root, ".github", "workflows"), { recursive: true });
|
|
313
|
+
for (const { file, content } of workflows) {
|
|
314
|
+
yield* fs.writeFileString(path.join(root, file), content);
|
|
315
|
+
yield* Console.log(`${NAME}: wrote ${file}`);
|
|
316
|
+
}
|
|
170
317
|
return yield* check(root);
|
|
171
318
|
});
|
|
172
319
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { git, refArgs } from "./git.ts";
|
|
3
|
+
import { readQuality } from "./quality-file.ts";
|
|
4
|
+
|
|
5
|
+
export const rangeGateInputs = Effect.fn("rangeGateInputs")(function* (usage: string) {
|
|
6
|
+
const refs = yield* refArgs(process.argv.slice(2), usage);
|
|
7
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
8
|
+
return { refs, root, ...(yield* readQuality(root)) };
|
|
9
|
+
});
|