@avi2dg/checks 0.48.0 → 0.49.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 CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.49.0
6
+
7
+ Released 2026-10-20.
8
+
9
+ ### Features
10
+
11
+ - **testing:** build the full mutation baseline with bail, fastest unit tests first [#190](https://github.com/avi2d/checks/pull/190)
12
+
13
+ ### Fixes
14
+
15
+ - **delivery:** name the parsed invocation's line in CI wiring refusals [#189](https://github.com/avi2d/checks/pull/189)
16
+ - **quality:** refuse named font sizes in font shorthand and build token map once per file [#188](https://github.com/avi2d/checks/pull/188)
17
+ - **delivery:** judge shell-shaped release references in checks-ci-wiring [#186](https://github.com/avi2d/checks/pull/186)
18
+
19
+ ## 0.48.1
20
+
21
+ Released 2026-10-20.
22
+
23
+ ### Fixes
24
+
25
+ - **quality:** hold font shorthand and local size custom properties to the tokens option [#185](https://github.com/avi2d/checks/pull/185)
26
+ - **testing:** compare checks-night-run against the latest night that recorded scores [#184](https://github.com/avi2d/checks/pull/184)
27
+ - **testing:** default checks-test --parallel to the machine's core count [#182](https://github.com/avi2d/checks/pull/182)
28
+
5
29
  ## 0.48.0
6
30
 
7
31
  Released 2026-10-19.
package/README.md CHANGED
@@ -25,6 +25,7 @@ Each repository owns its workflows and native tool configs, as [Native settings]
25
25
  - `html-validate` 11.16.0
26
26
  - `playwright-core` 1.63.0
27
27
  - `postcss-html` 2.0.0
28
+ - `postcss-value-parser` 4.2.0
28
29
  - `stylelint` 17.16.0
29
30
  - `stylelint-config-standard` 40.0.0
30
31
  - `stylelint-plugin-use-baseline` 1.4.7
@@ -0,0 +1,159 @@
1
+ // src/testing/unit-test-order.ts
2
+ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node-shared";
3
+ import { Console, Effect as Effect2, FileSystem, Layer, Path, Schema as Schema2 } from "effect";
4
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
5
+
6
+ // src/testing/test-report.ts
7
+ import { Effect, Schema } from "effect";
8
+
9
+ class ReportError extends Schema.TaggedError()("ReportError", {
10
+ message: Schema.String
11
+ }) {
12
+ }
13
+ var NAME_SEPARATOR = " > ";
14
+ function reporterArgs(outfile) {
15
+ return ["--reporter=junit", `--reporter-outfile=${outfile}`];
16
+ }
17
+ var MARKUP = /<(?:\?[\s\S]*?\?>|!--[\s\S]*?-->|!\[CDATA\[[\s\S]*?\]\]>|(\/?)([A-Za-z_][\w.:-]*)((?:\s+[A-Za-z_][\w.:-]*\s*=\s*"[^"<]*")*)\s*(\/?)>)/g;
18
+ var ATTRIBUTE = /([A-Za-z_][\w.:-]*)\s*=\s*"([^"<]*)"/g;
19
+ var ENTITY = /&(?:(amp|lt|gt|quot|apos)|#(\d+)|#x([0-9A-Fa-f]+));/g;
20
+ var NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
21
+ var decodeSuite = Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.String }));
22
+ var decodeCase = Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.String, file: Schema.String, line: Schema.NumberFromString.check(Schema.isInt()) }));
23
+ var decodeCrash = Schema.decodeUnknownEffect(Schema.Struct({ file: Schema.String }));
24
+ var decodeTimed = Schema.decodeUnknownEffect(Schema.Struct({ file: Schema.String, time: Schema.FiniteFromString }));
25
+ function unescape(text) {
26
+ return text.replace(ENTITY, (_, named, decimal, hex) => {
27
+ if (named !== undefined)
28
+ return NAMED_ENTITIES[named] ?? "";
29
+ return String.fromCodePoint(decimal === undefined ? Number.parseInt(hex ?? "", 16) : Number.parseInt(decimal, 10));
30
+ });
31
+ }
32
+ function attributesOf(source) {
33
+ const attributes = {};
34
+ for (const [, name, value] of source.matchAll(ATTRIBUTE)) {
35
+ if (name !== undefined && value !== undefined)
36
+ attributes[name] = unescape(value);
37
+ }
38
+ return attributes;
39
+ }
40
+ var malformed = (reason) => new ReportError({ message: `the junit report is malformed: ${reason}` });
41
+ var takeTag = Effect.fnUntraced(function* (open, match) {
42
+ const [, closing, tag, attributes, selfClosing] = match;
43
+ if (tag === undefined)
44
+ return;
45
+ const parent = open.at(-1);
46
+ if (parent === undefined)
47
+ return yield* malformed(`<${tag}> after the root closed`);
48
+ if (closing === "/") {
49
+ if (parent.tag !== tag)
50
+ return yield* malformed(`</${tag}> closes <${parent.tag}>`);
51
+ open.pop();
52
+ return;
53
+ }
54
+ const element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
55
+ parent.children.push(element);
56
+ if (selfClosing !== "/")
57
+ open.push(element);
58
+ });
59
+ var parseElements = Effect.fnUntraced(function* (xml) {
60
+ const document = { tag: "", attributes: {}, children: [] };
61
+ const open = [document];
62
+ let consumed = 0;
63
+ for (const match of xml.matchAll(MARKUP)) {
64
+ if (xml.slice(consumed, match.index).includes("<"))
65
+ return yield* malformed(`stray < before offset ${match.index}`);
66
+ consumed = match.index + match[0].length;
67
+ yield* takeTag(open, match);
68
+ }
69
+ if (xml.slice(consumed).includes("<"))
70
+ return yield* malformed("stray < after the last element");
71
+ if (open.length !== 1)
72
+ return yield* malformed(`<${open.at(-1)?.tag}> never closes`);
73
+ const [root, ...extra] = document.children;
74
+ if (root?.tag !== "testsuites" || extra.length > 0)
75
+ return yield* malformed("the root is not one <testsuites>");
76
+ return root;
77
+ });
78
+ function outcomeOf(testcase) {
79
+ const skipped = testcase.children.find((child) => child.tag === "skipped");
80
+ if (skipped !== undefined)
81
+ return skipped.attributes["message"] === "TODO" ? "todo" : "skipped";
82
+ return testcase.children.some((child) => child.tag === "failure" || child.tag === "error") ? "failed" : "passed";
83
+ }
84
+ function crashed(testcase) {
85
+ return testcase.attributes["line"] === undefined && outcomeOf(testcase) === "failed";
86
+ }
87
+ var collect = Effect.fnUntraced(function* (suite, describes, report) {
88
+ const unreadable = (tag) => (cause) => new ReportError({ message: `the junit report holds a <${tag}> bun never writes: ${cause.message}` });
89
+ for (const child of suite.children) {
90
+ if (child.tag === "testsuite") {
91
+ const { name } = yield* decodeSuite(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
92
+ yield* collect(child, [...describes, name], report);
93
+ } else if (child.tag === "testcase" && crashed(child)) {
94
+ const { file } = yield* decodeCrash(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
95
+ report.crashedFiles.push(file);
96
+ } else if (child.tag === "testcase") {
97
+ const { name, file, line } = yield* decodeCase(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
98
+ report.results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
99
+ }
100
+ }
101
+ });
102
+ var parseReport = Effect.fn("parseReport")(function* (xml) {
103
+ const root = yield* parseElements(xml);
104
+ const results = [];
105
+ const crashedFiles = [];
106
+ for (const fileSuite of root.children) {
107
+ if (fileSuite.tag === "testsuite")
108
+ yield* collect(fileSuite, [], { results, crashedFiles });
109
+ }
110
+ return { results, crashedFiles };
111
+ });
112
+ var fileSeconds = Effect.fn("fileSeconds")(function* (xml) {
113
+ const root = yield* parseElements(xml);
114
+ const seconds = new Map;
115
+ for (const fileSuite of root.children) {
116
+ if (fileSuite.tag !== "testsuite")
117
+ continue;
118
+ const { file, time } = yield* decodeTimed(fileSuite.attributes).pipe(Effect.mapError((cause) => new ReportError({ message: `the junit report holds a <testsuite> with no file or time: ${cause.message}` })));
119
+ seconds.set(file, time);
120
+ }
121
+ return seconds;
122
+ });
123
+
124
+ // src/testing/unit-test-order.ts
125
+ class OrderError extends Schema2.TaggedError()("OrderError", {
126
+ message: Schema2.String
127
+ }) {
128
+ }
129
+ var NON_RUNS = ["--help", "-h", "--version"];
130
+ function ordersTests(command, args) {
131
+ return command === "run" && !args.some((arg) => NON_RUNS.includes(arg) || arg === "--ignoreStatic");
132
+ }
133
+ function fastestFirst(seconds) {
134
+ return [...seconds].toSorted(([file, time], [other, otherTime]) => time - otherTime || file.localeCompare(other)).map(([file]) => file);
135
+ }
136
+ function inRunOrder(listed, timed) {
137
+ const known = new Set(listed);
138
+ const first = new Set(timed.filter((file) => known.has(file)));
139
+ const rest = listed.filter((file) => !first.has(file));
140
+ return [...[...first, ...rest].map((file) => `./${file}`), ...listed];
141
+ }
142
+ var timedFiles = Effect2.fn("timedFiles")(function* (listed) {
143
+ const fs = yield* FileSystem.FileSystem;
144
+ const outfile = (yield* Path.Path).join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-order-" }), "junit.xml");
145
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
146
+ const args = ["test", ...listed.map((file) => `./${file}`), ...reporterArgs(outfile)];
147
+ const exitCode = yield* spawner.exitCode(ChildProcess.make("bun", args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }));
148
+ if (exitCode !== ChildProcessSpawner.ExitCode(0))
149
+ return yield* new OrderError({ message: `bun test exited ${exitCode} while timing the unit tests` });
150
+ return fastestFirst(yield* fileSeconds(yield* fs.readFileString(outfile)));
151
+ }, Effect2.scoped);
152
+ var NodeServices = NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)));
153
+ var runOrder = (listed) => Effect2.runPromise(timedFiles(listed).pipe(Effect2.map((timed) => inRunOrder(listed, timed)), Effect2.catch((failure) => Console.error(`checks: ${failure.message}, so each static mutant runs the unit tests in path order`).pipe(Effect2.as(listed))), Effect2.provide(NodeServices)));
154
+ export {
155
+ fastestFirst,
156
+ inRunOrder,
157
+ ordersTests,
158
+ runOrder
159
+ };
@@ -1,5 +1,6 @@
1
1
  // src/quality/presets/stylelint.ts
2
2
  import { fileURLToPath } from "node:url";
3
+ import valueParser from "postcss-value-parser";
3
4
  var HTML_AND_ASTRO = ["**/*.html", "**/*.astro"];
4
5
  var MARKUP = [...HTML_AND_ASTRO, "**/*.vue", "**/*.svelte"];
5
6
  function peer(name) {
@@ -49,11 +50,108 @@ var PSEUDO_ARGUMENT = "(?:[^()]|\\([^()]*\\))*";
49
50
  var PSEUDO_CLASS = `::?[\\w-]+(?:\\(${PSEUDO_ARGUMENT}\\))?`;
50
51
  var HAS_IN_PSEUDO_CLASS = `(?::has\\(${PSEUDO_ARGUMENT}\\)|::?[\\w-]+\\(${PSEUDO_ARGUMENT}:has\\([^()]*\\)${PSEUDO_ARGUMENT}\\))`;
51
52
  var CLASSLESS_HAS = `/(?:^|[\\s>+~,])(?:${PSEUDO_CLASS})*${HAS_IN_PSEUDO_CLASS}(?:${PSEUDO_CLASS})*(?=[\\s>+~,]|$)/i`;
53
+ var VAR = "var\\(\\s*--[^\\s,()]+\\s*\\)";
54
+ var VAR_NAMES = /var\(\s*(--[^\s,()]+)\s*\)/gi;
55
+ var FONT_SIZE_VALUE = new RegExp(`^${VAR}$`, "i");
56
+ var FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH = "(?:normal|italic|oblique(?:\\s+-?[\\d.]+(?:deg|grad|rad|turn))?|small-caps|bold|bolder|lighter|[1-9]\\d*(?:\\.\\d+)?|(?:ultra-|extra-|semi-)?(?:condensed|expanded))";
57
+ var FONT_FAMILY = `(?:"[^"]*"|'[^']*'|${VAR}|-?[a-z_][\\w-]*(?:\\s+-?[a-z_][\\w-]*)*)`;
58
+ var FONT_FAMILIES = `${FONT_FAMILY}(?:\\s*,\\s*${FONT_FAMILY})*`;
59
+ var FONT_FAMILY_VALUE = new RegExp(`^${FONT_FAMILIES}$`, "i");
60
+ var NAMED_FONT_SIZES = new Set([
61
+ "xx-small",
62
+ "x-small",
63
+ "small",
64
+ "medium",
65
+ "large",
66
+ "x-large",
67
+ "xx-large",
68
+ "xxx-large",
69
+ "larger",
70
+ "smaller",
71
+ "math"
72
+ ]);
73
+ var SIZE_PRECEDER = new RegExp(`^${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}$`, "i");
74
+ var FONT_VALUE = new RegExp(`^(?:${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}\\s+)*(?<size>${VAR})(?<lineHeight>\\s*\\/\\s*(?:${VAR}|[\\w.%-]+))?(?:\\s+(?<families>${FONT_FAMILIES}))?$`, "i");
75
+ var FONT_SIZE_RULE = "checks/font-size-from-tokens";
76
+ function varNames(value) {
77
+ return [...value.matchAll(VAR_NAMES)].map(([, name]) => name).filter((name) => name !== undefined);
78
+ }
79
+ function sizePosition(families) {
80
+ const position = valueParser(families).nodes.find(({ type, value }) => type !== "space" && !(type === "word" && SIZE_PRECEDER.test(value)));
81
+ return position === undefined ? "" : valueParser.stringify(position);
82
+ }
83
+ function fontSizePosition(font) {
84
+ return font.lineHeight === undefined ? sizePosition(font.families ?? "") : "";
85
+ }
86
+ function namedSize(position) {
87
+ return NAMED_FONT_SIZES.has(position.toLowerCase()) ? position : undefined;
88
+ }
89
+ function sizeCarriers(property, value) {
90
+ const name = property.toLowerCase();
91
+ if (name === "font-size" && FONT_SIZE_VALUE.test(value))
92
+ return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE, atSize: false }));
93
+ const font = name === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
94
+ if (font?.size === undefined)
95
+ return [];
96
+ const atSize = varNames(fontSizePosition(font));
97
+ return [
98
+ ...varNames(font.size).map((token) => ({ token, setTo: FONT_SIZE_VALUE, atSize: false })),
99
+ ...varNames(font.families ?? "").map((token) => ({ token, setTo: FONT_FAMILY_VALUE, atSize: atSize.includes(token) }))
100
+ ];
101
+ }
102
+ function literalSize(property, value) {
103
+ const font = property.toLowerCase() === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
104
+ return font?.size === undefined ? undefined : namedSize(fontSizePosition(font));
105
+ }
106
+ function fromTokens({ token, setTo, atSize }, declared, seen) {
107
+ const values = declared.get(token);
108
+ if (values === undefined)
109
+ return true;
110
+ return values.every((value) => {
111
+ const position = atSize ? sizePosition(value) : "";
112
+ return setTo.test(value) && namedSize(position) === undefined && varNames(value).every((alias) => !seen.has(alias) && fromTokens({ token: alias, setTo, atSize: varNames(position).includes(alias) }, declared, new Set([...seen, alias])));
113
+ });
114
+ }
115
+ var declaredByFile = new WeakMap;
116
+ function fileDeclarations(document) {
117
+ const cached = declaredByFile.get(document);
118
+ if (cached !== undefined)
119
+ return cached;
120
+ const fresh = new Map;
121
+ document.walkDecls(/^--/, ({ prop, value }) => {
122
+ fresh.set(prop, [...fresh.get(prop) ?? [], value]);
123
+ });
124
+ declaredByFile.set(document, fresh);
125
+ return fresh;
126
+ }
127
+ var fontSizeFromTokens = () => async (root, result) => {
128
+ const { utils } = (await import("stylelint")).default;
129
+ const declared = fileDeclarations(result.root);
130
+ root.walkDecls((node) => {
131
+ const literal = literalSize(node.prop, node.value);
132
+ if (literal !== undefined) {
133
+ utils.report({ ruleName: FONT_SIZE_RULE, result, node, word: literal, message: `Expected the font size from the token files rather than the literal "${literal}"` });
134
+ }
135
+ for (const carrier of sizeCarriers(node.prop, node.value)) {
136
+ if (fromTokens(carrier, declared, new Set([carrier.token])))
137
+ continue;
138
+ utils.report({ ruleName: FONT_SIZE_RULE, result, node, word: carrier.token, message: `Expected "${carrier.token}" from the token files rather than set in this file` });
139
+ }
140
+ });
141
+ };
142
+ var FONT_SIZE_PLUGIN = {
143
+ ruleName: FONT_SIZE_RULE,
144
+ rule: Object.assign(fontSizeFromTokens, { ruleName: FONT_SIZE_RULE, messages: {} })
145
+ };
52
146
  var DESIGN_TOKENS = {
53
147
  "color-no-hex": true,
54
148
  "color-named": "never",
55
149
  "function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
56
- "declaration-property-value-allowed-list": { "/^font-size$/i": ["/^var\\(\\s*--[^\\s,()]+\\s*\\)$/i", "/^inherit$/i"] }
150
+ "declaration-property-value-allowed-list": {
151
+ "/^font-size$/i": [String(FONT_SIZE_VALUE), "/^inherit$/i"],
152
+ "/^font$/i": [String(FONT_VALUE), "/^inherit$/i"]
153
+ },
154
+ [FONT_SIZE_RULE]: true
57
155
  };
58
156
  var REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
59
157
  var REFUSED_TOKENS = "@avi2dg/checks/stylelint: set tokens to the globs of the token files, or false to leave the design token rules off";
@@ -112,7 +210,7 @@ function defineConfig({ baseline, tokens, bem, flatSelectors, noPageHas, rules,
112
210
  return {
113
211
  ...rest,
114
212
  extends: [peer("stylelint-config-standard")],
115
- plugins: [peer("stylelint-plugin-use-baseline")],
213
+ plugins: [peer("stylelint-plugin-use-baseline"), FONT_SIZE_PLUGIN],
116
214
  rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
117
215
  overrides: [
118
216
  { files: [...MARKUP], customSyntax: peer("postcss-html") },
package/docs/design.md CHANGED
@@ -113,7 +113,8 @@ The `.ts` bins keep a `bun` shebang and need no build step, unlike the oxlint pl
113
113
 
114
114
  The bins are written in Effect.
115
115
  So `effect` is a peer dependency, and `@effect/platform-bun`, which only the bins use, is a dependency.
116
- `@effect/platform-node-shared` is a direct dependency only to pin its version.
116
+ The Stryker test order helper runs under Node, so it takes its services from `@effect/platform-node-shared`, which is a direct dependency.
117
+ That dependency also pins its version.
117
118
  `@effect/platform-bun` asks for it with a `^` range, so a newer minor of it could resolve and peer on a newer `effect` than the exact version consumers install.
118
119
  So the three packages move together at one exact version.
119
120
 
@@ -305,10 +306,10 @@ A browser check needs a built site and a browser, and a backend repository has n
305
306
  So a product opts in by declaring `browser-checks.json`, `checks-lint` never starts a browser, and the browser libraries are optional peers a backend never installs.
306
307
  `checks-frontend-syntax` needs no browser and runs inside `checks-lint`, but only where `frontend-syntax.json` declares its inputs, since a repository with no interface has no CSS to hold to it.
307
308
 
308
- Each check wraps a detector that already works rather than a rule of the kit's own.
309
+ Each check wraps a detector that already works, and the kit writes a rule of its own only where none decides the constraint.
309
310
  The syntax gate runs two of stylelint's built-in rules and the stylelint team's shared configs, `axe` runs axe-core and `nesting` runs one html-validate rule.
310
311
  Its Baseline family runs `stylelint-plugin-use-baseline`, the one stylelint rule that judges against the Baseline data rather than a browser list.
311
- Its design token family is stylelint's own colour and value rules, applied outside the files a repository names as its tokens.
312
+ Its design token family is stylelint's own colour and value rules and the kit's `checks/font-size-from-tokens`, applied outside the files a repository names as its tokens.
312
313
  A check of the rendered page against declared tokens would need a token format of the kit's own.
313
314
  The repository's `stylelint.config.ts` sets the Baseline target and the token files, since only the repository knows either.
314
315
  The gate imports that file itself, because stylelint's config loader calls `typescript.findConfigFile`, which TypeScript 7 lacks, and a JavaScript config is what [checks-no-plain-js](gates/checks-no-plain-js.md) refuses.
@@ -24,7 +24,9 @@ It also refuses a workflow that does not trigger on both `opened` and `synchroni
24
24
  A path filter cannot cover every pull request and therefore cannot satisfy the check.
25
25
  It refuses a reference to a workflow or job the repository does not hold.
26
26
  Each literal `checks-release-pr` and `checks-release-tag` argument names a workflow file, so every one must match a file under `.github/workflows/`.
27
+ A call counts directly, through `bunx`, or through `bun` or `bun run` with the bin's path or its source file.
27
28
  An argument built from a variable or an expression is known only when the job runs, so the check passes over it.
29
+ An argument built by command substitution is known only when the job runs, so the check passes over it.
28
30
  Each `on.workflow_run.workflows` entry names a workflow by its `name`, so every one must match a name in the repository.
29
31
  A workflow without a `name` answers to its path from the repository root.
30
32
  A local reusable `uses` must name a file under `.github/workflows/` that the repository holds.
@@ -15,8 +15,10 @@ A run that fails with no failing test, such as a test file that throws while loa
15
15
 
16
16
  ## Parallel hunts
17
17
 
18
- A repository whose `test` script passes `--parallel=<workers>` to [checks-test](checks-test.md#parallel-runs) is hunted with the same worker count, so each run is `bun test --randomize --seed=<seed> --parallel=<workers>`, the mode CI runs in.
19
- A `test` script without the option keeps the serial hunt.
18
+ A repository whose `test` script runs [checks-test](checks-test.md#parallel-runs) is hunted with the worker count `checks-test` runs on.
19
+ That is the `--parallel=<workers>` the script passes, or else one worker per core of the machine.
20
+ So each run is `bun test --randomize --seed=<seed> --parallel=<workers>`, the mode CI runs in.
21
+ A `test` script that does not run `checks-test` gets the serial hunt.
20
22
 
21
23
  Under workers a seed fixes the order Bun hands out the files.
22
24
  Which worker takes each file is a race between the workers.
@@ -42,7 +44,8 @@ A run whose worker crashed gets a replay of that worker's files up to the crashe
42
44
  ## What it reads
43
45
 
44
46
  It reads bun's JUnit report of each run.
45
- It reads the worker count from the `test` script in `package.json`, as [checks-test-layout](checks-test-layout.md) validates that script, and hunts serially when the file is missing.
47
+ It reads the worker count from the `test` script in `package.json`, as [checks-test-layout](checks-test-layout.md) validates that script.
48
+ It hunts serially when the file is missing or its `test` script does not run `checks-test`.
46
49
  Under workers it reads the record its preload writes of which worker ran each file.
47
50
  For each replay it reads the order Bun ran its placeholder files.
48
51
  Under GitHub Actions it appends its summary to the file `GITHUB_STEP_SUMMARY` names, which is the job summary.
@@ -37,7 +37,7 @@ A control that needs selection off sets it on its own selector, such as `.drag-h
37
37
  | G1 correctness | `stylelint-config-recommended`, the stylelint team's rules for likely mistakes, such as `font-variant-numeric: oldstyle-num` for `oldstyle-nums` | on |
38
38
  | G2 conventions | `stylelint-config-standard`, which extends G1 with the stylelint team's notation and spacing rules | on |
39
39
  | G3 selector complexity | `selector-max-specificity` at `0,3,2` and `selector-max-compound-selectors` at 3 | on |
40
- | G4 design tokens | `color-no-hex`, `color-named: "never"`, `function-disallowed-list` over `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch` and `color`, and `font-size` only as one `var(--…)` with no fallback, or `inherit` | off until `tokens` names the token files, and off in those files |
40
+ | G4 design tokens | `color-no-hex`, `color-named: "never"`, `function-disallowed-list` over `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch` and `color`, `font-size` only as one `var(--…)` with no fallback, or `inherit`, `font` only as `inherit`, or as such a `var(--…)` for its size after any literal style, variant, weight or width, and before an optional line height and a family list of names, strings and `var(--…)`s, and `checks/font-size-from-tokens`, which refuses a `var(--…)` for a size or a family naming a custom property that any style block or attribute of the same file sets, unless the file sets it only to another such `var(--…)`, or for a family to a family list, and refuses a named size such as `small` or `math` at the one place the size can still stand, the first family word after any style, variant, weight or width while no line height fixes the `var(--…)` as the size, written there or set there by such a property | off until `tokens` names the token files, and off in those files |
41
41
  | G5 Baseline target | `plugin/use-baseline` from `stylelint-plugin-use-baseline`, which judges each property, value, function, at-rule, selector and unit against the Baseline data, and passes a property inside an `@supports` block. It passes `@property` at every target, since a browser without `@property` treats the declaration as an unregistered custom property | on, at the target `baseline` sets |
42
42
  | G6 comments refused | `comment-word-disallowed-list` over `/^[\s\S]*$/`, which refuses every comment, including an empty `/* */` | on |
43
43
  | G7 language-free selectors | `selector-disallowed-list` over `/[^\x00-\x7F]/`, which refuses any selector holding a non-ASCII character, in attribute values, class names and ids alike | on |
@@ -74,6 +74,7 @@ These are outside what it decides:
74
74
 
75
75
  - A `transition` shorthand that names no property, such as `transition: 200ms`, which the browser reads as `all`.
76
76
  - A value it cannot see, such as `transition: var(--motion)`, a style a script sets, or a colour a build writes into CSS outside the declared inputs.
77
+ - A font size custom property that one stylesheet outside the token files sets and another reads.
77
78
  - `user-select: none` in a `style` attribute on `<body>`, which has no selector to judge.
78
79
  - A viewport that disables zoom, since stylelint reads only CSS.
79
80
  A repository that runs only this gate gets no viewport check, and [checks-browser](checks-browser.md) refuses one on the rendered page through axe's `meta-viewport` rule.
@@ -133,7 +134,7 @@ A config not built with `defineConfig` names its own `customSyntax` for markup,
133
134
  The repository installs the optional peers the gate loads, at the versions the kit pins:
134
135
 
135
136
  ```sh
136
- bun add -d stylelint@17.16.0 postcss-html@2.0.0 stylelint-config-standard@40.0.0 stylelint-plugin-use-baseline@1.4.7
137
+ bun add -d stylelint@17.16.0 postcss-html@2.0.0 stylelint-config-standard@40.0.0 stylelint-plugin-use-baseline@1.4.7 postcss-value-parser@4.2.0
137
138
  ```
138
139
 
139
140
  ## Arguments
@@ -53,7 +53,11 @@ So a helper, fixture or preload changed on `main` since that baseline can hide a
53
53
  - The full baseline report that `checks-mutation-baseline --full` restores, for each mutant's `coveredBy` and `killedBy` and the `testFiles` that own those tests.
54
54
 
55
55
  Stryker leaves `coveredBy` empty on a static mutant, so only `killedBy` names its tests.
56
- The report must come from a run that restored no state and passed `--disableBail`, which lists every killing test.
56
+ The report must come from a run that restored no state, so each mutant's tests are the ones it ran against.
57
+ With bail, Stryker's default, `killedBy` names the one test that killed the mutant first.
58
+ One killer is enough, since a branch loses a kill through its tests only when it changes every test that made the kill.
59
+ A test left alone, whose sources the branch also leaves alone, still kills the mutant at `HEAD`.
60
+ So whichever killer the report names is a changed test, and a deleted test counts as changed.
57
61
 
58
62
  ## Arguments
59
63
 
@@ -25,7 +25,12 @@ The preset also registers an ignore plugin that checks the incremental report on
25
25
  A config that replaces `plugins` or `ignorers` must keep the preset's entries, or that check does not run.
26
26
  The preset also exports `unitTestFiles`, which lists every `.test.ts` and `.test.tsx` file under `tests/unit` at config load.
27
27
  A repository sets `bun.testFiles` to its result, so a covered, non-static mutant loads only the test files that cover it.
28
- A static mutant has no per-test coverage, so it still loads every listed file.
28
+ A static mutant has no per-test coverage, so it still loads every listed file, and with bail it stops at the first test that fails.
29
+ So when a `stryker run` without `--ignoreStatic` loads the preset, the preset first runs the listed files once with `bun test` and times each file.
30
+ `unitTestFiles` then lists each file with a leading `./`, fastest first, and Bun runs such paths in the order given.
31
+ It then lists each path again without the `./`, the spelling the runner matches a covering test against.
32
+ Bun loads a file once however often the list names it.
33
+ When that timing run fails, the preset prints why on stderr and `unitTestFiles` keeps the path order.
29
34
  The config then reads:
30
35
 
31
36
  ```js
@@ -96,7 +101,7 @@ jobs:
96
101
  with:
97
102
  bun-version-file: .bun-version
98
103
  - run: bun install --frozen-lockfile
99
- - run: bunx stryker run --disableBail
104
+ - run: bunx stryker run
100
105
  - run: bunx checks-mutation-baseline --slim reports/mutation/mutation.json "$RUNNER_TEMP/full/mutation.json"
101
106
  - uses: actions/upload-artifact@v4
102
107
  with:
@@ -105,7 +110,8 @@ jobs:
105
110
  retention-days: 30
106
111
  ```
107
112
 
108
- The run restores no state and passes `--disableBail`, so its report names every test that covers or kills each mutant.
113
+ The run restores no state, so its report names every test that covers each covered mutant and the first test that killed each killed mutant.
114
+ [checks-mutation-scope](checks-mutation-scope.md#what-it-reads) says why that one killer is enough.
109
115
  The upload is the `mutation-baseline-full` artifact that a branch comparison's scope step restores with `checks-mutation-baseline --full`.
110
116
  [checks-mutation-scope](checks-mutation-scope.md) reads it to bring in the sources a changed unit test covers or kills.
111
117
  The restore reads only a successful run of `mutation.yml` on `main`, so a sweep that fails publishes nothing a scope step reads.
@@ -44,8 +44,10 @@ A run on any ref but `refs/heads/main` files no issue and records no score, and
44
44
  ## What it reads
45
45
 
46
46
  It reads tonight's Stryker `mutation.json` report, when the mutation run left one, and any number of JUnit reports, such as the ones `checks-test --junit=<file>` keeps, as [checks-test](checks-test.md#arguments) says.
47
- It reads the previous night from the `night-run` artifact of the newest successful run of the same workflow on `main`.
48
- That artifact holds `night-run.json`, the scores the run recorded, and a newest run without it counts as no previous night.
47
+ It scans the newest 30 successful runs of the same workflow on `main` from newest to oldest.
48
+ It takes the first run whose `night-run` artifact still exists as the previous night.
49
+ That artifact holds `night-run.json`, the scores the run recorded.
50
+ When no scanned run holds the artifact it compares no score.
49
51
  A run on another branch never becomes the previous night.
50
52
  On `main` with a mutation report it writes tonight's scores to `reports/night-run.json`, and the workflow uploads that file as the `night-run` artifact.
51
53
  It reads the run from `GITHUB_SERVER_URL`, `GITHUB_REPOSITORY`, `GITHUB_RUN_ID`, `GITHUB_WORKFLOW_REF` and `GITHUB_REF`, so it runs only inside a GitHub Actions job.
@@ -26,7 +26,7 @@ It fails unless the repository holds this shape, and names the file and the path
26
26
  It may not import `$`, `spawn`, `spawnSync`, `connect`, `serve` or `listen` from `bun`, may not touch `Bun.$` or `Bun.spawn`, and may not call `fetch`.
27
27
  A test at the `e2e`, `live` or `pixel` level may do all of it, under `tests/quarantine/` as well.
28
28
  Helpers in `tests/lib/**` answer to the same rule, since an in-process test reaches them.
29
- - `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize` as [checks-test](checks-test.md) says.
29
+ - `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize --parallel=<workers>` as [checks-test](checks-test.md#parallel-runs) says.
30
30
  - `scripts.lint` runs this check, itself or through `checks-lint` called by its bare bin name.
31
31
  - `bunfig.toml` carries every `[test]` key of the shipped preset with the same value.
32
32
  `[test].pathIgnorePatterns` is the preset's `["**/tests/quarantine/**", "**/tests/live/**", "**/tests/pixel/**", "repos/**"]`, which the check pins itself, so the kit's own repository, whose bunfig is the preset, cannot drift it either.
@@ -8,7 +8,7 @@ audience: consumers
8
8
 
9
9
  ## What it checks
10
10
 
11
- It runs the default suite with `bun test --randomize` and reads Bun's JUnit report from that run.
11
+ It runs the default suite with `bun test --randomize --parallel=<workers>`, as [Parallel runs](#parallel-runs) says, and reads Bun's JUnit report from that run.
12
12
  It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
13
13
  Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
14
14
  A test that `test.skip`, `test.skipIf`, `test.if`, `test.todo` or an enclosing `describe.skip` skips fails unless the test declares its reason.
@@ -76,7 +76,9 @@ Bun ignores both directories during the default run.
76
76
 
77
77
  ## Parallel runs
78
78
 
79
- A repository runs its test files in parallel by passing a worker count in its `test` script:
79
+ `checks-test` runs `bun test --randomize --parallel=<workers>`, so Bun hands each test file to one of that many worker processes.
80
+ Without `--parallel=<workers>`, the count is one worker per core, as `os.availableParallelism()` reports it, so a local run never oversubscribes the machine.
81
+ A repository sets its own count by passing `--parallel=<workers>` in its `test` script:
80
82
 
81
83
  ```json
82
84
  {
@@ -86,13 +88,12 @@ A repository runs its test files in parallel by passing a worker count in its `t
86
88
  }
87
89
  ```
88
90
 
89
- `checks-test --parallel=<workers>` runs `bun test --randomize --parallel=<workers>`, so Bun hands each test file to one of that many worker processes.
90
- Each file then runs in a fresh global, so a file cannot lean on state another file left behind.
91
- The count is a whole number from 1 up, and without the option the suite runs serially in one process.
91
+ The count is a whole number from 1 up.
92
+ Each file runs in a fresh global, so a file cannot lean on state another file left behind.
92
93
  Tests still run in random order, and `test.only` still fails the run.
93
- Bun merges every worker's results into the one JUnit report, so each skipped test and each todo is judged as in a serial run.
94
+ Bun merges every worker's results into the one JUnit report, so each skipped test and each todo is judged across the whole suite.
94
95
  A tier script takes the same option, as in `checks-test --tier=live --parallel=4`.
95
- [checks-flake](checks-flake.md#parallel-hunts) hunts with the worker count the `test` script sets.
96
+ [checks-flake](checks-flake.md#parallel-hunts) hunts with the same worker count.
96
97
 
97
98
  A file whose worker crashes, for example when a test calls `process.exit`, fails the run.
98
99
  Bun reports no result from that file, so its skips are not judged and its declarations are not reported as stale.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -180,7 +180,7 @@
180
180
  },
181
181
  "scripts": {
182
182
  "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",
183
- "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",
183
+ "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 src/testing/unit-test-order.ts --outdir dist/mutation --target node --format esm --packages external && 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 --packages external && 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",
184
184
  "lint": "oxlint && bun src/core/lint.ts",
185
185
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
186
186
  "test": "bun src/testing/test.ts",
@@ -202,6 +202,7 @@
202
202
  "oxlint-tsgolint": "7.0.2002",
203
203
  "playwright-core": "1.63.0",
204
204
  "postcss-html": "2.0.0",
205
+ "postcss-value-parser": "4.2.0",
205
206
  "stylelint": "17.16.0",
206
207
  "stylelint-config-standard": "40.0.0",
207
208
  "stylelint-plugin-use-baseline": "1.4.7",
@@ -220,6 +221,9 @@
220
221
  "postcss-html": {
221
222
  "optional": true
222
223
  },
224
+ "postcss-value-parser": {
225
+ "optional": true
226
+ },
223
227
  "stylelint": {
224
228
  "optional": true
225
229
  },
@@ -247,6 +251,7 @@
247
251
  "oxlint-tsgolint": "7.0.2002",
248
252
  "playwright-core": "1.63.0",
249
253
  "postcss-html": "2.0.0",
254
+ "postcss-value-parser": "4.2.0",
250
255
  "stylelint": "17.16.0",
251
256
  "stylelint-config-standard": "40.0.0",
252
257
  "stylelint-plugin-use-baseline": "1.4.7",
@@ -64,7 +64,9 @@ export function plainCommand(script: string): Command | undefined {
64
64
  return words.length === 0 ? undefined : words;
65
65
  }
66
66
 
67
- type ShellPart = Span & { readonly substitutions: readonly string[] };
67
+ export type Word = { readonly text: string; readonly start: number };
68
+
69
+ type ShellPart = Span & { readonly substitutions: readonly Word[] };
68
70
 
69
71
  const COMMAND_BREAKS = new Set(["\n", ";", "&", "|", "(", ")", "`"]);
70
72
  const WORD_BREAKS = new Set([" ", "\t"]);
@@ -87,8 +89,11 @@ const LAUNCHER_OPERANDS = new Map<string, ReadonlySet<string>>([
87
89
 
88
90
  function substitutionEnd(script: string, start: number): number {
89
91
  if (script.charAt(start) === "`") {
90
- const close = script.indexOf("`", start + 1);
91
- return close === -1 ? script.length : close;
92
+ for (let index = start + 1; index < script.length; index += 1) {
93
+ if (script.charAt(index) === "\\") index += 1;
94
+ else if (script.charAt(index) === "`") return index;
95
+ }
96
+ return script.length;
92
97
  }
93
98
  let depth = 0;
94
99
  for (let index = start + 1; index < script.length; index += 1) {
@@ -106,12 +111,14 @@ function doubleQuotedPart(script: string, index: number): ShellPart {
106
111
  if (char === "\\" && DOUBLE_QUOTE_ESCAPES.has(next)) return { text: next, end: index + 2, substitutions: [] };
107
112
  if (char !== "`" && !(char === "$" && next === "(")) return { text: char, end: index + 1, substitutions: [] };
108
113
  const end = substitutionEnd(script, index);
109
- return { text: "", end: end + 1, substitutions: [script.slice(index + (char === "`" ? 1 : 2), end)] };
114
+ const start = index + (char === "`" ? 1 : 2);
115
+ // An unresolved expansion never becomes an empty literal.
116
+ return { text: "$", end: end + 1, substitutions: [{ text: script.slice(start, end), start }] };
110
117
  }
111
118
 
112
119
  function shellDoubleQuoted(script: string, open: number): ShellPart {
113
120
  let text = "";
114
- const substitutions: string[] = [];
121
+ const substitutions: Word[] = [];
115
122
  let index = open + 1;
116
123
  while (index < script.length && script.charAt(index) !== '"') {
117
124
  const part = doubleQuotedPart(script, index);
@@ -142,31 +149,32 @@ function commentEnd(script: string, from: number): number {
142
149
  export const SHELLS = ["sh", "bash", "dash", "ksh", "zsh"];
143
150
 
144
151
  type Heredoc = {
145
- readonly consumer: Command;
152
+ readonly consumer: readonly Word[];
146
153
  readonly delimiter: string;
147
154
  readonly tabs: boolean;
148
155
  readonly literal: boolean;
149
156
  };
150
157
 
151
158
  type ShellParse = {
152
- readonly commands: string[][];
153
- readonly substitutions: string[];
159
+ readonly commands: (readonly Word[])[];
160
+ readonly substitutions: Word[];
154
161
  readonly heredocs: Heredoc[];
155
- words: string[];
162
+ words: Word[];
156
163
  word: string | undefined;
164
+ wordStart: number;
157
165
  redirecting: boolean;
158
166
  };
159
167
 
160
168
  function endWord(parse: ShellParse): void {
161
169
  if (parse.word === undefined) return;
162
- if (!parse.redirecting) parse.words.push(parse.word);
170
+ if (!parse.redirecting) parse.words.push({ text: parse.word, start: parse.wordStart });
163
171
  parse.redirecting = false;
164
172
  parse.word = undefined;
165
173
  }
166
174
 
167
175
  function endCommand(parse: ShellParse): void {
168
176
  endWord(parse);
169
- parse.commands.push(parse.words);
177
+ parse.commands.push(...parse.substitutions.splice(0).flatMap(nested), parse.words);
170
178
  parse.words = [];
171
179
  parse.redirecting = false;
172
180
  }
@@ -206,8 +214,8 @@ function heredocBody(script: string, from: number, { delimiter, tabs }: Heredoc)
206
214
  return { text: script.slice(from), end: script.length };
207
215
  }
208
216
 
209
- function bodySubstitutions(body: string): readonly string[] {
210
- const substitutions: string[] = [];
217
+ function bodySubstitutions(body: string): readonly Word[] {
218
+ const substitutions: Word[] = [];
211
219
  let index = 0;
212
220
  while (index < body.length) {
213
221
  const part = doubleQuotedPart(body, index);
@@ -221,16 +229,41 @@ function skipHeredocs(parse: ShellParse, script: string, from: number): number {
221
229
  let index = from;
222
230
  for (const heredoc of parse.heredocs.splice(0)) {
223
231
  const body = heredocBody(script, index, heredoc);
224
- const [program = ""] = fromProgram(heredoc.consumer);
225
- if (SHELLS.includes(program.slice(program.lastIndexOf("/") + 1))) parse.substitutions.push(body.text);
226
- else if (!heredoc.literal) parse.substitutions.push(...bodySubstitutions(body.text));
232
+ const [program = ""] = fromProgram(texts(heredoc.consumer));
233
+ if (SHELLS.includes(program.slice(program.lastIndexOf("/") + 1))) parse.substitutions.push({ text: body.text, start: index });
234
+ else if (!heredoc.literal) parse.substitutions.push(...bodySubstitutions(body.text).map(({ text, start }) => ({ text, start: start + index })));
227
235
  index = body.end;
228
236
  }
229
237
  return index;
230
238
  }
231
239
 
232
- export function shellCommands(script: string): readonly Command[] {
233
- const parse: ShellParse = { commands: [], substitutions: [], heredocs: [], words: [], word: undefined, redirecting: false };
240
+ function extendWord(parse: ShellParse, script: string, index: number): number {
241
+ const part = shellPart(script, index);
242
+ if (parse.word === undefined) parse.wordStart = index;
243
+ parse.word = (parse.word ?? "") + part.text;
244
+ parse.substitutions.push(...part.substitutions);
245
+ return part.end;
246
+ }
247
+
248
+ function extendBacktick(parse: ShellParse, script: string, index: number): number {
249
+ const end = substitutionEnd(script, index);
250
+ if (parse.word === undefined) parse.wordStart = index;
251
+ parse.word = (parse.word ?? "") + "$";
252
+ parse.substitutions.push({ text: script.slice(index + 1, end), start: index + 1 });
253
+ return end + 1;
254
+ }
255
+
256
+ function texts(words: readonly Word[]): Command {
257
+ return words.map(({ text }) => text);
258
+ }
259
+
260
+ function nested({ text, start }: Word): readonly (readonly Word[])[] {
261
+ return shellWords(text).map((words) => words.map((word) => ({ text: word.text, start: word.start + start })));
262
+ }
263
+
264
+ // Each word starts at its offset in the script.
265
+ export function shellWords(script: string): readonly (readonly Word[])[] {
266
+ const parse: ShellParse = { commands: [], substitutions: [], heredocs: [], words: [], word: undefined, wordStart: 0, redirecting: false };
234
267
  let index = 0;
235
268
  while (index < script.length) {
236
269
  const char = script.charAt(index);
@@ -240,6 +273,8 @@ export function shellCommands(script: string): readonly Command[] {
240
273
  index = commentEnd(script, index);
241
274
  } else if (REDIRECTS.has(char)) {
242
275
  index = startRedirect(parse, script, index);
276
+ } else if (char === "`") {
277
+ index = extendBacktick(parse, script, index);
243
278
  } else if (COMMAND_BREAKS.has(char)) {
244
279
  endCommand(parse);
245
280
  index = char === "\n" ? skipHeredocs(parse, script, index + 1) : index + 1;
@@ -247,14 +282,15 @@ export function shellCommands(script: string): readonly Command[] {
247
282
  endWord(parse);
248
283
  index += 1;
249
284
  } else {
250
- const part = shellPart(script, index);
251
- parse.word = (parse.word ?? "") + part.text;
252
- parse.substitutions.push(...part.substitutions);
253
- index = part.end;
285
+ index = extendWord(parse, script, index);
254
286
  }
255
287
  }
256
288
  endCommand(parse);
257
- return [...parse.commands, ...parse.substitutions.flatMap(shellCommands)].filter((command) => command.length > 0);
289
+ return parse.commands.filter((command) => command.length > 0);
290
+ }
291
+
292
+ export function shellCommands(script: string): readonly Command[] {
293
+ return shellWords(script).map(texts);
258
294
  }
259
295
 
260
296
  export function withoutOptions(words: Command, operands: ReadonlySet<string>): Command {
@@ -1,4 +1,4 @@
1
- import { fromProgram, shellCommands, type Command } from "./shell-command.ts";
1
+ import { fromProgram, shellWords, type Command } from "./shell-command.ts";
2
2
 
3
3
  export type Workflow = {
4
4
  readonly path: string;
@@ -28,6 +28,10 @@ export function names(value: unknown): readonly string[] | undefined {
28
28
 
29
29
  type Key = string | number;
30
30
 
31
+ type Target = { readonly name: string; readonly invocation: number };
32
+
33
+ type Piece = { readonly at: number; readonly text: string };
34
+
31
35
  type Locator = (keys: readonly Key[], needle: string, fallback: string) => string;
32
36
 
33
37
  // Lines from..to hold the node, and its child keys start at column.
@@ -45,18 +49,25 @@ function isPathTo(word: string, files: readonly string[]): boolean {
45
49
  }
46
50
 
47
51
  function releaseArguments(program: Command): readonly string[] {
48
- const [head, target] = program;
52
+ const [head, target, entry] = program;
49
53
  if (head !== undefined && (RELEASE_BINS.includes(head) || isPathTo(head, RELEASE_BINS))) return program.slice(1);
54
+ if (head === "bun" && target === "run" && entry !== undefined && (RELEASE_SOURCES.includes(entry) || isPathTo(entry, [...RELEASE_SOURCES, ...RELEASE_BINS]))) {
55
+ return program.slice(3);
56
+ }
50
57
  if (head === "bun" && target !== undefined && (RELEASE_SOURCES.includes(target) || isPathTo(target, [...RELEASE_SOURCES, ...RELEASE_BINS]))) {
51
58
  return program.slice(2);
52
59
  }
53
60
  return [];
54
61
  }
55
62
 
56
- function literalTargets(run: string): readonly string[] {
57
- return shellCommands(run.replace(EXPRESSION, "$EXPRESSION"))
58
- .flatMap((command) => releaseArguments(fromProgram(command)))
59
- .filter((target) => !target.includes("$"));
63
+ // The stand-in for an expression keeps its length, so each invocation starts at its offset in run.
64
+ function literalTargets(run: string): readonly Target[] {
65
+ return shellWords(run.replace(EXPRESSION, (expression) => `$${"_".repeat(expression.length - 1)}`)).flatMap((words) => {
66
+ const invocation = words[0]?.start ?? 0;
67
+ return releaseArguments(fromProgram(words.map(({ text }) => text)))
68
+ .filter((name) => !name.includes("$"))
69
+ .map((name) => ({ name, invocation }));
70
+ });
60
71
  }
61
72
 
62
73
  function escapeRegExp(text: string): string {
@@ -113,6 +124,102 @@ function nodeAt(lines: readonly string[], keys: readonly Key[]): Node | undefine
113
124
  );
114
125
  }
115
126
 
127
+ const BLOCK_HEADER = /^[|>][1-9+-]*\s*(#.*)?$/;
128
+ const PLAIN_COMMENT = /(^|\s)#/;
129
+ const ESCAPES = new Map([
130
+ ["0", "\0"], ["a", "\x07"], ["b", "\b"], ["t", "\t"], ["\t", "\t"], ["n", "\n"], ["v", "\v"], ["f", "\f"], ["r", "\r"], ["e", "\x1b"],
131
+ [" ", " "], ['"', '"'], ["/", "/"], ["\\", "\\"], ["N", "\x85"], ["_", "\xa0"], ["L", "\u2028"], ["P", "\u2029"],
132
+ ]);
133
+ const HEX_ESCAPES = new Map([["x", 2], ["u", 4], ["U", 8]]);
134
+
135
+ function rawPieces(lines: readonly string[], node: Node): readonly Piece[] {
136
+ const head = lines[node.from] ?? "";
137
+ const keyColumn = /^\s*(?:-\s+)?/.exec(head)?.[0].length ?? 0;
138
+ return [
139
+ { at: node.from, text: head.slice(head.indexOf(":") + 1).trim() },
140
+ ...lines.slice(node.from + 1, node.to).map((line, index) => ({ at: node.from + 1 + index, text: indentOf(line) > keyColumn ? line.trim() : "" })),
141
+ ];
142
+ }
143
+
144
+ function plainPieces(pieces: readonly Piece[]): readonly Piece[] {
145
+ const decoded: Piece[] = [];
146
+ for (const { at, text } of pieces) {
147
+ const comment = PLAIN_COMMENT.exec(text);
148
+ const content = comment === null ? text : text.slice(0, comment.index).trimEnd();
149
+ decoded.push({ at, text: content });
150
+ if (comment !== null && decoded.some((piece) => piece.text !== "")) break;
151
+ }
152
+ return decoded;
153
+ }
154
+
155
+ function escaped(text: string, at: number): { readonly text: string; readonly end: number } {
156
+ const code = text.charAt(at + 1);
157
+ const width = HEX_ESCAPES.get(code);
158
+ if (width !== undefined) return { text: String.fromCodePoint(Number.parseInt(text.slice(at + 2, at + 2 + width), 16)), end: at + 2 + width };
159
+ return { text: ESCAPES.get(code) ?? code, end: at + 2 };
160
+ }
161
+
162
+ function quotedPieces(pieces: readonly Piece[], quote: "'" | '"'): readonly Piece[] {
163
+ const decoded: Piece[] = [];
164
+ for (const [position, { at, text }] of pieces.entries()) {
165
+ let content = "";
166
+ let index = position === 0 ? 1 : 0;
167
+ while (index < text.length) {
168
+ const char = text.charAt(index);
169
+ if (quote === "'" && char === "'" && text.charAt(index + 1) === "'") {
170
+ content += "'";
171
+ index += 2;
172
+ } else if (char === quote) {
173
+ return [...decoded, { at, text: content }];
174
+ } else if (quote === '"' && char === "\\") {
175
+ const escape = escaped(text, index);
176
+ content += escape.text;
177
+ index = escape.end;
178
+ } else {
179
+ content += char;
180
+ index += 1;
181
+ }
182
+ }
183
+ decoded.push({ at, text: content });
184
+ }
185
+ return decoded;
186
+ }
187
+
188
+ function quoteOf(text: string): "'" | '"' | undefined {
189
+ return text.startsWith("'") ? "'" : text.startsWith('"') ? '"' : undefined;
190
+ }
191
+
192
+ // Each piece holds what its physical line adds to the value, without the folding between lines.
193
+ function scalarPieces(lines: readonly string[], node: Node): readonly Piece[] {
194
+ const [head, ...body] = rawPieces(lines, node);
195
+ if (head === undefined) return [];
196
+ if (BLOCK_HEADER.test(head.text)) return body;
197
+ const open = quoteOf(head.text);
198
+ if (open !== undefined) return quotedPieces([head, ...body], open);
199
+ if (head.text !== "" && !head.text.startsWith("#")) return plainPieces([head, ...body]);
200
+ const first = body.findIndex((piece) => piece.text !== "" && !piece.text.startsWith("#"));
201
+ const token = first === -1 ? undefined : quoteOf(body[first]?.text ?? "");
202
+ return token === undefined ? plainPieces([head, ...body]) : quotedPieces(body.slice(first), token);
203
+ }
204
+
205
+ function scalarLine(lines: readonly string[], node: Node, value: string, offset: number): number {
206
+ let line = node.from;
207
+ let cursor = 0;
208
+ for (const { at, text } of scalarPieces(lines, node)) {
209
+ const start = text === "" ? -1 : value.indexOf(text, cursor);
210
+ if (start === -1) continue;
211
+ if (start > offset) break;
212
+ line = at;
213
+ cursor = start + text.length;
214
+ }
215
+ return line;
216
+ }
217
+
218
+ function releaseLocation(path: string, lines: readonly string[], keys: readonly Key[], run: string, target: Target, fallback: string): string {
219
+ const node = nodeAt(lines, keys);
220
+ return node === undefined ? `${path} ${fallback}` : `${path} line ${scalarLine(lines, node, run, target.invocation) + 1}`;
221
+ }
222
+
116
223
  export function locate(path: string, lines: readonly string[]): Locator {
117
224
  const used = new Map<string, number>();
118
225
  return (keys, needle, fallback) => {
@@ -144,7 +251,14 @@ function missingTarget(uses: string, paths: ReadonlySet<string>, at: Locator, ke
144
251
  : undefined;
145
252
  }
146
253
 
147
- function missingJobRefs(id: string, job: unknown, paths: ReadonlySet<string>, at: Locator): readonly MissingReference[] {
254
+ function missingJobRefs(
255
+ id: string,
256
+ job: unknown,
257
+ paths: ReadonlySet<string>,
258
+ at: Locator,
259
+ path: string,
260
+ lines: readonly string[],
261
+ ): readonly MissingReference[] {
148
262
  if (!isRecord(job)) return [];
149
263
  const missing: MissingReference[] = [];
150
264
  if (typeof job["uses"] === "string") {
@@ -162,8 +276,9 @@ function missingJobRefs(id: string, job: unknown, paths: ReadonlySet<string>, at
162
276
  }
163
277
  if (typeof step["run"] !== "string") return;
164
278
  for (const target of literalTargets(step["run"])) {
165
- if (!paths.has(`${WORKFLOWS}/${target}`)) {
166
- missing.push({ location: at(["jobs", id, "steps", index, "run"], target, fallback), fault: `names missing workflow ${target}` });
279
+ if (!paths.has(`${WORKFLOWS}/${target.name}`)) {
280
+ const location = releaseLocation(path, lines, ["jobs", id, "steps", index, "run"], step["run"], target, fallback);
281
+ missing.push({ location, fault: `names missing workflow ${target.name}` });
167
282
  }
168
283
  }
169
284
  });
@@ -176,11 +291,12 @@ export function findMissingWorkflows(workflows: readonly Workflow[]): readonly M
176
291
  const titles = new Set(workflows.map((workflow) => workflowTitle(workflow.document) ?? workflow.path));
177
292
  return workflows.flatMap((workflow) => {
178
293
  const missing: MissingReference[] = [];
179
- const at = locate(workflow.path, workflow.text.split("\n"));
294
+ const lines = workflow.text.split("\n");
295
+ const at = locate(workflow.path, lines);
180
296
  const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
181
297
  if (isRecord(jobs)) {
182
298
  missing.push(...missingNeeds(jobs, at));
183
- for (const [id, job] of Object.entries(jobs)) missing.push(...missingJobRefs(id, job, paths, at));
299
+ for (const [id, job] of Object.entries(jobs)) missing.push(...missingJobRefs(id, job, paths, at, workflow.path, lines));
184
300
  }
185
301
  const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
186
302
  const watched = isRecord(on) ? on["workflow_run"] : undefined;
@@ -1,5 +1,6 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import type { Config } from "stylelint";
2
+ import valueParser from "postcss-value-parser";
3
+ import type { Config, Plugin, RuleBase } from "stylelint";
3
4
 
4
5
  type Globs = readonly [string, ...string[]];
5
6
 
@@ -87,11 +88,142 @@ const HAS_IN_PSEUDO_CLASS = `(?::has\\(${PSEUDO_ARGUMENT}\\)|::?[\\w-]+\\(${PSEU
87
88
  // A compound of pseudo-classes alone, :scope and :not(.card) among them, can match the root element, so its :has() reads page state.
88
89
  const CLASSLESS_HAS = `/(?:^|[\\s>+~,])(?:${PSEUDO_CLASS})*${HAS_IN_PSEUDO_CLASS}(?:${PSEUDO_CLASS})*(?=[\\s>+~,]|$)/i`;
89
90
 
91
+ const VAR = "var\\(\\s*--[^\\s,()]+\\s*\\)";
92
+
93
+ const VAR_NAMES = /var\(\s*(--[^\s,()]+)\s*\)/gi;
94
+
95
+ const FONT_SIZE_VALUE = new RegExp(`^${VAR}$`, "i");
96
+
97
+ const FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH = "(?:normal|italic|oblique(?:\\s+-?[\\d.]+(?:deg|grad|rad|turn))?|small-caps|bold|bolder|lighter|[1-9]\\d*(?:\\.\\d+)?|(?:ultra-|extra-|semi-)?(?:condensed|expanded))";
98
+
99
+ const FONT_FAMILY = `(?:"[^"]*"|'[^']*'|${VAR}|-?[a-z_][\\w-]*(?:\\s+-?[a-z_][\\w-]*)*)`;
100
+
101
+ const FONT_FAMILIES = `${FONT_FAMILY}(?:\\s*,\\s*${FONT_FAMILY})*`;
102
+
103
+ const FONT_FAMILY_VALUE = new RegExp(`^${FONT_FAMILIES}$`, "i");
104
+
105
+ const NAMED_FONT_SIZES = new Set([
106
+ "xx-small",
107
+ "x-small",
108
+ "small",
109
+ "medium",
110
+ "large",
111
+ "x-large",
112
+ "xx-large",
113
+ "xxx-large",
114
+ "larger",
115
+ "smaller",
116
+ "math",
117
+ ]);
118
+
119
+ const SIZE_PRECEDER = new RegExp(`^${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}$`, "i");
120
+
121
+ const FONT_VALUE = new RegExp(
122
+ `^(?:${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}\\s+)*(?<size>${VAR})(?<lineHeight>\\s*\\/\\s*(?:${VAR}|[\\w.%-]+))?(?:\\s+(?<families>${FONT_FAMILIES}))?$`,
123
+ "i",
124
+ );
125
+
126
+ const FONT_SIZE_RULE = "checks/font-size-from-tokens";
127
+
128
+ function varNames(value: string): string[] {
129
+ return [...value.matchAll(VAR_NAMES)].map(([, name]) => name).filter((name) => name !== undefined);
130
+ }
131
+
132
+ type SizeCarrier = { readonly token: string; readonly setTo: RegExp; readonly atSize: boolean };
133
+
134
+ // The size follows any style, variant, weight and width, so the first other word or var() is the one place a family list can hold it.
135
+ function sizePosition(families: string): string {
136
+ const position = valueParser(families).nodes.find(({ type, value }) => type !== "space" && !(type === "word" && SIZE_PRECEDER.test(value)));
137
+ return position === undefined ? "" : valueParser.stringify(position);
138
+ }
139
+
140
+ // A line height fixes the var() before it as the size, which leaves the family list no size to hold.
141
+ function fontSizePosition(font: Readonly<Record<string, string | undefined>>): string {
142
+ return font.lineHeight === undefined ? sizePosition(font.families ?? "") : "";
143
+ }
144
+
145
+ function namedSize(position: string): string | undefined {
146
+ return NAMED_FONT_SIZES.has(position.toLowerCase()) ? position : undefined;
147
+ }
148
+
149
+ // A var() before the size var can be a weight, which leaves a family var() free to hold the size.
150
+ function sizeCarriers(property: string, value: string): SizeCarrier[] {
151
+ const name = property.toLowerCase();
152
+ if (name === "font-size" && FONT_SIZE_VALUE.test(value)) return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE, atSize: false }));
153
+ const font = name === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
154
+ if (font?.size === undefined) return [];
155
+ const atSize = varNames(fontSizePosition(font));
156
+ return [
157
+ ...varNames(font.size).map((token) => ({ token, setTo: FONT_SIZE_VALUE, atSize: false })),
158
+ ...varNames(font.families ?? "").map((token) => ({ token, setTo: FONT_FAMILY_VALUE, atSize: atSize.includes(token) })),
159
+ ];
160
+ }
161
+
162
+ function literalSize(property: string, value: string): string | undefined {
163
+ const font = property.toLowerCase() === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
164
+ return font?.size === undefined ? undefined : namedSize(fontSizePosition(font));
165
+ }
166
+
167
+ function fromTokens({ token, setTo, atSize }: SizeCarrier, declared: ReadonlyMap<string, readonly string[]>, seen: ReadonlySet<string>): boolean {
168
+ const values = declared.get(token);
169
+ if (values === undefined) return true;
170
+ return values.every((value) => {
171
+ const position = atSize ? sizePosition(value) : "";
172
+ return (
173
+ setTo.test(value) &&
174
+ namedSize(position) === undefined &&
175
+ varNames(value).every((alias) => !seen.has(alias) && fromTokens({ token: alias, setTo, atSize: varNames(position).includes(alias) }, declared, new Set([...seen, alias])))
176
+ );
177
+ });
178
+ }
179
+
180
+ const declaredByFile = new WeakMap<object, Map<string, string[]>>();
181
+
182
+ type DeclarationDocument = {
183
+ walkDecls(filter: RegExp, visit: (decl: { readonly prop: string; readonly value: string }) => void): void;
184
+ };
185
+
186
+ function fileDeclarations(document: DeclarationDocument): Map<string, string[]> {
187
+ const cached = declaredByFile.get(document);
188
+ if (cached !== undefined) return cached;
189
+ const fresh = new Map<string, string[]>();
190
+ document.walkDecls(/^--/, ({ prop, value }) => {
191
+ fresh.set(prop, [...(fresh.get(prop) ?? []), value]);
192
+ });
193
+ declaredByFile.set(document, fresh);
194
+ return fresh;
195
+ }
196
+
197
+ // postcss-html hands the rule one root per style block or attribute, so the file's declarations sit on the result's document.
198
+ const fontSizeFromTokens: RuleBase = () => async (root, result) => {
199
+ const { utils } = (await import("stylelint")).default;
200
+ const declared = fileDeclarations(result.root);
201
+ root.walkDecls((node) => {
202
+ const literal = literalSize(node.prop, node.value);
203
+ if (literal !== undefined) {
204
+ utils.report({ ruleName: FONT_SIZE_RULE, result, node, word: literal, message: `Expected the font size from the token files rather than the literal "${literal}"` });
205
+ }
206
+ for (const carrier of sizeCarriers(node.prop, node.value)) {
207
+ if (fromTokens(carrier, declared, new Set([carrier.token]))) continue;
208
+ utils.report({ ruleName: FONT_SIZE_RULE, result, node, word: carrier.token, message: `Expected "${carrier.token}" from the token files rather than set in this file` });
209
+ }
210
+ });
211
+ };
212
+
213
+ const FONT_SIZE_PLUGIN = {
214
+ ruleName: FONT_SIZE_RULE,
215
+ rule: Object.assign(fontSizeFromTokens, { ruleName: FONT_SIZE_RULE, messages: {} }),
216
+ } satisfies Plugin;
217
+
90
218
  const DESIGN_TOKENS = {
91
219
  "color-no-hex": true,
92
220
  "color-named": "never",
93
221
  "function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
94
- "declaration-property-value-allowed-list": { "/^font-size$/i": ["/^var\\(\\s*--[^\\s,()]+\\s*\\)$/i", "/^inherit$/i"] },
222
+ "declaration-property-value-allowed-list": {
223
+ "/^font-size$/i": [String(FONT_SIZE_VALUE), "/^inherit$/i"],
224
+ "/^font$/i": [String(FONT_VALUE), "/^inherit$/i"],
225
+ },
226
+ [FONT_SIZE_RULE]: true,
95
227
  } as const satisfies Rules;
96
228
 
97
229
  const REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
@@ -154,7 +286,7 @@ export function defineConfig({ baseline, tokens, bem, flatSelectors, noPageHas,
154
286
  return {
155
287
  ...rest,
156
288
  extends: [peer("stylelint-config-standard")],
157
- plugins: [peer("stylelint-plugin-use-baseline")],
289
+ plugins: [peer("stylelint-plugin-use-baseline"), FONT_SIZE_PLUGIN],
158
290
  rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
159
291
  overrides: [
160
292
  { files: [...MARKUP], customSyntax: peer("postcss-html") },
@@ -3,7 +3,7 @@ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } fro
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { runMain, Usage } from "../core/main.ts";
5
5
  import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
6
- import { scriptOptions } from "./test.ts";
6
+ import { scriptOptions, workerCount } from "./test.ts";
7
7
 
8
8
  export type Run = {
9
9
  readonly seed: number;
@@ -90,7 +90,8 @@ const workersOf = Effect.fn("workersOf")(function* (root: string) {
90
90
  Effect.flatMap(decodeManifest),
91
91
  Effect.mapError((cause) => new ManifestError({ message: `cannot read ${file}: ${cause.message}` })),
92
92
  );
93
- return scriptOptions(scripts?.["test"])?.workers;
93
+ const options = scriptOptions(scripts?.["test"]);
94
+ return options === undefined ? undefined : workerCount(options.workers);
94
95
  });
95
96
 
96
97
  const assignmentOf = Effect.fn("assignmentOf")(function* (log: string) {
@@ -196,7 +196,8 @@ export function preloadChange(
196
196
  return [...preloads.atBase, ...loaded].find((path) => changed.has(path));
197
197
  }
198
198
 
199
- // Stryker leaves coveredBy empty on a static mutant, so only killedBy names its tests, and all of them only from a run without bail.
199
+ // Stryker leaves coveredBy empty on a static mutant, so only killedBy names its tests.
200
+ // A kill is lost only when every test that made it changed, so the one killer a run with bail records is enough.
200
201
  export function coveringTests(baseline: Baseline, tests: ReadonlySet<string>, mutated: (file: string) => boolean): Map<string, readonly string[]> {
201
202
  const owners = new Map(Object.entries(baseline.testFiles).flatMap(([file, { tests: listed }]) => listed.map(({ id }) => [id, file] as const)));
202
203
  const covering = new Map<string, readonly string[]>();
@@ -175,24 +175,26 @@ const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NightRecor
175
175
 
176
176
  const unreadable = (source: string) => (cause: { readonly message: string }) => new NightRunFailure({ message: `cannot read ${source}: ${cause.message}` });
177
177
 
178
- // Only the newest successful run on main counts, so a branch dispatch never becomes the night a later run compares with.
178
+ // A skipped night records no artifact, so the newest successful run can carry no baseline.
179
179
  const previousNight = Effect.fn("previousNight")(
180
180
  function* ({ runs, workflow }: Actions) {
181
- const listed = ["run", "list", "--workflow", workflow, "--branch", DEFAULT_BRANCH, "--status", "success", "--limit", "1", "--json", "databaseId,event,createdAt"];
182
- const [newest] = yield* gh(listed).pipe(Effect.flatMap(decodeRuns), Effect.mapError(unreadable(`the runs of ${workflow}`)));
183
- if (newest === undefined) return Option.none<PreviousNight>();
184
- const run = newest.databaseId;
185
- const { artifacts } = yield* gh(["api", `${REPOSITORY}/actions/runs/${run}/artifacts?name=${ARTIFACT}`]).pipe(
186
- Effect.flatMap(decodeArtifacts),
187
- Effect.mapError(unreadable(`the artifacts of run ${run}`)),
188
- );
189
- if (!artifacts.some((artifact) => artifact.name === ARTIFACT && !artifact.expired)) return Option.none<PreviousNight>();
190
- const fs = yield* FileSystem.FileSystem;
191
- const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-night-run-" });
192
- yield* gh(["run", "download", String(run), "--name", ARTIFACT, "--dir", dir]);
193
- const file = (yield* Path.Path).join(dir, RECORD_NAME);
194
- const record = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRecord), Effect.mapError(unreadable(`the ${ARTIFACT} artifact of run ${run}`)));
195
- return Option.some<PreviousNight>({ url: `${runs}/${run}`, record });
181
+ const listed = ["run", "list", "--workflow", workflow, "--branch", DEFAULT_BRANCH, "--status", "success", "--limit", "30", "--json", "databaseId,event,createdAt"];
182
+ const candidates = yield* gh(listed).pipe(Effect.flatMap(decodeRuns), Effect.mapError(unreadable(`the runs of ${workflow}`)));
183
+ for (const candidate of candidates) {
184
+ const run = candidate.databaseId;
185
+ const { artifacts } = yield* gh(["api", `${REPOSITORY}/actions/runs/${run}/artifacts?name=${ARTIFACT}`]).pipe(
186
+ Effect.flatMap(decodeArtifacts),
187
+ Effect.mapError(unreadable(`the artifacts of run ${run}`)),
188
+ );
189
+ if (!artifacts.some((artifact) => artifact.name === ARTIFACT && !artifact.expired)) continue;
190
+ const fs = yield* FileSystem.FileSystem;
191
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-night-run-" });
192
+ yield* gh(["run", "download", String(run), "--name", ARTIFACT, "--dir", dir]);
193
+ const file = (yield* Path.Path).join(dir, RECORD_NAME);
194
+ const record = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRecord), Effect.mapError(unreadable(`the ${ARTIFACT} artifact of run ${run}`)));
195
+ return Option.some<PreviousNight>({ url: `${runs}/${run}`, record });
196
+ }
197
+ return Option.none<PreviousNight>();
196
198
  },
197
199
  Effect.scoped,
198
200
  );
@@ -42,6 +42,7 @@ const decodeCase = Schema.decodeUnknownEffect(
42
42
  Schema.Struct({ name: Schema.String, file: Schema.String, line: Schema.NumberFromString.check(Schema.isInt()) }),
43
43
  );
44
44
  const decodeCrash = Schema.decodeUnknownEffect(Schema.Struct({ file: Schema.String }));
45
+ const decodeTimed = Schema.decodeUnknownEffect(Schema.Struct({ file: Schema.String, time: Schema.FiniteFromString }));
45
46
 
46
47
  function unescape(text: string): string {
47
48
  return text.replace(ENTITY, (_, named: string | undefined, decimal: string | undefined, hex: string | undefined) => {
@@ -133,3 +134,17 @@ export const parseReport = Effect.fn("parseReport")(function* (xml: string) {
133
134
  }
134
135
  return { results, crashedFiles } satisfies Report;
135
136
  });
137
+
138
+ // Bun names a file's top-level <testsuite> by its path with no leading ./ whatever spelling ran it, and times the whole file there.
139
+ export const fileSeconds = Effect.fn("fileSeconds")(function* (xml: string) {
140
+ const root = yield* parseElements(xml);
141
+ const seconds = new Map<string, number>();
142
+ for (const fileSuite of root.children) {
143
+ if (fileSuite.tag !== "testsuite") continue;
144
+ const { file, time } = yield* decodeTimed(fileSuite.attributes).pipe(
145
+ Effect.mapError((cause) => new ReportError({ message: `the junit report holds a <testsuite> with no file or time: ${cause.message}` })),
146
+ );
147
+ seconds.set(file, time);
148
+ }
149
+ return seconds;
150
+ });
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { availableParallelism } from "node:os";
2
3
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
4
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
5
  import { TEST_ENTRY_POINT } from "../core/gates.ts";
@@ -164,10 +165,13 @@ function tierArgs(tier: TestTier | undefined): readonly string[] {
164
165
  return [`./tests/${tier}`];
165
166
  }
166
167
 
168
+ export function workerCount(workers: number | undefined): number {
169
+ return workers ?? availableParallelism();
170
+ }
171
+
167
172
  const runSuite = Effect.fn("runSuite")(function* (outfile: string, { tier, workers }: TestOptions) {
168
173
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
169
- const parallelArgs = workers === undefined ? [] : [`--parallel=${workers}`];
170
- const args = ["test", "--randomize", ...parallelArgs, ...tierArgs(tier), ...reporterArgs(outfile)];
174
+ const args = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...tierArgs(tier), ...reporterArgs(outfile)];
171
175
  return yield* spawner.exitCode(
172
176
  ChildProcess.make(process.execPath, args, {
173
177
  stdin: "ignore",
package/stryker.preset.js CHANGED
@@ -3,6 +3,7 @@ import { availableParallelism } from "node:os";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { GUARD_IGNORER } from "./dist/mutation/mutation-guard-plugin.js";
5
5
  import { fullRunRefusal } from "./dist/mutation/mutation-refusal.js";
6
+ import { ordersTests, runOrder } from "./dist/mutation/unit-test-order.js";
6
7
 
7
8
  const [, , command, ...args] = process.argv;
8
9
  const refusal = command === "run" ? fullRunRefusal(args, process.env.CI ?? "") : undefined;
@@ -11,12 +12,16 @@ if (refusal !== undefined) throw new Error(refusal);
11
12
  export const halfAvailableCores = (cores = availableParallelism()) => Math.max(1, Math.floor(cores / 2));
12
13
 
13
14
  // The bun runner narrows a mutant's run to its covering files only when each listed path equals a test id's file part, which carries no leading ./.
14
- export const unitTestFiles = () =>
15
+ const listedUnitTests = () =>
15
16
  readdirSync("tests/unit", { recursive: true, encoding: "utf8" })
16
17
  .filter((name) => name.endsWith(".test.ts") || name.endsWith(".test.tsx"))
17
18
  .map((name) => `tests/unit/${name}`)
18
19
  .toSorted();
19
20
 
21
+ const timedUnitTests = ordersTests(command, args) ? await runOrder(listedUnitTests()) : undefined;
22
+
23
+ export const unitTestFiles = () => timedUnitTests ?? listedUnitTests();
24
+
20
25
  export default {
21
26
  packageManager: "npm",
22
27
  plugins: [