flake-ledger 0.0.0-stage → 0.1.1

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/lib/xml.mjs ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * A deliberately small, dependency-free XML reader.
3
+ *
4
+ * JUnit XML is written by dozens of tools and some of them are not strict
5
+ * XML serialisers. This parser therefore accepts the useful subset (elements,
6
+ * attributes, text, CDATA, comments, declarations and character entities) and
7
+ * is forgiving about unknown named entities while still rejecting structurally
8
+ * broken documents with a useful line/column error.
9
+ */
10
+
11
+ const NAMED_ENTITIES = Object.freeze({
12
+ amp: '&',
13
+ lt: '<',
14
+ gt: '>',
15
+ quot: '"',
16
+ apos: "'",
17
+ });
18
+
19
+ export class XmlParseError extends Error {
20
+ constructor(message, source, index, sourceName = '<xml>') {
21
+ const { line, column } = positionAt(source, index);
22
+ super(`${sourceName}:${line}:${column}: ${message}`);
23
+ this.name = 'XmlParseError';
24
+ this.line = line;
25
+ this.column = column;
26
+ this.sourceName = sourceName;
27
+ }
28
+ }
29
+
30
+ function positionAt(source, index) {
31
+ const safeIndex = Math.max(0, Math.min(index, source.length));
32
+ let line = 1;
33
+ let lineStart = 0;
34
+ for (let i = 0; i < safeIndex; i += 1) {
35
+ if (source.charCodeAt(i) === 10) {
36
+ line += 1;
37
+ lineStart = i + 1;
38
+ }
39
+ }
40
+ return { line, column: safeIndex - lineStart + 1 };
41
+ }
42
+
43
+ export function localName(name) {
44
+ if (typeof name !== 'string') return '';
45
+ const colon = name.lastIndexOf(':');
46
+ return colon === -1 ? name : name.slice(colon + 1);
47
+ }
48
+
49
+ export function isElement(node) {
50
+ return Boolean(node) && node.type === 'element';
51
+ }
52
+
53
+ export function elementChildren(node, name = null) {
54
+ if (!node || !Array.isArray(node.children)) return [];
55
+ const wanted = name === null ? null : String(name).toLowerCase();
56
+ return node.children.filter(
57
+ (child) => isElement(child) && (wanted === null || localName(child.name).toLowerCase() === wanted),
58
+ );
59
+ }
60
+
61
+ export function firstElementChild(node, name) {
62
+ if (!node || !Array.isArray(node.children)) return null;
63
+ const wanted = String(name).toLowerCase();
64
+ for (const child of node.children) {
65
+ if (isElement(child) && localName(child.name).toLowerCase() === wanted) return child;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export function getAttribute(node, name) {
71
+ if (!node || !node.attrs) return undefined;
72
+ if (Object.prototype.hasOwnProperty.call(node.attrs, name)) return node.attrs[name];
73
+ const wanted = String(name).toLowerCase();
74
+ for (const [key, value] of Object.entries(node.attrs)) {
75
+ if (key === name) return value;
76
+ if (localName(key).toLowerCase() === wanted) return value;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ export function textContent(node) {
82
+ if (!node) return '';
83
+ if (node.type === 'text' || node.type === 'cdata') return node.value;
84
+ if (!Array.isArray(node.children)) return '';
85
+ let out = '';
86
+ for (const child of node.children) out += textContent(child);
87
+ return out;
88
+ }
89
+
90
+ export function decodeEntities(value) {
91
+ return String(value).replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9._-]*);/g, (match, body) => {
92
+ if (body[0] === '#') {
93
+ const hex = body[1] === 'x' || body[1] === 'X';
94
+ const codePoint = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
95
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return match;
96
+ try {
97
+ return String.fromCodePoint(codePoint);
98
+ } catch {
99
+ return match;
100
+ }
101
+ }
102
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : match;
103
+ });
104
+ }
105
+
106
+ export function parseXml(source, options = {}) {
107
+ if (typeof source !== 'string') throw new TypeError('parseXml expects a string');
108
+ const sourceName = options.sourceName || '<xml>';
109
+ const document = {
110
+ type: 'document',
111
+ name: '#document',
112
+ attrs: Object.create(null),
113
+ children: [],
114
+ };
115
+ const stack = [document];
116
+ const length = source.length;
117
+ let index = 0;
118
+
119
+ const current = () => stack[stack.length - 1];
120
+ const fail = (message, at = index) => {
121
+ throw new XmlParseError(message, source, at, sourceName);
122
+ };
123
+
124
+ const appendText = (raw, cdata = false) => {
125
+ if (stack.length === 1) return;
126
+ const value = cdata ? raw : decodeEntities(raw);
127
+ if (value === '') return;
128
+ if (!cdata && value.trim() === '') return;
129
+ current().children.push({ type: cdata ? 'cdata' : 'text', value });
130
+ };
131
+
132
+ while (index < length) {
133
+ const open = source.indexOf('<', index);
134
+ if (open === -1) {
135
+ appendText(source.slice(index));
136
+ index = length;
137
+ break;
138
+ }
139
+ if (open > index) appendText(source.slice(index, open));
140
+ index = open;
141
+
142
+ if (source.startsWith('<!--', index)) {
143
+ const end = source.indexOf('-->', index + 4);
144
+ if (end === -1) fail('unterminated comment');
145
+ index = end + 3;
146
+ continue;
147
+ }
148
+
149
+ if (source.startsWith('<![CDATA[', index)) {
150
+ const end = source.indexOf(']]>', index + 9);
151
+ if (end === -1) fail('unterminated CDATA section');
152
+ appendText(source.slice(index + 9, end), true);
153
+ index = end + 3;
154
+ continue;
155
+ }
156
+
157
+ if (source.startsWith('<?', index)) {
158
+ const end = source.indexOf('?>', index + 2);
159
+ if (end === -1) fail('unterminated processing instruction');
160
+ index = end + 2;
161
+ continue;
162
+ }
163
+
164
+ if (source.startsWith('<!', index)) {
165
+ // DOCTYPE / declarations. Skip internal subsets without trying to
166
+ // resolve entities (which also avoids entity-expansion attacks).
167
+ let cursor = index + 2;
168
+ let quote = null;
169
+ let bracketDepth = 0;
170
+ let done = false;
171
+ while (cursor < length) {
172
+ const char = source[cursor];
173
+ if (quote) {
174
+ if (char === quote) quote = null;
175
+ } else if (char === '"' || char === "'") {
176
+ quote = char;
177
+ } else if (char === '[') {
178
+ bracketDepth += 1;
179
+ } else if (char === ']') {
180
+ bracketDepth = Math.max(0, bracketDepth - 1);
181
+ } else if (char === '>' && bracketDepth === 0) {
182
+ done = true;
183
+ cursor += 1;
184
+ break;
185
+ }
186
+ cursor += 1;
187
+ }
188
+ if (!done) fail('unterminated declaration');
189
+ index = cursor;
190
+ continue;
191
+ }
192
+
193
+ if (source.startsWith('</', index)) {
194
+ const end = source.indexOf('>', index + 2);
195
+ if (end === -1) fail('unterminated closing tag');
196
+ const rawName = source.slice(index + 2, end).trim();
197
+ if (rawName === '') fail('empty closing tag', index);
198
+ const name = rawName.split(/\s+/, 1)[0];
199
+ if (stack.length === 1) fail(`unexpected closing tag </${name}>`, index);
200
+ const node = current();
201
+ if (node.name !== name) {
202
+ fail(`closing tag </${name}> does not match <${node.name}>`, index);
203
+ }
204
+ stack.pop();
205
+ index = end + 1;
206
+ continue;
207
+ }
208
+
209
+ // Start tag.
210
+ const start = parseStartTag(source, index, sourceName);
211
+ const node = {
212
+ type: 'element',
213
+ name: start.name,
214
+ attrs: start.attrs,
215
+ children: [],
216
+ };
217
+ current().children.push(node);
218
+ if (!start.selfClosing) stack.push(node);
219
+ index = start.end;
220
+ }
221
+
222
+ if (stack.length > 1) {
223
+ const unclosed = stack[stack.length - 1];
224
+ fail(`unclosed tag <${unclosed.name}>`, length);
225
+ }
226
+
227
+ return document;
228
+ }
229
+
230
+ function parseStartTag(source, index, sourceName) {
231
+ const fail = (message, at = index) => {
232
+ throw new XmlParseError(message, source, at, sourceName);
233
+ };
234
+ let cursor = index + 1;
235
+ const length = source.length;
236
+ const nameStart = cursor;
237
+ while (cursor < length && !/[\s/>]/.test(source[cursor])) cursor += 1;
238
+ const name = source.slice(nameStart, cursor);
239
+ if (!name) fail('empty tag name');
240
+ if (name.startsWith('!') || name.startsWith('?')) fail(`unsupported markup <${name}>`);
241
+
242
+ const attrs = Object.create(null);
243
+ let selfClosing = false;
244
+
245
+ while (cursor < length) {
246
+ while (cursor < length && /\s/.test(source[cursor])) cursor += 1;
247
+ if (cursor >= length) fail(`unterminated tag <${name}>`);
248
+
249
+ if (source[cursor] === '>') {
250
+ cursor += 1;
251
+ break;
252
+ }
253
+
254
+ if (source[cursor] === '/') {
255
+ if (source[cursor + 1] !== '>') fail(`expected '/>' in tag <${name}>`, cursor);
256
+ selfClosing = true;
257
+ cursor += 2;
258
+ break;
259
+ }
260
+
261
+ const attrStart = cursor;
262
+ while (cursor < length && !/[\s=/>]/.test(source[cursor])) cursor += 1;
263
+ const attrName = source.slice(attrStart, cursor);
264
+ if (!attrName) fail(`invalid attribute in tag <${name}>`, cursor);
265
+
266
+ while (cursor < length && /\s/.test(source[cursor])) cursor += 1;
267
+ if (source[cursor] === '=') {
268
+ cursor += 1;
269
+ while (cursor < length && /\s/.test(source[cursor])) cursor += 1;
270
+ if (cursor >= length) fail(`unterminated attribute ${attrName}`, cursor);
271
+
272
+ let value = '';
273
+ const quote = source[cursor];
274
+ if (quote === '"' || quote === "'") {
275
+ cursor += 1;
276
+ const valueStart = cursor;
277
+ while (cursor < length && source[cursor] !== quote) cursor += 1;
278
+ if (cursor >= length) fail(`unterminated attribute ${attrName}`, valueStart);
279
+ value = source.slice(valueStart, cursor);
280
+ cursor += 1;
281
+ } else {
282
+ // Not strictly XML, but several JUnit writers emit bare values.
283
+ const valueStart = cursor;
284
+ while (cursor < length && !/[\s/>]/.test(source[cursor])) cursor += 1;
285
+ value = source.slice(valueStart, cursor);
286
+ }
287
+ attrs[attrName] = decodeEntities(value);
288
+ } else {
289
+ // Valueless attributes are invalid XML, but accepting them is more
290
+ // useful than refusing to read an otherwise usable report.
291
+ attrs[attrName] = '';
292
+ }
293
+ }
294
+
295
+ return { name, attrs, selfClosing, end: cursor };
296
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "flake-ledger",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Ranks flaky tests from JUnit XML across CI runs, including passed-on-retry.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./lib/index.mjs",
8
+ "bin": {
9
+ "flake-ledger": "./bin/flake-ledger.mjs"
10
+ },
11
+ "files": [
12
+ "bin/",
13
+ "lib/",
14
+ "examples/"
15
+ ],
16
+ "scripts": {
17
+ "test": "node --test",
18
+ "example": "node examples/demo.mjs"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "ci",
25
+ "flaky-tests",
26
+ "junit",
27
+ "junit-xml",
28
+ "playwright",
29
+ "pytest",
30
+ "surefire",
31
+ "test-reliability"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/WJDdidi-1/flake-ledger.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/WJDdidi-1/flake-ledger/issues"
39
+ },
40
+ "homepage": "https://github.com/WJDdidi-1/flake-ledger#readme"
41
+ }