From c40ac72c8c795f46ce0830f5cd34f121259ab4ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:34:34 +0000 Subject: [PATCH 01/20] Add gap analysis for full ReqIF import and export Documents the current coverage of the ReqIF 1.2 schema, the findings from XSD validation and round-trip experiments, and a phased plan to reach schema-valid, lossless import and export. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- IST-SOLL-ANALYSE.md | 308 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 IST-SOLL-ANALYSE.md diff --git a/IST-SOLL-ANALYSE.md b/IST-SOLL-ANALYSE.md new file mode 100644 index 0000000..9cc27af --- /dev/null +++ b/IST-SOLL-ANALYSE.md @@ -0,0 +1,308 @@ +# Ist-/Soll-Analyse reqif4j: Weg zum vollständigen ReqIF-Import und -Export + +Stand: 2026-10-11 · Basis: Branch `claude/dreamy-goldberg-muhi8j` (= `master` @ `972ae90`, Version `1.1.2-SNAPSHOT`) + +## 1. Zusammenfassung + +**Zielbild:** Jede gültige ReqIF-1.2-Datei (`.reqif`/`.reqifz`) wird verlustfrei eingelesen, lässt +sich über eine API bearbeiten und wird wieder als **schemakonforme** Datei ausgegeben, die +gängige Werkzeuge (DOORS, Polarion, ReqIF Studio, Enterprise Architect …) wieder importieren können. + +**Ist-Stand in einem Satz:** Der *Import* deckt die fachlichen Kerninhalte gut ab und ist +weitgehend robust. Der *Export* ist strukturell vollständig, erzeugt aber **in keinem Fall eine +XSD-gültige Datei** und verliert beim Round-Trip eine Reihe von Informationen. + +| Bereich | Reifegrad | Kernaussage | +|---|---|---| +| Import `.reqif` | 🟡 gut | Alle Datentypen, Spec-Typen, Objekte, Relationen, Relationsgruppen, Hierarchien, XHTML, Tool-Extensions. Es fehlen Metadaten (`LAST-CHANGE`, `DESC`, `LONG-NAME` von Objekten …). Bei einigen gültigen Dateien bricht der Import ab. | +| Import `.reqifz` | 🟡 eingeschränkt | Nur Anhänge vom Typ `png`/`jpg`/`jpeg`. OLE-, GIF-, SVG- und PDF-Anhänge gehen verloren. | +| Export `.reqif` | 🔴 nicht schemakonform | `LAST-CHANGE` und weitere Pflichtattribute fehlen. Jede geschriebene Datei verletzt das OMG-XSD. | +| Export `.reqifz` | 🟡 eingeschränkt | Funktioniert, übernimmt aber nur die Bildanhänge, die der Import kennt. | +| Erzeugen (`ReqIFBuilder`) | 🟡 gut | Komfortabel, prüft Referenzen. Relationsgruppen, Metadaten und Pflicht-Header-Felder werden nicht unterstützt bzw. nicht erzwungen. | +| Validierung | 🟡 Lücke | Die Modellprüfung meldet „gültig“, obwohl das XSD 17 bis 23 Fehler findet. Die Schemaprüfung kann das offizielle XSD nicht laden. | +| Bearbeiten geparster Dokumente | 🔴 fehlt | Es gibt keine Mutations-API (Werte, Hierarchie, Relationen). | +| Tests/CI | 🟢 solide Basis | 154 Tests, alle grün (`mvn verify`). Es fehlen XSD-Konformitätstests und echte Tool-Exporte als Testdaten. | + +## 2. Vorgehen + +1. Code-Review der Kernklassen unter `src/main/java/de/uni_stuttgart/ils/reqif4j` (83 Dateien) + mit Fokus auf Parser, Writer, Builder und Validator. +2. `mvn verify` ausgeführt: **154 Tests, 0 Fehler**. +3. Das offizielle OMG-Schema `http://www.omg.org/spec/ReqIF/20110401/reqif.xsd` samt XHTML-Modulen + heruntergeladen und lokal aufgelöst. +4. Experimente gegen den aktuellen Build: + - **Builder → Writer → XSD:** das Beispiel aus dem README, mit dem Schema geprüft. + - **Round-Trip:** eine Datei, die alle optionalen ReqIF-Merkmale nutzt, einlesen, schreiben, + die Ausgabe vergleichen und gegen das XSD prüfen. + - **Robustheit:** fehlender `LONG-NAME`, doppelte Attributnamen, unbekannte Typreferenz, + `.reqifz` mit OLE-, GIF- und PNG-Anhängen. + +Alle unten als „nachgewiesen“ markierten Befunde stammen aus diesen Experimenten. + +## 3. Ist-Stand + +### 3.1 Architektur + +| Paket | Aufgabe | Bemerkung | +|---|---|---| +| `reqif` | Einstieg: `ReqIF` (.reqif), `ReqIFz` (.reqifz), `ReqIFDocument`, `ReqIFHeader`, `ReqIFCoreContent` | DOM-basiert, liest die ganze Datei in den Speicher | +| `datatypes` | `Datatype*` (Boolean, Integer, String, Real→„Double“, Date, XHTML, Enumeration) | Unbekannte Datentypen werden generisch erhalten | +| `attributes` | `AttributeDefinition*`, `AttributeValue*`, XHTML-Werte | Werte sind nach `LONG-NAME` der Definition abgelegt, nicht nach ID | +| `specification` | `SpecType`, `SpecObject`, `SpecRelation`, `Specification`, `SpecHierarchy`, `RelationGroup`, Klassifizierer | Inhaltskategorien (REQ/HEADLINE/…) sind parsereigene Erweiterungen | +| `xhtml` | Typisierter XHTML-Knotenbaum, Token-Liste, Rendering | Gut getestet | +| `write` | `ReqIFWriter`, `ReqIFzWriter` | Schreibt aus dem Objektmodell, nicht aus dem Quell-DOM | +| `build` | `ReqIFBuilder`, `SpecTypeBuilder`, `ValuesBuilder` | Fluent-API zum Neuerzeugen von Dokumenten | +| `validate` | `ReqIFValidator` (Modell + optional XSD) | | +| `util` | `SecureXml` (XXE-Schutz), `XmlUtils`, `XhtmlParser` | Zip-Slip-Schutz in `ReqIFz` | + +Positiv: Prefix-unabhängiges Lesen (`rif:`, `xhtml:`, `reqif-xhtml:`), sichere XML-Konfiguration, +Erhalt unbekannter Datentyp-, Attribut- und Spec-Typ-Arten, Tool-Extensions werden 1:1 +durchgereicht, Multiselect-Enums, XHTML-Rendering mit korrektem Escaping, deterministische Ausgabe. + +### 3.2 Abdeckungsmatrix (ReqIF 1.2 gegen reqif4j) + +Legende: ✅ unterstützt · ⚠️ teilweise · ❌ fehlt · — nicht anwendbar + +| ReqIF-Element / -Attribut | Import | Export | Builder | +|---|---|---|---| +| `REQ-IF` `xml:lang` | ❌ | ❌ | ❌ | +| Header: `COMMENT`, `CREATION-TIME`, `REQ-IF-TOOL-ID`, `REQ-IF-VERSION`, `SOURCE-TOOL-ID`, `TITLE` | ✅ | ⚠️ leere Werte werden ausgelassen, obwohl sie Pflicht sind | ⚠️ nicht erzwungen | +| Header: `REPOSITORY-ID` | ❌ | ❌ | ❌ | +| `TOOL-EXTENSIONS` | ✅ (roh) | ✅ (roh) | ❌ | +| Identifiable: `IDENTIFIER` | ✅ | ✅ | ✅ | +| Identifiable: `LAST-CHANGE` (**Pflicht**) | ❌ | ❌ | ❌ | +| Identifiable: `DESC` | ❌ | ❌ | ❌ | +| Identifiable: `LONG-NAME` | ⚠️ nur Datentypen, Spec-Typen, Attributdefinitionen, Spezifikationen, Relationsgruppen. Bei Fehlen: Absturz | ⚠️ ebenso | ⚠️ | +| `ALTERNATIVE-ID` | ✅ | ✅ | ❌ | +| `DATATYPE-DEFINITION-BOOLEAN` / `-DATE` / `-XHTML` | ✅ | ✅ | ✅ | +| `DATATYPE-DEFINITION-STRING` `MAX-LENGTH` | ✅ | ✅ | ✅ | +| `DATATYPE-DEFINITION-INTEGER` `MIN`/`MAX` | ✅ (long) | ✅ | ✅ | +| `DATATYPE-DEFINITION-REAL` `ACCURACY`/`MIN`/`MAX` (**Pflicht**) | ❌ | ❌ | ❌ | +| `DATATYPE-DEFINITION-ENUMERATION`, `ENUM-VALUE`, `KEY` | ✅ | ✅ | ✅ | +| `EMBEDDED-VALUE` `OTHER-CONTENT` (**Pflicht**) | ✅ | ⚠️ entfällt, wenn leer | ✅ | +| `SPEC-OBJECT-TYPE`, `SPECIFICATION-TYPE`, `SPEC-RELATION-TYPE` | ✅ | ✅ | ✅ | +| `RELATION-GROUP-TYPE` | ✅ | ✅ | ❌ | +| `ATTRIBUTE-DEFINITION-*` | ✅ | ✅ | ✅ | +| `ATTRIBUTE-DEFINITION-*` `IS-EDITABLE` | ❌ | ❌ | ❌ | +| `ATTRIBUTE-DEFINITION-ENUMERATION` `MULTI-VALUED` (**Pflicht**) | ✅ | ⚠️ nur `true` wird geschrieben | ✅ | +| `DEFAULT-VALUE` (einfache Typen, Enum) | ✅ | ✅ | ✅ | +| `DEFAULT-VALUE` (XHTML) | ❌ | ❌ | ❌ | +| `ATTRIBUTE-VALUE-*` (alle 7 Arten) | ✅ | ✅ | ✅ | +| `ATTRIBUTE-VALUE-INTEGER` außerhalb des int-Bereichs | ❌ Absturz | — | ⚠️ | +| `ATTRIBUTE-VALUE-XHTML` `THE-ORIGINAL-VALUE` / `IS-SIMPLIFIED` | ❌ | ❌ | ❌ | +| `SPEC-OBJECT` | ✅ | ✅ | ✅ | +| `SPEC-RELATION` (+ Attribute) | ✅ | ✅ | ✅ | +| `SPECIFICATION` (+ Attribute) | ✅ | ✅ | ✅ | +| `SPEC-HIERARCHY` | ✅ | ✅ | ✅ | +| `SPEC-HIERARCHY` `IS-TABLE-INTERNAL`, `IS-EDITABLE`, `EDITABLE-ATTS` | ❌ | ❌ | ❌ | +| `RELATION-GROUP` | ✅ | ✅ | ❌ | +| `.reqifz` mit mehreren `.reqif` | ✅ | ✅ | — | +| `.reqifz`-Anhänge `png`/`jpg`/`jpeg` | ✅ | ✅ | ✅ | +| `.reqifz`-Anhänge OLE, GIF, SVG, PDF, RTF … | ❌ | ❌ | ⚠️ manuell über `Map` | + +## 4. Befunde: Was zum Zielstand fehlt + +Die Befunde sind nach Auswirkung auf das Zielbild priorisiert: +**P0** blockiert jeden produktiven Export, **P1** verursacht Datenverlust oder Abbrüche bei realen Dateien, +**P2** betrifft Vollständigkeit, API und Betrieb. + +### P0: Export ist nicht schemakonform + +**4.1 `LAST-CHANGE` fehlt vollständig (nachgewiesen).** +Das XSD verlangt `LAST-CHANGE` (`xsd:dateTime`, `use="required"`) auf **jedem** Identifiable: +Datentypen, Enum-Werte, Spec-Typen, Attributdefinitionen, Spec-Objekte, Relationen, +Spezifikationen, Hierarchieknoten und Relationsgruppen. Der Parser liest das Attribut nicht, +das Modell hat kein Feld dafür und der Writer schreibt es nicht. Ergebnis: **Jede** exportierte +Datei ist ungültig. Bei einem Werkzeug wie DOORS geht damit außerdem die Änderungsverfolgung +beim Re-Import verloren. + +**4.2 Weitere Pflichtattribute werden nicht geschrieben (nachgewiesen).** +- `MULTI-VALUED` auf `ATTRIBUTE-DEFINITION-ENUMERATION` wird nur bei `true` gesetzt (`ReqIFWriter.attributeDefinition`). +- `OTHER-CONTENT` auf `EMBEDDED-VALUE` entfällt, wenn der Wert leer ist. +- `ACCURACY`, `MIN` und `MAX` auf `DATATYPE-DEFINITION-REAL` werden weder gelesen noch geschrieben + (`DatatypeDouble` hat keine Felder). +- Die Pflichtelemente des Headers (`CREATION-TIME`, `REQ-IF-TOOL-ID`, `REQ-IF-VERSION`, + `SOURCE-TOOL-ID`, `TITLE`) werden bei leerem Wert weggelassen. Der Builder setzt keine Defaults. + Das README-Beispiel erzeugt so einen ungültigen Header. + +XSD-Prüfung des README-Beispiels: **17 unterschiedliche Fehlermeldungen**. +Round-Trip der Testdatei: **23 unterschiedliche Fehlermeldungen**. + +**4.3 Die Modellvalidierung gibt falsche Sicherheit (nachgewiesen).** +`ReqIFValidator.validate()` meldet für dasselbe Builder-Dokument `isValid() == true`. +Geprüft werden Referenzen und Eindeutigkeit, aber keine Pflichtattribute und Wertebereiche +(`MAX-LENGTH`, `MIN`/`MAX`, `ACCURACY`). + +**4.4 `validateAgainstSchema` kann das offizielle XSD nicht laden (nachgewiesen).** +`reqif.xsd` importiert `xml.xsd` und `driver.xsd` (XHTML-Module) per HTTP. `SecureXml` erlaubt +nur `file:`-Zugriffe. Die Prüfung endet daher mit +`Failed to read schema document 'xml.xsd', because 'http' access is not allowed`. +Nötig sind ein `LSResourceResolver` oder ein XML-Katalog mit lokal abgelegten Schemata +(im Test-Classpath oder als Download-Anleitung). Erst dann kann die Schemakonformität in der CI +geprüft werden. + +### P1: Datenverlust und Abbrüche beim Import und im Round-Trip + +**4.5 Metadaten gehen verloren (nachgewiesen).** Folgende Angaben werden nicht modelliert und +fehlen nach `parse → write`: +`DESC` (alle Elemente), `LONG-NAME` von `SPEC-OBJECT`/`SPEC-RELATION`/`SPEC-HIERARCHY`, +`REPOSITORY-ID`, `xml:lang`, `IS-EDITABLE`, `IS-TABLE-INTERNAL`, `EDITABLE-ATTS`, +`ACCURACY`/`MIN`/`MAX` (Real) sowie XHTML-Defaultwerte. + +**4.6 XHTML: `THE-ORIGINAL-VALUE` und `IS-SIMPLIFIED` fehlen (nachgewiesen).** +Werkzeuge wie DOORS legen den vollständigen Inhalt (z. B. eingebettete OLE-Objekte mit +Vorschaubild) in `THE-ORIGINAL-VALUE` ab und eine vereinfachte Fassung in `THE-VALUE`. +reqif4j liest nur `THE-VALUE`. **Eingebettete OLE-Objekte verschwinden** beim Round-Trip. + +**4.7 Nicht gesetzte Werte werden zu explizit gesetzten Werten (nachgewiesen).** +Beim Import füllt `SpecObject`/`Specification` fehlende Werte mit dem Default auf. Fehlt auch +der Default, bleibt Boolean `false`, Integer `0` und Real `0.0`. Der Writer schreibt diese +Werte als echte `ATTRIBUTE-VALUE-*`. In der Testdatei hatte das Objekt kein `Flag` und kein `Real`. +Nach dem Export trägt es `THE-VALUE="false"` und `THE-VALUE="0.0"`. +Das ändert die Semantik: „nicht gesetzt“ ist nicht dasselbe wie „false“. Das Modell muss +*explizite Werte* und *effektive Werte (inkl. Default)* trennen. + +**4.8 Werte sind nach `LONG-NAME` statt nach Definitions-ID abgelegt (nachgewiesen).** +`attributeValues` ist eine `HashMap`. Tragen zwei Attributdefinitionen eines Typs +denselben Namen (bei DOORS-Exporten mit Modul- und Systemattributen keine Seltenheit), überschreibt +der zweite Wert den ersten. Im Test kam von `A` und `B` nur `B` an, und es wurde nur ein Wert +geschrieben. Die Reihenfolge der Werte geht ebenfalls verloren. + +**4.9 Integer-Werte außerhalb des `int`-Bereichs brechen den Import ab (nachgewiesen).** +`DatatypeInteger` arbeitet mit `long`, `AttributeValueInteger` aber mit `Integer.parseInt`. +`THE-VALUE="99999999999"` führt zur `NumberFormatException`, und das ganze Dokument wird +nicht geladen. ReqIF verwendet `xsd:integer` (unbeschränkt). Richtig wäre `long` mit Fallback +auf `BigInteger`. + +**4.10 Optionale Angaben führen zu `NullPointerException` (nachgewiesen).** +- `LONG-NAME` ist im XSD optional. `Datatype`-, `SpecType`-, `AttributeDefinition`- und + `Specification`-Konstruktoren rufen aber `getNamedItem(LONG_NAME).getTextContent()` ohne + Null-Prüfung auf. +- Ein `SPEC-OBJECT` mit unbekannter Typreferenz bricht in `readAttributeValues` ab (`specType` ist `null`). +- `SpecHierarchy` mit nicht auflösbarem `OBJECT`: `getSpecObjectID()` wirft eine NPE, und der Writer + lässt das Pflichtelement `OBJECT` weg. + +Bei einem fehlertoleranten Import gehören solche Fälle in `ValidationResult`-Meldungen, nicht in Abstürze. + +**4.11 `.reqifz`: Anhänge außer `png`/`jpg`/`jpeg` gehen verloren (nachgewiesen).** +`ReqIFz` registriert nur diese drei Endungen, `ReqIFzWriter.write(ReqIFz, …)` packt nur sie +wieder ein. Aus einem Archiv mit `obj.ole`, `a.gif` und `b.png` blieb nach dem Neupacken nur +`b.png` übrig. Für einen vollständigen Export müssen **alle** Nicht-ReqIF-Einträge erhalten +bleiben, unabhängig von der Endung. Dasselbe gilt für den Bilderordner neben einer `.reqif`-Datei. + +**4.12 Doppelte Identifier werden stillschweigend überschrieben.** +Das ist im README dokumentiert, aber ungelöst. Der Parser sollte Duplikate beim Einlesen erkennen und melden. + +### P2: Vollständigkeit, API und Betrieb + +**4.13 Keine API zum Bearbeiten geparster Dokumente.** +`AttributeValue` hat keine Setter, Hierarchien und Relationen lassen sich nicht umhängen, +Objekte nicht konsistent löschen (inklusive Referenzen in Hierarchien und Relationen). +Der Builder erzeugt nur *neue* Dokumente. Für „Import → Änderung → Export“ fehlt eine +Bearbeitungsschicht, die auch `LAST-CHANGE` automatisch fortschreibt. + +**4.14 Builder unvollständig.** +Es fehlen `RELATION-GROUP` und `RELATION-GROUP-TYPE`, `ALTERNATIVE-ID`, `DESC`, `LAST-CHANGE`, +`LONG-NAME` für Objekte, `REPOSITORY-ID`, `IS-EDITABLE`, Real-Attribute (`ACCURACY` …), +Tool-Extensions und Defaults für Pflicht-Header-Felder. + +**4.15 Doppelte Parse-Logik.** +Das Einlesen der Attributwerte ist in `SpecObject.readAttributeValues` und im Konstruktor von +`Specification` doppelt vorhanden (ca. 100 Zeilen) und schon jetzt uneinheitlich, +z. B. `REAL` im Wert-Switch gegenüber `DOUBLE` im Default-Switch. Jede Erweiterung aus 4.5 bis 4.9 +müsste doppelt umgesetzt werden. Vorher sollte eine gemeinsame Basisklasse +`Identifiable`/`SpecElementWithAttributes` (wie im ReqIF-Metamodell) eingeführt werden. + +**4.16 Speicher und Dateisystem.** +- Rein DOM-basiert: Große Exporte (z. B. 100.000 Objekte mit XHTML) liegen vollständig im Speicher, + der Writer baut zusätzlich ein zweites DOM auf. Für große Dateien wären StAX zum Lesen und + Streaming zum Schreiben sinnvoll. +- `ReqIFz` entpackt ungefragt in ein Verzeichnis `_unzipped` neben der Quelldatei (nachgewiesen). + Das scheitert auf schreibgeschützten Laufwerken und wird nie aufgeräumt. + Eine `InputStream`-Variante für `.reqifz` fehlt. +- Bild-`InputStream`s sind nur einmal lesbar. Hier wäre `byte[]` bzw. `Path`-Zugriff besser. + +**4.17 Interoperabilität und Profile.** +Die Klassifizierung nach Implementation Guide existiert. Es fehlen aber Prüfungen und Hilfen für die +Regeln des *ReqIF Implementation Guide* (ProSTEP iViP), z. B. die erlaubte XHTML-Teilmenge, +OLE-Konventionen (`object` mit Vorschaubild), `ReqIF.ForeignID`/`ReqIF.ChapterName` und den +Umgang mit `IS-SIMPLIFIED`. + +**4.18 Tests und Dokumentation.** +- Es gibt keinen Test, der Writer-Ausgaben gegen das XSD prüft. Die 154 Tests vergleichen das Modell + nach Round-Trip, nicht die XML-Vollständigkeit. Deshalb fielen 4.1 bis 4.11 bisher nicht auf. +- Es fehlen echte, anonymisierte Exporte (DOORS Classic/Next, Polarion, Jama, EA, ReqIF Studio) + als Testdaten. +- Das README nennt Version `1.1.0`, die `pom.xml` steht auf `1.1.2-SNAPSHOT`. Der Writer ist + noch als *experimental* gekennzeichnet. + +## 5. Soll-Bild: Abnahmekriterien „vollständiger Import und Export“ + +1. **Schemakonformität:** Jede vom Writer erzeugte Datei besteht die Validierung gegen das + OMG-XSD (ReqIF 1.2, `20110401`). Das wird in der CI geprüft. +2. **Verlustfreier Round-Trip:** `parse → write → parse` ergibt ein inhaltlich identisches Modell, und + `parse → write` ergibt ein XML, das bis auf Formatierung und Attributreihenfolge dem + Original entspricht (kanonischer XML-Vergleich). Getestet wird das mit einer „Alle-Merkmale“-Datei + und mit echten Tool-Exporten. +3. **Fehlertoleranter Import:** Gültige, aber ungewöhnliche Dateien (optionale Attribute fehlen, + große Zahlen, unbekannte Referenzen) werden geladen. Probleme landen als `ValidationIssue` + statt als Exception. +4. **Vollständige Archive:** `.reqifz` behält alle Einträge (Dokumente und beliebige Anhänge), + auch beim Neupacken. +5. **Bearbeitbarkeit:** Geparste Dokumente lassen sich über eine API ändern. `LAST-CHANGE` wird dabei + automatisch gepflegt, und Referenzen bleiben konsistent. +6. **Interop-Nachweis:** Exportierte Dateien lassen sich in mindestens zwei Zielwerkzeugen + (z. B. ReqIF Studio und DOORS oder Polarion) importieren. + +## 6. Empfohlene Umsetzungsschritte + +Aufwand: S ≈ bis 1 Tag, M ≈ 2–4 Tage, L ≈ 1–2 Wochen (eine Person, inklusive Tests). + +| # | Arbeitspaket | Befunde | Aufwand | +|---|---|---|---| +| **Phase 1: Fundament und Messbarkeit** |||| +| 1.1 | XSD und XHTML-Module lokal einbinden (Resolver/Katalog), Testhelfer `assertSchemaValid(doc)`, CI-Test „Builder-Beispiel ist XSD-gültig“ (zunächst rot) | 4.4, 4.18 | S | +| 1.2 | Gemeinsame Basisklasse `Identifiable` (ID, LONG-NAME optional, DESC, LAST-CHANGE, ALTERNATIVE-ID) für alle Elemente. Wert-Parsing in einer Klasse zusammenführen | 4.5, 4.10, 4.15 | M | +| **Phase 2: Schemakonformer Export** |||| +| 2.1 | `LAST-CHANGE` lesen, modellieren und schreiben (Default: Zeitpunkt des Exports bzw. Erzeugungszeit) | 4.1 | S | +| 2.2 | Pflichtattribute: `MULTI-VALUED` immer, `OTHER-CONTENT` immer, Real `ACCURACY`/`MIN`/`MAX`, Header-Pflichtfelder mit sinnvollen Defaults | 4.2 | S | +| 2.3 | `ReqIFValidator` um Pflichtfelder und Wertebereiche erweitern | 4.3 | S–M | +| **Phase 3: Verlustfreier Import und Round-Trip** |||| +| 3.1 | Werte nach Definitions-ID statt nach LONG-NAME ablegen (Namenszugriff als Komfortmethode beibehalten), Reihenfolge erhalten | 4.8 | M (API-Bruch → Major-Version) | +| 3.2 | Explizite und effektive Werte trennen, keine Default-Werte mehr materialisieren | 4.7 | M | +| 3.3 | Integer als `long`/`BigInteger`, Real-Grenzen, XHTML-Defaults | 4.9, 4.5 | S | +| 3.4 | `THE-ORIGINAL-VALUE`/`IS-SIMPLIFIED`, `IS-EDITABLE`, `IS-TABLE-INTERNAL`, `EDITABLE-ATTS`, `REPOSITORY-ID`, `xml:lang` | 4.5, 4.6 | M | +| 3.5 | `.reqifz`: alle Einträge erhalten, `InputStream`-Konstruktor, Entpacken ins Temp-Verzeichnis oder in den Speicher | 4.11, 4.16 | M | +| 3.6 | Fehlertoleranter Import: NPE-Stellen in `ValidationIssue`s umwandeln, Duplikate melden | 4.10, 4.12 | M | +| 3.7 | Round-Trip-Test mit „Alle-Merkmale“-Datei und kanonischem XML-Vergleich | 4.18 | S | +| **Phase 4: Bearbeitung und Komfort** |||| +| 4.1 | Mutations-API (Werte setzen und entfernen, Objekte und Hierarchieknoten einfügen, verschieben und löschen, Relationen pflegen) mit automatischem `LAST-CHANGE` | 4.13 | L | +| 4.2 | Builder vervollständigen (Relationsgruppen, Metadaten, Real-Attribute, Tool-Extensions) | 4.14 | M | +| **Phase 5: Interoperabilität und Skalierung** |||| +| 5.1 | Echte Tool-Exporte als Testkorpus, Import-Tests in ReqIF Studio und Zielwerkzeugen | 4.17, 4.18 | M | +| 5.2 | Implementation-Guide-Prüfungen (XHTML-Teilmenge, OLE-Konventionen, Standardattribute) | 4.17 | M | +| 5.3 | Optional: StAX-basierter Reader/Writer für sehr große Dateien | 4.16 | L | + +**Schnellster Weg zu einem brauchbaren Export:** Phase 1 und Phase 2 (ca. 1–1,5 Wochen) machen den +Export schemakonform. Mit Phase 3 (ca. 3–4 Wochen) wird der Round-Trip verlustfrei. Danach ist +das Zielbild „vollständiger Import und Export“ im engeren Sinn erreicht. Phase 4 und 5 machen die +Bibliothek bearbeitungsfähig und belegen die Interoperabilität mit realen Werkzeugen. + +## Anhang: Reproduktion der Experimente + +Das XSD ist nicht im Repository enthalten (Lizenz). Zum Nachstellen: + +1. `reqif.xsd` von `https://www.omg.org/spec/ReqIF/20110401/reqif.xsd` laden und die + `schemaLocation`-Importe (`xml.xsd`, `driver.xsd` und die XHTML-Module) lokal auflösen. +2. Das README-Beispiel (`ReqIFBuilder` … `build()`) mit + `new ReqIFValidator().validateAgainstSchema(doc, lokalesXsd)` prüfen. Ergebnis: + Fehler zu `LAST-CHANGE`, `MULTI-VALUED`, `OTHER-CONTENT` und zum Header. +3. Eine Datei mit `LAST-CHANGE`, `DESC`, `LONG-NAME` am `SPEC-OBJECT`, `REPOSITORY-ID`, `xml:lang`, + `IS-EDITABLE`, `ACCURACY`, `THE-ORIGINAL-VALUE`, `IS-TABLE-INTERNAL`, `EDITABLE-ATTS` und + ungesetzten Boolean- und Real-Attributen einlesen und mit `ReqIFWriter` schreiben. Die Ausgabe + enthält keines dieser Merkmale, dafür aber `THE-VALUE="false"` und `THE-VALUE="0.0"`. +4. `THE-VALUE="99999999999"` in einem Integer-Attribut führt zur `NumberFormatException`. + Ein fehlender `LONG-NAME` an einem Datentyp oder eine unbekannte `SPEC-OBJECT-TYPE-REF` + führt zur `NullPointerException`. +5. Ein `.reqifz` mit `files/obj.ole`, `files/a.gif` und `files/b.png` einlesen und mit + `ReqIFzWriter.write(ReqIFz, …)` neu packen: Das Ergebnis enthält nur `doc.reqif` und `files/b.png`. From 16757cfb25aa4b76d0704d4ba6c3298be19720a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:47:26 +0000 Subject: [PATCH 02/20] Validate against the official ReqIF schema without going online reqif.xsd imports xml.xsd, driver.xsd and the XHTML modules by http URL, which the hardened schema factory refuses to fetch, so validating against the official schema always failed. Remote imports are now resolved by file name from the directory of reqif.xsd. A missing import is answered with an empty input instead of being handed back to the parser, and it is reported by name together with its URL. Warnings raised while loading the schema are no longer swallowed. validateAgainstSchema now also accepts a file on disk, read through a hardened SAX reader so findings carry line numbers. The tests download the official schema set on first use and cache it (REQIF_SCHEMA_DIR, cached on CI). Without network they are skipped locally and fail on CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- .github/workflows/ci.yml | 10 + README.md | 19 +- .../ils/reqif4j/util/SecureXml.java | 29 +++ .../reqif4j/validate/LocalSchemaResolver.java | 108 +++++++++ .../ils/reqif4j/validate/ReqIFValidator.java | 157 +++++++++++--- .../ils/reqif4j/OfficialReqIFSchema.java | 205 ++++++++++++++++++ .../ils/reqif4j/SchemaConformanceTest.java | 91 ++++++++ .../ils/reqif4j/TestFixtures.java | 151 +++++++++++++ .../validate/LocalSchemaResolverTest.java | 82 +++++++ 9 files changed, 823 insertions(+), 29 deletions(-) create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolver.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/OfficialReqIFSchema.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolverTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2d63a..3fad4c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,17 @@ jobs: java-version: '17' cache: maven + # The schema conformance tests download the official OMG ReqIF XSD and its + # imports on first use; the schemas are frozen, so the cache key never changes. + - name: Cache ReqIF schema + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/reqif-schema + key: reqif-schema-20110401 + - name: Build and run tests + env: + REQIF_SCHEMA_DIR: ${{ runner.temp }}/reqif-schema run: mvn -B --no-transfer-progress verify - name: Upload test reports diff --git a/README.md b/README.md index 0ea34b2..d8921ec 100644 --- a/README.md +++ b/README.md @@ -369,13 +369,28 @@ new ReqIFWriter().write(document, Path.of("out.reqif")); This is a model-level check. The OMG ReqIF XSD is **not bundled** with this library for licensing reasons; if you have it, run a full XML -Schema validation on top: +Schema validation on top - either of the XML the writer produces for a +document, or of a file on disk (findings then carry line numbers): ```java ValidationResult schemaIssues = - new ReqIFValidator().validateAgainstSchema(document, Path.of("reqif.xsd")); + new ReqIFValidator().validateAgainstSchema(document, Path.of("schema/reqif.xsd")); +ValidationResult fileIssues = + new ReqIFValidator().validateAgainstSchema(Path.of("in.reqif"), Path.of("schema/reqif.xsd")); ``` +`reqif.xsd` imports further schemas by http URL: `xml.xsd`, +[`driver.xsd`](http://www.omg.org/spec/ReqIF/20110402/driver.xsd) and the +XHTML modules it references (`xhtml-*-1.xsd`). Validation never goes +online; these imports are looked up by file name in the directory of +`reqif.xsd`, so download them into the same folder. A missing import is +reported by name together with its original URL. + +The test suite validates against the official schema. It downloads the +schema files on first use into `target/reqif-schema` (or the directory +in the `REQIF_SCHEMA_DIR` environment variable); without network access +these tests are skipped locally and fail on CI. + Not covered yet: identifiers duplicated within one category (the parser keys its maps by identifier, so a duplicate has already replaced its predecessor by the time the model exists). diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/SecureXml.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/SecureXml.java index 1e395fd..cf63af9 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/SecureXml.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/SecureXml.java @@ -4,11 +4,15 @@ import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParserFactory; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerConfigurationException; import javax.xml.transform.TransformerFactory; import javax.xml.validation.SchemaFactory; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; + /** * Factories for XML components with the same hardening applied everywhere. * @@ -45,6 +49,31 @@ public static DocumentBuilder newDocumentBuilder() throws ParserConfigurationExc return factory.newDocumentBuilder(); } + /** + * @return a namespace-aware SAX reader with the same hardening as + * {@link #newDocumentBuilder()}. Unlike a DOM it keeps line numbers, + * which makes schema violations of a file traceable. + */ + public static XMLReader newXMLReader() throws ParserConfigurationException, SAXException { + + SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setXIncludeAware(false); + + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + + XMLReader reader = factory.newSAXParser().getXMLReader(); + try { + reader.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + reader.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + } catch (SAXException ignored) { + // parser implementation does not support these properties + } + return reader; + } + /** * @return an identity transformer with secure processing enabled */ diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolver.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolver.java new file mode 100644 index 0000000..1917613 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolver.java @@ -0,0 +1,108 @@ +package de.uni_stuttgart.ils.reqif4j.validate; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import javax.xml.parsers.ParserConfigurationException; + +import org.w3c.dom.ls.DOMImplementationLS; +import org.w3c.dom.ls.LSInput; +import org.w3c.dom.ls.LSResourceResolver; + +import de.uni_stuttgart.ils.reqif4j.util.SecureXml; + +/** + * Resolves the remote imports of a schema to files in a local directory. + * + * The official {@code reqif.xsd} imports {@code xml.xsd} and the XHTML + * modules ({@code driver.xsd}, {@code xhtml-*-1.xsd}) by absolute http URL. + * Schema validation must not go online, so every http(s) location is looked up + * by its file name in the schema's own directory instead. Other locations + * (relative paths, {@code file:}, Windows drive letters) are left to the + * parser, which may only load local files. + * + * A remote import that is not available locally is answered with an empty + * input - never handed back to the parser, which could otherwise fetch it if + * it ignores the external access restriction - and remembered, so the caller + * can name it in the error message. + * + * Mapping by file name assumes the imported file names are unique, which holds + * for the ReqIF schema set. + */ +final class LocalSchemaResolver implements LSResourceResolver { + + /** a single path segment, so a location can never point outside the directory */ + private static final Pattern PLAIN_FILE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*"); + + private final Path schemaDirectory; + private final DOMImplementationLS inputs; + private final Map missing = new LinkedHashMap(); + + LocalSchemaResolver(Path schemaDirectory) { + this.schemaDirectory = schemaDirectory; + try { + this.inputs = (DOMImplementationLS) SecureXml.newDocumentBuilder().getDOMImplementation(); + } catch (ParserConfigurationException e) { + throw new IllegalStateException("No DOM implementation available", e); + } + } + + /** + * @return the remote imports that could not be found locally, as the + * location the schema referenced to the file name looked up + */ + Map getMissing() { + return this.missing; + } + + @Override + public LSInput resolveResource(String type, String namespaceURI, String publicId, String systemId, String baseURI) { + + URI location = resolve(systemId, baseURI); + if (location == null || !isRemote(location)) { + return null; + } + + String rawPath = location.getRawPath(); + String fileName = rawPath == null ? "" : rawPath.substring(rawPath.lastIndexOf('/') + 1); + Path local = PLAIN_FILE_NAME.matcher(fileName).matches() ? this.schemaDirectory.resolve(fileName) : null; + + LSInput input = this.inputs.createLSInput(); + input.setPublicId(publicId); + input.setBaseURI(baseURI); + if (local == null || !Files.isRegularFile(local)) { + this.missing.put(location.toString(), fileName); + input.setSystemId(location.toString()); + input.setStringData(""); + } else { + input.setSystemId(local.toUri().toString()); + } + return input; + } + + private static boolean isRemote(URI location) { + String scheme = location.getScheme(); + return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme); + } + + private static URI resolve(String systemId, String baseURI) { + + if (systemId == null) { + return null; + } + try { + URI location = new URI(systemId); + if (location.isAbsolute() || baseURI == null) { + return location; + } + return new URI(baseURI).resolve(location); + } catch (URISyntaxException | IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java index 27a8052..3e7e471 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java @@ -2,17 +2,24 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import javax.xml.XMLConstants; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.Source; import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.sax.SAXSource; import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; import javax.xml.validation.Validator; import org.xml.sax.ErrorHandler; +import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.SAXParseException; @@ -49,8 +56,9 @@ * * This is a model-level check, not a schema check. The OMG ReqIF XSD is not * bundled with this library; if you have it, pass it to - * {@link #validateAgainstSchema(ReqIFDocument, Path)} for a full XML Schema - * validation on top. + * {@link #validateAgainstSchema(ReqIFDocument, Path)} (or, for a file on disk, + * {@link #validateAgainstSchema(Path, Path)}) for a full XML Schema validation + * on top. */ public class ReqIFValidator { @@ -306,48 +314,143 @@ private void checkIdentifier(String id, String kind, ValidationResult result, Ma /** - * Validates the written XML against an XML Schema. The OMG ReqIF XSD is not - * bundled with this library for licensing reasons; download it from the OMG - * and pass its path here. + * Validates the XML the writer produces for this document against an XML + * Schema. The OMG ReqIF XSD is not bundled with this library for licensing + * reasons; download it from the OMG and pass its path here. + * + * The official {@code reqif.xsd} imports {@code xml.xsd}, {@code driver.xsd} + * and the XHTML modules ({@code xhtml-*-1.xsd}) by http URL. Validation never + * goes online: these imports are looked up by file name in the directory of + * {@code schema}, so place them next to it. * * @param schema path to reqif.xsd * @return the schema violations, empty if the document is schema-valid */ public ValidationResult validateAgainstSchema(ReqIFDocument document, Path schema) throws IOException { + return validate(new DOMSource(new ReqIFWriter().buildDocument(document)), schema); + } + + /** + * Validates a ReqIF file as it is on disk, e.g. a file received from another + * tool or one written by {@link ReqIFWriter}. Unlike + * {@link #validateAgainstSchema(ReqIFDocument, Path)} the findings carry the + * line numbers of the file. + * + * @param reqifFile the .reqif file to check + * @param schema path to reqif.xsd, with its imports next to it + * @return the schema violations, empty if the file is schema-valid + */ + public ValidationResult validateAgainstSchema(Path reqifFile, Path schema) throws IOException { + + try { + InputSource input = new InputSource(reqifFile.toUri().toString()); + return validate(new SAXSource(SecureXml.newXMLReader(), input), schema); + } catch (ParserConfigurationException | SAXException e) { + throw new IOException("Failed to create an XML reader", e); + } + } + + private ValidationResult validate(Source source, Path schema) throws IOException { + ValidationResult result = new ValidationResult(); + Path schemaFile = schema.toAbsolutePath(); + LocalSchemaResolver resolver = new LocalSchemaResolver(schemaFile.getParent()); + + // Problems while loading the schema itself - an unreadable import is only a + // warning to the parser - would make every finding below meaningless. + ValidationResult schemaProblems = new ValidationResult(); + IssueCollector schemaCollector = new IssueCollector(schemaProblems); + Schema xsd = null; try { SchemaFactory factory = SecureXml.newSchemaFactory(XMLConstants.W3C_XML_SCHEMA_NS_URI); - Schema xsd = factory.newSchema(schema.toFile()); + factory.setResourceResolver(resolver); + factory.setErrorHandler(schemaCollector); + xsd = factory.newSchema(schemaFile.toFile()); + } catch (SAXException e) { + schemaCollector.reportUnlessReported(e); + } + if (xsd == null || !schemaProblems.getIssues().isEmpty() || !resolver.getMissing().isEmpty()) { + StringBuilder message = new StringBuilder("Schema could not be loaded: ").append(schemaFile); + for (ValidationIssue problem : schemaProblems.getIssues()) { + message.append("\n ").append(problem.getMessage()); + } + appendMissingImports(message, resolver, schemaFile); + result.error(null, message.toString()); + return result; + } + IssueCollector collector = new IssueCollector(result); + try { Validator validator = xsd.newValidator(); - validator.setErrorHandler(new ErrorHandler() { + validator.setErrorHandler(collector); + validator.validate(source); + } catch (SAXException e) { + // normally reported through the error handler already; anything else + // stopped the validation and must not be lost + collector.reportUnlessReported(e); + } + return result; + } - @Override - public void warning(SAXParseException exception) { - result.warning(null, message(exception)); - } + private static void appendMissingImports(StringBuilder message, LocalSchemaResolver resolver, Path schemaFile) { - @Override - public void error(SAXParseException exception) { - result.error(null, message(exception)); - } + if (resolver.getMissing().isEmpty()) { + return; + } + message.append("\nThe schema imports files that are not available locally. Download them and place them next to ") + .append(schemaFile.getFileName()).append(" in ").append(schemaFile.getParent()).append(':'); + for (Map.Entry missing : resolver.getMissing().entrySet()) { + message.append("\n ").append(missing.getValue()).append(" (").append(missing.getKey()).append(')'); + } + } - @Override - public void fatalError(SAXParseException exception) { - result.error(null, message(exception)); - } + /** + * Records parser findings as issues. Warnings stay warnings; errors and + * fatal errors are errors. + */ + private static final class IssueCollector implements ErrorHandler { - private String message(SAXParseException exception) { - return "line " + exception.getLineNumber() + ": " + exception.getMessage(); - } - }); - validator.validate(new DOMSource(new ReqIFWriter().buildDocument(document))); + private final ValidationResult result; + private final Set reported = Collections.newSetFromMap(new IdentityHashMap()); - } catch (SAXException e) { - result.error(null, "Schema validation failed: " + e.getMessage()); + IssueCollector(ValidationResult result) { + this.result = result; + } + + @Override + public void warning(SAXParseException exception) { + this.reported.add(exception); + this.result.warning(null, message(exception)); + } + + @Override + public void error(SAXParseException exception) { + this.reported.add(exception); + this.result.error(null, message(exception)); + } + + @Override + public void fatalError(SAXParseException exception) { + this.reported.add(exception); + this.result.error(null, message(exception)); + } + + void reportUnlessReported(SAXException exception) { + if (!this.reported.contains(exception)) { + this.reported.add(exception); + this.result.error(null, exception instanceof SAXParseException + ? message((SAXParseException) exception) + : exception.getMessage()); + } + } + + private static String message(SAXParseException exception) { + // a DOM source has no line numbers + return exception.getLineNumber() < 0 + ? exception.getMessage() + : "line " + exception.getLineNumber() + ": " + exception.getMessage(); } - return result; } private static boolean isBlank(String value) { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/OfficialReqIFSchema.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/OfficialReqIFSchema.java new file mode 100644 index 0000000..e2a2507 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/OfficialReqIFSchema.java @@ -0,0 +1,205 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.io.InputStream; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Assumptions; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.util.SecureXml; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; +import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; +import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; + +/** + * Provides the official OMG ReqIF 1.2 schema for conformance tests. + * + * The XSD is not bundled with the library (licensing), so it is downloaded on + * first use together with everything it imports ({@code xml.xsd}, + * {@code driver.xsd} and the XHTML modules) and cached flat, by file name, in + * one directory - the layout {@link ReqIFValidator#validateAgainstSchema} + * expects. + * + * The cache directory is {@code $REQIF_SCHEMA_DIR}, or {@code target/reqif-schema} + * if the variable is not set. Without network access the conformance tests are + * skipped locally; on CI ({@code CI=true}) a failed download fails the build, + * so the tests can never silently stop running there. + */ +public final class OfficialReqIFSchema { + + public static final String REQIF_XSD_URL = "https://www.omg.org/spec/ReqIF/20110401/reqif.xsd"; + + private static Path cachedSchema; + private static String unavailable; + + private OfficialReqIFSchema() { + } + + /** + * @return the local reqif.xsd with all its imports next to it + */ + public static synchronized Path reqifXsd() { + + if (cachedSchema != null) { + return cachedSchema; + } + if (unavailable == null) { + Path directory = directory(); + try { + Files.createDirectories(directory); + download(directory); + cachedSchema = directory.resolve(fileName(URI.create(REQIF_XSD_URL))); + return cachedSchema; + } catch (IOException | RuntimeException e) { + unavailable = "Official ReqIF schema is not available in " + directory + ": " + e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + unavailable = "Interrupted while downloading the ReqIF schema"; + } + } + // remembered, so offline every further test gives up at once instead of + // waiting for another connect timeout + if ("true".equalsIgnoreCase(System.getenv("CI"))) { + fail(unavailable); + } + Assumptions.abort(unavailable); + return null; + } + + /** + * Fails with all schema violations if the XML the writer produces for the + * document is not valid against the official ReqIF schema. + */ + public static void assertSchemaValid(ReqIFDocument document) throws IOException { + assertValid(new ReqIFValidator().validateAgainstSchema(document, reqifXsd())); + } + + /** + * Fails with all schema violations if the file is not valid against the + * official ReqIF schema. + */ + public static void assertSchemaValid(Path reqifFile) throws IOException { + assertValid(new ReqIFValidator().validateAgainstSchema(reqifFile, reqifXsd())); + } + + private static void assertValid(ValidationResult result) { + assertTrue(result.getIssues().isEmpty(), "not valid against the ReqIF schema:\n" + result); + } + + private static Path directory() { + String configured = System.getenv("REQIF_SCHEMA_DIR"); + return configured == null || configured.isBlank() + ? Path.of("target", "reqif-schema") + : Path.of(configured); + } + + /** + * Fetches reqif.xsd and, transitively, every schema it imports or includes. + * Files already in the cache are not downloaded again. + */ + private static void download(Path directory) throws IOException, InterruptedException { + + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .proxy(ProxySelector.getDefault()) + .connectTimeout(Duration.ofSeconds(20)) + .build(); + + Deque pending = new ArrayDeque(); + Set seen = new HashSet(); + pending.add(URI.create(REQIF_XSD_URL)); + + while (!pending.isEmpty()) { + URI location = pending.poll(); + String fileName = fileName(location); + if (!seen.add(fileName)) { + continue; + } + + Path local = directory.resolve(fileName); + if (!Files.isRegularFile(local)) { + fetch(client, location, local); + } + for (URI imported : referencedSchemas(local)) { + pending.add(location.resolve(imported)); + } + } + } + + private static void fetch(HttpClient client, URI location, Path target) throws IOException, InterruptedException { + + // w3.org and omg.org serve the same files over https; the http URLs in + // the schemas would need a plain http proxy that CI and sandboxes lack + URI secure = "http".equalsIgnoreCase(location.getScheme()) + ? URI.create("https" + location.toString().substring("http".length())) + : location; + + HttpRequest request = HttpRequest.newBuilder(secure) + .timeout(Duration.ofSeconds(60)) + .header("User-Agent", "reqif4j-tests") + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream body = response.body()) { + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + " for " + secure); + } + // write to a temporary file first, so an interrupted download never + // leaves a truncated schema in the cache + Path partial = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".part"); + try { + Files.copy(body, partial, StandardCopyOption.REPLACE_EXISTING); + Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } finally { + Files.deleteIfExists(partial); + } + } + } + + /** + * @return the schemaLocation of every xs:import, xs:include and xs:redefine + * of the schema; locations mentioned in annotations are ignored + */ + private static Set referencedSchemas(Path schema) throws IOException { + + Set locations = new HashSet(); + Document xsd; + try { + xsd = SecureXml.newDocumentBuilder().parse(schema.toFile()); + } catch (Exception e) { + throw new IOException("Cached schema " + schema + " is not readable; delete it to download it again", e); + } + for (String kind : new String[] { "import", "include", "redefine" }) { + for (Element reference : XmlUtils.descendantsByLocalName(xsd, kind)) { + String location = reference.getAttribute("schemaLocation"); + if (!location.isEmpty() && "http://www.w3.org/2001/XMLSchema".equals(reference.getNamespaceURI())) { + locations.add(URI.create(location)); + } + } + } + return locations; + } + + private static String fileName(URI location) { + String path = location.getPath(); + return path.substring(path.lastIndexOf('/') + 1); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java new file mode 100644 index 0000000..b3c00ea --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java @@ -0,0 +1,91 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; +import de.uni_stuttgart.ils.reqif4j.validate.ValidationIssue; +import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; + +/** + * Validation against the official OMG ReqIF schema, whose imports (xml.xsd, + * driver.xsd, XHTML modules) are resolved from the schema's directory instead + * of being fetched over http. + */ +class SchemaConformanceTest { + + @Test + void validFileIsAcceptedByTheOfficialSchema(@TempDir Path tempDir) throws Exception { + + Path file = TestFixtures.write(tempDir, "valid.reqif", TestFixtures.SCHEMA_VALID_REQIF); + + OfficialReqIFSchema.assertSchemaValid(file); + } + + @Test + void violationsOfAFileAreReportedWithLineNumbers(@TempDir Path tempDir) throws Exception { + + String withoutLastChange = TestFixtures.SCHEMA_VALID_REQIF.replace( + "", + ""); + Path file = TestFixtures.write(tempDir, "invalid.reqif", withoutLastChange); + int line = lineOf(withoutLastChange, ""); + + ValidationResult result = new ReqIFValidator() + .validateAgainstSchema(file, OfficialReqIFSchema.reqifXsd()); + + List errors = result.getErrors(); + assertEquals(1, errors.size(), result.toString()); + assertTrue(errors.get(0).getMessage().contains("LAST-CHANGE"), result.toString()); + assertTrue(errors.get(0).getMessage().startsWith("line " + line + ":"), result.toString()); + } + + @Test + void parsedDocumentIsValidatedWithoutGoingOnline(@TempDir Path tempDir) throws Exception { + + // The writer does not produce schema-valid output yet (LAST-CHANGE is not + // written); what matters here is that the schema with its http imports + // loads at all, which it did not before. + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "doc.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + + ValidationResult result = new ReqIFValidator() + .validateAgainstSchema(reqif.getReqIFDocument(), OfficialReqIFSchema.reqifXsd()); + + List loadFailures = result.getIssues().stream() + .map(ValidationIssue::getMessage) + .filter(message -> message.startsWith("Schema could not be loaded")) + .collect(Collectors.toList()); + assertTrue(loadFailures.isEmpty(), loadFailures.toString()); + } + + @Test + void missingImportsAreNamedInsteadOfFetched(@TempDir Path tempDir) throws Exception { + + // only reqif.xsd, without xml.xsd / driver.xsd next to it + Path schema = Files.copy(OfficialReqIFSchema.reqifXsd(), tempDir.resolve("reqif.xsd")); + Path file = TestFixtures.write(tempDir, "valid.reqif", TestFixtures.SCHEMA_VALID_REQIF); + + ValidationResult result = new ReqIFValidator().validateAgainstSchema(file, schema); + + assertFalse(result.isValid()); + String message = result.getErrors().get(0).getMessage(); + assertTrue(message.startsWith("Schema could not be loaded"), message); + assertTrue(message.contains("xml.xsd (http://www.w3.org/2001/xml.xsd)"), message); + assertTrue(message.contains("place them next to reqif.xsd"), message); + } + + private static int lineOf(String text, String fragment) { + String before = text.substring(0, text.indexOf(fragment)); + return (int) before.chars().filter(c -> c == '\n').count() + 1; + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java index 869bf2e..51c23f4 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java @@ -190,6 +190,157 @@ private TestFixtures() { """; + /** + * A document that is valid against the official OMG ReqIF schema and uses + * every Identifiable property (LAST-CHANGE, LONG-NAME, DESC, ALTERNATIVE-ID) + * on the elements that carry one, including spec objects, relations and + * hierarchy nodes. Values are set for some attributes only, and the + * ALTERNATIVE-ID uses the nested form the schema prescribes. + */ + public static final String SCHEMA_VALID_REQIF = """ + + + + + Created by: Tester + 2026-07-23T10:00:00Z + reqif4j + 1.0 + test + Schema-valid fixture + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + dt-string + + + dt-xhtml + + + dt-int + + + ad-prio + + + + + dt-bool + + + dt-date + + + dt-enum + + + + + + + + + + + + + ad-id + + + ad-text + The system shall boot within 5 s. + + + ad-prio + + + ad-safety + + + ad-due + + + ad-status + ev-approved + + + st-req + + + + + ad-id + + + ad-safety + + + st-req + + + + + st-rel + so-2 + so-1 + + + + + st-spec + + + so-1 + + + so-2 + + + + + + + + + st-group + spec-1 + spec-1 + sr-1 + + + + + + """; + /** * Writes the given content to {@code dir/name} and returns the path. */ diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolverTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolverTest.java new file mode 100644 index 0000000..0a5d7e1 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/validate/LocalSchemaResolverTest.java @@ -0,0 +1,82 @@ +package de.uni_stuttgart.ils.reqif4j.validate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.w3c.dom.ls.LSInput; + +/** + * The resolver must map remote schema imports to local files and must never + * hand a remote location back to the parser, which could fetch it. + */ +class LocalSchemaResolverTest { + + private static final String XSD = "http://www.w3.org/2001/XMLSchema"; + + @Test + void remoteImportIsMappedToTheFileWithTheSameName(@TempDir Path dir) throws Exception { + + Path local = Files.writeString(dir.resolve("xml.xsd"), ""); + LocalSchemaResolver resolver = new LocalSchemaResolver(dir); + + LSInput input = resolver.resolveResource(XSD, null, null, "http://www.w3.org/2001/xml.xsd", null); + + assertEquals(local.toUri().toString(), input.getSystemId()); + assertTrue(resolver.getMissing().isEmpty()); + } + + @Test + void relativeImportOfARemoteSchemaIsMappedAsWell(@TempDir Path dir) throws Exception { + + Path local = Files.writeString(dir.resolve("xhtml-notations-1.xsd"), ""); + LocalSchemaResolver resolver = new LocalSchemaResolver(dir); + + LSInput input = resolver.resolveResource(XSD, null, null, "xhtml-notations-1.xsd", + "https://www.w3.org/MarkUp/SCHEMA/xhtml-framework-1.xsd"); + + assertEquals(local.toUri().toString(), input.getSystemId()); + } + + @Test + void missingRemoteImportIsAnsweredLocallyAndRemembered(@TempDir Path dir) { + + LocalSchemaResolver resolver = new LocalSchemaResolver(dir); + + LSInput input = resolver.resolveResource(XSD, null, null, "http://www.w3.org/2001/xml.xsd", null); + + // an empty input instead of null: null would let the parser fetch the URL + assertEquals("", input.getStringData()); + assertEquals("xml.xsd", resolver.getMissing().get("http://www.w3.org/2001/xml.xsd")); + } + + @Test + void remoteLocationCannotEscapeTheSchemaDirectory(@TempDir Path dir) throws Exception { + + Path schemas = Files.createDirectory(dir.resolve("schemas")); + Files.writeString(dir.resolve("other.xsd"), ""); + LocalSchemaResolver resolver = new LocalSchemaResolver(schemas); + + LSInput input = resolver.resolveResource(XSD, null, null, "http://host/x/..%5C..%5Cother.xsd", null); + + assertEquals("", input.getStringData()); + assertEquals(1, resolver.getMissing().size()); + } + + @Test + void localLocationsAreLeftToTheParser(@TempDir Path dir) { + + LocalSchemaResolver resolver = new LocalSchemaResolver(dir); + String base = dir.resolve("reqif.xsd").toUri().toString(); + + assertNull(resolver.resolveResource(XSD, null, null, "driver.xsd", base)); + assertNull(resolver.resolveResource(XSD, null, null, "C:/schemas/common.xsd", null)); + assertNull(resolver.resolveResource(XSD, null, null, base, null)); + assertTrue(resolver.getMissing().isEmpty()); + } +} From bce36d7ae4776de14edbfe1d2a9a2346b3c2286f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:57:23 +0000 Subject: [PATCH 03/20] Introduce Identifiable: read and write LAST-CHANGE, DESC, LONG-NAME All nine identifiable ReqIF elements (datatypes, enum values, spec types, attribute definitions, spec objects, spec relations, specifications, spec hierarchy nodes, relation groups) now share an Identifiable base class that mirrors the ReqIF metamodel. LAST-CHANGE, DESC, LONG-NAME and ALTERNATIVE-ID are read for every element and written back. Properties a document does not declare stay absent instead of being written empty. ALTERNATIVE-ID is written in the nested form the schema prescribes; the flat form previous versions wrote is still read. An element without the optional LONG-NAME no longer crashes the parser, and one without the required IDENTIFIER is rejected with a ReqIFParseException. Reading attribute values was duplicated between SpecObject and Specification. It now lives in AttributeValueReader. Values of definitions without a LONG-NAME are keyed by the definition id, so they no longer overwrite each other. The schema round-trip test surfaced two writer defects that are fixed here: SPEC-RELATION-GROUPS was written before SPECIFICATIONS, although REQ-IF-CONTENT is a sequence, and XHTML attributes without content were written as an empty THE-VALUE. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- IST-SOLL-ANALYSE.md | 34 ++- README.md | 32 +++ .../attributes/AttributeDefinition.java | 30 +-- .../ils/reqif4j/datatypes/Datatype.java | 28 +-- .../datatypes/DatatypeEnumeration.java | 25 +- .../datatypes/DatatypeEnumerationValue.java | 34 +-- .../ils/reqif4j/reqif/Identifiable.java | 123 ++++++++++ .../ils/reqif4j/reqif/ReqIFConst.java | 2 + .../ils/reqif4j/reqif/ReqIFCoreContent.java | 13 +- .../specification/AttributeValueReader.java | 177 ++++++++++++++ .../specification/LongNameTypeClassifier.java | 13 +- .../reqif4j/specification/RelationGroup.java | 28 +-- .../reqif4j/specification/SpecHierarchy.java | 20 +- .../ils/reqif4j/specification/SpecObject.java | 170 +------------ .../ils/reqif4j/specification/SpecType.java | 32 +-- .../reqif4j/specification/Specification.java | 169 +------------ .../ils/reqif4j/util/XmlUtils.java | 27 ++- .../ils/reqif4j/write/ReqIFWriter.java | 91 ++++--- .../ils/reqif4j/IdentifiableTest.java | 228 ++++++++++++++++++ .../ils/reqif4j/SchemaConformanceTest.java | 22 +- 20 files changed, 763 insertions(+), 535 deletions(-) create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java diff --git a/IST-SOLL-ANALYSE.md b/IST-SOLL-ANALYSE.md index 9cc27af..d2fa732 100644 --- a/IST-SOLL-ANALYSE.md +++ b/IST-SOLL-ANALYSE.md @@ -2,6 +2,32 @@ Stand: 2026-10-11 · Basis: Branch `claude/dreamy-goldberg-muhi8j` (= `master` @ `972ae90`, Version `1.1.2-SNAPSHOT`) +## 0. Umsetzungsstand + +| Phase | Stand | Ergebnis | +|---|---|---| +| **1.1** Schemaprüfung | ✅ erledigt | `validateAgainstSchema` löst die http-Importe des offiziellen XSD lokal auf und geht nie online. Fehlende Importe werden mit Namen und URL gemeldet. Neue Variante für Dateien auf der Platte mit Zeilennummern. Die Tests laden das Schema einmalig herunter (in der CI gecacht) und prüfen gegen das echte OMG-XSD (`OfficialReqIFSchema.assertSchemaValid`). | +| **1.2** `Identifiable` | ✅ erledigt | Gemeinsame Basisklasse für alle 9 Elementarten. `LAST-CHANGE`, `DESC`, `LONG-NAME` (auch an Spec-Objekten, Relationen und Hierarchieknoten) und `ALTERNATIVE-ID` werden gelesen und geschrieben. Fehlender `LONG-NAME` führt nicht mehr zur NPE, fehlender `IDENTIFIER` zu einer klaren `ReqIFParseException`. Das Lesen der Attributwerte liegt jetzt in einer Klasse (`AttributeValueReader`). | + +Beim Umsetzen von Phase 1 fielen vier weitere Writer-Fehler auf. Sie wurden behoben, weil sie einen +schemagültigen Round-Trip verhinderten: + +- **`ALTERNATIVE-ID` hatte die falsche Form.** Das Schema verschachtelt das Element + (``). reqif4j las und schrieb die flache + Form, las also die Alternative-IDs echter Dateien nicht und schrieb ungültiges XML. Jetzt wird die + verschachtelte Form geschrieben, gelesen werden beide. +- **`SPEC-RELATION-GROUPS` stand vor `SPECIFICATIONS`.** `REQ-IF-CONTENT` ist eine `xsd:sequence`. + Jede Datei mit Relationsgruppen war deshalb ungültig. +- **Leeres `` für XHTML-Attribute ohne Inhalt.** Das ist ungültig, solche Werte werden jetzt ausgelassen. +- **Werte unbenannter Attributdefinitionen überschrieben sich.** Ohne `LONG-NAME` dient jetzt die ID als + Schlüssel. Die eigentliche Umstellung auf IDs bleibt Arbeitspaket 3.1. + +Eine API-Änderung: `getName()` liefert für Elemente ohne `LONG-NAME` jetzt `null` (vorher Absturz, +bei Enum-Werten `""`). `DatatypeEnumeration.getEnumValueName()` liefert weiterhin `""`. + +Offen für Phase 2: ein Default für `LAST-CHANGE` bei erzeugten Elementen (2.1), die übrigen +Pflichtattribute (2.2) und deren Prüfung im Modellvalidator (2.3). + ## 1. Zusammenfassung **Zielbild:** Jede gültige ReqIF-1.2-Datei (`.reqif`/`.reqifz`) wird verlustfrei eingelesen, lässt @@ -70,10 +96,10 @@ Legende: ✅ unterstützt · ⚠️ teilweise · ❌ fehlt · — nicht anwendba | Header: `REPOSITORY-ID` | ❌ | ❌ | ❌ | | `TOOL-EXTENSIONS` | ✅ (roh) | ✅ (roh) | ❌ | | Identifiable: `IDENTIFIER` | ✅ | ✅ | ✅ | -| Identifiable: `LAST-CHANGE` (**Pflicht**) | ❌ | ❌ | ❌ | -| Identifiable: `DESC` | ❌ | ❌ | ❌ | -| Identifiable: `LONG-NAME` | ⚠️ nur Datentypen, Spec-Typen, Attributdefinitionen, Spezifikationen, Relationsgruppen. Bei Fehlen: Absturz | ⚠️ ebenso | ⚠️ | -| `ALTERNATIVE-ID` | ✅ | ✅ | ❌ | +| Identifiable: `LAST-CHANGE` (**Pflicht**) | ✅ *(1.2)* | ⚠️ nur wenn vorhanden, kein Default *(2.1)* | ⚠️ nur per Setter | +| Identifiable: `DESC` | ✅ *(1.2)* | ✅ *(1.2)* | ⚠️ nur per Setter | +| Identifiable: `LONG-NAME` | ✅ alle Elemente, optional *(1.2)* | ✅ *(1.2)* | ⚠️ nicht für Spec-Objekte | +| `ALTERNATIVE-ID` | ✅ verschachtelte und flache Form *(1.2)* | ✅ verschachtelt *(1.2)* | ⚠️ nur per Setter | | `DATATYPE-DEFINITION-BOOLEAN` / `-DATE` / `-XHTML` | ✅ | ✅ | ✅ | | `DATATYPE-DEFINITION-STRING` `MAX-LENGTH` | ✅ | ✅ | ✅ | | `DATATYPE-DEFINITION-INTEGER` `MIN`/`MAX` | ✅ (long) | ✅ | ✅ | diff --git a/README.md b/README.md index d8921ec..70385bf 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,38 @@ enumerations and XHTML included), spec relations with their attributes, and the specification hierarchy. Attribute values are written sorted by definition id so the output is reproducible. +## Identity and metadata of elements + +Every identifiable element - datatypes, enum values, spec types, attribute +definitions, spec objects, spec relations, specifications, spec hierarchy +nodes and relation groups - extends `Identifiable` and exposes the +properties the ReqIF schema defines for it: + +```java +SpecObject specObject = reqif.getReqIFCoreContent().getSpecObject("so-1"); + +specObject.getID(); // IDENTIFIER +specObject.getName(); // LONG-NAME, null if the document declares none +specObject.getDesc(); // DESC +specObject.getLastChange(); // LAST-CHANGE, as written (xsd:dateTime) +specObject.getAlternativeID(); // ALTERNATIVE-ID + +specObject.setDesc("reviewed").setLastChange("2026-10-11T12:00:00Z"); +``` + +They are written back unchanged; properties a document does not declare +stay absent instead of being written empty. `ALTERNATIVE-ID` is written +in the nested form the schema prescribes +(``); +the flat form older versions of this library wrote is still read. + +A schema-valid document stays schema-valid after a round trip, as long as +it uses only what the writer already covers. Not yet covered: elements +without `LAST-CHANGE` (e.g. generated with `ReqIFBuilder`) get none, and +`MULTI-VALUED="false"`, an empty `OTHER-CONTENT` and the `ACCURACY`/`MIN`/`MAX` +of REAL datatypes are not written although the schema requires them - see +`IST-SOLL-ANALYSE.md`. + Indenting is **off by default**: the XML indenter inserts whitespace into mixed content, which would change XHTML attribute values. Enable it with `new ReqIFWriter().setIndent(true)` only when readable output matters more diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java index 5bd2ba2..0e368c4 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java @@ -7,30 +7,20 @@ import org.w3c.dom.NodeList; import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; -public class AttributeDefinition { +public class AttributeDefinition extends Identifiable { - private String id; - private String name; private Datatype type; private String defaultValue; - private String alternativeID; private String sourceElementName; - public String getID() { - return this.id; - } - - public String getName() { - return this.name; - } - public Datatype getDataType() { return this.type; } @@ -39,13 +29,6 @@ public String getDefaultValue() { return this.defaultValue; } - /** - * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null - */ - public String getAlternativeID() { - return this.alternativeID; - } - /** * @return the ATTRIBUTE-DEFINITION-* element name this definition was read * from, or null when it was not created from a document @@ -62,18 +45,15 @@ public String getSourceElementName() { * generated instead of parsed. */ public AttributeDefinition(String id, String name, Datatype type, String defaultValue) { + super(id, name); - this.id = id; - this.name = name; this.type = type; this.defaultValue = defaultValue; } public AttributeDefinition(Node attributeDefinition, Map dataTypes) { - - this.id = attributeDefinition.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - this.name = attributeDefinition.getAttributes().getNamedItem(ReqIFConst.LONG_NAME).getTextContent(); - this.alternativeID = XmlUtils.alternativeID(attributeDefinition); + super(attributeDefinition); + this.sourceElementName = XmlUtils.localName(attributeDefinition); // Navigate by element (not by fixed child index) so both pretty-printed diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java index 0af27de..d964bc9 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java @@ -1,24 +1,15 @@ package de.uni_stuttgart.ils.reqif4j.datatypes; -public class Datatype { +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; + +public class Datatype extends Identifiable { - private String id; - private String name; private String type; private String sourceElementName; - private String alternativeID; - - public String getID() { - return this.id; - } - - public String getName() { - return this.name; - } public String getType() { return this.type; @@ -33,15 +24,9 @@ public String getSourceElementName() { return this.sourceElementName; } - /** - * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null - */ - public String getAlternativeID() { - return this.alternativeID; - } - + @Override public Datatype setAlternativeID(String alternativeID) { - this.alternativeID = alternativeID; + super.setAlternativeID(alternativeID); return this; } @@ -49,9 +34,8 @@ public Datatype setAlternativeID(String alternativeID) { public Datatype(String id, String name, String type) { + super(id, name); - this.id = id; - this.name = name; this.type = type; } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java index 76fed6a..5b1af70 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java @@ -23,12 +23,15 @@ public Map getEnumValues() { } /** - * @return the LONG-NAME of the enum value with the given id, or null if - * unknown + * @return the LONG-NAME of the enum value with the given id, "" if the + * value has no LONG-NAME, or null if the id is unknown */ public String getEnumValueName(String id) { DatatypeEnumerationValue value = this.enumValues.get(id); - return value == null ? null : value.getName(); + if (value == null) { + return null; + } + return value.getName() == null ? "" : value.getName(); } /** @@ -70,22 +73,12 @@ public DatatypeEnumeration(String id, String name, Node enumeration) { // namespace prefixes and optional attributes. for (Element enumValue : XmlUtils.descendantsByLocalName(enumeration, ReqIFConst.ENUM_VALUE)) { - String identifier = XmlUtils.attribute(enumValue, ReqIFConst.IDENTIFIER); - if (identifier == null) { + if (XmlUtils.attribute(enumValue, ReqIFConst.IDENTIFIER) == null) { continue; } - String longName = XmlUtils.attribute(enumValue, ReqIFConst.LONG_NAME); - - Element embeddedValue = XmlUtils.firstDescendantByLocalName(enumValue, ReqIFConst.EMBEDDED_VALUE); - String key = embeddedValue == null ? "" : orEmpty(XmlUtils.attribute(embeddedValue, ReqIFConst.KEY)); - String otherContent = embeddedValue == null ? "" : orEmpty(XmlUtils.attribute(embeddedValue, ReqIFConst.OTHER_CONTENT)); - - enumValues.put(identifier, new DatatypeEnumerationValue(identifier, orEmpty(longName), key, otherContent)); + DatatypeEnumerationValue value = new DatatypeEnumerationValue(enumValue); + enumValues.put(value.getID(), value); } } - private static String orEmpty(String value) { - return value == null ? "" : value; - } - } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumerationValue.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumerationValue.java index 919d52b..c0be4f7 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumerationValue.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumerationValue.java @@ -1,24 +1,20 @@ package de.uni_stuttgart.ils.reqif4j.datatypes; -public class DatatypeEnumerationValue { +import org.w3c.dom.Element; + +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; + +public class DatatypeEnumerationValue extends Identifiable { - private java.lang.String id; - private java.lang.String name; private java.lang.String key; private java.lang.String otherContent; - public String getID() { - return this.id; - } - - public String getName() { - return this.name; - } - public String getKey() { return this.key; } @@ -31,11 +27,21 @@ public String getOtherContent() { public DatatypeEnumerationValue(String id, String name, String key, String otherContent) { - - this.id = id; - this.name = name; + super(id, name); + this.key = key; this.otherContent = otherContent; } + /** + * Reads an ENUM-VALUE element. A missing KEY or OTHER-CONTENT is read as "". + */ + DatatypeEnumerationValue(Element enumValue) { + super(enumValue); + + Element embeddedValue = XmlUtils.firstDescendantByLocalName(enumValue, ReqIFConst.EMBEDDED_VALUE); + this.key = XmlUtils.attributeOrEmpty(embeddedValue, ReqIFConst.KEY); + this.otherContent = XmlUtils.attributeOrEmpty(embeddedValue, ReqIFConst.OTHER_CONTENT); + } + } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java new file mode 100644 index 0000000..bfcb886 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java @@ -0,0 +1,123 @@ +package de.uni_stuttgart.ils.reqif4j.reqif; + +import org.w3c.dom.Node; + +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; + +/** + * Common base of every ReqIF element that has an identity: datatypes, enum + * values, spec types, attribute definitions, spec objects, spec relations, + * specifications, spec hierarchy nodes and relation groups. + * + * Mirrors the abstract {@code Identifiable} of the ReqIF metamodel: + * + *
+ * IDENTIFIER     required  the element's id, unique within the document
+ * LAST-CHANGE    required  xsd:dateTime of the last modification
+ * LONG-NAME      optional  human readable name
+ * DESC           optional  description
+ * ALTERNATIVE-ID optional  a second, tool specific id
+ * 
+ * + * Values are kept exactly as written in the document; an attribute the + * document does not declare is {@code null}, so it is not invented when the + * element is written back. + */ +public abstract class Identifiable { + + private String id; + private String name; + private String desc; + private String lastChange; + private String alternativeID; + + + /** + * For elements created from plain values instead of a document. + */ + protected Identifiable(String id, String name) { + this.id = id; + this.name = name; + } + + /** + * Reads IDENTIFIER, LONG-NAME, DESC, LAST-CHANGE and ALTERNATIVE-ID of a + * parsed element. + */ + protected Identifiable(Node element) { + readIdentifiable(element); + } + + /** + * Fills the properties from an element; for elements that are created from + * values while a document is read (datatypes). + * + * @throws ReqIFParseException if the element has no IDENTIFIER, which the + * schema requires and every reference relies on + */ + protected final void readIdentifiable(Node element) { + this.id = XmlUtils.attribute(element, ReqIFConst.IDENTIFIER); + if (this.id == null) { + throw new ReqIFParseException(XmlUtils.localName(element) + " without " + ReqIFConst.IDENTIFIER); + } + this.name = XmlUtils.attribute(element, ReqIFConst.LONG_NAME); + this.desc = XmlUtils.attribute(element, ReqIFConst.DESC); + this.lastChange = XmlUtils.attribute(element, ReqIFConst.LAST_CHANGE); + this.alternativeID = XmlUtils.alternativeID(element); + } + + + /** + * @return the IDENTIFIER + */ + public String getID() { + return this.id; + } + + /** + * @return the LONG-NAME, or null if the element declares none + */ + public String getName() { + return this.name; + } + + /** + * @return the DESC, or null if the element declares none + */ + public String getDesc() { + return this.desc; + } + + public Identifiable setDesc(String desc) { + this.desc = desc; + return this; + } + + /** + * @return the LAST-CHANGE exactly as written (xsd:dateTime), or null if the + * element declares none + */ + public String getLastChange() { + return this.lastChange; + } + + /** + * @param lastChange xsd:dateTime, e.g. 2026-07-23T10:00:00Z + */ + public Identifiable setLastChange(String lastChange) { + this.lastChange = lastChange; + return this; + } + + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } + + public Identifiable setAlternativeID(String alternativeID) { + this.alternativeID = alternativeID; + return this; + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java index ed585db..d1638cc 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java @@ -4,6 +4,8 @@ public class ReqIFConst { public final static String IDENTIFIER = "IDENTIFIER"; public final static String LONG_NAME = "LONG-NAME"; + public final static String DESC = "DESC"; + public final static String LAST_CHANGE = "LAST-CHANGE"; public final static String THE_VALUE = "THE-VALUE"; public final static String KEY = "KEY"; public final static String OTHER_CONTENT = "OTHER-CONTENT"; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java index f8ce6bb..cfd5e4d 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java @@ -149,9 +149,9 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { String dataTypeNodeName = XmlUtils.localName(dataType); { - String dataTypeID = dataType.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - String dataTypeName = dataType.getAttributes().getNamedItem(ReqIFConst.LONG_NAME).getTextContent(); - String dataTypeAlternativeID = XmlUtils.alternativeID(dataType); + String dataTypeID = XmlUtils.attribute(dataType, ReqIFConst.IDENTIFIER); + // LONG-NAME is optional in ReqIF + String dataTypeName = XmlUtils.attribute(dataType, ReqIFConst.LONG_NAME); switch (dataTypeNodeName.substring(dataTypeNodeName.lastIndexOf("-") + 1)) { @@ -195,9 +195,8 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { this.dataTypes.put(dataTypeID, new Datatype(dataTypeID, dataTypeName, ReqIFConst.UNDEFINED, dataTypeNodeName)); break; } - if (this.dataTypes.get(dataTypeID) != null) { - this.dataTypes.get(dataTypeID).setAlternativeID(dataTypeAlternativeID); - } + // DESC, LAST-CHANGE and ALTERNATIVE-ID + this.dataTypes.get(dataTypeID).readIdentifiable(dataType); } } } @@ -211,7 +210,7 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { String specTypeNodeName = XmlUtils.localName(specType); { - String specTypeID = specType.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); + String specTypeID = XmlUtils.attribute(specType, ReqIFConst.IDENTIFIER); switch (specTypeNodeName) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java new file mode 100644 index 0000000..91fd4ae --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java @@ -0,0 +1,177 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinitionEnumeration; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueBoolean; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDate; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDouble; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueInteger; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueString; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; +import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; + +/** + * Reads the VALUES of an element with attributes - spec objects, spec + * relations and specifications share the same structure - and fills in the + * default values of attribute definitions that carry no explicit value. + */ +final class AttributeValueReader { + + private AttributeValueReader() { + } + + /** + * @param element the SPEC-OBJECT, SPEC-RELATION or SPECIFICATION + * @param specType the element's resolved spec type + * @param attributeValues receives the values, keyed by {@link #key} + */ + static void read(Node element, SpecType specType, Map attributeValues) { + + // VALUES is located by local name so prefixed ReqIF namespaces work too; + // only this element's own VALUES is taken, not a nested one. + Element valuesElement = XmlUtils.firstChildElementByLocalName(element, ReqIFConst.VALUES); + if(valuesElement != null) { + + for(Element attribute: XmlUtils.childElements(valuesElement)) { + + String attValNodeName = XmlUtils.localName(attribute); + String attributeDefinitionRef = XmlUtils.firstChildElement( + XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim(); + AttributeDefinition attributeDefinition = specType.getAttributeDefinition(attributeDefinitionRef); + String attributeDefinitionName = key(attributeDefinition); + + switch(attValNodeName.substring(attValNodeName.lastIndexOf("-")+1)) { + + case ReqIFConst.BOOLEAN: + attributeValues.put(attributeDefinitionName, new AttributeValueBoolean(theValue(attribute), attributeDefinition)); + break; + + case ReqIFConst.INTEGER: + attributeValues.put(attributeDefinitionName, new AttributeValueInteger(theValue(attribute), attributeDefinition)); + break; + + case ReqIFConst.STRING: + attributeValues.put(attributeDefinitionName, new AttributeValueString(theValue(attribute), attributeDefinition)); + break; + + case ReqIFConst.ENUMERATION: + // Multiselect: read ALL ENUM-VALUE-REF children, not just the first + List enumRefs = new ArrayList(); + List enumNames = new ArrayList(); + for(Element ref : XmlUtils.descendantsByLocalName(attribute, ReqIFConst.ENUM_VALUE_REF)) { + String refID = ref.getTextContent().trim(); + enumRefs.add(refID); + enumNames.add(specType.getEnumValueName(refID)); + } + attributeValues.put(attributeDefinitionName, new AttributeValueEnumeration(enumNames, enumRefs, attributeDefinition)); + break; + + case ReqIFConst.XHTML: + attributeValues.put(attributeDefinitionName, new AttributeValueXHTML(attribute, attributeDefinition)); + break; + + case ReqIFConst.DATE: + attributeValues.put(attributeDefinitionName, new AttributeValueDate(theValue(attribute), attributeDefinition)); + break; + + case ReqIFConst.REAL: + attributeValues.put(attributeDefinitionName, new AttributeValueDouble(theValue(attribute), attributeDefinition)); + break; + + // Values of datatype kinds the parser does not model are kept + // generically instead of being dropped, so they survive a + // round trip. + default: + attributeValues.put(attributeDefinitionName, + new AttributeValue(theValue(attribute), attributeDefinition) + .setSourceElementName(attValNodeName)); + break; + } + } + } + + if(attributeValues.size() < specType.getAttributeDefinitions().size()) { + + for(AttributeDefinition attributeDefinition: specType.getAttributeDefinitions().values()) { + + String key = key(attributeDefinition); + if(!attributeValues.containsKey(key)) { + + Datatype datatype = attributeDefinition.getDataType(); + + if(datatype == null) { + throw new ExceptionSpecObject("Attribute definition not existing in ReqIF parser\n", attributeDefinition); + } + + String defaultValue = attributeDefinition.getDefaultValue(); + switch(datatype.getType()) { + + case ReqIFConst.BOOLEAN: + attributeValues.put(key, new AttributeValueBoolean(defaultValue, attributeDefinition)); + break; + + case ReqIFConst.INTEGER: + attributeValues.put(key, new AttributeValueInteger(defaultValue, attributeDefinition)); + break; + + case ReqIFConst.STRING: + attributeValues.put(key, new AttributeValueString(defaultValue, attributeDefinition)); + break; + + case ReqIFConst.ENUMERATION: + if(attributeDefinition instanceof AttributeDefinitionEnumeration) { + AttributeDefinitionEnumeration enumDefinition = (AttributeDefinitionEnumeration) attributeDefinition; + attributeValues.put(key, new AttributeValueEnumeration(enumDefinition.getDefaultValues(), enumDefinition.getDefaultValueRefs(), attributeDefinition)); + }else{ + attributeValues.put(key, new AttributeValueEnumeration(defaultValue, attributeDefinition)); + } + break; + + case ReqIFConst.XHTML: + attributeValues.put(key, new AttributeValueXHTML(defaultValue, attributeDefinition)); + break; + + case ReqIFConst.DATE: + attributeValues.put(key, new AttributeValueDate(defaultValue, attributeDefinition)); + break; + + // the parser normalizes REAL datatypes to DOUBLE + case ReqIFConst.DOUBLE: + attributeValues.put(key, new AttributeValueDouble(defaultValue, attributeDefinition)); + break; + + default: + break; + } + } + } + } + } + + /** + * Attribute values are kept by the LONG-NAME of their definition. LONG-NAME + * is optional, though; definitions without one are keyed by their + * IDENTIFIER instead, so their values do not overwrite each other. + */ + static String key(AttributeDefinition attributeDefinition) { + return attributeDefinition.getName() != null ? attributeDefinition.getName() : attributeDefinition.getID(); + } + + /** + * @return the THE-VALUE attribute, or "" if the value element has none + */ + private static String theValue(Element attribute) { + return XmlUtils.attributeOrEmpty(attribute, ReqIFConst.THE_VALUE); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/LongNameTypeClassifier.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/LongNameTypeClassifier.java index fa012f0..c52063c 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/LongNameTypeClassifier.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/LongNameTypeClassifier.java @@ -45,7 +45,7 @@ public boolean isRequirement(SpecObject specObject) { for (AttributeValue attributeValue : specObject.getAttributes().values()) { if (isBoolean(attributeValue) - && attributeValue.getName().toLowerCase().contains(ReqIFConst.REQ.toLowerCase())) { + && lowerCaseName(attributeValue).contains(ReqIFConst.REQ.toLowerCase())) { return Boolean.TRUE.equals(attributeValue.getValue()); } } @@ -61,8 +61,8 @@ public boolean isSubRequirement(SpecObject specObject) { for (AttributeValue attributeValue : specObject.getAttributes().values()) { if (isBoolean(attributeValue) - && attributeValue.getName().toLowerCase().contains(ReqIFConst.SUB.toLowerCase()) - && attributeValue.getName().toLowerCase().contains(ReqIFConst.REQ.toLowerCase())) { + && lowerCaseName(attributeValue).contains(ReqIFConst.SUB.toLowerCase()) + && lowerCaseName(attributeValue).contains(ReqIFConst.REQ.toLowerCase())) { return Boolean.TRUE.equals(attributeValue.getValue()); } } @@ -74,4 +74,11 @@ private static boolean isBoolean(AttributeValue attributeValue) { && attributeValue.getAttributeDefinitionType().getDataType() != null && ReqIFConst.BOOLEAN.equals(attributeValue.getAttributeDefinitionType().getDataType().getType()); } + + /** + * LONG-NAME is optional in ReqIF, so an attribute may have no name. + */ + private static String lowerCaseName(AttributeValue attributeValue) { + return attributeValue.getName() == null ? "" : attributeValue.getName().toLowerCase(); + } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java index 777fc1b..b364c53 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java @@ -7,6 +7,7 @@ import org.w3c.dom.Element; import org.w3c.dom.Node; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; @@ -18,32 +19,14 @@ * between a source and a target specification, for example a traceability view * between a system and a software specification. */ -public class RelationGroup { +public class RelationGroup extends Identifiable { - private String id; - private String name; - private String alternativeID; private String relationGroupTypeRef; private String sourceSpecificationRef; private String targetSpecificationRef; private final List specRelationRefs = new ArrayList(); - public String getID() { - return this.id; - } - - public String getName() { - return this.name; - } - - /** - * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null - */ - public String getAlternativeID() { - return this.alternativeID; - } - /** * @return the IDENTIFIER of the RELATION-GROUP-TYPE, or null */ @@ -81,9 +64,8 @@ public List getSpecRelationRefs() { */ public RelationGroup(String id, String name, String relationGroupTypeRef, String sourceSpecificationRef, String targetSpecificationRef, List specRelationRefs) { + super(id, name); - this.id = id; - this.name = name; this.relationGroupTypeRef = relationGroupTypeRef; this.sourceSpecificationRef = sourceSpecificationRef; this.targetSpecificationRef = targetSpecificationRef; @@ -93,10 +75,8 @@ public RelationGroup(String id, String name, String relationGroupTypeRef, } public RelationGroup(Node relationGroup) { + super(relationGroup); - this.id = XmlUtils.attribute(relationGroup, ReqIFConst.IDENTIFIER); - this.name = XmlUtils.attribute(relationGroup, ReqIFConst.LONG_NAME); - this.alternativeID = XmlUtils.alternativeID(relationGroup); this.relationGroupTypeRef = refIn(relationGroup, ReqIFConst.TYPE, ReqIFConst.RELATION_GROUP_TYPE_REF); this.sourceSpecificationRef = refIn(relationGroup, ReqIFConst.SOURCE_SPECIFICATION, ReqIFConst.SPECIFICATION_REF); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java index 220ed87..65214a6 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java @@ -12,16 +12,15 @@ import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTMLElementList; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLNode; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElementDiv; -public class SpecHierarchy { +public class SpecHierarchy extends Identifiable { - private String specHierarchyID; - private String alternativeID; private int hierarchyLvl; private int section; private SpecObject specObject; @@ -30,15 +29,11 @@ public class SpecHierarchy { - public String getSpecHierarchyID() { - return this.specHierarchyID; - } - /** - * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + * @return the IDENTIFIER of this hierarchy node, same as {@link #getID()} */ - public String getAlternativeID() { - return this.alternativeID; + public String getSpecHierarchyID() { + return getID(); } public String getSpecObjectID(){ @@ -167,8 +162,8 @@ public List getAllChildren() { */ public SpecHierarchy(String specHierarchyID, int hierarchyLvl, int section, SpecObject specObject, List children) { + super(specHierarchyID, null); - this.specHierarchyID = specHierarchyID; this.hierarchyLvl = hierarchyLvl; this.section = section; this.specObject = specObject; @@ -181,11 +176,10 @@ public SpecHierarchy(String specHierarchyID, int hierarchyLvl, int section, Spec } public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map specObjects) { + super(specHierarchy); this.hierarchyLvl = hierarchyLvl; this.section = section; - this.specHierarchyID = specHierarchy.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - this.alternativeID = XmlUtils.alternativeID(specHierarchy); Element object = XmlUtils.firstChildElementByLocalName(specHierarchy, ReqIFConst.OBJECT); Element specObjectRef = XmlUtils.firstDescendantByLocalName(object, ReqIFConst.SPEC_OBJECT_REF); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java index 0273e0a..9b9bbe7 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java @@ -1,44 +1,26 @@ package de.uni_stuttgart.ils.reqif4j.specification; -import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; -import java.util.List; import java.util.Map; -import de.uni_stuttgart.ils.reqif4j.attributes.*; -import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; -import org.w3c.dom.Element; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; import org.w3c.dom.Node; -import org.w3c.dom.NodeList; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; -import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; -public class SpecObject { +public class SpecObject extends Identifiable { - protected String id; protected SpecType specType; protected String type; - protected String alternativeID; protected TypeClassifier typeClassifier = TypeClassifier.defaultClassifier(); protected Map attributeValues = new HashMap(); - public String getID() { - return this.id; - } - - /** - * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null - */ - public String getAlternativeID() { - return this.alternativeID; - } - public String getType() { return this.type; } @@ -96,8 +78,7 @@ public boolean isText() { public SpecObject(Node specObject){ - this.id = specObject.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - this.alternativeID = XmlUtils.alternativeID(specObject); + super(specObject); } /** @@ -107,14 +88,14 @@ public SpecObject(Node specObject){ */ public SpecObject(String id, SpecType specType, Collection attributeValues, TypeClassifier typeClassifier) { + super(id, null); - this.id = id; this.specType = specType; this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; if(attributeValues != null) { for(AttributeValue attributeValue: attributeValues) { - this.attributeValues.put(attributeValue.getName(), attributeValue); + this.attributeValues.put(AttributeValueReader.key(attributeValue.getAttributeDefinitionType()), attributeValue); } } this.type = this.typeClassifier.classify(this); @@ -129,9 +110,8 @@ public SpecObject(Node specObject, SpecType specType) { } public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassifier) { + super(specObject); - this.id = specObject.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - this.alternativeID = XmlUtils.alternativeID(specObject); this.specType = specType; this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; @@ -149,141 +129,7 @@ public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassif * {@link SpecObject} and {@link SpecRelation}. */ protected void readAttributeValues(Node specObject, SpecType specType) { - - // VALUES is located by local name so prefixed ReqIF namespaces work too; - // only this object's own VALUES is taken, not a nested one. - Element valuesElement = XmlUtils.firstChildElementByLocalName(specObject, ReqIFConst.VALUES); - if(valuesElement != null) { - - for(Element attribute: XmlUtils.childElements(valuesElement)) { - - String attValNodeName = XmlUtils.localName(attribute); - { - - String attributeDefinitionRef = XmlUtils.firstChildElement( - XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim(); - String attributeDefinitionName = specType.getAttributeDefinitions().get(attributeDefinitionRef).getName(); - String attributeValue; - AttributeDefinition attributeDefinition = specType.getAttributeDefinition(attributeDefinitionRef); - - switch(attValNodeName.substring(attValNodeName.lastIndexOf("-")+1)) { - - case ReqIFConst.BOOLEAN: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueBoolean(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.INTEGER: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueInteger(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.STRING: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueString(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.ENUMERATION: // Multiselect: read ALL ENUM-VALUE-REF children, not just the first - List enumRefs = new ArrayList(); - List enumNames = new ArrayList(); - for(Element ref : XmlUtils.descendantsByLocalName(attribute, ReqIFConst.ENUM_VALUE_REF)) { - String refID = ref.getTextContent().trim(); - enumRefs.add(refID); - enumNames.add(specType.getEnumValueName(refID)); - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueEnumeration(enumNames, enumRefs, attributeDefinition)); - break; - - case ReqIFConst.XHTML: this.attributeValues.put(attributeDefinitionName, new AttributeValueXHTML(attribute, attributeDefinition)); - break; - - case ReqIFConst.DATE: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueDate(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.REAL: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueDouble(attributeValue, attributeDefinition)); - break; - - // Values of datatype kinds the parser does not model are - // kept generically instead of being dropped, so they - // survive a round trip. - default: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, - new AttributeValue(attributeValue, attributeDefinition) - .setSourceElementName(XmlUtils.localName(attribute))); - break; - } - } - } - } - - if(this.attributeValues.size() < specType.getAttributeDefinitions().size()) { - - for(AttributeDefinition attributeDefinition: specType.getAttributeDefinitions().values()) { - - if(!this.attributeValues.containsKey(attributeDefinition.getName())) { - - Datatype datatype = attributeDefinition.getDataType(); - - if(datatype == null) { - throw new ExceptionSpecObject("Attribute definition not existing in ReqIF parser\n", attributeDefinition); - } - - switch(attributeDefinition.getDataType().getType()) { - - case ReqIFConst.BOOLEAN: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueBoolean(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.INTEGER: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueInteger(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.STRING: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueString(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.ENUMERATION: if(attributeDefinition instanceof AttributeDefinitionEnumeration) { - AttributeDefinitionEnumeration enumDefinition = (AttributeDefinitionEnumeration) attributeDefinition; - this.attributeValues.put(attributeDefinition.getName(), new AttributeValueEnumeration(enumDefinition.getDefaultValues(), enumDefinition.getDefaultValueRefs(), attributeDefinition)); - }else{ - this.attributeValues.put(attributeDefinition.getName(), new AttributeValueEnumeration(attributeDefinition.getDefaultValue(), attributeDefinition)); - } - break; - - case ReqIFConst.XHTML: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueXHTML(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.DATE: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueDate(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.DOUBLE: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueDouble(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - default: break; - } - } - } - } + AttributeValueReader.read(specObject, specType, this.attributeValues); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java index b0c456c..07842a8 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java @@ -9,33 +9,20 @@ import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; -public class SpecType { +public class SpecType extends Identifiable { private Map attributeDefinitions = new LinkedHashMap(); - private String id; - protected String name; protected String type; - protected String alternativeID; protected String sourceElementName; - public String getID() { - return this.id; - } - - /** - * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null - */ - public String getAlternativeID() { - return this.alternativeID; - } - /** * @return the SPEC-TYPE element name this type was read from, or null when * it was not created from a document. Needed to write back spec type @@ -46,10 +33,6 @@ public String getSourceElementName() { return this.sourceElementName; } - public String getName() { - return this.name; - } - public String getType() { return this.type; } @@ -126,9 +109,8 @@ public String getEnumValueOtherContent(String id) { * @param kind SPEC-OBJECT-TYPE, SPECIFICATION-TYPE or SPEC-RELATION-TYPE */ public SpecType(String id, String name, String kind) { + super(id, name); - this.id = id; - this.name = name; this.type = kind; } @@ -142,10 +124,8 @@ public SpecType addAttributeDefinition(AttributeDefinition attributeDefinition) } public SpecType(Node specType, Map dataTypes) { - - this.id = specType.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - this.name = specType.getAttributes().getNamedItem(ReqIFConst.LONG_NAME).getTextContent(); - this.alternativeID = XmlUtils.alternativeID(specType); + super(specType); + this.sourceElementName = XmlUtils.localName(specType); this.type = ReqIFConst.UNDEFINED; @@ -158,7 +138,7 @@ public SpecType(Node specType, Map dataTypes) { String attDefNodeName = XmlUtils.localName(attributeDefinition); { - String attDefID = attributeDefinition.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); + String attDefID = XmlUtils.attribute(attributeDefinition, ReqIFConst.IDENTIFIER); switch(attDefNodeName.substring(attDefNodeName.lastIndexOf("-")+1)) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java index a99c65d..1278b71 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java @@ -8,27 +8,15 @@ import org.w3c.dom.Element; import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinitionEnumeration; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueBoolean; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDate; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDouble; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueInteger; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueString; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; -public class Specification { +public class Specification extends Identifiable { - private String id; - private String name; - private String alternativeID; private SpecType type; private int numberOfSpecObjects; private int sectionCounter = 0; @@ -39,16 +27,6 @@ public class Specification { - public String getID() { - return this.id; - } - - /** - * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null - */ - public String getAlternativeID() { - return this.alternativeID; - } /** * @return the value of an attribute named "Description", or null if the @@ -59,9 +37,6 @@ public String getDescription() { return description == null ? null : (String) description.getValue(); } - public String getName() { - return this.name; - } public String getSpecType() { return this.type.getType(); @@ -124,14 +99,13 @@ public List getAllSpecHierarchies() { */ public Specification(String id, String name, SpecType specType, java.util.Collection attributeValues, List children) { + super(id, name); - this.id = id; - this.name = name; this.type = specType; if(attributeValues != null) { for(AttributeValue attributeValue: attributeValues) { - this.attributeValues.put(attributeValue.getName(), attributeValue); + this.attributeValues.put(AttributeValueReader.key(attributeValue.getAttributeDefinitionType()), attributeValue); } } @@ -152,143 +126,12 @@ public Specification(String id, String name, SpecType specType, } public Specification(Node specification, SpecType specType, Map specObjects) { + super(specification); - this.id = specification.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - this.name = specification.getAttributes().getNamedItem(ReqIFConst.LONG_NAME).getTextContent(); - this.alternativeID = XmlUtils.alternativeID(specification); this.type = specType; - - Element valuesElement = XmlUtils.firstChildElementByLocalName(specification, ReqIFConst.VALUES); - if(valuesElement != null) { - - for(Element attribute: XmlUtils.childElements(valuesElement)) { - - String attValNodeName = XmlUtils.localName(attribute); - { - String attributeDefinitionRef = XmlUtils.firstChildElement( - XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim(); - String attributeDefinitionName = specType.getAttributeDefinitions().get(attributeDefinitionRef).getName(); - String attributeValue; - AttributeDefinition attributeDefinition = specType.getAttributeDefinition(attributeDefinitionRef); - - switch(attValNodeName.substring(attValNodeName.lastIndexOf("-")+1)) { - - case ReqIFConst.BOOLEAN: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueBoolean(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.INTEGER: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueInteger(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.STRING: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueString(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.ENUMERATION: // Multiselect: read ALL ENUM-VALUE-REF children, not just the first - List enumRefs = new ArrayList(); - List enumNames = new ArrayList(); - for(Element ref : XmlUtils.descendantsByLocalName(attribute, ReqIFConst.ENUM_VALUE_REF)) { - String refID = ref.getTextContent().trim(); - enumRefs.add(refID); - enumNames.add(specType.getEnumValueName(refID)); - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueEnumeration(enumNames, enumRefs, attributeDefinition)); - break; - - case ReqIFConst.XHTML: this.attributeValues.put(attributeDefinitionName, new AttributeValueXHTML(attribute, attributeDefinition)); - break; + AttributeValueReader.read(specification, specType, this.attributeValues); - // DATE and REAL values were formerly ignored on specifications - case ReqIFConst.DATE: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueDate(attributeValue, attributeDefinition)); - break; - - case ReqIFConst.REAL: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, new AttributeValueDouble(attributeValue, attributeDefinition)); - break; - - // Values of datatype kinds the parser does not model are - // kept generically instead of being dropped. - default: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { - attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); - }else{ - attributeValue = ""; - } - this.attributeValues.put(attributeDefinitionName, - new AttributeValue(attributeValue, attributeDefinition) - .setSourceElementName(XmlUtils.localName(attribute))); - break; - } - } - } - } - - if(this.attributeValues.size() < specType.getAttributeDefinitions().size()) { - - for(AttributeDefinition attributeDefinition: specType.getAttributeDefinitions().values()) { - - if(!this.attributeValues.containsKey(attributeDefinition.getName())) { - - if(attributeDefinition.getDataType() == null) { - throw new ExceptionSpecObject("Attribute definition not existing in ReqIF parser\n", attributeDefinition); - } - - switch(attributeDefinition.getDataType().getType()) { - - case ReqIFConst.BOOLEAN: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueBoolean(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.INTEGER: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueInteger(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.STRING: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueString(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.ENUMERATION: if(attributeDefinition instanceof AttributeDefinitionEnumeration) { - AttributeDefinitionEnumeration enumDefinition = (AttributeDefinitionEnumeration) attributeDefinition; - this.attributeValues.put(attributeDefinition.getName(), new AttributeValueEnumeration(enumDefinition.getDefaultValues(), enumDefinition.getDefaultValueRefs(), attributeDefinition)); - }else{ - this.attributeValues.put(attributeDefinition.getName(), new AttributeValueEnumeration(attributeDefinition.getDefaultValue(), attributeDefinition)); - } - break; - - case ReqIFConst.XHTML: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueXHTML(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.DATE: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueDate(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - case ReqIFConst.DOUBLE: this.attributeValues.put(attributeDefinition.getName(), new AttributeValueDouble(attributeDefinition.getDefaultValue(), attributeDefinition)); - break; - - default: break; - } - } - } - } - Element childrenElement = XmlUtils.firstChildElementByLocalName(specification, ReqIFConst.CHILDREN); if(childrenElement != null) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java index ad2bc26..f5dae2c 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java @@ -7,6 +7,8 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; + /** * Helper methods for namespace- and whitespace-independent DOM navigation. * @@ -125,13 +127,23 @@ private static void collectDescendants(Node node, String localName, List}. + * The flat form {@code } is not + * schema-valid but is accepted as well, because earlier versions of this + * library wrote it. + * + * @return the IDENTIFIER of the alternative id, or null if the element * declares none */ public static String alternativeID(Node identifiable) { - Element alternativeID = firstChildElementByLocalName(identifiable, "ALTERNATIVE-ID"); - return alternativeID == null ? null : attribute(alternativeID, "IDENTIFIER"); + Element wrapper = firstChildElementByLocalName(identifiable, ReqIFConst.ALTERNATIVE_ID); + if (wrapper == null) { + return null; + } + Element nested = firstChildElementByLocalName(wrapper, ReqIFConst.ALTERNATIVE_ID); + return attribute(nested != null ? nested : wrapper, ReqIFConst.IDENTIFIER); } /** @@ -145,4 +157,13 @@ public static String attribute(Node node, String attributeName) { Node attribute = node.getAttributes().getNamedItem(attributeName); return attribute == null ? null : attribute.getTextContent(); } + + /** + * @return the text content of the named attribute, or "" if the node has + * no such attribute. + */ + public static String attributeOrEmpty(Node node, String attributeName) { + String value = attribute(node, attributeName); + return value == null ? "" : value; + } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index 8c704e1..f108b7f 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -31,6 +31,7 @@ import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumerationValue; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeInteger; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeString; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; @@ -202,6 +203,13 @@ private Element coreContent(Document xml, ReqIFCoreContent content) { } reqifContent.appendChild(specRelations); + Element specifications = element(xml, ReqIFConst.SPECIFICATIONS); + for (Specification specification : content.getSpecifications().values()) { + specifications.appendChild(specification(xml, specification)); + } + reqifContent.appendChild(specifications); + + // REQ-IF-CONTENT is an xsd:sequence: the relation groups come last if (!content.getRelationGroups().isEmpty()) { Element relationGroups = element(xml, ReqIFConst.SPEC_RELATION_GROUPS); for (RelationGroup relationGroup : content.getRelationGroups().values()) { @@ -210,12 +218,6 @@ private Element coreContent(Document xml, ReqIFCoreContent content) { reqifContent.appendChild(relationGroups); } - Element specifications = element(xml, ReqIFConst.SPECIFICATIONS); - for (Specification specification : content.getSpecifications().values()) { - specifications.appendChild(specification(xml, specification)); - } - reqifContent.appendChild(specifications); - return coreContent; } @@ -233,9 +235,7 @@ private Element datatype(Document xml, Datatype datatype) { } Element definition = element(xml, elementName); - definition.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(datatype.getID())); - definition.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(datatype.getName())); - appendAlternativeID(xml, definition, datatype.getAlternativeID()); + identifiable(xml, definition, datatype); if (datatype instanceof DatatypeInteger) { DatatypeInteger integer = (DatatypeInteger) datatype; @@ -260,8 +260,7 @@ private Element datatype(Document xml, Datatype datatype) { private Element enumValue(Document xml, DatatypeEnumerationValue value) { Element enumValue = element(xml, ReqIFConst.ENUM_VALUE); - enumValue.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(value.getID())); - enumValue.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(value.getName())); + identifiable(xml, enumValue, value); Element embedded = element(xml, ReqIFConst.EMBEDDED_VALUE); embedded.setAttribute(ReqIFConst.KEY, nullToEmpty(value.getKey())); @@ -291,9 +290,7 @@ private Element specType(Document xml, SpecType specType) { } Element type = element(xml, elementName); - type.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specType.getID())); - type.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(specType.getName())); - appendAlternativeID(xml, type, specType.getAlternativeID()); + identifiable(xml, type, specType); Element specAttributes = element(xml, ReqIFConst.SPEC_ATTRIBUTES); for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) { @@ -323,9 +320,7 @@ private Element attributeDefinition(Document xml, AttributeDefinition definition } Element attributeDefinition = element(xml, elementName); - attributeDefinition.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(definition.getID())); - attributeDefinition.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(definition.getName())); - appendAlternativeID(xml, attributeDefinition, definition.getAlternativeID()); + identifiable(xml, attributeDefinition, definition); if (definition instanceof AttributeDefinitionEnumeration && ((AttributeDefinitionEnumeration) definition).isMultiValued()) { @@ -383,8 +378,7 @@ private Element defaultValue(Document xml, AttributeDefinition definition, Datat private Element specObject(Document xml, SpecObject specObject) { Element element = element(xml, ReqIFConst.SPEC_OBJECT); - element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specObject.getID())); - appendAlternativeID(xml, element, specObject.getAlternativeID()); + identifiable(xml, element, specObject); element.appendChild(values(xml, specObject.getAttributes())); element.appendChild(typeRef(xml, ReqIFConst.SPEC_OBJECT_TYPE, specObject.getSpecTypeID())); return element; @@ -393,8 +387,7 @@ private Element specObject(Document xml, SpecObject specObject) { private Element specRelation(Document xml, SpecRelation specRelation) { Element element = element(xml, ReqIFConst.SPEC_RELATION); - element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specRelation.getID())); - appendAlternativeID(xml, element, specRelation.getAlternativeID()); + identifiable(xml, element, specRelation); element.appendChild(values(xml, specRelation.getAttributes())); element.appendChild(typeRef(xml, ReqIFConst.SPEC_RELATION_TYPE, specRelation.getRelationTypeRef())); element.appendChild(objectRef(xml, ReqIFConst.SOURCE, specRelation.getSourceObjID())); @@ -405,9 +398,7 @@ private Element specRelation(Document xml, SpecRelation specRelation) { private Element specification(Document xml, Specification specification) { Element element = element(xml, ReqIFConst.SPECIFICATION); - element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specification.getID())); - element.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(specification.getName())); - appendAlternativeID(xml, element, specification.getAlternativeID()); + identifiable(xml, element, specification); element.appendChild(values(xml, specification.getAttributes())); element.appendChild(typeRef(xml, ReqIFConst.SPECIFICATION_TYPE, specification.getSpecTypeID())); @@ -430,8 +421,7 @@ private Element children(Document xml, List hierarchies) { private Element specHierarchy(Document xml, SpecHierarchy hierarchy) { Element element = element(xml, ReqIFConst.SPEC_HIERARCHY); - element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(hierarchy.getSpecHierarchyID())); - appendAlternativeID(xml, element, hierarchy.getAlternativeID()); + identifiable(xml, element, hierarchy); if (hierarchy.getSpecObject() != null) { element.appendChild(objectRef(xml, ReqIFConst.OBJECT, hierarchy.getSpecObjectID())); @@ -523,14 +513,18 @@ private Element enumerationValue(Document xml, AttributeDefinition definition, L private Element xhtmlValue(Document xml, AttributeDefinition definition, AttributeValue attributeValue) { + // THE-VALUE must hold an XHTML div or p; an attribute without content + // is left out instead of being written as an invalid empty value + Node content = xhtmlContent(xml, attributeValue); + if (content == null) { + return null; + } + Element element = element(xml, ReqIFElements.attributeValue(ReqIFConst.XHTML)); element.appendChild(definitionRef(xml, definition)); Element theValue = element(xml, ReqIFConst.THE_VALUE); - Node content = xhtmlContent(xml, attributeValue); - if (content != null) { - theValue.appendChild(content); - } + theValue.appendChild(content); element.appendChild(theValue); return element; } @@ -589,9 +583,7 @@ private Element objectRef(Document xml, String wrapper, String specObjectID) { private Element relationGroup(Document xml, RelationGroup relationGroup) { Element element = element(xml, ReqIFConst.RELATION_GROUP); - element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(relationGroup.getID())); - element.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(relationGroup.getName())); - appendAlternativeID(xml, element, relationGroup.getAlternativeID()); + identifiable(xml, element, relationGroup); element.appendChild(wrappedRef(xml, ReqIFConst.TYPE, ReqIFConst.RELATION_GROUP_TYPE_REF, relationGroup.getRelationGroupTypeRef())); @@ -622,16 +614,35 @@ private Element wrappedRef(Document xml, String wrapper, String refName, String } /** - * ALTERNATIVE-ID is the first child of an identifiable element. + * Writes IDENTIFIER, LONG-NAME, DESC, LAST-CHANGE and ALTERNATIVE-ID. Optional + * properties the element does not have are left out rather than written + * empty, so a document round-trips unchanged. + * + * The ALTERNATIVE-ID is wrapped twice, as the schema prescribes: + * {@code }. + * It must be the first child, so call this right after creating the element. */ - private void appendAlternativeID(Document xml, Element parent, String alternativeID) { + private void identifiable(Document xml, Element element, Identifiable identifiable) { - if (alternativeID == null || alternativeID.isEmpty()) { - return; + element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(identifiable.getID())); + setAttributeIfPresent(element, ReqIFConst.LONG_NAME, identifiable.getName()); + setAttributeIfPresent(element, ReqIFConst.DESC, identifiable.getDesc()); + setAttributeIfPresent(element, ReqIFConst.LAST_CHANGE, identifiable.getLastChange()); + + String alternativeID = identifiable.getAlternativeID(); + if (alternativeID != null && !alternativeID.isEmpty()) { + Element wrapper = element(xml, ReqIFConst.ALTERNATIVE_ID); + Element alternative = element(xml, ReqIFConst.ALTERNATIVE_ID); + alternative.setAttribute(ReqIFConst.IDENTIFIER, alternativeID); + wrapper.appendChild(alternative); + element.appendChild(wrapper); + } + } + + private static void setAttributeIfPresent(Element element, String name, String value) { + if (value != null) { + element.setAttribute(name, value); } - Element element = element(xml, ReqIFConst.ALTERNATIVE_ID); - element.setAttribute(ReqIFConst.IDENTIFIER, alternativeID); - parent.appendChild(element); } private Element element(Document xml, String name) { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java new file mode 100644 index 0000000..4c81f33 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java @@ -0,0 +1,228 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; +import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFParseException; +import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; +import de.uni_stuttgart.ils.reqif4j.specification.SpecType; +import de.uni_stuttgart.ils.reqif4j.specification.Specification; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * IDENTIFIER, LONG-NAME, DESC, LAST-CHANGE and ALTERNATIVE-ID are read and + * written for every identifiable ReqIF element, so they survive a round trip. + */ +class IdentifiableTest { + + @Test + void identifiablePropertiesAreReadForEveryKindOfElement(@TempDir Path tempDir) throws Exception { + + ReqIFCoreContent content = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF); + + Datatype string = content.getDatatype("dt-string"); + assertEquals("String", string.getName()); + assertEquals("Plain text", string.getDesc()); + assertEquals("2026-07-01T08:00:00Z", string.getLastChange()); + assertEquals("alt-dt-string", string.getAlternativeID(), "nested ALTERNATIVE-ID form"); + + Identifiable draft = ((DatatypeEnumeration) content.getDatatype("dt-enum")).getEnumValues().get("ev-draft"); + assertEquals("Not reviewed yet", draft.getDesc()); + assertEquals("2026-07-01T08:00:00Z", draft.getLastChange()); + + SpecType requirement = content.getSpecType("st-req"); + assertEquals("A single requirement", requirement.getDesc()); + assertEquals("2026-07-02T08:00:00Z", requirement.getAttributeDefinition("ad-id").getLastChange()); + + Identifiable boot = content.getSpecObject("so-1"); + assertEquals("Boot", boot.getName(), "spec objects have a LONG-NAME too"); + assertEquals("First requirement", boot.getDesc()); + assertEquals("2026-07-03T09:15:00Z", boot.getLastChange()); + assertEquals("REQ-1", boot.getAlternativeID()); + + Identifiable withoutName = content.getSpecObject("so-2"); + assertNull(withoutName.getName()); + assertNull(withoutName.getDesc()); + + assertEquals("so-2 refines so-1", content.getSpecRelation("sr-1").getName()); + assertEquals("Trace link", content.getSpecRelation("sr-1").getDesc()); + + Specification specification = content.getSpecification("spec-1"); + assertEquals("Top level", specification.getDesc()); + SpecHierarchy chapter = specification.getChildren().get(0); + assertEquals("1 Boot", chapter.getName()); + assertEquals("Chapter", chapter.getDesc()); + assertEquals("2026-07-05T11:00:00Z", chapter.getLastChange()); + + assertEquals("All refinements", content.getRelationGroup("rg-1").getDesc()); + } + + @Test + void roundTripPreservesTheIdentifiablePropertiesOfAllElements(@TempDir Path tempDir) throws Exception { + + ReqIF original = new ReqIF(TestFixtures.write(tempDir, "in.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + String written = new ReqIFWriter().toXml(original.getReqIFDocument()); + ReqIFCoreContent roundTripped = parse(tempDir, written); + + Map> before = identifiables(original.getReqIFCoreContent()); + assertEquals(25, before.size(), "every identifiable of the fixture is collected"); + assertEquals(before, identifiables(roundTripped)); + } + + @Test + void alternativeIDIsWrittenInTheNestedSchemaForm(@TempDir Path tempDir) throws Exception { + + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + + assertTrue(xml.contains(""), xml); + } + + @Test + void missingOptionalPropertiesAreNotInvented(@TempDir Path tempDir) throws Exception { + + // LONG-NAME is optional in ReqIF; reading it used to throw a + // NullPointerException for datatypes, spec types, attribute definitions + // and specifications + String withoutLongNames = TestFixtures.SCHEMA_VALID_REQIF + .replace(" LONG-NAME=\"Bool\"", "") + .replace(" LONG-NAME=\"Requirement\"", "") + .replace(" LONG-NAME=\"Safety relevant\"", "") + .replace(" LONG-NAME=\"System Spec\"", ""); + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", withoutLongNames).toString()); + ReqIFCoreContent content = reqif.getReqIFCoreContent(); + + assertNull(content.getDatatype("dt-bool").getName()); + assertNull(content.getSpecType("st-req").getName()); + assertNull(content.getSpecType("st-req").getAttributeDefinition("ad-safety").getName()); + assertNull(content.getSpecification("spec-1").getName()); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + assertTrue(xml.contains(""), xml); + assertFalse(xml.contains("LONG-NAME=\"\""), "an absent LONG-NAME must not be written empty"); + assertFalse(xml.contains("DESC=\"\""), "an absent DESC must not be written empty"); + } + + @Test + void valuesOfUnnamedAttributeDefinitionsDoNotOverwriteEachOther(@TempDir Path tempDir) throws Exception { + + // values are kept by attribute LONG-NAME; without one, the definition's + // IDENTIFIER is the key, so the two values stay apart + String unnamed = TestFixtures.SCHEMA_VALID_REQIF + .replace(" LONG-NAME=\"ReqIF.ForeignID\"", "") + .replace(" LONG-NAME=\"Safety relevant\"", ""); + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", unnamed).toString()); + + assertEquals("REQ-1", reqif.getReqIFCoreContent().getSpecObject("so-1").getAttribute("ad-id")); + assertEquals(true, reqif.getReqIFCoreContent().getSpecObject("so-1").getAttribute("ad-safety")); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + assertTrue(xml.contains(""), xml); + assertTrue(xml.contains(""), xml); + } + + @Test + void elementWithoutIdentifierIsRejectedWithAClearMessage(@TempDir Path tempDir) throws Exception { + + String withoutIdentifier = TestFixtures.SCHEMA_VALID_REQIF + .replace(" new ReqIF(file.toString())); + + assertEquals("SPECIFICATION-TYPE without IDENTIFIER", failure.getMessage()); + } + + @Test + void generatedElementsCarryDescAndLastChange() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr").title("T")) + .stringDatatype("dt-string", "String", 100) + .specObjectType("st-req", "Requirement", t -> t.stringAttribute("ad-name", "Name", "dt-string")) + .specObject("so-1", "st-req", o -> o.set("ad-name", "x")) + .build(); + document.getCoreContent().getSpecObject("so-1") + .setDesc("generated") + .setLastChange("2026-10-11T12:00:00Z") + .setAlternativeID("ALT-1"); + + String xml = new ReqIFWriter().toXml(document); + + assertTrue(xml.contains(""), xml); + assertTrue(xml.contains(""), xml); + } + + @Test + void writerFollowsTheSchemaOrderAndSkipsEmptyXhtml(@TempDir Path tempDir) throws Exception { + + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + + // REQ-IF-CONTENT is an xsd:sequence ending with the relation groups + assertTrue(xml.indexOf("") < xml.indexOf(""), xml); + // so-2 has no ReqIF.Text; an empty THE-VALUE would be schema-invalid + assertFalse(xml.contains(""), xml); + } + + private static ReqIFCoreContent parse(Path dir, String content) throws Exception { + return new ReqIF(TestFixtures.write(dir, "doc-" + System.nanoTime() + ".reqif", content).toString()) + .getReqIFCoreContent(); + } + + /** + * @return every identifiable element of the content, by kind and id, with + * its LONG-NAME, DESC, LAST-CHANGE and ALTERNATIVE-ID + */ + private static Map> identifiables(ReqIFCoreContent content) { + + Map> all = new TreeMap>(); + for (Datatype datatype : content.getDatatypes().values()) { + put(all, "datatype", datatype); + if (datatype instanceof DatatypeEnumeration) { + ((DatatypeEnumeration) datatype).getEnumValues().values().forEach(value -> put(all, "enum-value", value)); + } + } + for (SpecType specType : content.getSpecTypes().values()) { + put(all, "spec-type", specType); + for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) { + put(all, "attribute-definition", definition); + } + } + content.getSpecObjects().values().forEach(specObject -> put(all, "spec-object", specObject)); + content.getSpecRelation().values().forEach(relation -> put(all, "spec-relation", relation)); + for (Specification specification : content.getSpecifications().values()) { + put(all, "specification", specification); + specification.getAllSpecHierarchies().forEach(hierarchy -> put(all, "spec-hierarchy", hierarchy)); + } + content.getRelationGroups().values().forEach(group -> put(all, "relation-group", group)); + return all; + } + + private static void put(Map> all, String kind, Identifiable identifiable) { + all.put(kind + ":" + identifiable.getID(), Arrays.asList(identifiable.getName(), identifiable.getDesc(), + identifiable.getLastChange(), identifiable.getAlternativeID())); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java index b3c00ea..e031f52 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java @@ -7,7 +7,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -16,6 +15,7 @@ import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; import de.uni_stuttgart.ils.reqif4j.validate.ValidationIssue; import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; /** * Validation against the official OMG ReqIF schema, whose imports (xml.xsd, @@ -51,21 +51,17 @@ void violationsOfAFileAreReportedWithLineNumbers(@TempDir Path tempDir) throws E } @Test - void parsedDocumentIsValidatedWithoutGoingOnline(@TempDir Path tempDir) throws Exception { + void roundTripOfASchemaValidDocumentStaysSchemaValid(@TempDir Path tempDir) throws Exception { - // The writer does not produce schema-valid output yet (LAST-CHANGE is not - // written); what matters here is that the schema with its http imports - // loads at all, which it did not before. - ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "doc.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); - ValidationResult result = new ReqIFValidator() - .validateAgainstSchema(reqif.getReqIFDocument(), OfficialReqIFSchema.reqifXsd()); + // the XML the writer produces for the model ... + OfficialReqIFSchema.assertSchemaValid(reqif.getReqIFDocument()); - List loadFailures = result.getIssues().stream() - .map(ValidationIssue::getMessage) - .filter(message -> message.startsWith("Schema could not be loaded")) - .collect(Collectors.toList()); - assertTrue(loadFailures.isEmpty(), loadFailures.toString()); + // ... and the file written to disk + Path written = tempDir.resolve("out.reqif"); + new ReqIFWriter().write(reqif.getReqIFDocument(), written); + OfficialReqIFSchema.assertSchemaValid(written); } @Test From 65f1eb77f9e0c08ec6c1d7945b79a055155915a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:01:42 +0000 Subject: [PATCH 04/20] Report each schema finding once and keep util free of model imports After a fatal error the parser throws a new exception for the same problem, so telling reported findings apart by object identity listed every fatal error twice. Findings are now told apart by position and message. XmlUtils goes back to string literals for the ALTERNATIVE-ID lookup, so the util package does not depend on the model packages that depend on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- .../ils/reqif4j/util/XmlUtils.java | 10 +++--- .../ils/reqif4j/validate/ReqIFValidator.java | 32 ++++++++++++------- .../ils/reqif4j/SchemaConformanceTest.java | 13 ++++++++ 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java index f5dae2c..1158b6a 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java @@ -7,8 +7,6 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; -import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; - /** * Helper methods for namespace- and whitespace-independent DOM navigation. * @@ -138,12 +136,14 @@ private static void collectDescendants(Node node, String localName, List reported = Collections.newSetFromMap(new IdentityHashMap()); + private final Set reported = new HashSet(); IssueCollector(ValidationResult result) { this.result = result; @@ -420,31 +423,38 @@ private static final class IssueCollector implements ErrorHandler { @Override public void warning(SAXParseException exception) { - this.reported.add(exception); - this.result.warning(null, message(exception)); + if (this.reported.add(fingerprint(exception))) { + this.result.warning(null, message(exception)); + } } @Override public void error(SAXParseException exception) { - this.reported.add(exception); - this.result.error(null, message(exception)); + reportUnlessReported(exception); } @Override public void fatalError(SAXParseException exception) { - this.reported.add(exception); - this.result.error(null, message(exception)); + reportUnlessReported(exception); } void reportUnlessReported(SAXException exception) { - if (!this.reported.contains(exception)) { - this.reported.add(exception); + if (this.reported.add(fingerprint(exception))) { this.result.error(null, exception instanceof SAXParseException ? message((SAXParseException) exception) : exception.getMessage()); } } + private static String fingerprint(SAXException exception) { + if (exception instanceof SAXParseException) { + SAXParseException parseException = (SAXParseException) exception; + return parseException.getSystemId() + ':' + parseException.getLineNumber() + ':' + + parseException.getColumnNumber() + ':' + parseException.getMessage(); + } + return String.valueOf(exception.getMessage()); + } + private static String message(SAXParseException exception) { // a DOM source has no line numbers return exception.getLineNumber() < 0 diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java index e031f52..da136be 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java @@ -50,6 +50,19 @@ void violationsOfAFileAreReportedWithLineNumbers(@TempDir Path tempDir) throws E assertTrue(errors.get(0).getMessage().startsWith("line " + line + ":"), result.toString()); } + @Test + void malformedFileIsReportedOnce(@TempDir Path tempDir) throws Exception { + + Path file = TestFixtures.write(tempDir, "truncated.reqif", + ""); + + ValidationResult result = new ReqIFValidator() + .validateAgainstSchema(file, OfficialReqIFSchema.reqifXsd()); + + // the parser reports a fatal error and then throws it again + assertEquals(1, result.getErrors().size(), result.toString()); + } + @Test void roundTripOfASchemaValidDocumentStaysSchemaValid(@TempDir Path tempDir) throws Exception { From 537d05b3f6252b0500c5bcb3915eebb8be353e45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:17:04 +0000 Subject: [PATCH 05/20] Write every value the ReqIF schema requires The writer fills in what the schema requires but a model may lack, typically one built with ReqIFBuilder. The model itself is not changed. - LAST-CHANGE of every element: the header CREATION-TIME, or the time of writing (injectable clock) if the header has none. - Header CREATION-TIME, REQ-IF-TOOL-ID, REQ-IF-VERSION, SOURCE-TOOL-ID and TITLE are always written; empty ones get defaults. - MULTI-VALUED is written for every enumeration attribute definition, and OTHER-CONTENT for every enum value, also when false or empty. - REAL datatypes keep ACCURACY, MIN and MAX. Missing values default to 15 digits and the full double range. Infinity is written and read in xsd notation (INF, -INF) for bounds and values alike. The builder gives the header a free default identifier and refuses inconsistent REAL bounds. A missing header is now a validation error, because the schema requires it and no identifier can be invented for it. The builder example from the README and a round trip of a document with REAL bounds, MULTI-VALUED="false" and an empty OTHER-CONTENT are now schema-valid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- .../attributes/AttributeValueDouble.java | 5 +- .../ils/reqif4j/build/ReqIFBuilder.java | 57 ++++- .../ils/reqif4j/datatypes/DatatypeDouble.java | 87 ++++++- .../ils/reqif4j/reqif/ReqIFConst.java | 1 + .../ils/reqif4j/reqif/ReqIFCoreContent.java | 5 +- .../ils/reqif4j/util/XsdDouble.java | 40 ++++ .../ils/reqif4j/validate/ReqIFValidator.java | 8 +- .../ils/reqif4j/write/ReqIFWriter.java | 107 +++++++-- .../ils/reqif4j/IdentifiableTest.java | 2 +- .../ils/reqif4j/ReqIFValidatorTest.java | 8 +- .../ils/reqif4j/RequiredValuesTest.java | 218 ++++++++++++++++++ .../ils/reqif4j/SchemaConformanceTest.java | 39 ++++ .../ils/reqif4j/TestFixtures.java | 20 +- 13 files changed, 563 insertions(+), 34 deletions(-) create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java index 12178d5..e1495e3 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java @@ -1,5 +1,7 @@ package de.uni_stuttgart.ils.reqif4j.attributes; +import de.uni_stuttgart.ils.reqif4j.util.XsdDouble; + public class AttributeValueDouble extends AttributeValue{ public AttributeValueDouble(String value, AttributeDefinition type) { @@ -9,7 +11,8 @@ public AttributeValueDouble(String value, AttributeDefinition type) { if(value == null || value.isBlank()){ this.value = 0.0; }else { - this.value = Double.parseDouble(value.trim()); + // xsd:double notation: INF / -INF for infinity + this.value = XsdDouble.parse(value); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java index e820184..2a6c06c 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java @@ -2,9 +2,11 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Consumer; import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; @@ -59,6 +61,9 @@ */ public class ReqIFBuilder { + /** IDENTIFIER of the header if {@link HeaderBuilder#id(String)} is not set. */ + public static final String DEFAULT_HEADER_ID = "reqif4j-header"; + private final Map datatypes = new LinkedHashMap(); private final Map specTypes = new LinkedHashMap(); private final Map specObjects = new LinkedHashMap(); @@ -114,11 +119,31 @@ public ReqIFBuilder dateDatatype(String id, String name) { return datatype(new DatatypeDate(id, name)); } - /** Written as DATATYPE-DEFINITION-REAL. */ + /** + * Written as DATATYPE-DEFINITION-REAL, with the default accuracy and the + * full double range ({@link DatatypeDouble#DEFAULT_ACCURACY}). + */ public ReqIFBuilder realDatatype(String id, String name) { return datatype(new DatatypeDouble(id, name)); } + /** + * Written as DATATYPE-DEFINITION-REAL. + * + * @param accuracy the maximum precision of the values, not negative + * @param min lower bound, may be {@link Double#NEGATIVE_INFINITY} + * @param max upper bound, not below {@code min} + */ + public ReqIFBuilder realDatatype(String id, String name, int accuracy, double min, double max) { + if (accuracy < 0) { + throw new ReqIFBuildException("Accuracy of real datatype '" + id + "' must not be negative: " + accuracy); + } + if (Double.isNaN(min) || Double.isNaN(max) || min > max) { + throw new ReqIFBuildException("Real datatype '" + id + "' needs min <= max, got " + min + " and " + max); + } + return datatype(new DatatypeDouble(id, name, accuracy, min, max)); + } + public ReqIFBuilder xhtmlDatatype(String id, String name) { return datatype(new DatatypeXHTML(id, name)); } @@ -232,7 +257,29 @@ public ReqIFDocument build() { content.addSpecification(specification); } - return new ReqIFDocument(this.headerBuilder.build(), content); + return new ReqIFDocument(this.headerBuilder.build(defaultHeaderID()), content); + } + + /** + * The schema requires a header IDENTIFIER that is unique in the document; + * if none was set, the first free one of reqif4j-header, reqif4j-header-2, ... + */ + private String defaultHeaderID() { + + Set used = new HashSet(this.identifiers.keySet()); + for (SpecType specType : this.specTypes.values()) { + used.addAll(specType.getAttributeDefinitions().keySet()); + } + for (Datatype datatype : this.datatypes.values()) { + if (datatype instanceof DatatypeEnumeration) { + used.addAll(((DatatypeEnumeration) datatype).getEnumValues().keySet()); + } + } + String id = DEFAULT_HEADER_ID; + for (int suffix = 2; used.contains(id); suffix++) { + id = DEFAULT_HEADER_ID + "-" + suffix; + } + return id; } @@ -418,9 +465,9 @@ public HeaderBuilder reqifVersion(String reqifVersion) { return this; } - private ReqIFHeader build() { - return new ReqIFHeader(this.id, this.title, this.comment, this.creationTime, - this.toolID, this.sourceToolID, this.reqifVersion); + private ReqIFHeader build(String defaultID) { + return new ReqIFHeader(this.id == null || this.id.isBlank() ? defaultID : this.id, this.title, + this.comment, this.creationTime, this.toolID, this.sourceToolID, this.reqifVersion); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java index 06af942..5f6bb4f 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java @@ -1,9 +1,90 @@ package de.uni_stuttgart.ils.reqif4j.datatypes; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.util.XsdDouble; +/** + * A DATATYPE-DEFINITION-REAL. The parser normalizes the ReqIF kind REAL to the + * category {@link ReqIFConst#DOUBLE}; it is written as REAL again. + * + * ACCURACY, MIN and MAX are required by the ReqIF schema. A document that + * omits them, or a datatype created without them, gets the defaults below, + * so it is always written schema-valid. + */ public class DatatypeDouble extends Datatype { - public DatatypeDouble(String id, String name) { - super(id, name, ReqIFConst.DOUBLE); - } + + /** Significant decimal digits a double can hold. */ + public static final int DEFAULT_ACCURACY = 15; + public static final double DEFAULT_MIN = -Double.MAX_VALUE; + public static final double DEFAULT_MAX = Double.MAX_VALUE; + + private final int accuracy; + private final double min; + private final double max; + + + /** + * Creates a datatype with the default accuracy and the full double range. + */ + public DatatypeDouble(String id, String name) { + this(id, name, DEFAULT_ACCURACY, DEFAULT_MIN, DEFAULT_MAX); + } + + public DatatypeDouble(String id, String name, int accuracy, double min, double max) { + super(id, name, ReqIFConst.DOUBLE); + + this.accuracy = accuracy; + this.min = min; + this.max = max; + } + + /** + * Reads the attributes as written in a document; missing or unparseable + * values fall back to the defaults. + */ + public DatatypeDouble(String id, String name, String accuracy, String min, String max) { + this(id, name, parseAccuracy(accuracy), parseBound(min, DEFAULT_MIN), parseBound(max, DEFAULT_MAX)); + } + + + /** + * @return the ACCURACY, the maximum precision of the values + */ + public int getAccuracy() { + return this.accuracy; + } + + public double getMin() { + return this.min; + } + + public double getMax() { + return this.max; + } + + + private static int parseAccuracy(String value) { + if (value == null || value.isBlank()) { + return DEFAULT_ACCURACY; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return DEFAULT_ACCURACY; + } + } + + /** + * Accepts the xsd:double spellings of infinity (INF, -INF) as well. + */ + private static double parseBound(String value, double fallback) { + if (value == null || value.isBlank()) { + return fallback; + } + try { + return XsdDouble.parse(value); + } catch (NumberFormatException e) { + return fallback; + } + } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java index d1638cc..8e9738a 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java @@ -12,6 +12,7 @@ public class ReqIFConst { public final static String MIN = "MIN"; public final static String MAX = "MAX"; public final static String MAX_LENGTH = "MAX-LENGTH"; + public final static String ACCURACY = "ACCURACY"; public final static String _TEXT = "#text"; public final static String ENUM_VALUE = "ENUM-VALUE"; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java index cfd5e4d..4a920e8 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java @@ -186,7 +186,10 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { break; case ReqIFConst.DOUBLE, ReqIFConst.REAL: - this.dataTypes.put(dataTypeID, new DatatypeDouble(dataTypeID, dataTypeName)); + this.dataTypes.put(dataTypeID, new DatatypeDouble(dataTypeID, dataTypeName, + XmlUtils.attribute(dataType, ReqIFConst.ACCURACY), + XmlUtils.attribute(dataType, ReqIFConst.MIN), + XmlUtils.attribute(dataType, ReqIFConst.MAX))); break; default: diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java new file mode 100644 index 0000000..5cae9df --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java @@ -0,0 +1,40 @@ +package de.uni_stuttgart.ils.reqif4j.util; + +/** + * Conversion between Java doubles and the xsd:double notation ReqIF uses for + * REAL bounds and values. The two differ only for infinity: xsd:double spells + * it {@code INF} / {@code -INF}, Java {@code Infinity} / {@code -Infinity}. + * Both spellings are read; xsd notation is written. + */ +public final class XsdDouble { + + private XsdDouble() { + } + + /** + * @return the parsed value + * @throws NumberFormatException if the text is neither xsd nor Java notation + */ + public static double parse(String text) { + String trimmed = text.trim(); + switch (trimmed) { + case "INF": + case "+INF": return Double.POSITIVE_INFINITY; + case "-INF": return Double.NEGATIVE_INFINITY; + default: return Double.parseDouble(trimmed); + } + } + + /** + * @return the value in xsd:double notation + */ + public static String format(double value) { + if (value == Double.POSITIVE_INFINITY) { + return "INF"; + } + if (value == Double.NEGATIVE_INFINITY) { + return "-INF"; + } + return Double.toString(value); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java index 717a539..9ac7a38 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java @@ -74,7 +74,8 @@ public ValidationResult validate(ReqIFDocument document) { return result; } if (document.getHeader() == null) { - result.warning(null, "Document has no THE-HEADER"); + // the schema requires THE-HEADER, and the writer cannot invent its IDENTIFIER + result.error(null, "Document has no THE-HEADER"); } else if (isBlank(document.getHeader().getID())) { result.error(null, "REQ-IF-HEADER has no IDENTIFIER"); } @@ -317,6 +318,11 @@ private void checkIdentifier(String id, String kind, ValidationResult result, Ma * Schema. The OMG ReqIF XSD is not bundled with this library for licensing * reasons; download it from the OMG and pass its path here. * + * What is checked is the output, not the model: required values the writer + * fills in (LAST-CHANGE, header fields, see + * {@link ReqIFWriter#buildDocument(ReqIFDocument)}) are not reported as + * missing. + * * The official {@code reqif.xsd} imports {@code xml.xsd}, {@code driver.xsd} * and the XHTML modules ({@code xhtml-*-1.xsd}) by http URL. Validation never * goes online: these imports are looked up by file name in the directory of diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index f108b7f..059271e 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -6,6 +6,8 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Clock; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -27,6 +29,7 @@ import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeDouble; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumerationValue; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeInteger; @@ -44,6 +47,7 @@ import de.uni_stuttgart.ils.reqif4j.specification.Specification; import de.uni_stuttgart.ils.reqif4j.util.SecureXml; import de.uni_stuttgart.ils.reqif4j.util.XhtmlParser; +import de.uni_stuttgart.ils.reqif4j.util.XsdDouble; /** * Serializes a parsed {@link ReqIFDocument} back to ReqIF XML. @@ -66,9 +70,17 @@ public class ReqIFWriter { public static final String REQIF_NAMESPACE = "http://www.omg.org/spec/ReqIF/20110401/reqif.xsd"; public static final String XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; + /** REQ-IF-TOOL-ID and SOURCE-TOOL-ID written when the header has none. */ + public static final String DEFAULT_TOOL_ID = "reqif4j"; + /** The only REQ-IF-VERSION the schema allows. */ + public static final String REQIF_VERSION = "1.0"; + private static final String XHTML_PREFIX = "xhtml"; + /** user data key of the output DOM holding the LAST-CHANGE default */ + private static final String DEFAULT_LAST_CHANGE = "reqif4j.defaultLastChange"; private boolean indent = false; + private Clock clock = Clock.systemUTC(); /** * Enables pretty-printing. @@ -84,6 +96,17 @@ public ReqIFWriter setIndent(boolean indent) { } + /** + * Sets the clock that supplies the timestamp for required dates the model + * does not carry; see {@link #buildDocument(ReqIFDocument)}. Mainly useful + * to make generated output reproducible. + */ + public ReqIFWriter setClock(Clock clock) { + this.clock = clock == null ? Clock.systemUTC() : clock; + return this; + } + + /** * Writes the document to the given file. */ @@ -120,6 +143,22 @@ public String toXml(ReqIFDocument document) { /** * Builds the output DOM from the object model. + * + * The schema requires some values a model may lack, typically one that was + * generated rather than parsed. They are filled in on output only; the + * model is not changed: + * + *
    + *
  • LAST-CHANGE of an element: the header's CREATION-TIME, or the time + * of writing if the header has none;
  • + *
  • header CREATION-TIME: the time of writing;
  • + *
  • header REQ-IF-TOOL-ID and SOURCE-TOOL-ID: {@value #DEFAULT_TOOL_ID}; + * REQ-IF-VERSION: {@value #REQIF_VERSION}; TITLE: empty.
  • + *
+ * + * Not filled in, because no default would be meaningful: a missing header + * and missing identifiers. {@link de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator} + * reports both. */ public Document buildDocument(ReqIFDocument document) { @@ -128,12 +167,17 @@ public Document buildDocument(ReqIFDocument document) { } Document xml = newDocument(); + // one timestamp for the whole document, so all defaults agree + String now = this.clock.instant().truncatedTo(ChronoUnit.SECONDS).toString(); + String creationTime = document.getHeader() == null ? "" : document.getHeader().getCreationTime(); + xml.setUserData(DEFAULT_LAST_CHANGE, isBlank(creationTime) ? now : creationTime, null); + Element root = xml.createElementNS(REQIF_NAMESPACE, "REQ-IF"); root.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:" + XHTML_PREFIX, XHTML_NAMESPACE); xml.appendChild(root); if (document.getHeader() != null) { - root.appendChild(header(xml, document.getHeader())); + root.appendChild(header(xml, document.getHeader(), now)); } root.appendChild(coreContent(xml, document.getCoreContent())); @@ -142,11 +186,17 @@ public Document buildDocument(ReqIFDocument document) { root.appendChild(xml.importNode(extension, true)); } + // only needed while building; not part of the returned document + xml.setUserData(DEFAULT_LAST_CHANGE, null, null); return xml; } - private Element header(Document xml, ReqIFHeader header) { + /** + * REQ-IF-HEADER is an xsd:sequence; only COMMENT and REPOSITORY-ID are + * optional, the other elements get a default if the header lacks them. + */ + private Element header(Document xml, ReqIFHeader header, String now) { Element theHeader = element(xml, ReqIFConst.THE_HEADER); Element reqifHeader = element(xml, ReqIFConst.REQ_IF_HEADER); @@ -154,11 +204,11 @@ private Element header(Document xml, ReqIFHeader header) { theHeader.appendChild(reqifHeader); appendTextIfPresent(xml, reqifHeader, ReqIFConst.COMMENT, header.getComment()); - appendTextIfPresent(xml, reqifHeader, ReqIFConst.CREATION_TIME, header.getCreationTime()); - appendTextIfPresent(xml, reqifHeader, ReqIFConst.REQ_IF_TOOL_ID, header.getToolID()); - appendTextIfPresent(xml, reqifHeader, ReqIFConst.REQ_IF_VERSION, header.getReqIFVersion()); - appendTextIfPresent(xml, reqifHeader, ReqIFConst.SOURCE_TOOL_ID, header.getSourceToolID()); - appendTextIfPresent(xml, reqifHeader, ReqIFConst.TITLE, header.getTitle()); + appendText(xml, reqifHeader, ReqIFConst.CREATION_TIME, orDefault(header.getCreationTime(), now)); + appendText(xml, reqifHeader, ReqIFConst.REQ_IF_TOOL_ID, orDefault(header.getToolID(), DEFAULT_TOOL_ID)); + appendText(xml, reqifHeader, ReqIFConst.REQ_IF_VERSION, orDefault(header.getReqIFVersion(), REQIF_VERSION)); + appendText(xml, reqifHeader, ReqIFConst.SOURCE_TOOL_ID, orDefault(header.getSourceToolID(), DEFAULT_TOOL_ID)); + appendText(xml, reqifHeader, ReqIFConst.TITLE, nullToEmpty(header.getTitle())); return theHeader; } @@ -242,6 +292,12 @@ private Element datatype(Document xml, Datatype datatype) { definition.setAttribute(ReqIFConst.MIN, Long.toString(integer.getMin())); definition.setAttribute(ReqIFConst.MAX, Long.toString(integer.getMax())); + } else if (datatype instanceof DatatypeDouble) { + DatatypeDouble real = (DatatypeDouble) datatype; + definition.setAttribute(ReqIFConst.ACCURACY, Integer.toString(real.getAccuracy())); + definition.setAttribute(ReqIFConst.MIN, XsdDouble.format(real.getMin())); + definition.setAttribute(ReqIFConst.MAX, XsdDouble.format(real.getMax())); + } else if (datatype instanceof DatatypeString) { definition.setAttribute(ReqIFConst.MAX_LENGTH, Integer.toString(((DatatypeString) datatype).getMaxLength())); @@ -263,10 +319,9 @@ private Element enumValue(Document xml, DatatypeEnumerationValue value) { identifiable(xml, enumValue, value); Element embedded = element(xml, ReqIFConst.EMBEDDED_VALUE); + // both attributes are required, OTHER-CONTENT even when empty embedded.setAttribute(ReqIFConst.KEY, nullToEmpty(value.getKey())); - if (value.getOtherContent() != null && !value.getOtherContent().isEmpty()) { - embedded.setAttribute(ReqIFConst.OTHER_CONTENT, value.getOtherContent()); - } + embedded.setAttribute(ReqIFConst.OTHER_CONTENT, nullToEmpty(value.getOtherContent())); Element properties = element(xml, ReqIFConst.PROPERTIES); properties.appendChild(embedded); @@ -322,9 +377,11 @@ private Element attributeDefinition(Document xml, AttributeDefinition definition Element attributeDefinition = element(xml, elementName); identifiable(xml, attributeDefinition, definition); - if (definition instanceof AttributeDefinitionEnumeration - && ((AttributeDefinitionEnumeration) definition).isMultiValued()) { - attributeDefinition.setAttribute(ReqIFConst.MULTI_VALUED, "true"); + // required on every enumeration attribute definition, "false" included + if (ReqIFConst.ENUMERATION.equals(datatype.getType())) { + boolean multiValued = definition instanceof AttributeDefinitionEnumeration + && ((AttributeDefinitionEnumeration) definition).isMultiValued(); + attributeDefinition.setAttribute(ReqIFConst.MULTI_VALUED, Boolean.toString(multiValued)); } Element type = element(xml, ReqIFConst.TYPE); @@ -491,7 +548,8 @@ private Element attributeValue(Document xml, AttributeValue attributeValue) { } Element element = element(xml, elementName); - element.setAttribute(ReqIFConst.THE_VALUE, String.valueOf(value)); + element.setAttribute(ReqIFConst.THE_VALUE, + value instanceof Double ? XsdDouble.format((Double) value) : String.valueOf(value)); element.appendChild(definitionRef(xml, definition)); return element; } @@ -616,7 +674,8 @@ private Element wrappedRef(Document xml, String wrapper, String refName, String /** * Writes IDENTIFIER, LONG-NAME, DESC, LAST-CHANGE and ALTERNATIVE-ID. Optional * properties the element does not have are left out rather than written - * empty, so a document round-trips unchanged. + * empty, so a document round-trips unchanged; the required LAST-CHANGE gets + * the document's default. * * The ALTERNATIVE-ID is wrapped twice, as the schema prescribes: * {@code }. @@ -627,7 +686,10 @@ private void identifiable(Document xml, Element element, Identifiable identifiab element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(identifiable.getID())); setAttributeIfPresent(element, ReqIFConst.LONG_NAME, identifiable.getName()); setAttributeIfPresent(element, ReqIFConst.DESC, identifiable.getDesc()); - setAttributeIfPresent(element, ReqIFConst.LAST_CHANGE, identifiable.getLastChange()); + // required by the schema; see buildDocument for the default + element.setAttribute(ReqIFConst.LAST_CHANGE, isBlank(identifiable.getLastChange()) + ? (String) xml.getUserData(DEFAULT_LAST_CHANGE) + : identifiable.getLastChange()); String alternativeID = identifiable.getAlternativeID(); if (alternativeID != null && !alternativeID.isEmpty()) { @@ -654,6 +716,11 @@ private void appendTextIfPresent(Document xml, Element parent, String name, Stri if (text == null || text.isEmpty()) { return; } + appendText(xml, parent, name, text); + } + + private void appendText(Document xml, Element parent, String name, String text) { + Element element = element(xml, name); element.setTextContent(text); parent.appendChild(element); @@ -663,6 +730,14 @@ private static String nullToEmpty(String value) { return value == null ? "" : value; } + private static String orDefault(String value, String fallback) { + return isBlank(value) ? fallback : value; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + private Document newDocument() { try { return SecureXml.newDocumentBuilder().newDocument(); diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java index 4c81f33..3823525 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java @@ -85,7 +85,7 @@ void roundTripPreservesTheIdentifiablePropertiesOfAllElements(@TempDir Path temp ReqIFCoreContent roundTripped = parse(tempDir, written); Map> before = identifiables(original.getReqIFCoreContent()); - assertEquals(25, before.size(), "every identifiable of the fixture is collected"); + assertEquals(28, before.size(), "every identifiable of the fixture is collected"); assertEquals(before, identifiables(roundTripped)); } diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java index 5bf04e7..e85f3a9 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java @@ -18,7 +18,6 @@ import de.uni_stuttgart.ils.reqif4j.specification.RelationGroup; import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; -import de.uni_stuttgart.ils.reqif4j.validate.ValidationIssue; import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; /** @@ -146,15 +145,16 @@ void relationGroupWithUnknownReferencesIsReported() { } @Test - void missingHeaderIsOnlyAWarning() { + void missingHeaderIsAnError() { ReqIFDocument document = new ReqIFDocument(null, validBuilder().build().getCoreContent()); ValidationResult result = validate(document); - assertTrue(result.isValid(), "a missing header must not make the document invalid"); + // the schema requires THE-HEADER, and the writer cannot invent its IDENTIFIER + assertFalse(result.isValid()); assertEquals(1, result.getIssues().size()); - assertEquals(ValidationIssue.Severity.WARNING, result.getIssues().get(0).getSeverity()); + assertEquals("Document has no THE-HEADER", result.getIssues().get(0).getMessage()); } @Test diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java new file mode 100644 index 0000000..0928453 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java @@ -0,0 +1,218 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.w3c.dom.Document; + +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuildException; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeDouble; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * The writer emits every value the ReqIF schema requires, also when the model + * does not carry it - typically for documents generated with the builder. + */ +class RequiredValuesTest { + + private static final Clock FIXED = Clock.fixed(Instant.parse("2026-10-11T12:34:56.789Z"), ZoneOffset.UTC); + + @Test + void elementsWithoutLastChangeGetTheHeaderCreationTime() { + + ReqIFDocument document = minimalDocument(h -> h.id("hdr").creationTime("2026-07-23T10:00:00Z")); + + String xml = new ReqIFWriter().setClock(FIXED).toXml(document); + + assertTrue(xml.contains(""), xml); + assertFalse(xml.contains("2026-10-11T12:34:56"), "the clock is not needed when the header has a creation time"); + } + + @Test + void withoutCreationTimeTheTimeOfWritingIsUsedEverywhere() { + + ReqIFDocument document = minimalDocument(h -> h.id("hdr")); + + String xml = new ReqIFWriter().setClock(FIXED).toXml(document); + + // truncated to seconds, one timestamp for header and elements alike + assertTrue(xml.contains("2026-10-11T12:34:56Z"), xml); + assertTrue(xml.contains(""), xml); + } + + @Test + void writingDoesNotChangeTheModel() { + + ReqIFDocument document = minimalDocument(h -> h.id("hdr")); + + new ReqIFWriter().setClock(FIXED).toXml(document); + + assertNull(document.getCoreContent().getSpecObject("so-1").getLastChange()); + assertEquals("", document.getHeader().getCreationTime()); + } + + @Test + void existingLastChangeIsKept(@TempDir Path tempDir) throws Exception { + + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + + String xml = new ReqIFWriter().setClock(FIXED).toXml(reqif.getReqIFDocument()); + + assertTrue(xml.contains("IDENTIFIER=\"so-1\" LAST-CHANGE=\"2026-07-03T09:15:00Z\""), xml); + assertFalse(xml.contains("2026-10-11"), xml); + } + + @Test + void missingHeaderFieldsGetDefaults() { + + ReqIFDocument document = minimalDocument(h -> h.id("hdr").reqifVersion("")); + + String xml = new ReqIFWriter().setClock(FIXED).toXml(document); + + assertTrue(xml.contains("" + + "2026-10-11T12:34:56Z" + + "reqif4j" + + "1.0" + + "reqif4j" + + "" + + "</REQ-IF-HEADER>"), xml); + } + + @Test + void requiredEnumerationAttributesAreAlwaysWritten() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .enumerationDatatype("dt-enum", "Color", e -> e.value("ev-red", "Red", "1")) + .specObjectType("st", "Type", t -> t.enumerationAttribute("ad-color", "Color", "dt-enum", false)) + .build(); + + String xml = new ReqIFWriter().setClock(FIXED).toXml(document); + + assertTrue(xml.contains("<EMBEDDED-VALUE KEY=\"1\" OTHER-CONTENT=\"\"/>"), xml); + assertTrue(xml.contains("IDENTIFIER=\"ad-color\" LAST-CHANGE=\"2026-10-11T12:34:56Z\" LONG-NAME=\"Color\" MULTI-VALUED=\"false\""), xml); + } + + @Test + void realDatatypeRoundTripsAccuracyAndBounds(@TempDir Path tempDir) throws Exception { + + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + DatatypeDouble real = (DatatypeDouble) reqif.getReqIFCoreContent().getDatatype("dt-real"); + assertEquals(3, real.getAccuracy()); + assertEquals(-1.5, real.getMin()); + assertEquals(1000.0, real.getMax()); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + + assertTrue(xml.contains("ACCURACY=\"3\" IDENTIFIER=\"dt-real\" LAST-CHANGE=\"2026-07-01T08:00:00Z\" LONG-NAME=\"Real\" MAX=\"1000.0\" MIN=\"-1.5\""), xml); + assertTrue(xml.contains("<ATTRIBUTE-VALUE-REAL THE-VALUE=\"2.5\">"), xml); + } + + @Test + void realDatatypeWithoutBoundsGetsSchemaValidDefaults() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .realDatatype("dt-real", "Real") + .realDatatype("dt-ratio", "Ratio", 2, 0.0, Double.POSITIVE_INFINITY) + .build(); + + String xml = new ReqIFWriter().setClock(FIXED).toXml(document); + + assertTrue(xml.contains("ACCURACY=\"15\" IDENTIFIER=\"dt-real\""), xml); + assertTrue(xml.contains("MAX=\"1.7976931348623157E308\" MIN=\"-1.7976931348623157E308\""), xml); + // xsd:double spells infinity INF, not Java's "Infinity" + assertTrue(xml.contains("ACCURACY=\"2\" IDENTIFIER=\"dt-ratio\""), xml); + assertTrue(xml.contains("MAX=\"INF\" MIN=\"0.0\""), xml); + } + + @Test + void realBoundsSpelledInXsdNotationAreRead(@TempDir Path tempDir) throws Exception { + + String infinite = TestFixtures.SCHEMA_VALID_REQIF.replace("MIN=\"-1.5\" MAX=\"1000.0\"", "MIN=\"-INF\" MAX=\"INF\""); + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", infinite).toString()); + + DatatypeDouble real = (DatatypeDouble) reqif.getReqIFCoreContent().getDatatype("dt-real"); + + assertEquals(Double.NEGATIVE_INFINITY, real.getMin()); + assertEquals(Double.POSITIVE_INFINITY, real.getMax()); + } + + @Test + void infiniteRealValuesAreWrittenInXsdNotationAndReadBack(@TempDir Path tempDir) throws Exception { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .realDatatype("dt-real", "Real", 2, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY) + .specObjectType("st", "Type", t -> t.realAttribute("ad-effort", "Effort", "dt-real")) + .specObject("so-1", "st", o -> o.set("ad-effort", Double.POSITIVE_INFINITY)) + .build(); + Path file = tempDir.resolve("out.reqif"); + new ReqIFWriter().setClock(FIXED).write(document, file); + + String xml = Files.readString(file); + assertTrue(xml.contains("<ATTRIBUTE-VALUE-REAL THE-VALUE=\"INF\">"), xml); + + // the library reads back what it writes + ReqIF reqif = new ReqIF(file.toString()); + assertEquals(Double.POSITIVE_INFINITY, reqif.getReqIFCoreContent().getSpecObject("so-1").getAttribute("Effort")); + } + + @Test + void headerWithoutIdentifierGetsAFreeDefault() { + + ReqIFDocument withoutHeader = minimalDocument(h -> { }); + ReqIFDocument clashing = ReqIFBuilder.create() + .stringDatatype(ReqIFBuilder.DEFAULT_HEADER_ID, "String", 10) + .build(); + + assertEquals("reqif4j-header", withoutHeader.getHeader().getID()); + assertEquals("reqif4j-header-2", clashing.getHeader().getID()); + } + + @Test + void inconsistentRealDatatypeIsRefused() { + + assertThrows(ReqIFBuildException.class, + () -> ReqIFBuilder.create().realDatatype("dt", "Real", -1, 0.0, 1.0)); + assertThrows(ReqIFBuildException.class, + () -> ReqIFBuilder.create().realDatatype("dt", "Real", 2, 10.0, 0.0)); + assertThrows(ReqIFBuildException.class, + () -> ReqIFBuilder.create().realDatatype("dt", "Real", 2, Double.NaN, 0.0)); + } + + @Test + void outputCarriesNoInternalState() { + + Document xml = new ReqIFWriter().buildDocument(minimalDocument(h -> h.id("hdr"))); + + assertNull(xml.getUserData("reqif4j.defaultLastChange")); + } + + private static ReqIFDocument minimalDocument(Consumer<ReqIFBuilder.HeaderBuilder> header) { + return ReqIFBuilder.create() + .header(header) + .stringDatatype("dt-string", "String", 100) + .specObjectType("st-req", "Requirement", t -> t.stringAttribute("ad-name", "Name", "dt-string")) + .specificationType("st-spec", "Spec", t -> { }) + .specObject("so-1", "st-req", o -> o.set("ad-name", "x")) + .specification("spec-1", "Spec", "st-spec", s -> s.child("sh-1", "so-1")) + .build(); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java index da136be..56ce729 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java @@ -11,7 +11,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; import de.uni_stuttgart.ils.reqif4j.validate.ValidationIssue; import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; @@ -77,6 +79,43 @@ void roundTripOfASchemaValidDocumentStaysSchemaValid(@TempDir Path tempDir) thro OfficialReqIFSchema.assertSchemaValid(written); } + @Test + void documentGeneratedWithTheBuilderIsSchemaValid() throws Exception { + + // the README example, plus the kinds whose required attributes are easy + // to drop: REAL bounds, a single-valued enumeration, empty OTHER-CONTENT + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr-1").title("My Spec").toolID("reqif4j") + .creationTime("2026-07-23T10:00:00Z")) + .stringDatatype("dt-string", "String", 4096) + .xhtmlDatatype("dt-xhtml", "XHTML") + .realDatatype("dt-real", "Real") + .enumerationDatatype("dt-enum", "Color", e -> e + .value("ev-red", "Red", "1", "#ff0000") + .value("ev-blue", "Blue", "2")) + .specObjectType("st-req", "Requirement Type", t -> t + .stringAttribute("ad-title", "ReqIF.Name", "dt-string") + .xhtmlAttribute("ad-text", "ReqIF.Text", "dt-xhtml") + .realAttribute("ad-effort", "Effort", "dt-real") + .enumerationAttribute("ad-color", "Colors", "dt-enum", true, List.of("ev-blue")) + .enumerationAttribute("ad-main", "Main color", "dt-enum", false)) + .specificationType("st-spec", "Specification Type", t -> { }) + .specRelationType("st-rel", "satisfies", t -> { }) + .specObject("so-1", "st-req", o -> o + .set("ad-title", "First requirement") + .set("ad-effort", 2.5) + .setEnum("ad-color", "ev-red", "ev-blue") + .setEnum("ad-main", "ev-red") + .setXhtml("ad-text", "<p>The system shall boot.</p>")) + .specObject("so-2", "st-req") + .specRelation("sr-1", "st-rel", "so-1", "so-2") + .specification("spec-1", "Main Spec", "st-spec", s -> s + .child("sh-1", "so-1", c -> c.child("sh-2", "so-2"))) + .build(); + + OfficialReqIFSchema.assertSchemaValid(document); + } + @Test void missingImportsAreNamedInsteadOfFetched(@TempDir Path tempDir) throws Exception { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java index 51c23f4..2a36c89 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java @@ -195,7 +195,9 @@ private TestFixtures() { * every Identifiable property (LAST-CHANGE, LONG-NAME, DESC, ALTERNATIVE-ID) * on the elements that carry one, including spec objects, relations and * hierarchy nodes. Values are set for some attributes only, and the - * ALTERNATIVE-ID uses the nested form the schema prescribes. + * ALTERNATIVE-ID uses the nested form the schema prescribes. Also covers the + * required attributes that are easy to drop: REAL ACCURACY/MIN/MAX, + * MULTI-VALUED="false" and an empty OTHER-CONTENT. */ public static final String SCHEMA_VALID_REQIF = """ <?xml version="1.0" encoding="UTF-8"?> @@ -218,6 +220,7 @@ private TestFixtures() { </DATATYPE-DEFINITION-STRING> <DATATYPE-DEFINITION-INTEGER IDENTIFIER="dt-int" LAST-CHANGE="2026-07-01T08:00:00Z" LONG-NAME="Int" MIN="0" MAX="1000"/> <DATATYPE-DEFINITION-BOOLEAN IDENTIFIER="dt-bool" LAST-CHANGE="2026-07-01T08:00:00Z" LONG-NAME="Bool"/> + <DATATYPE-DEFINITION-REAL IDENTIFIER="dt-real" LAST-CHANGE="2026-07-01T08:00:00Z" LONG-NAME="Real" ACCURACY="3" MIN="-1.5" MAX="1000.0"/> <DATATYPE-DEFINITION-DATE IDENTIFIER="dt-date" LAST-CHANGE="2026-07-01T08:00:00Z" LONG-NAME="Date"/> <DATATYPE-DEFINITION-XHTML IDENTIFIER="dt-xhtml" LAST-CHANGE="2026-07-01T08:00:00Z" LONG-NAME="Formatted text"/> <DATATYPE-DEFINITION-ENUMERATION IDENTIFIER="dt-enum" LAST-CHANGE="2026-07-01T08:00:00Z" LONG-NAME="Status"> @@ -229,7 +232,7 @@ private TestFixtures() { </ENUM-VALUE> <ENUM-VALUE IDENTIFIER="ev-approved" LAST-CHANGE="2026-07-01T08:00:00Z" LONG-NAME="Approved"> <PROPERTIES> - <EMBEDDED-VALUE KEY="2" OTHER-CONTENT="green"/> + <EMBEDDED-VALUE KEY="2" OTHER-CONTENT=""/> </PROPERTIES> </ENUM-VALUE> </SPECIFIED-VALUES> @@ -261,6 +264,12 @@ private TestFixtures() { <ATTRIBUTE-DEFINITION-ENUMERATION IDENTIFIER="ad-status" LAST-CHANGE="2026-07-02T08:00:00Z" LONG-NAME="Status" MULTI-VALUED="true"> <TYPE><DATATYPE-DEFINITION-ENUMERATION-REF>dt-enum</DATATYPE-DEFINITION-ENUMERATION-REF></TYPE> </ATTRIBUTE-DEFINITION-ENUMERATION> + <ATTRIBUTE-DEFINITION-ENUMERATION IDENTIFIER="ad-review" LAST-CHANGE="2026-07-02T08:00:00Z" LONG-NAME="Review state" MULTI-VALUED="false"> + <TYPE><DATATYPE-DEFINITION-ENUMERATION-REF>dt-enum</DATATYPE-DEFINITION-ENUMERATION-REF></TYPE> + </ATTRIBUTE-DEFINITION-ENUMERATION> + <ATTRIBUTE-DEFINITION-REAL IDENTIFIER="ad-effort" LAST-CHANGE="2026-07-02T08:00:00Z" LONG-NAME="Effort"> + <TYPE><DATATYPE-DEFINITION-REAL-REF>dt-real</DATATYPE-DEFINITION-REAL-REF></TYPE> + </ATTRIBUTE-DEFINITION-REAL> </SPEC-ATTRIBUTES> </SPEC-OBJECT-TYPE> <SPEC-RELATION-TYPE IDENTIFIER="st-rel" LAST-CHANGE="2026-07-02T08:00:00Z" LONG-NAME="refines"/> @@ -291,6 +300,13 @@ private TestFixtures() { <DEFINITION><ATTRIBUTE-DEFINITION-ENUMERATION-REF>ad-status</ATTRIBUTE-DEFINITION-ENUMERATION-REF></DEFINITION> <VALUES><ENUM-VALUE-REF>ev-approved</ENUM-VALUE-REF></VALUES> </ATTRIBUTE-VALUE-ENUMERATION> + <ATTRIBUTE-VALUE-ENUMERATION> + <DEFINITION><ATTRIBUTE-DEFINITION-ENUMERATION-REF>ad-review</ATTRIBUTE-DEFINITION-ENUMERATION-REF></DEFINITION> + <VALUES><ENUM-VALUE-REF>ev-draft</ENUM-VALUE-REF></VALUES> + </ATTRIBUTE-VALUE-ENUMERATION> + <ATTRIBUTE-VALUE-REAL THE-VALUE="2.5"> + <DEFINITION><ATTRIBUTE-DEFINITION-REAL-REF>ad-effort</ATTRIBUTE-DEFINITION-REAL-REF></DEFINITION> + </ATTRIBUTE-VALUE-REAL> </VALUES> <TYPE><SPEC-OBJECT-TYPE-REF>st-req</SPEC-OBJECT-TYPE-REF></TYPE> </SPEC-OBJECT> From b9e8b4e78b87f2f8b969b3869367405816f7eff5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 11 Oct 2026 13:24:19 +0000 Subject: [PATCH 06/20] Validate what would make the written file schema-invalid ReqIFValidator now reports as errors everything the writer cannot repair and the schema would reject: - identifiers that are not valid xsd:IDs; - LAST-CHANGE, CREATION-TIME and DATE values that are not xsd:dateTime; - a REQ-IF-VERSION other than 1.0; - enum values without an integer KEY; - default values that are not in schema notation (they are written verbatim). Enum values now take part in the identifier uniqueness check. Values outside their datatype's MAX-LENGTH or MIN/MAX are warnings, because the schema cannot express those limits. String length is counted in characters. Findings come in a stable order. The xsd lexical rules live in util.XsdTypes (which replaces XsdDouble): NCName, dateTime with the leap year rule and without year 0000, integer, double and boolean. XHTML default values are written as markup in a THE-VALUE element instead of as an attribute the schema does not allow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- IST-SOLL-ANALYSE.md | 28 ++- README.md | 49 ++++- .../attributes/AttributeValueDouble.java | 4 +- .../ils/reqif4j/datatypes/DatatypeDouble.java | 4 +- .../ils/reqif4j/util/XsdDouble.java | 40 ---- .../ils/reqif4j/util/XsdTypes.java | 147 +++++++++++++ .../ils/reqif4j/validate/ReqIFValidator.java | 204 ++++++++++++++++-- .../ils/reqif4j/write/ReqIFWriter.java | 19 +- .../ils/reqif4j/ReqIFValidatorTest.java | 15 +- .../ils/reqif4j/RequiredValuesTest.java | 17 ++ .../ils/reqif4j/ValidatorSchemaRulesTest.java | 189 ++++++++++++++++ .../ils/reqif4j/util/XsdTypesTest.java | 76 +++++++ 12 files changed, 709 insertions(+), 83 deletions(-) delete mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java diff --git a/IST-SOLL-ANALYSE.md b/IST-SOLL-ANALYSE.md index d2fa732..913eba1 100644 --- a/IST-SOLL-ANALYSE.md +++ b/IST-SOLL-ANALYSE.md @@ -25,8 +25,20 @@ schemagültigen Round-Trip verhinderten: Eine API-Änderung: `getName()` liefert für Elemente ohne `LONG-NAME` jetzt `null` (vorher Absturz, bei Enum-Werten `""`). `DatatypeEnumeration.getEnumValueName()` liefert weiterhin `""`. -Offen für Phase 2: ein Default für `LAST-CHANGE` bei erzeugten Elementen (2.1), die übrigen -Pflichtattribute (2.2) und deren Prüfung im Modellvalidator (2.3). +| Phase | Stand | Ergebnis | +|---|---|---| +| **2.1** `LAST-CHANGE`-Default | ✅ erledigt | Der Writer setzt fehlendes `LAST-CHANGE` auf die `CREATION-TIME` des Headers bzw. den Schreibzeitpunkt (Clock injizierbar). Das Modell bleibt unverändert. | +| **2.2** Pflichtattribute | ✅ erledigt | `MULTI-VALUED` und `OTHER-CONTENT` werden immer geschrieben, REAL behält `ACCURACY`/`MIN`/`MAX` (Defaults 15 / voller double-Bereich), Header-Pflichtfelder bekommen Defaults, der Builder vergibt eine freie Header-ID. Unendlich wird als `INF`/`-INF` geschrieben und gelesen, XHTML-Defaults werden als Markup geschrieben. | +| **2.3** Modellvalidator | ✅ erledigt | Fehler bei allem, was die Ausgabe schemaungültig machen würde: ungültige `xsd:ID`, kein `xsd:dateTime` (`LAST-CHANGE`, `CREATION-TIME`, DATE-Werte), `REQ-IF-VERSION` ≠ `1.0`, nicht ganzzahliger Enum-`KEY`, Defaults nicht in Schema-Notation, fehlender Header. Warnungen bei Verstößen gegen `MAX-LENGTH` und `MIN`/`MAX`. Vertrag: Ein Dokument ohne Fehler wird schemagültig geschrieben. | + +Ergebnis von Phase 2: Mit dem Builder erzeugte Dokumente (das README-Beispiel) und der Round-Trip einer +schemagültigen Datei mit REAL-Grenzen, `MULTI-VALUED="false"` und leerem `OTHER-CONTENT` bestehen das +offizielle OMG-Schema. Die Tests prüfen das in der CI gegen das echte XSD. + +Bekannte Grenzen: Die xsd:ID-Prüfung folgt den Namenszeichen von XML 1.0 (5. Ausgabe), der +JDK-Validator den älteren Tabellen. Bei exotischen Unicode-Zeichen können beide abweichen. Ein reines +Datum (`2026-01-01`) in DATE-Werten liest der Parser weiterhin tolerant ein, der Validator meldet es als +Fehler. ## 1. Zusammenfassung @@ -92,27 +104,27 @@ Legende: ✅ unterstützt · ⚠️ teilweise · ❌ fehlt · — nicht anwendba | ReqIF-Element / -Attribut | Import | Export | Builder | |---|---|---|---| | `REQ-IF` `xml:lang` | ❌ | ❌ | ❌ | -| Header: `COMMENT`, `CREATION-TIME`, `REQ-IF-TOOL-ID`, `REQ-IF-VERSION`, `SOURCE-TOOL-ID`, `TITLE` | ✅ | ⚠️ leere Werte werden ausgelassen, obwohl sie Pflicht sind | ⚠️ nicht erzwungen | +| Header: `COMMENT`, `CREATION-TIME`, `REQ-IF-TOOL-ID`, `REQ-IF-VERSION`, `SOURCE-TOOL-ID`, `TITLE` | ✅ | ✅ Pflichtfelder mit Default *(2.2)* | ✅ freie Header-ID als Default *(2.2)* | | Header: `REPOSITORY-ID` | ❌ | ❌ | ❌ | | `TOOL-EXTENSIONS` | ✅ (roh) | ✅ (roh) | ❌ | | Identifiable: `IDENTIFIER` | ✅ | ✅ | ✅ | -| Identifiable: `LAST-CHANGE` (**Pflicht**) | ✅ *(1.2)* | ⚠️ nur wenn vorhanden, kein Default *(2.1)* | ⚠️ nur per Setter | +| Identifiable: `LAST-CHANGE` (**Pflicht**) | ✅ *(1.2)* | ✅ mit Default *(2.1)* | ⚠️ nur per Setter, sonst Default | | Identifiable: `DESC` | ✅ *(1.2)* | ✅ *(1.2)* | ⚠️ nur per Setter | | Identifiable: `LONG-NAME` | ✅ alle Elemente, optional *(1.2)* | ✅ *(1.2)* | ⚠️ nicht für Spec-Objekte | | `ALTERNATIVE-ID` | ✅ verschachtelte und flache Form *(1.2)* | ✅ verschachtelt *(1.2)* | ⚠️ nur per Setter | | `DATATYPE-DEFINITION-BOOLEAN` / `-DATE` / `-XHTML` | ✅ | ✅ | ✅ | | `DATATYPE-DEFINITION-STRING` `MAX-LENGTH` | ✅ | ✅ | ✅ | | `DATATYPE-DEFINITION-INTEGER` `MIN`/`MAX` | ✅ (long) | ✅ | ✅ | -| `DATATYPE-DEFINITION-REAL` `ACCURACY`/`MIN`/`MAX` (**Pflicht**) | ❌ | ❌ | ❌ | +| `DATATYPE-DEFINITION-REAL` `ACCURACY`/`MIN`/`MAX` (**Pflicht**) | ✅ *(2.2)* | ✅ *(2.2)* | ✅ *(2.2)* | | `DATATYPE-DEFINITION-ENUMERATION`, `ENUM-VALUE`, `KEY` | ✅ | ✅ | ✅ | -| `EMBEDDED-VALUE` `OTHER-CONTENT` (**Pflicht**) | ✅ | ⚠️ entfällt, wenn leer | ✅ | +| `EMBEDDED-VALUE` `OTHER-CONTENT` (**Pflicht**) | ✅ | ✅ auch leer *(2.2)* | ✅ | | `SPEC-OBJECT-TYPE`, `SPECIFICATION-TYPE`, `SPEC-RELATION-TYPE` | ✅ | ✅ | ✅ | | `RELATION-GROUP-TYPE` | ✅ | ✅ | ❌ | | `ATTRIBUTE-DEFINITION-*` | ✅ | ✅ | ✅ | | `ATTRIBUTE-DEFINITION-*` `IS-EDITABLE` | ❌ | ❌ | ❌ | -| `ATTRIBUTE-DEFINITION-ENUMERATION` `MULTI-VALUED` (**Pflicht**) | ✅ | ⚠️ nur `true` wird geschrieben | ✅ | +| `ATTRIBUTE-DEFINITION-ENUMERATION` `MULTI-VALUED` (**Pflicht**) | ✅ | ✅ auch `false` *(2.2)* | ✅ | | `DEFAULT-VALUE` (einfache Typen, Enum) | ✅ | ✅ | ✅ | -| `DEFAULT-VALUE` (XHTML) | ❌ | ❌ | ❌ | +| `DEFAULT-VALUE` (XHTML) | ❌ *(3.3)* | ✅ als Markup *(2.2)* | ✅ über `attribute(...)` | | `ATTRIBUTE-VALUE-*` (alle 7 Arten) | ✅ | ✅ | ✅ | | `ATTRIBUTE-VALUE-INTEGER` außerhalb des int-Bereichs | ❌ Absturz | — | ⚠️ | | `ATTRIBUTE-VALUE-XHTML` `THE-ORIGINAL-VALUE` / `IS-SIMPLIFIED` | ❌ | ❌ | ❌ | diff --git a/README.md b/README.md index 70385bf..d4b2783 100644 --- a/README.md +++ b/README.md @@ -311,12 +311,35 @@ in the nested form the schema prescribes (`<ALTERNATIVE-ID><ALTERNATIVE-ID IDENTIFIER="..."/></ALTERNATIVE-ID>`); the flat form older versions of this library wrote is still read. -A schema-valid document stays schema-valid after a round trip, as long as -it uses only what the writer already covers. Not yet covered: elements -without `LAST-CHANGE` (e.g. generated with `ReqIFBuilder`) get none, and -`MULTI-VALUED="false"`, an empty `OTHER-CONTENT` and the `ACCURACY`/`MIN`/`MAX` -of REAL datatypes are not written although the schema requires them - see -`IST-SOLL-ANALYSE.md`. +## Schema-valid output + +The writer emits every value the official ReqIF schema requires, also when +the model does not carry it - typically for a document built with +`ReqIFBuilder`. The model itself is not changed: + +| missing in the model | written as | +|---|---| +| `LAST-CHANGE` of an element | the header's `CREATION-TIME`, or the time of writing | +| header `CREATION-TIME` | the time of writing | +| header `REQ-IF-TOOL-ID`, `SOURCE-TOOL-ID` | `reqif4j` | +| header `REQ-IF-VERSION` | `1.0` (the only value the schema allows) | +| header `TITLE` | empty | +| `ACCURACY` / `MIN` / `MAX` of a REAL datatype | 15 / full double range | + +`MULTI-VALUED` and `OTHER-CONTENT` are always written, also when `false` +or empty, and infinite REAL values use the xsd notation `INF` / `-INF`. +The time of writing comes from a `java.time.Clock`; pass a fixed one for +reproducible output: + +```java +new ReqIFWriter().setClock(Clock.fixed(Instant.parse("2026-10-11T12:00:00Z"), ZoneOffset.UTC)) + .write(document, Path.of("out.reqif")); +``` + +What no default can fix - a missing header, missing or invalid +identifiers, malformed dates - is reported by `ReqIFValidator` (see +below). A document the validator accepts without errors is written +schema-valid. Indenting is **off by default**: the XML indenter inserts whitespace into mixed content, which would change XHTML attribute values. Enable it with @@ -389,11 +412,19 @@ try (ReqIFz source = new ReqIFz("in.reqifz")) { # Validation `ReqIFValidator` checks a document before it is written: identifiers -present and globally unique, resolvable datatype and spec type -references, attribute values matching their spec type, enum value -references, relation endpoints, spec hierarchy targets and relation +present, valid `xsd:ID`s and globally unique, resolvable datatype and +spec type references, attribute values matching their spec type, enum +value references, relation endpoints, spec hierarchy targets and relation group references. +Values the schema constrains are errors when they do not fit: +`LAST-CHANGE`, `CREATION-TIME` and DATE values must be `xsd:dateTime` +(a date alone is not enough), enum `KEY`s integers, `REQ-IF-VERSION` +`1.0`, and default values - written as they are - must already be in +schema notation (`true`/`false`, `1.5`, `INF`). Values outside the +limits of their datatype (`MAX-LENGTH`, `MIN`/`MAX`) are warnings: the +schema cannot express those limits, but importing tools may reject them. + ```java new ReqIFValidator().validate(document).throwIfInvalid(); new ReqIFWriter().write(document, Path.of("out.reqif")); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java index e1495e3..dfba172 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueDouble.java @@ -1,6 +1,6 @@ package de.uni_stuttgart.ils.reqif4j.attributes; -import de.uni_stuttgart.ils.reqif4j.util.XsdDouble; +import de.uni_stuttgart.ils.reqif4j.util.XsdTypes; public class AttributeValueDouble extends AttributeValue{ @@ -12,7 +12,7 @@ public AttributeValueDouble(String value, AttributeDefinition type) { this.value = 0.0; }else { // xsd:double notation: INF / -INF for infinity - this.value = XsdDouble.parse(value); + this.value = XsdTypes.parseDouble(value); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java index 5f6bb4f..41a0828 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeDouble.java @@ -1,7 +1,7 @@ package de.uni_stuttgart.ils.reqif4j.datatypes; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; -import de.uni_stuttgart.ils.reqif4j.util.XsdDouble; +import de.uni_stuttgart.ils.reqif4j.util.XsdTypes; /** * A DATATYPE-DEFINITION-REAL. The parser normalizes the ReqIF kind REAL to the @@ -82,7 +82,7 @@ private static double parseBound(String value, double fallback) { return fallback; } try { - return XsdDouble.parse(value); + return XsdTypes.parseDouble(value); } catch (NumberFormatException e) { return fallback; } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java deleted file mode 100644 index 5cae9df..0000000 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdDouble.java +++ /dev/null @@ -1,40 +0,0 @@ -package de.uni_stuttgart.ils.reqif4j.util; - -/** - * Conversion between Java doubles and the xsd:double notation ReqIF uses for - * REAL bounds and values. The two differ only for infinity: xsd:double spells - * it {@code INF} / {@code -INF}, Java {@code Infinity} / {@code -Infinity}. - * Both spellings are read; xsd notation is written. - */ -public final class XsdDouble { - - private XsdDouble() { - } - - /** - * @return the parsed value - * @throws NumberFormatException if the text is neither xsd nor Java notation - */ - public static double parse(String text) { - String trimmed = text.trim(); - switch (trimmed) { - case "INF": - case "+INF": return Double.POSITIVE_INFINITY; - case "-INF": return Double.NEGATIVE_INFINITY; - default: return Double.parseDouble(trimmed); - } - } - - /** - * @return the value in xsd:double notation - */ - public static String format(double value) { - if (value == Double.POSITIVE_INFINITY) { - return "INF"; - } - if (value == Double.NEGATIVE_INFINITY) { - return "-INF"; - } - return Double.toString(value); - } -} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java new file mode 100644 index 0000000..9adb0d9 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java @@ -0,0 +1,147 @@ +package de.uni_stuttgart.ils.reqif4j.util; + +import java.math.BigInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The lexical rules of the XML Schema types ReqIF uses, for checks and + * conversions the JDK does not offer directly. + */ +public final class XsdTypes { + + /** + * xsd:NCName (the lexical space of xsd:ID), following the name characters + * of XML 1.0 fifth edition without the colon. The JDK's schema validator + * uses the older, stricter character tables, so a few exotic characters + * pass here but not there; ASCII and common letters agree. + */ + private static final Pattern NC_NAME = Pattern.compile( + "[A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF" + + "\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD" + + "\\x{10000}-\\x{EFFFF}]" + + "[A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF" + + "\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD" + + "\\x{10000}-\\x{EFFFF}\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*"); + + /** + * xsd:dateTime: year with at least four digits, optional fraction of + * seconds, optional time zone. Year 0000 is excluded, as in XML Schema 1.0, + * which the JDK's schema validator implements. + */ + private static final Pattern DATE_TIME = Pattern.compile( + "(-?)(?!0000)([1-9][0-9]{3,}|0[0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])" + + "T(([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\\.[0-9]+)?|24:00:00(\\.0+)?)" + + "(Z|[+-]((0[0-9]|1[0-3]):[0-5][0-9]|14:00))?"); + + private static final Pattern INTEGER = Pattern.compile("[+-]?[0-9]+"); + + /** xsd:double in XML Schema 1.0 notation (no "+INF") */ + private static final Pattern DOUBLE = Pattern.compile( + "[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([Ee][+-]?[0-9]+)?|-?INF|NaN"); + + private static final Pattern BOOLEAN = Pattern.compile("true|false|1|0"); + + private static final BigInteger FOUR = BigInteger.valueOf(4); + private static final BigInteger HUNDRED = BigInteger.valueOf(100); + private static final BigInteger FOUR_HUNDRED = BigInteger.valueOf(400); + + private XsdTypes() { + } + + + /** + * @return true if the text is a valid xsd:ID / xsd:NCName + */ + public static boolean isNCName(String text) { + return text != null && NC_NAME.matcher(text).matches(); + } + + /** + * @return true if the text is a valid xsd:dateTime, including an existing + * day of month (no 30 February) + */ + public static boolean isDateTime(String text) { + + if (text == null) { + return false; + } + Matcher matcher = DATE_TIME.matcher(text.trim()); + if (!matcher.matches()) { + return false; + } + // the pattern allows day 31 in every month; the year may have any + // number of digits, so the leap year rule is applied on a BigInteger + BigInteger year = new BigInteger(matcher.group(1) + matcher.group(2)); + int month = Integer.parseInt(matcher.group(3)); + int day = Integer.parseInt(matcher.group(4)); + return day <= daysIn(month, year); + } + + private static int daysIn(int month, BigInteger year) { + switch (month) { + case 2: + boolean leap = year.mod(FOUR).signum() == 0 + && (year.mod(HUNDRED).signum() != 0 || year.mod(FOUR_HUNDRED).signum() == 0); + return leap ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + /** + * @return true if the text is a valid xsd:integer (any number of digits) + */ + public static boolean isInteger(String text) { + return text != null && INTEGER.matcher(text.trim()).matches(); + } + + + /** + * @return true if the text is a valid xsd:double (XML Schema 1.0 notation) + */ + public static boolean isDouble(String text) { + return text != null && DOUBLE.matcher(text.trim()).matches(); + } + + /** + * @return true if the text is a valid xsd:boolean: true, false, 1 or 0 + */ + public static boolean isBoolean(String text) { + return text != null && BOOLEAN.matcher(text.trim()).matches(); + } + + /** + * Parses xsd:double notation, which spells infinity {@code INF} / + * {@code -INF}; Java's {@code Infinity} is accepted as well. + * + * @throws NumberFormatException if the text is not a double + */ + public static double parseDouble(String text) { + String trimmed = text.trim(); + switch (trimmed) { + case "INF": + case "+INF": return Double.POSITIVE_INFINITY; + case "-INF": return Double.NEGATIVE_INFINITY; + default: return Double.parseDouble(trimmed); + } + } + + /** + * @return the value in xsd:double notation + */ + public static String formatDouble(double value) { + if (value == Double.POSITIVE_INFINITY) { + return "INF"; + } + if (value == Double.NEGATIVE_INFINITY) { + return "-INF"; + } + return Double.toString(value); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java index 9ac7a38..2272169 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java @@ -1,7 +1,10 @@ package de.uni_stuttgart.ils.reqif4j.validate; import java.io.IOException; +import java.math.BigInteger; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -26,10 +29,18 @@ import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinitionEnumeration; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeDouble; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumerationValue; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeInteger; +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeString; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFHeader; import de.uni_stuttgart.ils.reqif4j.specification.RelationGroup; import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; @@ -37,6 +48,7 @@ import de.uni_stuttgart.ils.reqif4j.specification.SpecType; import de.uni_stuttgart.ils.reqif4j.specification.Specification; import de.uni_stuttgart.ils.reqif4j.util.SecureXml; +import de.uni_stuttgart.ils.reqif4j.util.XsdTypes; import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; /** @@ -48,10 +60,18 @@ * new ReqIFWriter().write(document, Path.of("out.reqif")); * </pre> * - * Validated are: identifiers present and globally unique, resolvable datatype - * and spec type references, attribute values matching their spec type, enum - * value references, relation endpoints, spec hierarchy targets and relation - * group references. + * Validated are: identifiers present, valid xsd:IDs and globally unique, + * resolvable datatype and spec type references, attribute values matching + * their spec type, enum value references, relation endpoints, spec hierarchy + * targets and relation group references. Values the schema constrains + * lexically - LAST-CHANGE, CREATION-TIME and DATE values (xsd:dateTime), + * enum KEYs (xsd:integer), REQ-IF-VERSION (fixed to 1.0) - are errors when + * they do not fit; values outside the limits of their datatype (MAX-LENGTH, + * MIN/MAX) are warnings, because the schema cannot express those limits. + * + * Required values the writer fills in when the model lacks them (see + * {@link ReqIFWriter#buildDocument(ReqIFDocument)}) are not reported. A + * document without errors is written schema-valid. * * This is a model-level check, not a schema check. The OMG ReqIF XSD is not * bundled with this library; if you have it, pass it to @@ -73,11 +93,12 @@ public ValidationResult validate(ReqIFDocument document) { result.error(null, "Document is null"); return result; } + Map<String, String> identifiers = new HashMap<String, String>(); if (document.getHeader() == null) { // the schema requires THE-HEADER, and the writer cannot invent its IDENTIFIER result.error(null, "Document has no THE-HEADER"); - } else if (isBlank(document.getHeader().getID())) { - result.error(null, "REQ-IF-HEADER has no IDENTIFIER"); + } else { + checkHeader(document.getHeader(), result, identifiers); } ReqIFCoreContent content = document.getCoreContent(); @@ -86,7 +107,6 @@ public ValidationResult validate(ReqIFDocument document) { return result; } - Map<String, String> identifiers = new HashMap<String, String>(); checkDatatypes(content, result, identifiers); checkSpecTypes(content, result, identifiers); checkSpecObjects(content, result, identifiers); @@ -98,20 +118,73 @@ public ValidationResult validate(ReqIFDocument document) { } + /** + * Empty header fields are fine, the writer fills them in; present ones must + * match the schema. + */ + private void checkHeader(ReqIFHeader header, ValidationResult result, Map<String, String> identifiers) { + + if (isBlank(header.getID())) { + result.error(null, "REQ-IF-HEADER has no IDENTIFIER"); + } else { + checkIdentifier(header.getID(), "REQ-IF-HEADER", result, identifiers); + } + if (!isBlank(header.getCreationTime()) && !XsdTypes.isDateTime(header.getCreationTime())) { + result.error(header.getID(), "CREATION-TIME '" + header.getCreationTime() + "' is not an xsd:dateTime"); + } + if (!isBlank(header.getReqIFVersion()) && !ReqIFWriter.REQIF_VERSION.equals(header.getReqIFVersion())) { + result.error(header.getID(), "REQ-IF-VERSION is '" + header.getReqIFVersion() + + "', but the schema only allows " + ReqIFWriter.REQIF_VERSION); + } + } + private void checkDatatypes(ReqIFCoreContent content, ValidationResult result, Map<String, String> identifiers) { for (Datatype datatype : content.getDatatypes().values()) { - checkIdentifier(datatype.getID(), "DATATYPE-DEFINITION", result, identifiers); + checkIdentifiable(datatype, "DATATYPE-DEFINITION", result, identifiers); + + if (datatype instanceof DatatypeInteger) { + DatatypeInteger integer = (DatatypeInteger) datatype; + checkBounds(datatype, integer.getMin() <= integer.getMax(), integer.getMin(), integer.getMax(), result); + + } else if (datatype instanceof DatatypeDouble) { + DatatypeDouble real = (DatatypeDouble) datatype; + checkBounds(datatype, real.getMin() <= real.getMax(), real.getMin(), real.getMax(), result); + + } else if (datatype instanceof DatatypeEnumeration) { + for (DatatypeEnumerationValue value : ((DatatypeEnumeration) datatype).getEnumValues().values()) { + checkIdentifiable(value, "ENUM-VALUE", result, identifiers); + if (isBlank(value.getKey())) { + result.error(value.getID(), "Enum value '" + value.getName() + "' has no KEY"); + } else if (!XsdTypes.isInteger(value.getKey())) { + result.error(value.getID(), "KEY '" + value.getKey() + "' of enum value '" + value.getName() + + "' is not an integer"); + } + } + } + } + } + + private void checkBounds(Datatype datatype, boolean ordered, Object min, Object max, ValidationResult result) { + if (!ordered) { + result.warning(datatype.getID(), "Datatype '" + datatype.getName() + "' has MIN " + min + + " greater than MAX " + max + "; no value can satisfy it"); } } private void checkSpecTypes(ReqIFCoreContent content, ValidationResult result, Map<String, String> identifiers) { for (SpecType specType : content.getSpecTypes().values()) { - checkIdentifier(specType.getID(), "SPEC-TYPE", result, identifiers); + checkIdentifiable(specType, "SPEC-TYPE", result, identifiers); for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) { - checkIdentifier(definition.getID(), "ATTRIBUTE-DEFINITION", result, identifiers); + checkIdentifiable(definition, "ATTRIBUTE-DEFINITION", result, identifiers); + // written verbatim, so it must already be in schema notation; XHTML + // defaults are markup and written as such + if (definition.getDefaultValue() != null && !(definition instanceof AttributeDefinitionEnumeration) + && !(definition.getDataType() != null && ReqIFConst.XHTML.equals(definition.getDataType().getType()))) { + checkScalar(definition.getID(), "Default value", definition, definition.getDefaultValue(), result); + } Datatype datatype = definition.getDataType(); if (datatype == null) { @@ -161,7 +234,7 @@ private void checkEnumRefs(AttributeDefinition definition, List<String> refs, Va private void checkSpecObjects(ReqIFCoreContent content, ValidationResult result, Map<String, String> identifiers) { for (SpecObject specObject : content.getSpecObjects().values()) { - checkIdentifier(specObject.getID(), "SPEC-OBJECT", result, identifiers); + checkIdentifiable(specObject, "SPEC-OBJECT", result, identifiers); checkSpecTypeRef(content, specObject.getID(), specObject.getSpecTypeID(), "SPEC-OBJECT", result); checkAttributeValues(specObject.getID(), specObject.getSpecTypeID(), content, specObject.getAttributes(), result); @@ -172,7 +245,7 @@ private void checkSpecRelations(ReqIFCoreContent content, ValidationResult resul Map<String, String> identifiers) { for (SpecRelation relation : content.getSpecRelation().values()) { - checkIdentifier(relation.getID(), "SPEC-RELATION", result, identifiers); + checkIdentifiable(relation, "SPEC-RELATION", result, identifiers); checkSpecTypeRef(content, relation.getID(), relation.getRelationTypeRef(), "SPEC-RELATION", result); if (content.getSpecObject(relation.getSourceObjID()) == null) { @@ -192,13 +265,13 @@ private void checkSpecifications(ReqIFCoreContent content, ValidationResult resu Map<String, String> identifiers) { for (Specification specification : content.getSpecifications().values()) { - checkIdentifier(specification.getID(), "SPECIFICATION", result, identifiers); + checkIdentifiable(specification, "SPECIFICATION", result, identifiers); checkSpecTypeRef(content, specification.getID(), specification.getSpecTypeID(), "SPECIFICATION", result); checkAttributeValues(specification.getID(), specification.getSpecTypeID(), content, specification.getAttributes(), result); for (SpecHierarchy hierarchy : specification.getAllSpecHierarchies()) { - checkIdentifier(hierarchy.getSpecHierarchyID(), "SPEC-HIERARCHY", result, identifiers); + checkIdentifiable(hierarchy, "SPEC-HIERARCHY", result, identifiers); if (hierarchy.getSpecObject() == null) { result.error(hierarchy.getSpecHierarchyID(), @@ -216,7 +289,7 @@ private void checkRelationGroups(ReqIFCoreContent content, ValidationResult resu Map<String, String> identifiers) { for (RelationGroup group : content.getRelationGroups().values()) { - checkIdentifier(group.getID(), "RELATION-GROUP", result, identifiers); + checkIdentifiable(group, "RELATION-GROUP", result, identifiers); if (group.getRelationGroupTypeRef() != null && content.getSpecType(group.getRelationGroupTypeRef()) == null) { @@ -256,7 +329,13 @@ private void checkAttributeValues(String ownerID, String specTypeID, ReqIFCoreCo return; } - for (AttributeValue attributeValue : attributeValues.values()) { + // sorted by definition id like the writer does, so findings come in a + // stable order + List<AttributeValue> sorted = new ArrayList<AttributeValue>(attributeValues.values()); + sorted.sort(Comparator.comparing(value -> value.getAttributeDefinitionType() == null + || value.getAttributeDefinitionType().getID() == null ? "" : value.getAttributeDefinitionType().getID())); + + for (AttributeValue attributeValue : sorted) { AttributeDefinition definition = attributeValue.getAttributeDefinitionType(); if (definition == null) { @@ -267,6 +346,13 @@ private void checkAttributeValues(String ownerID, String specTypeID, ReqIFCoreCo result.error(ownerID, "Attribute value '" + attributeValue.getName() + "' uses definition " + definition.getID() + ", which does not belong to spec type " + specTypeID); } + if (attributeValue.getValue() != null && !(attributeValue instanceof AttributeValueEnumeration) + && !(attributeValue instanceof AttributeValueXHTML)) { + Object value = attributeValue.getValue(); + // typed values are written in xsd notation, so check that form + String text = value instanceof Double ? XsdTypes.formatDouble((Double) value) : String.valueOf(value); + checkScalar(ownerID, "Attribute '" + attributeValue.getName() + "'", definition, text, result); + } if (attributeValue instanceof AttributeValueEnumeration) { AttributeValueEnumeration enumValue = (AttributeValueEnumeration) attributeValue; checkEnumRefs(definition, enumValue.getValueRefs(), result, "enum value"); @@ -294,7 +380,87 @@ private void checkSpecTypeRef(ReqIFCoreContent content, String ownerID, String s } /** - * ReqIF identifiers must be present and unique across the whole document. + * A value must be lexically valid for its datatype (an error: the written + * file would violate the schema) and should respect the datatype's limits + * (a warning: the schema cannot express them, but importing tools may + * reject the value). + * + * @param text the value as written, e.g. "42" or "2026-07-23T10:00:00Z" + */ + private void checkScalar(String ownerID, String what, AttributeDefinition definition, String text, + ValidationResult result) { + + Datatype datatype = definition.getDataType(); + if (datatype == null) { + return; + } + String category = datatype.getType(); + + if (ReqIFConst.DATE.equals(category)) { + if (!XsdTypes.isDateTime(text)) { + result.error(ownerID, what + ": '" + text + "' is not an xsd:dateTime"); + } + + } else if (ReqIFConst.BOOLEAN.equals(category)) { + if (!XsdTypes.isBoolean(text)) { + result.error(ownerID, what + ": '" + text + "' is not an xsd:boolean (true, false, 1 or 0)"); + } + + } else if (datatype instanceof DatatypeString) { + // xsd counts characters, not UTF-16 units + int length = text.codePointCount(0, text.length()); + int maxLength = ((DatatypeString) datatype).getMaxLength(); + if (length > maxLength) { + result.warning(ownerID, what + " is " + length + " characters long, but datatype '" + + datatype.getName() + "' allows " + maxLength); + } + + } else if (datatype instanceof DatatypeInteger) { + DatatypeInteger integer = (DatatypeInteger) datatype; + if (!XsdTypes.isInteger(text)) { + result.error(ownerID, what + ": '" + text + "' is not an integer"); + } else if (outside(new BigInteger(text.trim()), integer.getMin(), integer.getMax())) { + result.warning(ownerID, what + " " + text.trim() + " is outside the range " + integer.getMin() + + ".." + integer.getMax() + " of datatype '" + datatype.getName() + "'"); + } + + } else if (datatype instanceof DatatypeDouble) { + DatatypeDouble real = (DatatypeDouble) datatype; + if (!XsdTypes.isDouble(text)) { + result.error(ownerID, what + ": '" + text + "' is not an xsd:double"); + return; + } + double value = XsdTypes.parseDouble(text); + if (value < real.getMin() || value > real.getMax()) { + result.warning(ownerID, what + " " + text.trim() + " is outside the range " + real.getMin() + + ".." + real.getMax() + " of datatype '" + datatype.getName() + "'"); + } + } + } + + private static boolean outside(BigInteger value, long min, long max) { + return value.compareTo(BigInteger.valueOf(min)) < 0 || value.compareTo(BigInteger.valueOf(max)) > 0; + } + + /** + * Checks the identifier and, if present, the LAST-CHANGE of an element. A + * missing LAST-CHANGE is fine: the writer fills it in. + */ + private void checkIdentifiable(Identifiable identifiable, String kind, ValidationResult result, + Map<String, String> identifiers) { + + checkIdentifier(identifiable.getID(), kind, result, identifiers); + + String lastChange = identifiable.getLastChange(); + if (!isBlank(lastChange) && !XsdTypes.isDateTime(lastChange)) { + result.error(identifiable.getID(), "LAST-CHANGE '" + lastChange + "' of " + kind + + " is not an xsd:dateTime"); + } + } + + /** + * ReqIF identifiers must be present, valid xsd:IDs and unique across the + * whole document. * * Note that identifiers duplicated <em>within</em> one category cannot be * detected here: the parser keys its maps by identifier, so a duplicate has @@ -306,6 +472,10 @@ private void checkIdentifier(String id, String kind, ValidationResult result, Ma result.error(null, kind + " has no IDENTIFIER"); return; } + if (!XsdTypes.isNCName(id)) { + // e.g. starts with a digit or contains a blank or colon + result.error(id, "Identifier '" + id + "' of " + kind + " is not a valid xsd:ID"); + } String previousKind = identifiers.put(id, kind); if (previousKind != null) { result.error(id, "Identifier is used twice: as " + previousKind + " and as " + kind); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index 059271e..eb09895 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -47,7 +47,7 @@ import de.uni_stuttgart.ils.reqif4j.specification.Specification; import de.uni_stuttgart.ils.reqif4j.util.SecureXml; import de.uni_stuttgart.ils.reqif4j.util.XhtmlParser; -import de.uni_stuttgart.ils.reqif4j.util.XsdDouble; +import de.uni_stuttgart.ils.reqif4j.util.XsdTypes; /** * Serializes a parsed {@link ReqIFDocument} back to ReqIF XML. @@ -295,8 +295,8 @@ private Element datatype(Document xml, Datatype datatype) { } else if (datatype instanceof DatatypeDouble) { DatatypeDouble real = (DatatypeDouble) datatype; definition.setAttribute(ReqIFConst.ACCURACY, Integer.toString(real.getAccuracy())); - definition.setAttribute(ReqIFConst.MIN, XsdDouble.format(real.getMin())); - definition.setAttribute(ReqIFConst.MAX, XsdDouble.format(real.getMax())); + definition.setAttribute(ReqIFConst.MIN, XsdTypes.formatDouble(real.getMin())); + definition.setAttribute(ReqIFConst.MAX, XsdTypes.formatDouble(real.getMax())); } else if (datatype instanceof DatatypeString) { definition.setAttribute(ReqIFConst.MAX_LENGTH, @@ -417,6 +417,17 @@ private Element defaultValue(Document xml, AttributeDefinition definition, Datat if (definition.getDefaultValue() == null) { return null; } + if (ReqIFConst.XHTML.equals(datatype.getType())) { + // an XHTML value carries its content in a THE-VALUE element, not in + // an attribute + Element value = xhtmlValue(xml, definition, new AttributeValueXHTML(definition.getDefaultValue(), definition)); + if (value == null) { + return null; + } + Element defaultValue = element(xml, ReqIFConst.DEFAULT_VALUE); + defaultValue.appendChild(value); + return defaultValue; + } String elementName = ReqIFElements.attributeValue(datatype.getType()); if (elementName == null) { return null; @@ -549,7 +560,7 @@ private Element attributeValue(Document xml, AttributeValue attributeValue) { Element element = element(xml, elementName); element.setAttribute(ReqIFConst.THE_VALUE, - value instanceof Double ? XsdDouble.format((Double) value) : String.valueOf(value)); + value instanceof Double ? XsdTypes.formatDouble((Double) value) : String.valueOf(value)); element.appendChild(definitionRef(xml, definition)); return element; } diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java index e85f3a9..6148a0b 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java @@ -61,7 +61,20 @@ void parsedFixtureIsValid(@TempDir Path tempDir) throws Exception { ValidationResult result = validate(reqif.getReqIFDocument()); - assertTrue(result.isValid(), "the fixture must validate cleanly, got:\n" + result); + // The main fixture deliberately carries a date-only DATE value, which the + // reader accepts leniently but the schema does not (xsd:dateTime needs a + // time). That must be the only finding. + assertEquals(1, result.getIssues().size(), result.toString()); + assertTrue(hasError(result, "'2026-01-01' is not an xsd:dateTime"), result.toString()); + } + + @Test + void schemaValidFixtureIsValid(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "valid.reqif", TestFixtures.SCHEMA_VALID_REQIF).toString()); + + ValidationResult result = validate(reqif.getReqIFDocument()); + + assertTrue(result.getIssues().isEmpty(), "the fixture must validate cleanly, got:\n" + result); } @Test diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java index 0928453..2420f31 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java @@ -197,6 +197,23 @@ void inconsistentRealDatatypeIsRefused() { () -> ReqIFBuilder.create().realDatatype("dt", "Real", 2, Double.NaN, 0.0)); } + @Test + void xhtmlDefaultValueIsWrittenAsMarkup() throws Exception { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .xhtmlDatatype("dt-xhtml", "XHTML") + .specObjectType("st", "Type", t -> t.attribute("ad-text", "Text", "dt-xhtml", "<p>to be written</p>")) + .build(); + + String xml = new ReqIFWriter().setClock(FIXED).toXml(document); + + assertTrue(xml.contains("<DEFAULT-VALUE><ATTRIBUTE-VALUE-XHTML><DEFINITION>" + + "<ATTRIBUTE-DEFINITION-XHTML-REF>ad-text</ATTRIBUTE-DEFINITION-XHTML-REF></DEFINITION>" + + "<THE-VALUE><div xmlns=\"http://www.w3.org/1999/xhtml\"><p>to be written</p></div></THE-VALUE>"), xml); + OfficialReqIFSchema.assertSchemaValid(document); + } + @Test void outputCarriesNoInternalState() { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java new file mode 100644 index 0000000..1f8eea5 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java @@ -0,0 +1,189 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; +import de.uni_stuttgart.ils.reqif4j.validate.ValidationIssue; +import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; + +/** + * The model validator reports what would make the written file + * schema-invalid as an error, and values outside their datatype's limits - + * which the schema cannot express - as a warning. + */ +class ValidatorSchemaRulesTest { + + @Test + void identifierMustBeAnXsdID() { + + ReqIFDocument document = document(b -> b.specObject("1st requirement", "st-req")); + + assertEquals(List.of("ERROR [1st requirement]: Identifier '1st requirement' of SPEC-OBJECT is not a valid xsd:ID"), + issues(document)); + } + + @Test + void lastChangeMustBeAnXsdDateTime() { + + ReqIFDocument document = document(b -> { }); + document.getCoreContent().getSpecObject("so-1").setLastChange("yesterday"); + + assertEquals(List.of("ERROR [so-1]: LAST-CHANGE 'yesterday' of SPEC-OBJECT is not an xsd:dateTime"), + issues(document)); + } + + @Test + void headerFieldsMustMatchTheSchema() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr").creationTime("2026-07-23").reqifVersion("1.2")) + .build(); + + assertEquals(List.of( + "ERROR [hdr]: CREATION-TIME '2026-07-23' is not an xsd:dateTime", + "ERROR [hdr]: REQ-IF-VERSION is '1.2', but the schema only allows 1.0"), issues(document)); + } + + @Test + void enumValuesNeedAnIntegerKeyAndAUniqueIdentifier() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .enumerationDatatype("dt-enum", "Color", e -> e + .value("ev-red", "Red", "red") + .value("hdr", "Clash", "2")) + .build(); + + assertEquals(List.of( + "ERROR [ev-red]: KEY 'red' of enum value 'Red' is not an integer", + "ERROR [hdr]: Identifier is used twice: as REQ-IF-HEADER and as ENUM-VALUE"), issues(document)); + } + + @Test + void dateValuesAndDefaultsMustBeXsdDateTimes() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .dateDatatype("dt-date", "Date") + .specObjectType("st", "Type", t -> t.attribute("ad-due", "Due", "dt-date", "2026-02-30T00:00:00Z")) + .specObject("so-1", "st", o -> o.set("ad-due", "31.12.2026")) + .build(); + + assertEquals(List.of( + "ERROR [ad-due]: Default value: '2026-02-30T00:00:00Z' is not an xsd:dateTime", + "ERROR [so-1]: Attribute 'Due': '31.12.2026' is not an xsd:dateTime"), issues(document)); + } + + @Test + void defaultValuesAreWrittenVerbatimSoMustBeInSchemaNotation() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .booleanDatatype("dt-bool", "Bool") + .realDatatype("dt-real", "Real") + .integerDatatype("dt-int", "Int", 0, 10) + .specObjectType("st", "Type", t -> t + .attribute("ad-flag", "Flag", "dt-bool", "yes") + .attribute("ad-effort", "Effort", "dt-real", "1.5d") + .attribute("ad-count", "Count", "dt-int", "three")) + .build(); + + assertEquals(List.of( + "ERROR [ad-flag]: Default value: 'yes' is not an xsd:boolean (true, false, 1 or 0)", + "ERROR [ad-effort]: Default value: '1.5d' is not an xsd:double", + "ERROR [ad-count]: Default value: 'three' is not an integer"), issues(document)); + } + + @Test + void enumValueWithoutKeyIsNamedAsSuch() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .enumerationDatatype("dt-enum", "Color", e -> e.value("ev-red", "Red", null)) + .build(); + + assertEquals(List.of("ERROR [ev-red]: Enum value 'Red' has no KEY"), issues(document)); + } + + @Test + void stringLengthCountsCharactersNotUtf16Units() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .stringDatatype("dt-short", "Short", 2) + .specObjectType("st", "Type", t -> t.stringAttribute("ad-icon", "Icon", "dt-short")) + .specObject("so-1", "st", o -> o.set("ad-icon", "\uD83D\uDE00\uD83D\uDE00")) + .build(); + + assertEquals(List.of(), issues(document), "two emoji are two characters"); + } + + @Test + void valuesOutsideTheirDatatypeAreWarnings() throws Exception { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .stringDatatype("dt-short", "Short", 3) + .integerDatatype("dt-prio", "Priority", 1, 5) + .realDatatype("dt-ratio", "Ratio", 2, 0.0, 1.0) + .specObjectType("st", "Type", t -> t + .stringAttribute("ad-code", "Code", "dt-short") + .integerAttribute("ad-prio", "Priority", "dt-prio") + .realAttribute("ad-ratio", "Ratio", "dt-ratio")) + .specObject("so-1", "st", o -> o.set("ad-code", "ABCD").set("ad-prio", 9).set("ad-ratio", 1.5)) + .build(); + + ValidationResult result = new ReqIFValidator().validate(document); + + assertTrue(result.isValid(), "range violations do not make the file schema-invalid"); + assertEquals(List.of( + "WARNING [so-1]: Attribute 'Code' is 4 characters long, but datatype 'Short' allows 3", + "WARNING [so-1]: Attribute 'Priority' 9 is outside the range 1..5 of datatype 'Priority'", + "WARNING [so-1]: Attribute 'Ratio' 1.5 is outside the range 0.0..1.0 of datatype 'Ratio'"), + issues(document)); + + // the contract: a document without errors is written schema-valid + OfficialReqIFSchema.assertSchemaValid(document); + } + + @Test + void datatypeWithMinAboveMaxIsAWarning() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .integerDatatype("dt-int", "Broken", 10, 1) + .build(); + + assertEquals(List.of("WARNING [dt-int]: Datatype 'Broken' has MIN 10 greater than MAX 1; no value can satisfy it"), + issues(document)); + } + + /** + * A valid document with one spec type and one spec object, plus whatever + * the test adds. + */ + private static ReqIFDocument document(Consumer<ReqIFBuilder> additions) { + ReqIFBuilder builder = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .stringDatatype("dt-string", "String", 100) + .specObjectType("st-req", "Requirement", t -> t.stringAttribute("ad-name", "Name", "dt-string")) + .specObject("so-1", "st-req", o -> o.set("ad-name", "x")); + additions.accept(builder); + return builder.build(); + } + + private static List<String> issues(ReqIFDocument document) { + return new ReqIFValidator().validate(document).getIssues().stream() + .map(ValidationIssue::toString) + .collect(Collectors.toList()); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java new file mode 100644 index 0000000..1902ed1 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java @@ -0,0 +1,76 @@ +package de.uni_stuttgart.ils.reqif4j.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class XsdTypesTest { + + @Test + void ncNames() { + for (String valid : new String[] { "so-1", "_x", "REQ.1", "Anforderung-ä", "a·b", "_1" }) { + assertTrue(XsdTypes.isNCName(valid), valid); + } + for (String invalid : new String[] { "", "1-req", "-a", ".a", "a b", "a:b", "a/b", "a#b" }) { + assertFalse(XsdTypes.isNCName(invalid), invalid); + } + assertFalse(XsdTypes.isNCName(null)); + } + + @Test + void dateTimes() { + for (String valid : new String[] { "2026-07-23T10:00:00Z", "2026-07-23T10:00:00", "2026-07-23T10:00:00.123+02:00", + "2024-02-29T00:00:00Z", "2000-02-29T00:00:00Z", "2026-07-23T24:00:00Z", "12026-01-01T00:00:00Z", + "-0044-03-15T12:00:00Z", "1000000000-02-29T00:00:00Z", "30000000000-12-31T00:00:00Z" }) { + assertTrue(XsdTypes.isDateTime(valid), valid); + } + for (String invalid : new String[] { "", "2026-01-01", "2026-07-23 10:00:00", "2026-13-01T00:00:00Z", + "2026-02-30T00:00:00Z", "2025-02-29T00:00:00Z", "2026-07-23T10:00Z", "2026-07-23T10:00:00+15:00", + "26-07-23T10:00:00Z", "yesterday", "0000-01-01T00:00:00Z", "1900-02-29T00:00:00Z", + "2026-04-31T00:00:00Z", "1000000001-02-29T00:00:00Z" }) { + assertFalse(XsdTypes.isDateTime(invalid), invalid); + } + } + + @Test + void integers() { + assertTrue(XsdTypes.isInteger("42")); + assertTrue(XsdTypes.isInteger("-7")); + assertTrue(XsdTypes.isInteger("+0012")); + assertTrue(XsdTypes.isInteger("123456789012345678901234567890")); + assertFalse(XsdTypes.isInteger("")); + assertFalse(XsdTypes.isInteger("1.0")); + assertFalse(XsdTypes.isInteger("abc")); + assertFalse(XsdTypes.isInteger(null)); + } + + @Test + void doublesAndBooleansLexically() { + for (String valid : new String[] { "1", "-1.5", "+.5", "1.", "1e3", "1.5E-3", "INF", "-INF", "NaN" }) { + assertTrue(XsdTypes.isDouble(valid), valid); + } + for (String invalid : new String[] { "", "Infinity", "+INF", "1.5d", "2f", "0x1p3", "1,5", "e3" }) { + assertFalse(XsdTypes.isDouble(invalid), invalid); + } + for (String valid : new String[] { "true", "false", "1", "0" }) { + assertTrue(XsdTypes.isBoolean(valid), valid); + } + for (String invalid : new String[] { "", "yes", "TRUE", "2" }) { + assertFalse(XsdTypes.isBoolean(invalid), invalid); + } + } + + @Test + void doublesInXsdNotation() { + assertEquals(Double.POSITIVE_INFINITY, XsdTypes.parseDouble("INF")); + assertEquals(Double.NEGATIVE_INFINITY, XsdTypes.parseDouble(" -INF ")); + assertEquals(Double.POSITIVE_INFINITY, XsdTypes.parseDouble("Infinity")); + assertEquals(1.5e3, XsdTypes.parseDouble("1.5E3")); + assertEquals("INF", XsdTypes.formatDouble(Double.POSITIVE_INFINITY)); + assertEquals("-INF", XsdTypes.formatDouble(Double.NEGATIVE_INFINITY)); + assertEquals("NaN", XsdTypes.formatDouble(Double.NaN)); + assertEquals("2.5", XsdTypes.formatDouble(2.5)); + } +} From b5627a91ee0f04a61116930d4ac845493de7b11b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 11 Oct 2026 13:32:25 +0000 Subject: [PATCH 07/20] Close the gaps between the validator and the schema A review of the claim "a document without validation errors is written schema-valid" found cases where it did not hold. They are fixed: - Identifiers with non-ASCII characters are checked by the JDK's own schema validator, whose name tables are older than XML 1.0 fifth edition, so validator and schema always agree. - Text with characters XML cannot hold (control characters, unpaired surrogates) is an error; writing it used to fail with an IOException. - A malformed XHTML default, an enumeration default given as text (now also not written), and a relation group without type, source or target specification are errors. - The parser keeps only the TOOL-EXTENSIONS of REQ-IF itself, not elements of that name nested in a vendor extension. Schema findings on a DOM source carry no position, so merging findings with equal messages hid distinct problems. Only the fatal error a parser throws a second time is now skipped. The remaining exception is documented: which XHTML elements a value may use is only checked by validateAgainstSchema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- IST-SOLL-ANALYSE.md | 14 ++- README.md | 6 +- .../ils/reqif4j/reqif/ReqIFDocument.java | 8 +- .../ils/reqif4j/util/XsdTypes.java | 97 ++++++++++++++++--- .../ils/reqif4j/validate/ReqIFValidator.java | 77 +++++++++++---- .../ils/reqif4j/write/ReqIFWriter.java | 4 +- .../ils/reqif4j/ModelCoverageTest.java | 15 +++ .../ils/reqif4j/SchemaConformanceTest.java | 22 +++++ .../ils/reqif4j/ValidatorSchemaRulesTest.java | 59 +++++++++++ .../ils/reqif4j/util/XsdTypesTest.java | 20 +++- 10 files changed, 282 insertions(+), 40 deletions(-) diff --git a/IST-SOLL-ANALYSE.md b/IST-SOLL-ANALYSE.md index 913eba1..e69015e 100644 --- a/IST-SOLL-ANALYSE.md +++ b/IST-SOLL-ANALYSE.md @@ -29,16 +29,20 @@ bei Enum-Werten `""`). `DatatypeEnumeration.getEnumValueName()` liefert weiterhi |---|---|---| | **2.1** `LAST-CHANGE`-Default | ✅ erledigt | Der Writer setzt fehlendes `LAST-CHANGE` auf die `CREATION-TIME` des Headers bzw. den Schreibzeitpunkt (Clock injizierbar). Das Modell bleibt unverändert. | | **2.2** Pflichtattribute | ✅ erledigt | `MULTI-VALUED` und `OTHER-CONTENT` werden immer geschrieben, REAL behält `ACCURACY`/`MIN`/`MAX` (Defaults 15 / voller double-Bereich), Header-Pflichtfelder bekommen Defaults, der Builder vergibt eine freie Header-ID. Unendlich wird als `INF`/`-INF` geschrieben und gelesen, XHTML-Defaults werden als Markup geschrieben. | -| **2.3** Modellvalidator | ✅ erledigt | Fehler bei allem, was die Ausgabe schemaungültig machen würde: ungültige `xsd:ID`, kein `xsd:dateTime` (`LAST-CHANGE`, `CREATION-TIME`, DATE-Werte), `REQ-IF-VERSION` ≠ `1.0`, nicht ganzzahliger Enum-`KEY`, Defaults nicht in Schema-Notation, fehlender Header. Warnungen bei Verstößen gegen `MAX-LENGTH` und `MIN`/`MAX`. Vertrag: Ein Dokument ohne Fehler wird schemagültig geschrieben. | +| **2.3** Modellvalidator | ✅ erledigt | Fehler bei allem, was die Ausgabe schemaungültig machen würde: ungültige `xsd:ID`, kein `xsd:dateTime` (`LAST-CHANGE`, `CREATION-TIME`, DATE-Werte), `REQ-IF-VERSION` ≠ `1.0`, nicht ganzzahliger Enum-`KEY`, Defaults nicht in Schema-Notation, fehlender Header. Warnungen bei Verstößen gegen `MAX-LENGTH` und `MIN`/`MAX`. Außerdem Fehler bei Zeichen, die XML nicht aufnehmen kann, Enum-Defaults als Text und Relationsgruppen ohne Typ/Quelle/Ziel. Vertrag: Ein Dokument ohne Fehler wird schemagültig geschrieben, mit Ausnahme des XHTML-Vokabulars (z. B. `font`, `img` sind in ReqIF nicht erlaubt), das nur `validateAgainstSchema` prüft (siehe 5.2). | Ergebnis von Phase 2: Mit dem Builder erzeugte Dokumente (das README-Beispiel) und der Round-Trip einer schemagültigen Datei mit REAL-Grenzen, `MULTI-VALUED="false"` und leerem `OTHER-CONTENT` bestehen das offizielle OMG-Schema. Die Tests prüfen das in der CI gegen das echte XSD. -Bekannte Grenzen: Die xsd:ID-Prüfung folgt den Namenszeichen von XML 1.0 (5. Ausgabe), der -JDK-Validator den älteren Tabellen. Bei exotischen Unicode-Zeichen können beide abweichen. Ein reines -Datum (`2026-01-01`) in DATE-Werten liest der Parser weiterhin tolerant ein, der Validator meldet es als -Fehler. +Weitere Funde der Review-Runden in Phase 2: Der Parser übernahm auch verschachtelte `TOOL-EXTENSIONS` +aus Vendor-Erweiterungen (jetzt nur direkte Kinder von `REQ-IF`). Unendliche REAL-Werte konnte die +Bibliothek nicht mehr einlesen, nachdem sie als `INF` geschrieben wurden (jetzt in beide Richtungen +xsd-Notation). XHTML-Defaults wurden als ungültiges Attribut geschrieben. + +Bekannte Grenzen: IDs mit Nicht-ASCII-Zeichen prüft der Validator mit dem Schemavalidator des JDK, damit +beide übereinstimmen. Ein reines Datum (`2026-01-01`) in DATE-Werten liest der Parser weiterhin tolerant +ein, der Validator meldet es als Fehler. ## 1. Zusammenfassung diff --git a/README.md b/README.md index d4b2783..b1f9c27 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,9 @@ new ReqIFWriter().setClock(Clock.fixed(Instant.parse("2026-10-11T12:00:00Z"), Zo What no default can fix - a missing header, missing or invalid identifiers, malformed dates - is reported by `ReqIFValidator` (see below). A document the validator accepts without errors is written -schema-valid. +schema-valid, with one exception: which XHTML elements a value may use +(ReqIF allows a subset, e.g. no `font` or `img`) is only checked by +`validateAgainstSchema`, against the official schema. Indenting is **off by default**: the XML indenter inserts whitespace into mixed content, which would change XHTML attribute values. Enable it with @@ -424,6 +426,8 @@ Values the schema constrains are errors when they do not fit: schema notation (`true`/`false`, `1.5`, `INF`). Values outside the limits of their datatype (`MAX-LENGTH`, `MIN`/`MAX`) are warnings: the schema cannot express those limits, but importing tools may reject them. +Text with characters XML cannot hold (control characters) is an error, +as are relation groups without type, source or target specification. ```java new ReqIFValidator().validate(document).throwIfInvalid(); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java index 5af0731..d3fbac8 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java @@ -144,7 +144,13 @@ private void readDocument() { this.content = new ReqIFCoreContent(coreContent, this.typeClassifier); // Tool extensions are kept verbatim; the parser does not interpret them. - this.toolExtensions.addAll(XmlUtils.descendantsByLocalName(this.reqifDocument, ReqIFConst.TOOL_EXTENSIONS)); + // Only the TOOL-EXTENSIONS of REQ-IF itself; a vendor extension may well + // contain an element of that name, and the schema allows one only. + for (Element child : XmlUtils.childElements(this.reqifDocument.getDocumentElement())) { + if (ReqIFConst.TOOL_EXTENSIONS.equals(XmlUtils.localName(child))) { + this.toolExtensions.add(child); + } + } } private static String extractFileName(String path) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java index 9adb0d9..b6c5888 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypes.java @@ -1,9 +1,18 @@ package de.uni_stuttgart.ils.reqif4j.util; +import java.io.IOException; +import java.io.StringReader; +import java.io.UncheckedIOException; import java.math.BigInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.xml.XMLConstants; +import javax.xml.transform.stream.StreamSource; +import javax.xml.validation.Schema; + +import org.xml.sax.SAXException; + /** * The lexical rules of the XML Schema types ReqIF uses, for checks and * conversions the JDK does not offer directly. @@ -11,18 +20,15 @@ public final class XsdTypes { /** - * xsd:NCName (the lexical space of xsd:ID), following the name characters - * of XML 1.0 fifth edition without the colon. The JDK's schema validator - * uses the older, stricter character tables, so a few exotic characters - * pass here but not there; ASCII and common letters agree. + * xsd:NCName restricted to ASCII, where all editions of XML agree on the + * name characters. */ - private static final Pattern NC_NAME = Pattern.compile( - "[A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF" - + "\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD" - + "\\x{10000}-\\x{EFFFF}]" - + "[A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF" - + "\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD" - + "\\x{10000}-\\x{EFFFF}\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040]*"); + private static final Pattern ASCII_NC_NAME = Pattern.compile("[A-Za-z_][A-Za-z0-9._-]*"); + + private static final Pattern ASCII = Pattern.compile("\\p{ASCII}*"); + + /** compiled on first use; only needed for identifiers with non-ASCII characters */ + private static Schema ncNameSchema; /** * xsd:dateTime: year with at least four digits, optional fraction of @@ -52,9 +58,76 @@ private XsdTypes() { /** * @return true if the text is a valid xsd:ID / xsd:NCName + * + * The editions of XML differ in which non-ASCII characters a name may + * contain, and the JDK's schema validator follows an older one than the + * current specification. Identifiers with non-ASCII characters are + * therefore checked by that validator itself, so this method and + * schema validation always agree. */ public static boolean isNCName(String text) { - return text != null && NC_NAME.matcher(text).matches(); + + if (text == null || text.isEmpty()) { + return false; + } + if (ASCII.matcher(text).matches()) { + return ASCII_NC_NAME.matcher(text).matches(); + } + // the schema collapses whitespace before checking, an identifier + // written to a file must not contain any + if (!isXmlText(text) || text.codePoints().anyMatch(Character::isWhitespace)) { + return false; + } + try { + String document = "<n>" + text.replace("&", "&").replace("<", "<") + "</n>"; + ncNameSchema().newValidator().validate(new StreamSource(new StringReader(document))); + return true; + } catch (SAXException notAName) { + return false; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static synchronized Schema ncNameSchema() { + + if (ncNameSchema == null) { + String xsd = "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">" + + "<xs:element name=\"n\" type=\"xs:NCName\"/></xs:schema>"; + try { + ncNameSchema = SecureXml.newSchemaFactory(XMLConstants.W3C_XML_SCHEMA_NS_URI) + .newSchema(new StreamSource(new StringReader(xsd))); + } catch (SAXException e) { + throw new IllegalStateException("Built-in NCName schema does not compile", e); + } + } + return ncNameSchema; + } + + /** + * @return true if every character of the text may appear in an XML 1.0 + * document; control characters (except tab, line feed, carriage + * return), unpaired surrogates and U+FFFE/U+FFFF may not + */ + public static boolean isXmlText(String text) { + + if (text == null) { + return true; + } + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (Character.isHighSurrogate(c) && i + 1 < text.length() && Character.isLowSurrogate(text.charAt(i + 1))) { + i++; + continue; + } + boolean allowed = c == 0x9 || c == 0xA || c == 0xD + || (c >= 0x20 && c <= 0xD7FF) + || (c >= 0xE000 && c <= 0xFFFD); + if (!allowed) { + return false; + } + } + return true; } /** diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java index 2272169..e4a705f 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java @@ -48,6 +48,7 @@ import de.uni_stuttgart.ils.reqif4j.specification.SpecType; import de.uni_stuttgart.ils.reqif4j.specification.Specification; import de.uni_stuttgart.ils.reqif4j.util.SecureXml; +import de.uni_stuttgart.ils.reqif4j.util.XhtmlParser; import de.uni_stuttgart.ils.reqif4j.util.XsdTypes; import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; @@ -71,7 +72,9 @@ * * Required values the writer fills in when the model lacks them (see * {@link ReqIFWriter#buildDocument(ReqIFDocument)}) are not reported. A - * document without errors is written schema-valid. + * document without errors is written schema-valid, except for the XHTML + * content model: which XHTML elements a value may use is only checked by + * {@link #validateAgainstSchema(ReqIFDocument, Path)}. * * This is a model-level check, not a schema check. The OMG ReqIF XSD is not * bundled with this library; if you have it, pass it to @@ -129,6 +132,10 @@ private void checkHeader(ReqIFHeader header, ValidationResult result, Map<String } else { checkIdentifier(header.getID(), "REQ-IF-HEADER", result, identifiers); } + checkText(header.getID(), "TITLE", header.getTitle(), result); + checkText(header.getID(), "COMMENT", header.getComment(), result); + checkText(header.getID(), "REQ-IF-TOOL-ID", header.getToolID(), result); + checkText(header.getID(), "SOURCE-TOOL-ID", header.getSourceToolID(), result); if (!isBlank(header.getCreationTime()) && !XsdTypes.isDateTime(header.getCreationTime())) { result.error(header.getID(), "CREATION-TIME '" + header.getCreationTime() + "' is not an xsd:dateTime"); } @@ -154,6 +161,7 @@ private void checkDatatypes(ReqIFCoreContent content, ValidationResult result, M } else if (datatype instanceof DatatypeEnumeration) { for (DatatypeEnumerationValue value : ((DatatypeEnumeration) datatype).getEnumValues().values()) { checkIdentifiable(value, "ENUM-VALUE", result, identifiers); + checkText(value.getID(), "OTHER-CONTENT of enum value", value.getOtherContent(), result); if (isBlank(value.getKey())) { result.error(value.getID(), "Enum value '" + value.getName() + "' has no KEY"); } else if (!XsdTypes.isInteger(value.getKey())) { @@ -179,10 +187,8 @@ private void checkSpecTypes(ReqIFCoreContent content, ValidationResult result, M for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) { checkIdentifiable(definition, "ATTRIBUTE-DEFINITION", result, identifiers); - // written verbatim, so it must already be in schema notation; XHTML - // defaults are markup and written as such - if (definition.getDefaultValue() != null && !(definition instanceof AttributeDefinitionEnumeration) - && !(definition.getDataType() != null && ReqIFConst.XHTML.equals(definition.getDataType().getType()))) { + // written verbatim, so it must already be in schema notation + if (definition.getDefaultValue() != null && !(definition instanceof AttributeDefinitionEnumeration)) { checkScalar(definition.getID(), "Default value", definition, definition.getDefaultValue(), result); } @@ -291,8 +297,9 @@ private void checkRelationGroups(ReqIFCoreContent content, ValidationResult resu for (RelationGroup group : content.getRelationGroups().values()) { checkIdentifiable(group, "RELATION-GROUP", result, identifiers); - if (group.getRelationGroupTypeRef() != null - && content.getSpecType(group.getRelationGroupTypeRef()) == null) { + if (isBlank(group.getRelationGroupTypeRef())) { + result.error(group.getID(), "Relation group has no type reference"); + } else if (content.getSpecType(group.getRelationGroupTypeRef()) == null) { result.error(group.getID(), "Relation group references type " + group.getRelationGroupTypeRef() + ", which is not declared in SPEC-TYPES"); } @@ -311,7 +318,9 @@ private void checkRelationGroups(ReqIFCoreContent content, ValidationResult resu private void checkSpecificationRef(ReqIFCoreContent content, RelationGroup group, String specificationRef, String role, ValidationResult result) { - if (specificationRef != null && content.getSpecification(specificationRef) == null) { + if (isBlank(specificationRef)) { + result.error(group.getID(), "Relation group has no " + role + " specification"); + } else if (content.getSpecification(specificationRef) == null) { result.error(group.getID(), "Relation group " + role + " specification " + specificationRef + " is not declared in SPECIFICATIONS"); } @@ -395,8 +404,25 @@ private void checkScalar(String ownerID, String what, AttributeDefinition defini return; } String category = datatype.getType(); + if (!XsdTypes.isXmlText(text)) { + result.error(ownerID, what + " contains characters XML does not allow (control characters)"); + return; + } + + if (ReqIFConst.ENUMERATION.equals(category)) { + // only reachable for a default of a definition that is not an + // AttributeDefinitionEnumeration; enum values are references + result.error(ownerID, what + " of an enumeration must be given as enum value references " + + "(an enumeration attribute definition), not as text"); - if (ReqIFConst.DATE.equals(category)) { + } else if (ReqIFConst.XHTML.equals(category)) { + try { + XhtmlParser.parseDiv(text); + } catch (IllegalArgumentException e) { + result.error(ownerID, what + " is not well-formed XHTML"); + } + + } else if (ReqIFConst.DATE.equals(category)) { if (!XsdTypes.isDateTime(text)) { result.error(ownerID, what + ": '" + text + "' is not an xsd:dateTime"); } @@ -438,6 +464,15 @@ private void checkScalar(String ownerID, String what, AttributeDefinition defini } } + /** + * Text with characters XML does not allow cannot be written at all. + */ + private void checkText(String ownerID, String what, String text, ValidationResult result) { + if (!XsdTypes.isXmlText(text)) { + result.error(ownerID, what + " contains characters XML does not allow (control characters)"); + } + } + private static boolean outside(BigInteger value, long min, long max) { return value.compareTo(BigInteger.valueOf(min)) < 0 || value.compareTo(BigInteger.valueOf(max)) > 0; } @@ -450,6 +485,8 @@ private void checkIdentifiable(Identifiable identifiable, String kind, Validatio Map<String, String> identifiers) { checkIdentifier(identifiable.getID(), kind, result, identifiers); + checkText(identifiable.getID(), "LONG-NAME of " + kind, identifiable.getName(), result); + checkText(identifiable.getID(), "DESC of " + kind, identifiable.getDesc(), result); String lastChange = identifiable.getLastChange(); if (!isBlank(lastChange) && !XsdTypes.isDateTime(lastChange)) { @@ -585,13 +622,14 @@ private static void appendMissingImports(StringBuilder message, LocalSchemaResol * fatal errors are errors. * * After a fatal error the parser throws a new exception for the same - * problem, so findings are told apart by position and message, not by - * object identity. + * problem; that one must not be reported again. Errors and warnings are + * never merged: findings on a DOM source carry no position, so equal + * messages may well be different problems. */ private static final class IssueCollector implements ErrorHandler { private final ValidationResult result; - private final Set<String> reported = new HashSet<String>(); + private final Set<String> fatalErrors = new HashSet<String>(); IssueCollector(ValidationResult result) { this.result = result; @@ -599,23 +637,26 @@ private static final class IssueCollector implements ErrorHandler { @Override public void warning(SAXParseException exception) { - if (this.reported.add(fingerprint(exception))) { - this.result.warning(null, message(exception)); - } + this.result.warning(null, message(exception)); } @Override public void error(SAXParseException exception) { - reportUnlessReported(exception); + this.result.error(null, message(exception)); } @Override public void fatalError(SAXParseException exception) { - reportUnlessReported(exception); + this.fatalErrors.add(fingerprint(exception)); + this.result.error(null, message(exception)); } + /** + * For an exception that ended parsing: reports it unless it repeats a + * fatal error the handler has seen already. + */ void reportUnlessReported(SAXException exception) { - if (this.reported.add(fingerprint(exception))) { + if (!this.fatalErrors.contains(fingerprint(exception))) { this.result.error(null, exception instanceof SAXParseException ? message((SAXParseException) exception) : exception.getMessage()); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index eb09895..de3c094 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -414,7 +414,9 @@ private Element defaultValue(Document xml, AttributeDefinition definition, Datat return defaultValue; } - if (definition.getDefaultValue() == null) { + // an enumeration default is a list of enum value references, which only + // an enumeration definition carries; text would be schema-invalid + if (definition.getDefaultValue() == null || ReqIFConst.ENUMERATION.equals(datatype.getType())) { return null; } if (ReqIFConst.XHTML.equals(datatype.getType())) { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCoverageTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCoverageTest.java index 48ff620..8e22e15 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCoverageTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCoverageTest.java @@ -113,4 +113,19 @@ void writingStaysIdempotentWithTheNewElements() { assertEquals(new ReqIFWriter().toXml(original.getReqIFDocument()), new ReqIFWriter().toXml(roundTripped.getReqIFDocument())); } + + @Test + void onlyTheToolExtensionsOfTheDocumentItselfAreKept(@TempDir Path tempDir) throws Exception { + + // a vendor extension may contain an element named TOOL-EXTENSIONS; it + // belongs to the extension, and the schema allows one TOOL-EXTENSIONS only + String nested = TestFixtures.REQIF_FIXTURE.replace( + "<myTool:settings xmlns:myTool=\"http://example.com/mytool\" view=\"table\"/>", + "<myTool:settings xmlns:myTool=\"http://example.com/mytool\"><TOOL-EXTENSIONS/></myTool:settings>"); + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "nested.reqif", nested).toString()); + + assertEquals(1, reqif.getReqIFDocument().getToolExtensions().size()); + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + assertEquals(xml.indexOf("<TOOL-EXTENSIONS>"), xml.lastIndexOf("<TOOL-EXTENSIONS>"), xml); + } } diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java index 56ce729..9d6861b 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/SchemaConformanceTest.java @@ -65,6 +65,28 @@ void malformedFileIsReportedOnce(@TempDir Path tempDir) throws Exception { assertEquals(1, result.getErrors().size(), result.toString()); } + @Test + void documentAndFileValidationReportTheSameFindings(@TempDir Path tempDir) throws Exception { + + // two objects with the same disallowed XHTML element: on a DOM source + // the findings have no position, but they are still two findings + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .xhtmlDatatype("dt-xhtml", "XHTML") + .specObjectType("st", "Type", t -> t.xhtmlAttribute("ad-text", "Text", "dt-xhtml")) + .specObject("so-1", "st", o -> o.setXhtml("ad-text", "<font>x</font>")) + .specObject("so-2", "st", o -> o.setXhtml("ad-text", "<font>x</font>")) + .build(); + Path file = tempDir.resolve("out.reqif"); + new ReqIFWriter().write(document, file); + + ValidationResult ofDocument = new ReqIFValidator().validateAgainstSchema(document, OfficialReqIFSchema.reqifXsd()); + ValidationResult ofFile = new ReqIFValidator().validateAgainstSchema(file, OfficialReqIFSchema.reqifXsd()); + + assertFalse(ofFile.isValid()); + assertEquals(ofFile.getErrors().size(), ofDocument.getErrors().size(), ofDocument.toString()); + } + @Test void roundTripOfASchemaValidDocumentStaysSchemaValid(@TempDir Path tempDir) throws Exception { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java index 1f8eea5..29c4883 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ValidatorSchemaRulesTest.java @@ -1,6 +1,7 @@ package de.uni_stuttgart.ils.reqif4j; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -11,9 +12,11 @@ import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.specification.RelationGroup; import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; import de.uni_stuttgart.ils.reqif4j.validate.ValidationIssue; import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; /** * The model validator reports what would make the written file @@ -127,6 +130,62 @@ void stringLengthCountsCharactersNotUtf16Units() { assertEquals(List.of(), issues(document), "two emoji are two characters"); } + @Test + void enumerationDefaultGivenAsTextIsAnErrorAndNotWritten() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .enumerationDatatype("dt-enum", "Color", e -> e.value("ev-red", "Red", "1")) + .specObjectType("st", "Type", t -> t.attribute("ad-color", "Color", "dt-enum", "ev-red")) + .build(); + + assertEquals(List.of("ERROR [ad-color]: Default value of an enumeration must be given as enum value " + + "references (an enumeration attribute definition), not as text"), issues(document)); + assertFalse(new ReqIFWriter().toXml(document).contains("<DEFAULT-VALUE>")); + } + + @Test + void malformedXhtmlDefaultIsAnError() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .xhtmlDatatype("dt-xhtml", "XHTML") + .specObjectType("st", "Type", t -> t.attribute("ad-text", "Text", "dt-xhtml", "a < b")) + .build(); + + assertEquals(List.of("ERROR [ad-text]: Default value is not well-formed XHTML"), issues(document)); + } + + @Test + void relationGroupNeedsTypeSourceAndTarget() { + + ReqIFDocument document = document(b -> { }); + document.getCoreContent().addRelationGroup(new RelationGroup("rg-1", "Group", null, null, null, null)); + + assertEquals(List.of( + "ERROR [rg-1]: Relation group has no type reference", + "ERROR [rg-1]: Relation group has no source specification", + "ERROR [rg-1]: Relation group has no target specification"), issues(document)); + } + + @Test + void textXmlCannotHoldIsAnError() { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr").title("bell\u0007")) + .stringDatatype("dt-string", "String", 100) + .specObjectType("st", "Type", t -> t.stringAttribute("ad-name", "Name", "dt-string")) + .specObject("so-1", "st", o -> o.set("ad-name", "a\u0001b")) + .build(); + document.getCoreContent().getSpecObject("so-1").setDesc("lone \uD800"); + + assertEquals(List.of( + "ERROR [hdr]: TITLE contains characters XML does not allow (control characters)", + "ERROR [so-1]: DESC of SPEC-OBJECT contains characters XML does not allow (control characters)", + "ERROR [so-1]: Attribute 'Name' contains characters XML does not allow (control characters)"), + issues(document)); + } + @Test void valuesOutsideTheirDatatypeAreWarnings() throws Exception { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java index 1902ed1..bd10c4b 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/util/XsdTypesTest.java @@ -10,15 +10,31 @@ class XsdTypesTest { @Test void ncNames() { - for (String valid : new String[] { "so-1", "_x", "REQ.1", "Anforderung-ä", "a·b", "_1" }) { + for (String valid : new String[] { "so-1", "_x", "REQ.1", "Anforderung-\u00E4", "a\u00B7b", "_1", "\u03A9mega" }) { assertTrue(XsdTypes.isNCName(valid), valid); } - for (String invalid : new String[] { "", "1-req", "-a", ".a", "a b", "a:b", "a/b", "a#b" }) { + for (String invalid : new String[] { "", "1-req", "-a", ".a", "a b", "a:b", "a/b", "a#b", "\u00E4 b", "\u00E4\u0001" }) { assertFalse(XsdTypes.isNCName(invalid), invalid); } assertFalse(XsdTypes.isNCName(null)); } + @Test + void nonAsciiNamesFollowTheJdkSchemaValidator() { + // Glagolitic was added to Unicode after the name tables the JDK's schema + // validator uses; XML 1.0 fifth edition would allow it + assertFalse(XsdTypes.isNCName("\u2C00x")); + } + + @Test + void xmlText() { + assertTrue(XsdTypes.isXmlText("tab\tnewline\n\u00E4\uD83D\uDE00")); + assertTrue(XsdTypes.isXmlText(null)); + assertFalse(XsdTypes.isXmlText("a\u0001b")); + assertFalse(XsdTypes.isXmlText("lone \uD800 surrogate")); + assertFalse(XsdTypes.isXmlText("\uFFFE")); + } + @Test void dateTimes() { for (String valid : new String[] { "2026-07-23T10:00:00Z", "2026-07-23T10:00:00", "2026-07-23T10:00:00.123+02:00", From e46f28c88d8109311ad05560dadb135d14b22ba0 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 11 Oct 2026 13:36:18 +0000 Subject: [PATCH 08/20] Read back XHTML defaults and xsd boolean "1" The writer emits XHTML default values as markup since the previous commits, but the parser only read defaults from a THE-VALUE attribute, so they were lost on the next read. XHTML defaults are now read from the THE-VALUE element and kept as rendered XHTML, like XHTML values. xsd:boolean allows "1" for true, which the validator accepts, but the parser read it as false. It now reads "1" as true as well. Writer and validator share one ordering of attribute values (AttributeValue.BY_DEFINITION_ID). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- IST-SOLL-ANALYSE.md | 6 +++-- .../attributes/AttributeDefinition.java | 13 ++++++--- .../reqif4j/attributes/AttributeValue.java | 10 +++++++ .../attributes/AttributeValueBoolean.java | 5 +++- .../ils/reqif4j/validate/ReqIFValidator.java | 4 +-- .../ils/reqif4j/write/ReqIFWriter.java | 6 +---- .../ils/reqif4j/RequiredValuesTest.java | 27 +++++++++++++++++++ 7 files changed, 57 insertions(+), 14 deletions(-) diff --git a/IST-SOLL-ANALYSE.md b/IST-SOLL-ANALYSE.md index e69015e..fcc390a 100644 --- a/IST-SOLL-ANALYSE.md +++ b/IST-SOLL-ANALYSE.md @@ -38,7 +38,9 @@ offizielle OMG-Schema. Die Tests prüfen das in der CI gegen das echte XSD. Weitere Funde der Review-Runden in Phase 2: Der Parser übernahm auch verschachtelte `TOOL-EXTENSIONS` aus Vendor-Erweiterungen (jetzt nur direkte Kinder von `REQ-IF`). Unendliche REAL-Werte konnte die Bibliothek nicht mehr einlesen, nachdem sie als `INF` geschrieben wurden (jetzt in beide Richtungen -xsd-Notation). XHTML-Defaults wurden als ungültiges Attribut geschrieben. +xsd-Notation). XHTML-Defaults wurden als ungültiges Attribut geschrieben und nicht gelesen (jetzt beides, +das Lesen ist aus 3.3 vorgezogen). Ein Boolean-Wert `1` (gültiges xsd:boolean) wurde als `false` gelesen. +Offen für 3.3 bleibt: Integer-Werte außerhalb des int-Bereichs. Bekannte Grenzen: IDs mit Nicht-ASCII-Zeichen prüft der Validator mit dem Schemavalidator des JDK, damit beide übereinstimmen. Ein reines Datum (`2026-01-01`) in DATE-Werten liest der Parser weiterhin tolerant @@ -128,7 +130,7 @@ Legende: ✅ unterstützt · ⚠️ teilweise · ❌ fehlt · — nicht anwendba | `ATTRIBUTE-DEFINITION-*` `IS-EDITABLE` | ❌ | ❌ | ❌ | | `ATTRIBUTE-DEFINITION-ENUMERATION` `MULTI-VALUED` (**Pflicht**) | ✅ | ✅ auch `false` *(2.2)* | ✅ | | `DEFAULT-VALUE` (einfache Typen, Enum) | ✅ | ✅ | ✅ | -| `DEFAULT-VALUE` (XHTML) | ❌ *(3.3)* | ✅ als Markup *(2.2)* | ✅ über `attribute(...)` | +| `DEFAULT-VALUE` (XHTML) | ✅ *(2.2, aus 3.3 vorgezogen)* | ✅ als Markup *(2.2)* | ✅ über `attribute(...)` | | `ATTRIBUTE-VALUE-*` (alle 7 Arten) | ✅ | ✅ | ✅ | | `ATTRIBUTE-VALUE-INTEGER` außerhalb des int-Bereichs | ❌ Absturz | — | ⚠️ | | `ATTRIBUTE-VALUE-XHTML` `THE-ORIGINAL-VALUE` / `IS-SIMPLIFIED` | ❌ | ❌ | ❌ | diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java index 0e368c4..fe6c80b 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java @@ -67,9 +67,16 @@ public AttributeDefinition(Node attributeDefinition, Map<String, Datatype> dataT Element defVal = XmlUtils.firstChildElementByLocalName(attributeDefinition, ReqIFConst.DEFAULT_VALUE); if(defVal != null) { - Node attDefVal = XmlUtils.firstChildElement(defVal); - if(attDefVal != null && attDefVal.hasAttributes() && attDefVal.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) != null) { - this.defaultValue = attDefVal.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); + Element attDefVal = XmlUtils.firstChildElement(defVal); + String theValue = XmlUtils.attribute(attDefVal, ReqIFConst.THE_VALUE); + if(theValue != null) { + this.defaultValue = theValue; + + } else if(XmlUtils.firstChildElementByLocalName(attDefVal, ReqIFConst.THE_VALUE) != null) { + // an XHTML default carries its markup in a THE-VALUE element; it is + // kept as rendered XHTML, like the value of an XHTML attribute + String markup = (String) new AttributeValueXHTML(attDefVal, this).getValue(); + this.defaultValue = markup.isEmpty() ? null : markup; } } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java index 5a30cf4..8d79133 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java @@ -1,6 +1,16 @@ package de.uni_stuttgart.ils.reqif4j.attributes; +import java.util.Comparator; + public class AttributeValue { + + /** + * Orders values by the IDENTIFIER of their definition; the writer emits + * them in this order so the output is reproducible. + */ + public static final Comparator<AttributeValue> BY_DEFINITION_ID = Comparator.comparing(value -> + value.getAttributeDefinitionType() == null || value.getAttributeDefinitionType().getID() == null + ? "" : value.getAttributeDefinitionType().getID()); private String name; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueBoolean.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueBoolean.java index 1fa40d9..d5b0eed 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueBoolean.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueBoolean.java @@ -6,7 +6,10 @@ public class AttributeValueBoolean extends AttributeValue { public AttributeValueBoolean(String value, AttributeDefinition type) { super(value, type); - if(value != null && value.toLowerCase().equals("true")) { + // xsd:boolean: "true" or "1" (lower case "true" only, but older versions + // accepted any case) + String trimmed = value == null ? "" : value.trim(); + if(trimmed.equalsIgnoreCase("true") || trimmed.equals("1")) { this.value = true; }else{ this.value = false; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java index e4a705f..fdac656 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java @@ -4,7 +4,6 @@ import java.math.BigInteger; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -341,8 +340,7 @@ private void checkAttributeValues(String ownerID, String specTypeID, ReqIFCoreCo // sorted by definition id like the writer does, so findings come in a // stable order List<AttributeValue> sorted = new ArrayList<AttributeValue>(attributeValues.values()); - sorted.sort(Comparator.comparing(value -> value.getAttributeDefinitionType() == null - || value.getAttributeDefinitionType().getID() == null ? "" : value.getAttributeDefinitionType().getID())); + sorted.sort(AttributeValue.BY_DEFINITION_ID); for (AttributeValue attributeValue : sorted) { AttributeDefinition definition = attributeValue.getAttributeDefinitionType(); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index de3c094..4b78374 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -9,7 +9,6 @@ import java.time.Clock; import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import javax.xml.parsers.ParserConfigurationException; @@ -510,10 +509,7 @@ private Element values(Document xml, java.util.Map<String, AttributeValue> attri List<AttributeValue> sorted = new ArrayList<AttributeValue>(attributeValues.values()); // deterministic output regardless of the map implementation - sorted.sort(Comparator.comparing(value -> { - AttributeDefinition definition = value.getAttributeDefinitionType(); - return definition == null || definition.getID() == null ? "" : definition.getID(); - })); + sorted.sort(AttributeValue.BY_DEFINITION_ID); for (AttributeValue attributeValue : sorted) { Element written = attributeValue(xml, attributeValue); diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java index 2420f31..c9a3e35 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/RequiredValuesTest.java @@ -17,11 +17,13 @@ import org.junit.jupiter.api.io.TempDir; import org.w3c.dom.Document; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueBoolean; import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuildException; import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeDouble; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.specification.SpecType; import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; /** @@ -214,6 +216,31 @@ void xhtmlDefaultValueIsWrittenAsMarkup() throws Exception { OfficialReqIFSchema.assertSchemaValid(document); } + @Test + void xhtmlAndBooleanDefaultsSurviveARoundTrip(@TempDir Path tempDir) throws Exception { + + ReqIFDocument document = ReqIFBuilder.create() + .header(h -> h.id("hdr")) + .xhtmlDatatype("dt-xhtml", "XHTML") + .booleanDatatype("dt-bool", "Bool") + .specObjectType("st", "Type", t -> t + .attribute("ad-text", "Text", "dt-xhtml", "<p>tbd</p>") + .attribute("ad-flag", "Flag", "dt-bool", "1")) + .build(); + Path file = tempDir.resolve("out.reqif"); + new ReqIFWriter().setClock(FIXED).write(document, file); + + ReqIF reqif = new ReqIF(file.toString()); + SpecType type = reqif.getReqIFCoreContent().getSpecType("st"); + + assertEquals("<div><p>tbd</p></div>", type.getAttributeDefinition("ad-text").getDefaultValue()); + assertEquals("1", type.getAttributeDefinition("ad-flag").getDefaultValue()); + // xsd:boolean "1" is true + assertEquals(true, new AttributeValueBoolean("1", type.getAttributeDefinition("ad-flag")).getValue()); + // written again, the default is still there + assertTrue(new ReqIFWriter().toXml(reqif.getReqIFDocument()).contains("<p>tbd</p>")); + } + @Test void outputCarriesNoInternalState() { From 609a005bb8110a4da290f31dca10bbace51f348d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 11 Oct 2026 13:47:30 +0000 Subject: [PATCH 09/20] Keep explicit values by definition and stop writing defaults Attribute values were kept in a HashMap keyed by LONG-NAME and padded with the defaults of all unset attributes. Values of definitions that share a LONG-NAME overwrote each other, document order was lost, and the writer turned every default (and neutral values such as false or 0) into an explicit value. Spec objects, spec relations and specifications now extend SpecElementWithAttributes, as in the ReqIF metamodel. It keeps the values the element carries itself by attribute definition IDENTIFIER, in document order: - getValues() / getValue(definitionID): what the document contains; this is what the writer writes and the validator checks. - getAttributes() / getAttribute(name): the effective values by LONG-NAME, with definition defaults filled in, derived on each call so it never goes stale; an explicit value always wins over a default of the same name. The view is read-only now, because changes through it would not be written. - setValue / removeValue to change values; the content category is derived again afterwards. An explicit empty enumeration selection is written as such; left out, the definition's default would apply on the next read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- README.md | 30 ++- .../reqif4j/attributes/AttributeValue.java | 10 - .../specification/AttributeValueReader.java | 215 +++++++++--------- .../specification/AttributeValues.java | 89 ++++++++ .../SpecElementWithAttributes.java | 111 +++++++++ .../ils/reqif4j/specification/SpecObject.java | 30 ++- .../reqif4j/specification/SpecRelation.java | 5 + .../reqif4j/specification/Specification.java | 20 +- .../ils/reqif4j/validate/ReqIFValidator.java | 15 +- .../ils/reqif4j/write/ReqIFWriter.java | 24 +- .../ils/reqif4j/ExplicitValuesTest.java | 193 ++++++++++++++++ 11 files changed, 562 insertions(+), 180 deletions(-) create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/ExplicitValuesTest.java diff --git a/README.md b/README.md index b1f9c27..cd37ae5 100644 --- a/README.md +++ b/README.md @@ -283,8 +283,34 @@ Round-tripping (parse -> write -> parse) preserves the header, all datatypes including enumerations, spec types with attribute definitions and defaults, spec objects with all attribute value kinds (multiselect enumerations and XHTML included), spec relations with their attributes, -and the specification hierarchy. Attribute values are written sorted by -definition id so the output is reproducible. +and the specification hierarchy. Attribute values are written in +document order. + +## Attribute values + +An element's values are available in two views: + +```java +SpecObject specObject = reqif.getReqIFCoreContent().getSpecObject("so-1"); + +// what the element itself carries - this is what is written back - +// keyed by attribute definition IDENTIFIER, in document order +Map<String, AttributeValue> own = specObject.getValues(); +AttributeValue text = specObject.getValue("ad-text"); + +// effective values by LONG-NAME, including the defaults of attribute +// definitions the element has no value for (read-only) +Object priority = specObject.getAttribute("Priority"); + +// changing values +specObject.setValue(new AttributeValueString("REQ-1b", definition)); +specObject.removeValue("ad-text"); // the default applies again +``` + +Defaults are never turned into values: an attribute the document leaves +unset stays unset when the document is written, and the default keeps +applying through its attribute definition. Prefer `getValues()` when +names may repeat - LONG-NAMEs need not be unique within a spec type. ## Identity and metadata of elements diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java index 8d79133..5a30cf4 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java @@ -1,16 +1,6 @@ package de.uni_stuttgart.ils.reqif4j.attributes; -import java.util.Comparator; - public class AttributeValue { - - /** - * Orders values by the IDENTIFIER of their definition; the writer emits - * them in this order so the output is reproducible. - */ - public static final Comparator<AttributeValue> BY_DEFINITION_ID = Comparator.comparing(value -> - value.getAttributeDefinitionType() == null || value.getAttributeDefinitionType().getID() == null - ? "" : value.getAttributeDefinitionType().getID()); private String name; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java index 91fd4ae..3ab12bc 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java @@ -2,7 +2,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Map; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -23,8 +22,8 @@ /** * Reads the VALUES of an element with attributes - spec objects, spec - * relations and specifications share the same structure - and fills in the - * default values of attribute definitions that carry no explicit value. + * relations and specifications share the same structure - and creates the + * values that stand in for absent ones from the attribute definition defaults. */ final class AttributeValueReader { @@ -32,133 +31,123 @@ private AttributeValueReader() { } /** - * @param element the SPEC-OBJECT, SPEC-RELATION or SPECIFICATION - * @param specType the element's resolved spec type - * @param attributeValues receives the values, keyed by {@link #key} + * Reads the explicit values, the ones the element itself carries. + * + * @param element the SPEC-OBJECT, SPEC-RELATION or SPECIFICATION + * @param specType the element's resolved spec type + * @param values receives the values */ - static void read(Node element, SpecType specType, Map<String, AttributeValue> attributeValues) { + static void read(Node element, SpecType specType, AttributeValues values) { // VALUES is located by local name so prefixed ReqIF namespaces work too; // only this element's own VALUES is taken, not a nested one. Element valuesElement = XmlUtils.firstChildElementByLocalName(element, ReqIFConst.VALUES); - if(valuesElement != null) { - - for(Element attribute: XmlUtils.childElements(valuesElement)) { - - String attValNodeName = XmlUtils.localName(attribute); - String attributeDefinitionRef = XmlUtils.firstChildElement( - XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim(); - AttributeDefinition attributeDefinition = specType.getAttributeDefinition(attributeDefinitionRef); - String attributeDefinitionName = key(attributeDefinition); - - switch(attValNodeName.substring(attValNodeName.lastIndexOf("-")+1)) { - - case ReqIFConst.BOOLEAN: - attributeValues.put(attributeDefinitionName, new AttributeValueBoolean(theValue(attribute), attributeDefinition)); - break; - - case ReqIFConst.INTEGER: - attributeValues.put(attributeDefinitionName, new AttributeValueInteger(theValue(attribute), attributeDefinition)); - break; - - case ReqIFConst.STRING: - attributeValues.put(attributeDefinitionName, new AttributeValueString(theValue(attribute), attributeDefinition)); - break; - - case ReqIFConst.ENUMERATION: - // Multiselect: read ALL ENUM-VALUE-REF children, not just the first - List<String> enumRefs = new ArrayList<String>(); - List<String> enumNames = new ArrayList<String>(); - for(Element ref : XmlUtils.descendantsByLocalName(attribute, ReqIFConst.ENUM_VALUE_REF)) { - String refID = ref.getTextContent().trim(); - enumRefs.add(refID); - enumNames.add(specType.getEnumValueName(refID)); - } - attributeValues.put(attributeDefinitionName, new AttributeValueEnumeration(enumNames, enumRefs, attributeDefinition)); - break; - - case ReqIFConst.XHTML: - attributeValues.put(attributeDefinitionName, new AttributeValueXHTML(attribute, attributeDefinition)); - break; - - case ReqIFConst.DATE: - attributeValues.put(attributeDefinitionName, new AttributeValueDate(theValue(attribute), attributeDefinition)); - break; - - case ReqIFConst.REAL: - attributeValues.put(attributeDefinitionName, new AttributeValueDouble(theValue(attribute), attributeDefinition)); - break; - - // Values of datatype kinds the parser does not model are kept - // generically instead of being dropped, so they survive a - // round trip. - default: - attributeValues.put(attributeDefinitionName, - new AttributeValue(theValue(attribute), attributeDefinition) - .setSourceElementName(attValNodeName)); - break; - } - } + if(valuesElement == null) { + return; } - if(attributeValues.size() < specType.getAttributeDefinitions().size()) { + for(Element attribute: XmlUtils.childElements(valuesElement)) { - for(AttributeDefinition attributeDefinition: specType.getAttributeDefinitions().values()) { + String attValNodeName = XmlUtils.localName(attribute); + String attributeDefinitionRef = XmlUtils.firstChildElement( + XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim(); + AttributeDefinition attributeDefinition = specType.getAttributeDefinition(attributeDefinitionRef); - String key = key(attributeDefinition); - if(!attributeValues.containsKey(key)) { + switch(attValNodeName.substring(attValNodeName.lastIndexOf("-")+1)) { - Datatype datatype = attributeDefinition.getDataType(); + case ReqIFConst.BOOLEAN: + values.put(new AttributeValueBoolean(theValue(attribute), attributeDefinition)); + break; - if(datatype == null) { - throw new ExceptionSpecObject("Attribute definition not existing in ReqIF parser\n", attributeDefinition); - } + case ReqIFConst.INTEGER: + values.put(new AttributeValueInteger(theValue(attribute), attributeDefinition)); + break; - String defaultValue = attributeDefinition.getDefaultValue(); - switch(datatype.getType()) { - - case ReqIFConst.BOOLEAN: - attributeValues.put(key, new AttributeValueBoolean(defaultValue, attributeDefinition)); - break; - - case ReqIFConst.INTEGER: - attributeValues.put(key, new AttributeValueInteger(defaultValue, attributeDefinition)); - break; - - case ReqIFConst.STRING: - attributeValues.put(key, new AttributeValueString(defaultValue, attributeDefinition)); - break; - - case ReqIFConst.ENUMERATION: - if(attributeDefinition instanceof AttributeDefinitionEnumeration) { - AttributeDefinitionEnumeration enumDefinition = (AttributeDefinitionEnumeration) attributeDefinition; - attributeValues.put(key, new AttributeValueEnumeration(enumDefinition.getDefaultValues(), enumDefinition.getDefaultValueRefs(), attributeDefinition)); - }else{ - attributeValues.put(key, new AttributeValueEnumeration(defaultValue, attributeDefinition)); - } - break; - - case ReqIFConst.XHTML: - attributeValues.put(key, new AttributeValueXHTML(defaultValue, attributeDefinition)); - break; - - case ReqIFConst.DATE: - attributeValues.put(key, new AttributeValueDate(defaultValue, attributeDefinition)); - break; - - // the parser normalizes REAL datatypes to DOUBLE - case ReqIFConst.DOUBLE: - attributeValues.put(key, new AttributeValueDouble(defaultValue, attributeDefinition)); - break; - - default: - break; + case ReqIFConst.STRING: + values.put(new AttributeValueString(theValue(attribute), attributeDefinition)); + break; + + case ReqIFConst.ENUMERATION: + // Multiselect: read ALL ENUM-VALUE-REF children, not just the first + List<String> enumRefs = new ArrayList<String>(); + List<String> enumNames = new ArrayList<String>(); + for(Element ref : XmlUtils.descendantsByLocalName(attribute, ReqIFConst.ENUM_VALUE_REF)) { + String refID = ref.getTextContent().trim(); + enumRefs.add(refID); + enumNames.add(specType.getEnumValueName(refID)); } - } + values.put(new AttributeValueEnumeration(enumNames, enumRefs, attributeDefinition)); + break; + + case ReqIFConst.XHTML: + values.put(new AttributeValueXHTML(attribute, attributeDefinition)); + break; + + case ReqIFConst.DATE: + values.put(new AttributeValueDate(theValue(attribute), attributeDefinition)); + break; + + case ReqIFConst.REAL: + values.put(new AttributeValueDouble(theValue(attribute), attributeDefinition)); + break; + + // Values of datatype kinds the parser does not model are kept + // generically instead of being dropped, so they survive a + // round trip. + default: + values.put(new AttributeValue(theValue(attribute), attributeDefinition) + .setSourceElementName(attValNodeName)); + break; } } } + /** + * @return the value that stands in for an absent one: the definition's + * default, or the neutral value of its datatype (false, 0, ...) as + * earlier versions reported it; null for datatypes without one + */ + static AttributeValue defaultValue(AttributeDefinition attributeDefinition) { + + Datatype datatype = attributeDefinition.getDataType(); + if(datatype == null) { + return null; + } + String defaultValue = attributeDefinition.getDefaultValue(); + switch(datatype.getType()) { + + case ReqIFConst.BOOLEAN: + return new AttributeValueBoolean(defaultValue, attributeDefinition); + + case ReqIFConst.INTEGER: + return new AttributeValueInteger(defaultValue, attributeDefinition); + + case ReqIFConst.STRING: + return new AttributeValueString(defaultValue, attributeDefinition); + + case ReqIFConst.ENUMERATION: + if(attributeDefinition instanceof AttributeDefinitionEnumeration) { + AttributeDefinitionEnumeration enumDefinition = (AttributeDefinitionEnumeration) attributeDefinition; + return new AttributeValueEnumeration(enumDefinition.getDefaultValues(), enumDefinition.getDefaultValueRefs(), attributeDefinition); + } + return new AttributeValueEnumeration(defaultValue, attributeDefinition); + + case ReqIFConst.XHTML: + return new AttributeValueXHTML(defaultValue, attributeDefinition); + + case ReqIFConst.DATE: + return new AttributeValueDate(defaultValue, attributeDefinition); + + // the parser normalizes REAL datatypes to DOUBLE + case ReqIFConst.DOUBLE: + case ReqIFConst.REAL: + return new AttributeValueDouble(defaultValue, attributeDefinition); + + default: + return null; + } + } + /** * Attribute values are kept by the LONG-NAME of their definition. LONG-NAME * is optional, though; definitions without one are keyed by their diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java new file mode 100644 index 0000000..d1f7d0d --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java @@ -0,0 +1,89 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; + +/** + * The attribute values of a spec object, spec relation or specification. + * + * Two views are kept apart: + * + * <ul> + * <li><em>explicit</em> values are the ones the element carries itself - + * exactly what the document contains and what is written back - keyed by the + * IDENTIFIER of their attribute definition, in document order;</li> + * <li><em>effective</em> values additionally fill in the default value of + * every attribute definition the element has no value for. They are keyed by + * LONG-NAME for convenient lookup and only ever derived, never written.</li> + * </ul> + */ +final class AttributeValues { + + private final SpecType specType; + private final Map<String, AttributeValue> explicit = new LinkedHashMap<String, AttributeValue>(); + private final Map<String, AttributeValue> explicitView = Collections.unmodifiableMap(this.explicit); + + AttributeValues(SpecType specType) { + this.specType = specType; + } + + /** + * Adds an explicit value; a second value for the same definition replaces + * the first. + */ + void put(AttributeValue value) { + this.explicit.put(value.getAttributeDefinitionType().getID(), value); + } + + AttributeValue remove(String attributeDefinitionID) { + return this.explicit.remove(attributeDefinitionID); + } + + /** + * @return the explicit values by attribute definition IDENTIFIER, in + * document order (read-only) + */ + Map<String, AttributeValue> explicit() { + return this.explicitView; + } + + /** + * @return the effective values by LONG-NAME (IDENTIFIER for definitions + * without one), in spec type order: explicit values plus the + * defaults of all other definitions. An explicit value always wins + * over a default of the same name. Derived on every call, so it + * follows later changes of the values or the spec type. + */ + Map<String, AttributeValue> effectiveByName() { + + Map<String, AttributeValue> effective = new LinkedHashMap<String, AttributeValue>(); + Map<String, AttributeValue> undeclared = new LinkedHashMap<String, AttributeValue>(this.explicit); + Set<String> explicitNames = new HashSet<String>(); + for (AttributeValue value : this.explicit.values()) { + explicitNames.add(AttributeValueReader.key(value.getAttributeDefinitionType())); + } + + if (this.specType != null) { + for (AttributeDefinition definition : this.specType.getAttributeDefinitions().values()) { + String name = AttributeValueReader.key(definition); + AttributeValue value = undeclared.remove(definition.getID()); + if (value == null && !explicitNames.contains(name)) { + value = AttributeValueReader.defaultValue(definition); + } + if (value != null) { + effective.putIfAbsent(name, value); + } + } + } + for (AttributeValue value : undeclared.values()) { + effective.putIfAbsent(AttributeValueReader.key(value.getAttributeDefinitionType()), value); + } + return Collections.unmodifiableMap(effective); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java new file mode 100644 index 0000000..09d9cdd --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java @@ -0,0 +1,111 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import java.util.Map; + +import org.w3c.dom.Node; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; + +/** + * An element that carries attribute values: spec objects, spec relations and + * specifications, as in the ReqIF metamodel. + * + * Values are available in two views. {@link #getValues()} holds what the + * element itself carries - exactly what the document contains and what is + * written back. {@link #getAttributes()} adds the defaults of all attribute + * definitions the element has no value for, keyed by LONG-NAME for + * convenient lookup. + */ +public abstract class SpecElementWithAttributes extends Identifiable { + + private AttributeValues values = new AttributeValues(null); + + + protected SpecElementWithAttributes(String id, String name) { + super(id, name); + } + + protected SpecElementWithAttributes(Node element) { + super(element); + } + + + /** + * @return the values this element carries itself, by IDENTIFIER of their + * attribute definition, in document order (read-only). These are + * the values that are written; definition defaults are not + * included. + */ + public Map<String, AttributeValue> getValues() { + return this.values.explicit(); + } + + /** + * @return the value this element carries for the attribute definition, or + * null if it has none (the definition's default then applies) + */ + public AttributeValue getValue(String attributeDefinitionID) { + return this.values.explicit().get(attributeDefinitionID); + } + + /** + * Sets a value of this element, replacing the one it has for the same + * attribute definition. + */ + public SpecElementWithAttributes setValue(AttributeValue value) { + if (value == null || value.getAttributeDefinitionType() == null) { + throw new IllegalArgumentException("A value needs an attribute definition"); + } + this.values.put(value); + valuesChanged(); + return this; + } + + /** + * Removes the element's own value for the attribute definition, so its + * default applies again. + * + * @return the removed value, or null if the element had none + */ + public AttributeValue removeValue(String attributeDefinitionID) { + AttributeValue removed = this.values.remove(attributeDefinitionID); + valuesChanged(); + return removed; + } + + /** + * @return the effective values by attribute LONG-NAME: the element's own + * values plus the defaults of all other attribute definitions of + * its type (read-only). A convenience view: LONG-NAMEs need not be + * unique, so prefer {@link #getValues()} to see what the document + * contains. + */ + public Map<String, AttributeValue> getAttributes() { + return this.values.effectiveByName(); + } + + /** + * @return the effective value of the attribute with this LONG-NAME, or null + */ + public Object getAttribute(String attributeName) { + AttributeValue attributeValue = getAttributes().get(attributeName); + return attributeValue == null ? null : attributeValue.getValue(); + } + + + /** + * Starts a new set of values for the given spec type, e.g. before reading + * them from a document. + */ + AttributeValues resetValues(SpecType specType) { + this.values = new AttributeValues(specType); + return this.values; + } + + /** + * Called after a value was set or removed. + */ + protected void valuesChanged() { + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java index 9b9bbe7..cf5b772 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java @@ -1,22 +1,19 @@ package de.uni_stuttgart.ils.reqif4j.specification; import java.util.Collection; -import java.util.HashMap; import java.util.Map; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; import org.w3c.dom.Node; -import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; -public class SpecObject extends Identifiable { +public class SpecObject extends SpecElementWithAttributes { protected SpecType specType; protected String type; protected TypeClassifier typeClassifier = TypeClassifier.defaultClassifier(); - protected Map<String, AttributeValue> attributeValues = new HashMap<String, AttributeValue>(); @@ -41,14 +38,6 @@ public String getSpecTypeID() { return this.specType == null ? null : this.specType.getID(); } - public Map<String, AttributeValue> getAttributes() { - return this.attributeValues; - } - - public Object getAttribute(String attributeName) { - AttributeValue attributeValue = this.attributeValues.get(attributeName); - return attributeValue == null ? null : attributeValue.getValue(); - } public boolean isReq() { return this.typeClassifier.isRequirement(this); @@ -93,9 +82,10 @@ public SpecObject(String id, SpecType specType, Collection<AttributeValue> attri this.specType = specType; this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; + AttributeValues values = resetValues(specType); if(attributeValues != null) { for(AttributeValue attributeValue: attributeValues) { - this.attributeValues.put(AttributeValueReader.key(attributeValue.getAttributeDefinitionType()), attributeValue); + values.put(attributeValue); } } this.type = this.typeClassifier.classify(this); @@ -124,12 +114,20 @@ public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassif } /** - * Reads all attribute values of the given node and fills in default values - * for attribute definitions that carry no explicit value. Shared by + * The content category may depend on the values (attribute-based + * classifiers), so it is derived again. + */ + @Override + protected void valuesChanged() { + this.type = this.typeClassifier.classify(this); + } + + /** + * Reads the attribute values of the given node. Shared by * {@link SpecObject} and {@link SpecRelation}. */ protected void readAttributeValues(Node specObject, SpecType specType) { - AttributeValueReader.read(specObject, specType, this.attributeValues); + AttributeValueReader.read(specObject, specType, resetValues(specType)); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java index 3a2a053..76e2c57 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java @@ -87,6 +87,11 @@ public String getRelationTypeName() { return this.specType == null ? null : this.specType.getName(); } + // A relation has no content category, whatever its values. + @Override + protected void valuesChanged() { + } + // A relation is none of the content categories. @Override diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java index 1278b71..9573b45 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java @@ -1,7 +1,6 @@ package de.uni_stuttgart.ils.reqif4j.specification; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -10,17 +9,15 @@ import org.w3c.dom.Node; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; -import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; -public class Specification extends Identifiable { +public class Specification extends SpecElementWithAttributes { private SpecType type; private int numberOfSpecObjects; private int sectionCounter = 0; - private Map<String, AttributeValue> attributeValues = new HashMap<String, AttributeValue>(); private Map<String, SpecHierarchy> children = new LinkedHashMap<String, SpecHierarchy>(); private List<SpecHierarchy> allSpecHierarchies = new ArrayList<SpecHierarchy>(); @@ -33,7 +30,7 @@ public class Specification extends Identifiable { * specification has no such attribute */ public String getDescription() { - AttributeValue description = this.attributeValues.get("Description"); + AttributeValue description = getAttributes().get("Description"); return description == null ? null : (String) description.getValue(); } @@ -54,14 +51,6 @@ public String getSpecTypeID() { return this.type == null ? null : this.type.getID(); } - public Map<String, AttributeValue> getAttributes() { - return this.attributeValues; - } - - public Object getAttribute(String attributeName) { - AttributeValue attributeValue = this.attributeValues.get(attributeName); - return attributeValue == null ? null : attributeValue.getValue(); - } public int getNumberOfSpecObjects() { return this.numberOfSpecObjects; @@ -103,9 +92,10 @@ public Specification(String id, String name, SpecType specType, this.type = specType; + AttributeValues values = resetValues(specType); if(attributeValues != null) { for(AttributeValue attributeValue: attributeValues) { - this.attributeValues.put(AttributeValueReader.key(attributeValue.getAttributeDefinitionType()), attributeValue); + values.put(attributeValue); } } @@ -130,7 +120,7 @@ public Specification(Node specification, SpecType specType, Map<String, SpecObje this.type = specType; - AttributeValueReader.read(specification, specType, this.attributeValues); + AttributeValueReader.read(specification, specType, resetValues(specType)); Element childrenElement = XmlUtils.firstChildElementByLocalName(specification, ReqIFConst.CHILDREN); if(childrenElement != null) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java index fdac656..40a9e98 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java @@ -3,7 +3,6 @@ import java.io.IOException; import java.math.BigInteger; import java.nio.file.Path; -import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -242,7 +241,7 @@ private void checkSpecObjects(ReqIFCoreContent content, ValidationResult result, checkIdentifiable(specObject, "SPEC-OBJECT", result, identifiers); checkSpecTypeRef(content, specObject.getID(), specObject.getSpecTypeID(), "SPEC-OBJECT", result); checkAttributeValues(specObject.getID(), specObject.getSpecTypeID(), content, - specObject.getAttributes(), result); + specObject.getValues(), result); } } @@ -262,7 +261,7 @@ private void checkSpecRelations(ReqIFCoreContent content, ValidationResult resul + " is not a known spec object"); } checkAttributeValues(relation.getID(), relation.getRelationTypeRef(), content, - relation.getAttributes(), result); + relation.getValues(), result); } } @@ -273,7 +272,7 @@ private void checkSpecifications(ReqIFCoreContent content, ValidationResult resu checkIdentifiable(specification, "SPECIFICATION", result, identifiers); checkSpecTypeRef(content, specification.getID(), specification.getSpecTypeID(), "SPECIFICATION", result); checkAttributeValues(specification.getID(), specification.getSpecTypeID(), content, - specification.getAttributes(), result); + specification.getValues(), result); for (SpecHierarchy hierarchy : specification.getAllSpecHierarchies()) { checkIdentifiable(hierarchy, "SPEC-HIERARCHY", result, identifiers); @@ -337,12 +336,8 @@ private void checkAttributeValues(String ownerID, String specTypeID, ReqIFCoreCo return; } - // sorted by definition id like the writer does, so findings come in a - // stable order - List<AttributeValue> sorted = new ArrayList<AttributeValue>(attributeValues.values()); - sorted.sort(AttributeValue.BY_DEFINITION_ID); - - for (AttributeValue attributeValue : sorted) { + // the explicit values, the ones that are written, in document order + for (AttributeValue attributeValue : attributeValues.values()) { AttributeDefinition definition = attributeValue.getAttributeDefinitionType(); if (definition == null) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index 4b78374..35f0ded 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -8,7 +8,6 @@ import java.nio.file.Path; import java.time.Clock; import java.time.temporal.ChronoUnit; -import java.util.ArrayList; import java.util.List; import javax.xml.parsers.ParserConfigurationException; @@ -448,7 +447,7 @@ private Element specObject(Document xml, SpecObject specObject) { Element element = element(xml, ReqIFConst.SPEC_OBJECT); identifiable(xml, element, specObject); - element.appendChild(values(xml, specObject.getAttributes())); + element.appendChild(values(xml, specObject.getValues())); element.appendChild(typeRef(xml, ReqIFConst.SPEC_OBJECT_TYPE, specObject.getSpecTypeID())); return element; } @@ -457,7 +456,7 @@ private Element specRelation(Document xml, SpecRelation specRelation) { Element element = element(xml, ReqIFConst.SPEC_RELATION); identifiable(xml, element, specRelation); - element.appendChild(values(xml, specRelation.getAttributes())); + element.appendChild(values(xml, specRelation.getValues())); element.appendChild(typeRef(xml, ReqIFConst.SPEC_RELATION_TYPE, specRelation.getRelationTypeRef())); element.appendChild(objectRef(xml, ReqIFConst.SOURCE, specRelation.getSourceObjID())); element.appendChild(objectRef(xml, ReqIFConst.TARGET, specRelation.getTargetObjID())); @@ -468,7 +467,7 @@ private Element specification(Document xml, Specification specification) { Element element = element(xml, ReqIFConst.SPECIFICATION); identifiable(xml, element, specification); - element.appendChild(values(xml, specification.getAttributes())); + element.appendChild(values(xml, specification.getValues())); element.appendChild(typeRef(xml, ReqIFConst.SPECIFICATION_TYPE, specification.getSpecTypeID())); List<SpecHierarchy> children = specification.getChildren(); @@ -507,11 +506,9 @@ private Element values(Document xml, java.util.Map<String, AttributeValue> attri Element values = element(xml, ReqIFConst.VALUES); - List<AttributeValue> sorted = new ArrayList<AttributeValue>(attributeValues.values()); - // deterministic output regardless of the map implementation - sorted.sort(AttributeValue.BY_DEFINITION_ID); - - for (AttributeValue attributeValue : sorted) { + // only the element's own values, in document order; defaults stay with + // their attribute definitions + for (AttributeValue attributeValue : attributeValues.values()) { Element written = attributeValue(xml, attributeValue); if (written != null) { values.appendChild(written); @@ -532,11 +529,10 @@ private Element attributeValue(Document xml, AttributeValue attributeValue) { if (!(attributeValue instanceof AttributeValueEnumeration)) { return null; } - List<String> refs = ((AttributeValueEnumeration) attributeValue).getValueRefs(); - if (refs.isEmpty()) { - return null; - } - return enumerationValue(xml, definition, refs); + // an empty selection is a value too ("nothing selected"); left out, the + // definition's default would apply when the document is read again + return enumerationValue(xml, definition, + ((AttributeValueEnumeration) attributeValue).getValueRefs()); } if (ReqIFConst.XHTML.equals(datatypeCategory)) { diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ExplicitValuesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ExplicitValuesTest.java new file mode 100644 index 0000000..22f8cf0 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ExplicitValuesTest.java @@ -0,0 +1,193 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueString; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.specification.ReqIFImplementationGuideClassifier; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * Values are kept as the document has them - by attribute definition, in + * document order - and defaults are not turned into values on the way out. + */ +class ExplicitValuesTest { + + @Test + void explicitValuesAreKeptByDefinitionInDocumentOrder(@TempDir Path tempDir) throws Exception { + + SpecObject boot = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF).getReqIFCoreContent().getSpecObject("so-1"); + + assertEquals(List.of("ad-id", "ad-text", "ad-prio", "ad-safety", "ad-due", "ad-status", "ad-review", "ad-effort"), + new ArrayList<>(boot.getValues().keySet())); + assertEquals("REQ-1", boot.getValue("ad-id").getValue()); + } + + @Test + void defaultsAreEffectiveButNotExplicit(@TempDir Path tempDir) throws Exception { + + SpecObject withoutPriority = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF).getReqIFCoreContent().getSpecObject("so-2"); + + assertNull(withoutPriority.getValue("ad-prio"), "so-2 has no own Priority value"); + assertEquals(3, withoutPriority.getAttribute("Priority"), "the definition's default applies"); + assertEquals(List.of("ad-id", "ad-safety"), new ArrayList<>(withoutPriority.getValues().keySet())); + } + + @Test + void defaultsAreNotWrittenAsValues(@TempDir Path tempDir) throws Exception { + + ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + + // so-2 carries exactly its two own values, not the Priority default 3 + // or invented ones such as false for unset booleans + String so2 = xml.substring(xml.indexOf("IDENTIFIER=\"so-2\""), xml.indexOf("</SPEC-OBJECT>", xml.indexOf("IDENTIFIER=\"so-2\""))); + assertEquals(List.of("ad-id", "ad-safety"), definitionRefs(so2)); + + // re-read, the default still applies through the definition + ReqIF roundTripped = parse(tempDir, xml); + assertEquals(3, roundTripped.getReqIFCoreContent().getSpecObject("so-2").getAttribute("Priority")); + } + + @Test + void valuesAreWrittenInDocumentOrder(@TempDir Path tempDir) throws Exception { + + ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + + String so1 = xml.substring(xml.indexOf("IDENTIFIER=\"so-1\""), xml.indexOf("</SPEC-OBJECT>")); + assertEquals(List.of("ad-id", "ad-text", "ad-prio", "ad-safety", "ad-due", "ad-status", "ad-review", "ad-effort"), + definitionRefs(so1)); + } + + @Test + void valuesOfDefinitionsSharingALongNameAreAllKept(@TempDir Path tempDir) throws Exception { + + // DOORS exports regularly reuse attribute names; keyed by name, one of + // the two values used to be lost + String sameName = TestFixtures.SCHEMA_VALID_REQIF.replace("LONG-NAME=\"Safety relevant\"", "LONG-NAME=\"ReqIF.ForeignID\""); + ReqIF reqif = parse(tempDir, sameName); + SpecObject boot = reqif.getReqIFCoreContent().getSpecObject("so-1"); + + assertEquals("REQ-1", boot.getValue("ad-id").getValue()); + assertEquals(true, boot.getValue("ad-safety").getValue()); + + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + assertTrue(xml.contains("<ATTRIBUTE-VALUE-STRING THE-VALUE=\"REQ-1\">"), xml); + assertTrue(xml.contains("<ATTRIBUTE-VALUE-BOOLEAN THE-VALUE=\"true\">"), xml); + } + + @Test + void theNameViewIsReadOnly(@TempDir Path tempDir) throws Exception { + + SpecObject boot = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF).getReqIFCoreContent().getSpecObject("so-1"); + AttributeValue any = boot.getValue("ad-id"); + + // a change through this view would not be written, so it is refused + assertThrows(UnsupportedOperationException.class, () -> boot.getAttributes().put("x", any)); + assertThrows(UnsupportedOperationException.class, () -> boot.getValues().remove("ad-id")); + assertFalse(boot.getAttributes().isEmpty()); + } + + @Test + void anExplicitValueIsNotHiddenByADefaultOfTheSameName(@TempDir Path tempDir) throws Exception { + + // ad-prio (default 3, no value on so-1 once its value is removed) and + // ad-id share a name; so-1's explicit ad-id value must win + String sameName = TestFixtures.SCHEMA_VALID_REQIF.replace("LONG-NAME=\"Priority\"", "LONG-NAME=\"ReqIF.ForeignID\""); + SpecObject boot = parse(tempDir, sameName).getReqIFCoreContent().getSpecObject("so-1"); + boot.removeValue("ad-prio"); + + assertEquals("REQ-1", boot.getAttribute("ReqIF.ForeignID")); + } + + @Test + void anEmptyEnumerationSelectionSurvivesARoundTrip(@TempDir Path tempDir) throws Exception { + + // "nothing selected" on so-1, while the definition defaults to Draft + String withDefault = TestFixtures.SCHEMA_VALID_REQIF + .replace("<VALUES><ENUM-VALUE-REF>ev-draft</ENUM-VALUE-REF></VALUES>", "<VALUES/>") + .replace("LONG-NAME=\"Review state\" MULTI-VALUED=\"false\">", + "LONG-NAME=\"Review state\" MULTI-VALUED=\"false\">" + + "<DEFAULT-VALUE><ATTRIBUTE-VALUE-ENUMERATION>" + + "<DEFINITION><ATTRIBUTE-DEFINITION-ENUMERATION-REF>ad-review</ATTRIBUTE-DEFINITION-ENUMERATION-REF></DEFINITION>" + + "<VALUES><ENUM-VALUE-REF>ev-draft</ENUM-VALUE-REF></VALUES>" + + "</ATTRIBUTE-VALUE-ENUMERATION></DEFAULT-VALUE>"); + ReqIF reqif = parse(tempDir, withDefault); + assertEquals(List.of(), reviewState(reqif, "so-1")); + assertEquals(List.of("ev-draft"), reviewState(reqif, "so-2"), "so-2 gets the default"); + + ReqIF roundTripped = parse(tempDir, new ReqIFWriter().toXml(reqif.getReqIFDocument())); + + assertEquals(List.of(), reviewState(roundTripped, "so-1"), "the empty selection must not turn into the default"); + OfficialReqIFSchema.assertSchemaValid(reqif.getReqIFDocument()); + } + + @Test + void valuesCanBeSetAndRemoved(@TempDir Path tempDir) throws Exception { + + ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF); + SpecObject draft = reqif.getReqIFCoreContent().getSpecObject("so-2"); + AttributeValue foreignID = new AttributeValueString("REQ-2b", + reqif.getReqIFCoreContent().getSpecType("st-req").getAttributeDefinition("ad-id")); + + draft.setValue(foreignID); + draft.removeValue("ad-safety"); + + assertEquals(List.of("ad-id"), new ArrayList<>(draft.getValues().keySet())); + assertEquals("REQ-2b", draft.getAttribute("ReqIF.ForeignID")); + String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + assertTrue(xml.contains("<ATTRIBUTE-VALUE-STRING THE-VALUE=\"REQ-2b\">"), xml); + } + + @Test + void theContentCategoryFollowsTheValues(@TempDir Path tempDir) throws Exception { + + // attribute-based classification: ReqIF.ChapterName makes a headline + String chapter = TestFixtures.SCHEMA_VALID_REQIF.replace("LONG-NAME=\"ReqIF.ForeignID\"", "LONG-NAME=\"ReqIF.ChapterName\""); + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "chapter.reqif", chapter).toString(), + new ReqIFImplementationGuideClassifier()); + SpecObject boot = reqif.getReqIFCoreContent().getSpecObject("so-1"); + assertTrue(boot.isHeadline()); + + boot.removeValue("ad-id"); + + assertFalse(boot.isHeadline(), "without a chapter name it is no headline any more"); + } + + private static List<String> reviewState(ReqIF reqif, String specObjectID) { + return ((AttributeValueEnumeration) reqif.getReqIFCoreContent().getSpecObject(specObjectID) + .getAttributes().get("Review state")).getValueRefs(); + } + + private static ReqIF parse(Path dir, String content) throws Exception { + return new ReqIF(TestFixtures.write(dir, "doc-" + System.nanoTime() + ".reqif", content).toString()); + } + + private static List<String> definitionRefs(String xml) { + List<String> refs = new ArrayList<>(); + Matcher matcher = Pattern.compile("<ATTRIBUTE-DEFINITION-[A-Z]+-REF>([^<]+)<").matcher(xml); + while (matcher.find()) { + refs.add(matcher.group(1)); + } + return refs; + } +} From 25ed0662ee40727393deca9e923238f740aedea9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 11 Oct 2026 13:57:04 +0000 Subject: [PATCH 10/20] Read and write every part of the ReqIF schema The parser dropped several parts of the schema, so they were lost on a round trip. They are now modelled, read and written: - integer values of any size (xsd:integer); getValue() stays an Integer while the value fits, getBigInteger() is always exact; - xml:lang of the document and REPOSITORY-ID of the header; - IS-EDITABLE of attribute definitions; IS-EDITABLE, IS-TABLE-INTERNAL and EDITABLE-ATTS of spec hierarchy nodes; - THE-ORIGINAL-VALUE and IS-SIMPLIFIED of XHTML values and defaults (e.g. an embedded OLE object of which THE-VALUE only holds a preview), and XHTML content made of a single p instead of a div; - the OBJECT reference of a hierarchy node, also when it cannot be resolved, so it is written back and the validator names it. An xsd:boolean flag that is not true/false/1/0 is read as unknown instead of being flipped to false. LosslessRoundTripTest reads a schema-valid document that uses every element and attribute of the schema, writes it and compares both with CanonicalXml, which ignores only what the schema leaves open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- README.md | 17 +- .../attributes/AttributeDefinition.java | 34 +++- .../attributes/AttributeValueInteger.java | 49 +++++- .../attributes/AttributeValueXHTML.java | 48 +++++- .../ils/reqif4j/build/ReqIFBuilder.java | 9 +- .../ils/reqif4j/reqif/ReqIFConst.java | 6 + .../ils/reqif4j/reqif/ReqIFDocument.java | 26 ++- .../ils/reqif4j/reqif/ReqIFHeader.java | 15 ++ .../specification/AttributeValueReader.java | 4 +- .../reqif4j/specification/SpecHierarchy.java | 75 +++++++- .../ils/reqif4j/util/XmlUtils.java | 23 +++ .../ils/reqif4j/validate/ReqIFValidator.java | 8 +- .../ils/reqif4j/write/ReqIFWriter.java | 38 +++- .../ils/reqif4j/CanonicalXml.java | 132 ++++++++++++++ .../ils/reqif4j/LosslessRoundTripTest.java | 57 ++++++ .../ils/reqif4j/ModelCompletenessTest.java | 142 +++++++++++++++ src/test/resources/full-feature.reqif | 163 ++++++++++++++++++ 17 files changed, 808 insertions(+), 38 deletions(-) create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/CanonicalXml.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/LosslessRoundTripTest.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java create mode 100644 src/test/resources/full-feature.reqif diff --git a/README.md b/README.md index cd37ae5..4e49327 100644 --- a/README.md +++ b/README.md @@ -279,12 +279,17 @@ new ReqIFWriter().write(reqif.getReqIFDocument(), Path.of("out.reqif")); String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument()); ``` -Round-tripping (parse -> write -> parse) preserves the header, all -datatypes including enumerations, spec types with attribute definitions -and defaults, spec objects with all attribute value kinds (multiselect -enumerations and XHTML included), spec relations with their attributes, -and the specification hierarchy. Attribute values are written in -document order. +Round-tripping (parse -> write) is lossless: every element and attribute +of the ReqIF schema is read into the model and written back, including +`xml:lang`, the header's `REPOSITORY-ID`, `IS-EDITABLE` of attribute +definitions and hierarchy nodes, `IS-TABLE-INTERNAL` and `EDITABLE-ATTS`, +and XHTML values with `THE-ORIGINAL-VALUE` / `IS-SIMPLIFIED` (e.g. an +embedded OLE object of which `THE-VALUE` only shows the preview). +`LosslessRoundTripTest` checks this with a document that uses all of +them; the comparison ignores only what the schema leaves open (namespace +prefixes, attribute order, the order of xsd:all children, formatting). +Attribute values are written in document order. Integer values have no +size limit (`AttributeValueInteger.getBigInteger()`). ## Attribute values diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java index fe6c80b..2f64a69 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java @@ -17,6 +17,8 @@ public class AttributeDefinition extends Identifiable { private Datatype type; private String defaultValue; private String sourceElementName; + private Boolean editable; + private AttributeValueXHTML defaultXhtml; @@ -29,6 +31,28 @@ public String getDefaultValue() { return this.defaultValue; } + /** + * @return the default of an XHTML attribute as read from the document, or + * null if there is none or the definition was not read from a + * document (then {@link #getDefaultValue()} holds the markup) + */ + public AttributeValueXHTML getDefaultXhtmlValue() { + return this.defaultXhtml; + } + + /** + * @return IS-EDITABLE: whether values of this attribute may be edited in + * the receiving tool, or null if the document does not say + */ + public Boolean getEditable() { + return this.editable; + } + + public AttributeDefinition setEditable(Boolean editable) { + this.editable = editable; + return this; + } + /** * @return the ATTRIBUTE-DEFINITION-* element name this definition was read * from, or null when it was not created from a document @@ -55,6 +79,7 @@ public AttributeDefinition(Node attributeDefinition, Map<String, Datatype> dataT super(attributeDefinition); this.sourceElementName = XmlUtils.localName(attributeDefinition); + this.editable = XmlUtils.booleanAttribute(attributeDefinition, ReqIFConst.IS_EDITABLE); // Navigate by element (not by fixed child index) so both pretty-printed // and minified ReqIF files are handled. @@ -73,9 +98,12 @@ public AttributeDefinition(Node attributeDefinition, Map<String, Datatype> dataT this.defaultValue = theValue; } else if(XmlUtils.firstChildElementByLocalName(attDefVal, ReqIFConst.THE_VALUE) != null) { - // an XHTML default carries its markup in a THE-VALUE element; it is - // kept as rendered XHTML, like the value of an XHTML attribute - String markup = (String) new AttributeValueXHTML(attDefVal, this).getValue(); + // an XHTML default carries its markup in a THE-VALUE element. The + // parsed value is kept, so it is written back as it was (a p + // instead of a div, THE-ORIGINAL-VALUE); getDefaultValue() offers + // it as rendered XHTML. + this.defaultXhtml = new AttributeValueXHTML(attDefVal, this); + String markup = (String) this.defaultXhtml.getValue(); this.defaultValue = markup.isEmpty() ? null : markup; } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueInteger.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueInteger.java index b7b24ee..612854a 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueInteger.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueInteger.java @@ -1,22 +1,53 @@ package de.uni_stuttgart.ils.reqif4j.attributes; +import java.math.BigInteger; + +/** + * Value of an INTEGER attribute. ReqIF declares it as {@code xsd:integer}, + * which has no size limit, and DOORS regularly uses values beyond the int + * range. + * + * {@link #getValue()} returns an {@link Integer} when the value fits, so + * existing code comparing with int values keeps working, a {@link Long} when + * it fits that, and a {@link BigInteger} otherwise. {@link #getBigInteger()} + * always returns the exact value. + */ public class AttributeValueInteger extends AttributeValue { - - + + private final BigInteger integer; + public AttributeValueInteger(String value, AttributeDefinition type) { super(value, type); // Missing THE-VALUE reaches this constructor as null or "", both of // which must not crash the parser. - if(value == null || value.isBlank()){ - this.value = 0; - }else { - this.value = Integer.parseInt(value.trim()); - } + this.integer = value == null || value.isBlank() ? BigInteger.ZERO : new BigInteger(value.trim()); + this.value = narrowest(this.integer); } + /** + * @return the value as Integer, Long or BigInteger, whichever is the + * smallest type that holds it + */ @Override public Object getValue() { - return (int)this.value; + return this.value; + } + + /** + * @return the exact value + */ + public BigInteger getBigInteger() { + return this.integer; + } + + private static Number narrowest(BigInteger value) { + // bitLength excludes the sign bit + if (value.bitLength() < Integer.SIZE) { + return value.intValue(); + } + if (value.bitLength() < Long.SIZE) { + return value.longValue(); + } + return value; } - } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java index 85b5c93..e1fe5d9 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java @@ -4,8 +4,10 @@ import java.util.Collections; import java.util.List; +import org.w3c.dom.Element; import org.w3c.dom.Node; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XhtmlParser; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElement; @@ -59,6 +61,8 @@ public class AttributeValueXHTML extends AttributeValue { private static final String VARIABLE_NAME_MISSING = "VARIABLE_NAME_MISSING"; XHTMLElementDiv divValue; + private XHTMLElementDiv originalValue; + private Boolean simplified; @Override public Object getValue() { @@ -68,16 +72,48 @@ public Object getValue() { return divValue.toString(); } + /** + * @param xhtmlContent the ATTRIBUTE-VALUE-XHTML element; for compatibility a + * node that merely contains the XHTML div is accepted too + */ public AttributeValueXHTML(Node xhtmlContent, AttributeDefinition type) { super(type); - // The div may carry a namespace prefix (xhtml:div, reqif-xhtml:div, ...), - // so it has to be located by local name. - Node div = XmlUtils.firstDescendantByLocalName(xhtmlContent, XHTML.DIV); - this.divValue = div == null ? null : new XHTMLElementDiv(div); + Element theValue = XmlUtils.firstChildElementByLocalName(xhtmlContent, ReqIFConst.THE_VALUE); + Node content; + if(theValue != null) { + // THE-VALUE holds a div or, as the schema allows, a single p + content = XmlUtils.firstChildElement(theValue); + Element original = XmlUtils.firstChildElement( + XmlUtils.firstChildElementByLocalName(xhtmlContent, ReqIFConst.THE_ORIGINAL_VALUE)); + this.originalValue = original == null ? null : new XHTMLElementDiv(original); + this.simplified = XmlUtils.booleanAttribute(xhtmlContent, ReqIFConst.IS_SIMPLIFIED); + } else { + // The div may carry a namespace prefix (xhtml:div, reqif-xhtml:div, ...), + // so it has to be located by local name. + content = XmlUtils.firstDescendantByLocalName(xhtmlContent, XHTML.DIV); + } + this.divValue = content == null ? null : new XHTMLElementDiv(content); this.value = deconstructXHTML(this.divValue); } + /** + * @return THE-ORIGINAL-VALUE: the complete content when THE-VALUE holds a + * simplified version of it, e.g. an embedded OLE object of which + * THE-VALUE only shows the preview image; null if there is none + */ + public XHTMLElementDiv getOriginalValue() { + return this.originalValue; + } + + /** + * @return IS-SIMPLIFIED: whether THE-VALUE is a simplified version of + * THE-ORIGINAL-VALUE, or null if the document does not say + */ + public Boolean getSimplified() { + return this.simplified; + } + /** * Creates an XHTML value from markup, for documents that are generated * instead of parsed. The markup is parsed so the node tree and the token @@ -121,7 +157,9 @@ private AttributeValueXHTMLElementList deconstructXHTML(XHTMLElementDiv div) { return xhtmlElementList; } - for(XHTMLNode child: childrenOf(div)) { + // THE-VALUE may hold a single p instead of a div + List<XHTMLNode> blocks = XHTML.P.equals(div.getTagName()) ? List.of(div) : childrenOf(div); + for(XHTMLNode child: blocks) { String elementType = elementToken(child.getTagName()); if(elementType != null) { xhtmlElementList.add(elementType, deconstructElement(elementType, child)); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java index 2a6c06c..5fa9993 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java @@ -428,6 +428,7 @@ public static class HeaderBuilder { private String toolID = ""; private String sourceToolID = ""; private String reqifVersion = "1.0"; + private String repositoryID; public HeaderBuilder id(String id) { this.id = id; @@ -460,6 +461,11 @@ public HeaderBuilder sourceToolID(String sourceToolID) { return this; } + public HeaderBuilder repositoryID(String repositoryID) { + this.repositoryID = repositoryID; + return this; + } + public HeaderBuilder reqifVersion(String reqifVersion) { this.reqifVersion = reqifVersion; return this; @@ -467,7 +473,8 @@ public HeaderBuilder reqifVersion(String reqifVersion) { private ReqIFHeader build(String defaultID) { return new ReqIFHeader(this.id == null || this.id.isBlank() ? defaultID : this.id, this.title, - this.comment, this.creationTime, this.toolID, this.sourceToolID, this.reqifVersion); + this.comment, this.creationTime, this.toolID, this.sourceToolID, this.reqifVersion) + .setRepositoryID(this.repositoryID); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java index 8e9738a..02fafaf 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java @@ -58,6 +58,12 @@ public class ReqIFConst { public final static String REQ_IF_VERSION = "REQ-IF-VERSION"; public final static String COMMENT = "COMMENT"; public final static String CREATION_TIME = "CREATION-TIME"; + public final static String REPOSITORY_ID = "REPOSITORY-ID"; + public final static String IS_EDITABLE = "IS-EDITABLE"; + public final static String IS_TABLE_INTERNAL = "IS-TABLE-INTERNAL"; + public final static String EDITABLE_ATTS = "EDITABLE-ATTS"; + public final static String THE_ORIGINAL_VALUE = "THE-ORIGINAL-VALUE"; + public final static String IS_SIMPLIFIED = "IS-SIMPLIFIED"; public final static String SPECIFICATION_TYPE = "SPECIFICATION-TYPE"; public final static String SPEC_OBJECT_TYPE = "SPEC-OBJECT-TYPE"; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java index d3fbac8..0e325f8 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java @@ -3,6 +3,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import javax.xml.XMLConstants; import javax.xml.parsers.ParserConfigurationException; import org.w3c.dom.Document; @@ -25,6 +26,7 @@ public class ReqIFDocument { private ReqIFHeader header; private ReqIFCoreContent content; private final java.util.List<org.w3c.dom.Node> toolExtensions = new java.util.ArrayList<>(); + private String lang; public String getFilePath() { @@ -43,6 +45,19 @@ public ReqIFCoreContent getCoreContent() { return this.content; } + /** + * @return the document language (xml:lang of REQ-IF), e.g. "de", or null + * if the document declares none + */ + public String getLang() { + return this.lang; + } + + public ReqIFDocument setLang(String lang) { + this.lang = lang; + return this; + } + /** * Tool-specific extensions of the document. They are not interpreted, only * kept as their original nodes so they survive a round trip. @@ -143,10 +158,15 @@ private void readDocument() { } this.content = new ReqIFCoreContent(coreContent, this.typeClassifier); + Element root = this.reqifDocument.getDocumentElement(); + if (root.hasAttributeNS(XMLConstants.XML_NS_URI, "lang")) { + this.lang = root.getAttributeNS(XMLConstants.XML_NS_URI, "lang"); + } + // Tool extensions are kept verbatim; the parser does not interpret them. - // Only the TOOL-EXTENSIONS of REQ-IF itself; a vendor extension may well - // contain an element of that name, and the schema allows one only. - for (Element child : XmlUtils.childElements(this.reqifDocument.getDocumentElement())) { + // Only the TOOL-EXTENSIONS of REQ-IF itself count: a vendor extension may + // well contain an element of that name, and the schema allows one only. + for (Element child : XmlUtils.childElements(root)) { if (ReqIFConst.TOOL_EXTENSIONS.equals(XmlUtils.localName(child))) { this.toolExtensions.add(child); } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java index 59b7e59..663c5c9 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java @@ -16,6 +16,7 @@ public class ReqIFHeader { private String comment = ""; private String creationDate = ""; private String creationTime = ""; + private String repositoryID; @@ -64,6 +65,19 @@ public String getCreationDate() { public String getCreationTime() { return this.creationTime; } + + /** + * @return the optional REPOSITORY-ID, the repository the document was + * exported from, or null if the document declares none + */ + public String getRepositoryID() { + return this.repositoryID; + } + + public ReqIFHeader setRepositoryID(String repositoryID) { + this.repositoryID = repositoryID; + return this; + } @@ -92,6 +106,7 @@ public ReqIFHeader(Element theHeader) { this.creationTime = creationTime; this.creationDate = creationDateOf(creationTime); } + this.repositoryID = textOf(theHeader, ReqIFConst.REPOSITORY_ID); String title = textOf(theHeader, ReqIFConst.TITLE); if(title != null) { // The title is returned as written in the document; stripping a diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java index 3ab12bc..9d3a38b 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java @@ -133,7 +133,9 @@ static AttributeValue defaultValue(AttributeDefinition attributeDefinition) { return new AttributeValueEnumeration(defaultValue, attributeDefinition); case ReqIFConst.XHTML: - return new AttributeValueXHTML(defaultValue, attributeDefinition); + return attributeDefinition.getDefaultXhtmlValue() != null + ? attributeDefinition.getDefaultXhtmlValue() + : new AttributeValueXHTML(defaultValue, attributeDefinition); case ReqIFConst.DATE: return new AttributeValueDate(defaultValue, attributeDefinition); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java index 65214a6..8cfc5f5 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java @@ -11,6 +11,7 @@ import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; +import de.uni_stuttgart.ils.reqif4j.attributes.XHTML; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTMLElementList; import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; @@ -24,6 +25,10 @@ public class SpecHierarchy extends Identifiable { private int hierarchyLvl; private int section; private SpecObject specObject; + private String specObjectRef; + private Boolean tableInternal; + private Boolean editable; + private final List<Map.Entry<String, String>> editableAttributes = new ArrayList<Map.Entry<String, String>>(); private Map<String, SpecHierarchy> children = new LinkedHashMap<String, SpecHierarchy>(); @@ -36,8 +41,59 @@ public String getSpecHierarchyID() { return getID(); } + /** + * @return the IDENTIFIER of the referenced spec object; also when the + * reference could not be resolved, null if there is none + */ public String getSpecObjectID(){ - return this.specObject.getID(); + return this.specObject != null ? this.specObject.getID() : this.specObjectRef; + } + + /** + * @return IS-TABLE-INTERNAL: whether this node is part of a table in the + * tool's view, or null if the document does not say + */ + public Boolean getTableInternal() { + return this.tableInternal; + } + + public SpecHierarchy setTableInternal(Boolean tableInternal) { + this.tableInternal = tableInternal; + return this; + } + + /** + * @return IS-EDITABLE: whether the referenced object may be edited, or + * null if the document does not say + */ + public Boolean getEditable() { + return this.editable; + } + + public SpecHierarchy setEditable(Boolean editable) { + this.editable = editable; + return this; + } + + /** + * @return the IDENTIFIERs of the attribute definitions listed in + * EDITABLE-ATTS (editable on this node), in document order + */ + public List<String> getEditableAttributeIDs() { + List<String> ids = new ArrayList<String>(); + for(Map.Entry<String, String> ref: this.editableAttributes) { + ids.add(ref.getKey()); + } + return ids; + } + + /** + * @return the EDITABLE-ATTS references as read, in document order: + * attribute definition IDENTIFIER and the reference element name, + * e.g. ATTRIBUTE-DEFINITION-STRING-REF (read-only) + */ + public List<Map.Entry<String, String>> getEditableAttributeRefs() { + return java.util.Collections.unmodifiableList(this.editableAttributes); } public int getHierarchyLvl() { @@ -77,7 +133,8 @@ public List<XHTMLNode> getXHTMLDivContent() { if(attributeValue instanceof AttributeValueXHTML) { XHTMLElementDiv value = ((AttributeValueXHTML)attributeValue).getDivValue(); if(value != null) { - return value.getChildren(); + // a value may consist of a single p instead of a div + return XHTML.P.equals(value.getTagName()) ? List.of(value) : value.getChildren(); } } } @@ -184,7 +241,18 @@ public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map<Stri Element object = XmlUtils.firstChildElementByLocalName(specHierarchy, ReqIFConst.OBJECT); Element specObjectRef = XmlUtils.firstDescendantByLocalName(object, ReqIFConst.SPEC_OBJECT_REF); if(specObjectRef != null) { - this.specObject = specObjects.get(specObjectRef.getTextContent().trim()); + // the reference is kept even if it cannot be resolved, so it is not + // lost on writing and the validator can report it + this.specObjectRef = specObjectRef.getTextContent().trim(); + this.specObject = specObjects.get(this.specObjectRef); + } + + this.tableInternal = XmlUtils.booleanAttribute(specHierarchy, ReqIFConst.IS_TABLE_INTERNAL); + this.editable = XmlUtils.booleanAttribute(specHierarchy, ReqIFConst.IS_EDITABLE); + Element editableAtts = XmlUtils.firstChildElementByLocalName(specHierarchy, ReqIFConst.EDITABLE_ATTS); + for(Element ref: XmlUtils.childElements(editableAtts)) { + this.editableAttributes.add(new java.util.AbstractMap.SimpleImmutableEntry<String, String>( + ref.getTextContent().trim(), XmlUtils.localName(ref))); } Element childrenElement = XmlUtils.firstChildElementByLocalName(specHierarchy, ReqIFConst.CHILDREN); @@ -198,5 +266,4 @@ public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map<Stri } } } - } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java index 1158b6a..9ce7b58 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java @@ -166,4 +166,27 @@ public static String attributeOrEmpty(Node node, String attributeName) { String value = attribute(node, attributeName); return value == null ? "" : value; } + + /** + * @return the xsd:boolean attribute ("true"/"1" is true, "false"/"0" + * false), or null if the node has no such attribute or its value + * is not an xsd:boolean - an unknown value must not turn into + * false and be written back as such + */ + public static Boolean booleanAttribute(Node node, String attributeName) { + String value = attribute(node, attributeName); + if (value == null) { + return null; + } + switch (value.trim()) { + case "true": + case "1": + return Boolean.TRUE; + case "false": + case "0": + return Boolean.FALSE; + default: + return null; + } + } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java index 40a9e98..f9c0b1d 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java @@ -277,11 +277,11 @@ private void checkSpecifications(ReqIFCoreContent content, ValidationResult resu for (SpecHierarchy hierarchy : specification.getAllSpecHierarchies()) { checkIdentifiable(hierarchy, "SPEC-HIERARCHY", result, identifiers); - if (hierarchy.getSpecObject() == null) { - result.error(hierarchy.getSpecHierarchyID(), - "Spec hierarchy references a spec object that does not exist"); + if (hierarchy.getSpecObjectID() == null) { + result.error(hierarchy.getSpecHierarchyID(), "Spec hierarchy has no OBJECT reference"); - } else if (content.getSpecObject(hierarchy.getSpecObjectID()) == null) { + } else if (hierarchy.getSpecObject() == null + || content.getSpecObject(hierarchy.getSpecObjectID()) == null) { result.error(hierarchy.getSpecHierarchyID(), "Spec hierarchy references spec object " + hierarchy.getSpecObjectID() + ", which is not declared in SPEC-OBJECTS"); } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index 35f0ded..3d1694d 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -10,6 +10,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; +import javax.xml.XMLConstants; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; @@ -172,6 +173,9 @@ public Document buildDocument(ReqIFDocument document) { Element root = xml.createElementNS(REQIF_NAMESPACE, "REQ-IF"); root.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:" + XHTML_PREFIX, XHTML_NAMESPACE); + if (document.getLang() != null) { + root.setAttributeNS(XMLConstants.XML_NS_URI, "xml:lang", document.getLang()); + } xml.appendChild(root); if (document.getHeader() != null) { @@ -203,6 +207,7 @@ private Element header(Document xml, ReqIFHeader header, String now) { appendTextIfPresent(xml, reqifHeader, ReqIFConst.COMMENT, header.getComment()); appendText(xml, reqifHeader, ReqIFConst.CREATION_TIME, orDefault(header.getCreationTime(), now)); + appendTextIfPresent(xml, reqifHeader, ReqIFConst.REPOSITORY_ID, header.getRepositoryID()); appendText(xml, reqifHeader, ReqIFConst.REQ_IF_TOOL_ID, orDefault(header.getToolID(), DEFAULT_TOOL_ID)); appendText(xml, reqifHeader, ReqIFConst.REQ_IF_VERSION, orDefault(header.getReqIFVersion(), REQIF_VERSION)); appendText(xml, reqifHeader, ReqIFConst.SOURCE_TOOL_ID, orDefault(header.getSourceToolID(), DEFAULT_TOOL_ID)); @@ -374,6 +379,7 @@ private Element attributeDefinition(Document xml, AttributeDefinition definition Element attributeDefinition = element(xml, elementName); identifiable(xml, attributeDefinition, definition); + setAttributeIfPresent(attributeDefinition, ReqIFConst.IS_EDITABLE, toString(definition.getEditable())); // required on every enumeration attribute definition, "false" included if (ReqIFConst.ENUMERATION.equals(datatype.getType())) { @@ -420,7 +426,9 @@ private Element defaultValue(Document xml, AttributeDefinition definition, Datat if (ReqIFConst.XHTML.equals(datatype.getType())) { // an XHTML value carries its content in a THE-VALUE element, not in // an attribute - Element value = xhtmlValue(xml, definition, new AttributeValueXHTML(definition.getDefaultValue(), definition)); + AttributeValueXHTML parsed = definition.getDefaultXhtmlValue(); + Element value = xhtmlValue(xml, definition, + parsed != null ? parsed : new AttributeValueXHTML(definition.getDefaultValue(), definition)); if (value == null) { return null; } @@ -491,7 +499,19 @@ private Element specHierarchy(Document xml, SpecHierarchy hierarchy) { Element element = element(xml, ReqIFConst.SPEC_HIERARCHY); identifiable(xml, element, hierarchy); - if (hierarchy.getSpecObject() != null) { + setAttributeIfPresent(element, ReqIFConst.IS_TABLE_INTERNAL, toString(hierarchy.getTableInternal())); + setAttributeIfPresent(element, ReqIFConst.IS_EDITABLE, toString(hierarchy.getEditable())); + if (!hierarchy.getEditableAttributeRefs().isEmpty()) { + Element editableAtts = element(xml, ReqIFConst.EDITABLE_ATTS); + for (java.util.Map.Entry<String, String> ref : hierarchy.getEditableAttributeRefs()) { + Element refElement = element(xml, ref.getValue()); + refElement.setTextContent(ref.getKey()); + editableAtts.appendChild(refElement); + } + element.appendChild(editableAtts); + } + // written also when unresolved, so the reference is not lost + if (hierarchy.getSpecObjectID() != null) { element.appendChild(objectRef(xml, ReqIFConst.OBJECT, hierarchy.getSpecObjectID())); } List<SpecHierarchy> nested = hierarchy.getChildren(); @@ -589,6 +609,16 @@ private Element xhtmlValue(Document xml, AttributeDefinition definition, Attribu Element theValue = element(xml, ReqIFConst.THE_VALUE); theValue.appendChild(content); element.appendChild(theValue); + + if (attributeValue instanceof AttributeValueXHTML) { + AttributeValueXHTML xhtml = (AttributeValueXHTML) attributeValue; + setAttributeIfPresent(element, ReqIFConst.IS_SIMPLIFIED, toString(xhtml.getSimplified())); + if (xhtml.getOriginalValue() != null && xhtml.getOriginalValue().getNode() != null) { + Element original = element(xml, ReqIFConst.THE_ORIGINAL_VALUE); + original.appendChild(xml.importNode(xhtml.getOriginalValue().getNode(), true)); + element.appendChild(original); + } + } return element; } @@ -735,6 +765,10 @@ private static String nullToEmpty(String value) { return value == null ? "" : value; } + private static String toString(Boolean value) { + return value == null ? null : value.toString(); + } + private static String orDefault(String value, String fallback) { return isBlank(value) ? fallback : value; } diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/CanonicalXml.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/CanonicalXml.java new file mode 100644 index 0000000..13fbf3a --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/CanonicalXml.java @@ -0,0 +1,132 @@ +package de.uni_stuttgart.ils.reqif4j; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; + +import org.w3c.dom.Attr; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +import de.uni_stuttgart.ils.reqif4j.util.SecureXml; + +/** + * Renders a ReqIF document as an indented tree that is independent of + * everything the ReqIF schema leaves open, so two documents with the same + * content compare equal: + * + * <ul> + * <li>namespace prefixes and declarations (names are compared by namespace + * URI and local name), attribute order, and indentation;</li> + * <li>the order of child elements that all have different names - the + * ReqIF elements are xsd:all groups - while repeated elements (lists such as + * VALUES or CHILDREN) keep their order;</li> + * <li>empty elements without attributes, e.g. an empty VALUES or + * SPEC-ATTRIBUTES, which carry no content.</li> + * </ul> + * + * XHTML content is compared exactly: element order, text and whitespace. + */ +final class CanonicalXml { + + private static final String XHTML = "http://www.w3.org/1999/xhtml"; + private static final String XMLNS = "http://www.w3.org/2000/xmlns/"; + + private CanonicalXml() { + } + + static String of(String xml) throws Exception { + Document document = SecureXml.newDocumentBuilder() + .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + StringBuilder out = new StringBuilder(); + render(document.getDocumentElement(), 0, out); + return out.toString(); + } + + private static void render(Element element, int depth, StringBuilder out) { + + boolean xhtml = XHTML.equals(element.getNamespaceURI()); + indent(depth, out); + out.append('<').append(name(element)); + + TreeMap<String, String> attributes = new TreeMap<String, String>(); + NamedNodeMap map = element.getAttributes(); + for (int i = 0; i < map.getLength(); i++) { + Attr attribute = (Attr) map.item(i); + if (!XMLNS.equals(attribute.getNamespaceURI())) { + attributes.put(name(attribute), attribute.getValue()); + } + } + attributes.forEach((key, value) -> out.append(' ').append(key).append("=\"").append(value).append('"')); + out.append(">\n"); + + List<Node> children = new ArrayList<Node>(); + for (Node child = element.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child.getNodeType() == Node.ELEMENT_NODE) { + if (xhtml || !isEmpty((Element) child)) { + children.add(child); + } + } else if (child.getNodeType() == Node.TEXT_NODE || child.getNodeType() == Node.CDATA_SECTION_NODE) { + String text = child.getNodeValue(); + if (xhtml || !text.isBlank()) { + children.add(child); + } + } + } + if (!xhtml && distinctElementNames(children)) { + children.sort((a, b) -> name(a).compareTo(name(b))); + } + + for (Node child : children) { + if (child.getNodeType() == Node.ELEMENT_NODE) { + render((Element) child, depth + 1, out); + } else { + indent(depth + 1, out); + String text = child.getNodeValue(); + out.append("\"").append(xhtml ? text : text.trim()).append("\"\n"); + } + } + } + + private static boolean isEmpty(Element element) { + return !element.hasAttributes() && !element.hasChildNodes() + || !element.hasAttributes() && element.getTextContent().isBlank() && !hasElementChild(element); + } + + private static boolean hasElementChild(Element element) { + for (Node child = element.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child.getNodeType() == Node.ELEMENT_NODE) { + return true; + } + } + return false; + } + + private static boolean distinctElementNames(List<Node> children) { + Set<String> names = new HashSet<String>(); + for (Node child : children) { + if (child.getNodeType() != Node.ELEMENT_NODE || !names.add(name(child))) { + return false; + } + } + return true; + } + + private static String name(Node node) { + String namespace = node.getNamespaceURI(); + String local = node.getLocalName() != null ? node.getLocalName() : node.getNodeName(); + return namespace == null ? local : "{" + namespace + "}" + local; + } + + private static void indent(int depth, StringBuilder out) { + for (int i = 0; i < depth; i++) { + out.append(" "); + } + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/LosslessRoundTripTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/LosslessRoundTripTest.java new file mode 100644 index 0000000..34c2363 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/LosslessRoundTripTest.java @@ -0,0 +1,57 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * The acceptance test for lossless import and export: a schema-valid document + * that uses every element and attribute of the ReqIF schema is written back + * with the same content. + */ +class LosslessRoundTripTest { + + static String fullFeatureDocument() throws Exception { + try (InputStream in = LosslessRoundTripTest.class.getResourceAsStream("/full-feature.reqif")) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Test + void everyElementAndAttributeSurvivesARoundTrip(@TempDir Path tempDir) throws Exception { + + String original = fullFeatureDocument(); + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", original).toString()); + + String written = new ReqIFWriter().toXml(reqif.getReqIFDocument()); + + assertEquals(CanonicalXml.of(original), CanonicalXml.of(written)); + } + + @Test + void theFixtureIsSchemaValidAndStaysSo(@TempDir Path tempDir) throws Exception { + + Path in = TestFixtures.write(tempDir, "in.reqif", fullFeatureDocument()); + OfficialReqIFSchema.assertSchemaValid(in); + + OfficialReqIFSchema.assertSchemaValid(new ReqIF(in.toString()).getReqIFDocument()); + } + + @Test + void writingIsStable(@TempDir Path tempDir) throws Exception { + + ReqIF first = new ReqIF(TestFixtures.write(tempDir, "in.reqif", fullFeatureDocument()).toString()); + String once = new ReqIFWriter().toXml(first.getReqIFDocument()); + ReqIF second = new ReqIF(TestFixtures.write(tempDir, "once.reqif", once).toString()); + + assertEquals(once, new ReqIFWriter().toXml(second.getReqIFDocument())); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java new file mode 100644 index 0000000..1c4f1b2 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java @@ -0,0 +1,142 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueInteger; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent; +import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; +import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * The model exposes every part of the ReqIF schema that used to be dropped + * when reading: big integers, REPOSITORY-ID, xml:lang, IS-EDITABLE, + * IS-TABLE-INTERNAL, EDITABLE-ATTS, THE-ORIGINAL-VALUE and IS-SIMPLIFIED. + */ +class ModelCompletenessTest { + + private ReqIF reqif; + private ReqIFCoreContent content; + + @BeforeEach + void parse(@TempDir Path tempDir) throws Exception { + this.reqif = new ReqIF(TestFixtures.write(tempDir, "in.reqif", LosslessRoundTripTest.fullFeatureDocument()).toString()); + this.content = this.reqif.getReqIFCoreContent(); + } + + @Test + void integersAreNotLimitedToInt() { + + AttributeValueInteger big = (AttributeValueInteger) this.content.getSpecObject("so-1").getValue("ad-count"); + assertEquals(99999999999L, big.getValue(), "a Long once the value exceeds int"); + assertEquals(new BigInteger("99999999999"), big.getBigInteger()); + + assertEquals(7, this.content.getSpecObject("so-2").getAttribute("Count"), "an Integer while it fits"); + + AttributeValueInteger huge = new AttributeValueInteger("123456789012345678901234567890", + this.content.getSpecType("st-req").getAttributeDefinition("ad-count")); + assertEquals(new BigInteger("123456789012345678901234567890"), huge.getValue()); + } + + @Test + void documentAndHeaderMetadata() { + + assertEquals("de", this.reqif.getReqIFDocument().getLang()); + assertEquals("repo-42", this.reqif.getReqIFHeader().getRepositoryID()); + + String xml = new ReqIFWriter().toXml(ReqIFBuilder.create() + .header(h -> h.id("hdr").repositoryID("repo-7").creationTime("2026-07-23T10:00:00Z")) + .build()); + assertTrue(xml.contains("<CREATION-TIME>2026-07-23T10:00:00Z</CREATION-TIME><REPOSITORY-ID>repo-7</REPOSITORY-ID>"), xml); + } + + @Test + void editabilityAndTableFlags() { + + assertEquals(false, this.content.getSpecType("st-req").getAttributeDefinition("ad-id").getEditable()); + assertEquals(true, this.content.getSpecType("st-req").getAttributeDefinition("ad-text").getEditable()); + assertNull(this.content.getSpecType("st-req").getAttributeDefinition("ad-due").getEditable()); + + SpecHierarchy chapter = this.content.getSpecification("spec-1").getChildren().get(0); + assertEquals(true, chapter.getEditable()); + assertEquals(false, chapter.getTableInternal()); + assertEquals(List.of("ad-text", "ad-status", "ad-text"), chapter.getEditableAttributeIDs(), + "in document order, a repeated reference included"); + + SpecHierarchy row = chapter.getChildren().get(0); + assertEquals(true, row.getTableInternal()); + assertNull(row.getEditable()); + assertTrue(row.getEditableAttributeIDs().isEmpty()); + } + + @Test + void originalAndSimplifiedXhtml() { + + AttributeValueXHTML text = (AttributeValueXHTML) this.content.getSpecObject("so-1").getValue("ad-text"); + + assertEquals(true, text.getSimplified()); + assertTrue(text.getOriginalValue().toString().contains("files/diagram.ole"), text.getOriginalValue().toString()); + assertTrue(!text.getValue().toString().contains("diagram.ole"), "THE-VALUE is the simplified content"); + } + + @Test + void xhtmlValueMadeOfASingleParagraph() { + + AttributeValueXHTML paragraph = (AttributeValueXHTML) this.content.getSpecObject("so-2").getValue("ad-text"); + + assertEquals("<p>A single paragraph.</p>", paragraph.getValue()); + assertEquals(1, paragraph.getElementList().size()); + assertEquals("P", paragraph.getElementList().getElementType(0)); + } + + @Test + void xhtmlDefaultKeepsItsFormAndOriginal() { + + AttributeValueXHTML defaultText = this.content.getSpecType("st-req").getAttributeDefinition("ad-text") + .getDefaultXhtmlValue(); + + assertEquals("<p>to be written</p>", defaultText.getValue()); + assertEquals(true, defaultText.getSimplified()); + assertTrue(defaultText.getOriginalValue().toString().contains("<b>written</b>")); + } + + @Test + void invalidBooleanFlagIsNotFlipped(@TempDir Path tempDir) throws Exception { + + // xsd:boolean is case sensitive; TRUE is not false, it is unknown + String upperCase = LosslessRoundTripTest.fullFeatureDocument().replace("IS-EDITABLE=\"true\" IS-TABLE-INTERNAL", "IS-EDITABLE=\"TRUE\" IS-TABLE-INTERNAL"); + ReqIF withInvalid = new ReqIF(TestFixtures.write(tempDir, "invalid.reqif", upperCase).toString()); + + SpecHierarchy chapter = withInvalid.getReqIFCoreContent().getSpecification("spec-1").getChildren().get(0); + assertNull(chapter.getEditable()); + assertTrue(!new ReqIFWriter().toXml(withInvalid.getReqIFDocument()).contains("IS-EDITABLE=\"false\" IS-TABLE-INTERNAL")); + } + + @Test + void unresolvedHierarchyReferenceIsKept(@TempDir Path tempDir) throws Exception { + + String dangling = LosslessRoundTripTest.fullFeatureDocument() + .replace("<OBJECT><SPEC-OBJECT-REF>so-2</SPEC-OBJECT-REF></OBJECT>", "<OBJECT><SPEC-OBJECT-REF>so-gone</SPEC-OBJECT-REF></OBJECT>"); + ReqIF withDangling = new ReqIF(TestFixtures.write(tempDir, "dangling.reqif", dangling).toString()); + SpecHierarchy row = withDangling.getReqIFCoreContent().getSpecification("spec-1").getChildren().get(0).getChildren().get(0); + + assertNull(row.getSpecObject()); + assertEquals("so-gone", row.getSpecObjectID()); + assertTrue(new ReqIFWriter().toXml(withDangling.getReqIFDocument()).contains("<SPEC-OBJECT-REF>so-gone</SPEC-OBJECT-REF>")); + assertTrue(new ReqIFValidator().validate(withDangling.getReqIFDocument()).getErrors().stream() + .anyMatch(issue -> issue.getMessage().contains("references spec object so-gone"))); + } +} diff --git a/src/test/resources/full-feature.reqif b/src/test/resources/full-feature.reqif new file mode 100644 index 0000000..8d838c2 --- /dev/null +++ b/src/test/resources/full-feature.reqif @@ -0,0 +1,163 @@ +<?xml version="1.0" encoding="UTF-8"?> +<REQ-IF xmlns="http://www.omg.org/spec/ReqIF/20110401/reqif.xsd" xmlns:xhtml="http://www.w3.org/1999/xhtml" xml:lang="de"> + <THE-HEADER> + <REQ-IF-HEADER IDENTIFIER="hdr"> + <COMMENT>Exported for the round trip test</COMMENT> + <CREATION-TIME>2026-07-23T10:00:00Z</CREATION-TIME> + <REPOSITORY-ID>repo-42</REPOSITORY-ID> + <REQ-IF-TOOL-ID>Some Tool</REQ-IF-TOOL-ID> + <REQ-IF-VERSION>1.0</REQ-IF-VERSION> + <SOURCE-TOOL-ID>DOORS</SOURCE-TOOL-ID> + <TITLE>All features + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + dt-string + + + ad-textto be writtento be written + dt-xhtml + + + ad-count + dt-int + + + dt-real + + + ad-safety + dt-bool + + + dt-date + + + ad-statusev-open + dt-enum + + + dt-enum + + + + + + + dt-string + + + + + + + dt-string + + + + + + + + + + ad-id + + ad-text + The system shall boot. Diagram + The system shall boot. Diagram + + ad-count + ad-effort + ad-safety + ad-due + ad-statusev-done + ad-tagsev-openev-done + + st-req + + + + ad-id + ad-textA single paragraph. + ad-tags + + st-req + + + + + + ad-rel-note + + st-rel + so-2 + so-1 + + + + + + ad-spec-owner + + st-spec + + + + ad-text + ad-status + ad-text + + so-1 + + + so-2 + + + + + + + + + + st-group + spec-1 + spec-1 + sr-1 + + + + + + + + + +
From b7fdd55e4f2ac3636d7508a58bfc901481c8e32c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:01:30 +0000 Subject: [PATCH 11/20] Keep every file of a .reqifz archive ReqIFz only kept png and jpg files, so embedded OLE objects, PDFs, GIFs and other attachments were lost on reading and when re-packing. Every entry that is not a ReqIF document is now kept in getAttachmentFiles(); getPictureFiles() lists the common image types among them. ReqIFzWriter.write(ReqIFz, ...) re-packs all attachments, copied straight from the extracted files instead of being loaded into memory. The archive is extracted into a temporary directory that close() deletes, no longer into a "_unzipped" directory next to the source file, which failed on read-only locations and was never cleaned up. Archives can be read from an InputStream. Reading is more robust: document entries are recognized case-insensitively (".REQIF"), backslash entry names are normalized, a duplicate entry replaces the earlier one instead of failing, and no stream or file handle is left open when reading fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- README.md | 26 ++- .../ils/reqif4j/reqif/ReqIFz.java | 217 +++++++++++++----- .../ils/reqif4j/write/ReqIFzWriter.java | 124 +++++----- .../ils/reqif4j/ReqIFzAttachmentsTest.java | 152 ++++++++++++ 4 files changed, 405 insertions(+), 114 deletions(-) create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFzAttachmentsTest.java diff --git a/README.md b/README.md index 4e49327..1d92788 100644 --- a/README.md +++ b/README.md @@ -422,19 +422,33 @@ values too. The content category of generated spec objects is derived by the same `TypeClassifier` used when reading (`typeClassifier(...)` to override). -## Writing .reqifz archives +## Reading and writing .reqifz archives -Images travel with the document, stored under the path the XHTML +A .reqifz archive holds one or more ReqIF documents plus the files they +reference - images, embedded OLE objects, PDFs or anything else. Reading +it keeps every entry: the documents in `getReqIFDocuments()`, all other +files in `getAttachmentFiles()` (pictures additionally in +`getPictureFiles()`). The archive is extracted into a temporary +directory that `close()` deletes again, so use try-with-resources and +read the files before closing. Archives can also be read from a stream: + +```java +try (ReqIFz reqifz = new ReqIFz(uploadStream, "upload.reqifz")) { + File ole = reqifz.getAttachmentFiles().get("files/diagram.ole"); +} +``` + +Attachments travel with the document, stored under the path the XHTML `object` elements reference: ```java -Map pictures = Map.of("files/diagram.png", pngBytes); -new ReqIFzWriter().write(document, "spec.reqif", pictures, Path.of("spec.reqifz")); +Map attachments = Map.of("files/diagram.png", pngBytes); +new ReqIFzWriter().write(document, "spec.reqif", attachments, Path.of("spec.reqifz")); ``` An archive that was read can be re-packed - the documents are serialized -from the model (so modifications are included) while the images are -copied from the extracted files, without consuming the one-shot streams: +from the model (so modifications are included) while every attachment is +copied unchanged from the extracted files: ```java try (ReqIFz source = new ReqIFz("in.reqifz")) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java index 18d6f62..ad65039 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java @@ -2,35 +2,66 @@ import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import de.uni_stuttgart.ils.reqif4j.specification.TypeClassifier; +/** + * A .reqifz archive: one or more ReqIF documents plus the files they + * reference - images, embedded OLE objects, PDFs or anything else. + * + * The archive is extracted into a temporary directory, which {@link #close()} + * deletes again; use try-with-resources. Every entry that is not a ReqIF + * document is kept as an attachment, so re-packing the archive with + * {@code ReqIFzWriter} loses nothing. + */ public class ReqIFz extends ReqIFFile { - private static final String EXTRACTION_SUFFIX = "_unzipped"; + private static final String[] PICTURE_EXTENSIONS = { + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".tif", ".tiff", ".webp", ".emf", ".wmf" }; - private final Map pictureFiles = new java.util.LinkedHashMap<>(); - private final Map reqifFiles = new java.util.LinkedHashMap<>(); + private Path extractionDirectory; + private final Map attachmentFiles = new LinkedHashMap<>(); + private final Map pictureFiles = new LinkedHashMap<>(); + private final Map reqifFiles = new LinkedHashMap<>(); /** - * @return the extracted picture files, keyed by their archive entry name - * (forward slashes, as referenced by xhtml object data attributes). - * Unlike the input streams these can be read repeatedly, which is - * what re-packing an archive needs. + * @return every entry that is not a ReqIF document, keyed by its archive + * entry name (forward slashes, as referenced by xhtml object data + * attributes), in archive order. Valid until {@link #close()}. + */ + public Map getAttachmentFiles() { + return this.attachmentFiles; + } + + /** + * @return the attachments that are pictures (png, jpg, gif, svg, ... by + * file extension), keyed like + * {@link #getAttachmentFiles()}. Unlike the input streams these can + * be read repeatedly. Valid until {@link #close()}. */ public Map getPictureFiles() { return this.pictureFiles; } /** - * @return the extracted ReqIF files, keyed by their archive entry name + * @return the extracted ReqIF files, keyed by their archive entry name. + * Valid until {@link #close()}. */ public Map getReqIFFiles() { return this.reqifFiles; @@ -46,73 +77,149 @@ public ReqIFz(String filePath) throws IOException { * LONG-NAME heuristic */ public ReqIFz(String filePath, TypeClassifier typeClassifier) throws IOException { + this(new FileInputStream(filePath), filePath, typeClassifier); + } + + /** + * Reads an archive from a stream, e.g. an upload or a classpath resource. + * The stream is closed. + * + * @param name the archive's name or path, used in messages and as + * {@link #getPath()} + */ + public ReqIFz(InputStream archive, String name) throws IOException { + this(archive, name, TypeClassifier.defaultClassifier()); + } + + public ReqIFz(InputStream archive, String name, TypeClassifier typeClassifier) throws IOException { - this.path = filePath; - this.name = extractFileName(filePath); + this.path = name; + this.name = extractFileName(name); - File destDir = new File(removeExtension(new File(filePath).getAbsolutePath()) + EXTRACTION_SUFFIX); - if (!destDir.exists() && !destDir.mkdirs()) { - throw new IOException("Failed to create extraction directory " + destDir); + try (InputStream in = archive) { + this.extractionDirectory = Files.createTempDirectory("reqifz-"); + extract(in, typeClassifier); + } catch (IOException | RuntimeException e) { + try { + close(); + } catch (IOException cleanup) { + e.addSuppressed(cleanup); + } + throw e; } - String canonicalDestDir = destDir.getCanonicalPath(); + } - byte[] buffer = new byte[8192]; - Map picturesIS = new HashMap<>(); + private void extract(InputStream archive, TypeClassifier typeClassifier) throws IOException { - try (ZipInputStream zis = new ZipInputStream(new FileInputStream(filePath))) { - ZipEntry zipEntry = zis.getNextEntry(); + Path destination = this.extractionDirectory.toRealPath(); - while (zipEntry != null) { - File newFile = new File(destDir, zipEntry.getName()); + try (ZipInputStream zis = new ZipInputStream(archive)) { + for (ZipEntry zipEntry = zis.getNextEntry(); zipEntry != null; zipEntry = zis.getNextEntry()) { + // zip entries use forward slashes; archives made on Windows + // sometimes contain backslashes instead + String entryName = zipEntry.getName().replace('\\', '/'); + Path target = destination.resolve(entryName).normalize(); // Guard against zip-slip: entries must not escape the // extraction directory (e.g. via "../" path segments). - if (!newFile.getCanonicalPath().startsWith(canonicalDestDir + File.separator)) { - throw new IOException("Zip entry outside of extraction directory: " + zipEntry.getName()); + if (!target.startsWith(destination) || target.equals(destination)) { + throw new IOException("Zip entry outside of extraction directory: " + entryName); } - if (zipEntry.isDirectory()) { - if (!newFile.isDirectory() && !newFile.mkdirs()) { - throw new IOException("Failed to create directory " + newFile); + Files.createDirectories(target); + continue; + } + // also for archives that list no directory entries + Files.createDirectories(target.getParent()); + // a zip may hold an entry twice (tools append updated + // entries); the later one wins, as when unzipping + Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING); + + File file = target.toFile(); + if (entryName.toLowerCase(Locale.ROOT).endsWith(".reqif")) { + this.numberOfReqIFDocuments++; + this.reqifFiles.put(entryName, file); + try (InputStream reqifIS = new FileInputStream(file)) { + this.reqifDocuments.put(entryName, new ReqIFDocument(reqifIS, this.path, entryName, typeClassifier)); } } else { - // Fix for Windows-created archives - File parent = newFile.getParentFile(); - if (!parent.isDirectory() && !parent.mkdirs()) { - throw new IOException("Failed to create directory " + parent); - } - - // Write file content - try (FileOutputStream fos = new FileOutputStream(newFile)) { - int len; - while ((len = zis.read(buffer)) > 0) { - fos.write(buffer, 0, len); - } - } - - // Process reqif files and associated images - if (zipEntry.getName().endsWith("reqif")) { - this.numberOfReqIFDocuments++; - this.reqifFiles.put(zipEntry.getName(), newFile); - try (InputStream reqifIS = new FileInputStream(newFile)) { - this.reqifDocuments.put(zipEntry.getName(), new ReqIFDocument(reqifIS, filePath, zipEntry.getName(), typeClassifier)); - } - } else if (zipEntry.getName().endsWith("png") || zipEntry.getName().endsWith("jpeg") || zipEntry.getName().endsWith("jpg")) { - // Keyed by the archive entry path (forward slashes), which is - // what xhtml object data attributes reference. - this.pictureFiles.put(zipEntry.getName(), newFile); - picturesIS.put(zipEntry.getName(), new FileInputStream(newFile)); + this.attachmentFiles.put(entryName, file); + if (isPicture(entryName)) { + this.pictureFiles.put(entryName, file); } } - zipEntry = zis.getNextEntry(); } } - // Associate the archive's pictures with each contained ReqIF document, - // using the same keys as reqifDocuments (full entry name). + // Opened only now, so a failure while extracting leaves no stream open + // that would keep the files from being deleted. Associated with each + // contained ReqIF document, using the same keys as reqifDocuments. + // The map is registered before the streams are opened, so close() + // reaches every stream also if opening one of them fails. + Map picturesIS = new HashMap<>(); for (String reqifDocumentName : this.reqifDocuments.keySet()) { this.picturesInReqIFDocument.put(reqifDocumentName, picturesIS); } + if (!this.reqifDocuments.isEmpty()) { + for (Map.Entry picture : this.pictureFiles.entrySet()) { + picturesIS.put(picture.getKey(), new FileInputStream(picture.getValue())); + } + } + } + + /** + * Closes the picture streams and deletes the extracted files. + */ + @Override + public void close() throws IOException { + + IOException failure = null; + try { + super.close(); + } catch (IOException e) { + failure = e; + } + if (this.extractionDirectory != null && Files.exists(this.extractionDirectory)) { + List files; + try (Stream walk = Files.walk(this.extractionDirectory)) { + // children before their directories + files = walk.sorted(Comparator.reverseOrder()).collect(Collectors.toList()); + } catch (IOException | UncheckedIOException e) { + files = List.of(); + failure = addFailure(failure, e); + } + // a file that cannot be deleted must not keep the others + for (Path file : files) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + failure = addFailure(failure, e); + } + } + } + if (failure != null) { + throw failure; + } + } + + private static boolean isPicture(String entryName) { + String lower = entryName.toLowerCase(Locale.ROOT); + for (String extension : PICTURE_EXTENSIONS) { + if (lower.endsWith(extension)) { + return true; + } + } + return false; + } + + private static IOException addFailure(IOException failure, Exception next) { + IOException asIO = next instanceof UncheckedIOException ? ((UncheckedIOException) next).getCause() + : next instanceof IOException ? (IOException) next : new IOException(next); + if (failure == null) { + return asIO; + } + failure.addSuppressed(asIO); + return failure; } public static String removeFileExtension(String filename, boolean removeAllExtensions) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFzWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFzWriter.java index edbc8ac..1fa4275 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFzWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFzWriter.java @@ -16,19 +16,19 @@ /** * Writes a .reqifz archive: one or more ReqIF documents together with the - * images they reference. + * files they reference - images, embedded OLE objects, PDFs or anything else. * - * Image entries are stored under the path the XHTML {@code object} elements - * reference (forward slashes), so a written archive resolves its images the - * same way a tool-generated one does: + * Attachments are stored under the path the XHTML {@code object} elements + * reference (forward slashes), so a written archive resolves them the same + * way a tool-generated one does: * *
- * Map<String, byte[]> pictures = Map.of("files/diagram.png", pngBytes);
- * new ReqIFzWriter().write(document, "spec.reqif", pictures, Path.of("spec.reqifz"));
+ * Map<String, byte[]> attachments = Map.of("files/diagram.png", pngBytes);
+ * new ReqIFzWriter().write(document, "spec.reqif", attachments, Path.of("spec.reqifz"));
  * 
* - * An archive that was read can be re-packed without consuming its picture - * streams, because {@link ReqIFz} keeps the extracted files: + * An archive that was read can be re-packed; every attachment is copied from + * the files {@link ReqIFz} extracted, without loading them into memory: * *
  * try (ReqIFz source = new ReqIFz("in.reqifz")) {
@@ -38,6 +38,11 @@
  */
 public class ReqIFzWriter {
 
+	/** The content of one archive entry. */
+	private interface Content {
+		void writeTo(OutputStream out) throws IOException;
+	}
+
 	private final ReqIFWriter documentWriter;
 
 	public ReqIFzWriter() {
@@ -53,33 +58,67 @@ public ReqIFzWriter(ReqIFWriter documentWriter) {
 
 
 	/**
-	 * Writes a single document without images.
+	 * Writes a single document without attachments.
 	 */
 	public void write(ReqIFDocument document, String entryName, Path archive) throws IOException {
 		write(document, entryName, null, archive);
 	}
 
 	/**
-	 * Writes a single document together with its images.
+	 * Writes a single document together with its attachments.
 	 *
-	 * @param entryName name of the ReqIF entry inside the archive,
-	 *                  e.g. {@code spec.reqif}
-	 * @param pictures  image data keyed by the archive path referenced from the
-	 *                  XHTML object elements, e.g. {@code files/diagram.png};
-	 *                  may be null
+	 * @param entryName   name of the ReqIF entry inside the archive,
+	 *                    e.g. {@code spec.reqif}
+	 * @param attachments file contents keyed by the archive path referenced
+	 *                    from the XHTML object elements, e.g.
+	 *                    {@code files/diagram.png}; may be null
 	 */
-	public void write(ReqIFDocument document, String entryName, Map pictures, Path archive)
+	public void write(ReqIFDocument document, String entryName, Map attachments, Path archive)
 			throws IOException {
 
 		Map documents = new LinkedHashMap();
 		documents.put(normalizeEntryName(entryName), document);
-		write(documents, pictures, archive);
+		write(documents, attachments, archive);
 	}
 
 	/**
-	 * Writes several documents and their images into one archive.
+	 * Writes several documents and their attachments into one archive.
 	 */
-	public void write(Map documents, Map pictures, Path archive)
+	public void write(Map documents, Map attachments, Path archive)
+			throws IOException {
+
+		Map contents = new LinkedHashMap();
+		if (attachments != null) {
+			for (Map.Entry attachment : attachments.entrySet()) {
+				byte[] bytes = attachment.getValue();
+				contents.put(attachment.getKey(), out -> {
+					if (bytes != null) {
+						out.write(bytes);
+					}
+				});
+			}
+		}
+		writeArchive(documents, contents, archive);
+	}
+
+	/**
+	 * Re-packs an archive that was read: the documents are serialized from the
+	 * object model (so modifications are included) while every other entry -
+	 * images, OLE objects, PDFs, ... - is copied unchanged from the extracted
+	 * files.
+	 */
+	public void write(ReqIFz source, Path archive) throws IOException {
+
+		Map contents = new LinkedHashMap();
+		for (Map.Entry attachment : source.getAttachmentFiles().entrySet()) {
+			Path file = attachment.getValue().toPath();
+			contents.put(attachment.getKey(), out -> Files.copy(file, out));
+		}
+		writeArchive(source.getReqIFDocuments(), contents, archive);
+	}
+
+
+	private void writeArchive(Map documents, Map attachments, Path archive)
 			throws IOException {
 
 		if (documents == null || documents.isEmpty()) {
@@ -88,43 +127,31 @@ public void write(Map documents, Map pict
 
 		// Collect and check the names first: a zip cannot hold the same entry
 		// twice, and the raw ZipException would not say which name clashed.
-		Map entries = new LinkedHashMap();
+		// Documents are serialized up front, so a failure leaves no half
+		// written archive behind.
+		Map entries = new LinkedHashMap();
 		for (Map.Entry document : documents.entrySet()) {
-			addEntry(entries, normalizeEntryName(document.getKey()), documentBytes(document.getValue()));
+			byte[] bytes = documentBytes(document.getValue());
+			addEntry(entries, normalizeEntryName(document.getKey()), out -> out.write(bytes));
 		}
-		if (pictures != null) {
-			for (Map.Entry picture : pictures.entrySet()) {
-				addEntry(entries, normalizeEntryName(picture.getKey()), picture.getValue());
-			}
+		for (Map.Entry attachment : attachments.entrySet()) {
+			addEntry(entries, normalizeEntryName(attachment.getKey()), attachment.getValue());
 		}
 
 		try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
-			for (Map.Entry entry : entries.entrySet()) {
-				writeEntry(zip, entry.getKey(), entry.getValue());
+			for (Map.Entry entry : entries.entrySet()) {
+				zip.putNextEntry(new ZipEntry(entry.getKey()));
+				entry.getValue().writeTo(zip);
+				zip.closeEntry();
 			}
 		}
 	}
 
-	/**
-	 * Re-packs an archive that was read: the documents are serialized from the
-	 * object model (so modifications are included) while the images are copied
-	 * from the extracted files.
-	 */
-	public void write(ReqIFz source, Path archive) throws IOException {
-
-		Map pictures = new LinkedHashMap();
-		for (Map.Entry picture : source.getPictureFiles().entrySet()) {
-			pictures.put(picture.getKey(), Files.readAllBytes(picture.getValue().toPath()));
-		}
-		write(source.getReqIFDocuments(), pictures, archive);
-	}
-
-
-	private static void addEntry(Map entries, String name, byte[] content) {
+	private static void addEntry(Map entries, String name, Content content) {
 
 		if (entries.containsKey(name)) {
 			throw new ReqIFWriteException("Archive entry '" + name + "' is added twice; "
-					+ "document and picture names must be unique within the archive");
+					+ "document and attachment names must be unique within the archive");
 		}
 		entries.put(name, content);
 	}
@@ -136,15 +163,6 @@ private byte[] documentBytes(ReqIFDocument document) throws IOException {
 		return bytes.toByteArray();
 	}
 
-	private static void writeEntry(ZipOutputStream zip, String name, byte[] content) throws IOException {
-
-		zip.putNextEntry(new ZipEntry(name));
-		if (content != null) {
-			zip.write(content);
-		}
-		zip.closeEntry();
-	}
-
 	/**
 	 * Zip entries always use forward slashes, independent of the platform the
 	 * archive is written on.
diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFzAttachmentsTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFzAttachmentsTest.java
new file mode 100644
index 0000000..97af2fd
--- /dev/null
+++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFzAttachmentsTest.java
@@ -0,0 +1,152 @@
+package de.uni_stuttgart.ils.reqif4j;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipOutputStream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFz;
+import de.uni_stuttgart.ils.reqif4j.write.ReqIFzWriter;
+
+/**
+ * A .reqifz archive keeps every file next to its documents - not only
+ * pictures - and extracting it leaves nothing behind.
+ */
+class ReqIFzAttachmentsTest {
+
+	private static final Map ATTACHMENTS = new LinkedHashMap<>();
+	static {
+		ATTACHMENTS.put("files/diagram.ole", new byte[] { (byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0 });
+		ATTACHMENTS.put("files/diagram.png", new byte[] { (byte) 0x89, 'P', 'N', 'G' });
+		ATTACHMENTS.put("files/logo.GIF", "GIF89a".getBytes(StandardCharsets.US_ASCII));
+		ATTACHMENTS.put("files/sub/manual.pdf", "%PDF-1.7".getBytes(StandardCharsets.US_ASCII));
+		ATTACHMENTS.put("files/photo.JPG", new byte[] { (byte) 0xFF, (byte) 0xD8 });
+	}
+
+	@Test
+	void everyEntryIsKeptAsAnAttachment(@TempDir Path tempDir) throws Exception {
+
+		try (ReqIFz reqifz = new ReqIFz(archive(tempDir).toString())) {
+
+			assertEquals(List.of("spec.reqif"), List.copyOf(reqifz.getReqIFDocuments().keySet()));
+			assertEquals(List.copyOf(ATTACHMENTS.keySet()), List.copyOf(reqifz.getAttachmentFiles().keySet()));
+			assertEquals(List.of("files/diagram.png", "files/logo.GIF", "files/photo.JPG"),
+					List.copyOf(reqifz.getPictureFiles().keySet()), "pictures are recognized case-insensitively");
+			for (Map.Entry attachment : reqifz.getAttachmentFiles().entrySet()) {
+				assertArrayEquals(ATTACHMENTS.get(attachment.getKey()), Files.readAllBytes(attachment.getValue().toPath()));
+			}
+		}
+	}
+
+	@Test
+	void repackingKeepsEveryAttachment(@TempDir Path tempDir) throws Exception {
+
+		Path repacked = tempDir.resolve("out.reqifz");
+		try (ReqIFz reqifz = new ReqIFz(archive(tempDir).toString())) {
+			new ReqIFzWriter().write(reqifz, repacked);
+		}
+
+		try (ZipFile zip = new ZipFile(repacked.toFile())) {
+			for (Map.Entry attachment : ATTACHMENTS.entrySet()) {
+				ZipEntry entry = zip.getEntry(attachment.getKey());
+				assertTrue(entry != null, attachment.getKey() + " is lost");
+				try (InputStream in = zip.getInputStream(entry)) {
+					assertArrayEquals(attachment.getValue(), in.readAllBytes());
+				}
+			}
+			assertTrue(zip.getEntry("spec.reqif") != null);
+		}
+	}
+
+	@Test
+	void extractingLeavesNothingBehind(@TempDir Path tempDir) throws Exception {
+
+		Path archive = archive(tempDir);
+		File extracted;
+		try (ReqIFz reqifz = new ReqIFz(archive.toString())) {
+			extracted = reqifz.getAttachmentFiles().get("files/diagram.ole");
+			assertTrue(extracted.isFile());
+		}
+
+		assertFalse(extracted.exists(), "close() deletes the extracted files");
+		try (var siblings = Files.list(tempDir)) {
+			assertEquals(List.of(archive), siblings.toList(), "no directory is created next to the archive");
+		}
+	}
+
+	@Test
+	void archiveCanBeReadFromAStream(@TempDir Path tempDir) throws Exception {
+
+		try (ReqIFz reqifz = new ReqIFz(Files.newInputStream(archive(tempDir)), "upload.reqifz")) {
+			assertEquals("upload.reqifz", reqifz.getName());
+			assertEquals(1, reqifz.getReqIFDocumentsCount());
+			assertEquals(ATTACHMENTS.size(), reqifz.getAttachmentFiles().size());
+		}
+	}
+
+	@Test
+	void documentEntriesAreRecognizedCaseInsensitively(@TempDir Path tempDir) throws Exception {
+
+		Path archive = tempDir.resolve("upper.reqifz");
+		try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
+			put(zip, "SPEC.REQIF", TestFixtures.SCHEMA_VALID_REQIF.getBytes(StandardCharsets.UTF_8));
+			put(zip, "notes.notreqif", new byte[] { 1 });
+		}
+
+		try (ReqIFz reqifz = new ReqIFz(archive.toString())) {
+			assertEquals(List.of("SPEC.REQIF"), List.copyOf(reqifz.getReqIFDocuments().keySet()));
+			assertEquals(List.of("notes.notreqif"), List.copyOf(reqifz.getAttachmentFiles().keySet()));
+		}
+	}
+
+	@Test
+	void duplicateAndBackslashEntriesDoNotBreakReading(@TempDir Path tempDir) throws Exception {
+
+		Path archive = tempDir.resolve("odd.reqifz");
+		try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
+			put(zip, "spec.reqif", TestFixtures.SCHEMA_VALID_REQIF.getBytes(StandardCharsets.UTF_8));
+			put(zip, "files\\old.png", new byte[] { 1 });
+			// some tools append an updated entry under the same name
+			put(zip, "files/old.png", new byte[] { 2 });
+		}
+
+		try (ReqIFz reqifz = new ReqIFz(archive.toString())) {
+			assertEquals(List.of("files/old.png"), List.copyOf(reqifz.getAttachmentFiles().keySet()));
+			assertArrayEquals(new byte[] { 2 }, Files.readAllBytes(reqifz.getAttachmentFiles().get("files/old.png").toPath()),
+					"the later entry wins, as when unzipping");
+		}
+	}
+
+	private static Path archive(Path dir) throws IOException {
+		Path archive = dir.resolve("in.reqifz");
+		try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
+			put(zip, "spec.reqif", TestFixtures.SCHEMA_VALID_REQIF.getBytes(StandardCharsets.UTF_8));
+			for (Map.Entry attachment : ATTACHMENTS.entrySet()) {
+				put(zip, attachment.getKey(), attachment.getValue());
+			}
+		}
+		return archive;
+	}
+
+	private static void put(ZipOutputStream zip, String name, byte[] content) throws IOException {
+		zip.putNextEntry(new ZipEntry(name));
+		zip.write(content);
+		zip.closeEntry();
+	}
+}

From 1e5f24962adfbd105327838d91caf60ebff76535 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sun, 11 Oct 2026 14:12:16 +0000
Subject: [PATCH 12/20] Read imperfect documents instead of failing on them

A single broken element - a value referencing an unknown attribute
definition, an unresolvable type reference, an element without
IDENTIFIER, THE-VALUE="abc" for an integer - used to make the whole
document unreadable with a NullPointerException or NumberFormatException.

The parser now passes a ParseIssues collector through the model
constructors. What cannot be used is skipped, everything else is kept,
and each problem is recorded in ReqIFDocument.getParseIssues():

- elements without IDENTIFIER are skipped;
- elements with an unresolvable type reference are kept with that
  reference, which is written back; only their values are skipped;
- values of unknown attribute definitions are skipped, the others kept;
- values and defaults that do not fit their datatype are kept as written
  and written back unchanged; the validator reports them;
- identifiers used twice within one kind are reported.

ReqIFValidator.validate reports parse issues as warnings: what is still
wrong with the model is reported as an error by its own checks, and stops
being reported once fixed. The model constructors without ParseIssues
read as tolerantly as before and discard the issues.

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN
---
 README.md                                     |  37 ++-
 .../datatypes/DatatypeEnumeration.java        |  16 +-
 .../ils/reqif4j/reqif/ParseIssue.java         |  33 +++
 .../ils/reqif4j/reqif/ParseIssues.java        |  85 +++++++
 .../ils/reqif4j/reqif/ReqIFCoreContent.java   | 215 ++++++++----------
 .../ils/reqif4j/reqif/ReqIFDocument.java      |  10 +
 .../specification/AttributeValueReader.java   |  86 +++++--
 .../specification/RelationGroupType.java      |   7 +-
 .../SpecElementWithAttributes.java            |  33 +++
 .../reqif4j/specification/SpecHierarchy.java  |  22 +-
 .../ils/reqif4j/specification/SpecObject.java |  44 +++-
 .../reqif4j/specification/SpecObjectType.java |   7 +-
 .../reqif4j/specification/SpecRelation.java   |  24 +-
 .../specification/SpecRelationType.java       |   7 +-
 .../ils/reqif4j/specification/SpecType.java   |  74 +++---
 .../reqif4j/specification/Specification.java  |  40 ++--
 .../specification/SpecificationType.java      |   7 +-
 .../ils/reqif4j/validate/ReqIFValidator.java  |   9 +
 .../ils/reqif4j/IdentifiableTest.java         |  23 +-
 .../ils/reqif4j/RobustnessTest.java           |   2 +
 .../ils/reqif4j/TolerantImportTest.java       | 189 +++++++++++++++
 21 files changed, 756 insertions(+), 214 deletions(-)
 create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssue.java
 create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java
 create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/TolerantImportTest.java

diff --git a/README.md b/README.md
index 1d92788..6a9d763 100644
--- a/README.md
+++ b/README.md
@@ -503,9 +503,40 @@ schema files on first use into `target/reqif-schema` (or the directory
 in the `REQIF_SCHEMA_DIR` environment variable); without network access
 these tests are skipped locally and fail on CI.
 
-Not covered yet: identifiers duplicated within one category (the parser
-keys its maps by identifier, so a duplicate has already replaced its
-predecessor by the time the model exists).
+Identifiers duplicated within one category cannot be seen in the model
+(maps are keyed by identifier, so the later element has replaced the
+earlier); the parser reports them while reading, see below.
+
+# Reading imperfect documents
+
+Real exports are not always schema-valid. A document with broken parts is
+still read: what cannot be used is skipped, everything else is kept, and
+each problem is recorded instead of stopping the reading with an
+exception:
+
+```java
+ReqIF reqif = new ReqIF("export.reqif");
+for (ParseIssue issue : reqif.getReqIFDocument().getParseIssues()) {
+    System.out.println(issue);   // e.g. "[so-1] Value references attribute definition ad-x, ..."
+}
+```
+
+- elements without `IDENTIFIER` are skipped;
+- an unresolvable type reference keeps the element and its reference (it
+  is written back), only its values cannot be interpreted and are skipped;
+- values of unknown attribute definitions are skipped, the others kept;
+- values that do not fit their datatype (`THE-VALUE="abc"` for an integer)
+  are kept as written and written back unchanged;
+- identifiers used twice within one kind are reported, the later element
+  wins.
+
+`ReqIFValidator.validate` reports these issues as warnings - they say
+what happened while reading - next to its own findings, which report as
+errors what is still wrong with the model (an unresolved reference, a
+value that does not fit its datatype) until it is fixed. The model
+classes' constructors that take no `ParseIssues` read just as tolerantly
+and discard the issues; only an element without IDENTIFIER makes them
+throw a `ReqIFParseException`.
 
 # Unmodelled ReqIF kinds
 
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java
index 5b1af70..9d6b124 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/DatatypeEnumeration.java
@@ -6,6 +6,7 @@
 import org.w3c.dom.Element;
 import org.w3c.dom.Node;
 
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
 
@@ -67,17 +68,22 @@ public DatatypeEnumeration(String id, String name, java.util.List new DatatypeEnumerationValue(enumValue));
+			issues.put(enumValues, value, "Enum value");
 		}
 	}
 
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssue.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssue.java
new file mode 100644
index 0000000..7149e71
--- /dev/null
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssue.java
@@ -0,0 +1,33 @@
+package de.uni_stuttgart.ils.reqif4j.reqif;
+
+/**
+ * A problem found while reading a document that did not stop the reading,
+ * e.g. a value referencing an attribute definition the document does not
+ * declare. See {@link ReqIFDocument#getParseIssues()}.
+ */
+public final class ParseIssue {
+
+	private final String elementID;
+	private final String message;
+
+	public ParseIssue(String elementID, String message) {
+		this.elementID = elementID;
+		this.message = message;
+	}
+
+	/**
+	 * @return the IDENTIFIER of the element the issue was found on, or null
+	 */
+	public String getElementID() {
+		return this.elementID;
+	}
+
+	public String getMessage() {
+		return this.message;
+	}
+
+	@Override
+	public String toString() {
+		return (this.elementID == null ? "" : "[" + this.elementID + "] ") + this.message;
+	}
+}
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java
new file mode 100644
index 0000000..d7e4c79
--- /dev/null
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java
@@ -0,0 +1,85 @@
+package de.uni_stuttgart.ils.reqif4j.reqif;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Receives the problems found while reading a document.
+ *
+ * A {@link #collecting() collecting} instance lets the reader skip what it
+ * cannot use and carry on, so one broken element does not make a whole
+ * document unreadable; the constructors of the model classes that take no
+ * issues use one and discard it. A {@link #strict() strict} instance stops
+ * at the first problem with a {@link ReqIFParseException}.
+ */
+public final class ParseIssues {
+
+	private final boolean strict;
+	private final List issues = new ArrayList();
+
+	private ParseIssues(boolean strict) {
+		this.strict = strict;
+	}
+
+	public static ParseIssues collecting() {
+		return new ParseIssues(false);
+	}
+
+	public static ParseIssues strict() {
+		return new ParseIssues(true);
+	}
+
+	/**
+	 * @throws ReqIFParseException if this instance is strict
+	 */
+	public void report(String elementID, String message) {
+		ParseIssue issue = new ParseIssue(elementID, message);
+		if (this.strict) {
+			throw new ReqIFParseException(issue.toString());
+		}
+		this.issues.add(issue);
+	}
+
+	/**
+	 * @return the issues in the order they were found (read-only)
+	 */
+	public List getIssues() {
+		return Collections.unmodifiableList(this.issues);
+	}
+
+	/**
+	 * Runs a reader for one element; if the element cannot be read at all (it
+	 * has no IDENTIFIER), it is reported and skipped.
+	 *
+	 * @return what the reader returned, or null if the element was skipped
+	 */
+	public  T readOrSkip(String kind, java.util.function.Supplier reader) {
+		try {
+			return reader.get();
+		} catch (ReqIFParseException e) {
+			if (this.strict) {
+				throw e;
+			}
+			report(null, e.getMessage() + "; the " + kind + " is skipped");
+			return null;
+		}
+	}
+
+	/**
+	 * Adds an element read from the document to the map that holds its kind,
+	 * keyed by identifier. A second element with the same identifier replaces
+	 * the first, which is reported.
+	 *
+	 * @param element may be null (skipped element), then nothing happens
+	 */
+	public  void put(java.util.Map elements, T element, String kind) {
+		if (element == null) {
+			return;
+		}
+		if (elements.containsKey(element.getID())) {
+			report(element.getID(), kind + " " + element.getID() + " is declared twice; the later one replaces the earlier");
+		}
+		elements.put(element.getID(), element);
+	}
+}
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java
index 4a920e8..47530db 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java
@@ -9,8 +9,6 @@
 import de.uni_stuttgart.ils.reqif4j.specification.*;
 import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
 import org.w3c.dom.Element;
-import org.w3c.dom.Node;
-import org.w3c.dom.NodeList;
 
 public class ReqIFCoreContent {
 
@@ -21,6 +19,7 @@ public class ReqIFCoreContent {
     private Map specRelation = new LinkedHashMap<>();
     private Map specifications = new LinkedHashMap();
     private Map relationGroups = new LinkedHashMap();
+    private ParseIssues parseIssues = ParseIssues.collecting();
 
 
     public Map getDatatypes() {
@@ -133,151 +132,137 @@ public ReqIFCoreContent(Element coreContent) {
     }
 
     public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) {
+        this(coreContent, typeClassifier, ParseIssues.collecting());
+    }
 
-        if (typeClassifier == null) {
-            typeClassifier = TypeClassifier.defaultClassifier();
-        }
+    /**
+     * Reads the content of a document. Elements that cannot be read are
+     * skipped and, like every other problem found, reported to {@code issues}
+     * instead of making the whole document unreadable.
+     */
+    public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier, ParseIssues issues) {
 
+        TypeClassifier classifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier;
+        this.parseIssues = issues;
 
         // Every element is matched by local name, so documents that put the
         // ReqIF elements into a prefixed namespace are read as well.
         Element datatypesElement = XmlUtils.firstDescendantByLocalName(coreContent, ReqIFConst.DATATYPES);
-        if (datatypesElement != null) {
-
-            for (Element dataType : XmlUtils.childElements(datatypesElement)) {
-
-                String dataTypeNodeName = XmlUtils.localName(dataType);
-                {
-
-                    String dataTypeID = XmlUtils.attribute(dataType, ReqIFConst.IDENTIFIER);
-                    // LONG-NAME is optional in ReqIF
-                    String dataTypeName = XmlUtils.attribute(dataType, ReqIFConst.LONG_NAME);
-
-                    switch (dataTypeNodeName.substring(dataTypeNodeName.lastIndexOf("-") + 1)) {
-
-                        case ReqIFConst.BOOLEAN:
-                            this.dataTypes.put(dataTypeID, new DatatypeBoolean(dataTypeID, dataTypeName));
-                            break;
-
-                        case ReqIFConst.INTEGER:
-                            // MIN/MAX are optional in ReqIF
-                            String min = XmlUtils.attribute(dataType, ReqIFConst.MIN);
-                            String max = XmlUtils.attribute(dataType, ReqIFConst.MAX);
-                            this.dataTypes.put(dataTypeID, new DatatypeInteger(dataTypeID, dataTypeName, min, max));
-                            break;
-
-                        case ReqIFConst.STRING:
-                            // MAX-LENGTH is optional in ReqIF
-                            String maxLength = XmlUtils.attribute(dataType, ReqIFConst.MAX_LENGTH);
-                            this.dataTypes.put(dataTypeID, new DatatypeString(dataTypeID, dataTypeName, maxLength));
-                            break;
-
-                        case ReqIFConst.ENUMERATION:
-                            Node enumeration = dataType;
-                            this.dataTypes.put(dataTypeID, new DatatypeEnumeration(dataTypeID, dataTypeName, enumeration));
-                            break;
-
-                        case ReqIFConst.XHTML:
-                            this.dataTypes.put(dataTypeID, new DatatypeXHTML(dataTypeID, dataTypeName));
-                            break;
-
-                        case ReqIFConst.DATE:
-                            this.dataTypes.put(dataTypeID, new DatatypeDate(dataTypeID, dataTypeName));
-                            break;
-
-                        case ReqIFConst.DOUBLE, ReqIFConst.REAL:
-                            this.dataTypes.put(dataTypeID, new DatatypeDouble(dataTypeID, dataTypeName,
-                                    XmlUtils.attribute(dataType, ReqIFConst.ACCURACY),
-                                    XmlUtils.attribute(dataType, ReqIFConst.MIN),
-                                    XmlUtils.attribute(dataType, ReqIFConst.MAX)));
-                            break;
-
-                        default:
-                            // Register unknown datatypes under their ID (formerly the
-                            // key was null, making them unresolvable).
-                            this.dataTypes.put(dataTypeID, new Datatype(dataTypeID, dataTypeName, ReqIFConst.UNDEFINED, dataTypeNodeName));
-                            break;
-                    }
-                    // DESC, LAST-CHANGE and ALTERNATIVE-ID
-                    this.dataTypes.get(dataTypeID).readIdentifiable(dataType);
-                }
-            }
+        for (Element dataType : XmlUtils.childElements(datatypesElement)) {
+            issues.put(this.dataTypes, issues.readOrSkip("datatype", () -> readDatatype(dataType, issues)), "Datatype");
         }
 
-
         Element specTypesElement = XmlUtils.firstDescendantByLocalName(coreContent, ReqIFConst.SPEC_TYPES);
-        if (specTypesElement != null) {
-
-            for (Element specType : XmlUtils.childElements(specTypesElement)) {
-
-                String specTypeNodeName = XmlUtils.localName(specType);
-                {
+        for (Element specType : XmlUtils.childElements(specTypesElement)) {
+            issues.put(this.specTypes, issues.readOrSkip("spec type", () -> readSpecType(specType, issues)), "Spec type");
+        }
 
-                    String specTypeID = XmlUtils.attribute(specType, ReqIFConst.IDENTIFIER);
+        for (Element specObj : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPEC_OBJECT)) {
+            SpecType type = resolveType(specObj, ReqIFConst.SPEC_OBJECT_TYPE_REF, issues);
+            issues.put(this.specObjects, issues.readOrSkip("spec object",
+                    () -> new SpecObject(specObj, type, classifier, issues)), "Spec object");
+        }
 
-                    switch (specTypeNodeName) {
+        for (Element specRelation : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPEC_RELATION)) {
+            SpecType type = resolveType(specRelation, ReqIFConst.SPEC_RELATION_TYPE_REF, issues);
+            issues.put(this.specRelation, issues.readOrSkip("spec relation",
+                    () -> new SpecRelation(specRelation, type, issues)), "Spec relation");
+        }
 
-                        case ReqIFConst.SPECIFICATION_TYPE:
-                            this.specTypes.put(specTypeID, new SpecificationType(specType, this.dataTypes));
-                            break;
+        for (Element relationGroup : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.RELATION_GROUP)) {
+            issues.put(this.relationGroups, issues.readOrSkip("relation group",
+                    () -> new RelationGroup(relationGroup)), "Relation group");
+        }
 
-                        case ReqIFConst.SPEC_OBJECT_TYPE:
-                            this.specTypes.put(specTypeID, new SpecObjectType(specType, this.dataTypes));
-                            break;
+        for (Element specification : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPECIFICATION)) {
+            SpecType type = resolveType(specification, ReqIFConst.SPEC_TYPE_REF, issues);
+            issues.put(this.specifications, issues.readOrSkip("specification",
+                    () -> new Specification(specification, type, this.specObjects, issues)), "Specification");
+        }
+    }
 
-                        case ReqIFConst.SPEC_RELATION_TYPE:
-                            this.specTypes.put(specTypeID, new SpecRelationType(specType, this.dataTypes));
-                            break;
+    /**
+     * @return the problems found while reading this content; empty for content
+     *         that was not read from a document
+     */
+    public java.util.List getParseIssues() {
+        return this.parseIssues.getIssues();
+    }
 
-                        case ReqIFConst.RELATION_GROUP_TYPE:
-                            this.specTypes.put(specTypeID, new RelationGroupType(specType, this.dataTypes));
-                            break;
 
-                        default:
-                            this.specTypes.put(specTypeID, new SpecType(specType, this.dataTypes));
-                            break;
-                    }
-                }
-            }
-        }
+    private static Datatype readDatatype(Element dataType, ParseIssues issues) {
 
+        String dataTypeNodeName = XmlUtils.localName(dataType);
+        String dataTypeID = XmlUtils.attribute(dataType, ReqIFConst.IDENTIFIER);
+        // LONG-NAME is optional in ReqIF
+        String dataTypeName = XmlUtils.attribute(dataType, ReqIFConst.LONG_NAME);
 
-        for (Element specObj : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPEC_OBJECT)) {
+        Datatype datatype;
+        switch (dataTypeNodeName.substring(dataTypeNodeName.lastIndexOf("-") + 1)) {
 
-            String specObjID = XmlUtils.attribute(specObj, ReqIFConst.IDENTIFIER);
-            Element typeRef = XmlUtils.firstDescendantByLocalName(specObj, ReqIFConst.SPEC_OBJECT_TYPE_REF);
-            String specObjTypeRef = typeRef == null ? null : typeRef.getTextContent().trim();
+            case ReqIFConst.BOOLEAN:
+                datatype = new DatatypeBoolean(dataTypeID, dataTypeName);
+                break;
 
-            this.specObjects.put(specObjID, new SpecObject(specObj, this.specTypes.get(specObjTypeRef), typeClassifier));
-        }
+            case ReqIFConst.INTEGER:
+                // MIN/MAX are optional in ReqIF
+                datatype = new DatatypeInteger(dataTypeID, dataTypeName,
+                        XmlUtils.attribute(dataType, ReqIFConst.MIN), XmlUtils.attribute(dataType, ReqIFConst.MAX));
+                break;
 
+            case ReqIFConst.STRING:
+                // MAX-LENGTH is optional in ReqIF
+                datatype = new DatatypeString(dataTypeID, dataTypeName, XmlUtils.attribute(dataType, ReqIFConst.MAX_LENGTH));
+                break;
 
-        for (Element specRelation : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPEC_RELATION)) {
+            case ReqIFConst.ENUMERATION:
+                datatype = new DatatypeEnumeration(dataTypeID, dataTypeName, dataType, issues);
+                break;
 
-            String specRelID = XmlUtils.attribute(specRelation, ReqIFConst.IDENTIFIER);
-            Element typeRef = XmlUtils.firstDescendantByLocalName(specRelation, ReqIFConst.SPEC_RELATION_TYPE_REF);
-            String specRelTypeRef = typeRef == null ? null : typeRef.getTextContent().trim();
+            case ReqIFConst.XHTML:
+                datatype = new DatatypeXHTML(dataTypeID, dataTypeName);
+                break;
 
-            this.specRelation.put(specRelID, new SpecRelation(specRelation, this.specTypes.get(specRelTypeRef)));
-        }
+            case ReqIFConst.DATE:
+                datatype = new DatatypeDate(dataTypeID, dataTypeName);
+                break;
 
+            case ReqIFConst.DOUBLE, ReqIFConst.REAL:
+                datatype = new DatatypeDouble(dataTypeID, dataTypeName,
+                        XmlUtils.attribute(dataType, ReqIFConst.ACCURACY),
+                        XmlUtils.attribute(dataType, ReqIFConst.MIN),
+                        XmlUtils.attribute(dataType, ReqIFConst.MAX));
+                break;
 
-        for (Element relationGroup : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.RELATION_GROUP)) {
-            RelationGroup group = new RelationGroup(relationGroup);
-            this.relationGroups.put(group.getID(), group);
+            default:
+                // kinds the parser does not model are kept under their element name
+                datatype = new Datatype(dataTypeID, dataTypeName, ReqIFConst.UNDEFINED, dataTypeNodeName);
+                break;
         }
+        // IDENTIFIER (required, checked here), DESC, LAST-CHANGE and ALTERNATIVE-ID
+        datatype.readIdentifiable(dataType);
+        return datatype;
+    }
 
+    private SpecType readSpecType(Element specType, ParseIssues issues) {
 
-        for (Element specification : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPECIFICATION)) {
-
-            String specID = XmlUtils.attribute(specification, ReqIFConst.IDENTIFIER);
-            Element typeRef = XmlUtils.firstDescendantByLocalName(specification, ReqIFConst.SPEC_TYPE_REF);
-            String specTypeRef = typeRef == null ? null : typeRef.getTextContent().trim();
-
-            this.specifications.put(specID, new Specification(specification, this.specTypes.get(specTypeRef), this.specObjects));
+        switch (XmlUtils.localName(specType)) {
+            case ReqIFConst.SPECIFICATION_TYPE:		return new SpecificationType(specType, this.dataTypes, issues);
+            case ReqIFConst.SPEC_OBJECT_TYPE:		return new SpecObjectType(specType, this.dataTypes, issues);
+            case ReqIFConst.SPEC_RELATION_TYPE:		return new SpecRelationType(specType, this.dataTypes, issues);
+            case ReqIFConst.RELATION_GROUP_TYPE:	return new RelationGroupType(specType, this.dataTypes, issues);
+            default:								return new SpecType(specType, this.dataTypes, issues);
         }
+    }
 
+    /**
+     * @return the spec type the element's TYPE refers to, or null if the
+     *         reference cannot be resolved (the element reports that)
+     */
+    private SpecType resolveType(Element element, String refName, ParseIssues issues) {
 
+        Element typeRef = XmlUtils.firstDescendantByLocalName(element, refName);
+        return typeRef == null ? null : this.specTypes.get(typeRef.getTextContent().trim());
     }
 
 }
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java
index 0e325f8..c7415ae 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java
@@ -45,6 +45,16 @@ public ReqIFCoreContent getCoreContent() {
 		return this.content;
 	}
 
+	/**
+	 * @return the problems found while reading the document - skipped
+	 *         elements, unresolved references, identifiers used twice - in the
+	 *         order they were found; empty for a document that was not read
+	 *         from a file. {@code ReqIFValidator} reports them as warnings.
+	 */
+	public java.util.List getParseIssues() {
+		return this.content == null ? java.util.List.of() : this.content.getParseIssues();
+	}
+
 	/**
 	 * @return the document language (xml:lang of REQ-IF), e.g. "de", or null
 	 *         if the document declares none
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java
index 9d3a38b..d555b21 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValueReader.java
@@ -17,8 +17,10 @@
 import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueString;
 import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML;
 import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
+import de.uni_stuttgart.ils.reqif4j.util.XsdTypes;
 
 /**
  * Reads the VALUES of an element with attributes - spec objects, spec
@@ -33,11 +35,17 @@ private AttributeValueReader() {
 	/**
 	 * Reads the explicit values, the ones the element itself carries.
 	 *
+	 * A value whose definition cannot be found is reported and skipped. A
+	 * value that does not fit its datatype (e.g. THE-VALUE="abc" for an
+	 * integer) is kept as written, as a plain {@link AttributeValue}, so it
+	 * is written back unchanged and the validator can report it.
+	 *
 	 * @param element  the SPEC-OBJECT, SPEC-RELATION or SPECIFICATION
 	 * @param specType the element's resolved spec type
 	 * @param values   receives the values
+	 * @param issues   receives the problems found
 	 */
-	static void read(Node element, SpecType specType, AttributeValues values) {
+	static void read(Node element, SpecType specType, AttributeValues values, ParseIssues issues) {
 
 		// VALUES is located by local name so prefixed ReqIF namespaces work too;
 		// only this element's own VALUES is taken, not a nested one.
@@ -45,26 +53,53 @@ static void read(Node element, SpecType specType, AttributeValues values) {
 		if(valuesElement == null) {
 			return;
 		}
+		String ownerID = XmlUtils.attribute(element, ReqIFConst.IDENTIFIER);
 
 		for(Element attribute: XmlUtils.childElements(valuesElement)) {
 
 			String attValNodeName = XmlUtils.localName(attribute);
-			String attributeDefinitionRef = XmlUtils.firstChildElement(
-					XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim();
+			Element definitionRef = XmlUtils.firstChildElement(
+					XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION));
+			if(definitionRef == null) {
+				issues.report(ownerID, attValNodeName + " has no DEFINITION; the value is skipped");
+				continue;
+			}
+			String attributeDefinitionRef = definitionRef.getTextContent().trim();
 			AttributeDefinition attributeDefinition = specType.getAttributeDefinition(attributeDefinitionRef);
+			if(attributeDefinition == null) {
+				issues.report(ownerID, "Value references attribute definition " + attributeDefinitionRef
+						+ ", which spec type " + specType.getID() + " does not declare; the value is skipped");
+				continue;
+			}
+			if(values.explicit().containsKey(attributeDefinitionRef)) {
+				issues.report(ownerID, "Attribute definition " + attributeDefinitionRef
+						+ " has two values; the later one replaces the earlier");
+			}
 
-			switch(attValNodeName.substring(attValNodeName.lastIndexOf("-")+1)) {
+			String theValue = theValue(attribute);
+			String kind = attValNodeName.substring(attValNodeName.lastIndexOf("-")+1);
+			if(THE_VALUE_KINDS.contains(kind) && XmlUtils.attribute(attribute, ReqIFConst.THE_VALUE) == null) {
+				// read as the neutral value (false, 0, ""), as earlier versions did
+				issues.report(ownerID, attValNodeName + " for " + attributeDefinitionRef
+						+ " has no THE-VALUE; it is read as empty");
+			}
+			switch(kind) {
 
 				case ReqIFConst.BOOLEAN:
-					values.put(new AttributeValueBoolean(theValue(attribute), attributeDefinition));
+					// any case is accepted, as earlier versions did; other text is kept
+					values.put(theValue.isEmpty() || BOOLEAN_TEXT.contains(theValue.trim().toLowerCase(java.util.Locale.ROOT))
+							? new AttributeValueBoolean(theValue, attributeDefinition)
+							: asWritten(theValue, attributeDefinition, attValNodeName));
 					break;
 
 				case ReqIFConst.INTEGER:
-					values.put(new AttributeValueInteger(theValue(attribute), attributeDefinition));
+					values.put(theValue.isEmpty() || XsdTypes.isInteger(theValue)
+							? new AttributeValueInteger(theValue, attributeDefinition)
+							: asWritten(theValue, attributeDefinition, attValNodeName));
 					break;
 
 				case ReqIFConst.STRING:
-					values.put(new AttributeValueString(theValue(attribute), attributeDefinition));
+					values.put(new AttributeValueString(theValue, attributeDefinition));
 					break;
 
 				case ReqIFConst.ENUMERATION:
@@ -84,24 +119,39 @@ static void read(Node element, SpecType specType, AttributeValues values) {
 					break;
 
 				case ReqIFConst.DATE:
-					values.put(new AttributeValueDate(theValue(attribute), attributeDefinition));
+					values.put(new AttributeValueDate(theValue, attributeDefinition));
 					break;
 
 				case ReqIFConst.REAL:
-					values.put(new AttributeValueDouble(theValue(attribute), attributeDefinition));
+					values.put(theValue.isEmpty() || XsdTypes.isDouble(theValue)
+							? new AttributeValueDouble(theValue, attributeDefinition)
+							: asWritten(theValue, attributeDefinition, attValNodeName));
 					break;
 
 				// Values of datatype kinds the parser does not model are kept
 				// generically instead of being dropped, so they survive a
 				// round trip.
 				default:
-					values.put(new AttributeValue(theValue(attribute), attributeDefinition)
-							.setSourceElementName(attValNodeName));
+					values.put(asWritten(theValue, attributeDefinition, attValNodeName));
 					break;
 			}
 		}
 	}
 
+	private static final java.util.Set BOOLEAN_TEXT = java.util.Set.of("true", "false", "1", "0");
+
+	/** the value kinds that carry their value in a required THE-VALUE attribute */
+	private static final java.util.Set THE_VALUE_KINDS = java.util.Set.of(
+			ReqIFConst.BOOLEAN, ReqIFConst.INTEGER, ReqIFConst.STRING, ReqIFConst.DATE, ReqIFConst.REAL);
+
+	/**
+	 * @return the value exactly as written, for kinds the parser does not model
+	 *         and for text that does not fit the datatype
+	 */
+	private static AttributeValue asWritten(String text, AttributeDefinition definition, String elementName) {
+		return new AttributeValue(text, definition).setSourceElementName(elementName);
+	}
+
 	/**
 	 * @return the value that stands in for an absent one: the definition's
 	 *         default, or the neutral value of its datatype (false, 0, ...) as
@@ -116,11 +166,17 @@ static AttributeValue defaultValue(AttributeDefinition attributeDefinition) {
 		String defaultValue = attributeDefinition.getDefaultValue();
 		switch(datatype.getType()) {
 
+			// a default that does not fit its datatype is kept as written, like a
+			// value; the validator reports it
 			case ReqIFConst.BOOLEAN:
-				return new AttributeValueBoolean(defaultValue, attributeDefinition);
+				return defaultValue == null || BOOLEAN_TEXT.contains(defaultValue.trim().toLowerCase(java.util.Locale.ROOT))
+						? new AttributeValueBoolean(defaultValue, attributeDefinition)
+						: new AttributeValue(defaultValue, attributeDefinition);
 
 			case ReqIFConst.INTEGER:
-				return new AttributeValueInteger(defaultValue, attributeDefinition);
+				return defaultValue == null || XsdTypes.isInteger(defaultValue)
+						? new AttributeValueInteger(defaultValue, attributeDefinition)
+						: new AttributeValue(defaultValue, attributeDefinition);
 
 			case ReqIFConst.STRING:
 				return new AttributeValueString(defaultValue, attributeDefinition);
@@ -143,7 +199,9 @@ static AttributeValue defaultValue(AttributeDefinition attributeDefinition) {
 			// the parser normalizes REAL datatypes to DOUBLE
 			case ReqIFConst.DOUBLE:
 			case ReqIFConst.REAL:
-				return new AttributeValueDouble(defaultValue, attributeDefinition);
+				return defaultValue == null || XsdTypes.isDouble(defaultValue)
+						? new AttributeValueDouble(defaultValue, attributeDefinition)
+						: new AttributeValue(defaultValue, attributeDefinition);
 
 			default:
 				return null;
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroupType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroupType.java
index a87a529..9095d16 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroupType.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroupType.java
@@ -5,13 +5,18 @@
 import org.w3c.dom.Node;
 
 import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 
 public class RelationGroupType extends SpecType {
 
 
 	public RelationGroupType(Node specType, Map dataTypes) {
-		super(specType, dataTypes);
+		this(specType, dataTypes, ParseIssues.collecting());
+	}
+
+	public RelationGroupType(Node specType, Map dataTypes, ParseIssues issues) {
+		super(specType, dataTypes, issues);
 
 		this.type = ReqIFConst.RELATION_GROUP_TYPE;
 	}
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java
index 09d9cdd..08f713d 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java
@@ -2,10 +2,14 @@
 
 import java.util.Map;
 
+import org.w3c.dom.Element;
 import org.w3c.dom.Node;
 
 import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue;
 import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
+import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
+import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
 
 /**
  * An element that carries attribute values: spec objects, spec relations and
@@ -108,4 +112,33 @@ AttributeValues resetValues(SpecType specType) {
 	 */
 	protected void valuesChanged() {
 	}
+
+	/**
+	 * Reads the element's values. Without a resolved spec type there are no
+	 * attribute definitions to interpret them by; that is reported once,
+	 * together with the number of values skipped.
+	 *
+	 * @param typeRef the unresolved type reference, for the report
+	 */
+	void readValues(Node element, SpecType specType, String typeRef, ParseIssues issues) {
+
+		AttributeValues values = resetValues(specType);
+		if (specType != null) {
+			AttributeValueReader.read(element, specType, values, issues);
+			return;
+		}
+		int count = XmlUtils.childElements(XmlUtils.firstChildElementByLocalName(element, ReqIFConst.VALUES)).size();
+		issues.report(getID(), XmlUtils.localName(element)
+				+ (typeRef == null ? " has no type reference" : " references type " + typeRef + ", which is not declared")
+				+ (count == 0 ? "" : "; its " + count + " value" + (count == 1 ? " is" : "s are") + " skipped"));
+	}
+
+	/**
+	 * @return the IDENTIFIER in TYPE/{refName}, or null
+	 */
+	static String typeRef(Node element, String refName) {
+		Element type = XmlUtils.firstChildElementByLocalName(element, ReqIFConst.TYPE);
+		Element ref = XmlUtils.firstChildElementByLocalName(type, refName);
+		return ref == null ? null : ref.getTextContent().trim();
+	}
 }
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java
index 8cfc5f5..9c445ab 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java
@@ -14,6 +14,7 @@
 import de.uni_stuttgart.ils.reqif4j.attributes.XHTML;
 import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTMLElementList;
 import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
 import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLNode;
@@ -233,6 +234,15 @@ public SpecHierarchy(String specHierarchyID, int hierarchyLvl, int section, Spec
 	}
 
 	public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map specObjects) {
+		this(hierarchyLvl, section, specHierarchy, specObjects, ParseIssues.collecting());
+	}
+
+	/**
+	 * @param issues receives nested hierarchy nodes that cannot be read or are
+	 *               declared twice
+	 */
+	public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map specObjects,
+			ParseIssues issues) {
 		super(specHierarchy);
 		
 		this.hierarchyLvl = hierarchyLvl;
@@ -256,14 +266,10 @@ public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map new SpecHierarchy(this.hierarchyLvl+1, section, newSpecHierarchy, specObjects, issues));
+			issues.put(this.children, child, "Spec hierarchy");
 		}
 	}
 }
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java
index cf5b772..670cfab 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java
@@ -1,17 +1,18 @@
 package de.uni_stuttgart.ils.reqif4j.specification;
 
 import java.util.Collection;
-import java.util.Map;
 
 import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue;
 import org.w3c.dom.Node;
 
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 
 public class SpecObject extends SpecElementWithAttributes {
 	
 	
 	protected SpecType specType;
+	private String specTypeRef;
 	protected String type;
 	protected TypeClassifier typeClassifier = TypeClassifier.defaultClassifier();
 	
@@ -22,20 +23,28 @@ public String getType() {
 		return this.type;
 	}
 	
+	/**
+	 * @return the kind of the spec type (e.g. SPEC-OBJECT-TYPE), or null if
+	 *         the type reference could not be resolved
+	 */
 	public String getSpecType() {
-		return this.specType.getType();
+		return this.specType == null ? null : this.specType.getType();
 	}
 
+	/**
+	 * @return the LONG-NAME of the spec type, or null if the type reference
+	 *         could not be resolved
+	 */
 	public String getSpecTypeName() {
-		return this.specType.getName();
+		return this.specType == null ? null : this.specType.getName();
 	}
 
 	/**
-	 * @return the IDENTIFIER of the spec type this object refers to, or null if
-	 *         the reference could not be resolved
+	 * @return the IDENTIFIER of the spec type this object refers to - also if
+	 *         the reference could not be resolved - or null if it has none
 	 */
 	public String getSpecTypeID() {
-		return this.specType == null ? null : this.specType.getID();
+		return this.specType != null ? this.specType.getID() : this.specTypeRef;
 	}
 	
 	
@@ -100,12 +109,23 @@ public SpecObject(Node specObject, SpecType specType) {
 	}
 
 	public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassifier) {
+		this(specObject, specType, typeClassifier, ParseIssues.collecting());
+	}
+
+	/**
+	 * @param specType the resolved spec type; if null, the object is kept with
+	 *                 its unresolved type reference, but its values cannot be
+	 *                 interpreted and are reported and skipped
+	 * @param issues   receives the problems found
+	 */
+	public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassifier, ParseIssues issues) {
 		super(specObject);
 
 		this.specType = specType;
+		this.specTypeRef = typeRef(specObject, ReqIFConst.SPEC_OBJECT_TYPE_REF);
 		this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier;
 
-		readAttributeValues(specObject, specType);
+		readAttributeValues(specObject, specType, issues);
 
 		// Classify only after all attribute values are available, so an
 		// attribute-based classifier (e.g. the ReqIF Implementation Guide
@@ -127,7 +147,15 @@ protected void valuesChanged() {
 	 * {@link SpecObject} and {@link SpecRelation}.
 	 */
 	protected void readAttributeValues(Node specObject, SpecType specType) {
-		AttributeValueReader.read(specObject, specType, resetValues(specType));
+		readAttributeValues(specObject, specType, ParseIssues.collecting());
+	}
+
+	/**
+	 * Reads the attribute values of the given node; without a spec type they
+	 * cannot be interpreted and are reported and skipped.
+	 */
+	protected void readAttributeValues(Node specObject, SpecType specType, ParseIssues issues) {
+		readValues(specObject, specType, getSpecTypeID(), issues);
 	}
 
 }
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java
index 39daf5d..28759a2 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObjectType.java
@@ -5,13 +5,18 @@
 import org.w3c.dom.Node;
 
 import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 
 public class SpecObjectType extends SpecType {
 	
 	
 	public SpecObjectType(Node specType, Map dataTypes) {
-		super(specType, dataTypes);
+		this(specType, dataTypes, ParseIssues.collecting());
+	}
+
+	public SpecObjectType(Node specType, Map dataTypes, ParseIssues issues) {
+		super(specType, dataTypes, issues);
 
 		this.type = ReqIFConst.SPEC_OBJECT_TYPE;
 	}
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java
index 76e2c57..20a31fc 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java
@@ -3,6 +3,7 @@
 import java.util.Collection;
 
 import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
 import org.w3c.dom.Element;
@@ -40,6 +41,16 @@ public SpecRelation(String id, SpecType specType, String sourceObjID, String tar
     }
 
     public SpecRelation(Node specRelation, SpecType specType) {
+        this(specRelation, specType, ParseIssues.collecting());
+    }
+
+    /**
+     * @param specType the resolved relation type; if null, the relation is
+     *                 kept with its unresolved type reference, but its values
+     *                 cannot be interpreted and are reported and skipped
+     * @param issues   receives the problems found
+     */
+    public SpecRelation(Node specRelation, SpecType specType, ParseIssues issues) {
         super(specRelation);
         this.specType = specType;
 
@@ -58,9 +69,16 @@ public SpecRelation(Node specRelation, SpecType specType) {
         // relationTypeRef, not in the inherited type field.
         this.type = ReqIFConst.UNDEFINED;
 
-        if (specType != null) {
-            readAttributeValues(specRelation, specType);
-        }
+        readAttributeValues(specRelation, specType, issues);
+    }
+
+    /**
+     * @return the IDENTIFIER of the relation type - also if the reference
+     *         could not be resolved - or null if it has none
+     */
+    @Override
+    public String getSpecTypeID() {
+        return this.specType != null ? this.specType.getID() : this.relationTypeRef;
     }
 
     public String getSourceObjID() {
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java
index f1a3445..10014de 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelationType.java
@@ -5,13 +5,18 @@
 import org.w3c.dom.Node;
 
 import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 
 public class SpecRelationType extends SpecType {
 	
 	
 	public SpecRelationType(Node specType, Map dataTypes) {
-		super(specType, dataTypes);
+		this(specType, dataTypes, ParseIssues.collecting());
+	}
+
+	public SpecRelationType(Node specType, Map dataTypes, ParseIssues issues) {
+		super(specType, dataTypes, issues);
 
 		this.type = ReqIFConst.SPEC_RELATION_TYPE;
 	}
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java
index 07842a8..e2f5c06 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java
@@ -10,6 +10,7 @@
 import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype;
 import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration;
 import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
 
@@ -124,6 +125,14 @@ public SpecType addAttributeDefinition(AttributeDefinition attributeDefinition)
 	}
 
 	public SpecType(Node specType, Map dataTypes) {
+		this(specType, dataTypes, ParseIssues.collecting());
+	}
+
+	/**
+	 * @param issues receives attribute definitions that cannot be read or are
+	 *               declared twice
+	 */
+	public SpecType(Node specType, Map dataTypes, ParseIssues issues) {
 		super(specType);
 
 		this.sourceElementName = XmlUtils.localName(specType);
@@ -131,47 +140,36 @@ public SpecType(Node specType, Map dataTypes) {
 
 		//Doors relationship definitionen habe keine ChildNodes
 		Node specAttributes = XmlUtils.firstChildElementByLocalName(specType, ReqIFConst.SPEC_ATTRIBUTES);
-		if(specAttributes != null) {
-
-			for(Node attributeDefinition: XmlUtils.childElements(specAttributes)) {
-
-				String attDefNodeName = XmlUtils.localName(attributeDefinition);
-				{
-					
-					String attDefID = XmlUtils.attribute(attributeDefinition, ReqIFConst.IDENTIFIER);
-					
-					switch(attDefNodeName.substring(attDefNodeName.lastIndexOf("-")+1)) {
-					
-						case ReqIFConst.BOOLEAN:		this.attributeDefinitions.put(attDefID, new AttributeDefinitionBoolean(attributeDefinition, dataTypes));
-														break;
-											
-						case ReqIFConst.INTEGER:		this.attributeDefinitions.put(attDefID, new AttributeDefinitionInteger(attributeDefinition, dataTypes));
-														break;
-											
-						case ReqIFConst.STRING:			this.attributeDefinitions.put(attDefID, new AttributeDefinitionString(attributeDefinition, dataTypes));
-														break;
-											
-						case ReqIFConst.ENUMERATION:	this.attributeDefinitions.put(attDefID, new AttributeDefinitionEnumeration(attributeDefinition, dataTypes));
-														break;
-											
-						case ReqIFConst.XHTML:			this.attributeDefinitions.put(attDefID, new AttributeDefinitionXHTML(attributeDefinition, dataTypes));
-														break;
-
-						case ReqIFConst.DATE:			this.attributeDefinitions.put(attDefID, new AttributeDefinitionDate(attributeDefinition, dataTypes));
-														break;
-
-						case ReqIFConst.DOUBLE:			this.attributeDefinitions.put(attDefID, new AttributeDefinitionDouble(attributeDefinition, dataTypes));
-														break;
-						//Convert REAL to DOUBLE
-						case ReqIFConst.REAL:			this.attributeDefinitions.put(attDefID, new AttributeDefinitionDouble(attributeDefinition, dataTypes));
-														break;
-											
-						default:						this.attributeDefinitions.put(attDefID, new AttributeDefinition(attributeDefinition, dataTypes));
-														break;
-					}
+		for(Node attributeDefinition: XmlUtils.childElements(specAttributes)) {
+
+			AttributeDefinition definition = issues.readOrSkip("attribute definition",
+					() -> readAttributeDefinition(attributeDefinition, dataTypes));
+			if(definition != null) {
+				if(definition.getDataType() == null) {
+					issues.report(definition.getID(), "Attribute definition references a datatype that is not declared "
+							+ "in DATATYPES; it and its values cannot be written");
 				}
+				issues.put(this.attributeDefinitions, definition, "Attribute definition");
 			}
 		}
 	}
 
+	private static AttributeDefinition readAttributeDefinition(Node attributeDefinition, Map dataTypes) {
+
+		String attDefNodeName = XmlUtils.localName(attributeDefinition);
+		switch(attDefNodeName.substring(attDefNodeName.lastIndexOf("-")+1)) {
+
+			case ReqIFConst.BOOLEAN:		return new AttributeDefinitionBoolean(attributeDefinition, dataTypes);
+			case ReqIFConst.INTEGER:		return new AttributeDefinitionInteger(attributeDefinition, dataTypes);
+			case ReqIFConst.STRING:			return new AttributeDefinitionString(attributeDefinition, dataTypes);
+			case ReqIFConst.ENUMERATION:	return new AttributeDefinitionEnumeration(attributeDefinition, dataTypes);
+			case ReqIFConst.XHTML:			return new AttributeDefinitionXHTML(attributeDefinition, dataTypes);
+			case ReqIFConst.DATE:			return new AttributeDefinitionDate(attributeDefinition, dataTypes);
+			// REAL is read as DOUBLE
+			case ReqIFConst.DOUBLE:
+			case ReqIFConst.REAL:			return new AttributeDefinitionDouble(attributeDefinition, dataTypes);
+			default:						return new AttributeDefinition(attributeDefinition, dataTypes);
+		}
+	}
+
 }
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java
index 9573b45..c034527 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java
@@ -9,6 +9,7 @@
 import org.w3c.dom.Node;
 
 import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 import de.uni_stuttgart.ils.reqif4j.util.XmlUtils;
 
@@ -16,6 +17,7 @@ public class Specification extends SpecElementWithAttributes {
 	
 	
 	private SpecType type;
+	private String specTypeRef;
 	private int numberOfSpecObjects;
 	private int sectionCounter = 0;
 	private Map children = new LinkedHashMap();
@@ -36,19 +38,19 @@ public String getDescription() {
 	
 	
 	public String getSpecType() {
-		return this.type.getType();
+		return this.type == null ? null : this.type.getType();
 	}
 	
 	public String getSpecTypeName() {
-		return this.type.getName();
+		return this.type == null ? null : this.type.getName();
 	}
 
 	/**
-	 * @return the IDENTIFIER of the specification type, or null if the
-	 *         reference could not be resolved
+	 * @return the IDENTIFIER of the specification type - also if the
+	 *         reference could not be resolved - or null if it has none
 	 */
 	public String getSpecTypeID() {
-		return this.type == null ? null : this.type.getID();
+		return this.type != null ? this.type.getID() : this.specTypeRef;
 	}
 	
 	
@@ -116,21 +118,33 @@ public Specification(String id, String name, SpecType specType,
 	}
 
 	public Specification(Node specification, SpecType specType, Map specObjects) {
+		this(specification, specType, specObjects, ParseIssues.collecting());
+	}
+
+	/**
+	 * @param specType the resolved specification type; if null, the
+	 *                 specification is kept with its unresolved type reference,
+	 *                 but its values cannot be interpreted and are reported and
+	 *                 skipped
+	 * @param issues   receives the problems found
+	 */
+	public Specification(Node specification, SpecType specType, Map specObjects, ParseIssues issues) {
 		super(specification);
 		
 		this.type = specType;
+		this.specTypeRef = typeRef(specification, ReqIFConst.SPEC_TYPE_REF);
 
-		AttributeValueReader.read(specification, specType, resetValues(specType));
+		readValues(specification, specType, this.specTypeRef, issues);
 
 		Element childrenElement = XmlUtils.firstChildElementByLocalName(specification, ReqIFConst.CHILDREN);
-		if(childrenElement != null) {
-
-			for(Element specHierarchy: XmlUtils.childElements(childrenElement)) {
-
-				String specHierarchyID = XmlUtils.attribute(specHierarchy, ReqIFConst.IDENTIFIER);
-
-				this.children.put(specHierarchyID, new SpecHierarchy(1, ++this.sectionCounter, specHierarchy, specObjects));
+		for(Element specHierarchy: XmlUtils.childElements(childrenElement)) {
+			int section = this.sectionCounter + 1;
+			SpecHierarchy child = issues.readOrSkip("spec hierarchy",
+					() -> new SpecHierarchy(1, section, specHierarchy, specObjects, issues));
+			if(child != null) {
+				this.sectionCounter = section;
 			}
+			issues.put(this.children, child, "Spec hierarchy");
 		}
 		
 		List allChildren = new ArrayList();
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java
index d81202c..7ad8e9c 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecificationType.java
@@ -5,13 +5,18 @@
 import org.w3c.dom.Node;
 
 import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 
 public class SpecificationType extends SpecType {
 	
 	
 	public SpecificationType(Node specType, Map dataTypes) {
-		super(specType, dataTypes);
+		this(specType, dataTypes, ParseIssues.collecting());
+	}
+
+	public SpecificationType(Node specType, Map dataTypes, ParseIssues issues) {
+		super(specType, dataTypes, issues);
 
 		this.type = ReqIFConst.SPECIFICATION_TYPE;
 	}
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java
index f9c0b1d..80889fb 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java
@@ -35,6 +35,7 @@
 import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeInteger;
 import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeString;
 import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable;
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssue;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent;
 import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument;
@@ -94,6 +95,14 @@ public ValidationResult validate(ReqIFDocument document) {
 			result.error(null, "Document is null");
 			return result;
 		}
+		// What the parser had to skip or could not resolve. Warnings: they tell
+		// what happened to the file, while what is still wrong with the model -
+		// an unresolved reference, a value that does not fit - is reported as
+		// an error by the checks below, and stops being reported once fixed.
+		for (ParseIssue issue : document.getParseIssues()) {
+			result.warning(issue.getElementID(), "While reading: " + issue.getMessage());
+		}
+
 		Map identifiers = new HashMap();
 		if (document.getHeader() == null) {
 			// the schema requires THE-HEADER, and the writer cannot invent its IDENTIFIER
diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java
index 3823525..3477244 100644
--- a/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java
+++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/IdentifiableTest.java
@@ -143,15 +143,32 @@ void valuesOfUnnamedAttributeDefinitionsDoNotOverwriteEachOther(@TempDir Path te
 	}
 
 	@Test
-	void elementWithoutIdentifierIsRejectedWithAClearMessage(@TempDir Path tempDir) throws Exception {
+	void elementWithoutIdentifierIsSkippedAndReported(@TempDir Path tempDir) throws Exception {
 
 		String withoutIdentifier = TestFixtures.SCHEMA_VALID_REQIF
 				.replace(" new ReqIF(file.toString()));
+		ReqIF reqif = new ReqIF(file.toString());
 
-		assertEquals("SPECIFICATION-TYPE without IDENTIFIER", failure.getMessage());
+		assertEquals("SPECIFICATION-TYPE without IDENTIFIER; the spec type is skipped",
+				reqif.getReqIFDocument().getParseIssues().get(0).getMessage());
+		assertNull(reqif.getReqIFCoreContent().getSpecType("st-spec"));
+		// the specification referencing it is kept, with its type reference
+		assertEquals("st-spec", reqif.getReqIFCoreContent().getSpecification("spec-1").getSpecTypeID());
+	}
+
+	@Test
+	void modelConstructorsRejectAnElementWithoutIdentifier() throws Exception {
+
+		org.w3c.dom.Element withoutIdentifier = de.uni_stuttgart.ils.reqif4j.util.SecureXml.newDocumentBuilder()
+				.parse(new org.xml.sax.InputSource(new java.io.StringReader("")))
+				.getDocumentElement();
+
+		ReqIFParseException failure = assertThrows(ReqIFParseException.class,
+				() -> new de.uni_stuttgart.ils.reqif4j.specification.SpecObjectType(withoutIdentifier, java.util.Map.of()));
+
+		assertEquals("SPEC-OBJECT-TYPE without IDENTIFIER", failure.getMessage());
 	}
 
 	@Test
diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/RobustnessTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/RobustnessTest.java
index 450a674..417f536 100644
--- a/src/test/java/de/uni_stuttgart/ils/reqif4j/RobustnessTest.java
+++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/RobustnessTest.java
@@ -82,6 +82,8 @@ void emptyNumericValuesDefaultToZero(@TempDir Path tempDir) throws Exception {
 
 		assertEquals(0, so1.getAttribute("Priority"),
 				"a missing THE-VALUE on an integer attribute must parse as 0, not crash");
+		assertTrue(reqif.getReqIFDocument().getParseIssues().get(0).getMessage().contains("has no THE-VALUE"),
+				"but it is reported");
 	}
 
 	@Test
diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/TolerantImportTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/TolerantImportTest.java
new file mode 100644
index 0000000..7533b56
--- /dev/null
+++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/TolerantImportTest.java
@@ -0,0 +1,189 @@
+package de.uni_stuttgart.ils.reqif4j;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssue;
+import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF;
+import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent;
+import de.uni_stuttgart.ils.reqif4j.specification.SpecObject;
+import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator;
+import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult;
+import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter;
+
+/**
+ * A document with broken parts is still read: what cannot be used is skipped
+ * and reported, what does not fit its datatype is kept as written, and the
+ * validator turns the reports into errors.
+ */
+class TolerantImportTest {
+
+	@Test
+	void unknownSpecTypeKeepsTheObjectAndItsReference(@TempDir Path tempDir) throws Exception {
+
+		// so-1 is the first object referencing st-req
+		ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF.replaceFirst(
+				"st-req", "st-gone"));
+		SpecObject boot = reqif.getReqIFCoreContent().getSpecObject("so-1");
+
+		assertNotNull(boot, "the object is kept");
+		assertEquals("st-gone", boot.getSpecTypeID());
+		assertNull(boot.getSpecTypeName());
+		assertTrue(boot.getValues().isEmpty());
+		assertEquals(List.of("[so-1] SPEC-OBJECT references type st-gone, which is not declared; its 8 values are skipped"),
+				issues(reqif), "one report per defect");
+		assertTrue(new ReqIFWriter().toXml(reqif.getReqIFDocument()).contains("st-gone"));
+	}
+
+	@Test
+	void valueOfAnUnknownDefinitionIsSkipped(@TempDir Path tempDir) throws Exception {
+
+		// the only value referencing ad-effort is so-1's
+		ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF.replace(
+				"ad-effort",
+				"ad-gone"));
+
+		assertEquals(List.of("[so-1] Value references attribute definition ad-gone, which spec type st-req does not "
+				+ "declare; the value is skipped"), issues(reqif));
+		assertEquals(7, reqif.getReqIFCoreContent().getSpecObject("so-1").getValues().size(), "the other values are kept");
+	}
+
+	@Test
+	void valuesThatDoNotFitTheirDatatypeAreKeptAsWritten(@TempDir Path tempDir) throws Exception {
+
+		ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF
+				.replace("", "")
+				.replace("", "")
+				.replace("", ""));
+		SpecObject boot = reqif.getReqIFCoreContent().getSpecObject("so-1");
+
+		// no NumberFormatException; the text survives for the user to fix
+		assertEquals("one", boot.getValue("ad-prio").getValue());
+		assertEquals("1.5d", boot.getValue("ad-effort").getValue(), "Java accepts 1.5d, xsd:double does not");
+		assertEquals("yes", boot.getValue("ad-safety").getValue());
+		String xml = new ReqIFWriter().toXml(reqif.getReqIFDocument());
+		assertTrue(xml.contains(""), xml);
+
+		ValidationResult result = new ReqIFValidator().validate(reqif.getReqIFDocument());
+		assertTrue(messages(result).containsAll(List.of(
+				"Attribute 'Priority': 'one' is not an integer",
+				"Attribute 'Effort': '1.5d' is not an xsd:double",
+				"Attribute 'Safety relevant': 'yes' is not an xsd:boolean (true, false, 1 or 0)")), result.toString());
+	}
+
+	@Test
+	void identifiersUsedTwiceAreReported(@TempDir Path tempDir) throws Exception {
+
+		ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF
+				.replace(" issue.contains("twice")).collect(Collectors.toList()));
+	}
+
+	@Test
+	void elementsWithoutIdentifierAreSkipped(@TempDir Path tempDir) throws Exception {
+
+		ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF
+				.replace("dt-date",
+				"dt-gone"));
+
+		assertTrue(issues(reqif).contains("[ad-due] Attribute definition references a datatype that is not declared "
+				+ "in DATATYPES; it and its values cannot be written"), issues(reqif).toString());
+	}
+
+	@Test
+	void theValidatorReportsWhatTheParserSkippedAsWarnings(@TempDir Path tempDir) throws Exception {
+
+		ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF.replace("", ""));
+
+		assertEquals("three", reqif.getReqIFCoreContent().getSpecObject("so-2").getAttribute("Priority"));
+		assertTrue(messages(new ReqIFValidator().validate(reqif.getReqIFDocument()))
+				.contains("Default value: 'three' is not an integer"));
+	}
+
+	@Test
+	void unresolvedRelationTypeIsKept(@TempDir Path tempDir) throws Exception {
+
+		ReqIF reqif = parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF.replace(
+				"st-rel", "st-gone"));
+
+		assertEquals("st-gone", reqif.getReqIFCoreContent().getSpecRelation("sr-1").getSpecTypeID());
+	}
+
+	@Test
+	void modelConstructorsReadTolerantlyAsBefore() throws Exception {
+
+		org.w3c.dom.Element relation = de.uni_stuttgart.ils.reqif4j.util.SecureXml.newDocumentBuilder()
+				.parse(new org.xml.sax.InputSource(new java.io.StringReader(
+						"")))
+				.getDocumentElement();
+
+		// no type to interpret the value by: skipped, not an exception
+		assertTrue(new de.uni_stuttgart.ils.reqif4j.specification.SpecRelation(relation, null).getValues().isEmpty());
+	}
+
+	@Test
+	void aCleanDocumentHasNoIssues(@TempDir Path tempDir) throws Exception {
+		assertEquals(List.of(), issues(parse(tempDir, TestFixtures.SCHEMA_VALID_REQIF)));
+		assertEquals(List.of(), issues(parse(tempDir, LosslessRoundTripTest.fullFeatureDocument())));
+	}
+
+	private static ReqIF parse(Path dir, String content) throws Exception {
+		return new ReqIF(TestFixtures.write(dir, "doc-" + System.nanoTime() + ".reqif", content).toString());
+	}
+
+	private static List issues(ReqIF reqif) {
+		return reqif.getReqIFDocument().getParseIssues().stream().map(ParseIssue::toString).collect(Collectors.toList());
+	}
+
+	private static List messages(ValidationResult result) {
+		return result.getIssues().stream().map(issue -> issue.getMessage()).collect(Collectors.toList());
+	}
+}

From ab146c43c4c5449b9d74e9caf082d42c39be37b8 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Sun, 11 Oct 2026 14:17:33 +0000
Subject: [PATCH 13/20] Close the Phase 3 review: XHTML defaults and hierarchy
 content

- XHTML rendering keeps the xml: prefix of xml:lang and xml:space. As
  "lang" they clashed with a lang attribute on the same element, and the
  validator rejected the rendered default as not well-formed.
- An XHTML default read from a document is not checked again by the
  validator; it is well-formed by construction.
- SpecHierarchy.getXHTMLContent / getXHTMLDivContent take the object's own
  XHTML values first, so the default of another attribute no longer
  stands in for the object's text.
- The README states that typed values are written in canonical notation.
- The gap analysis records the Phase 3 results.

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN
---
 IST-SOLL-ANALYSE.md                           | 32 +++++++---
 README.md                                     |  3 +
 .../reqif4j/specification/SpecHierarchy.java  | 59 +++++++++++++------
 .../ils/reqif4j/validate/ReqIFValidator.java  |  6 +-
 .../ils/reqif4j/xhtml/XHTMLNode.java          |  7 ++-
 .../ils/reqif4j/ModelCompletenessTest.java    | 26 ++++++++
 6 files changed, 105 insertions(+), 28 deletions(-)

diff --git a/IST-SOLL-ANALYSE.md b/IST-SOLL-ANALYSE.md
index fcc390a..c9d1afa 100644
--- a/IST-SOLL-ANALYSE.md
+++ b/IST-SOLL-ANALYSE.md
@@ -22,6 +22,24 @@ schemagültigen Round-Trip verhinderten:
 - **Werte unbenannter Attributdefinitionen überschrieben sich.** Ohne `LONG-NAME` dient jetzt die ID als
   Schlüssel. Die eigentliche Umstellung auf IDs bleibt Arbeitspaket 3.1.
 
+| Phase | Stand | Ergebnis |
+|---|---|---|
+| **3.1/3.2** Werte | ✅ erledigt | Werte liegen nach Definitions-ID in Dokumentreihenfolge vor (`getValues()`), Defaults werden nicht mehr als Werte geschrieben. `getAttributes()` bleibt als schreibgeschützte Sicht mit Defaults nach `LONG-NAME`, `setValue`/`removeValue` ändern Werte. Gemeinsame Basisklasse `SpecElementWithAttributes`. |
+| **3.3** Zahlen | ✅ erledigt | Integer ohne Größenbeschränkung (`getBigInteger()`), REAL-Grenzen und XHTML-Defaults schon in Phase 2. |
+| **3.4** Metadaten | ✅ erledigt | `xml:lang`, `REPOSITORY-ID`, `IS-EDITABLE`, `IS-TABLE-INTERNAL`, `EDITABLE-ATTS`, `THE-ORIGINAL-VALUE`, `IS-SIMPLIFIED`, `p`-Inhalt statt `div`. Laut Schemaabgleich fehlt dem Code kein Element und kein Attribut des XSD mehr. |
+| **3.5** `.reqifz` | ✅ erledigt | Alle Anhänge bleiben erhalten (OLE, PDF, GIF …), Neupacken ohne Speicherladen, `InputStream`-Konstruktor, Entpacken in ein Temp-Verzeichnis, das `close()` löscht. |
+| **3.6** toleranter Import | ✅ erledigt | Statt Abstürzen gibt es `ReqIFDocument.getParseIssues()`. Unpassende Werte bleiben als Rohtext erhalten, nicht auflösbare Referenzen werden mitgeschrieben, nur Unverwendbares wird übersprungen. Der Validator meldet das als Warnungen. |
+| **3.7** Round-Trip-Test | ✅ erledigt | `LosslessRoundTripTest` mit einer schemagültigen Datei, die alle Elemente und Attribute nutzt. Der kanonische XML-Vergleich ignoriert nur, was das Schema offenlässt. |
+
+Ergebnis von Phase 3: Import und Export sind im Sinne des Zielbilds vollständig. Was eine schemagültige
+Datei enthält, wird gelesen und inhaltlich unverändert geschrieben. Lexikalisch normalisiert werden nur
+typisierte Werte (`1` → `true`, `1e3` → `1000.0`). Kaputte Dateien werden gelesen, soweit es geht, und die
+Probleme gemeldet.
+
+API-Änderungen in Phase 3: `getAttributes()` ist schreibgeschützt (Änderungen über `setValue`/`removeValue`),
+`ReqIFz` entpackt nicht mehr neben die Quelldatei, und ein XHTML-Default gilt jetzt als effektiver Wert. Bei
+attributbasierter Klassifizierung kann sich dadurch die Kategorie von Objekten ohne eigenen Wert ändern.
+
 Eine API-Änderung: `getName()` liefert für Elemente ohne `LONG-NAME` jetzt `null` (vorher Absturz,
 bei Enum-Werten `""`). `DatatypeEnumeration.getEnumValueName()` liefert weiterhin `""`.
 
@@ -109,9 +127,9 @@ Legende: ✅ unterstützt · ⚠️ teilweise · ❌ fehlt · — nicht anwendba
 
 | ReqIF-Element / -Attribut | Import | Export | Builder |
 |---|---|---|---|
-| `REQ-IF` `xml:lang` | ❌ | ❌ | ❌ |
+| `REQ-IF` `xml:lang` | ✅ *(3.4)* | ✅ *(3.4)* | ⚠️ per Setter |
 | Header: `COMMENT`, `CREATION-TIME`, `REQ-IF-TOOL-ID`, `REQ-IF-VERSION`, `SOURCE-TOOL-ID`, `TITLE` | ✅ | ✅ Pflichtfelder mit Default *(2.2)* | ✅ freie Header-ID als Default *(2.2)* |
-| Header: `REPOSITORY-ID` | ❌ | ❌ | ❌ |
+| Header: `REPOSITORY-ID` | ✅ *(3.4)* | ✅ *(3.4)* | ✅ *(3.4)* |
 | `TOOL-EXTENSIONS` | ✅ (roh) | ✅ (roh) | ❌ |
 | Identifiable: `IDENTIFIER` | ✅ | ✅ | ✅ |
 | Identifiable: `LAST-CHANGE` (**Pflicht**) | ✅ *(1.2)* | ✅ mit Default *(2.1)* | ⚠️ nur per Setter, sonst Default |
@@ -127,22 +145,22 @@ Legende: ✅ unterstützt · ⚠️ teilweise · ❌ fehlt · — nicht anwendba
 | `SPEC-OBJECT-TYPE`, `SPECIFICATION-TYPE`, `SPEC-RELATION-TYPE` | ✅ | ✅ | ✅ |
 | `RELATION-GROUP-TYPE` | ✅ | ✅ | ❌ |
 | `ATTRIBUTE-DEFINITION-*` | ✅ | ✅ | ✅ |
-| `ATTRIBUTE-DEFINITION-*` `IS-EDITABLE` | ❌ | ❌ | ❌ |
+| `ATTRIBUTE-DEFINITION-*` `IS-EDITABLE` | ✅ *(3.4)* | ✅ *(3.4)* | ⚠️ per Setter |
 | `ATTRIBUTE-DEFINITION-ENUMERATION` `MULTI-VALUED` (**Pflicht**) | ✅ | ✅ auch `false` *(2.2)* | ✅ |
 | `DEFAULT-VALUE` (einfache Typen, Enum) | ✅ | ✅ | ✅ |
 | `DEFAULT-VALUE` (XHTML) | ✅ *(2.2, aus 3.3 vorgezogen)* | ✅ als Markup *(2.2)* | ✅ über `attribute(...)` |
 | `ATTRIBUTE-VALUE-*` (alle 7 Arten) | ✅ | ✅ | ✅ |
-| `ATTRIBUTE-VALUE-INTEGER` außerhalb des int-Bereichs | ❌ Absturz | — | ⚠️ |
-| `ATTRIBUTE-VALUE-XHTML` `THE-ORIGINAL-VALUE` / `IS-SIMPLIFIED` | ❌ | ❌ | ❌ |
+| `ATTRIBUTE-VALUE-INTEGER` außerhalb des int-Bereichs | ✅ unbegrenzt *(3.3)* | ✅ *(3.3)* | ✅ *(3.3)* |
+| `ATTRIBUTE-VALUE-XHTML` `THE-ORIGINAL-VALUE` / `IS-SIMPLIFIED` | ✅ *(3.4)* | ✅ *(3.4)* | ❌ *(4.2)* |
 | `SPEC-OBJECT` | ✅ | ✅ | ✅ |
 | `SPEC-RELATION` (+ Attribute) | ✅ | ✅ | ✅ |
 | `SPECIFICATION` (+ Attribute) | ✅ | ✅ | ✅ |
 | `SPEC-HIERARCHY` | ✅ | ✅ | ✅ |
-| `SPEC-HIERARCHY` `IS-TABLE-INTERNAL`, `IS-EDITABLE`, `EDITABLE-ATTS` | ❌ | ❌ | ❌ |
+| `SPEC-HIERARCHY` `IS-TABLE-INTERNAL`, `IS-EDITABLE`, `EDITABLE-ATTS` | ✅ *(3.4)* | ✅ *(3.4)* | ⚠️ Flags per Setter, `EDITABLE-ATTS` *(4.2)* |
 | `RELATION-GROUP` | ✅ | ✅ | ❌ |
 | `.reqifz` mit mehreren `.reqif` | ✅ | ✅ | — |
 | `.reqifz`-Anhänge `png`/`jpg`/`jpeg` | ✅ | ✅ | ✅ |
-| `.reqifz`-Anhänge OLE, GIF, SVG, PDF, RTF … | ❌ | ❌ | ⚠️ manuell über `Map` |
+| `.reqifz`-Anhänge OLE, GIF, SVG, PDF, RTF … | ✅ *(3.5)* | ✅ *(3.5)* | ✅ über `Map` |
 
 ## 4. Befunde: Was zum Zielstand fehlt
 
diff --git a/README.md b/README.md
index 6a9d763..47bca62 100644
--- a/README.md
+++ b/README.md
@@ -288,6 +288,9 @@ embedded OLE object of which `THE-VALUE` only shows the preview).
 `LosslessRoundTripTest` checks this with a document that uses all of
 them; the comparison ignores only what the schema leaves open (namespace
 prefixes, attribute order, the order of xsd:all children, formatting).
+Typed values are written in canonical notation, so the lexical form of
+a value may change while its meaning does not: `1` becomes `true` for a
+boolean, `1e3` becomes `1000.0` for a real, `+5` becomes `5`.
 Attribute values are written in document order. Integer values have no
 size limit (`AttributeValueInteger.getBigInteger()`).
 
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java
index 9c445ab..1d3b2a8 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java
@@ -117,31 +117,54 @@ public Object getAttribute(String attributeName) {
 		return (Object)this.specObject.getAttribute(attributeName);
 	}
 	
+	/**
+	 * @return the token list of the object's first XHTML value - its own values
+	 *         before defaults, so another attribute's default never stands in
+	 *         for the object's text - or null if it has none
+	 */
 	public AttributeValueXHTMLElementList getXHTMLContent() {
-		for(AttributeValue attributeValue: this.specObject.getAttributes().values()) {
-			// getValue() returns the rendered string, so the element list has
-			// to be fetched via its dedicated getter (the former cast threw
-			// ClassCastException on every call).
-			if(attributeValue instanceof AttributeValueXHTML) {
-				return ((AttributeValueXHTML)attributeValue).getElementList();
-			}
-		}
-		return null;
+		AttributeValueXHTML xhtml = firstXhtmlValue();
+		return xhtml == null ? null : xhtml.getElementList();
 	}
-	
+
+	/**
+	 * @return the block elements of the object's first XHTML value (see
+	 *         {@link #getXHTMLContent()}), or null if it has none
+	 */
 	public List getXHTMLDivContent() {
-		for(AttributeValue attributeValue: this.specObject.getAttributes().values()) {
-			if(attributeValue instanceof AttributeValueXHTML) {
-				XHTMLElementDiv value = ((AttributeValueXHTML)attributeValue).getDivValue();
-				if(value != null) {
-					// a value may consist of a single p instead of a div
-					return XHTML.P.equals(value.getTagName()) ? List.of(value) : value.getChildren();
+		AttributeValueXHTML xhtml = firstXhtmlValue();
+		XHTMLElementDiv value = xhtml == null ? null : xhtml.getDivValue();
+		if(value == null) {
+			return null;
+		}
+		// a value may consist of a single p instead of a div
+		return XHTML.P.equals(value.getTagName()) ? List.of(value) : value.getChildren();
+	}
+
+	/**
+	 * @return the first XHTML value with content, the object's own values
+	 *         first; failing that any XHTML value, as earlier versions returned
+	 *         an empty token list rather than null for it
+	 */
+	private AttributeValueXHTML firstXhtmlValue() {
+		AttributeValueXHTML withoutContent = null;
+		for(java.util.Collection values: List.of(this.specObject.getValues().values(),
+				this.specObject.getAttributes().values())) {
+			for(AttributeValue attributeValue: values) {
+				if(attributeValue instanceof AttributeValueXHTML) {
+					AttributeValueXHTML xhtml = (AttributeValueXHTML) attributeValue;
+					if(xhtml.getDivValue() != null) {
+						return xhtml;
+					}
+					if(withoutContent == null) {
+						withoutContent = xhtml;
+					}
 				}
 			}
 		}
-		return null;
+		return withoutContent;
 	}
-	
+
 	public String getSpecType() {
 		return this.specObject.getSpecType();
 	}
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java
index 80889fb..1edb103 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ReqIFValidator.java
@@ -194,8 +194,10 @@ private void checkSpecTypes(ReqIFCoreContent content, ValidationResult result, M
 
 			for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) {
 				checkIdentifiable(definition, "ATTRIBUTE-DEFINITION", result, identifiers);
-				// written verbatim, so it must already be in schema notation
-				if (definition.getDefaultValue() != null && !(definition instanceof AttributeDefinitionEnumeration)) {
+				// written verbatim, so it must already be in schema notation; an XHTML
+				// default read from a document is well-formed by construction
+				if (definition.getDefaultValue() != null && !(definition instanceof AttributeDefinitionEnumeration)
+						&& definition.getDefaultXhtmlValue() == null) {
 					checkScalar(definition.getID(), "Default value", definition, definition.getDefaultValue(), result);
 				}
 
diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java
index d822535..1822809 100644
--- a/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java
+++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/xhtml/XHTMLNode.java
@@ -104,7 +104,12 @@ protected String attributesToString() {
 			if(name.equals("xmlns") || name.startsWith("xmlns:")) {
 				continue;
 			}
-			sb.append(' ').append(XmlUtils.localName(attributeNode))
+			// xml:lang and xml:space keep their prefix: "lang" next to "xml:lang"
+			// would be a duplicate attribute, and the XHTML schema has no "space"
+			String attributeName = "http://www.w3.org/XML/1998/namespace".equals(attributeNode.getNamespaceURI())
+					? "xml:" + XmlUtils.localName(attributeNode)
+					: XmlUtils.localName(attributeNode);
+			sb.append(' ').append(attributeName)
 			  .append("=\"").append(escapeAttribute(attributeNode.getNodeValue())).append('"');
 		}
 		return sb.toString();
diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java
index 1c4f1b2..7ec26d1 100644
--- a/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java
+++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCompletenessTest.java
@@ -113,6 +113,32 @@ void xhtmlDefaultKeepsItsFormAndOriginal() {
 		assertTrue(defaultText.getOriginalValue().toString().contains("written"));
 	}
 
+	@Test
+	void xhtmlDefaultWithTheOriginalValueFirst(@TempDir Path tempDir) throws Exception {
+
+		// xsd:all: THE-ORIGINAL-VALUE may come before THE-VALUE; xml:lang must
+		// survive rendering, so lang next to xml:lang is no duplicate
+		String reordered = LosslessRoundTripTest.fullFeatureDocument().replace(
+				"to be writtento be written",
+				"to be writtento be written");
+		ReqIF withOriginalFirst = new ReqIF(TestFixtures.write(tempDir, "reordered.reqif", reordered).toString());
+		de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition text =
+				withOriginalFirst.getReqIFCoreContent().getSpecType("st-req").getAttributeDefinition("ad-text");
+
+		assertEquals("

to be written

", text.getDefaultValue()); + assertTrue(new ReqIFValidator().validate(withOriginalFirst.getReqIFDocument()).isValid()); + } + + @Test + void xhtmlContentOfAHierarchyNodePrefersTheObjectsOwnText() { + + // so-2 has its own ReqIF.Text; no other XHTML default may stand in + SpecHierarchy row = this.content.getSpecification("spec-1").getChildren().get(0).getChildren().get(0); + + assertEquals("A single paragraph.", ((de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElement) row.getXHTMLDivContent().get(0)) + .getChildren().get(0).toString()); + } + @Test void invalidBooleanFlagIsNotFlipped(@TempDir Path tempDir) throws Exception { From 4355c970d9403d7ed1d17f1b5dda3cd5151b857a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:12:02 +0000 Subject: [PATCH 14/20] Phase 4.1: mutation API with consistent references and LAST-CHANGE - Values: set/setXhtml/setEnum/removeValue on spec objects, relations and specifications, through a shared AttributeValueFactory that also backs the builder. - Hierarchy: addChild/removeChild on specifications and hierarchy nodes; nodes know their parent, so a node is in one place only and never below itself; levels and sections follow every change; flattened lists are cached until the structure changes. - Content: add methods refuse identifiers already in use anywhere in the document; addSpecHierarchy, moveSpecHierarchy and removeSpecHierarchy; removeSpecObject lifts the children of nodes that show the object and drops its relations, checked up front so a refusal changes nothing; removeSpecRelation, removeSpecification and removeRelationGroup keep relation groups consistent; spec types and datatypes in use are not removed. The maps returned by the getters are read-only. - Relations and groups can be rewired. - LAST-CHANGE: new and changed elements are modified; the writer gives them the time of writing and, once the output succeeded, takes it into the model, so writing again reports no new change. Validating and buildDocument leave the model alone; the builder's documents start unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JHowUptcqVmPtthduSbdvN --- .../attributes/AttributeDefinition.java | 1 + .../attributes/AttributeValueFactory.java | 104 ++++ .../reqif4j/build/ReqIFBuildException.java | 5 +- .../ils/reqif4j/build/ReqIFBuilder.java | 2 + .../ils/reqif4j/build/ValuesBuilder.java | 80 +-- .../ils/reqif4j/reqif/Identifiable.java | 51 +- .../ils/reqif4j/reqif/ParseIssues.java | 10 +- .../ils/reqif4j/reqif/ReqIFCoreContent.java | 480 +++++++++++++++++- .../specification/AttributeValues.java | 4 + .../specification/HierarchyChildren.java | 145 ++++++ .../specification/HierarchyParent.java | 35 ++ .../reqif4j/specification/RelationGroup.java | 34 ++ .../SpecElementWithAttributes.java | 46 +- .../reqif4j/specification/SpecHierarchy.java | 109 +++- .../reqif4j/specification/SpecRelation.java | 18 + .../ils/reqif4j/specification/SpecType.java | 1 + .../reqif4j/specification/Specification.java | 121 +++-- .../ils/reqif4j/write/ReqIFWriter.java | 87 +++- .../ils/reqif4j/write/ReqIFzWriter.java | 18 +- .../ils/reqif4j/IdentifiableTest.java | 5 +- .../ils/reqif4j/MutationApiTest.java | 474 +++++++++++++++++ .../ils/reqif4j/ReqIFValidatorTest.java | 22 +- 22 files changed, 1676 insertions(+), 176 deletions(-) create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueFactory.java create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyChildren.java create mode 100644 src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyParent.java create mode 100644 src/test/java/de/uni_stuttgart/ils/reqif4j/MutationApiTest.java diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java index 2f64a69..31d8063 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java @@ -50,6 +50,7 @@ public Boolean getEditable() { public AttributeDefinition setEditable(Boolean editable) { this.editable = editable; + markModified(); return this; } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueFactory.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueFactory.java new file mode 100644 index 0000000..bf01dca --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueFactory.java @@ -0,0 +1,104 @@ +package de.uni_stuttgart.ils.reqif4j.attributes; + +import java.util.ArrayList; +import java.util.List; + +import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; + +/** + * Creates attribute values of the class that fits the attribute's datatype, + * so callers need not know AttributeValueBoolean, AttributeValueInteger, ... + * Used by the builder and by the setters of the model elements. + */ +public final class AttributeValueFactory { + + private AttributeValueFactory() { + } + + /** + * @param value a Boolean, Number, String or anything whose toString() is + * the value's text; null for an empty value + * @throws IllegalArgumentException for enumeration and XHTML attributes, + * which have their own factory methods + */ + public static AttributeValue of(AttributeDefinition definition, Object value) { + + String category = categoryOf(definition); + String text = value == null ? null : String.valueOf(value); + + if (ReqIFConst.ENUMERATION.equals(category)) { + throw new IllegalArgumentException("Use setEnum(...) for the enumeration attribute " + definition.getID()); + } + if (ReqIFConst.XHTML.equals(category)) { + throw new IllegalArgumentException("Use setXhtml(...) for the XHTML attribute " + definition.getID()); + } + if (category == null) { + return new AttributeValue(text, definition); + } + try { + switch (category) { + case ReqIFConst.BOOLEAN: return new AttributeValueBoolean(text, definition); + case ReqIFConst.INTEGER: return new AttributeValueInteger(text, definition); + case ReqIFConst.STRING: return new AttributeValueString(text, definition); + case ReqIFConst.DATE: return new AttributeValueDate(text, definition); + case ReqIFConst.DOUBLE: + case ReqIFConst.REAL: return new AttributeValueDouble(text, definition); + default: return new AttributeValue(text, definition); + } + } catch (IllegalArgumentException e) { + // e.g. a NumberFormatException, which does not say which attribute + throw new IllegalArgumentException("'" + text + "' is not a valid " + category + + " value for attribute " + definition.getID(), e); + } + } + + /** + * @param markup XHTML, with or without a surrounding div + * @throws IllegalArgumentException if the attribute is not an XHTML one or + * the markup is not well-formed + */ + public static AttributeValueXHTML xhtml(AttributeDefinition definition, String markup) { + requireCategory(definition, ReqIFConst.XHTML); + return new AttributeValueXHTML(markup, definition); + } + + /** + * @param enumValueIDs the IDENTIFIERs of the selected enum values; none for + * an empty selection + * @throws IllegalArgumentException if the attribute is not an enumeration + * one or an enum value does not exist + */ + public static AttributeValueEnumeration enumeration(AttributeDefinition definition, List enumValueIDs) { + + requireCategory(definition, ReqIFConst.ENUMERATION); + if (enumValueIDs == null || enumValueIDs.contains(null)) { + throw new IllegalArgumentException("The enum values for attribute " + definition.getID() + + " must not be null; pass none for an empty selection"); + } + DatatypeEnumeration datatype = (DatatypeEnumeration) definition.getDataType(); + List names = new ArrayList(); + for (String enumValueID : enumValueIDs) { + String name = datatype.getEnumValueName(enumValueID); + if (name == null) { + throw new IllegalArgumentException("Unknown enum value " + enumValueID + + " for attribute " + definition.getID()); + } + names.add(name); + } + return new AttributeValueEnumeration(names, new ArrayList(enumValueIDs), definition); + } + + private static String categoryOf(AttributeDefinition definition) { + return definition.getDataType() == null ? null : definition.getDataType().getType(); + } + + private static void requireCategory(AttributeDefinition definition, String expected) { + String category = categoryOf(definition); + if (!expected.equals(category) + || ReqIFConst.ENUMERATION.equals(expected) && !(definition.getDataType() instanceof DatatypeEnumeration)) { + throw new IllegalArgumentException("Attribute " + definition.getID() + " is of kind " + category + + ", but " + expected + " was expected"); + } + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuildException.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuildException.java index e1eb8c8..05c5618 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuildException.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuildException.java @@ -2,9 +2,10 @@ /** * Thrown when a ReqIF document cannot be assembled, e.g. because an unknown - * identifier is referenced. + * identifier is referenced. An {@link IllegalArgumentException}: the builder + * was called with arguments that do not fit together. */ -public class ReqIFBuildException extends RuntimeException { +public class ReqIFBuildException extends IllegalArgumentException { public ReqIFBuildException(String message) { super(message); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java index 5fa9993..3e0e4d2 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java @@ -257,6 +257,8 @@ public ReqIFDocument build() { content.addSpecification(specification); } + // the elements are new, not changed: they get the default LAST-CHANGE + content.clearModified(); return new ReqIFDocument(this.headerBuilder.build(defaultHeaderID()), content); } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ValuesBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ValuesBuilder.java index 69159d4..8b552d3 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ValuesBuilder.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ValuesBuilder.java @@ -4,18 +4,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.function.Supplier; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueBoolean; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDate; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueDouble; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueInteger; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueString; -import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; -import de.uni_stuttgart.ils.reqif4j.datatypes.DatatypeEnumeration; -import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueFactory; import de.uni_stuttgart.ils.reqif4j.specification.SpecType; /** @@ -38,32 +31,16 @@ public class ValuesBuilder { * datatype. */ public ValuesBuilder set(String attributeDefinitionID, Object value) { - AttributeDefinition definition = requireDefinition(attributeDefinitionID); - String category = definition.getDataType() == null ? null : definition.getDataType().getType(); - String text = value == null ? null : String.valueOf(value); - - if (ReqIFConst.ENUMERATION.equals(category)) { - throw new ReqIFBuildException("Use setEnum(...) for the enumeration attribute " + attributeDefinitionID); - } - if (ReqIFConst.XHTML.equals(category)) { - throw new ReqIFBuildException("Use setXhtml(...) for the XHTML attribute " + attributeDefinitionID); - } - - this.values.add(scalarValue(category, text, definition)); - return this; + return add(() -> AttributeValueFactory.of(definition, value)); } /** * Sets an XHTML value from markup, with or without a surrounding div. */ public ValuesBuilder setXhtml(String attributeDefinitionID, String markup) { - AttributeDefinition definition = requireDefinition(attributeDefinitionID); - requireCategory(definition, ReqIFConst.XHTML, attributeDefinitionID); - - this.values.add(new AttributeValueXHTML(markup, definition)); - return this; + return add(() -> AttributeValueFactory.xhtml(definition, markup)); } /** @@ -74,47 +51,25 @@ public ValuesBuilder setEnum(String attributeDefinitionID, String... enumValueID } public ValuesBuilder setEnum(String attributeDefinitionID, List enumValueIDs) { - AttributeDefinition definition = requireDefinition(attributeDefinitionID); - requireCategory(definition, ReqIFConst.ENUMERATION, attributeDefinitionID); - - DatatypeEnumeration datatype = (DatatypeEnumeration) definition.getDataType(); - List names = new ArrayList(); - for (String enumValueID : enumValueIDs) { - String name = datatype.getEnumValueName(enumValueID); - if (name == null) { - throw new ReqIFBuildException("Unknown enum value " + enumValueID - + " for attribute " + attributeDefinitionID); - } - names.add(name); - } + return add(() -> AttributeValueFactory.enumeration(definition, enumValueIDs)); + } - this.values.add(new AttributeValueEnumeration(names, new ArrayList(enumValueIDs), definition)); + + private ValuesBuilder add(Supplier value) { + try { + this.values.add(value.get()); + } catch (IllegalArgumentException e) { + throw new ReqIFBuildException(e.getMessage(), e); + } return this; } - Collection build() { return this.values; } - private AttributeValue scalarValue(String category, String text, AttributeDefinition definition) { - - if (category == null) { - return new AttributeValue(text, definition); - } - switch (category) { - case ReqIFConst.BOOLEAN: return new AttributeValueBoolean(text, definition); - case ReqIFConst.INTEGER: return new AttributeValueInteger(text, definition); - case ReqIFConst.STRING: return new AttributeValueString(text, definition); - case ReqIFConst.DATE: return new AttributeValueDate(text, definition); - case ReqIFConst.DOUBLE: - case ReqIFConst.REAL: return new AttributeValueDouble(text, definition); - default: return new AttributeValue(text, definition); - } - } - private AttributeDefinition requireDefinition(String attributeDefinitionID) { AttributeDefinition definition = this.specType.getAttributeDefinition(attributeDefinitionID); @@ -124,13 +79,4 @@ private AttributeDefinition requireDefinition(String attributeDefinitionID) { } return definition; } - - private static void requireCategory(AttributeDefinition definition, String expected, String attributeDefinitionID) { - - String category = definition.getDataType() == null ? null : definition.getDataType().getType(); - if (!expected.equals(category)) { - throw new ReqIFBuildException("Attribute " + attributeDefinitionID + " is of kind " + category - + ", but " + expected + " was expected"); - } - } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java index bfcb886..b6668da 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/Identifiable.java @@ -22,6 +22,12 @@ * Values are kept exactly as written in the document; an attribute the * document does not declare is {@code null}, so it is not invented when the * element is written back. + * + * Creating an element from plain values or changing one through the API + * marks it {@link #isModified() modified}; the writer then gives it the time of writing as LAST-CHANGE, + * so tools that import the document see what changed, and takes that time + * into the model, so writing again gives the same result. Setting LAST-CHANGE + * explicitly after the last change takes precedence. */ public abstract class Identifiable { @@ -30,14 +36,19 @@ public abstract class Identifiable { private String desc; private String lastChange; private String alternativeID; + private boolean modified; /** - * For elements created from plain values instead of a document. + * For elements created from plain values instead of a document. A new + * element counts as modified, so it is written with the time of writing + * as LAST-CHANGE unless one is set; the builder clears that for the + * documents it assembles. */ protected Identifiable(String id, String name) { this.id = id; this.name = name; + this.modified = true; } /** @@ -64,6 +75,8 @@ protected final void readIdentifiable(Node element) { this.desc = XmlUtils.attribute(element, ReqIFConst.DESC); this.lastChange = XmlUtils.attribute(element, ReqIFConst.LAST_CHANGE); this.alternativeID = XmlUtils.alternativeID(element); + // as read, not changed + this.modified = false; } @@ -81,6 +94,12 @@ public String getName() { return this.name; } + public Identifiable setName(String name) { + this.name = name; + markModified(); + return this; + } + /** * @return the DESC, or null if the element declares none */ @@ -90,6 +109,7 @@ public String getDesc() { public Identifiable setDesc(String desc) { this.desc = desc; + markModified(); return this; } @@ -102,10 +122,15 @@ public String getLastChange() { } /** + * Sets LAST-CHANGE explicitly; the element is no longer considered + * modified, so the writer keeps this value - unless the element is + * changed again afterwards. + * * @param lastChange xsd:dateTime, e.g. 2026-07-23T10:00:00Z */ public Identifiable setLastChange(String lastChange) { this.lastChange = lastChange; + this.modified = false; return this; } @@ -118,6 +143,30 @@ public String getAlternativeID() { public Identifiable setAlternativeID(String alternativeID) { this.alternativeID = alternativeID; + markModified(); return this; } + + /** + * @return true if the element was changed through the API since it was + * read or built (and LAST-CHANGE was not set since) + */ + public boolean isModified() { + return this.modified; + } + + /** + * Marks the element changed; done by every setter and structural change. + */ + public void markModified() { + this.modified = true; + } + + /** + * Forgets that the element was changed; the writer then keeps the + * LAST-CHANGE it has. + */ + public void clearModified() { + this.modified = false; + } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java index d7e4c79..727f39e 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ParseIssues.java @@ -78,8 +78,16 @@ public void put(java.util.Map elements, T el return; } if (elements.containsKey(element.getID())) { - report(element.getID(), kind + " " + element.getID() + " is declared twice; the later one replaces the earlier"); + reportDuplicate(kind, element.getID()); } elements.put(element.getID(), element); } + + /** + * Reports a second element with the same identifier, which replaces the + * first. + */ + public void reportDuplicate(String kind, String id) { + report(id, kind + " " + id + " is declared twice; the later one replaces the earlier"); + } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java index 47530db..e822b3c 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java @@ -22,59 +22,78 @@ public class ReqIFCoreContent { private ParseIssues parseIssues = ParseIssues.collecting(); + /** + * @return the dataTypes by IDENTIFIER, in document order (read-only; change + * them with the add and remove methods) + */ public Map getDatatypes() { - return this.dataTypes; + return java.util.Collections.unmodifiableMap(this.dataTypes); } public Datatype getDatatype(String id) { return this.dataTypes.get(id); } + /** + * @return the specTypes by IDENTIFIER, in document order (read-only; change + * them with the add and remove methods) + */ public Map getSpecTypes() { - return this.specTypes; + return java.util.Collections.unmodifiableMap(this.specTypes); } public SpecType getSpecType(String id) { return this.specTypes.get(id); } + /** + * @return the specObjects by IDENTIFIER, in document order (read-only; change + * them with the add and remove methods) + */ public Map getSpecObjects() { - return this.specObjects; + return java.util.Collections.unmodifiableMap(this.specObjects); } public SpecObject getSpecObject(String id) { return this.specObjects.get(id); } + /** + * @return the relations by IDENTIFIER, in document order (read-only; change + * them with the add and remove methods) + */ public Map getSpecRelation() { - return this.specRelation; + return java.util.Collections.unmodifiableMap(this.specRelation); } public SpecRelation getSpecRelation(String id) { return this.specRelation.get(id); } + /** + * @return the specifications by IDENTIFIER, in document order (read-only; change + * them with the add and remove methods) + */ public Map getSpecifications() { - return this.specifications; + return java.util.Collections.unmodifiableMap(this.specifications); } public Specification getSpecification(String id) { return this.specifications.get(id); } + /** + * @return the relationGroups by IDENTIFIER, in document order (read-only; change + * them with the add and remove methods) + */ public Map getRelationGroups() { - return this.relationGroups; + return java.util.Collections.unmodifiableMap(this.relationGroups); } public RelationGroup getRelationGroup(String id) { return this.relationGroups.get(id); } - public ReqIFCoreContent addRelationGroup(RelationGroup relationGroup) { - this.relationGroups.put(relationGroup.getID(), relationGroup); - return this; - } - public List getSpecificationsList() { List specifications = new ArrayList(); for (Specification specification : this.specifications.values()) { @@ -101,31 +120,452 @@ public List getOrderedSpecHierarchyList() { public ReqIFCoreContent() { } + /* + * Adding and removing elements. The add methods refuse an element whose + * IDENTIFIER a datatype, spec type, spec object, relation, specification, + * hierarchy node or relation group already has (attribute definitions and + * enum values are not checked; the validator reports any clash); the + * remove methods keep the references between the elements consistent. + */ + + /** + * @throws IllegalArgumentException if the IDENTIFIER is missing or taken + */ public ReqIFCoreContent addDatatype(Datatype datatype) { - this.dataTypes.put(datatype.getID(), datatype); - return this; + return add(this.dataTypes, datatype); } + /** + * @throws IllegalArgumentException if the IDENTIFIER is missing or taken + */ public ReqIFCoreContent addSpecType(SpecType specType) { - this.specTypes.put(specType.getID(), specType); - return this; + return add(this.specTypes, specType); } + /** + * @throws IllegalArgumentException if the IDENTIFIER is missing or taken + */ public ReqIFCoreContent addSpecObject(SpecObject specObject) { - this.specObjects.put(specObject.getID(), specObject); - return this; + return add(this.specObjects, specObject); } + /** + * @throws IllegalArgumentException if the IDENTIFIER is missing or taken + */ public ReqIFCoreContent addSpecRelation(SpecRelation relation) { - this.specRelation.put(relation.getID(), relation); - return this; + return add(this.specRelation, relation); } + /** + * @throws IllegalArgumentException if the IDENTIFIER is missing or taken + */ public ReqIFCoreContent addSpecification(Specification specification) { - this.specifications.put(specification.getID(), specification); + if (specification != null && this.specifications.get(specification.getID()) != specification) { + for (SpecHierarchy node : specification.getAllSpecHierarchies()) { + checkUnused(node.getID()); + } + } + return add(this.specifications, specification); + } + + /** + * @throws IllegalArgumentException if the IDENTIFIER is missing or taken + */ + public ReqIFCoreContent addRelationGroup(RelationGroup relationGroup) { + return add(this.relationGroups, relationGroup); + } + + private ReqIFCoreContent add(Map elements, T element) { + + if (element == null) { + throw new IllegalArgumentException("Cannot add null"); + } + if (elements.get(element.getID()) == element) { + return this; + } + checkUnused(element.getID()); + elements.put(element.getID(), element); return this; } + /** + * @throws IllegalArgumentException if the identifier is blank or an + * element of this content has it + */ + private void checkUnused(String id) { + + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("An element needs an identifier"); + } + for (Map map : List.of(this.dataTypes, this.specTypes, this.specObjects, + this.specRelation, this.specifications, this.relationGroups)) { + if (map.containsKey(id)) { + throw new IllegalArgumentException("Identifier " + id + " is already in use"); + } + } + if (find(id) != null) { + throw new IllegalArgumentException("Identifier " + id + " is already in use by a spec hierarchy"); + } + } + + /** + * Inserts a new hierarchy node, with any nodes below it. Unlike + * {@link HierarchyParent#addChild(int, SpecHierarchy)}, this checks that + * no element of the document has the identifiers already. + * + * @param parentID the specification or hierarchy node to insert it below + * @param index the position among the parent's children, 0 for first + * @throws IllegalArgumentException if the parent does not exist, an + * identifier is taken, the node is already placed somewhere, or + * the position is out of range + */ + public SpecHierarchy addSpecHierarchy(String parentID, int index, SpecHierarchy node) { + + HierarchyParent parent = parent(parentID); + if (node == null) { + throw new IllegalArgumentException("Cannot add null"); + } + checkUnused(node.getID()); + for (SpecHierarchy below : node.getAllChildren()) { + checkUnused(below.getID()); + } + parent.addChild(index, node); + return node; + } + + /** + * @throws IllegalArgumentException if there is no such specification or + * hierarchy node + */ + private HierarchyParent parent(String parentID) { + HierarchyParent parent = this.specifications.get(parentID); + if (parent == null) { + Placement placement = find(parentID); + if (placement == null) { + throw new IllegalArgumentException("There is no specification or spec hierarchy " + parentID); + } + parent = placement.node; + } + return parent; + } + + /** + * Removes a spec object, the hierarchy nodes that show it - their children + * move up into their place - and the relations from or to it. + * + * @return the removed object, or null if there is no such object + */ + public SpecObject removeSpecObject(String id) { + + if (!this.specObjects.containsKey(id)) { + return null; + } + // checked before anything changes, so a refusal leaves all as it was + for (Specification specification : this.specifications.values()) { + checkLiftable(specification, id); + } + SpecObject removed = this.specObjects.remove(id); + for (Specification specification : this.specifications.values()) { + removeNodesShowing(specification, id); + } + for (SpecRelation relation : new ArrayList(this.specRelation.values())) { + if (id.equals(relation.getSourceObjID()) || id.equals(relation.getTargetObjID())) { + removeSpecRelation(relation.getID()); + } + } + return removed; + } + + /** + * Replaces each node below {@code parent} that shows the object by its + * children; {@link #checkLiftable} ensures this cannot fail halfway. + */ + private static void removeNodesShowing(HierarchyParent parent, String specObjectID) { + + List children = parent.getChildren(); + List lifted = liftedChildren(parent, specObjectID); + if (!lifted.equals(children)) { + // all children are taken out first, so no two with the same + // identifier are ever side by side + for (SpecHierarchy child : children) { + parent.removeChild(child.getID()); + } + for (SpecHierarchy node : lifted) { + if (node.getParent() != null) { + node.getParent().removeChild(node.getID()); + } + parent.addChild(parent.getChildren().size(), node); + } + } + for (SpecHierarchy node : lifted) { + removeNodesShowing(node, specObjectID); + } + } + + /** + * @throws IllegalStateException if lifting the children of the nodes that + * show the object would put two nodes with the same IDENTIFIER + * side by side (only in documents that already repeat one) + */ + private static void checkLiftable(HierarchyParent parent, String specObjectID) { + List ids = new ArrayList(); + for (SpecHierarchy child : liftedChildren(parent, specObjectID)) { + if (ids.contains(child.getID())) { + throw new IllegalStateException("Removing spec object " + specObjectID + " would put spec hierarchy " + + child.getID() + " twice below " + parent.getID()); + } + ids.add(child.getID()); + checkLiftable(child, specObjectID); + } + } + + /** + * @return the children {@code parent} has once the nodes showing the + * object are replaced by their children + */ + private static List liftedChildren(HierarchyParent parent, String specObjectID) { + List lifted = new ArrayList(); + for (SpecHierarchy child : parent.getChildren()) { + if (specObjectID.equals(child.getSpecObjectID())) { + lifted.addAll(liftedChildren(child, specObjectID)); + } else { + lifted.add(child); + } + } + return lifted; + } + + /** + * Removes a relation, also from the relation groups it belongs to. + * + * @return the removed relation, or null if there is no such relation + */ + public SpecRelation removeSpecRelation(String id) { + + SpecRelation removed = this.specRelation.remove(id); + if (removed != null) { + for (RelationGroup group : this.relationGroups.values()) { + group.removeSpecRelationRef(id); + } + } + return removed; + } + + /** + * Removes a specification with its hierarchy, and the relation groups + * between it and another specification. The spec objects stay. + * + * @return the removed specification, or null if there is no such one + */ + public Specification removeSpecification(String id) { + + Specification removed = this.specifications.remove(id); + if (removed != null) { + this.relationGroups.values().removeIf(group -> id.equals(group.getSourceSpecificationRef()) + || id.equals(group.getTargetSpecificationRef())); + } + return removed; + } + + /** + * Removes a relation group; its relations stay. + * + * @return the removed group, or null if there is no such group + */ + public RelationGroup removeRelationGroup(String id) { + return this.relationGroups.remove(id); + } + + /** + * Removes a spec type that no element uses. + * + * @return the removed type, or null if there is no such type + * @throws IllegalStateException if a spec object, relation, specification + * or relation group still has this type + */ + public SpecType removeSpecType(String id) { + + if (!this.specTypes.containsKey(id)) { + return null; + } + List users = new ArrayList(); + for (Map elements : List.of(this.specObjects, + this.specRelation, this.specifications)) { + for (SpecElementWithAttributes element : elements.values()) { + if (id.equals(typeID(element))) { + users.add(element.getID()); + } + } + } + for (RelationGroup group : this.relationGroups.values()) { + if (id.equals(group.getRelationGroupTypeRef())) { + users.add(group.getID()); + } + } + if (!users.isEmpty()) { + throw new IllegalStateException("Spec type " + id + " is still used by " + String.join(", ", users)); + } + return this.specTypes.remove(id); + } + + private static String typeID(SpecElementWithAttributes element) { + if (element instanceof SpecObject) { + return ((SpecObject) element).getSpecTypeID(); + } + return element instanceof Specification ? ((Specification) element).getSpecTypeID() : null; + } + + /** + * Removes a datatype that no attribute definition uses. + * + * @return the removed datatype, or null if there is no such datatype + * @throws IllegalStateException if an attribute definition still has + * this datatype + */ + public Datatype removeDatatype(String id) { + + Datatype datatype = this.dataTypes.get(id); + if (datatype == null) { + return null; + } + List users = new ArrayList(); + for (SpecType specType : this.specTypes.values()) { + for (de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition definition + : specType.getAttributeDefinitions().values()) { + if (definition.getDataType() == datatype) { + users.add(definition.getID()); + } + } + } + if (!users.isEmpty()) { + throw new IllegalStateException("Datatype " + id + " is still used by " + String.join(", ", users)); + } + return this.dataTypes.remove(id); + } + + /** + * @return the hierarchy node with this IDENTIFIER in any specification, or + * null if there is none + */ + public SpecHierarchy getSpecHierarchy(String id) { + Placement placement = find(id); + return placement == null ? null : placement.node; + } + + /** + * Removes a hierarchy node with everything below it. The spec objects + * stay. + * + * @return the removed node, or null if there is no such node + */ + public SpecHierarchy removeSpecHierarchy(String id) { + Placement placement = find(id); + return placement == null ? null : placement.parent.removeChild(id); + } + + /** + * Moves a hierarchy node, with everything below it, to another place, in + * the same or another specification. + * + * @param newParentID the specification or hierarchy node to move it to + * @param index the position among the new parent's children after + * the move, 0 for first + * @throws IllegalArgumentException if the node or the new parent does not + * exist, the new parent is the node itself or below it, or the + * position is out of range + */ + public SpecHierarchy moveSpecHierarchy(String id, String newParentID, int index) { + + Placement placement = find(id); + if (placement == null) { + throw new IllegalArgumentException("There is no spec hierarchy " + id); + } + HierarchyParent newParent = parent(newParentID); + if (newParent == placement.node || placement.node.getAllChildren().contains(newParent)) { + throw new IllegalArgumentException("Spec hierarchy " + id + " cannot be moved below itself"); + } + boolean sameParent = newParent == placement.parent; + int size = newParent.getChildren().size() - (sameParent ? 1 : 0); + if (index < 0 || index > size) { + throw new IllegalArgumentException("Position " + index + " is outside 0.." + size); + } + if (!sameParent) { + for (SpecHierarchy sibling : newParent.getChildren()) { + if (id.equals(sibling.getID())) { + throw new IllegalArgumentException("Spec hierarchy " + id + " is already a child of " + newParentID); + } + } + } + placement.parent.removeChild(id); + newParent.addChild(index, placement.node); + return placement.node; + } + + private static final class Placement { + final HierarchyParent parent; + final SpecHierarchy node; + + Placement(HierarchyParent parent, SpecHierarchy node) { + this.parent = parent; + this.node = node; + } + } + + private Placement find(String id) { + if (id == null) { + return null; + } + for (Specification specification : this.specifications.values()) { + Placement placement = find(specification, id); + if (placement != null) { + return placement; + } + } + return null; + } + + private static Placement find(HierarchyParent parent, String id) { + for (SpecHierarchy child : parent.getChildren()) { + if (id.equals(child.getID())) { + return new Placement(parent, child); + } + Placement placement = find(child, id); + if (placement != null) { + return placement; + } + } + return null; + } + + /** + * Marks every element as unchanged, so that the writer keeps their + * LAST-CHANGE instead of giving them the time of writing. + */ + public void clearModified() { + + List all = new ArrayList(); + for (Datatype datatype : this.dataTypes.values()) { + all.add(datatype); + if (datatype instanceof DatatypeEnumeration) { + all.addAll(((DatatypeEnumeration) datatype).getEnumValues().values()); + } + } + for (SpecType specType : this.specTypes.values()) { + all.add(specType); + all.addAll(specType.getAttributeDefinitions().values()); + } + all.addAll(this.specObjects.values()); + all.addAll(this.specRelation.values()); + for (Specification specification : this.specifications.values()) { + all.add(specification); + all.addAll(specification.getAllSpecHierarchies()); + } + all.addAll(this.relationGroups.values()); + for (Identifiable identifiable : all) { + identifiable.clearModified(); + } + } + public ReqIFCoreContent(Element coreContent) { this(coreContent, TypeClassifier.defaultClassifier()); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java index d1f7d0d..3817351 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/AttributeValues.java @@ -33,6 +33,10 @@ final class AttributeValues { this.specType = specType; } + SpecType specType() { + return this.specType; + } + /** * Adds an explicit value; a second value for the same definition replaces * the first. diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyChildren.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyChildren.java new file mode 100644 index 0000000..6770895 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyChildren.java @@ -0,0 +1,145 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The ordered children of a specification or a spec hierarchy node, keyed by + * IDENTIFIER. Each child knows its parent, so a node is in one place only and + * cannot be put below itself. + * + * The flattened list of all nodes below is kept until the structure below + * changes, as callers often ask for it repeatedly. + */ +final class HierarchyChildren { + + private final HierarchyParent owner; + private final Map children = new LinkedHashMap(); + private List descendants; + + HierarchyChildren(HierarchyParent owner) { + this.owner = owner; + } + + /** + * Adds a child while the owner is being read or created; a child with an + * IDENTIFIER that is already there replaces the earlier one. + */ + void put(SpecHierarchy child) { + SpecHierarchy previous = this.children.put(child.getID(), child); + if (previous != null && previous != child) { + previous.setParent(null); + } + child.setParent(this.owner); + changed(); + } + + /** + * Adds a child read from a document; one with an IDENTIFIER that is + * already there replaces the earlier one, which is reported. + * + * @param child may be null (a skipped node), then nothing happens + */ + void put(SpecHierarchy child, de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues issues) { + if (child == null) { + return; + } + if (this.children.containsKey(child.getID())) { + issues.reportDuplicate("Spec hierarchy", child.getID()); + } + put(child); + } + + List list() { + return new ArrayList(this.children.values()); + } + + /** + * @param index position among the children, 0 for first; the number of + * children appends + * @throws IllegalArgumentException if the node is already placed somewhere, + * a child with the same IDENTIFIER exists, the node is the owner or + * above it, or the index is out of range + */ + void add(int index, SpecHierarchy child) { + + if (child == null || child.getID() == null) { + throw new IllegalArgumentException("A spec hierarchy node needs an identifier"); + } + if (child.getParent() != null) { + throw new IllegalArgumentException("Spec hierarchy " + child.getID() + " is already placed below " + + child.getParent().getID() + "; remove or move it instead"); + } + for (HierarchyParent ancestor = this.owner; ancestor != null; ancestor = parentOf(ancestor)) { + if (ancestor == child) { + throw new IllegalArgumentException("Spec hierarchy " + child.getID() + " cannot be placed below itself"); + } + } + if (this.children.containsKey(child.getID())) { + throw new IllegalArgumentException("Spec hierarchy " + child.getID() + " is already a child here"); + } + if (index < 0 || index > this.children.size()) { + throw new IllegalArgumentException("Position " + index + " is outside 0.." + this.children.size()); + } + if (index == this.children.size()) { + this.children.put(child.getID(), child); + } else { + List ordered = list(); + ordered.add(index, child); + this.children.clear(); + for (SpecHierarchy node : ordered) { + this.children.put(node.getID(), node); + } + } + child.setParent(this.owner); + changed(); + } + + private static HierarchyParent parentOf(HierarchyParent node) { + return node instanceof SpecHierarchy ? ((SpecHierarchy) node).getParent() : null; + } + + SpecHierarchy remove(String id) { + SpecHierarchy removed = this.children.remove(id); + if (removed != null) { + removed.setParent(null); + changed(); + } + return removed; + } + + int size() { + return this.children.size(); + } + + /** + * @return all nodes below, depth-first in document order (read-only) + */ + List descendants() { + if (this.descendants == null) { + List all = new ArrayList(); + for (SpecHierarchy child : this.children.values()) { + all.add(child); + all.addAll(child.getAllChildren()); + } + this.descendants = Collections.unmodifiableList(all); + } + return this.descendants; + } + + /** + * Forgets the flattened lists here and above, after the structure changed. + */ + void changed() { + this.descendants = null; + HierarchyParent parent = parentOf(this.owner); + if (parent instanceof SpecHierarchy) { + ((SpecHierarchy) parent).hierarchyChildren().changed(); + } else if (parent instanceof Specification) { + ((Specification) parent).hierarchyChildren().changed(); + } + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyParent.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyParent.java new file mode 100644 index 0000000..09a8844 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/HierarchyParent.java @@ -0,0 +1,35 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import java.util.List; + +/** + * What holds spec hierarchy nodes: a specification (the top level) or a + * hierarchy node (the levels below). + */ +public interface HierarchyParent { + + /** + * @return the IDENTIFIER of this specification or hierarchy node + */ + String getID(); + + /** + * @return the direct children in document order (a copy) + */ + List getChildren(); + + /** + * Inserts a child node at a position, 0 for first. + * + * @throws IllegalArgumentException if a child with the same IDENTIFIER + * exists or the position is out of range + */ + HierarchyParent addChild(int index, SpecHierarchy child); + + /** + * Removes a direct child node, with everything below it. + * + * @return the removed node, or null if there is no such child + */ + SpecHierarchy removeChild(String specHierarchyID); +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java index b364c53..b9303f6 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java @@ -55,6 +55,40 @@ public List getSpecRelationRefs() { return Collections.unmodifiableList(this.specRelationRefs); } + public RelationGroup setSourceSpecificationRef(String sourceSpecificationRef) { + this.sourceSpecificationRef = sourceSpecificationRef; + markModified(); + return this; + } + + public RelationGroup setTargetSpecificationRef(String targetSpecificationRef) { + this.targetSpecificationRef = targetSpecificationRef; + markModified(); + return this; + } + + /** + * Adds a relation to the group, unless it already belongs to it. + */ + public RelationGroup addSpecRelationRef(String specRelationID) { + if (!this.specRelationRefs.contains(specRelationID)) { + this.specRelationRefs.add(specRelationID); + markModified(); + } + return this; + } + + /** + * @return whether the relation belonged to the group + */ + public boolean removeSpecRelationRef(String specRelationID) { + boolean removed = this.specRelationRefs.remove(specRelationID); + if (removed) { + markModified(); + } + return removed; + } + diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java index 08f713d..ebbf95a 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecElementWithAttributes.java @@ -1,11 +1,14 @@ package de.uni_stuttgart.ils.reqif4j.specification; +import java.util.List; import java.util.Map; import org.w3c.dom.Element; import org.w3c.dom.Node; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueFactory; import de.uni_stuttgart.ils.reqif4j.reqif.Identifiable; import de.uni_stuttgart.ils.reqif4j.reqif.ParseIssues; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; @@ -62,10 +65,38 @@ public SpecElementWithAttributes setValue(AttributeValue value) { throw new IllegalArgumentException("A value needs an attribute definition"); } this.values.put(value); + markModified(); valuesChanged(); return this; } + /** + * Sets a scalar value - boolean, integer, real, string or date - of the + * attribute definition with this IDENTIFIER, from e.g. a Boolean, a + * Number or a String. + * + * @throws IllegalArgumentException if the element's spec type has no such + * attribute definition, or it is an enumeration or XHTML one + */ + public SpecElementWithAttributes set(String attributeDefinitionID, Object value) { + return setValue(AttributeValueFactory.of(definition(attributeDefinitionID), value)); + } + + /** + * Sets an XHTML value from markup, with or without a surrounding div. + */ + public SpecElementWithAttributes setXhtml(String attributeDefinitionID, String markup) { + return setValue(AttributeValueFactory.xhtml(definition(attributeDefinitionID), markup)); + } + + /** + * Selects enum values by their IDENTIFIER; none for an empty selection. + */ + public SpecElementWithAttributes setEnum(String attributeDefinitionID, String... enumValueIDs) { + return setValue(AttributeValueFactory.enumeration(definition(attributeDefinitionID), + enumValueIDs == null ? null : java.util.Arrays.asList(enumValueIDs))); + } + /** * Removes the element's own value for the attribute definition, so its * default applies again. @@ -74,10 +105,23 @@ public SpecElementWithAttributes setValue(AttributeValue value) { */ public AttributeValue removeValue(String attributeDefinitionID) { AttributeValue removed = this.values.remove(attributeDefinitionID); - valuesChanged(); + if (removed != null) { + markModified(); + valuesChanged(); + } return removed; } + private AttributeDefinition definition(String attributeDefinitionID) { + SpecType specType = this.values.specType(); + AttributeDefinition definition = specType == null ? null : specType.getAttributeDefinition(attributeDefinitionID); + if (definition == null) { + throw new IllegalArgumentException((specType == null ? "The type of " + getID() + " is unknown" + : "Spec type " + specType.getID() + " has no attribute definition " + attributeDefinitionID)); + } + return definition; + } + /** * @return the effective values by attribute LONG-NAME: the element's own * values plus the defaults of all other attribute definitions of diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java index 1d3b2a8..a9474e6 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java @@ -1,7 +1,6 @@ package de.uni_stuttgart.ils.reqif4j.specification; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -20,7 +19,7 @@ import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLNode; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElementDiv; -public class SpecHierarchy extends Identifiable { +public class SpecHierarchy extends Identifiable implements HierarchyParent { private int hierarchyLvl; @@ -30,7 +29,8 @@ public class SpecHierarchy extends Identifiable { private Boolean tableInternal; private Boolean editable; private final List> editableAttributes = new ArrayList>(); - private Map children = new LinkedHashMap(); + private final HierarchyChildren children = new HierarchyChildren(this); + private HierarchyParent parent; @@ -60,6 +60,7 @@ public Boolean getTableInternal() { public SpecHierarchy setTableInternal(Boolean tableInternal) { this.tableInternal = tableInternal; + markModified(); return this; } @@ -73,6 +74,7 @@ public Boolean getEditable() { public SpecHierarchy setEditable(Boolean editable) { this.editable = editable; + markModified(); return this; } @@ -217,21 +219,96 @@ public boolean isText() { } } + /** + * @return the direct children in document order (a copy) + */ + @Override public List getChildren() { - return new ArrayList(this.children.values()); + return this.children.list(); } - + + /** + * @return all nodes below this one, depth-first in document order + */ public List getAllChildren() { - - List allChildren = new ArrayList(); - for(SpecHierarchy specHierarchy: this.children.values()) { - allChildren.add(specHierarchy); - for(SpecHierarchy child: specHierarchy.getAllChildren()) { - allChildren.add(child); - } + return this.children.descendants(); + } + + /** + * Appends a child node. + * + * @throws IllegalArgumentException see {@link #addChild(int, SpecHierarchy)} + */ + public SpecHierarchy addChild(SpecHierarchy child) { + return addChild(this.children.size(), child); + } + + /** + * Inserts a child node at a position, 0 for first. + * + * @throws IllegalArgumentException if the node is already placed somewhere + * (move it with {@link de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent#moveSpecHierarchy}), + * a child with the same IDENTIFIER exists, the node is this one or + * above it, or the position is out of range + */ + @Override + public SpecHierarchy addChild(int index, SpecHierarchy child) { + this.children.add(index, child); + child.place(this.hierarchyLvl + 1, this.section); + markModified(); + return this; + } + + /** + * Removes a direct child node, with everything below it. + * + * @return the removed node, or null if there is no such child + */ + @Override + public SpecHierarchy removeChild(String specHierarchyID) { + SpecHierarchy removed = this.children.remove(specHierarchyID); + if(removed != null) { + markModified(); + } + return removed; + } + + /** + * @return the specification or hierarchy node this node is placed below, + * or null if it is not placed + */ + public HierarchyParent getParent() { + return this.parent; + } + + void setParent(HierarchyParent parent) { + this.parent = parent; + } + + HierarchyChildren hierarchyChildren() { + return this.children; + } + + /** + * Points this node at another spec object. + */ + public SpecHierarchy setSpecObject(SpecObject specObject) { + this.specObject = specObject; + this.specObjectRef = specObject == null ? null : specObject.getID(); + markModified(); + return this; + } + + /** + * Sets level and section of this node and everything below it, after it + * was inserted somewhere. + */ + void place(int hierarchyLvl, int section) { + this.hierarchyLvl = hierarchyLvl; + this.section = section; + for(SpecHierarchy child: this.children.list()) { + child.place(hierarchyLvl + 1, section); } - - return allChildren; } @@ -251,7 +328,7 @@ public SpecHierarchy(String specHierarchyID, int hierarchyLvl, int section, Spec if(children != null) { for(SpecHierarchy child: children) { - this.children.put(child.getSpecHierarchyID(), child); + this.children.put(child); } } } @@ -292,7 +369,7 @@ public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map new SpecHierarchy(this.hierarchyLvl+1, section, newSpecHierarchy, specObjects, issues)); - issues.put(this.children, child, "Spec hierarchy"); + this.children.put(child, issues); } } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java index 20a31fc..b79c10e 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecRelation.java @@ -89,6 +89,24 @@ public String getTargetObjID() { return this.targetObjID; } + /** + * Points the relation at another source object. + */ + public SpecRelation setSourceObjID(String sourceObjID) { + this.sourceObjID = sourceObjID; + markModified(); + return this; + } + + /** + * Points the relation at another target object. + */ + public SpecRelation setTargetObjID(String targetObjID) { + this.targetObjID = targetObjID; + markModified(); + return this; + } + /** * @return the IDENTIFIER of the SPEC-RELATION-TYPE this relation refers to * (SPEC-RELATION-TYPE-REF), or null if the document declares none diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java index e2f5c06..598cb65 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java @@ -121,6 +121,7 @@ public SpecType(String id, String name, String kind) { public SpecType addAttributeDefinition(AttributeDefinition attributeDefinition) { this.attributeDefinitions.put(attributeDefinition.getID(), attributeDefinition); + markModified(); return this; } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java index c034527..152a449 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java @@ -1,7 +1,6 @@ package de.uni_stuttgart.ils.reqif4j.specification; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,15 +12,12 @@ import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; -public class Specification extends SpecElementWithAttributes { +public class Specification extends SpecElementWithAttributes implements HierarchyParent { private SpecType type; private String specTypeRef; - private int numberOfSpecObjects; - private int sectionCounter = 0; - private Map children = new LinkedHashMap(); - private List allSpecHierarchies = new ArrayList(); + private final HierarchyChildren children = new HierarchyChildren(this); @@ -54,31 +50,100 @@ public String getSpecTypeID() { } + /** + * @return the number of hierarchy nodes, at all levels + */ public int getNumberOfSpecObjects() { - return this.numberOfSpecObjects; + return this.children.descendants().size(); } - + + /** + * @return the number of top-level hierarchy nodes (sections) + */ public int getNumberOfSections() { - return this.sectionCounter; + return this.children.size(); } + /** + * @return the top-level hierarchy nodes in document order (a copy) + */ + @Override public List getChildren() { - - return new ArrayList(this.children.values()); + return this.children.list(); } public List getLvlOneSpecHierarchies() { List children = new ArrayList(); - for(SpecHierarchy specHierarchy: this.children.values()) { + for(SpecHierarchy specHierarchy: this.children.list()) { children.add(specHierarchy.getSpecObject()); } return children; } - + + /** + * @return all hierarchy nodes, depth-first in document order (read-only) + */ public List getAllSpecHierarchies() { - return this.allSpecHierarchies; + return this.children.descendants(); + } + + /** + * Appends a top-level hierarchy node. + * + * @throws IllegalArgumentException see {@link #addChild(int, SpecHierarchy)} + */ + public Specification addChild(SpecHierarchy child) { + return addChild(this.children.size(), child); + } + + /** + * Inserts a top-level hierarchy node at a position, 0 for first; the + * sections of the following nodes shift. + * + * @throws IllegalArgumentException if the node is already placed somewhere + * (move it with {@link de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent#moveSpecHierarchy}), + * a node with the same IDENTIFIER is already at the top level, or + * the position is out of range + */ + @Override + public Specification addChild(int index, SpecHierarchy child) { + this.children.add(index, child); + renumber(index); + markModified(); + return this; + } + + /** + * Removes a top-level hierarchy node, with everything below it. + * + * @return the removed node, or null if there is no such node + */ + @Override + public SpecHierarchy removeChild(String specHierarchyID) { + SpecHierarchy removed = this.children.remove(specHierarchyID); + if(removed != null) { + renumber(0); + markModified(); + } + return removed; + } + + /** + * Top-level nodes are the sections, numbered from 1. + * + * @param from the first position whose section may have changed + */ + private void renumber(int from) { + List children = this.children.list(); + for(int index = from; index < children.size(); index++) { + children.get(index).place(1, index + 1); + } + } + + HierarchyChildren hierarchyChildren() { + return this.children; } @@ -103,18 +168,9 @@ public Specification(String id, String name, SpecType specType, if(children != null) { for(SpecHierarchy child: children) { - this.children.put(child.getSpecHierarchyID(), child); - this.sectionCounter++; + this.children.put(child); } } - - List allChildren = new ArrayList(); - for(SpecHierarchy specHierarchy: this.children.values()) { - allChildren.add(specHierarchy); - allChildren.addAll(specHierarchy.getAllChildren()); - } - this.allSpecHierarchies.addAll(allChildren); - this.numberOfSpecObjects = allChildren.size(); } public Specification(Node specification, SpecType specType, Map specObjects) { @@ -137,25 +193,16 @@ public Specification(Node specification, SpecType specType, Map new SpecHierarchy(1, section, specHierarchy, specObjects, issues)); if(child != null) { - this.sectionCounter = section; - } - issues.put(this.children, child, "Spec hierarchy"); - } - - List allChildren = new ArrayList(); - for(SpecHierarchy specHierarchy: this.children.values()) { - allChildren.add(specHierarchy); - for(SpecHierarchy child: specHierarchy.getAllChildren()) { - allChildren.add(child); + sectionCounter = section; } + this.children.put(child, issues); } - this.allSpecHierarchies.addAll(allChildren); - this.numberOfSpecObjects = allChildren.size(); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index 3d1694d..d0198d2 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -8,6 +8,7 @@ import java.nio.file.Path; import java.time.Clock; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.List; import javax.xml.XMLConstants; @@ -75,8 +76,11 @@ public class ReqIFWriter { public static final String REQIF_VERSION = "1.0"; private static final String XHTML_PREFIX = "xhtml"; - /** user data key of the output DOM holding the LAST-CHANGE default */ + /** user data keys of the output DOM holding the LAST-CHANGE defaults */ private static final String DEFAULT_LAST_CHANGE = "reqif4j.defaultLastChange"; + private static final String NOW = "reqif4j.now"; + /** the modified elements, which take the time of writing as LAST-CHANGE */ + private static final String STAMPED = "reqif4j.stamped"; private boolean indent = false; private Clock clock = Clock.systemUTC(); @@ -119,20 +123,55 @@ public void write(ReqIFDocument document, Path file) throws IOException { * Writes the document to the given stream. The stream is not closed. */ public void write(ReqIFDocument document, OutputStream out) throws IOException { + serialize(document, out).apply(); + } + + /** + * Writes the document without yet taking the new LAST-CHANGE of modified + * elements into the model, for writers that store the output further and + * apply the stamps only once that succeeded. + */ + Stamps serialize(ReqIFDocument document, OutputStream out) throws IOException { + Stamps stamps = new Stamps(); try { - transformer().transform(new DOMSource(buildDocument(document)), new StreamResult(out)); + transformer().transform(new DOMSource(build(document, stamps)), new StreamResult(out)); } catch (TransformerException e) { throw new IOException("Failed to write ReqIF document", e); } + return stamps; } /** - * @return the document serialized as an XML string + * The modified elements of a written document and the LAST-CHANGE they + * were written with. + */ + static final class Stamps { + + private final List elements = new ArrayList(); + private String lastChange; + + /** + * Takes the written LAST-CHANGE into the model; the elements are no + * longer modified. + */ + void apply() { + for (Identifiable element : this.elements) { + element.setLastChange(this.lastChange); + } + } + } + + /** + * @return the document serialized as an XML string; like the other + * output methods, this takes the LAST-CHANGE of modified elements + * into the model, see {@link #buildDocument(ReqIFDocument)} */ public String toXml(ReqIFDocument document) { try { StringWriter writer = new StringWriter(); - transformer().transform(new DOMSource(buildDocument(document)), new StreamResult(writer)); + Stamps stamps = new Stamps(); + transformer().transform(new DOMSource(build(document, stamps)), new StreamResult(writer)); + stamps.apply(); return writer.toString(); } catch (TransformerException e) { throw new ReqIFWriteException("Failed to serialize ReqIF document", e); @@ -144,12 +183,14 @@ public String toXml(ReqIFDocument document) { * Builds the output DOM from the object model. * * The schema requires some values a model may lack, typically one that was - * generated rather than parsed. They are filled in on output only; the - * model is not changed: + * generated rather than parsed. They are filled in on output only; this + * method does not change the model: * *