@avi2dg/checks 0.48.1 → 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,20 @@
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
+
5
19
  ## 0.48.1
6
20
 
7
21
  Released 2026-10-20.
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) {
@@ -56,36 +57,81 @@ var FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH = "(?:normal|italic|oblique(?:\\s+-?[\\d.
56
57
  var FONT_FAMILY = `(?:"[^"]*"|'[^']*'|${VAR}|-?[a-z_][\\w-]*(?:\\s+-?[a-z_][\\w-]*)*)`;
57
58
  var FONT_FAMILIES = `${FONT_FAMILY}(?:\\s*,\\s*${FONT_FAMILY})*`;
58
59
  var FONT_FAMILY_VALUE = new RegExp(`^${FONT_FAMILIES}$`, "i");
59
- var FONT_VALUE = new RegExp(`^(?:${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}\\s+)*(?<size>${VAR})(?:\\s*\\/\\s*(?:${VAR}|[\\w.%-]+))?(?:\\s+(?<families>${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");
60
75
  var FONT_SIZE_RULE = "checks/font-size-from-tokens";
61
76
  function varNames(value) {
62
77
  return [...value.matchAll(VAR_NAMES)].map(([, name]) => name).filter((name) => name !== undefined);
63
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
+ }
64
89
  function sizeCarriers(property, value) {
65
90
  const name = property.toLowerCase();
66
91
  if (name === "font-size" && FONT_SIZE_VALUE.test(value))
67
- return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE }));
92
+ return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE, atSize: false }));
68
93
  const font = name === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
69
94
  if (font?.size === undefined)
70
95
  return [];
96
+ const atSize = varNames(fontSizePosition(font));
71
97
  return [
72
- ...varNames(font.size).map((token) => ({ token, setTo: FONT_SIZE_VALUE })),
73
- ...varNames(font.families ?? "").map((token) => ({ token, setTo: FONT_FAMILY_VALUE }))
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) }))
74
100
  ];
75
101
  }
76
- function fromTokens({ token, setTo }, declared, seen) {
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) {
77
107
  const values = declared.get(token);
78
108
  if (values === undefined)
79
109
  return true;
80
- return values.every((value) => setTo.test(value) && varNames(value).every((alias) => !seen.has(alias) && fromTokens({ token: alias, setTo }, declared, new Set([...seen, alias]))));
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;
81
126
  }
82
127
  var fontSizeFromTokens = () => async (root, result) => {
83
128
  const { utils } = (await import("stylelint")).default;
84
- const declared = new Map;
85
- result.root.walkDecls(/^--/, ({ prop, value }) => {
86
- declared.set(prop, [...declared.get(prop) ?? [], value]);
87
- });
129
+ const declared = fileDeclarations(result.root);
88
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
+ }
89
135
  for (const carrier of sizeCarriers(node.prop, node.value)) {
90
136
  if (fromTokens(carrier, declared, new Set([carrier.token])))
91
137
  continue;
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
 
@@ -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.
@@ -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`, `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 | 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 |
@@ -134,7 +134,7 @@ A config not built with `defineConfig` names its own `customSyntax` for markup,
134
134
  The repository installs the optional peers the gate loads, at the versions the kit pins:
135
135
 
136
136
  ```sh
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
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
138
138
  ```
139
139
 
140
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.48.1",
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 --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",
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,4 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import valueParser from "postcss-value-parser";
2
3
  import type { Config, Plugin, RuleBase } from "stylelint";
3
4
 
4
5
  type Globs = readonly [string, ...string[]];
@@ -101,9 +102,24 @@ const FONT_FAMILIES = `${FONT_FAMILY}(?:\\s*,\\s*${FONT_FAMILY})*`;
101
102
 
102
103
  const FONT_FAMILY_VALUE = new RegExp(`^${FONT_FAMILIES}$`, "i");
103
104
 
104
- // A family name or a token name can hold a size word, so the token is required in the size slot rather than size words refused anywhere.
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
+
105
121
  const FONT_VALUE = new RegExp(
106
- `^(?:${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}\\s+)*(?<size>${VAR})(?:\\s*\\/\\s*(?:${VAR}|[\\w.%-]+))?(?:\\s+(?<families>${FONT_FAMILIES}))?$`,
122
+ `^(?:${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}\\s+)*(?<size>${VAR})(?<lineHeight>\\s*\\/\\s*(?:${VAR}|[\\w.%-]+))?(?:\\s+(?<families>${FONT_FAMILIES}))?$`,
107
123
  "i",
108
124
  );
109
125
 
@@ -113,36 +129,80 @@ function varNames(value: string): string[] {
113
129
  return [...value.matchAll(VAR_NAMES)].map(([, name]) => name).filter((name) => name !== undefined);
114
130
  }
115
131
 
116
- type SizeCarrier = { readonly token: string; readonly setTo: RegExp };
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
+ }
117
148
 
118
149
  // A var() before the size var can be a weight, which leaves a family var() free to hold the size.
119
150
  function sizeCarriers(property: string, value: string): SizeCarrier[] {
120
151
  const name = property.toLowerCase();
121
- if (name === "font-size" && FONT_SIZE_VALUE.test(value)) return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE }));
152
+ if (name === "font-size" && FONT_SIZE_VALUE.test(value)) return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE, atSize: false }));
122
153
  const font = name === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
123
154
  if (font?.size === undefined) return [];
155
+ const atSize = varNames(fontSizePosition(font));
124
156
  return [
125
- ...varNames(font.size).map((token) => ({ token, setTo: FONT_SIZE_VALUE })),
126
- ...varNames(font.families ?? "").map((token) => ({ token, setTo: FONT_FAMILY_VALUE })),
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) })),
127
159
  ];
128
160
  }
129
161
 
130
- function fromTokens({ token, setTo }: SizeCarrier, declared: ReadonlyMap<string, readonly string[]>, seen: ReadonlySet<string>): boolean {
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 {
131
168
  const values = declared.get(token);
132
169
  if (values === undefined) return true;
133
- return values.every(
134
- (value) => setTo.test(value) && varNames(value).every((alias) => !seen.has(alias) && fromTokens({ token: alias, setTo }, declared, new Set([...seen, alias]))),
135
- );
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;
136
195
  }
137
196
 
138
197
  // postcss-html hands the rule one root per style block or attribute, so the file's declarations sit on the result's document.
139
198
  const fontSizeFromTokens: RuleBase = () => async (root, result) => {
140
199
  const { utils } = (await import("stylelint")).default;
141
- const declared = new Map<string, string[]>();
142
- result.root.walkDecls(/^--/, ({ prop, value }) => {
143
- declared.set(prop, [...(declared.get(prop) ?? []), value]);
144
- });
200
+ const declared = fileDeclarations(result.root);
145
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
+ }
146
206
  for (const carrier of sizeCarriers(node.prop, node.value)) {
147
207
  if (fromTokens(carrier, declared, new Set([carrier.token]))) continue;
148
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` });
@@ -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[]>();
@@ -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
+ });
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: [