canary-test-cli 6.8.1 → 7.0.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.
Files changed (36) hide show
  1. package/dist/engine/analysis/cli.js +155 -45
  2. package/dist/engine/analysis/engine.js +9 -9
  3. package/dist/engine/analysis/reports.js +0 -0
  4. package/dist/engine/cli-commands.js +2 -2
  5. package/dist/engine/core/migrator.js +142 -31
  6. package/dist/engine/core/static-linter.js +2 -2
  7. package/dist/engine/core/workspace-detect.js +0 -0
  8. package/dist/engine/guardian/adjudication.js +1 -1
  9. package/dist/engine/guardian/agent-tier.js +3 -3
  10. package/dist/engine/guardian/coverage.js +15 -1420
  11. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
  12. package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
  13. package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
  14. package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
  15. package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
  16. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
  17. package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
  18. package/dist/engine/guardian/diff-coverage/paths.js +164 -0
  19. package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
  20. package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
  21. package/dist/engine/guardian/diff-coverage/types.js +115 -0
  22. package/dist/engine/guardian/pr-check.js +5 -5
  23. package/dist/engine-checks.d.ts +15 -0
  24. package/dist/engine-checks.js +92 -1
  25. package/dist/overlay-commands.d.ts +12 -1
  26. package/dist/overlay-commands.js +28 -2
  27. package/dist/router.js +17 -5
  28. package/dist/uninstall-render.d.ts +11 -0
  29. package/dist/uninstall-render.js +60 -0
  30. package/dist/uninstall-scan.d.ts +14 -0
  31. package/dist/uninstall-scan.js +273 -0
  32. package/dist/uninstall-types.d.ts +46 -0
  33. package/dist/uninstall-types.js +91 -0
  34. package/dist/uninstall.d.ts +13 -0
  35. package/dist/uninstall.js +174 -0
  36. package/package.json +1 -1
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Cobertura `coverage.xml` reader — a minimal, targeted scanner pinned to the
3
+ * canonical `<coverage>...<class filename="..."><line number hits/>` shape
4
+ * rather than a general XML parser (see `./xml.ts` for why).
5
+ */
6
+ import { attrValue, isWellFormedXml } from './xml.js';
7
+ import { pyInt, selfDescribing, } from '../types.js';
8
+ // Coverage reports are semi-trusted CI artifacts, but canary distrusts input by
9
+ // default: cap size so a pathological XML cannot exhaust memory during parse.
10
+ // Exposed as a mutable object so tests can shrink the cap (the analog of the
11
+ // Python test's `monkeypatch.setattr(cov, "_MAX_REPORT_BYTES", 32)`).
12
+ export const coverageLimits = { maxReportBytes: 25 * 1024 * 1024 }; // 25 MiB
13
+ /**
14
+ * Parse a Cobertura `coverage.xml` into `{path: {line: hits}}`.
15
+ *
16
+ * Line-level only — branch/`condition-coverage` data is intentionally dropped.
17
+ * Pins to the canonical Cobertura shape emitted by coverage.py, Istanbul,
18
+ * SimpleCov and Jacoco→Cobertura converters: a `<coverage>` root with
19
+ * `<class filename=...>` elements carrying nested `<line number= hits=>`.
20
+ * (Native Jacoco XML uses a `<report>` root and is *not* Cobertura — it
21
+ * correctly returns `null`.) Any other XML → `null` so the caller falls through
22
+ * to a lower fidelity tier (absence never blocks).
23
+ *
24
+ * Security: rejects oversize input and DOCTYPE entity definitions *before*
25
+ * parsing (guards against entity-expansion / "billion laughs"). This targeted
26
+ * scanner resolves no entities and reads no DTD, so XXE is not in scope.
27
+ * Malformed input never throws — it degrades to `null`.
28
+ */
29
+ export function parseCobertura(text) {
30
+ const body = coberturaBody(text);
31
+ if (body === null)
32
+ return null;
33
+ // Built as raw hit maps; every `<line>` record is a line the instrumenter
34
+ // measured, so the recorded lines become the coverable set on the way out.
35
+ const index = {};
36
+ collectClasses(body, index);
37
+ return pruneEmptyClasses(index);
38
+ }
39
+ /**
40
+ * The comment-stripped document, or `null` if it is not a Cobertura report we
41
+ * are willing to read: oversize, entity-bearing, malformed, or a different
42
+ * format entirely.
43
+ */
44
+ function coberturaBody(text) {
45
+ if (text.length > coverageLimits.maxReportBytes)
46
+ return null;
47
+ // Reject any internal-subset DOCTYPE that declares entities. Scan the FULL
48
+ // (already size-capped) text: a leading comment can push the DOCTYPE past
49
+ // any fixed window, so a windowed check is bypassable.
50
+ if (text.includes('<!DOCTYPE') && text.includes('<!ENTITY'))
51
+ return null;
52
+ // Reject malformed XML up front, matching Python's `ET.fromstring` raising
53
+ // `ParseError` → the caller falls through to a lower-fidelity tier. Without
54
+ // this, a lenient scanner would happily extract coverage from a broken
55
+ // document, flipping both the fidelity tier AND the covered/uncovered verdict
56
+ // relative to the oracle.
57
+ if (!isWellFormedXml(text))
58
+ return null;
59
+ // Pin to the canonical (namespace-free) Cobertura root; anything else is a
60
+ // different XML format and is rejected rather than guessed at. Strip comments
61
+ // first so a `<foo>` inside a comment can't masquerade as the root element.
62
+ const withoutComments = text.replace(/<!--[\s\S]*?-->/g, '');
63
+ const rootMatch = /<(?![?!])([A-Za-z_][\w.:-]*)/.exec(withoutComments);
64
+ if (rootMatch === null || rootMatch[1] !== 'coverage')
65
+ return null;
66
+ return withoutComments;
67
+ }
68
+ const CLOSE_CLASS = '</class>';
69
+ /**
70
+ * Walk each `<class>` open tag, folding its lines into `index` by filename.
71
+ *
72
+ * A self-closing `<class .../>` is an empty class (its filename recorded, no
73
+ * lines) — critically, it must NOT be paired with the NEXT class's
74
+ * `</class>`, or that class's lines bind to the wrong file. Cobertura classes
75
+ * never nest, so for a real open tag the first following `</class>` is the
76
+ * correct close.
77
+ */
78
+ function collectClasses(body, index) {
79
+ const classOpenRe = /<class\b([^>]*?)(\/?)>/g;
80
+ for (let cls = classOpenRe.exec(body); cls !== null; cls = classOpenRe.exec(body)) {
81
+ // Normalize Windows separators so .NET/coverlet reports resolve against
82
+ // POSIX-style diff paths (the path matcher only recognizes "/").
83
+ const raw = attrValue(cls[1], 'filename');
84
+ const filename = raw === null ? null : raw.replace(/\\/g, '/');
85
+ if (cls[2] === '/') {
86
+ // Record the filename (mirrors ET's `setdefault`), but consume no body so
87
+ // the next class keeps its own lines.
88
+ if (filename)
89
+ bucketFor(index, filename);
90
+ continue;
91
+ }
92
+ const openEnd = classOpenRe.lastIndex;
93
+ const closeIdx = body.indexOf(CLOSE_CLASS, openEnd);
94
+ if (closeIdx === -1)
95
+ return; // no close (well-formed XML guarantees one)
96
+ classOpenRe.lastIndex = closeIdx + CLOSE_CLASS.length;
97
+ if (!filename)
98
+ continue;
99
+ readClassLines(body.slice(openEnd, closeIdx), bucketFor(index, filename));
100
+ }
101
+ }
102
+ /** One filename's hit map, created on first mention (ET's `setdefault`). */
103
+ function bucketFor(index, filename) {
104
+ return (index[filename] ??= {});
105
+ }
106
+ /** Fold every `<line number= hits=>` in one class body into `hitsByLine`. */
107
+ function readClassLines(body, hitsByLine) {
108
+ const lineRe = /<line\b([^>]*)>/g;
109
+ for (let ln = lineRe.exec(body); ln !== null; ln = lineRe.exec(body)) {
110
+ const num = attrValue(ln[1], 'number');
111
+ if (num === null)
112
+ continue;
113
+ const lineno = pyInt(num);
114
+ const hits = pyInt(attrValue(ln[1], 'hits') ?? '0');
115
+ if (lineno === null || hits === null)
116
+ continue;
117
+ // A line can appear at both method and class scope; keep the max.
118
+ hitsByLine[lineno] = Math.max(hitsByLine[lineno] ?? 0, hits);
119
+ }
120
+ }
121
+ /** Drop classes that yielded no parseable lines; require at least one. */
122
+ function pruneEmptyClasses(index) {
123
+ const pruned = {};
124
+ for (const [path, hits] of Object.entries(index)) {
125
+ if (Object.keys(hits).length > 0)
126
+ pruned[path] = selfDescribing(hits);
127
+ }
128
+ return Object.keys(pruned).length > 0 ? pruned : null;
129
+ }
130
+ //# sourceMappingURL=cobertura.js.map
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The producer-facing lint for coverage-json: it reports, loudly, exactly what
3
+ * `./coverage-json.ts` would silently accept-and-drop. Split one validator per
4
+ * field so each stays readable and the parser it mirrors stays adjacent.
5
+ */
6
+ import { COVERAGE_JSON_SCHEMA_VERSION, isSupportedSchemaVersion, } from './coverage-json.js';
7
+ import { isInt, isRecord, pyInt } from '../types.js';
8
+ /**
9
+ * Validate a coverage-json document against the v1 producer contract.
10
+ *
11
+ * Reports, loudly, exactly what `parseCoverageJson` would silently
12
+ * accept-and-drop, at two severities. Never raises and never mutates — it is a
13
+ * lint for producers, mirroring the parser it lives beside so the two cannot
14
+ * drift.
15
+ */
16
+ export function validateCoverageJson(data) {
17
+ const problems = [];
18
+ const report = {
19
+ err: (location, message) => void problems.push({ severity: 'error', location, message }),
20
+ warn: (location, message) => void problems.push({ severity: 'warning', location, message }),
21
+ };
22
+ if (!isRecord(data)) {
23
+ report.err('(root)', 'top-level value must be a JSON object');
24
+ return problems;
25
+ }
26
+ const version = data['schema_version'];
27
+ if (!isSupportedSchemaVersion(version)) {
28
+ report.err('schema_version', `unsupported schema_version ${repr(version)}; this build understands ` +
29
+ `v${COVERAGE_JSON_SCHEMA_VERSION} (omit the field to default to it)`);
30
+ }
31
+ const files = data['files'];
32
+ if (files === undefined || files === null) {
33
+ report.err('files', "missing required 'files' object");
34
+ return problems;
35
+ }
36
+ if (!isRecord(files)) {
37
+ report.err('files', "'files' must be an object mapping path -> coverage");
38
+ return problems;
39
+ }
40
+ for (const [path, entry] of Object.entries(files)) {
41
+ validateFileEntry(`files['${path}']`, entry, report);
42
+ }
43
+ return problems;
44
+ }
45
+ /** Validate one `files[path]` entry, mirroring what the parser keeps. */
46
+ function validateFileEntry(loc, entry, report) {
47
+ if (!isRecord(entry)) {
48
+ report.err(loc, "entry must be an object; this file's coverage is dropped");
49
+ return;
50
+ }
51
+ // Mirror the parser's surviving hit map so the verdict is bound to what
52
+ // the parser actually keeps.
53
+ const recorded = {};
54
+ validateLineHits(entry['line_hits'], recorded, loc, report.warn);
55
+ validateCoveredLines(entry['covered_lines'], recorded, loc, report.warn);
56
+ if (Object.keys(recorded).length === 0) {
57
+ report.warn(loc, 'no usable coverage lines; contributes nothing');
58
+ }
59
+ validateInstrumentedLines(entry, recorded, loc, report.warn);
60
+ }
61
+ /** Check `line_hits`, recording into `recorded` every entry the parser keeps. */
62
+ function validateLineHits(lineHits, recorded, loc, warn) {
63
+ if (lineHits === undefined || lineHits === null)
64
+ return;
65
+ if (!isRecord(lineHits)) {
66
+ warn(`${loc}.line_hits`, 'must be an object mapping line -> hits; ignored');
67
+ return;
68
+ }
69
+ for (const [k, v] of Object.entries(lineHits)) {
70
+ const kloc = `${loc}.line_hits['${k}']`;
71
+ if (!isInt(v)) {
72
+ warn(kloc, `hits ${repr(v)} is not an integer; dropped`);
73
+ continue;
74
+ }
75
+ if (v < 0) {
76
+ warn(kloc, `hits ${v} is negative; dropped`);
77
+ continue;
78
+ }
79
+ const lineno = pyInt(k);
80
+ if (lineno === null) {
81
+ warn(kloc, 'line key is not an integer; dropped');
82
+ continue;
83
+ }
84
+ if (lineno < 1) {
85
+ warn(kloc, 'line number must be >= 1; dropped');
86
+ continue;
87
+ }
88
+ recorded[lineno] = v;
89
+ }
90
+ }
91
+ /** Check `covered_lines`, folding the survivors into `recorded` as the parser does. */
92
+ function validateCoveredLines(covered, recorded, loc, warn) {
93
+ if (covered === undefined || covered === null)
94
+ return;
95
+ if (!Array.isArray(covered)) {
96
+ warn(`${loc}.covered_lines`, 'must be an array of line numbers; ignored');
97
+ return;
98
+ }
99
+ covered.forEach((lineno, i) => {
100
+ const cloc = `${loc}.covered_lines[${i}]`;
101
+ if (!isInt(lineno)) {
102
+ warn(cloc, `${repr(lineno)} is not an integer; dropped`);
103
+ return;
104
+ }
105
+ if (lineno < 1) {
106
+ warn(cloc, 'line number must be >= 1; dropped');
107
+ return;
108
+ }
109
+ if (!(lineno in recorded)) {
110
+ recorded[lineno] = 1;
111
+ return;
112
+ }
113
+ if (recorded[lineno] === 0) {
114
+ warn(cloc, `line ${lineno} is also in line_hits as unhit (0); ` +
115
+ 'line_hits wins, so it stays uncovered');
116
+ }
117
+ // a positive line_hits count makes this entry redundant
118
+ });
119
+ }
120
+ /**
121
+ * Check a file entry's `instrumented_lines`, and nudge producers that need it.
122
+ *
123
+ * Two jobs. The shape checks mirror the parser, as everywhere else in this
124
+ * validator. The interesting one is the **ambiguity** warning: a document
125
+ * leaning on `covered_lines` cannot express an unhit line *or* a
126
+ * non-instrumented one, so every line it omits lands downstream as a coverage
127
+ * gap. That is exactly what transcoding lcov into this format produces, and it
128
+ * is the failure #657 exists to close — the producer sees success while the
129
+ * consumer invents findings. Silence there would be the same shape this repo
130
+ * keeps closing, so the validator says it out loud.
131
+ *
132
+ * It is deliberately scoped to `covered_lines`, the field that *structurally*
133
+ * cannot report a miss. A `line_hits` document with no zeros is not evidence of
134
+ * the same mistake: that producer can express an unhit line and simply had none
135
+ * to report, which is what a fully-covered file looks like. Warning there would
136
+ * fire on correct documents, and a warning that cries wolf is one producers
137
+ * learn to skip — the precision lesson from #553.
138
+ *
139
+ * The warning clears as soon as the document is unambiguous by either route: a
140
+ * declared `instrumented_lines`, or an explicit `0` in `line_hits`. A producer
141
+ * already doing the right thing must not be nagged toward a second mechanism.
142
+ */
143
+ function validateInstrumentedLines(entry, recorded, loc, warn) {
144
+ const declaration = entry['instrumented_lines'];
145
+ const dloc = `${loc}.instrumented_lines`;
146
+ if (declaration === undefined || declaration === null) {
147
+ const usesShorthand = Array.isArray(entry['covered_lines']);
148
+ const declaresAMiss = Object.values(recorded).includes(0);
149
+ if (usesShorthand && !declaresAMiss) {
150
+ warn(loc, "reports coverage via 'covered_lines', which cannot express an unhit " +
151
+ 'line, so a changed line this document omits is read as uncovered — ' +
152
+ 'a line that was never instrumented (a comment, an import, a type ' +
153
+ "declaration) becomes a coverage gap. Declare 'instrumented_lines', " +
154
+ 'or record unhit lines as line_hits 0');
155
+ }
156
+ return;
157
+ }
158
+ if (!Array.isArray(declaration)) {
159
+ warn(dloc, 'must be an array of line numbers; ignored');
160
+ return;
161
+ }
162
+ declaration.forEach((lineno, i) => {
163
+ if (!isInt(lineno)) {
164
+ warn(`${dloc}[${i}]`, `${repr(lineno)} is not an integer; dropped`);
165
+ return;
166
+ }
167
+ if (lineno < 1) {
168
+ warn(`${dloc}[${i}]`, 'line number must be >= 1; dropped');
169
+ }
170
+ });
171
+ // A recorded line the declaration omits is a producer contradicting itself.
172
+ // The parser keeps the measurement, so this costs no coverage — but a
173
+ // declaration that disagrees with the data is worth hearing about.
174
+ const declared = new Set(declaration.filter(isInt));
175
+ const undeclared = Object.keys(recorded)
176
+ .map(Number)
177
+ .filter((ln) => !declared.has(ln));
178
+ if (undeclared.length > 0) {
179
+ warn(dloc, `line(s) ${undeclared.join(', ')} have coverage data but are not ` +
180
+ 'declared instrumented; the measurement wins and they stay coverable');
181
+ }
182
+ }
183
+ /** Rough analog of Python's `repr()` for scalar diagnostic values. */
184
+ function repr(value) {
185
+ if (typeof value === 'string')
186
+ return `'${value}'`;
187
+ // Match Python's `{v!r}` spelling of the JSON scalars so warning messages
188
+ // read byte-for-byte like the oracle (true→True, false→False, null→None).
189
+ if (value === true)
190
+ return 'True';
191
+ if (value === false)
192
+ return 'False';
193
+ if (value === null)
194
+ return 'None';
195
+ return String(value);
196
+ }
197
+ //# sourceMappingURL=coverage-json-lint.js.map
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Reader for the canary coverage-json shape. The producer contract it enforces
3
+ * is linted, field for field, by `./coverage-json-lint.ts` — the two are
4
+ * deliberately kept side by side so they cannot drift.
5
+ */
6
+ import { isInt, isRecord, pyInt, recordedLines, } from '../types.js';
7
+ // The coverage-json contract version this build understands. Bumped only on a
8
+ // breaking change; the shape evolves additively (see
9
+ // docs/specs/coverage-json-contract.md).
10
+ export const COVERAGE_JSON_SCHEMA_VERSION = 1;
11
+ /** True when `schema_version` is absent or names a version this build reads. */
12
+ export function isSupportedSchemaVersion(version) {
13
+ if (version === undefined || version === null)
14
+ return true;
15
+ return isInt(version) && version === COVERAGE_JSON_SCHEMA_VERSION;
16
+ }
17
+ /**
18
+ * Parse the canary coverage-json shape into `{path: {line: hits}}`.
19
+ *
20
+ * Supports `{"files": {"<path>": {"covered_lines": [...]}}}` and the same with
21
+ * an explicit `line_hits` mapping. Unrecognized structure, or a
22
+ * `schema_version` this build does not understand, → `null`. The v1 contract
23
+ * is enforced strictly (integers only, 1-based lines, non-negative hits) so
24
+ * this parser and `validateCoverageJson` stay in lockstep.
25
+ */
26
+ export function parseCoverageJson(data) {
27
+ if (!isRecord(data))
28
+ return null;
29
+ // Refuse a version we don't understand rather than silently consuming its
30
+ // v1-compatible parts and mislabeling the result coverage-verified.
31
+ if (!isSupportedSchemaVersion(data['schema_version']))
32
+ return null;
33
+ const files = data['files'];
34
+ if (!isRecord(files))
35
+ return null;
36
+ const index = {};
37
+ for (const [path, entry] of Object.entries(files)) {
38
+ if (isRecord(entry))
39
+ index[String(path)] = parseFileEntry(entry);
40
+ }
41
+ return index;
42
+ }
43
+ /** Resolve one `files[path]` entry into its hit map and coverable set. */
44
+ function parseFileEntry(entry) {
45
+ // line_hits is authoritative: covered_lines may add a line it didn't mention,
46
+ // but never overrides an explicit hit count (so a `{"14": 0}` unhit line
47
+ // stays uncovered).
48
+ const recorded = readLineHits(entry['line_hits']);
49
+ const hits = { ...recorded };
50
+ for (const lineno of coveredLineNumbers(entry['covered_lines'])) {
51
+ if (!(lineno in recorded))
52
+ hits[lineno] = 1;
53
+ }
54
+ return {
55
+ hits,
56
+ coverable: declaredCoverable(entry['instrumented_lines'], hits),
57
+ };
58
+ }
59
+ /** `line_hits` → hit map, dropping anything the v1 contract rejects. */
60
+ function readLineHits(value) {
61
+ const hits = {};
62
+ if (!isRecord(value))
63
+ return hits;
64
+ for (const [key, count] of Object.entries(value)) {
65
+ // Integers only, 1-based line, non-negative hits (see docstring).
66
+ if (!(isInt(count) && count >= 0))
67
+ continue;
68
+ const lineno = pyInt(key);
69
+ if (lineno === null || lineno < 1)
70
+ continue;
71
+ hits[lineno] = count;
72
+ }
73
+ return hits;
74
+ }
75
+ /** `covered_lines` → the line numbers that survive the v1 contract. */
76
+ function coveredLineNumbers(value) {
77
+ if (!Array.isArray(value))
78
+ return [];
79
+ return value.filter((n) => isInt(n) && n >= 1);
80
+ }
81
+ /**
82
+ * Resolve a file entry's `instrumented_lines` declaration (#657).
83
+ *
84
+ * Returns the set of lines the report can speak to, or `null` when the producer
85
+ * did not declare one — which is every v1 document written before this field
86
+ * existed, and is why adding it changes no existing behaviour.
87
+ *
88
+ * A malformed declaration degrades to `null` rather than dropping the entry,
89
+ * matching the parser's leniency everywhere else: one bad field costs that
90
+ * field, never the file's coverage. `validateCoverageJson` is what makes the
91
+ * degradation audible.
92
+ *
93
+ * Recorded lines are unioned in. A `line_hits` key outside the declared set is
94
+ * a producer contradicting itself, and real measurement outranks a declaration
95
+ * — dropping it would discard a hit count the producer actually took.
96
+ */
97
+ function declaredCoverable(declaration, hits) {
98
+ if (!Array.isArray(declaration))
99
+ return null;
100
+ const coverable = recordedLines(hits);
101
+ for (const lineno of declaration) {
102
+ if (isInt(lineno) && lineno >= 1)
103
+ coverable.add(lineno);
104
+ }
105
+ return coverable;
106
+ }
107
+ //# sourceMappingURL=coverage-json.js.map
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The XML primitives the Cobertura reader stands on: a non-throwing
3
+ * well-formedness check and an attribute extractor.
4
+ *
5
+ * Node has no built-in XML parser, and pulling one in would put a third-party
6
+ * parser inside the guardian's security boundary. Instead the check below is a
7
+ * hand-rolled scanner that resolves no entities and reads no DTD.
8
+ *
9
+ * Structured as one dispatch per construct rather than a single loop: each
10
+ * reader takes the current offset and returns the offset just past what it
11
+ * consumed, or `-1` for "not well formed". That keeps the branch count of every
12
+ * individual function low enough to reason about — the shape the 38-branch
13
+ * original could not offer (#668).
14
+ */
15
+ const XML_WS = new Set([' ', '\t', '\n', '\r']);
16
+ // Sticky (`y`) so each reader matches exactly at the offset it was handed, with
17
+ // no substring allocation. Every use sets `lastIndex` immediately before `exec`.
18
+ const NAME_RE = /[A-Za-z_][\w.:-]*/y;
19
+ const END_TAG_RE = /([A-Za-z_][\w.:-]*)[ \t\n\r]*>/y;
20
+ const ATTR_NAME_EQ_RE = /[A-Za-z_][\w.:-]*[ \t\n\r]*=[ \t\n\r]*/y;
21
+ const ENTITY_RE = /(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z_][\w.-]*);/y;
22
+ /** "Not well formed" — the sentinel every reader below returns on failure. */
23
+ const BAD = -1;
24
+ /**
25
+ * Minimal, non-throwing XML well-formedness check — the analog of what
26
+ * `ET.fromstring` enforces before it will yield an element tree. Returns
27
+ * `false` (so the caller degrades to `null`) on: an unbalanced or mismatched
28
+ * tag, an unquoted attribute value, or a raw `&` that is not a valid entity
29
+ * reference. Skips comments, CDATA, processing instructions, and DOCTYPE
30
+ * declarations (entity-bearing DOCTYPEs are already rejected upstream). O(n)
31
+ * over the size-capped input, allocation-free via sticky regexes.
32
+ */
33
+ export function isWellFormedXml(text) {
34
+ const stack = [];
35
+ let i = 0;
36
+ while (i < text.length) {
37
+ const ch = text[i];
38
+ let next;
39
+ if (ch === '<')
40
+ next = readMarkup(text, i, stack);
41
+ else if (ch === '&')
42
+ next = readEntity(text, i);
43
+ else
44
+ next = i + 1;
45
+ if (next === BAD)
46
+ return false;
47
+ i = next;
48
+ }
49
+ return stack.length === 0;
50
+ }
51
+ /** Dispatch on which `<`-introduced construct starts at `i`. */
52
+ function readMarkup(text, i, stack) {
53
+ if (text.startsWith('<!--', i))
54
+ return skipPast(text, '-->', i + 4);
55
+ if (text.startsWith('<![CDATA[', i))
56
+ return skipPast(text, ']]>', i + 9);
57
+ if (text.startsWith('<?', i))
58
+ return skipPast(text, '?>', i + 2);
59
+ if (text.startsWith('<!', i))
60
+ return skipDeclaration(text, i);
61
+ if (text.startsWith('</', i))
62
+ return readEndTag(text, i, stack);
63
+ return readStartTag(text, i, stack);
64
+ }
65
+ /** Offset just past the next `marker` at or after `from`. */
66
+ function skipPast(text, marker, from) {
67
+ const end = text.indexOf(marker, from);
68
+ return end === BAD ? BAD : end + marker.length;
69
+ }
70
+ /**
71
+ * Skip a DOCTYPE or other `<!` declaration to its matching top-level `>`,
72
+ * honoring an internal-subset `[ ... ]` whose contents may contain `>`.
73
+ */
74
+ function skipDeclaration(text, i) {
75
+ let depth = 0;
76
+ for (let j = i + 2; j < text.length; j++) {
77
+ const c = text[j];
78
+ if (c === '[')
79
+ depth++;
80
+ else if (c === ']') {
81
+ if (depth > 0)
82
+ depth--;
83
+ }
84
+ else if (c === '>' && depth === 0)
85
+ return j + 1;
86
+ }
87
+ return BAD;
88
+ }
89
+ /** Read `</name>`, requiring it to close the element on top of the stack. */
90
+ function readEndTag(text, i, stack) {
91
+ END_TAG_RE.lastIndex = i + 2;
92
+ const m = END_TAG_RE.exec(text);
93
+ if (m === null)
94
+ return BAD;
95
+ if (stack.pop() !== m[1])
96
+ return BAD;
97
+ return END_TAG_RE.lastIndex;
98
+ }
99
+ /** Read a start tag or a self-closing element, pushing the former. */
100
+ function readStartTag(text, i, stack) {
101
+ NAME_RE.lastIndex = i + 1;
102
+ const nm = NAME_RE.exec(text);
103
+ if (nm === null)
104
+ return BAD;
105
+ const name = nm[0];
106
+ let j = NAME_RE.lastIndex;
107
+ while (j < text.length) {
108
+ while (j < text.length && XML_WS.has(text[j]))
109
+ j++;
110
+ if (j >= text.length)
111
+ return BAD;
112
+ if (text[j] === '>') {
113
+ stack.push(name);
114
+ return j + 1;
115
+ }
116
+ if (text[j] === '/' && text[j + 1] === '>')
117
+ return j + 2;
118
+ j = readAttribute(text, j);
119
+ if (j === BAD)
120
+ return BAD;
121
+ }
122
+ return BAD; // ran out of input before the tag closed
123
+ }
124
+ /** Read one `name="value"` pair; rejects junk and unquoted values. */
125
+ function readAttribute(text, i) {
126
+ ATTR_NAME_EQ_RE.lastIndex = i;
127
+ if (ATTR_NAME_EQ_RE.exec(text) === null)
128
+ return BAD; // junk or valueless attr
129
+ const j = ATTR_NAME_EQ_RE.lastIndex;
130
+ const quote = text[j];
131
+ if (quote !== '"' && quote !== "'")
132
+ return BAD;
133
+ const close = text.indexOf(quote, j + 1);
134
+ return close === BAD ? BAD : close + 1;
135
+ }
136
+ /** Read an `&entity;` reference; a raw `&` is not well formed. */
137
+ function readEntity(text, i) {
138
+ ENTITY_RE.lastIndex = i + 1;
139
+ if (ENTITY_RE.exec(text) === null)
140
+ return BAD;
141
+ return ENTITY_RE.lastIndex;
142
+ }
143
+ /** Extract an XML attribute value (double- or single-quoted) from a tag body. */
144
+ export function attrValue(attrs, name) {
145
+ const re = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`);
146
+ const m = re.exec(attrs);
147
+ if (m === null)
148
+ return null;
149
+ return m[2] ?? m[3] ?? '';
150
+ }
151
+ //# sourceMappingURL=xml.js.map