diff --git a/FEHLERANALYSE.md b/FEHLERANALYSE.md index 9780530..04c2aa5 100644 --- a/FEHLERANALYSE.md +++ b/FEHLERANALYSE.md @@ -13,9 +13,25 @@ die bei jedem Push in der GitHub-Actions-Pipeline (`.github/workflows/ci.yml`, ` | Multiselect-Enums, Enum-Defaults, Null-Sicherheit | "Fix multiselect enumerations, enum defaults and enum null-safety" | `MultiselectEnumTest` | | Bild-/Objekt-Pipeline, Bild-Zuordnung, Zip-Slip | "Fix image/object conversion pipeline" | `ImagePipelineTest` | | Crash-/Robustheitsfehler (Abschnitt 3 + 4.1) | "Fix crash bugs and robustness issues" | `RobustnessTest` | +| LONG-NAME-Heuristik (4.2) → konfigurierbare Strategie | "Make spec object type classification pluggable" | `TypeClassifierTest` | +| Attributbasierte Klassifizierung (Implementor-Guide-Profil) | "Add attribute-based ReqIF Implementation Guide classifier" | `ImplementationGuideClassifierTest` | + +Zur Heuristik (4.2): Die Klassifizierung ist jetzt eine Strategie (`TypeClassifier`), +die das fertig geparste `SpecObject` (inkl. Attributwerte) erhält. +Der Default (`LongNameTypeClassifier`) verhält sich exakt wie bisher; eigene Regeln +lassen sich über `new ReqIF(pfad, classifier)` bzw. `new ReqIFz(pfad, classifier)` +injizieren. Zusätzlich liegt mit `ReqIFImplementationGuideClassifier` eine +attributbasierte Implementierung bei, die nach den standardisierten Attributnamen +des ProSTEP-Implementor-Forums (`ReqIF.ChapterName`, `ReqIF.Text`) klassifiziert — +tool-übergreifend robuster als die Namensheuristik und die empfohlene Wahl für +DOORS-/Polarion-Exporte. Details siehe README-Abschnitt „Type classification". + +Hinweis: `REQ`/`SUB-REQ`/`HEADLINE`/`TEXT` sind **keine** offiziellen ReqIF-Typen, +sondern parserinterne Inhaltskategorien. Der OMG-Standard definiert nur strukturelle +Typen (`SPEC-OBJECT-TYPE` usw.) mit frei vergebenen Namen/Attributen. Bewusst (noch) nicht angefasst, da Verhaltensänderungen für bestehende Nutzer: -die LONG-NAME-Heuristik der Typklassifizierung (4.2), die `type`-Semantik von `SpecRelation` (4.3), +die `type`-Semantik von `SpecRelation` (4.3), HTML-Escaping/Attribut-Erhalt in `toString()` (4.8) sowie die kosmetischen Punkte aus Abschnitt 5 (bis auf den entfernten `javax.xml.crypto.Data`-Import). diff --git a/README.md b/README.md index 4d87994..c9c30ff 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,82 @@ Every push and pull request runs the test suite via GitHub Actions fixed parser defects documented in `FEHLERANALYSE.md` (namespace-prefixed XHTML, multiselect enumerations, image/object conversion, picture lookup in .reqifz archives, and crash robustness). + +# Type classification + +ReqIF has no semantic "this is a requirement" flag. The categories +`REQ`, `SUB-REQ`, `HEADLINE` and `TEXT` are **this parser's own** content +categories, not official ReqIF types — the standard only defines structural +types (`SPEC-OBJECT-TYPE`, `SPECIFICATION-TYPE`, `SPEC-RELATION-TYPE`) with +free-form `LONG-NAME`s and free-form attributes. What an object *means* is a +convention of the exporting tool or exchange profile. + +Classification is therefore a pluggable strategy (`TypeClassifier`). The +strategy receives the fully parsed `SpecObject`, so it may decide based on +the spec type name, the attribute values, or both. Two implementations ship +with the library. + +## Default: `LongNameTypeClassifier` + +The default applies a substring heuristic on the spec type name +("req", "sub", "headline"). It fits profiles whose type names follow that +convention, but misses e.g. German type names or exports that only encode +the content kind in attributes. It is used automatically when no classifier +is passed. + +## Recommended for DOORS/Polarion exports: `ReqIFImplementationGuideClassifier` + +The ProSTEP iViP / ReqIF Implementor Forum "ReqIF Implementation Guide" +standardizes **attribute names** (while type names stay free-form). Tools +such as IBM DOORS, PTC and Polarion emit: + +- `ReqIF.ChapterName` — set on heading objects +- `ReqIF.Text` — the requirement/description body + +This classifier decides by those attributes, independent of the type name, +which is more robust and tool-independent: + +```java +import de.uni_stuttgart.ils.reqif4j.specification.ReqIFImplementationGuideClassifier; + +ReqIF reqif = new ReqIF("doors-export.reqif", new ReqIFImplementationGuideClassifier()); + +// custom attribute names, if your profile differs: +ReqIF reqif2 = new ReqIF("export.reqif", + new ReqIFImplementationGuideClassifier("Heading", "Body")); +``` + +Rule: non-empty `ReqIF.ChapterName` → `HEADLINE`; else non-empty +`ReqIF.Text` → `REQ`; else `TEXT`. Note that the guide does not distinguish +normative requirements from informational text (both use `ReqIF.Text`), so +text-bearing objects are reported as `REQ`; use a custom classifier if your +profile marks requirements with an extra attribute. + +## Custom classifier + +Any other convention can be implemented directly: + +```java +TypeClassifier classifier = new TypeClassifier() { + @Override + public String classify(SpecObject specObject) { + String name = specObject.getSpecTypeName().toLowerCase(); + if (name.contains("anforderung")) return ReqIFConst.REQ; + if (name.contains("überschrift")) return ReqIFConst.HEADLINE; + return ReqIFConst.TEXT; + } + + @Override + public boolean isRequirement(SpecObject specObject) { + return ReqIFConst.REQ.equals(specObject.getType()); + } + + @Override + public boolean isSubRequirement(SpecObject specObject) { + return false; + } +}; + +ReqIF reqif = new ReqIF("spec.reqif", classifier); // .reqif +ReqIFz reqifz = new ReqIFz("archive.reqifz", classifier); // .reqifz +``` diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIF.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIF.java index c1bd8a4..057459e 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIF.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIF.java @@ -7,6 +7,8 @@ import java.io.InputStream; import java.util.HashMap; +import de.uni_stuttgart.ils.reqif4j.specification.TypeClassifier; + public class ReqIF extends ReqIFFile { @@ -26,11 +28,20 @@ public ReqIFCoreContent getReqIFCoreContent() { public ReqIF(String filePath) throws FileNotFoundException { + this(filePath, TypeClassifier.defaultClassifier()); + } + + /** + * @param typeClassifier strategy deciding how spec objects are classified + * (requirement/headline/text); null uses the default + * LONG-NAME heuristic + */ + public ReqIF(String filePath, TypeClassifier typeClassifier) throws FileNotFoundException { this.path = filePath; this.name = extractFileName(filePath); - this.reqifDocuments.put(this.name, new ReqIFDocument(filePath)); + this.reqifDocuments.put(this.name, new ReqIFDocument(filePath, typeClassifier)); this.numberOfReqIFDocuments = 1; this.picturesIS = new HashMap(); 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 0baad28..90eb496 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 @@ -82,6 +82,14 @@ public List getOrderedSpecHierarchyList() { public ReqIFCoreContent(Element coreContent) { + this(coreContent, TypeClassifier.defaultClassifier()); + } + + public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { + + if (typeClassifier == null) { + typeClassifier = TypeClassifier.defaultClassifier(); + } if (coreContent.getElementsByTagName("DATATYPES").item(0).hasChildNodes()) { @@ -186,7 +194,7 @@ public ReqIFCoreContent(Element coreContent) { String specObjID = specObj.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); String specObjTypeRef = ((Element) specObj).getElementsByTagName(ReqIFConst.SPEC_OBJECT_TYPE_REF).item(0).getTextContent(); - this.specObjects.put(specObjID, new SpecObject(specObj, this.specTypes.get(specObjTypeRef))); + this.specObjects.put(specObjID, new SpecObject(specObj, this.specTypes.get(specObjTypeRef), typeClassifier)); } } 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 d25e56a..f45c582 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 @@ -12,6 +12,8 @@ import org.w3c.dom.Element; import org.xml.sax.SAXException; +import de.uni_stuttgart.ils.reqif4j.specification.TypeClassifier; + public class ReqIFDocument { @@ -19,6 +21,7 @@ public class ReqIFDocument { protected String filePath; private String fileName; + private TypeClassifier typeClassifier = TypeClassifier.defaultClassifier(); private ReqIFHeader header; private ReqIFCoreContent content; @@ -42,9 +45,14 @@ public ReqIFCoreContent getCoreContent() { public ReqIFDocument(String filePath) throws FileNotFoundException { + this(filePath, TypeClassifier.defaultClassifier()); + } + + public ReqIFDocument(String filePath, TypeClassifier typeClassifier) throws FileNotFoundException { this.filePath = filePath; this.fileName = extractFileName(filePath); + setTypeClassifier(typeClassifier); try { this.reqifDocument = newDocumentBuilder().parse(this.filePath); @@ -56,9 +64,14 @@ public ReqIFDocument(String filePath) throws FileNotFoundException { } public ReqIFDocument(InputStream is, String filePath) throws FileNotFoundException { + this(is, filePath, TypeClassifier.defaultClassifier()); + } + + public ReqIFDocument(InputStream is, String filePath, TypeClassifier typeClassifier) throws FileNotFoundException { this.filePath = filePath; this.fileName = extractFileName(filePath); + setTypeClassifier(typeClassifier); try { this.reqifDocument = newDocumentBuilder().parse(is); @@ -70,9 +83,14 @@ public ReqIFDocument(InputStream is, String filePath) throws FileNotFoundExcepti } public ReqIFDocument(InputStream is, String zipFilePath, String fileName) { + this(is, zipFilePath, fileName, TypeClassifier.defaultClassifier()); + } + + public ReqIFDocument(InputStream is, String zipFilePath, String fileName, TypeClassifier typeClassifier) { this.filePath = zipFilePath; this.fileName = fileName; + setTypeClassifier(typeClassifier); try { this.reqifDocument = newDocumentBuilder().parse(is); @@ -83,6 +101,10 @@ public ReqIFDocument(InputStream is, String zipFilePath, String fileName) { } } + private void setTypeClassifier(TypeClassifier typeClassifier) { + this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; + } + /** * Creates a namespace-aware, XXE-hardened document builder. Namespace @@ -119,7 +141,7 @@ private void readDocument() { if (this.reqifDocument.getElementsByTagName(ReqIFConst.CORE_CONTENT).getLength() == 0) { throw new ReqIFParseException("Document contains no " + ReqIFConst.CORE_CONTENT + " element: " + this.fileName); } - this.content = new ReqIFCoreContent((Element) this.reqifDocument.getElementsByTagName(ReqIFConst.CORE_CONTENT).item(0)); + this.content = new ReqIFCoreContent((Element) this.reqifDocument.getElementsByTagName(ReqIFConst.CORE_CONTENT).item(0), this.typeClassifier); } private static String extractFileName(String path) { 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 83e2415..2384931 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 @@ -10,11 +10,22 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import de.uni_stuttgart.ils.reqif4j.specification.TypeClassifier; + public class ReqIFz extends ReqIFFile { private static final String EXTRACTION_SUFFIX = "_unzipped"; public ReqIFz(String filePath) throws IOException { + this(filePath, TypeClassifier.defaultClassifier()); + } + + /** + * @param typeClassifier strategy deciding how spec objects are classified + * (requirement/headline/text); null uses the default + * LONG-NAME heuristic + */ + public ReqIFz(String filePath, TypeClassifier typeClassifier) throws IOException { this.path = filePath; this.name = extractFileName(filePath); @@ -63,7 +74,7 @@ public ReqIFz(String filePath) throws IOException { if (zipEntry.getName().endsWith("reqif")) { this.numberOfReqIFDocuments++; try (InputStream reqifIS = new FileInputStream(newFile)) { - this.reqifDocuments.put(zipEntry.getName(), new ReqIFDocument(reqifIS, filePath, zipEntry.getName())); + 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 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 new file mode 100644 index 0000000..fa012f0 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/LongNameTypeClassifier.java @@ -0,0 +1,77 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; + +/** + * Default {@link TypeClassifier}: the historic LONG-NAME substring heuristic. + * + * A spec type whose name contains "req" is a requirement type ("sub" + "req" + * a sub-requirement type), a name containing "headline" is a headline type, + * everything else is text. Whether a requirement-typed object really is a + * requirement is decided by the first boolean attribute whose name contains + * "req" (respectively "sub" and "req"). + * + * This matches tool profiles with type names like "Req", "SubReq" and + * "Headline". For other conventions provide your own {@link TypeClassifier}. + */ +public class LongNameTypeClassifier implements TypeClassifier { + + static final LongNameTypeClassifier INSTANCE = new LongNameTypeClassifier(); + + @Override + public String classify(SpecObject specObject) { + + String name = specObject.getSpecTypeName() == null ? "" : specObject.getSpecTypeName().toLowerCase(); + + if (name.contains(ReqIFConst.REQ.toLowerCase())) { + if (name.contains(ReqIFConst.SUB.toLowerCase())) { + return ReqIFConst.SUB_REQ; + } + return ReqIFConst.REQ; + } + if (name.contains(ReqIFConst.HEADLINE.toLowerCase())) { + return ReqIFConst.HEADLINE; + } + return ReqIFConst.TEXT; + } + + @Override + public boolean isRequirement(SpecObject specObject) { + + if (!ReqIFConst.REQ.equals(specObject.getType())) { + return false; + } + for (AttributeValue attributeValue : specObject.getAttributes().values()) { + + if (isBoolean(attributeValue) + && attributeValue.getName().toLowerCase().contains(ReqIFConst.REQ.toLowerCase())) { + return Boolean.TRUE.equals(attributeValue.getValue()); + } + } + return false; + } + + @Override + public boolean isSubRequirement(SpecObject specObject) { + + if (!ReqIFConst.SUB_REQ.equals(specObject.getType())) { + return false; + } + for (AttributeValue attributeValue : specObject.getAttributes().values()) { + + if (isBoolean(attributeValue) + && attributeValue.getName().toLowerCase().contains(ReqIFConst.SUB.toLowerCase()) + && attributeValue.getName().toLowerCase().contains(ReqIFConst.REQ.toLowerCase())) { + return Boolean.TRUE.equals(attributeValue.getValue()); + } + } + return false; + } + + private static boolean isBoolean(AttributeValue attributeValue) { + return attributeValue.getAttributeDefinitionType() != null + && attributeValue.getAttributeDefinitionType().getDataType() != null + && ReqIFConst.BOOLEAN.equals(attributeValue.getAttributeDefinitionType().getDataType().getType()); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/ReqIFImplementationGuideClassifier.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/ReqIFImplementationGuideClassifier.java new file mode 100644 index 0000000..272230a --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/ReqIFImplementationGuideClassifier.java @@ -0,0 +1,97 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; + +/** + * {@link TypeClassifier} based on the standardized attribute names of the + * ProSTEP iViP / ReqIF Implementor Forum "ReqIF Implementation Guide". Tools + * such as IBM DOORS, PTC and Polarion emit spec objects carrying these + * attributes regardless of how their spec types happen to be named: + * + * + * + * Classification rule (attribute values, not the type name): + *
    + *
  1. a non-empty {@code ReqIF.ChapterName} → {@link ReqIFConst#HEADLINE}
  2. + *
  3. otherwise a non-empty {@code ReqIF.Text} → {@link ReqIFConst#REQ}
  4. + *
  5. otherwise → {@link ReqIFConst#TEXT}
  6. + *
+ * + * This is more robust and tool-independent than the LONG-NAME heuristic of + * {@link LongNameTypeClassifier}, because the attribute names are fixed by the + * guide while the type names are free-form. + * + *

Note: the guide does not, by itself, distinguish a normative requirement + * from informational text — both carry {@code ReqIF.Text}. Objects with body + * text are therefore reported as {@link ReqIFConst#REQ}. If your project + * profile marks requirements with an extra attribute, supply the attribute + * names via the {@linkplain #ReqIFImplementationGuideClassifier(String, String) + * constructor} or provide a custom {@link TypeClassifier}. + */ +public class ReqIFImplementationGuideClassifier implements TypeClassifier { + + /** Default attribute LONG-NAME carrying a heading's text. */ + public static final String CHAPTER_NAME_ATTRIBUTE = "ReqIF.ChapterName"; + + /** Default attribute LONG-NAME carrying the requirement/description body. */ + public static final String TEXT_ATTRIBUTE = "ReqIF.Text"; + + private final String chapterNameAttribute; + private final String textAttribute; + + /** + * Uses the standard attribute names {@link #CHAPTER_NAME_ATTRIBUTE} and + * {@link #TEXT_ATTRIBUTE}. + */ + public ReqIFImplementationGuideClassifier() { + this(CHAPTER_NAME_ATTRIBUTE, TEXT_ATTRIBUTE); + } + + /** + * @param chapterNameAttribute attribute LONG-NAME identifying heading objects + * @param textAttribute attribute LONG-NAME carrying the body text + */ + public ReqIFImplementationGuideClassifier(String chapterNameAttribute, String textAttribute) { + this.chapterNameAttribute = chapterNameAttribute; + this.textAttribute = textAttribute; + } + + @Override + public String classify(SpecObject specObject) { + + if (hasContent(specObject, this.chapterNameAttribute)) { + return ReqIFConst.HEADLINE; + } + if (hasContent(specObject, this.textAttribute)) { + return ReqIFConst.REQ; + } + return ReqIFConst.TEXT; + } + + @Override + public boolean isRequirement(SpecObject specObject) { + return ReqIFConst.REQ.equals(specObject.getType()); + } + + @Override + public boolean isSubRequirement(SpecObject specObject) { + return ReqIFConst.SUB_REQ.equals(specObject.getType()); + } + + /** + * @return true if the object has an attribute of the given name with a + * non-blank value + */ + private static boolean hasContent(SpecObject specObject, String attributeName) { + AttributeValue attributeValue = specObject.getAttributes().get(attributeName); + if (attributeValue == null) { + return false; + } + Object value = attributeValue.getValue(); + return value != null && !value.toString().trim().isEmpty(); + } +} 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 4ec4a1a..72d7645 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 @@ -20,6 +20,7 @@ public class SpecObject { protected String id; protected SpecType specType; protected String type; + protected TypeClassifier typeClassifier = TypeClassifier.defaultClassifier(); protected Map attributeValues = new HashMap(); @@ -51,23 +52,7 @@ public Object getAttribute(String attributeName) { } public boolean isReq() { - - if(this.type.equals(ReqIFConst.REQ)) { - - for(AttributeValue attValue: this.attributeValues.values()) { - - if(attValue.getDatatype().equals(ReqIFConst.BOOLEAN) && attValue.getName().toLowerCase().contains(ReqIFConst.REQ.toLowerCase())) { - - if((Boolean)attValue.getValue()) { - return true; - - }else{ - return false; - } - } - } - } - return false; + return this.typeClassifier.isRequirement(this); } public boolean isHeadline() { @@ -80,23 +65,7 @@ public boolean isHeadline() { } public boolean isSubReq() { - - if(this.type.equals(ReqIFConst.SUB_REQ)) { - - for(AttributeValue attValue: this.attributeValues.values()) { - - if(attValue.getDatatype().equals(ReqIFConst.BOOLEAN) && attValue.getName().toLowerCase().contains(ReqIFConst.SUB.toLowerCase()) && attValue.getName().toLowerCase().contains(ReqIFConst.REQ.toLowerCase())) { - - if((Boolean)attValue.getValue()) { - return true; - - }else{ - return false; - } - } - } - } - return false; + return this.typeClassifier.isSubRequirement(this); } public boolean isText() { @@ -114,25 +83,15 @@ public SpecObject(Node specObject){ } public SpecObject(Node specObject, SpecType specType) { - + this(specObject, specType, TypeClassifier.defaultClassifier()); + } + + public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassifier) { + this.id = specObject.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); this.specType = specType; - - if(this.specType.getName().toLowerCase().contains(ReqIFConst.REQ.toLowerCase())) { - - if(this.specType.getName().toLowerCase().contains(ReqIFConst.SUB.toLowerCase())) { - this.type = ReqIFConst.SUB_REQ; - - }else{ - this.type = ReqIFConst.REQ; - } - }else if(this.specType.getName().toLowerCase().contains(ReqIFConst.HEADLINE.toLowerCase())) { - this.type = ReqIFConst.HEADLINE; - - }else{ - this.type = ReqIFConst.TEXT; - } - + this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; + if( ((Element)specObject).getElementsByTagName(ReqIFConst.VALUES).getLength() > 0 && ((Element)specObject).getElementsByTagName(ReqIFConst.VALUES).item(0).hasChildNodes() ) { @@ -256,7 +215,11 @@ public SpecObject(Node specObject, SpecType specType) { } } } - + + // Classify only after all attribute values are available, so an + // attribute-based classifier (e.g. the ReqIF Implementation Guide + // profile) can inspect them. + this.type = this.typeClassifier.classify(this); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/TypeClassifier.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/TypeClassifier.java new file mode 100644 index 0000000..5c1e3af --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/TypeClassifier.java @@ -0,0 +1,64 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; + +/** + * Strategy for classifying spec objects. ReqIF itself has no semantic notion + * of "this is a requirement" — spec types only carry free-form LONG-NAMEs and + * free-form attributes whose meaning is a convention of the exporting tool or + * project. + * + * The classification methods receive the fully parsed {@link SpecObject}, so an + * implementation may base its decision on the spec type name, on the object's + * attribute values, or both. + * + * Two implementations are provided: + *

+ * + * A custom implementation can be supplied via {@code new ReqIF(path, classifier)} + * or {@code new ReqIFz(path, classifier)}. + */ +public interface TypeClassifier { + + /** + * Classifies a spec object into one of the coarse content categories. Called + * once during construction, after the object's attribute values are parsed. + * + * @param specObject the spec object being built (type, spec type and + * attribute values are available) + * @return one of {@link ReqIFConst#REQ}, {@link ReqIFConst#SUB_REQ}, + * {@link ReqIFConst#HEADLINE} or {@link ReqIFConst#TEXT} + */ + String classify(SpecObject specObject); + + /** + * Decides whether a spec object classified as {@link ReqIFConst#REQ} + * actually is a requirement, e.g. by evaluating a boolean flag attribute. + * + * @param specObject the spec object (type and attribute values are parsed) + */ + boolean isRequirement(SpecObject specObject); + + /** + * Decides whether a spec object classified as {@link ReqIFConst#SUB_REQ} + * actually is a sub-requirement. + * + * @param specObject the spec object (type and attribute values are parsed) + */ + boolean isSubRequirement(SpecObject specObject); + + /** + * @return the default classifier implementing the historic LONG-NAME + * substring heuristic + */ + static TypeClassifier defaultClassifier() { + return LongNameTypeClassifier.INSTANCE; + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ImplementationGuideClassifierTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ImplementationGuideClassifierTest.java new file mode 100644 index 0000000..ddfbeec --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ImplementationGuideClassifierTest.java @@ -0,0 +1,136 @@ +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.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.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.specification.ReqIFImplementationGuideClassifier; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; + +/** + * The {@link ReqIFImplementationGuideClassifier} classifies by the + * standardized attribute names of the ReqIF Implementation Guide + * (ReqIF.ChapterName / ReqIF.Text), independent of the spec type name. + */ +class ImplementationGuideClassifierTest { + + /** + * Fixture modeled after a DOORS-style export: a single spec object type + * named "Object Type" (which the LONG-NAME heuristic could not classify) + * with the standard Implementation Guide attributes. Three objects: a + * heading (ChapterName set), a requirement (Text set) and an empty one. + */ + private static final String GUIDE_FIXTURE = """ + + + + + + + + + + + + dt-string + + + dt-string + + + + + + + st-obj + + + ad-chapter + + + + + st-obj + + + ad-text + + + + + st-obj + + + + + + + + """; + + @Test + void classifiesHeadingByChapterNameAttribute(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF( + TestFixtures.write(tempDir, "guide.reqif", GUIDE_FIXTURE).toString(), + new ReqIFImplementationGuideClassifier()); + + SpecObject heading = reqif.getReqIFCoreContent().getSpecObject("so-heading"); + assertEquals(ReqIFConst.HEADLINE, heading.getType()); + assertTrue(heading.isHeadline()); + assertFalse(heading.isReq()); + } + + @Test + void classifiesRequirementByTextAttribute(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF( + TestFixtures.write(tempDir, "guide.reqif", GUIDE_FIXTURE).toString(), + new ReqIFImplementationGuideClassifier()); + + SpecObject req = reqif.getReqIFCoreContent().getSpecObject("so-req"); + assertEquals(ReqIFConst.REQ, req.getType()); + assertTrue(req.isReq()); + assertFalse(req.isHeadline()); + } + + @Test + void classifiesEmptyObjectAsText(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF( + TestFixtures.write(tempDir, "guide.reqif", GUIDE_FIXTURE).toString(), + new ReqIFImplementationGuideClassifier()); + + SpecObject empty = reqif.getReqIFCoreContent().getSpecObject("so-empty"); + assertEquals(ReqIFConst.TEXT, empty.getType()); + assertTrue(empty.isText()); + } + + @Test + void defaultLongNameHeuristicCannotClassifyThisProfile(@TempDir Path tempDir) throws Exception { + // "Object Type" contains none of req/sub/headline -> everything is TEXT + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "guide.reqif", GUIDE_FIXTURE).toString()); + + assertEquals(ReqIFConst.TEXT, reqif.getReqIFCoreContent().getSpecObject("so-heading").getType(), + "the default heuristic misses headings that are only identifiable by attribute"); + assertEquals(ReqIFConst.TEXT, reqif.getReqIFCoreContent().getSpecObject("so-req").getType()); + } + + @Test + void customAttributeNamesAreSupported(@TempDir Path tempDir) throws Exception { + String fixture = GUIDE_FIXTURE + .replace("ReqIF.ChapterName", "Heading") + .replace("ReqIF.Text", "Body"); + + ReqIF reqif = new ReqIF( + TestFixtures.write(tempDir, "guide.reqif", fixture).toString(), + new ReqIFImplementationGuideClassifier("Heading", "Body")); + + assertEquals(ReqIFConst.HEADLINE, reqif.getReqIFCoreContent().getSpecObject("so-heading").getType()); + assertEquals(ReqIFConst.REQ, reqif.getReqIFCoreContent().getSpecObject("so-req").getType()); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/TypeClassifierTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/TypeClassifierTest.java new file mode 100644 index 0000000..73a0211 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/TypeClassifierTest.java @@ -0,0 +1,121 @@ +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.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.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.specification.TypeClassifier; + +/** + * The requirement/headline/text classification is based on a LONG-NAME + * substring heuristic that only fits certain tool profiles. It is now a + * pluggable strategy: the default keeps the historic behavior, custom + * classifiers support other naming conventions (e.g. German profiles). + */ +class TypeClassifierTest { + + /** Fixture variant with a boolean "IsReq" flag set on so-1. */ + private static String fixtureWithReqFlag() { + return TestFixtures.REQIF_FIXTURE + .replace("" + + "dt-bool" + + "" + + "", + "" + + "ad-isreq" + + "" + + ""); + } + + /** Fixture variant with a German spec type name the heuristic cannot match. */ + private static String germanFixture() { + return TestFixtures.REQIF_FIXTURE.replace("Requirement Type", "Anforderungstyp"); + } + + @Test + void defaultHeuristicClassifiesByLongNameSubstring(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + SpecObject so1 = reqif.getReqIFCoreContent().getSpecObject("so-1"); + + // "Requirement Type" contains "req" -> classified as requirement type + assertEquals(ReqIFConst.REQ, so1.getType()); + // ... but without a boolean req flag attribute it does not count as one + assertFalse(so1.isReq()); + assertTrue(so1.isText()); + } + + @Test + void defaultHeuristicEvaluatesBooleanReqFlag(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "test.reqif", fixtureWithReqFlag()).toString()); + + SpecObject so1 = reqif.getReqIFCoreContent().getSpecObject("so-1"); + assertTrue(so1.isReq(), "boolean flag attribute containing 'req' must mark the object as requirement"); + assertFalse(so1.isText()); + + // so-2 has no value for the flag; the default (false) applies + assertFalse(reqif.getReqIFCoreContent().getSpecObject("so-2").isReq()); + } + + @Test + void germanProfileIsMisclassifiedByDefaultHeuristic(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.write(tempDir, "test.reqif", germanFixture()).toString()); + SpecObject so1 = reqif.getReqIFCoreContent().getSpecObject("so-1"); + + // documents the limitation that motivates the strategy interface + assertEquals(ReqIFConst.TEXT, so1.getType()); + assertFalse(so1.isReq()); + } + + @Test + void customClassifierSupportsOtherNamingConventions(@TempDir Path tempDir) throws Exception { + TypeClassifier germanClassifier = new TypeClassifier() { + + @Override + public String classify(SpecObject specObject) { + String name = specObject.getSpecTypeName() == null ? "" : specObject.getSpecTypeName().toLowerCase(); + if (name.contains("anforderung")) { + return ReqIFConst.REQ; + } + if (name.contains("überschrift")) { + return ReqIFConst.HEADLINE; + } + return ReqIFConst.TEXT; + } + + @Override + public boolean isRequirement(SpecObject specObject) { + return ReqIFConst.REQ.equals(specObject.getType()); + } + + @Override + public boolean isSubRequirement(SpecObject specObject) { + return false; + } + }; + + Path file = TestFixtures.write(tempDir, "test.reqif", germanFixture()); + ReqIF reqif = new ReqIF(file.toString(), germanClassifier); + SpecObject so1 = reqif.getReqIFCoreContent().getSpecObject("so-1"); + + assertEquals(ReqIFConst.REQ, so1.getType(), "custom classifier must control the type classification"); + assertTrue(so1.isReq(), "custom classifier must control isReq"); + assertFalse(so1.isText()); + } + + @Test + void nullClassifierFallsBackToDefault(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString(), null); + + assertEquals(ReqIFConst.REQ, reqif.getReqIFCoreContent().getSpecObject("so-1").getType()); + } +}