@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 +14 -0
- package/README.md +1 -0
- package/dist/mutation/unit-test-order.js +159 -0
- package/dist/presets/stylelint.js +56 -10
- package/docs/design.md +2 -1
- package/docs/gates/checks-ci-wiring.md +2 -0
- package/docs/gates/checks-frontend-syntax.md +2 -2
- package/docs/gates/checks-mutation-scope.md +5 -1
- package/docs/gates/checks-mutation.md +9 -3
- package/package.json +7 -2
- package/src/delivery/shell-command.ts +59 -23
- package/src/delivery/workflow-references.ts +127 -11
- package/src/quality/presets/stylelint.ts +74 -14
- package/src/testing/mutation-scope.ts +2 -1
- package/src/testing/test-report.ts +15 -0
- package/stryker.preset.js +6 -1
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
|
|
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
|
|
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) =>
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
153
|
-
readonly substitutions:
|
|
159
|
+
readonly commands: (readonly Word[])[];
|
|
160
|
+
readonly substitutions: Word[];
|
|
154
161
|
readonly heredocs: Heredoc[];
|
|
155
|
-
words:
|
|
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
|
|
210
|
-
const substitutions:
|
|
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
|
-
|
|
233
|
-
const
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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})(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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: [
|