@avi2dg/checks 0.16.0 → 0.18.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 +22 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -1
- package/dependency-cruiser.config.js +8 -0
- package/dist/feature-rules.js +5 -4
- package/dist/index.js +426 -7
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/dependency-rules.md +1 -0
- package/docs/configs/effect-rules.md +1 -0
- package/docs/configs/quality-file.md +2 -2
- package/docs/configs/typescript-rules.md +36 -0
- package/docs/design.md +3 -1
- package/docs/gates/checks-comment-gate.md +2 -0
- package/docs/gates/checks-quality.md +23 -8
- package/docs/gates/checks-size-budget.md +4 -4
- package/oxlintrc.json +1 -0
- package/package.json +3 -1
- package/quality.schema.json +2 -2
- package/scripts/backtest.ts +57 -41
- package/scripts/ci-wiring.ts +2 -63
- package/scripts/comment-gate.ts +3 -4
- package/scripts/comment-matchers.ts +73 -58
- package/scripts/commit-identity.ts +3 -4
- package/scripts/doc-outline.ts +34 -16
- package/scripts/doc-rules.ts +34 -21
- package/scripts/docs.ts +3 -4
- package/scripts/feature-owners.ts +5 -8
- package/scripts/git.ts +31 -26
- package/scripts/prose-matchers.ts +93 -83
- package/scripts/quality.ts +157 -10
- package/scripts/range-gate.ts +9 -0
- package/scripts/shell-command.ts +74 -0
- package/scripts/size-budget.ts +18 -17
- package/scripts/size-rules.ts +13 -4
- package/scripts/suppressions-ratchet.ts +22 -24
- package/scripts/test-layout.ts +37 -35
- package/scripts/test-report.ts +18 -13
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type Command = readonly string[];
|
|
2
|
+
|
|
3
|
+
type Span = {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly end: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
|
|
9
|
+
const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
|
|
10
|
+
|
|
11
|
+
function variable(line: string, from: number): Span | undefined {
|
|
12
|
+
const match = VARIABLE.exec(line.slice(from));
|
|
13
|
+
return match === null ? undefined : { text: match[0], end: from + match[0].length };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function singleQuoted(line: string, open: number): Span | undefined {
|
|
17
|
+
const close = line.indexOf("'", open + 1);
|
|
18
|
+
return close === -1 ? undefined : { text: line.slice(open + 1, close), end: close + 1 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function doubleQuoted(line: string, open: number): Span | undefined {
|
|
22
|
+
let text = "";
|
|
23
|
+
let index = open + 1;
|
|
24
|
+
while (line.charAt(index) !== '"') {
|
|
25
|
+
const inner = line.charAt(index);
|
|
26
|
+
if (inner === "" || inner === "\\" || inner === "`") return undefined;
|
|
27
|
+
const part = inner === "$" ? variable(line, index) : { text: inner, end: index + 1 };
|
|
28
|
+
if (part === undefined) return undefined;
|
|
29
|
+
text += part.text;
|
|
30
|
+
index = part.end;
|
|
31
|
+
}
|
|
32
|
+
return { text, end: index + 1 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function wordPart(line: string, index: number): Span | undefined {
|
|
36
|
+
const char = line.charAt(index);
|
|
37
|
+
if (char === "'") return singleQuoted(line, index);
|
|
38
|
+
if (char === '"') return doubleQuoted(line, index);
|
|
39
|
+
if (char === "$") return variable(line, index);
|
|
40
|
+
return UNPLAIN.has(char) ? undefined : { text: char, end: index + 1 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A script counts only when it is one line of plain words: any shell control, redirection or
|
|
44
|
+
// substitution can run the gate without its failure failing the step.
|
|
45
|
+
export function plainCommand(script: string): Command | undefined {
|
|
46
|
+
const line = script.trim();
|
|
47
|
+
const words: string[] = [];
|
|
48
|
+
let word: string | undefined;
|
|
49
|
+
let index = 0;
|
|
50
|
+
while (index < line.length) {
|
|
51
|
+
const char = line.charAt(index);
|
|
52
|
+
if (char === " " || char === "\t") {
|
|
53
|
+
if (word !== undefined) words.push(word);
|
|
54
|
+
word = undefined;
|
|
55
|
+
index += 1;
|
|
56
|
+
} else {
|
|
57
|
+
const part = wordPart(line, index);
|
|
58
|
+
if (part === undefined) return undefined;
|
|
59
|
+
word = (word ?? "") + part.text;
|
|
60
|
+
index = part.end;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (word !== undefined) words.push(word);
|
|
64
|
+
return words.length === 0 ? undefined : words;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function invokes(command: Command | undefined, gate: Command): boolean {
|
|
68
|
+
return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function mentions(script: string, gate: Command): boolean {
|
|
72
|
+
const tokens = script.split(/[\s|&;<>()`]+/);
|
|
73
|
+
return tokens.some((_, start) => invokes(tokens.slice(start), gate));
|
|
74
|
+
}
|
package/scripts/size-budget.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { changedPaths, collect, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
4
|
-
import { runMain
|
|
5
|
-
import {
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
|
+
import { renderJson } from "./quality-file.ts";
|
|
6
|
+
import { rangeGateInputs } from "./range-gate.ts";
|
|
6
7
|
import {
|
|
7
8
|
budgetOf,
|
|
8
9
|
budgetsOf,
|
|
10
|
+
diagnosticCode,
|
|
11
|
+
qualifiedName,
|
|
9
12
|
SIZE_DEFAULTS,
|
|
10
13
|
SIZE_RULES,
|
|
11
14
|
TESTS_DIRECTORY,
|
|
@@ -68,16 +71,17 @@ const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Str
|
|
|
68
71
|
|
|
69
72
|
function rulesOf(budget: Budget): Record<string, unknown> {
|
|
70
73
|
return Object.fromEntries(
|
|
71
|
-
SIZE_RULES.map((
|
|
72
|
-
const max = budget[key];
|
|
73
|
-
return [
|
|
74
|
+
SIZE_RULES.map((entry) => {
|
|
75
|
+
const max = budget[entry.key];
|
|
76
|
+
return [qualifiedName(entry), max === undefined ? "off" : ["error", { max, ...entry.options }]];
|
|
74
77
|
}),
|
|
75
78
|
);
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
function sizeConfig({ production, tests }: Budgets): unknown {
|
|
81
|
+
function sizeConfig({ production, tests }: Budgets, plugin: string): unknown {
|
|
79
82
|
return {
|
|
80
83
|
plugins: [],
|
|
84
|
+
jsPlugins: [plugin],
|
|
81
85
|
categories: { correctness: "off" },
|
|
82
86
|
rules: rulesOf(production),
|
|
83
87
|
overrides: [{ files: [`${TESTS_DIRECTORY}/**`], rules: rulesOf(tests) }],
|
|
@@ -116,7 +120,7 @@ const materializeBase = Effect.fn("materializeBase")(function* (root: string, ba
|
|
|
116
120
|
|
|
117
121
|
const siteOf = (budgets: Budgets) =>
|
|
118
122
|
Effect.fn("siteOf")(function* ({ code, message, filename, labels }: typeof Diagnostic.Type) {
|
|
119
|
-
const rule = SIZE_RULES.find((candidate) => code ===
|
|
123
|
+
const rule = SIZE_RULES.find((candidate) => code === diagnosticCode(candidate));
|
|
120
124
|
if (rule === undefined) return [];
|
|
121
125
|
const measured = rule.measured.exec(message)?.[1];
|
|
122
126
|
const max = budgetOf(budgets, filename)[rule.key];
|
|
@@ -135,11 +139,11 @@ const siteOf = (budgets: Budgets) =>
|
|
|
135
139
|
];
|
|
136
140
|
});
|
|
137
141
|
|
|
138
|
-
const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets) {
|
|
142
|
+
const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets, plugin: string) {
|
|
139
143
|
const fs = yield* FileSystem.FileSystem;
|
|
140
144
|
if (!(yield* fs.exists(tree))) return [];
|
|
141
145
|
// oxlint reads an override's glob from the directory of the config that holds it, so the config sits in the tree.
|
|
142
|
-
yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets)));
|
|
146
|
+
yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets, plugin)));
|
|
143
147
|
|
|
144
148
|
const { stdout, stderr, exitCode } = yield* collect("oxlint", ["-c", CONFIG, "-f", "json", "."], tree).pipe(
|
|
145
149
|
Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot run oxlint: ${cause.message}` })),
|
|
@@ -190,7 +194,8 @@ const runBudget = Effect.fn("runBudget")(
|
|
|
190
194
|
|
|
191
195
|
const headTree = path.join(scratch, "head");
|
|
192
196
|
if (held.length + others.length > 0) yield* materializeHead(root, head, [...holds, ...others], headTree);
|
|
193
|
-
const
|
|
197
|
+
const plugin = path.join(import.meta.dir, "..", "dist", "index.js");
|
|
198
|
+
const sites = yield* measure(headTree, budgets, plugin);
|
|
194
199
|
const heldSites = sites.filter((site) => holds.has(site.file));
|
|
195
200
|
if (applies !== "ratchet") {
|
|
196
201
|
return { applies, held: held.length, overruns: heldSites, advisory: sites.filter((site) => !holds.has(site.file)) } satisfies Verdict;
|
|
@@ -198,7 +203,7 @@ const runBudget = Effect.fn("runBudget")(
|
|
|
198
203
|
|
|
199
204
|
const baseTree = path.join(scratch, "base");
|
|
200
205
|
if (held.length > 0) yield* materializeBase(root, base, held, baseTree);
|
|
201
|
-
const growths = growthsOf(heldSites, yield* measure(baseTree, budgets));
|
|
206
|
+
const growths = growthsOf(heldSites, yield* measure(baseTree, budgets, plugin));
|
|
202
207
|
const failing = new Set(growths.flatMap((growth) => growth.sites));
|
|
203
208
|
return { applies, held: held.length, growths, advisory: sites.filter((site) => !failing.has(site)) } satisfies Verdict;
|
|
204
209
|
},
|
|
@@ -239,16 +244,12 @@ function passes(verdict: Verdict): boolean {
|
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
const budget = Effect.gen(function* () {
|
|
242
|
-
const
|
|
243
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
244
|
-
|
|
245
|
-
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
246
|
-
const { source, quality } = yield* readQuality(root);
|
|
247
|
+
const { refs, root, source, quality } = yield* rangeGateInputs(USAGE);
|
|
247
248
|
if (quality.size === undefined) {
|
|
248
249
|
yield* Console.log(`${NAME}: ${source} declares no size budget`);
|
|
249
250
|
return true;
|
|
250
251
|
}
|
|
251
|
-
const { base, head } = yield* rangeEnds(first, second, root);
|
|
252
|
+
const { base, head } = yield* rangeEnds(refs.first, refs.second, root);
|
|
252
253
|
const verdict = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
|
|
253
254
|
|
|
254
255
|
yield* Console.log(report(verdict));
|
package/scripts/size-rules.ts
CHANGED
|
@@ -30,10 +30,11 @@ const STATEMENTS = {
|
|
|
30
30
|
|
|
31
31
|
const COMPLEXITY = {
|
|
32
32
|
key: "complexity",
|
|
33
|
-
rule: "complexity",
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
rule: "cognitive-complexity",
|
|
34
|
+
plugin: "effect-channel",
|
|
35
|
+
options: {},
|
|
36
|
+
measured: /has a cognitive complexity of (\d+)/,
|
|
37
|
+
limits: "The highest cognitive complexity a function may reach, a switch counted once",
|
|
37
38
|
} as const;
|
|
38
39
|
|
|
39
40
|
const DEPTH = {
|
|
@@ -49,6 +50,14 @@ export const SIZE_RULES = [FILE_LINES, FUNCTION_LINES, STATEMENTS, COMPLEXITY, D
|
|
|
49
50
|
export type SizeRule = (typeof SIZE_RULES)[number];
|
|
50
51
|
export type LimitKey = SizeRule["key"];
|
|
51
52
|
|
|
53
|
+
export function qualifiedName(rule: SizeRule): string {
|
|
54
|
+
return "plugin" in rule ? `${rule.plugin}/${rule.rule}` : rule.rule;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function diagnosticCode(rule: SizeRule): string {
|
|
58
|
+
return "plugin" in rule ? `${rule.plugin}(${rule.rule})` : `eslint(${rule.rule})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
// An absent limit turns its rule off.
|
|
53
62
|
export type Budget = { readonly [K in LimitKey]?: number };
|
|
54
63
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { git, parentOrEmptyTree } from "./git.ts";
|
|
4
|
-
import { runMain
|
|
3
|
+
import { git, parentOrEmptyTree, refArgs } from "./git.ts";
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
5
|
|
|
6
6
|
export const SUPPRESSIONS = "oxlint-suppressions.json";
|
|
7
7
|
|
|
@@ -49,28 +49,27 @@ export const parseSuppressions = Effect.fn("parseSuppressions")(function* (
|
|
|
49
49
|
return suppressions;
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
type Count = {
|
|
53
|
+
readonly file: string;
|
|
54
|
+
readonly rule: string;
|
|
55
|
+
readonly count: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function countsOf(suppressions: Suppressions): readonly Count[] {
|
|
59
|
+
return [...suppressions].flatMap(([file, rules]) => [...rules].map(([rule, count]) => ({ file, rule, count })));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function riseOf({ file, rule, count }: Count, before: number | undefined): readonly Rise[] {
|
|
63
|
+
if (before === undefined) return count > 0 ? [{ kind: "appeared", file, rule, head: count }] : [];
|
|
64
|
+
return count > before ? [{ kind: "rose", file, rule, base: before, head: count }] : [];
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
for (const [rule, count] of rules) {
|
|
57
|
-
counted += 1;
|
|
58
|
-
const before = base.get(file)?.get(rule);
|
|
59
|
-
if (before === undefined) {
|
|
60
|
-
if (count > 0) rises.push({ kind: "appeared", file, rule, head: count });
|
|
61
|
-
} else if (count > before) {
|
|
62
|
-
rises.push({ kind: "rose", file, rule, base: before, head: count });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
let lowered = 0;
|
|
67
|
-
for (const [file, rules] of base) {
|
|
68
|
-
for (const [rule, count] of rules) {
|
|
69
|
-
if ((head.get(file)?.get(rule) ?? 0) < count) lowered += 1;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
68
|
+
const counts = countsOf(head);
|
|
69
|
+
const rises = counts.flatMap((counted) => riseOf(counted, base.get(counted.file)?.get(counted.rule)));
|
|
70
|
+
const lowered = countsOf(base).filter(({ file, rule, count }) => (head.get(file)?.get(rule) ?? 0) < count).length;
|
|
72
71
|
rises.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
73
|
-
return { counted, lowered, rises };
|
|
72
|
+
return { counted: counts.length, lowered, rises };
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
function describeRise(rise: Rise): string {
|
|
@@ -118,8 +117,7 @@ export const runTip = Effect.fn("runTip")(function* (tip: string) {
|
|
|
118
117
|
});
|
|
119
118
|
|
|
120
119
|
const ratchet = Effect.gen(function* () {
|
|
121
|
-
const
|
|
122
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
120
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
123
121
|
|
|
124
122
|
const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
|
|
125
123
|
|
package/scripts/test-layout.ts
CHANGED
|
@@ -147,45 +147,47 @@ function importedNames(node: Record<string, unknown>): readonly string[] {
|
|
|
147
147
|
return names;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
type UseCheck = (node: Record<string, unknown>) => string | undefined;
|
|
151
|
+
|
|
152
|
+
function moduleUse(node: Record<string, unknown>): string | undefined {
|
|
153
|
+
const source = stringValue(node["source"]);
|
|
154
|
+
if (source === undefined) return undefined;
|
|
155
|
+
const banned = bannedModule(source);
|
|
156
|
+
if (banned !== undefined) return `imports node:${banned}`;
|
|
157
|
+
if (source !== "bun") return undefined;
|
|
158
|
+
const name = importedNames(node).find((candidate) => BANNED_BUN_NAMES.includes(candidate));
|
|
159
|
+
return name === undefined ? undefined : `imports ${name} from bun`;
|
|
160
|
+
}
|
|
152
161
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (source === "bun") {
|
|
159
|
-
const name = importedNames(node).find((candidate) => BANNED_BUN_NAMES.includes(candidate));
|
|
160
|
-
if (name !== undefined) return `imports ${name} from bun`;
|
|
161
|
-
}
|
|
162
|
-
return undefined;
|
|
163
|
-
}
|
|
162
|
+
function bunMemberUse(node: Record<string, unknown>): string | undefined {
|
|
163
|
+
if (identifierName(node["object"]) !== "Bun") return undefined;
|
|
164
|
+
const property = identifierName(node["property"]);
|
|
165
|
+
return property !== undefined && BANNED_BUN_NAMES.includes(property) ? `uses Bun.${property}` : undefined;
|
|
166
|
+
}
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
function callUse(node: Record<string, unknown>): string | undefined {
|
|
169
|
+
const callee = node["callee"];
|
|
170
|
+
const calleeName = identifierName(callee);
|
|
171
|
+
if (calleeName !== undefined && BANNED_GLOBAL_CALLS.includes(calleeName)) return `calls ${calleeName}`;
|
|
172
|
+
const argument = Array.isArray(node["arguments"]) ? node["arguments"][0] : undefined;
|
|
173
|
+
const specifier = isRecord(argument) ? stringValue(argument["expression"]) : undefined;
|
|
174
|
+
const banned = specifier === undefined ? undefined : bannedModule(specifier);
|
|
175
|
+
if (banned === undefined) return undefined;
|
|
176
|
+
if (isRecord(callee) && callee["type"] === "Import") return `imports node:${banned}`;
|
|
177
|
+
return calleeName === "require" ? `requires node:${banned}` : undefined;
|
|
178
|
+
}
|
|
171
179
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const specifier = isRecord(argument) ? stringValue(argument["expression"]) : undefined;
|
|
180
|
-
if (specifier === undefined) return undefined;
|
|
181
|
-
const banned = bannedModule(specifier);
|
|
182
|
-
if (banned === undefined) return undefined;
|
|
183
|
-
if (isRecord(callee) && callee["type"] === "Import") return `imports node:${banned}`;
|
|
184
|
-
if (calleeName === "require") return `requires node:${banned}`;
|
|
185
|
-
return undefined;
|
|
186
|
-
}
|
|
180
|
+
const USE_CHECKS = new Map<string, UseCheck>([
|
|
181
|
+
["ImportDeclaration", moduleUse],
|
|
182
|
+
["ExportNamedDeclaration", moduleUse],
|
|
183
|
+
["ExportAllDeclaration", moduleUse],
|
|
184
|
+
["MemberExpression", bunMemberUse],
|
|
185
|
+
["CallExpression", callUse],
|
|
186
|
+
]);
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
function outOfProcessUse(node: Record<string, unknown>): string | undefined {
|
|
189
|
+
const type = node["type"];
|
|
190
|
+
return typeof type === "string" ? USE_CHECKS.get(type)?.(node) : undefined;
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
export const isolationViolations = Effect.fn("isolationViolations")(function* (file: string, source: string) {
|
package/scripts/test-report.ts
CHANGED
|
@@ -52,26 +52,31 @@ function attributesOf(source: string): Readonly<Record<string, string>> {
|
|
|
52
52
|
return attributes;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
|
|
56
|
+
|
|
57
|
+
const takeTag = Effect.fnUntraced(function* (open: Element[], match: RegExpExecArray): Effect.fn.Return<void, ReportError> {
|
|
58
|
+
const [, closing, tag, attributes, selfClosing] = match;
|
|
59
|
+
if (tag === undefined) return;
|
|
60
|
+
const parent = open.at(-1);
|
|
61
|
+
if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
|
|
62
|
+
if (closing === "/") {
|
|
63
|
+
if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
|
|
64
|
+
open.pop();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
|
|
68
|
+
parent.children.push(element);
|
|
69
|
+
if (selfClosing !== "/") open.push(element);
|
|
70
|
+
});
|
|
71
|
+
|
|
55
72
|
const parseElements = Effect.fnUntraced(function* (xml: string): Effect.fn.Return<Element, ReportError> {
|
|
56
|
-
const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
|
|
57
73
|
const document: Element = { tag: "", attributes: {}, children: [] };
|
|
58
74
|
const open: Element[] = [document];
|
|
59
75
|
let consumed = 0;
|
|
60
76
|
for (const match of xml.matchAll(MARKUP)) {
|
|
61
77
|
if (xml.slice(consumed, match.index).includes("<")) return yield* malformed(`stray < before offset ${match.index}`);
|
|
62
78
|
consumed = match.index + match[0].length;
|
|
63
|
-
|
|
64
|
-
if (tag === undefined) continue;
|
|
65
|
-
const parent = open.at(-1);
|
|
66
|
-
if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
|
|
67
|
-
if (closing === "/") {
|
|
68
|
-
if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
|
|
69
|
-
open.pop();
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
|
|
73
|
-
parent.children.push(element);
|
|
74
|
-
if (selfClosing !== "/") open.push(element);
|
|
79
|
+
yield* takeTag(open, match);
|
|
75
80
|
}
|
|
76
81
|
if (xml.slice(consumed).includes("<")) return yield* malformed("stray < after the last element");
|
|
77
82
|
if (open.length !== 1) return yield* malformed(`<${open.at(-1)?.tag}> never closes`);
|