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