feat(fls): write the topology of a .fls, loaded by Java as the original

topologyWriter.ts writes konfiguration.xml from Java's view of a project:
the version line, the node items (label position, device with name,
host label flags, interfaces or ports), the cable items and the
documentation items. What a device's constructor makes is changed through
getters, as XMLEncoder does, and more is added; fewer than it makes set the
list anew. Cable ends name the port objects the getters return (the writer
now names a getter's result, <void id="Port0" property="port"/>), and each
cable carries the nodes the editor draws it between.

Checked by Java against Java: every corpus file, read and written again,
loads in Java FILIUS (the oracle's loading method, --sha256 ties its result
to the written bytes) to the same devices, names, positions, interfaces,
cables and documentation as the original; so does a synthetic topology with
what the corpus never sets. The oracle and the reader now also report a
cable's drawing ends (JCablePanel ziel1/ziel2): in the corpus the nodes it
joins, for all 183 cables. Mutation probes rewrite the files and let Java
load each mutant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 08:04:21 +00:00
co-authored by Claude Opus 5.5
parent 3795316fae
commit 314f696515
17 changed files with 16251 additions and 204 deletions
+1
View File
@@ -9,4 +9,5 @@ export * from './javaString.js';
export * from './javabeans.js';
export * from './systemSoftware.js';
export * from './topology.js';
export * from './topologyWriter.js';
export * from './zip.js';
+57 -14
View File
@@ -21,7 +21,22 @@ export type BeanValue =
| BeanPrimitive
| BeanObject
| BeanArray
| { readonly kind: 'class'; readonly name: string };
| { readonly kind: 'class'; readonly name: string }
| BeanReference;
/**
* An object only Java makes — a getter's result, such as the port a network
* interface's constructor creates — named where the getter is called
* (`<void id="Port0" property="port"/>`) and referenced after.
*/
export interface BeanReference {
readonly kind: 'reference';
readonly name: string;
}
export function reference(name: string): BeanReference {
return { kind: 'reference', name };
}
export interface BeanObject {
readonly kind: 'object';
@@ -42,14 +57,14 @@ export interface BeanArray {
export type BeanStatement =
/** target.setP(value) */
| { readonly set: string; readonly value: BeanValue }
/** target.getP(), then the statements on its result */
| { readonly get: string; readonly statements: readonly BeanStatement[] }
/** target.getP(), then the statements on its result; `as` names the result */
| { readonly get: string; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference }
/** target.m(args) */
| { readonly call: string; readonly args: readonly BeanValue[] }
/** target.set(index, value) on a list */
| { readonly setIndex: number; readonly value: BeanValue }
/** target.get(index), then the statements on its result */
| { readonly getIndex: number; readonly statements: readonly BeanStatement[] };
/** target.get(index), then the statements on its result; `as` names the result */
| { readonly getIndex: number; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference };
export const int = (value: number): BeanPrimitive => ({ kind: 'int', value });
export const long = (value: number | bigint): BeanPrimitive => ({ kind: 'long', value });
@@ -70,7 +85,7 @@ export function encodeJavaBeans(values: readonly BeanValue[]): string {
}
class Writer {
private readonly ids = new Map<BeanObject | BeanArray, string>();
private readonly ids = new Map<BeanObject | BeanArray | BeanReference, string>();
private readonly counters = new Map<string, number>();
/** Objects written more than once need an id; found before writing. */
constructor(private readonly shared: ReadonlySet<BeanObject | BeanArray>) {}
@@ -109,6 +124,12 @@ class Writer {
case 'object':
this.object(value, depth, lines);
break;
case 'reference': {
const id = this.ids.get(value);
if (id === undefined) throw new Error(`reference '${value.name}' used before the getter that names it`);
lines.push(`${indent}<object idref="${id}"/>`);
break;
}
}
}
}
@@ -171,10 +192,7 @@ class Writer {
if ('set' in statement) {
nested(`<void property="${escapeAttribute(statement.set)}"`, () => this.value(statement.value, depth + 1, lines));
} else if ('get' in statement) {
if (statement.statements.length === 0) return;
nested(`<void property="${escapeAttribute(statement.get)}"`, () => {
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
});
this.getter(`property="${escapeAttribute(statement.get)}"`, statement.statements, statement.as, depth, lines);
} else if ('call' in statement) {
if (statement.args.length === 0) {
lines.push(`${indent}<void method="${escapeAttribute(statement.call)}"/>`);
@@ -186,13 +204,38 @@ class Writer {
} else if ('setIndex' in statement) {
nested(`<void index="${statement.setIndex}"`, () => this.value(statement.value, depth + 1, lines));
} else {
if (statement.statements.length === 0) return;
nested(`<void index="${statement.getIndex}"`, () => {
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
});
this.getter(`index="${statement.getIndex}"`, statement.statements, statement.as, depth, lines);
}
}
/** A getter's call: left out when nothing happens to its result and nothing names it. */
private getter(
attribute: string,
statements: readonly BeanStatement[],
name: BeanReference | undefined,
depth: number,
lines: string[],
): void {
const indent = ' '.repeat(depth);
if (statements.length === 0 && name === undefined) return;
let idAttribute = '';
if (name !== undefined) {
if (this.ids.has(name)) throw new Error(`reference '${name.name}' named twice`);
const count = this.counters.get(name.name) ?? 0;
this.counters.set(name.name, count + 1);
const id = `${name.name}${count}`;
this.ids.set(name, id);
idAttribute = ` id="${id}"`;
}
if (statements.length === 0) {
lines.push(`${indent}<void${idAttribute} ${attribute}/>`);
return;
}
lines.push(`${indent}<void${idAttribute} ${attribute}>`);
for (const inner of statements) this.statement(inner, depth + 1, lines);
lines.push(`${indent}</void>`);
}
private idFor(value: BeanObject | BeanArray, name: string): string | undefined {
if (!this.shared.has(value)) return undefined;
const count = this.counters.get(name) ?? 0;
+17 -1
View File
@@ -60,6 +60,11 @@ export interface FlsPortReference {
export interface FlsLink {
ends: [FlsPortReference, FlsPortReference];
wireless: boolean;
/**
* The nodes the editor draws the cable between (JCablePanel ziel1, ziel2),
* by index; null where the file names none.
*/
panel: [number | null, number | null];
}
export type FlsColor = [red: number, green: number, blue: number, alpha: number];
@@ -146,7 +151,10 @@ export function readTopology(values: readonly JavaValue[]): FlsTopology {
const nodes: FlsNode[] = [];
for (const [index, item] of nodeItems.entries()) {
const node = context.node(item, index, nodes.length);
if (node !== undefined) nodes.push(node);
if (node !== undefined) {
if (isJavaObject(item)) context.nodeItems.set(item, nodes.length);
nodes.push(node);
}
}
const links: FlsLink[] = [];
for (const [index, item] of cableItems.entries()) {
@@ -168,6 +176,8 @@ function listOf(value: JavaValue | undefined, className: string): JavaValue[] {
class Reader {
/** Every port object a node owns, to resolve cable ends by identity as Java does. */
private readonly portOwners = new Map<JavaObject, FlsPortReference>();
/** Every node item read, to resolve a cable panel's ends. */
readonly nodeItems = new Map<JavaObject, number>();
constructor(private readonly warnings: string[]) {}
@@ -310,9 +320,15 @@ class Reader {
this.warnings.push(`cable ${itemIndex + 1}: an end is not a port of a device, left out`);
return undefined;
}
const panel = (item as JavaObject).properties.get('kabelpanel');
const panelEnd = (property: string) => {
const target = isJavaObject(panel) ? panel.properties.get(property) : undefined;
return isJavaObject(target) ? (this.nodeItems.get(target) ?? null) : null;
};
return {
ends: [first, second],
wireless: this.booleanProperty(cable, 'wireless', `cable ${itemIndex + 1}`),
panel: [panelEnd('ziel1'), panelEnd('ziel2')],
};
}
+222
View File
@@ -0,0 +1,222 @@
// Writes `projekt/konfiguration.xml` from Java's view of a project — the
// counterpart of topology.ts. The four values Java's netzwerkLaden reads: the
// version line, the list of GUIKnotenItem, of GUIKabelItem and of
// GUIDocuItem. What a node's constructor makes (its interfaces, a switch's
// ports) is reached through getters and changed there, as XMLEncoder does;
// what the constructor does not make is added. Cable ends name the port
// objects of the nodes, so the same cable joins the same ports when Java
// loads the file.
//
// Checked by Java: every corpus file, read and written again, loads in Java
// FILIUS to the same topology (topologyWriter.test.ts, tools/java-oracle).
import {
encodeJavaBeans,
int,
object,
reference,
type BeanObject,
type BeanReference,
type BeanStatement,
type BeanValue,
} from './javaBeansWriter.js';
import type { FlsDocumentationItem, FlsInterface, FlsLink, FlsNode, FlsNodeClass } from './topology.js';
/** The version line of the files this writer makes: the format of FILIUS 2.14. */
export const WRITTEN_VERSION_LINE = 'Filius version: 2.14.0 (mdg-filius)';
export type NodeToWrite = Pick<
FlsNode,
'javaClass' | 'name' | 'x' | 'y' | 'useIpAsName' | 'useMacAsName' | 'interfaces' | 'portCount'
>;
export interface TopologyToWrite {
readonly nodes: readonly NodeToWrite[];
readonly links: readonly FlsLink[];
readonly documentation: readonly FlsDocumentationItem[];
}
/** What each device class's constructor makes, and whether it is a host. */
const CONSTRUCTED: Record<FlsNodeClass, { count: number; local: boolean; host: boolean }> = {
Rechner: { count: 1, local: false, host: true },
Notebook: { count: 1, local: false, host: true },
Vermittlungsrechner: { count: 1, local: false, host: false },
Gateway: { count: 2, local: false, host: false },
Switch: { count: 24, local: true, host: false },
Modem: { count: 1, local: true, host: false },
};
export function writeTopologyXml(topology: TopologyToWrite): string {
return encodeJavaBeans(topologyBeans(topology));
}
export function topologyBeans(topology: TopologyToWrite): BeanValue[] {
const ports: BeanValue[][] = [];
const items = topology.nodes.map((node, index) => {
const nodePorts: BeanValue[] = [];
ports[index] = nodePorts;
return nodeItem(node, nodePorts);
});
const cables = topology.links.map((link) => cableItem(link, items, ports));
return [
WRITTEN_VERSION_LINE,
listOf(items),
listOf(cables),
listOf(topology.documentation.map(documentationItem)),
];
}
function listOf(values: readonly BeanValue[]): BeanObject {
return object(
'java.util.LinkedList',
values.map((value) => ({ call: 'add', args: [value] })),
);
}
/**
* A GUIKnotenItem: its label (the position) and its device; `ports`
* receives the device's port objects. The label's size is Java's to work
* out when it loads the file; the written 0 × 0 does not move it (measured
* on the corpus).
*/
function nodeItem(node: NodeToWrite, ports: BeanValue[]): BeanObject {
const label = object('filius.gui.netzwerksicht.JSidebarButton', [
{ set: 'bounds', value: object('java.awt.Rectangle', [], [int(node.x), int(node.y), int(0), int(0)]) },
]);
return object('filius.gui.netzwerksicht.GUIKnotenItem', [
{ set: 'imageLabel', value: label },
{ set: 'knoten', value: device(node, ports) },
]);
}
function device(node: NodeToWrite, ports: BeanValue[]): BeanObject {
const constructed = CONSTRUCTED[node.javaClass];
const statements: BeanStatement[] = [{ set: 'name', value: node.name }];
if (constructed.host) {
statements.push({ set: 'useIPAsName', value: node.useIpAsName }, { set: 'useMACAsName', value: node.useMacAsName });
}
if (constructed.local) {
statements.push(
constructedList('anschluesse', node.portCount, constructed.count, ports, () => ({ statements: [] }), () => {
const port = object('filius.hardware.Port');
return { value: port, port };
}),
);
} else {
statements.push(
constructedList(
'netzwerkInterfaces',
node.interfaces.length,
constructed.count,
ports,
(index) => {
const port = reference('Port');
return { statements: [...interfaceStatements(node.interfaces[index]!), { get: 'port', statements: [], as: port }], port };
},
(index) => {
const port = reference('Port');
const nic = object('filius.hardware.NetzwerkInterface', [
...interfaceStatements(node.interfaces[index]!),
{ get: 'port', statements: [], as: port },
]);
return { value: nic, port };
},
),
);
}
return object(`filius.hardware.knoten.${node.javaClass}`, statements);
}
/**
* A list the constructor filled with `constructed` elements: those are
* changed through `get(index)`, more are added. With fewer, the list is set
* anew. `ports` receives each element's port, in order.
*/
function constructedList(
property: string,
count: number,
constructed: number,
ports: BeanValue[],
existing: (index: number) => { statements: BeanStatement[]; port?: BeanReference },
added: (index: number) => { value: BeanValue; port: BeanValue },
): BeanStatement {
if (count < constructed) {
const values = Array.from({ length: count }, (_, index) => {
const { value, port } = added(index);
ports.push(port);
return value;
});
return { set: property, value: listOf(values) };
}
const statements: BeanStatement[] = [];
for (let index = 0; index < count; index++) {
if (index < constructed) {
// The element's own port, or — a switch's — the element itself.
const { statements: inner, port } = existing(index);
if (port === undefined) {
const element = reference('Port');
ports.push(element);
statements.push({ getIndex: index, statements: inner, as: element });
} else {
ports.push(port);
statements.push({ getIndex: index, statements: inner });
}
} else {
const { value, port } = added(index);
ports.push(port);
statements.push({ call: 'add', args: [value] });
}
}
return { get: property, statements };
}
/**
* A network interface's setters. An address Java's setter rejected (null)
* is not written: setIp(null) throws, and the constructor's address stays.
* A MAC the file lacked stays unwritten, so Java rolls one as it did.
*/
function interfaceStatements(nic: FlsInterface): BeanStatement[] {
const statements: BeanStatement[] = [];
if (nic.ip !== null) statements.push({ set: 'ip', value: nic.ip });
if (nic.mask !== null) statements.push({ set: 'subnetzMaske', value: nic.mask });
if (nic.mac !== undefined) statements.push({ set: 'mac', value: nic.mac });
statements.push({ set: 'gateway', value: nic.gateway }, { set: 'dns', value: nic.dns });
return statements;
}
function cableItem(link: FlsLink, items: readonly BeanObject[], ports: readonly BeanValue[][]): BeanObject {
const [first, second] = link.ends;
const end = (reference: { node: number; port: number }) => ports[reference.node]![reference.port]!;
const cable = object('filius.hardware.Kabel', [
{ set: 'anschluesse', value: { kind: 'array', componentClass: 'filius.hardware.Port', items: [end(first), end(second)] } },
...(link.wireless ? [{ set: 'wireless', value: true }] : []),
]);
return object('filius.gui.netzwerksicht.GUIKabelItem', [
{ set: 'dasKabel', value: cable },
{
get: 'kabelpanel',
statements: [
{ set: 'ziel1', value: items[first.node]! },
{ set: 'ziel2', value: items[second.node]! },
],
},
]);
}
function documentationItem(item: FlsDocumentationItem): BeanObject {
const statements: BeanStatement[] = [];
if (item.color !== null) statements.push({ set: 'color', value: object('java.awt.Color', [], item.color.map(int)) });
if (item.font !== null) {
statements.push({ set: 'font', value: object('java.awt.Font', [], [item.font.name, int(item.font.style), int(item.font.size)]) });
}
statements.push(
{ set: 'height', value: int(item.height) },
{ set: 'text', value: item.text },
{ set: 'type', value: int(item.type) },
{ set: 'width', value: int(item.width) },
{ set: 'x', value: int(item.x) },
{ set: 'y', value: int(item.y) },
);
return object('filius.gui.netzwerksicht.GUIDocuItem', statements);
}