Files
reverse-engineering-anything/src/domain/xmlDocument.ts
T
fd39977c32 docs(desktop): set up a desktop build host from a fresh Linux machine (#9184)
* fix(desktop): keep a program word and a directory's neighbor

The program after env stays the program even when a captured file sits in a
folder of that name. A selected directory is not a database, so a neighboring
file keeps the parent folder's spelling.

* fix(desktop): record whether a selected path is a directory

Capture stores the walked path's kind. A neighboring file of a selected
directory keeps the parent folder's spelling even when that directory never
changes and its children are not content roots.

* fix(desktop): mark containing paths without a pairwise scan

A recorded path marks its known ancestors as directories in one walk up
the path, instead of comparing every recorded path with every other.

* fix(desktop): keep a selected folder that is named like a database log

A folder the caller selected stays a selected root when its name ends in
-journal or -wal. A generated log beside a database file still does not.

* fix(desktop): refuse a build command that exceeds the argument vector

Splitting one argument still leaves the whole command under Linux's E2BIG
limit. A request past that budget is refused before the process starts.

* fix(desktop): remap a captured path in an env assignment

The program word stays the program. A NAME=value assignment keeps its name
and follows a captured path value when that path's spelling moves.

* fix(desktop): keep oversized text out of draft guards

A value or line longer than the draft's text bound is not copied into a
guard, so one large frame cannot fill the task by itself.

* docs(desktop): correct dconf and check-preview guidance

A dconf version kept without its bytes is unreadable, not an empty key
list. A values preview is copied into a check only when it is an uncut scalar.

* fix(desktop): keep an empty env value empty

An empty assignment is not a path. Resolving it named the working directory
and rewrote the value when that directory's spelling moved.

* fix(desktop): keep a run's font on the run, not the frame's text

A formatting attribute such as Scribus FONT is not the document's words.
Joining it across runs made a frame-wide text check of ItalicBold that
failed when a run was split. The font stays a value check on its own run.

* fix(desktop): do not pair JSON records by a selector-shaped field

A field named @record_id matched the id suffix and became [@record_id=one],
which reads an XML attribute. Records paired while their pointers matched
nothing. Names that start with @ or > are not id fields.

* fix(desktop): count a save-numbered id once when pairing members

xml:id and draw:id values such as id123456 were both generated and
numbered, so the same selector was counted twice and refused. A text
edit of two elements keyed only by those ids now keeps its check.

* fix(desktop): preserve selected state directory aliases

* docs(desktop): explain tool and skill refresh prerequisites

* fix(desktop): align nested state selections with aliases

* fix(desktop): check build runtime export capability

* fix(desktop): provide complete runtime repack command

* fix(desktop): retain runtime probe failures

* test(desktop): identify capture independently of schema preflight

* fix(desktop): preserve document URLs and value formats

* fix(desktop): bound added checks and omit noisy text guards

* fix(desktop): exclude unseeded locks from task checks

* fix(desktop): preserve draft bounds and launch URLs

* fix(desktop): render draft values using their source format

* fix(desktop): preserve launch URLs and JSON draft values

* fix(desktop): recognize captured file URL documents

* fix(desktop): render captured file URLs for runtime home

* fix(desktop): distinguish phone numbers from save metadata

* fix(desktop): preserve launch URLs and user text

* fix(desktop): preserve captured URLs and user text

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): render captured file URLs at launch

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): retain clean text in noisy record fields

* fix(desktop): classify added line values in one pass

* fix(desktop): apply XML quote rules to text drafts

* fix(desktop): distinguish XML attributes from quoted text

* fix(desktop): preserve parsed XML line context

* fix(desktop): retain partial XML syntax facts

* fix(desktop): recognize markdown autolinks in drafts

* fix(desktop): reuse export exclusions in task drafts

* docs(desktop): clarify gzip scrub limits

* style(desktop): format the noisy-record draft files with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): move launch execution to boundary suite

* fix(desktop): redact build schema failure output

* refactor(desktop): normalize schema failure once

* fix(desktop): omit unsafe schema parser snippets

* fix(desktop): preserve draft change and selected path evidence

* test(desktop): group retained dconf note regressions

* fix(desktop): respect archive entry format in draft checks

* test(desktop): separate unowned-file draft note cases

* fix(desktop): preserve explicitly selected journal documents

* fix(xml): avoid scanning selected ancestors per tag

* fix(desktop): exclude covered text runs from draft counts

* fix: preserve text when drafting desktop checks

* fix: restore nested desktop state aliases

* refactor: share UUID recognition across draft checks

* fix: retain multiline XML attribute context

* fix: retain relationship identifiers in task drafts

* docs: clarify desktop draft identifier handling

* test(desktop): type formatting run cases as pairs

* test(desktop): separate markup boundary regression group

* test(desktop): remove duplicate seed filename case

* docs(desktop): clarify task checks before scratch export

* fix(desktop): respell selected paths in file URLs

* test(desktop): group captured document spelling cases

* fix(desktop): bound draft preview serialization

* fix(desktop): restore selected state aliases

* fix(desktop): respect entry format in draft noise

* test(desktop): match exported alias evidence at its JSON boundary

* fix(desktop): retain app state logs in exported seeds

* fix: align desktop numeric seed comparisons

* test(desktop): keep one draft exclusion fixture import

* test(desktop): retain log eligibility across draft test groups

* style: fix desktop launch test formatting

* style: format native command path schema

* fix(desktop): map selected external launch paths

* style(desktop): format seed document regressions

* test(desktop): group launch URL regressions

* fix(desktop): omit redundant nested state aliases

* fix(desktop): compare added free text as a whole

* fix(desktop): check recompressed seeds as shipped

* docs(desktop): clarify recovered seed format

* test(desktop): group shipped gzip seed checks

* fix: retain quoted identifier text in task drafts

* fix: recognize Unicode XML in desktop state readers

* fix(desktop): bound nested draft checks

* fix(desktop): explain missing arrays in truncated views

* fix(desktop): find XML type references in archives

* test(desktop): remove duplicated capture assertions

* fix(desktop): retain nested omitted text boundaries

* style(desktop): format draft value imports

* test(desktop): group model and draft value checks

* fix(desktop): map captured launch paths once

* refactor: remove unused whole-text guard state

* refactor: stop passing unused text children

* test: align free-text checks with whole-note policy

* fix: validate recompressed desktop archive seeds

* style(desktop): format archive seed regression

* fix(desktop): retain placed-item sibling guards

* fix(desktop): redact runtime schema errors

* fix(desktop): normalize captured file URLs

* fix(desktop): report seeded files by capture path

* fix(desktop): retain startup readiness failure stage

* fix(desktop): retain JSON date checks

* fix(desktop): preserve archive formatting checks

* fix(desktop): preserve explicitly selected journal files

* fix(desktop): report build command transport limits

* docs(desktop): clarify unreadable dconf versions

* test(desktop): remove duplicate history regression

* fix(desktop): align XML names with exported reader

* fix(desktop): scrub XML independently of reader compatibility

* style(desktop): format review fixes

* style(desktop): format capture review fixes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 22:59:45 +08:00

296 lines
10 KiB
TypeScript

import { DOMParser, onErrorStopParsing, type Document } from "@xmldom/xmldom";
import { SaxesParser, type SaxesTagNS } from "saxes";
import { err, ok, type Result } from "./result.js";
/** XML 1.0 literal line endings; Unicode separators remain source characters. */
export const XML_LINE_ENDING = /\r\n?|\n/gu;
export class XmlDocumentParseError extends Error {
readonly _tag = "XmlDocumentParseError";
constructor(
readonly reason: "malformed" | "doctype" | "internal-subset",
cause?: unknown,
) {
super(
reason === "doctype"
? "XML document types are unsupported"
: reason === "internal-subset"
? "XML document type internal subsets are unsupported"
: `XML is not well-formed${cause instanceof Error ? `: ${cause.message}` : ""}`,
{ cause },
);
}
}
/** Whether a DOCTYPE's text, as saxes reports it, opens an internal subset: a `[` outside its quoted literals. */
const declaresInternalSubset = (declaration: string): boolean =>
declaration.replace(/"[^"]*"|'[^']*'/gu, "").includes("[");
/** XML 1.0's `NameStartChar`, and the further characters `NameChar` allows. */
const NAME_START = String.raw`:A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}`;
const NAME_MORE = String.raw`\-.0-9\u{B7}\u{300}-\u{36F}\u{203F}\u{2040}`;
/** XML element opening grammar shared by the desktop format readers. */
export const XML_ELEMENT_OPENING = String.raw`<[${NAME_START}][${NAME_START}${NAME_MORE}]*(?=[ \t\r\n]|/?>)`;
const XML_OPENING = new RegExp(
`^(?:<\\?xml\\b|<!DOCTYPE\\b|${XML_ELEMENT_OPENING})`,
"u",
);
/** Whether text begins with a possible XML declaration, DOCTYPE or start tag. */
export const opensLikeXml = (source: string): boolean =>
XML_OPENING.test(source);
/** XML 1.0's `PubidChar` but the apostrophe, which only a double-quoted literal may hold. */
const PUBID = String.raw`\n\r a-zA-Z0-9\-()+,./:=?;!*#@$_%`;
const SPACE = String.raw`[ \t\r\n]`;
/**
* A DOCTYPE without an internal subset, as XML 1.0's `doctypedecl` spells it
* between `<!DOCTYPE` and `>`: a name, then optionally `SYSTEM` and a literal,
* or `PUBLIC` and a public identifier and a literal.
*/
const EXTERNAL_DOCTYPE = new RegExp(
`^${SPACE}+[${NAME_START}][${NAME_START}${NAME_MORE}]*` +
`(?:${SPACE}+(?:SYSTEM|PUBLIC${SPACE}+(?:"[${PUBID}']*"|'[${PUBID}]*'))` +
`${SPACE}+(?:"[^"]*"|'[^']*'))?${SPACE}*$`,
"u",
);
/**
* Which DOCTYPE a document may have: any, none, or with `external` one
* without an internal subset, so the document declares no entity. Neither
* parser reads an external DTD.
*/
type DoctypePolicy = "allow" | "reject" | "external";
/**
* Read XML syntax as a stream, throwing at the first error; it holds only the
* open elements. Saxes reads a DOCTYPE without checking it, so the policy
* checks one it admits without an internal subset against XML's grammar.
*/
const checkXmlSyntax = (
source: string,
doctype: DoctypePolicy,
listen?: (syntax: SaxesParser<{ xmlns: true }>) => void,
nameSupported?: (name: string) => boolean,
): void => {
const syntax = new SaxesParser({
xmlns: true,
defaultXMLVersion: "1.0",
forceXMLVersion: true,
});
listen?.(syntax);
const checkName = (name: string) => {
if (nameSupported !== undefined && !nameSupported(name))
throw new XmlDocumentParseError("malformed");
};
if (nameSupported !== undefined) {
syntax.on("opentagstart", (tag) => checkName(tag.name));
syntax.on("attribute", (attribute) => checkName(attribute.name));
syntax.on("processinginstruction", (instruction) =>
checkName(instruction.target),
);
}
syntax.on("doctype", (declaration) => {
if (doctype === "reject") throw new XmlDocumentParseError("doctype");
if (doctype !== "external") return;
if (declaresInternalSubset(declaration))
throw new XmlDocumentParseError("internal-subset");
if (!EXTERNAL_DOCTYPE.test(declaration))
throw new XmlDocumentParseError("malformed");
checkName(declaration.trimStart().split(/[ \t\r\n]/u, 1)[0] ?? "");
});
syntax.write(source).close();
};
/**
* Whether XML passes the syntax check `parseXmlDocument` makes, without
* building a document. An internal subset's syntax needs the DOM parser, so
* the policy must refuse one.
*/
export const isWellFormedXml = (
source: string,
policy: {
readonly doctype: Exclude<DoctypePolicy, "allow">;
readonly nameSupported?: (name: string) => boolean;
},
): boolean => {
try {
checkXmlSyntax(source, policy.doctype, undefined, policy.nameSupported);
return true;
} catch {
return false;
}
};
/** An element as `xmlElementRanges` offers it: its namespace and local name, and an attribute's value by namespace and local name. */
export interface XmlElementFacts {
readonly uri: string;
readonly local: string;
readonly attribute: (uri: string, local: string) => string | undefined;
}
/**
* The source ranges of the elements `select` picks, from the `<` of the start
* tag to the `>` that ends the element, outermost first and none inside
* another picked one; read with the syntax check `parseXmlDocument` makes, so
* text in a comment, CDATA or an attribute is never an element.
*/
export const xmlElementRanges = (
source: string,
select: (element: XmlElementFacts) => boolean,
policy: { readonly doctype: Exclude<DoctypePolicy, "allow"> },
): Result<
readonly { readonly start: number; readonly end: number }[],
XmlDocumentParseError
> => {
const ranges: { start: number; end: number }[] = [];
// Each open element's start, and whether it was picked.
const open: { start: number; picked: boolean }[] = [];
let selectedAncestor = false;
try {
checkXmlSyntax(source, policy.doctype, (syntax) => {
// Saxes reports the opening after reading the name and one character past it, none of which is `<`.
syntax.on("opentagstart", () => {
open.push({
start: source.lastIndexOf("<", syntax.position - 1),
picked: false,
});
});
syntax.on("opentag", (tag: SaxesTagNS) => {
const element = open.at(-1);
if (element === undefined || selectedAncestor) return;
element.picked = select({
uri: tag.uri,
local: tag.local,
attribute: (uri, local) =>
Object.values(tag.attributes).find(
(attribute) => attribute.uri === uri && attribute.local === local,
)?.value,
});
selectedAncestor = element.picked;
});
syntax.on("closetag", () => {
const element = open.pop();
if (element?.picked === true) {
ranges.push({ start: element.start, end: syntax.position });
selectedAncestor = false;
}
});
});
return ok(ranges.sort((left, right) => left.start - right.start));
} catch (cause: unknown) {
return err(
cause instanceof XmlDocumentParseError
? cause
: new XmlDocumentParseError("malformed", cause),
);
}
};
/** Inclusive source ranges of attribute quotes, parsed without reading a DTD. */
export const xmlAttributeQuoteRanges = (
source: string,
): Result<
readonly { readonly start: number; readonly end: number }[],
{
readonly error: XmlDocumentParseError;
readonly ranges: readonly {
readonly start: number;
readonly end: number;
}[];
readonly examined: number;
}
> => {
const ranges: { start: number; end: number }[] = [];
let reader: SaxesParser<{ xmlns: true }> | undefined;
try {
checkXmlSyntax(source, "allow", (syntax) => {
reader = syntax;
syntax.on("attribute", () => {
const end = syntax.position - 1;
const quote = source[end];
if (quote !== '"' && quote !== "'")
throw new XmlDocumentParseError("malformed");
const start = source.lastIndexOf(quote, end - 1);
if (start < 0) throw new XmlDocumentParseError("malformed");
ranges.push({ start, end });
});
});
return ok(ranges);
} catch (cause: unknown) {
return err({
error:
cause instanceof XmlDocumentParseError
? cause
: new XmlDocumentParseError("malformed", cause),
ranges,
examined: Math.max(0, (reader?.position ?? 0) - 1),
});
}
};
/** One boundary owns strict XML syntax, under a DOCTYPE policy, and the source-located DOM projection. */
export const parseXmlDocument = (
source: string,
policy: {
readonly doctype: DoctypePolicy;
readonly lineEndings: "preserve" | "normalize";
readonly nameSupported?: (name: string) => boolean;
},
): Result<Document, XmlDocumentParseError> => {
try {
checkXmlSyntax(source, policy.doctype, undefined, policy.nameSupported);
// Saxes owns well-formedness outside DTDs. The DOM parser additionally
// checks DTD syntax and builds source-located nodes; its advisory warnings
// (including literal U+FFFD) are not byte-decoding or syntax failures.
return ok(
new DOMParser({
normalizeLineEndings:
policy.lineEndings === "preserve"
? (text) => text
: (text) => text.replace(XML_LINE_ENDING, "\n"),
onError: onErrorStopParsing,
}).parseFromString(source, "application/xml"),
);
} catch (cause: unknown) {
return err(
cause instanceof XmlDocumentParseError
? cause
: new XmlDocumentParseError("malformed", cause),
);
}
};
/** A DOCTYPE's name and external identifiers, null where it gives none. */
export interface DeclaredDocumentType {
readonly name: string;
readonly publicId: string | null;
readonly systemId: string | null;
}
/** A quoted literal's value; the DOM keeps the quotes, and holds "" where there is no literal. */
const literalValue = (literal: string): string | null =>
literal === "" ? null : literal.slice(1, -1);
/**
* The document's DOCTYPE as declared, or undefined without one. The public
* identifier is normalized as XML matches it, and as expat reports it: each
* run of spaces and line breaks becomes one space, and the ends are trimmed.
*/
export const declaredDocumentType = (
document: Document,
): DeclaredDocumentType | undefined => {
const doctype = document.doctype;
if (doctype === null) return undefined;
const publicId = literalValue(doctype.publicId);
return {
name: doctype.name,
publicId:
publicId === null
? null
: publicId.replace(/[ \r\n]+/gu, " ").replace(/^ | $/gu, ""),
systemId: literalValue(doctype.systemId),
};
};