@avi2dg/checks 0.43.0 → 0.45.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +9 -4
  3. package/{commitlint.config.js → commitlint.config.ts} +1 -1
  4. package/dist/mutation/mutation-guard-plugin.js +40 -0
  5. package/dist/mutation/mutation-refusal.js +23 -0
  6. package/dist/presets/stylelint.d.ts +10 -0
  7. package/dist/presets/stylelint.js +73 -0
  8. package/docs/configs/commit-messages.md +2 -2
  9. package/docs/configs/native-settings.md +15 -2
  10. package/docs/design.md +26 -1
  11. package/docs/gates/checks-ci-wiring.md +3 -2
  12. package/docs/gates/checks-comment-gate.md +2 -0
  13. package/docs/gates/checks-copy.md +113 -0
  14. package/docs/gates/checks-docs-only.md +3 -2
  15. package/docs/gates/checks-docs.md +3 -0
  16. package/docs/gates/checks-flake.md +49 -1
  17. package/docs/gates/checks-frontend-syntax.md +87 -12
  18. package/docs/gates/checks-lint.md +2 -1
  19. package/docs/gates/checks-mutation-baseline.md +4 -2
  20. package/docs/gates/checks-mutation-compare.md +33 -8
  21. package/docs/gates/checks-mutation-scope.md +140 -0
  22. package/docs/gates/checks-mutation.md +11 -7
  23. package/docs/gates/checks-no-plain-js.md +60 -0
  24. package/docs/gates/checks-repetition.md +3 -0
  25. package/docs/gates/checks-suppressions-ratchet.md +11 -9
  26. package/docs/gates/checks-test.md +5 -2
  27. package/package.json +33 -6
  28. package/src/complexity/suppressions-ratchet.ts +37 -25
  29. package/src/core/declared-inputs.ts +77 -0
  30. package/src/core/gates.ts +4 -0
  31. package/src/core/git.ts +9 -0
  32. package/src/delivery/ci-wiring.ts +25 -11
  33. package/src/delivery/commit-title.ts +2 -2
  34. package/src/docs/copy-html.ts +110 -0
  35. package/src/docs/copy-text.ts +70 -0
  36. package/src/docs/copy.ts +70 -0
  37. package/src/docs/prose-matchers.ts +14 -14
  38. package/src/quality/comment-matchers.ts +4 -1
  39. package/src/quality/frontend-syntax.ts +114 -71
  40. package/src/quality/no-plain-js.ts +71 -0
  41. package/src/quality/presets/stylelint.ts +91 -0
  42. package/src/testing/flake-preload.ts +4 -0
  43. package/src/testing/flake.ts +142 -16
  44. package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
  45. package/src/testing/mutation-scope.ts +354 -0
  46. package/src/testing/mutation.ts +1 -1
  47. package/src/testing/test-layout.ts +4 -20
  48. package/src/testing/test-skips.ts +1 -1
  49. package/src/testing/test.ts +19 -3
  50. package/stryker.preset.js +3 -3
  51. package/src/testing/mutation-guard-plugin.js +0 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,13 +17,14 @@
17
17
  "docs/design.md",
18
18
  "docs/gates/",
19
19
  "bunfig.toml",
20
- "commitlint.config.js",
20
+ "commitlint.config.ts",
21
21
  "dependency-cruiser.config.js",
22
22
  "knip-base.json",
23
23
  "src/quality/lint-coverage.sh",
24
24
  "src/core/main.ts",
25
25
  "src/core/git.ts",
26
26
  "src/core/gates.ts",
27
+ "src/core/declared-inputs.ts",
27
28
  "src/core/lint.ts",
28
29
  "src/delivery/changelog.ts",
29
30
  "src/delivery/changelog-write.ts",
@@ -39,13 +40,14 @@
39
40
  "src/core/swc.ts",
40
41
  "src/testing/test-report.ts",
41
42
  "src/testing/flake.ts",
43
+ "src/testing/flake-preload.ts",
42
44
  "src/delivery/commit-identity.ts",
43
45
  "src/delivery/commit-title.ts",
44
46
  "src/delivery/docs-only.ts",
45
47
  "src/testing/mutation.ts",
46
- "src/testing/mutation-scope.js",
47
- "src/testing/mutation-guard-plugin.js",
48
+ "src/testing/mutation-refusal.ts",
48
49
  "src/testing/mutation-compare.ts",
50
+ "src/testing/mutation-scope.ts",
49
51
  "src/testing/mutation-baseline.ts",
50
52
  "src/testing/mutation-baseline-saved.ts",
51
53
  "src/testing/subsumed-tests.ts",
@@ -71,6 +73,9 @@
71
73
  "src/testing/quarantine-clock.ts",
72
74
  "src/docs/sentence-length.ts",
73
75
  "src/docs/docs.ts",
76
+ "src/docs/copy.ts",
77
+ "src/docs/copy-text.ts",
78
+ "src/docs/copy-html.ts",
74
79
  "src/dependencies/vendor.ts",
75
80
  "src/dependencies/vendor-args.ts",
76
81
  "src/dependencies/cache-root.ts",
@@ -89,6 +94,7 @@
89
94
  "src/quality/presets/effect.language-service.json",
90
95
  "src/quality/presets/oxlint.ts",
91
96
  "src/quality/presets/dependency-cruiser.ts",
97
+ "src/quality/presets/stylelint.ts",
92
98
  "src/quality/effect-scope.ts",
93
99
  "src/quality/jsonc-patch.ts",
94
100
  "src/dependencies/imports.ts",
@@ -100,6 +106,7 @@
100
106
  "dist/effect-channel/index.js",
101
107
  "dist/readability/index.js",
102
108
  "dist/data-shape/index.js",
109
+ "dist/mutation/",
103
110
  "dist/presets/"
104
111
  ],
105
112
  "exports": {
@@ -123,6 +130,10 @@
123
130
  "./dependency-cruiser": {
124
131
  "types": "./dist/presets/dependency-cruiser.d.ts",
125
132
  "default": "./dist/presets/dependency-cruiser.js"
133
+ },
134
+ "./stylelint": {
135
+ "types": "./dist/presets/stylelint.d.ts",
136
+ "default": "./dist/presets/stylelint.js"
126
137
  }
127
138
  },
128
139
  "bin": {
@@ -142,6 +153,7 @@
142
153
  "checks-mutation": "src/testing/mutation.ts",
143
154
  "checks-mutation-compare": "src/testing/mutation-compare.ts",
144
155
  "checks-mutation-baseline": "src/testing/mutation-baseline.ts",
156
+ "checks-mutation-scope": "src/testing/mutation-scope.ts",
145
157
  "checks-subsumed-tests": "src/testing/subsumed-tests.ts",
146
158
  "checks-ci-wiring": "src/delivery/ci-wiring.ts",
147
159
  "checks-comment-gate": "src/quality/comment-gate.ts",
@@ -151,20 +163,24 @@
151
163
  "checks-exports": "src/complexity/exports.ts",
152
164
  "checks-quarantine-clock": "src/testing/quarantine-clock.ts",
153
165
  "checks-docs": "src/docs/docs.ts",
166
+ "checks-copy": "src/docs/copy.ts",
154
167
  "checks-vendor": "src/dependencies/vendor.ts",
155
168
  "checks-advisories": "src/dependencies/advisories.ts",
156
169
  "checks-secrets": "src/delivery/secrets.ts",
157
170
  "checks-frontend-syntax": "src/quality/frontend-syntax.ts",
171
+ "checks-no-plain-js": "src/quality/no-plain-js.ts",
158
172
  "checks-browser": "src/quality/browser/browser.ts",
159
173
  "checks-imports": "src/dependencies/imports.ts",
160
174
  "checks-effect-scope": "src/quality/effect-scope.ts"
161
175
  },
162
176
  "scripts": {
163
177
  "prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
164
- "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts --outdir dist/presets --target node --format esm && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
178
+ "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/testing/mutation-guard-plugin.ts src/testing/mutation-refusal.ts --outdir dist/mutation --target node --format esm && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts src/quality/presets/stylelint.ts --outdir dist/presets --target node --format esm && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
165
179
  "lint": "oxlint && bun src/core/lint.ts",
166
180
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
167
181
  "test": "bun src/testing/test.ts",
182
+ "test:unit": "bun src/testing/test.ts --tier=unit",
183
+ "test:e2e": "bun src/testing/test.ts --tier=e2e --parallel=4",
168
184
  "mutate": "bunx stryker run",
169
185
  "mutate:incremental": "bunx stryker run --incremental"
170
186
  },
@@ -181,6 +197,8 @@
181
197
  "playwright-core": "1.63.0",
182
198
  "postcss-html": "2.0.0",
183
199
  "stylelint": "17.16.0",
200
+ "stylelint-config-standard": "40.0.0",
201
+ "stylelint-plugin-use-baseline": "1.4.7",
184
202
  "typescript": "7.0.2"
185
203
  },
186
204
  "peerDependenciesMeta": {
@@ -198,6 +216,12 @@
198
216
  },
199
217
  "stylelint": {
200
218
  "optional": true
219
+ },
220
+ "stylelint-config-standard": {
221
+ "optional": true
222
+ },
223
+ "stylelint-plugin-use-baseline": {
224
+ "optional": true
201
225
  }
202
226
  },
203
227
  "devDependencies": {
@@ -218,6 +242,8 @@
218
242
  "playwright-core": "1.63.0",
219
243
  "postcss-html": "2.0.0",
220
244
  "stylelint": "17.16.0",
245
+ "stylelint-config-standard": "40.0.0",
246
+ "stylelint-plugin-use-baseline": "1.4.7",
221
247
  "typescript": "7.0.2"
222
248
  },
223
249
  "dependencies": {
@@ -227,7 +253,8 @@
227
253
  "@effect/platform-node-shared": "4.0.0",
228
254
  "@total-typescript/ts-reset": "0.6.1",
229
255
  "commonmark": "0.31.2",
230
- "knip": "6.38.0"
256
+ "knip": "6.38.0",
257
+ "minimatch": "10.2.6"
231
258
  },
232
259
  "overrides": {
233
260
  "qs": "6.16.0",
@@ -3,13 +3,15 @@ import { Console, Effect, Schema } from "effect";
3
3
  import { git, commitOf, parentOrEmptyTree, refArgs } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
 
6
- export const SUPPRESSIONS = "oxlint-suppressions.json";
6
+ export const SUPPRESSIONS = ["oxlint-suppressions.json", "stylelint-suppressions.json"] as const;
7
+
8
+ export type SuppressionsFile = (typeof SUPPRESSIONS)[number];
7
9
 
8
10
  export type Suppressions = ReadonlyMap<string, ReadonlyMap<string, number>>;
9
11
 
10
- export type Rise =
11
- | { readonly kind: "rose"; readonly file: string; readonly rule: string; readonly base: number; readonly head: number }
12
- | { readonly kind: "appeared"; readonly file: string; readonly rule: string; readonly head: number };
12
+ type Place = { readonly list: SuppressionsFile; readonly file: string; readonly rule: string };
13
+
14
+ export type Rise = Place & ({ readonly kind: "rose"; readonly base: number; readonly head: number } | { readonly kind: "appeared"; readonly head: number });
13
15
 
14
16
  export type Ratchet = {
15
17
  readonly counted: number;
@@ -49,40 +51,44 @@ export const parseSuppressions = Effect.fn("parseSuppressions")(function* (
49
51
  return suppressions;
50
52
  });
51
53
 
52
- type Count = {
53
- readonly file: string;
54
- readonly rule: string;
55
- readonly count: number;
56
- };
54
+ type Count = Place & { readonly count: number };
57
55
 
58
- function countsOf(suppressions: Suppressions): readonly Count[] {
59
- return [...suppressions].flatMap(([file, rules]) => [...rules].map(([rule, count]) => ({ file, rule, count })));
56
+ function countsOf(list: SuppressionsFile, suppressions: Suppressions): readonly Count[] {
57
+ return [...suppressions].flatMap(([file, rules]) => [...rules].map(([rule, count]) => ({ list, file, rule, count })));
60
58
  }
61
59
 
62
- function riseOf({ file, rule, count }: Count, before: number | undefined): readonly Rise[] {
63
- if (before === undefined) return count > 0 ? [{ kind: "appeared", file, rule, head: count }] : [];
64
- return count > before ? [{ kind: "rose", file, rule, base: before, head: count }] : [];
60
+ function riseOf({ count, ...place }: Count, before: number | undefined): readonly Rise[] {
61
+ if (before === undefined) return count > 0 ? [{ ...place, kind: "appeared", head: count }] : [];
62
+ return count > before ? [{ ...place, kind: "rose", base: before, head: count }] : [];
65
63
  }
66
64
 
67
- export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
68
- const counts = countsOf(head);
65
+ export function compareSuppressions(list: SuppressionsFile, base: Suppressions, head: Suppressions): Ratchet {
66
+ const counts = countsOf(list, head);
69
67
  const rises = counts.flatMap((counted) => riseOf(counted, base.get(counted.file)?.get(counted.rule)));
70
- const lowered = countsOf(base).filter(({ file, rule, count }) => (head.get(file)?.get(rule) ?? 0) < count).length;
68
+ const lowered = countsOf(list, base).filter(({ file, rule, count }) => (head.get(file)?.get(rule) ?? 0) < count).length;
71
69
  rises.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
72
70
  return { counted: counts.length, lowered, rises };
73
71
  }
74
72
 
73
+ export function combined(ratchets: readonly Ratchet[]): Ratchet {
74
+ return {
75
+ counted: ratchets.reduce((sum, { counted }) => sum + counted, 0),
76
+ lowered: ratchets.reduce((sum, { lowered }) => sum + lowered, 0),
77
+ rises: ratchets.flatMap(({ rises }) => rises),
78
+ };
79
+ }
80
+
75
81
  function describeRise(rise: Rise): string {
76
82
  const change = rise.kind === "rose" ? `rose from ${rise.base} to ${rise.head}` : `appeared with ${rise.head}`;
77
- return `${rise.file} ${rise.rule} ${change}`;
83
+ return `${rise.list} ${rise.file} ${rise.rule} ${change}`;
78
84
  }
79
85
 
80
86
  export function report({ counted, lowered, rises }: Ratchet): string {
81
87
  if (rises.length === 0) {
82
- return `suppressions-ratchet: no count in ${SUPPRESSIONS} rose or appeared (${counted} at the head, ${lowered} lowered)`;
88
+ return `suppressions-ratchet: no count in ${SUPPRESSIONS.join(" or ")} rose or appeared (${counted} at the head, ${lowered} lowered)`;
83
89
  }
84
90
  return [
85
- `suppressions-ratchet: ${rises.length} count(s) in ${SUPPRESSIONS} rose or appeared; fix the site instead of suppressing it:`,
91
+ `suppressions-ratchet: ${rises.length} count(s) rose or appeared; fix the site instead of suppressing it:`,
86
92
  ...rises.map((rise) => ` ${describeRise(rise)}`),
87
93
  ].join("\n");
88
94
  }
@@ -94,22 +100,28 @@ const mergeBase = Effect.fn("mergeBase")(function* (base: string, head: string)
94
100
  return commit.trim();
95
101
  });
96
102
 
97
- const suppressionsAt = Effect.fn("suppressionsAt")(function* (treeish: string) {
98
- const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", SUPPRESSIONS])).trim();
103
+ const suppressionsAt = Effect.fn("suppressionsAt")(function* (list: SuppressionsFile, treeish: string) {
104
+ const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", list])).trim();
99
105
  if (blob === "") return new Map() satisfies Suppressions;
100
- return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${treeish}`);
106
+ return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${list} at ${treeish}`);
107
+ });
108
+
109
+ const compareAt = Effect.fn("compareAt")(function* (base: string, head: string) {
110
+ const ratchets: Ratchet[] = [];
111
+ for (const list of SUPPRESSIONS) ratchets.push(compareSuppressions(list, yield* suppressionsAt(list, base), yield* suppressionsAt(list, head)));
112
+ return combined(ratchets);
101
113
  });
102
114
 
103
115
  export const runRange = Effect.fn("runRange")(function* (base: string, head: string) {
104
116
  const headCommit = yield* commitOf(head);
105
117
  // At the base tip, a count the base branch lowered after the head branched off would read as a rise at the head.
106
118
  const branchPoint = yield* mergeBase(yield* commitOf(base), headCommit);
107
- return compareSuppressions(yield* suppressionsAt(branchPoint), yield* suppressionsAt(headCommit));
119
+ return yield* compareAt(branchPoint, headCommit);
108
120
  });
109
121
 
110
122
  export const runTip = Effect.fn("runTip")(function* (tip: string) {
111
123
  const tipCommit = yield* commitOf(tip);
112
- return compareSuppressions(yield* suppressionsAt(yield* parentOrEmptyTree(tipCommit)), yield* suppressionsAt(tipCommit));
124
+ return yield* compareAt(yield* parentOrEmptyTree(tipCommit), tipCommit);
113
125
  });
114
126
 
115
127
  const ratchet = Effect.gen(function* () {
@@ -0,0 +1,77 @@
1
+ import type { BunServices } from "@effect/platform-bun";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { runMain, Usage } from "./main.ts";
4
+
5
+ export type Declared = {
6
+ readonly name: string;
7
+ readonly file: string;
8
+ readonly declares: string;
9
+ };
10
+
11
+ export type Input = {
12
+ readonly glob: string;
13
+ readonly files: readonly string[];
14
+ };
15
+
16
+ export type Violation = {
17
+ readonly file: string;
18
+ readonly line: number;
19
+ readonly column: number;
20
+ readonly text: string;
21
+ };
22
+
23
+ export type Verdict<S> = {
24
+ readonly report: (scan: S) => string;
25
+ readonly passes: (scan: S) => boolean;
26
+ };
27
+
28
+ const Declaration = Schema.Struct({ inputs: Schema.NonEmptyArray(Schema.NonEmptyString) });
29
+
30
+ const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
31
+
32
+ const readInputs = Effect.fn("readInputs")(function* (root: string, { file, declares }: Declared) {
33
+ const fs = yield* FileSystem.FileSystem;
34
+ const path = (yield* Path.Path).join(root, file);
35
+ if (!(yield* fs.exists(path))) {
36
+ return yield* new Usage({ message: `${file} is missing, and a repository opts in to this gate by declaring its ${declares} there` });
37
+ }
38
+ const { inputs } = yield* decodeDeclaration(yield* fs.readFileString(path)).pipe(
39
+ Effect.mapError(({ message }) => new Usage({ message: `${file} does not decode: ${message}` })),
40
+ );
41
+ return inputs;
42
+ });
43
+
44
+ export function emptyInputs(inputs: readonly Input[]): readonly Input[] {
45
+ return inputs.filter(({ files }) => files.length === 0);
46
+ }
47
+
48
+ export function violationLine({ file, line, column, text }: Violation): string {
49
+ return `${file}:${line}:${column} ${text}`;
50
+ }
51
+
52
+ export function inputsReport(name: string, inputs: readonly Input[], problems: readonly string[]): string {
53
+ const files = new Set(inputs.flatMap((input) => input.files)).size;
54
+ const scanned = `${files} file(s) from ${inputs.length} declared input(s)`;
55
+ if (problems.length === 0) return `${name}: no violation in ${scanned}`;
56
+ return [`${name}: ${problems.length} problem(s) in ${scanned}:`, ...problems.map((problem) => ` ${problem}`)].join("\n");
57
+ }
58
+
59
+ export const declaredGate = Effect.fn("declaredGate")(function* <S, E>(
60
+ declared: Declared,
61
+ scanInputs: (root: string, globs: readonly string[]) => Effect.Effect<S, E, BunServices.BunServices>,
62
+ { report, passes }: Verdict<S>,
63
+ directory: string | undefined,
64
+ ) {
65
+ const root = (yield* Path.Path).resolve(directory ?? ".");
66
+ const scan = yield* scanInputs(root, yield* readInputs(root, declared));
67
+ yield* Console.log(report(scan));
68
+ return passes(scan);
69
+ });
70
+
71
+ export function runDeclaredGate<S, E extends { readonly message: string }>(
72
+ declared: Declared,
73
+ scanInputs: (root: string, globs: readonly string[]) => Effect.Effect<S, E, BunServices.BunServices>,
74
+ verdict: Verdict<S>,
75
+ ): void {
76
+ runMain(declared.name, declaredGate(declared, scanInputs, verdict, process.argv[2]));
77
+ }
package/src/core/gates.ts CHANGED
@@ -40,6 +40,8 @@ const OXLINT_CONFIG: TrackedContent = { pathspecs: ["oxlint.config.ts"], content
40
40
 
41
41
  const FRONTEND_SYNTAX_DECLARATION: TrackedContent = { pathspecs: ["frontend-syntax.json"], content: "a frontend syntax declaration" };
42
42
 
43
+ const COPY_DECLARATION: TrackedContent = { pathspecs: ["copy.json"], content: "a copy declaration" };
44
+
43
45
  export const KIT_GATES = [
44
46
  { bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE },
45
47
  { bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
@@ -47,10 +49,12 @@ export const KIT_GATES = [
47
49
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
48
50
  { bin: "checks-effect-scope", vector: "quality", file: "effect-scope.ts", reads: "tree", args: ["--check"], appliesTo: OXLINT_CONFIG },
49
51
  { bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
52
+ { bin: "checks-no-plain-js", vector: "quality", file: "no-plain-js.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
50
53
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
51
54
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
52
55
  { bin: "checks-secrets", vector: "delivery", file: "secrets.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
53
56
  { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
57
+ { bin: "checks-copy", vector: "docs", file: "copy.ts", reads: "tree", appliesTo: COPY_DECLARATION },
54
58
  { bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
55
59
  { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: LINTED_SOURCE },
56
60
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
package/src/core/git.ts CHANGED
@@ -29,6 +29,15 @@ export const collect = Effect.fn("collect")(
29
29
  Effect.scoped,
30
30
  );
31
31
 
32
+ export const workingTreeFiles = Effect.fn("workingTreeFiles")(function* (root: string) {
33
+ const fs = yield* FileSystem.FileSystem;
34
+ const path = yield* Path.Path;
35
+ const listed = yield* git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root);
36
+ return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), {
37
+ concurrency: "unbounded",
38
+ });
39
+ });
40
+
32
41
  export const git = Effect.fn("git")(function* (args: readonly string[], cwd?: string, feed?: Feed) {
33
42
  const failed = (reason: string): GitFailure => new GitFailure({ message: `git ${args.join(" ")}: ${reason.trim()}` });
34
43
  const { stdout, stderr, exitCode } = yield* collect("git", args, cwd, feed).pipe(
@@ -17,6 +17,7 @@ export type Gate = {
17
17
  readonly command: string;
18
18
  readonly words: Command;
19
19
  readonly alternatives: readonly Command[];
20
+ readonly partition: readonly Command[];
20
21
  };
21
22
 
22
23
  export type Declaration = {
@@ -63,6 +64,8 @@ export class WiringError extends Schema.TaggedError<WiringError>()("WiringError"
63
64
 
64
65
  const WORKFLOWS = ".github/workflows";
65
66
  const SCRIPTED = ["lint", "build", "typecheck", "test"];
67
+ const WHOLE_SUITE = "bun run test";
68
+ const SPLIT_SUITE = ["test:unit", "test:e2e"];
66
69
  const BUILT_TREE = "git diff --exit-code";
67
70
  const TITLE_LINT = "bunx checks-commit-title";
68
71
  // The gate holds until every consumer migrates, so the direct commitlint call still counts.
@@ -196,6 +199,21 @@ function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Comman
196
199
  return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
197
200
  }
198
201
 
202
+ type Invocation = Omit<RunStep, "script">;
203
+
204
+ function invocations(commands: readonly { command: string; words: Command }[], steps: readonly RunStep[]): readonly Invocation[] {
205
+ return steps.flatMap(({ location, blocker, script }) => {
206
+ const plain = plainCommand(script);
207
+ if (commands.some(({ words }) => invokes(plain, words))) return [{ location, blocker }];
208
+ const mentioned = commands.find(({ words }) => mentions(script, words));
209
+ if (mentioned === undefined) return [];
210
+ const alone = `the step runs more than ${mentioned.command}; give it its own step with nothing else in it`;
211
+ return [{ location, blocker: blocker ?? alone }];
212
+ });
213
+ }
214
+
215
+ const runsUnblocked = (invoking: readonly Invocation[]): boolean => invoking.some((step) => step.blocker === undefined);
216
+
199
217
  function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates: readonly KitGate[]): readonly Gap[] {
200
218
  return gates.flatMap((gate) => {
201
219
  const entryPoint = entryPointCommand(gate.words, lintGates);
@@ -204,16 +222,10 @@ function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates:
204
222
  ...gate.alternatives.map((words) => ({ command: gate.command, words })),
205
223
  ...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
206
224
  ];
207
- const invoking = steps.flatMap(({ location, blocker, script }) => {
208
- const plain = plainCommand(script);
209
- if (commands.some(({ words }) => invokes(plain, words))) return [{ location, blocker }];
210
- const mentioned = commands.find(({ words }) => mentions(script, words));
211
- if (mentioned === undefined) return [];
212
- const alone = `the step runs more than ${mentioned.command}; give it its own step with nothing else in it`;
213
- return [{ location, blocker: blocker ?? alone }];
214
- });
215
- if (invoking.some((step) => step.blocker === undefined)) return [];
216
- const blocked = invoking.flatMap(({ location, blocker }) =>
225
+ const invoking = invocations(commands, steps);
226
+ const parts = gate.partition.map((words) => invocations([{ command: words.join(" "), words }], steps));
227
+ if (runsUnblocked(invoking) || (parts.length > 0 && parts.every(runsUnblocked))) return [];
228
+ const blocked = [...invoking, ...parts.flat()].flatMap(({ location, blocker }) =>
217
229
  blocker === undefined ? [] : [{ location, blocker }],
218
230
  );
219
231
  return [{ gate: gate.command, ...(entryPoint === undefined ? {} : { entryPoint: entryPoint.join(" ") }), blocked }];
@@ -252,11 +264,13 @@ export function requiredCommands(scripts: readonly string[]): readonly string[]
252
264
  }
253
265
 
254
266
  export function declarationFor(scripts: readonly string[], branch: string): Declaration {
267
+ const tiers = SPLIT_SUITE.every((name) => scripts.includes(name)) ? SPLIT_SUITE.map((name) => ["bun", "run", name]) : [];
255
268
  return {
256
269
  gates: requiredCommands(scripts).map((command) => ({
257
270
  command,
258
271
  words: command.split(" "),
259
272
  alternatives: command === TITLE_LINT ? TITLE_CALLS.map((call) => call.split(" ")) : [],
273
+ partition: command === WHOLE_SUITE ? tiers : [],
260
274
  })),
261
275
  defaultBranch: branch,
262
276
  lintGates: KIT_GATES,
@@ -277,7 +291,7 @@ function readsOverride(runsOn: unknown): boolean {
277
291
  function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility): string | undefined {
278
292
  const overridden = readsOverride(runsOn);
279
293
  const pin = `set runs-on: [${MUTATION_RUNNER.join(", ")}]`;
280
- if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; ${pin}`;
294
+ if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its mutation runs off winbox; ${pin}`;
281
295
  if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
282
296
  const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
283
297
  if (hosted || (visibility !== "private" && !overridden)) return undefined;
@@ -23,8 +23,8 @@ const cliOf = Effect.fn("cliOf")(function* () {
23
23
  // The config ships with the kit, so it sits two levels above this file wherever the kit is installed.
24
24
  const configOf = Effect.fn("configOf")(function* () {
25
25
  const file = yield* Effect.try({
26
- try: () => new URL("../../commitlint.config.js", import.meta.url),
27
- catch: () => setup("cannot resolve commitlint.config.js from the installed kit"),
26
+ try: () => new URL("../../commitlint.config.ts", import.meta.url),
27
+ catch: () => setup("cannot resolve commitlint.config.ts from the installed kit"),
28
28
  });
29
29
  return yield* (yield* Path.Path).fromFileUrl(file);
30
30
  });
@@ -0,0 +1,110 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { HtmlElement, HtmlValidate, Rule, StaticConfigLoader, TextNode, staticResolver, type DOMReadyEvent, type Plugin } from "html-validate";
3
+ import { collecting, separatorFindings, type Collector, type CopyFinding, type Place } from "./copy-text.ts";
4
+
5
+ const PLUGIN = "copy";
6
+ const RULE = `${PLUGIN}/separators`;
7
+
8
+ const UNREAD: ReadonlySet<string> = new Set(["script", "style", "template", "pre"]);
9
+ const CODE: ReadonlySet<string> = new Set(["code", "kbd", "samp", "var"]);
10
+ const TEXT_ATTRIBUTES = ["alt", "title", "placeholder", "aria-label", "aria-description"] as const;
11
+ const BUTTON_TYPES: ReadonlySet<string> = new Set(["submit", "button", "reset"]);
12
+ const LABELLED: ReadonlySet<string> = new Set(["option", "optgroup"]);
13
+ const META_COPY: ReadonlySet<string> = new Set(["description", "og:title", "og:description", "twitter:title", "twitter:description"]);
14
+
15
+ // Code stands in as one character that is neither a letter nor a space, so a hyphen after it still reads as spaced.
16
+ const HIDDEN = "\0";
17
+
18
+ const NAMED: Readonly<Record<string, string>> = { mdash: "—", ndash: "–", nbsp: "\u00a0" };
19
+ const ENTITY = /^&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([A-Za-z][A-Za-z0-9]{1,31}));/;
20
+ const LARGEST_CODE_POINT = 0x10ffff;
21
+
22
+ function decoded([, decimal, hex, name]: RegExpExecArray): string {
23
+ const point = decimal === undefined ? (hex === undefined ? undefined : Number.parseInt(hex, 16)) : Number(decimal);
24
+ if (point === undefined) return NAMED[name ?? ""] ?? HIDDEN;
25
+ return point > 0 && point <= LARGEST_CODE_POINT ? String.fromCodePoint(point) : HIDDEN;
26
+ }
27
+
28
+ function addSource(collector: Collector, source: string, from: Place): void {
29
+ let { line, column } = from;
30
+ let at = 0;
31
+ while (at < source.length) {
32
+ const entity = source.charAt(at) === "&" ? ENTITY.exec(source.slice(at)) : null;
33
+ const char = entity === null ? source.charAt(at) : decoded(entity);
34
+ const length = entity === null ? 1 : entity[0].length;
35
+ collector.add(char, { line, column });
36
+ if (char === "\n") {
37
+ line += 1;
38
+ column = 1;
39
+ } else column += length;
40
+ at += length;
41
+ }
42
+ }
43
+
44
+ function addAttribute(element: HtmlElement, key: string, attributes: Collector): void {
45
+ const attribute = element.getAttribute(key);
46
+ if (attribute === null || typeof attribute.value !== "string" || attribute.valueLocation === null) return;
47
+ addSource(attributes, attribute.value, attribute.valueLocation);
48
+ attributes.end();
49
+ }
50
+
51
+ function readAttributes(element: HtmlElement, attributes: Collector): void {
52
+ for (const key of TEXT_ATTRIBUTES) addAttribute(element, key, attributes);
53
+ const named = element.getAttributeValue("name") ?? element.getAttributeValue("property");
54
+ if (element.tagName === "meta" && named !== null && META_COPY.has(named)) addAttribute(element, "content", attributes);
55
+ const type = element.getAttributeValue("type")?.toLowerCase();
56
+ if (element.tagName === "input" && type !== undefined && BUTTON_TYPES.has(type)) addAttribute(element, "value", attributes);
57
+ if (LABELLED.has(element.tagName)) addAttribute(element, "label", attributes);
58
+ }
59
+
60
+ function readElement(element: HtmlElement, text: Collector, attributes: Collector): void {
61
+ readAttributes(element, attributes);
62
+ const inline = element.meta?.phrasing === true && element.tagName !== "br";
63
+ if (!inline) text.end();
64
+ if (CODE.has(element.tagName)) text.add(HIDDEN, element.location);
65
+ else if (!UNREAD.has(element.tagName)) readChildren(element, text, attributes);
66
+ if (!inline) text.end();
67
+ }
68
+
69
+ function readChildren(element: HtmlElement, text: Collector, attributes: Collector): void {
70
+ for (const child of element.childNodes) {
71
+ if (child instanceof TextNode) addSource(text, child.textContent, child.location);
72
+ else if (child instanceof HtmlElement) readElement(child, text, attributes);
73
+ }
74
+ }
75
+
76
+ class Separators extends Rule {
77
+ override setup(): void {
78
+ this.on("dom:ready", ({ document, source }: DOMReadyEvent) => {
79
+ const text = collecting();
80
+ const attributes = collecting();
81
+ readChildren(document.root, text, attributes);
82
+ for (const { line, column, message } of separatorFindings([...text.blocks(), ...attributes.blocks()])) {
83
+ this.report(null, message, { filename: source.filename, line, column, offset: 0, size: 1 });
84
+ }
85
+ });
86
+ }
87
+ }
88
+
89
+ const plugin: Plugin = { name: PLUGIN, rules: { [RULE]: Separators } };
90
+
91
+ const validator = new HtmlValidate(
92
+ new StaticConfigLoader([staticResolver({ plugins: { [PLUGIN]: plugin } })], {
93
+ root: true,
94
+ elements: ["html5", { svg: { foreign: false }, math: { foreign: false } }],
95
+ plugins: [PLUGIN],
96
+ rules: { [RULE]: "error" },
97
+ }),
98
+ );
99
+
100
+ class CopyHtmlError extends Schema.TaggedError<CopyHtmlError>()("CopyHtmlError", { message: Schema.String }) {}
101
+
102
+ export const htmlFindings = Effect.fn("htmlFindings")(function* (source: string, file: string) {
103
+ const { results } = yield* Effect.tryPromise({
104
+ try: () => validator.validateString(source, file),
105
+ catch: (cause) => new CopyHtmlError({ message: `html-validate cannot read ${file}: ${String(cause)}` }),
106
+ });
107
+ return results.flatMap(({ messages }) =>
108
+ messages.map(({ line, column, message, ruleId }) => ({ line, column, message: ruleId === RULE ? message : `does not parse as HTML: ${message}` })),
109
+ ) satisfies readonly CopyFinding[];
110
+ });
@@ -0,0 +1,70 @@
1
+ import { bodyOf, carries, continues, scanMarkdown, SEPARATORS, type LineKind } from "./prose-matchers.ts";
2
+
3
+ export type Place = { readonly line: number; readonly column: number };
4
+
5
+ export type CopyFinding = Place & { readonly message: string };
6
+
7
+ // Each place is the source position of the character at the same index in text.
8
+ type Block = { readonly text: string; readonly places: readonly Place[] };
9
+
10
+ export type Collector = {
11
+ readonly add: (char: string, place: Place) => void;
12
+ readonly end: () => void;
13
+ readonly blocks: () => readonly Block[];
14
+ };
15
+
16
+ const SPACE = /\s/;
17
+
18
+ // A reader sees each run of white space as one space, so a hyphen wrapped onto the next line still reads as spaced.
19
+ export function collecting(): Collector {
20
+ const done: Block[] = [];
21
+ let text = "";
22
+ let places: Place[] = [];
23
+ const add = (char: string, place: Place): void => {
24
+ const spaced = SPACE.test(char);
25
+ if (spaced && (text === "" || text.endsWith(" "))) return;
26
+ text += spaced ? " " : char;
27
+ places.push(place);
28
+ };
29
+ const end = (): void => {
30
+ if (text.trim() !== "") done.push({ text, places });
31
+ text = "";
32
+ places = [];
33
+ };
34
+ return {
35
+ add,
36
+ end,
37
+ blocks: () => {
38
+ end();
39
+ return done;
40
+ },
41
+ };
42
+ }
43
+
44
+ function blockFindings({ text, places }: Block): readonly CopyFinding[] {
45
+ return SEPARATORS.flatMap((rule) =>
46
+ [...text.matchAll(rule.find)].flatMap((match) => {
47
+ const place = places[match.index + match[0].length - match[0].trimStart().length];
48
+ return place === undefined ? [] : [{ ...place, message: carries(match[0], rule) }];
49
+ }),
50
+ );
51
+ }
52
+
53
+ export function separatorFindings(blocks: readonly Block[]): readonly CopyFinding[] {
54
+ return blocks.flatMap(blockFindings).toSorted((a, b) => a.line - b.line || a.column - b.column);
55
+ }
56
+
57
+ const READ_KINDS: ReadonlySet<LineKind> = new Set(["prose", "heading", "table", "html"]);
58
+
59
+ export function markdownFindings(text: string): readonly CopyFinding[] {
60
+ const lines = scanMarkdown(text);
61
+ const collector = collecting();
62
+ for (const [index, line] of lines.entries()) {
63
+ if (!continues(lines[index - 1], line)) collector.end();
64
+ if (!READ_KINDS.has(line.kind)) continue;
65
+ const body = bodyOf(line);
66
+ for (let at = 0; at < body.length; at += 1) collector.add(body.charAt(at), { line: line.line, column: at + 1 });
67
+ collector.add("\n", { line: line.line, column: body.length + 1 });
68
+ }
69
+ return separatorFindings(collector.blocks());
70
+ }