Files
Sebastian Poxhofer 97327c3d28 refactor(linters): add prefer-coerce-array rule (#45492)
* refactor(linters): add prefer-coerce-array rule

Refs #45217

`x ?? []` and `coerceArray(x)` say the same thing, but only the second
one is searchable and consistent. The rule flags a `??` whose default is
a bare `[]` literal; a non-empty default or an asserted `([] as Foo[])`
still passes.

Applying it across `lib/` surfaced three spots the helper cannot cover,
which keep `?? []` with a disable comment or a narrower fix:

- `package-files.ts` destructures a tuple, which `coerceArray()` widens
- `generate.spec.ts` defaults `matchAll()`, an iterator rather than an array
- `mise/upgradeable-tooling.ts` declares a `Record` return, so its
  `?? []` fallback was wrong to begin with and becomes `coerceObject()`

* refactor(platform/github): hoist the GHE version header lookup

`Object.entries(...).find()` returns a tuple, and defaulting it with
`?? []` only to destructure the second element reads as an array
default when it is not one. Name the found entry instead, which drops
the `?? []` rather than routing a tuple through `coerceArray()`.
2026-08-28 09:27:58 +00:00

197 lines
5.0 KiB
TypeScript

import { XmlDocument, type XmlElement } from 'xmldoc';
import { z } from 'zod/v4';
import { coerceArray } from '../../../util/array.ts';
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
class XmlWriter {
private lines: string[] = [];
private level: number;
constructor(level = 0) {
this.level = level;
}
value(name: string, value: string | undefined): void {
if (value === undefined) {
return;
}
this.lines.push(`${this.indent()}<${name}>${escapeXml(value)}</${name}>`);
}
node(name: string, renderChildren: (xml: XmlWriter) => void): void {
this.renderNode(name, renderChildren, false);
}
nodeOrEmpty(name: string, renderChildren: (xml: XmlWriter) => void): void {
this.renderNode(name, renderChildren, true);
}
hasContent(): boolean {
return this.lines.length > 0;
}
toString(): string {
return this.lines.join('\n');
}
private renderNode(
name: string,
renderChildren: (xml: XmlWriter) => void,
preserveEmpty: boolean,
): void {
const contentStart = this.lines.length;
this.level += 1;
renderChildren(this);
this.level -= 1;
const content = this.lines.splice(contentStart);
if (!content.length) {
if (preserveEmpty) {
this.lines.push(`${this.indent()}<${name} />`);
}
return;
}
this.lines.push(
`${this.indent()}<${name}>`,
...content,
`${this.indent()}</${name}>`,
);
}
private indent(): string {
return ' '.repeat(this.level);
}
}
function shrinkToUsefulSize(original: string, trimmed: string): string {
if (trimmed.length >= original.length) {
return original;
}
return trimmed;
}
function renderRelocationNode(
xml: XmlWriter,
relocation: XmlElement | undefined,
): void {
if (!relocation) {
return;
}
xml.nodeOrEmpty('relocation', () => {
xml.value('groupId', relocation.valueWithPath('groupId'));
xml.value('artifactId', relocation.valueWithPath('artifactId'));
xml.value('version', relocation.valueWithPath('version'));
xml.value('message', relocation.valueWithPath('message'));
});
}
function trimMetadataXml(metadata: XmlDocument, input: string): string {
const version = metadata.descendantWithPath('version')?.val;
const latest = metadata.descendantWithPath('versioning.latest')?.val;
const release = metadata.descendantWithPath('versioning.release')?.val;
const versions = coerceArray(
metadata
.descendantWithPath('versioning.versions')
?.childrenNamed('version')
.map((child) => child.val),
);
const snapshot = metadata.descendantWithPath('versioning.snapshot');
const timestamp = snapshot?.childNamed('timestamp')?.val;
const buildNumber = snapshot?.childNamed('buildNumber')?.val;
const xml = new XmlWriter();
xml.node('metadata', () => {
xml.value('version', version);
xml.node('versioning', () => {
xml.value('latest', latest);
xml.value('release', release);
xml.node('versions', () => {
for (const trimmedVersion of versions) {
xml.value('version', trimmedVersion);
}
});
xml.node('snapshot', () => {
xml.value('timestamp', timestamp);
xml.value('buildNumber', buildNumber);
});
});
});
if (!xml.hasContent()) {
return input;
}
return shrinkToUsefulSize(input, [xmlHeader, xml.toString()].join('\n'));
}
function trimPomXml(project: XmlDocument, input: string): string {
const homepage = project.valueWithPath('url');
const sourceUrl = project.valueWithPath('scm.url');
const groupId = project.valueWithPath('groupId');
const relocation = project.descendantWithPath(
'distributionManagement.relocation',
);
const parent = project.childNamed('parent');
const xml = new XmlWriter();
xml.node('project', () => {
xml.value('groupId', groupId);
xml.value('url', homepage);
xml.node('scm', () => {
xml.value('url', sourceUrl);
});
xml.node('distributionManagement', () => {
renderRelocationNode(xml, relocation);
});
xml.node('parent', () => {
xml.value('groupId', parent?.valueWithPath('groupId'));
xml.value('artifactId', parent?.valueWithPath('artifactId'));
xml.value('version', parent?.valueWithPath('version'));
});
});
if (!xml.hasContent()) {
return input;
}
return shrinkToUsefulSize(input, [xmlHeader, xml.toString()].join('\n'));
}
export function trimMavenXml(input: string): string {
let parsed: XmlDocument;
try {
parsed = new XmlDocument(input);
} catch {
return input;
}
if (parsed.name.includes(':')) {
return input;
}
switch (parsed.name) {
case 'metadata':
return trimMetadataXml(parsed, input);
case 'project':
return trimPomXml(parsed, input);
default:
return input;
}
}
export const CachedMavenXml = z.string().transform(trimMavenXml);